projectatlas_db/
lib.rs

1//! Purpose: Persist `ProjectAtlas` 3 indexes in `SQLite`.
2
3mod derived_snapshot;
4mod diagnostics;
5mod project_identity;
6mod repository_graph;
7mod schema;
8mod sqlite_profile;
9mod telemetry;
10
11pub use derived_snapshot::{
12    DerivedGraphSnapshot, DerivedGraphSnapshotImport, DerivedGraphSnapshotMetadata,
13    DerivedSnapshotContent, MAX_DERIVED_SNAPSHOT_JSON_BYTES,
14};
15pub use diagnostics::{
16    DatabaseCoverageSample, DatabaseCoverageSummary, DatabaseCoverageTotalState,
17    DatabaseFilesystemSupport, DatabaseOperatingProfileReport, DatabasePublicationContractState,
18    DatabasePublicationReport, DatabaseSchemaCompatibility, DatabaseSchemaReport,
19    DatabaseSettingsReport, SqliteCompileOptionsIdentity, SqliteRuntimeReport,
20    database_settings_report,
21};
22pub use project_identity::{ProjectRootTransition, ProjectRootTransitionResult};
23pub use repository_graph::{
24    MAX_REPOSITORY_GRAPH_FRONTIER, RepositoryAffectedSourceFootprint, RepositoryCoverageQuery,
25    RepositoryCoverageRow, RepositoryGraphAdjacencyContinuation, RepositoryGraphAdjacencyPage,
26    RepositoryGraphAdjacencyReadPage, RepositoryGraphAdjacencyRow, RepositoryGraphDirection,
27    RepositoryGraphPage, RepositoryGraphReadBatch, RepositoryGraphReadBudget,
28    RepositoryGraphReadPage, RepositoryGraphReadPages, RepositoryGraphReadWork,
29    RepositoryGraphRelationQuery, RepositoryGraphRelationRow, RepositoryGraphStagingGuard,
30    RepositoryNavigationConnections, RepositoryNavigationNode, RepositoryResolutionCandidate,
31};
32pub use sqlite_profile::validate_database_location;
33pub use telemetry::{
34    PlannerStatisticsPolicy, PlannerStatisticsState, SpillCleanupState, TelemetryCheckpointState,
35    TelemetryRetentionPolicy, TelemetryRetentionState,
36};
37
38use blake3::Hasher;
39use projectatlas_core::graph::{GraphContractError, ProjectInstanceId};
40use projectatlas_core::health::{
41    CATEGORY_DUPLICATE_PURPOSE, CATEGORY_MISSING_PURPOSE, CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
42    CATEGORY_REPEATED_TEMPORARY_FOLDER, CATEGORY_STALE_PURPOSE, CATEGORY_SUGGESTED_PURPOSE_REVIEW,
43    HealthFinding, MESSAGE_MISSING_PURPOSE, MESSAGE_PURPOSE_AGENT_REVIEW_REQUIRED,
44    MESSAGE_STALE_PURPOSE, MESSAGE_SUGGESTED_PURPOSE_REVIEW, RECOMMENDATION_DUPLICATE_PURPOSE,
45    RECOMMENDATION_MISSING_PURPOSE_QUEUE, RECOMMENDATION_PURPOSE_AGENT_REVIEW_REQUIRED,
46    RECOMMENDATION_REPEATED_TEMPORARY_FOLDER, RECOMMENDATION_STALE_PURPOSE,
47    RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW_QUEUE, STRUCTURAL_HEALTH_CATEGORIES, Severity,
48    TEMP_FOLDER_BUCKETS, finding_id,
49};
50use projectatlas_core::symbols::{
51    CodeSymbol, ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
52    SymbolRelation,
53};
54use projectatlas_core::telemetry::{
55    TelemetryContractError, TokenOverview, TokenTrendReport, TokenTrendWindow, UsageEvent,
56    UsageInstanceId, UsageInstanceOwner,
57};
58use projectatlas_core::{
59    AGENT_REVIEWED_SOURCE_VALUES, HIGH_IMPACT_FILE_NAMES, HIGH_IMPACT_PATH_PREFIXES,
60    HIGH_IMPACT_PATH_SEGMENTS, IndexGeneration, IndexWorkControl, IndexWorkFailure, IndexWorkStage,
61    IndexedNode, LEGACY_HUMAN_PURPOSE_SOURCE, Node, NodeKind, Overview, Purpose, PurposeSource,
62    PurposeStatus, normalize_native_path_display, normalize_repo_path_prefix,
63};
64use rusqlite::types::Value;
65use rusqlite::{
66    Connection, ErrorCode, OpenFlags, OptionalExtension, TransactionBehavior, params,
67    params_from_iter,
68};
69use serde::{Deserialize, Serialize};
70use std::cell::{Cell, RefCell};
71use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
72use std::num::{ParseIntError, TryFromIntError};
73use std::ops::{Deref, DerefMut};
74use std::path::{Path, PathBuf};
75use std::time::Duration;
76use thiserror::Error;
77
78use schema::{
79    FILE_TEXT_FTS_PROJECTION_REVISION_KEY, FILE_TEXT_FTS_SOURCE_REVISION_KEY,
80    INDEX_PUBLICATION_FINGERPRINT_KEY, INDEX_PUBLICATION_GENERATION_KEY,
81    INDEX_PUBLICATION_STATE_KEY, PROJECT_ROOT_KEY, SchemaState,
82};
83#[cfg(test)]
84use schema::{PREVIOUS_SCHEMA_VERSION, SCHEMA_VERSION, SCHEMA_VERSION_KEY, sqlite_sidecar_path};
85use sqlite_profile::{
86    DatabaseLocation, JournalModePolicy, SQLITE_BUSY_TIMEOUT, open_writable_connection,
87};
88
89/// Maximum persisted text for denormalized symbol-name search summaries.
90const MAX_SYMBOL_SEARCH_SUMMARY_CHARS: usize = 16_000;
91/// Publication acquisition fails fast so callers must restage after contention.
92const SQLITE_PUBLICATION_ACQUIRE_TIMEOUT: Duration = Duration::ZERO;
93/// Ancillary telemetry must not delay a valid navigation result under contention.
94const SQLITE_TELEMETRY_BUSY_TIMEOUT: Duration = Duration::from_millis(25);
95/// Maximum paths admitted to one purpose-curation hydration statement.
96pub const MAX_PURPOSE_CURATION_BATCH_ROWS: usize = 200;
97/// Maximum FTS candidates decoded for one exact-verification request.
98pub const MAX_FILE_TEXT_FTS_CANDIDATES: usize = 4_096;
99/// Path-indexed metadata cursor for one exact path-or-descendant fallback scope.
100const FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL: &str = "
101    SELECT path, content_hash, byte_count, line_count
102    FROM file_texts
103    WHERE path = ?1 OR (path >= ?2 AND path < ?3)
104    ORDER BY path
105";
106/// Metadata cursor used when a fallback glob has no safe fixed prefix.
107const FILE_TEXT_FALLBACK_ALL_METADATA_SQL: &str = "
108    SELECT path, content_hash, byte_count, line_count
109    FROM file_texts
110    ORDER BY path
111";
112/// Exact authoritative content hydration after service-owned admission.
113const FILE_TEXT_FALLBACK_CONTENT_SQL: &str = "SELECT content FROM file_texts WHERE path = ?1";
114/// Maximum UTF-8 bytes retained in one host-owned purpose task label.
115const MAX_PURPOSE_CURATION_TASK_BYTES: usize = 256;
116/// Domain separator for deterministic purpose-curation item work keys.
117const PURPOSE_CURATION_ITEM_KEY_DOMAIN: &str = "projectatlas:purpose-curation:item:v1";
118/// Domain separator for deterministic purpose-curation row-state tokens.
119const PURPOSE_CURATION_STATE_TOKEN_DOMAIN: &str = "projectatlas:purpose-curation:state:v1";
120/// Domain separator for deterministic purpose-curation batch work keys.
121const PURPOSE_CURATION_BATCH_KEY_DOMAIN: &str = "projectatlas:purpose-curation:batch:v1";
122/// Monotonic metadata revision for accepted authored-purpose mutations.
123const AUTHORED_PURPOSE_REVISION_KEY: &str = "purpose.authored_revision";
124/// Select create capability only for a path proven absent by preflight.
125fn writable_open_flags(state: SchemaState, database_exists: bool) -> OpenFlags {
126    match (state, database_exists) {
127        (SchemaState::Fresh, false) => {
128            OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE
129        }
130        (SchemaState::Fresh | SchemaState::Current | SchemaState::UpgradeRequired, true)
131        | (SchemaState::Current | SchemaState::UpgradeRequired, false) => {
132            OpenFlags::SQLITE_OPEN_READ_WRITE
133        }
134    }
135}
136
137/// Establish WAL only while creating or upgrading an admitted database.
138const fn writable_journal_policy(state: SchemaState) -> JournalModePolicy {
139    match state {
140        SchemaState::Current => JournalModePolicy::RequireWal,
141        SchemaState::Fresh | SchemaState::UpgradeRequired => JournalModePolicy::EnsureWal,
142    }
143}
144
145/// Database-layer error type.
146#[derive(Debug, Error)]
147pub enum DbError {
148    /// `SQLite` operation failed.
149    #[error("sqlite error: {0}")]
150    Sqlite(#[from] rusqlite::Error),
151    /// A live project database is located on a known unsupported filesystem.
152    #[error(
153        "database path {path:?} is on an unsupported filesystem (mount: {mount_point:?}, type: {filesystem_type:?}); live SQLite WAL requires supported local storage"
154    )]
155    DatabaseFilesystemUnsupported {
156        /// Database path rejected before writable access.
157        path: PathBuf,
158        /// Resolved mount point when one was safely available.
159        mount_point: Option<PathBuf>,
160        /// Normalized filesystem type when one was safely available.
161        filesystem_type: Option<String>,
162    },
163    /// Local WAL-safe filesystem placement could not be proved.
164    #[error(
165        "database path {path:?} has uncertain filesystem placement (mount: {mount_point:?}, type: {filesystem_type:?}): {reason}"
166    )]
167    DatabaseFilesystemUncertain {
168        /// Database path rejected before writable access.
169        path: PathBuf,
170        /// Resolved mount point when one was safely available.
171        mount_point: Option<PathBuf>,
172        /// Normalized filesystem type when one was safely available.
173        filesystem_type: Option<String>,
174        /// Bounded reason the local profile could not be established.
175        reason: String,
176    },
177    /// `SQLite` did not retain a required connection operating-profile value.
178    #[error("SQLite operating profile mismatch for {setting}: expected {expected}, found {found}")]
179    DatabaseOperatingProfile {
180        /// Connection or durable setting that did not match.
181        setting: &'static str,
182        /// Required value.
183        expected: String,
184        /// Observed value.
185        found: String,
186    },
187    /// A persisted or requested graph value violates the typed domain contract.
188    #[error("repository graph contract error: {0}")]
189    GraphContract(#[from] GraphContractError),
190    /// A telemetry identity violates its typed domain contract.
191    #[error("telemetry contract error: {0}")]
192    TelemetryContract(#[from] TelemetryContractError),
193    /// Persisted graph project identity differs from the selected identity.
194    #[error(
195        "repository graph project identity {found} does not match selected identity {expected}"
196    )]
197    GraphProjectIdentityMismatch {
198        /// Project identity selected by the caller.
199        expected: String,
200        /// Project identity stored in the database.
201        found: String,
202    },
203    /// A graph query requires one complete nonzero publication generation.
204    #[error("repository graph is unavailable without a complete published generation")]
205    GraphPublicationUnavailable,
206    /// A normalized graph row has an impossible column shape.
207    #[error("invalid {table} row: {reason}")]
208    GraphRowShape {
209        /// Owning normalized graph table.
210        table: &'static str,
211        /// Stable shape diagnostic.
212        reason: &'static str,
213    },
214    /// A derived graph snapshot violates its bounded portable contract.
215    #[error("invalid derived graph snapshot: {reason}")]
216    DerivedSnapshotInvalid {
217        /// Stable validation failure.
218        reason: &'static str,
219    },
220    /// A derived graph snapshot exceeded one declared resource ceiling.
221    #[error(
222        "derived graph snapshot {resource} exceeds the limit: found {found}, maximum {maximum}"
223    )]
224    DerivedSnapshotLimit {
225        /// Bounded resource that was exceeded.
226        resource: &'static str,
227        /// Observed amount.
228        found: u64,
229        /// Maximum admitted amount.
230        maximum: u64,
231    },
232    /// A private snapshot capture could not be created or cleaned up.
233    #[error("derived graph snapshot I/O failed for {path:?}: {source}")]
234    DerivedSnapshotIo {
235        /// Private temporary path involved in the operation.
236        path: PathBuf,
237        /// Underlying filesystem failure.
238        #[source]
239        source: std::io::Error,
240    },
241    /// A portable snapshot payload was not valid JSON.
242    #[error("derived graph snapshot JSON is invalid: {0}")]
243    DerivedSnapshotJson(#[from] serde_json::Error),
244    /// Persisted per-file symbol rows do not match their owning parser metadata.
245    #[error("invalid persisted symbol graph for {path:?}: {reason}")]
246    SymbolGraphRowShape {
247        /// Repository path whose persisted graph is inconsistent.
248        path: String,
249        /// Stable shape diagnostic.
250        reason: &'static str,
251    },
252    /// Equal scoped resolution digests retained different canonical witnesses.
253    #[error("resolution-key collision in {domain} for digest {digest:?}")]
254    ResolutionKeyCollision {
255        /// Closed resolution domain containing the conflict.
256        domain: &'static str,
257        /// Fixed digest shared by conflicting witnesses.
258        digest: [u8; 32],
259    },
260    /// A normalized binary graph field has the wrong width.
261    #[error("invalid {field} blob length {found}; expected {expected}")]
262    InvalidBlobLength {
263        /// Owning database field.
264        field: &'static str,
265        /// Required fixed byte width.
266        expected: usize,
267        /// Observed byte width.
268        found: usize,
269    },
270    /// An unsigned graph count cannot be represented by `SQLite`.
271    #[error("graph count for {field} exceeds SQLite integer range: {value}")]
272    GraphCountOverflow {
273        /// Owning database field.
274        field: &'static str,
275        /// Unsigned domain value that exceeded the database range.
276        value: u64,
277    },
278    /// Schema version is not supported.
279    #[error("unsupported schema version {found}, expected {expected}")]
280    SchemaVersion {
281        /// Version found in database.
282        found: i64,
283        /// Expected version.
284        expected: i64,
285    },
286    /// An existing database has no durable schema version.
287    #[error("existing database is missing schema_version metadata")]
288    SchemaVersionMissing,
289    /// A durable `SQLite` object does not match the supported schema contract.
290    #[error("incompatible schema object {object:?}: expected {expected}, found {found}")]
291    SchemaShape {
292        /// Table, index, or column whose shape is incompatible.
293        object: String,
294        /// Required `SQLite` object kind.
295        expected: String,
296        /// Observed `SQLite` object kind.
297        found: String,
298    },
299    /// `SQLite` integrity validation failed before migration.
300    #[error("database integrity check failed: {message}")]
301    IntegrityCheck {
302        /// Bounded `SQLite` integrity diagnostic.
303        message: String,
304    },
305    /// A migration did not reach the supported current schema.
306    #[error("schema migration did not reach expected version {expected}")]
307    SchemaPostcondition {
308        /// Version required after migration.
309        expected: i64,
310    },
311    /// A source-owned database has no durable project identity.
312    #[error("existing database is missing project_root metadata")]
313    ProjectRootMissing,
314    /// A source-owned database belongs to another project root.
315    #[error("database project root {found:?} does not match selected root {expected:?}")]
316    ProjectRootMismatch {
317        /// Canonical root selected by the caller.
318        expected: String,
319        /// Durable root recorded in `SQLite`.
320        found: String,
321    },
322    /// A root transition destination is not an absolute existing directory.
323    #[error("invalid project root transition destination {root:?}: {source}")]
324    ProjectRootDestinationInvalid {
325        /// Destination rejected before database preflight or mutation.
326        root: String,
327        /// Filesystem or input failure that made the destination invalid.
328        #[source]
329        source: std::io::Error,
330    },
331    /// A move or detach requires a previously bound project root.
332    #[error("project root transition requires an existing bound root")]
333    ProjectRootTransitionRequiresExistingRoot,
334    /// A move must select a destination different from the old root.
335    #[error("project root move destination {root:?} matches the existing root")]
336    ProjectRootTransitionRequiresDifferentRoot {
337        /// Root that was selected as both source and destination.
338        root: String,
339    },
340    /// A verified move cannot preserve identity while the old root still exists.
341    #[error("project root {root:?} still exists; use detach for an independent copy")]
342    ProjectRootStillPresent {
343        /// Recorded old root that remains accessible.
344        root: String,
345    },
346    /// Filesystem state could not prove that a move's old root is absent.
347    #[error("cannot prove project root {root:?} is absent: {source}")]
348    ProjectRootAbsenceUncertain {
349        /// Recorded old root whose state is uncertain.
350        root: String,
351        /// Filesystem failure that prevents an absence proof.
352        #[source]
353        source: std::io::Error,
354    },
355    /// Root or identity state changed after transition preflight.
356    #[error(
357        "project root transition state changed: root {expected_root:?} -> {found_root:?}, identity {expected_identity:?} -> {found_identity:?}"
358    )]
359    ProjectRootTransitionChanged {
360        /// Root captured by read-only preflight.
361        expected_root: Option<String>,
362        /// Root observed inside the write transaction.
363        found_root: Option<String>,
364        /// Identity captured by read-only preflight.
365        expected_identity: Option<String>,
366        /// Identity observed inside the write transaction.
367        found_identity: Option<String>,
368    },
369    /// A bound project database has no durable instance identity.
370    #[error("bound project database is missing project instance identity")]
371    ProjectInstanceIdentityMissing,
372    /// `SQLite` did not yield a usable nonzero project identity.
373    #[error("failed to generate a distinct nonzero project instance identity")]
374    ProjectInstanceIdentityGenerationFailed,
375    /// A transaction failed and the explicit rollback also failed.
376    #[error("{operation}; rollback also failed: {rollback}")]
377    TransactionRollback {
378        /// Primary operation failure that caused rollback.
379        #[source]
380        operation: Box<DbError>,
381        /// Secondary rollback failure retained for diagnosis.
382        rollback: rusqlite::Error,
383    },
384    /// Publication acquisition failed and its standard busy policy was not restored.
385    #[error(
386        "publication writer acquisition failed: {operation}; restoring the standard busy policy also failed: {restore}"
387    )]
388    PublicationAcquirePolicyRestore {
389        /// Writer-acquisition failure observed with fail-fast busy handling.
390        #[source]
391        operation: Box<rusqlite::Error>,
392        /// Failure restoring the ordinary connection busy policy.
393        restore: Box<rusqlite::Error>,
394    },
395    /// Invalid enum value read from the database.
396    #[error("invalid {field} value in database: {value}")]
397    InvalidEnum {
398        /// Field name.
399        field: &'static str,
400        /// Invalid value.
401        value: String,
402    },
403    /// A literal cannot safely use the complete-candidate FTS path.
404    #[error("literal token cannot use FTS acceleration: {reason}")]
405    FileTextFtsTokenUnsafe {
406        /// Stable rejection reason for service fallback selection.
407        reason: &'static str,
408    },
409    /// One FTS request exceeded the storage-owned candidate bound.
410    #[error("FTS candidate request {requested} exceeds the maximum {maximum}")]
411    FileTextFtsCandidateLimit {
412        /// Requested exact-verification candidates.
413        requested: usize,
414        /// Storage-owned hard maximum.
415        maximum: usize,
416    },
417    /// `SQLite` returned a non-finite lexical ranking score.
418    #[error("invalid FTS BM25 score for {path:?}")]
419    FileTextFtsScoreInvalid {
420        /// Candidate path whose score was invalid.
421        path: String,
422    },
423    /// Durable FTS synchronization metadata is absent or contradictory.
424    #[error("invalid FTS synchronization state: {reason}")]
425    FileTextFtsStateInvalid {
426        /// Stable reason that makes the acceleration state untrustworthy.
427        reason: &'static str,
428    },
429    /// Persisted text metadata disagrees with its authoritative UTF-8 content.
430    #[error("file text {path:?} has invalid {field}: recorded {recorded}, actual {actual}")]
431    FileTextMetadataMismatch {
432        /// Repository-relative path owning the invalid text row.
433        path: String,
434        /// Metadata field that disagrees with content.
435        field: &'static str,
436        /// Persisted or caller-supplied value.
437        recorded: usize,
438        /// Value derived from authoritative content.
439        actual: usize,
440    },
441    /// A bounded database read observed cancellation or its deadline.
442    #[error("{0}")]
443    IndexWork(#[from] IndexWorkFailure),
444    /// Count value from `SQLite` could not fit its owning unsigned domain type.
445    #[error("invalid count for {field}: {value}")]
446    InvalidCount {
447        /// Count field name.
448        field: &'static str,
449        /// Invalid database count.
450        value: i64,
451        /// Source conversion error.
452        source: TryFromIntError,
453    },
454    /// Integer metadata could not be parsed without losing its source error.
455    #[error("invalid integer metadata for {field}: {value:?}: {source}")]
456    InvalidInteger {
457        /// Metadata field name.
458        field: &'static str,
459        /// Invalid persisted value.
460        value: String,
461        /// Source parse failure.
462        source: ParseIntError,
463    },
464    /// Unsigned integer metadata could not advance without overflow.
465    #[error("integer metadata for {field} overflowed at {value}")]
466    IntegerMetadataOverflow {
467        /// Metadata key owning the monotonic value.
468        field: &'static str,
469        /// Largest persisted value that could not advance.
470        value: u64,
471    },
472    /// A caller supplied a path that is not in the current index.
473    #[error("path {path:?} is not indexed; run scan, fix the path, or choose an indexed path")]
474    PathNotIndexed {
475        /// Repository-relative path.
476        path: String,
477    },
478    /// A purpose-curation task label is blank, unsafe, or too large.
479    #[error("invalid purpose-curation task label: {reason}")]
480    PurposeCurationTaskInvalid {
481        /// Stable validation reason.
482        reason: &'static str,
483    },
484    /// A purpose-curation hydration request exceeds the bounded statement size.
485    #[error("purpose-curation batch requested {requested} paths; maximum is {maximum}")]
486    PurposeCurationBatchTooLarge {
487        /// Number of unique paths requested.
488        requested: usize,
489        /// Maximum paths admitted to one prepared set query.
490        maximum: usize,
491    },
492    /// A caller attempted to resolve a health finding that is not currently active.
493    #[error(
494        "health finding {finding_id:?} with category {category:?} and path {path:?} is not active; run health-check and use an exact finding id/path/category"
495    )]
496    HealthFindingNotActive {
497        /// Requested finding id.
498        finding_id: String,
499        /// Requested category.
500        category: String,
501        /// Requested primary path.
502        path: String,
503    },
504    /// A projection-only refresh no longer matches the established index contract.
505    #[error("index publication contract changed during projection refresh")]
506    PublicationContractChanged,
507    /// Prepared publication work was based on a generation that is no longer current.
508    #[error("index publication base generation changed: expected {expected}, found {found}")]
509    PublicationBaseGenerationChanged {
510        /// Complete generation used to prepare the publication batch.
511        expected: IndexGeneration,
512        /// Complete generation observed after reserving the writer transaction.
513        found: IndexGeneration,
514    },
515    /// A complete index generation cannot advance any further.
516    #[error("index publication generation overflowed")]
517    PublicationGenerationOverflow,
518    /// A full scan replacement has not removed its remaining absent projections.
519    #[error("index publication cannot complete before scan replacement finishes")]
520    ScanReplacementIncomplete,
521    /// The store already has an active read snapshot.
522    #[error("index read snapshot is already active on this store")]
523    IndexReadSnapshotActive,
524    /// A read-only store cannot locate its database for a separate telemetry write.
525    #[error("read-only store has no database path for telemetry persistence")]
526    TelemetryPathUnavailable,
527    /// One telemetry field exceeds its declared UTF-8 byte budget.
528    #[error("telemetry field {field} uses {bytes} UTF-8 bytes; limit is {limit}")]
529    TelemetryFieldTooLarge {
530        /// Stable field owner.
531        field: &'static str,
532        /// Observed UTF-8 byte count.
533        bytes: usize,
534        /// Maximum admitted UTF-8 byte count.
535        limit: usize,
536    },
537    /// A telemetry retention policy bound cannot make forward progress.
538    #[error("invalid telemetry retention limit for {field}: {value}")]
539    TelemetryLimitInvalid {
540        /// Stable retention-policy field.
541        field: &'static str,
542        /// Rejected policy value.
543        value: usize,
544    },
545    /// A telemetry counter cannot be represented exactly in `SQLite`.
546    #[error("telemetry integer overflow for {field}")]
547    TelemetryIntegerOverflow {
548        /// Stable counter owner.
549        field: &'static str,
550    },
551    /// A runtime attempted to reuse a sealed or expired instance.
552    #[error("telemetry usage instance is sealed or expired")]
553    TelemetryInstanceInactive,
554    /// Operating-system randomness was unavailable for optional telemetry identity creation.
555    #[error("telemetry runtime identity is unavailable")]
556    TelemetryIdentityUnavailable,
557    /// A runtime identity was reused with incompatible durable instance metadata.
558    #[error("telemetry runtime identity does not match its retained owner or caller label")]
559    TelemetryInstanceMismatch,
560    /// The bounded active-instance capacity is exhausted.
561    #[error("telemetry active-instance capacity is exhausted")]
562    TelemetryInstanceCapacity,
563    /// The bounded active-baseline capacity is exhausted.
564    #[error("telemetry modeled-baseline capacity is exhausted")]
565    TelemetryBaselineCapacity,
566    /// A compact modeled-baseline key collided with different witness material.
567    #[error("telemetry modeled-baseline key collided with different witness material")]
568    TelemetryBaselineCollision,
569}
570
571impl DbError {
572    /// Return whether a derived-index write could not proceed without implying
573    /// corruption, schema drift, or an identity-contract failure.
574    #[must_use]
575    pub fn is_write_unavailable(&self) -> bool {
576        match self {
577            Self::Sqlite(error) => matches!(
578                error.sqlite_error_code(),
579                Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked | ErrorCode::ReadOnly)
580            ),
581            _ => false,
582        }
583    }
584}
585
586/// Convenient result alias for database operations.
587pub type DbResult<T> = Result<T, DbError>;
588
589/// Maximum exact paths admitted to one bounded symbol hydration request.
590pub const MAX_SYMBOL_BATCH_PATHS: u32 = 64;
591/// Maximum symbol rows admitted to one bounded hydration request.
592pub const MAX_SYMBOL_BATCH_ROWS: u32 = 4_096;
593/// Maximum decoded symbol bytes admitted to one bounded hydration request.
594pub const MAX_SYMBOL_BATCH_DECODED_BYTES: u64 = 4 * 1_024 * 1_024;
595/// Paths bound per statement, below supported `SQLite` variable ceilings.
596const SYMBOL_BATCH_BIND_PATHS: usize = 48;
597
598/// Typed database envelope for one exact-path symbol batch.
599#[derive(Clone, Copy, Debug, Eq, PartialEq)]
600pub struct SymbolBatchReadBudget {
601    /// Maximum unique exact repository paths.
602    paths: u32,
603    /// Maximum decoded symbol rows.
604    rows: u32,
605    /// Maximum retained Rust row and owned string allocation bytes.
606    decoded_bytes: u64,
607}
608
609impl SymbolBatchReadBudget {
610    /// Construct one bounded exact-path symbol read envelope.
611    ///
612    /// # Errors
613    ///
614    /// Returns an error when a limit is zero or above its product ceiling.
615    pub fn new(paths: u32, rows: u32, decoded_bytes: u64) -> DbResult<Self> {
616        if paths == 0
617            || paths > MAX_SYMBOL_BATCH_PATHS
618            || rows == 0
619            || rows > MAX_SYMBOL_BATCH_ROWS
620            || decoded_bytes == 0
621            || decoded_bytes > MAX_SYMBOL_BATCH_DECODED_BYTES
622        {
623            return Err(GraphContractError::InvalidLimits {
624                reason: "symbol batch limit is zero or above the product ceiling",
625            }
626            .into());
627        }
628        Ok(Self {
629            paths,
630            rows,
631            decoded_bytes,
632        })
633    }
634
635    /// Return the exact-path ceiling.
636    #[must_use]
637    pub const fn paths(self) -> u32 {
638        self.paths
639    }
640
641    /// Return the decoded-row ceiling.
642    #[must_use]
643    pub const fn rows(self) -> u32 {
644        self.rows
645    }
646
647    /// Return the decoded-byte ceiling.
648    #[must_use]
649    pub const fn decoded_bytes(self) -> u64 {
650        self.decoded_bytes
651    }
652}
653
654/// Exact work retained by one bounded symbol batch read.
655#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
656pub struct SymbolBatchReadWork {
657    /// Unique exact paths supplied to `SQLite`.
658    pub requested_paths: u32,
659    /// Symbol rows retained after all bounds.
660    pub returned_rows: u32,
661    /// Retained Rust row and owned string allocation bytes after all bounds.
662    pub decoded_bytes: u64,
663}
664
665/// Bounded symbols and truncation state returned for exact paths.
666#[derive(Clone, Debug, Default, Eq, PartialEq)]
667pub struct SymbolBatchRead {
668    /// Deterministically ordered persisted symbols.
669    pub rows: Vec<CodeSymbol>,
670    /// Whether a path, row, or decoded-byte bound omitted rows.
671    pub truncated: bool,
672    /// First deterministic database limit that omitted symbol rows.
673    pub reached_limit: Option<SymbolBatchReadLimit>,
674    /// Exact work retained by this database read.
675    pub work: SymbolBatchReadWork,
676}
677
678/// Closed database limits that can truncate an exact-path symbol batch.
679#[derive(Clone, Copy, Debug, Eq, PartialEq)]
680pub enum SymbolBatchReadLimit {
681    /// More unique paths were requested than the batch admits.
682    Paths,
683    /// More symbol rows matched than the batch admits.
684    Rows,
685    /// The next symbol row crossed the decoded-byte envelope.
686    DecodedBytes,
687}
688
689/// Durable state of the multi-projection derived index publication.
690#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
691#[serde(rename_all = "snake_case")]
692pub enum IndexPublicationState {
693    /// A publisher may have changed only part of the derived index.
694    Updating,
695    /// Every projection completed under the recorded contract fingerprint.
696    Complete,
697}
698
699impl IndexPublicationState {
700    /// Return the stable `SQLite` representation.
701    const fn as_str(self) -> &'static str {
702        match self {
703            Self::Updating => "updating",
704            Self::Complete => "complete",
705        }
706    }
707
708    /// Parse the stable `SQLite` representation.
709    fn from_db(value: String) -> DbResult<Self> {
710        match value.as_str() {
711            "updating" => Ok(Self::Updating),
712            "complete" => Ok(Self::Complete),
713            _ => Err(DbError::InvalidEnum {
714                field: INDEX_PUBLICATION_STATE_KEY,
715                value,
716            }),
717        }
718    }
719}
720
721/// Persisted state needed to reject mixed or incompatible derived projections.
722#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
723pub struct IndexPublication {
724    /// Current publication state.
725    pub state: IndexPublicationState,
726    /// Contract fingerprint recorded by the last complete publication.
727    pub contract_fingerprint: Option<String>,
728    /// Monotonic generation of the last complete derived index.
729    pub generation: IndexGeneration,
730}
731
732/// Publication contract applied when one atomic writer commits.
733enum PublicationContract {
734    /// Establish or replace the complete derived-index contract.
735    Full(String),
736    /// Preserve the already established complete contract.
737    Projection(String),
738}
739
740/// Exclusive parent-owned atomic publication over all derived projections.
741pub struct IndexPublicationGuard<'store> {
742    /// Store whose connection owns the active `SQLite` write transaction.
743    store: &'store mut AtlasStore,
744    /// Contract behavior selected when the publication began.
745    contract: PublicationContract,
746    /// Generation visible before this publication began.
747    previous_generation: IndexGeneration,
748    /// Whether a full scan replacement still needs absent-projection cleanup.
749    scan_replacement_pending: bool,
750    /// Whether drop must roll the transaction back.
751    active: bool,
752}
753
754/// `SQLite`-backed `ProjectAtlas` index store.
755pub struct AtlasStore {
756    /// Active database connection for index reads and writes.
757    connection: Connection,
758    /// Whether normal reads currently share one explicit `SQLite` snapshot.
759    read_snapshot_active: Cell<bool>,
760    /// Durable database path when the store is file-backed.
761    database_path: Option<PathBuf>,
762    /// WAL-safe filesystem identity retained for later ancillary connections.
763    database_location: Option<DatabaseLocation>,
764    /// Whether this connection is restricted to non-mutating queries.
765    read_only: bool,
766    /// Project root validated for this store, when the database records one.
767    validated_project_root: Option<String>,
768    /// Project identity captured with the validated root binding.
769    validated_project_instance_id: Option<ProjectInstanceId>,
770    /// Bounded per-label instances used by direct library callers for this handle lifetime.
771    library_usage_instances: RefCell<HashMap<String, Option<UsageInstanceId>>>,
772}
773
774/// Root and project identity captured when one store binding was validated.
775#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
776pub struct CapturedProjectBinding {
777    /// Stable project identity captured at open or explicit root transition.
778    pub project_instance_id: ProjectInstanceId,
779    /// Normalized local source root captured with the project identity.
780    pub project_root: String,
781}
782
783/// Lightweight persisted import fact used by alias resolution.
784#[derive(Clone, Debug, Eq, PartialEq)]
785pub struct StoredImportRelation {
786    /// Repository-relative source path that owns the import.
787    pub path: String,
788    /// Parser-selected source declaration or module.
789    pub source_name: String,
790    /// Original persisted import text.
791    pub target_name: String,
792    /// One-based source line.
793    pub line: usize,
794}
795
796/// One unapproved purpose row bound to deterministic curator work.
797#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
798pub struct PurposeCurationCandidate {
799    /// Current indexed node, purpose, and summary state.
800    pub node: IndexedNode,
801    /// Project/generation/task/path work identity used to coalesce duplicate work.
802    pub work_key: String,
803    /// Opaque token binding conditional apply to this exact unapproved purpose row.
804    pub state_token: String,
805}
806
807/// Bounded purpose-curation work selected from one project generation.
808#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
809pub struct PurposeCurationBatch {
810    /// Stable selected-project identity.
811    pub project_instance_id: ProjectInstanceId,
812    /// Active graph/index generation observed with the queue rows.
813    pub active_generation: IndexGeneration,
814    /// Host-supplied task label used to coalesce task-local work.
815    pub task: String,
816    /// Deterministic identity of the complete returned batch.
817    pub work_key: String,
818    /// Current missing or suggested purpose rows, ordered by path.
819    pub items: Vec<PurposeCurationCandidate>,
820}
821
822/// Result of applying a purpose through the stale-safe curator path.
823#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
824#[serde(rename_all = "snake_case")]
825pub enum PurposeConditionalApplyState {
826    /// The current unapproved row matched and was approved atomically.
827    Applied,
828    /// Project, generation, task, path, or purpose row state changed after selection.
829    Stale,
830    /// The path now has accepted authored intent and was not overwritten.
831    Accepted,
832    /// The selected path is no longer active in the current index.
833    PathUnavailable,
834}
835
836/// One stale-safe purpose approval copied from a queue item.
837#[derive(Clone, Debug, Eq, PartialEq)]
838pub struct PurposeConditionalApplyRequest {
839    /// Queue task label.
840    pub task: String,
841    /// Exact repository-relative path.
842    pub path: String,
843    /// Queue item work key.
844    pub work_key: String,
845    /// Queue item current-row state token.
846    pub state_token: String,
847    /// Agent-reviewed purpose to approve on an exact state match.
848    pub purpose: String,
849}
850
851/// Per-item outcome from one atomic conditional purpose-review batch.
852#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
853pub struct PurposeConditionalApplyResult {
854    /// Exact requested path.
855    pub path: String,
856    /// Applied or non-mutating stale/accepted/unavailable outcome.
857    pub state: PurposeConditionalApplyState,
858    /// Current purpose state observed or written inside the owning transaction.
859    pub current_purpose: Option<Purpose>,
860}
861
862/// Identity depth required while opening one current project binding.
863#[derive(Clone, Copy, Debug, Eq, PartialEq)]
864enum ProjectIdentityRequirement {
865    /// Ordinary stores require a complete root and identity binding.
866    Required,
867    /// An explicit root transition owns identity creation or repair.
868    TransitionOwned,
869}
870
871impl ProjectIdentityRequirement {
872    /// Whether a missing identity must fail the open.
873    const fn is_required(self) -> bool {
874        matches!(self, Self::Required)
875    }
876}
877
878impl Deref for IndexPublicationGuard<'_> {
879    type Target = AtlasStore;
880
881    fn deref(&self) -> &Self::Target {
882        self.store
883    }
884}
885
886impl DerefMut for IndexPublicationGuard<'_> {
887    fn deref_mut(&mut self) -> &mut Self::Target {
888        self.store
889    }
890}
891
892impl IndexPublicationGuard<'_> {
893    /// Mark the current scan projection absent before bounded replacement batches.
894    ///
895    /// The caller must finish with [`Self::finish_scan_replacement`] before
896    /// completing this publication. Dropping the guard rolls every partial
897    /// replacement batch back with the parent transaction.
898    ///
899    /// # Errors
900    ///
901    /// Returns an error if the scan projection cannot be updated.
902    pub fn begin_scan_replacement(&mut self) -> DbResult<()> {
903        mark_all_scan_nodes_absent(&self.store.connection)?;
904        self.scan_replacement_pending = true;
905        Ok(())
906    }
907
908    /// Upsert one bounded scan-node batch inside the parent publication.
909    ///
910    /// # Errors
911    ///
912    /// Returns an error if any node in the batch cannot be persisted.
913    pub fn upsert_scan_node_batch(&mut self, nodes: &[Node]) -> DbResult<()> {
914        upsert_nodes(&self.store.connection, nodes)
915    }
916
917    /// Remove derived projections for nodes left absent after replacement.
918    ///
919    /// # Errors
920    ///
921    /// Returns an error if stale projections cannot be removed.
922    pub fn finish_scan_replacement(&mut self) -> DbResult<()> {
923        delete_absent_scan_projections(&self.store.connection)?;
924        self.scan_replacement_pending = false;
925        Ok(())
926    }
927
928    /// Commit every derived projection and advance the complete generation
929    /// exactly once.
930    ///
931    /// # Errors
932    ///
933    /// Returns an error if generation metadata is invalid, a projection-only
934    /// refresh no longer matches its established contract, or commit fails.
935    pub fn complete(mut self) -> DbResult<()> {
936        if self.scan_replacement_pending {
937            return Err(DbError::ScanReplacementIncomplete);
938        }
939        let next_generation = self
940            .previous_generation
941            .checked_next()
942            .ok_or(DbError::PublicationGenerationOverflow)?;
943        match &self.contract {
944            PublicationContract::Full(contract_fingerprint) => {
945                set_metadata(
946                    &self.store.connection,
947                    INDEX_PUBLICATION_FINGERPRINT_KEY,
948                    contract_fingerprint,
949                )?;
950            }
951            PublicationContract::Projection(contract_fingerprint) => {
952                let matches =
953                    load_index_publication(&self.store.connection)?.is_some_and(|publication| {
954                        publication.state == IndexPublicationState::Updating
955                            && publication.contract_fingerprint.as_deref()
956                                == Some(contract_fingerprint.as_str())
957                            && publication.generation == self.previous_generation
958                    });
959                if !matches {
960                    return Err(DbError::PublicationContractChanged);
961                }
962            }
963        }
964        set_metadata(
965            &self.store.connection,
966            INDEX_PUBLICATION_GENERATION_KEY,
967            &next_generation.to_string(),
968        )?;
969        set_metadata(
970            &self.store.connection,
971            INDEX_PUBLICATION_STATE_KEY,
972            IndexPublicationState::Complete.as_str(),
973        )?;
974        self.store.connection.execute_batch("COMMIT")?;
975        self.active = false;
976        Ok(())
977    }
978}
979
980impl Drop for IndexPublicationGuard<'_> {
981    fn drop(&mut self) {
982        if self.active {
983            let _rollback_result = self.store.connection.execute_batch("ROLLBACK");
984        }
985    }
986}
987
988/// UTF-8 source text persisted for indexed search.
989#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
990pub struct IndexedFileText {
991    /// Repository-relative file path using forward slashes.
992    pub path: String,
993    /// BLAKE3 content hash from the scanned file node.
994    pub content_hash: Option<String>,
995    /// UTF-8 byte count stored for telemetry.
996    pub byte_count: usize,
997    /// Number of text lines stored for context extraction.
998    pub line_count: usize,
999    /// Full UTF-8 source text used by indexed search.
1000    pub content: String,
1001}
1002
1003/// Bounded safe-token request for FTS candidate acceleration.
1004#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1005pub struct FileTextFtsQuery<'query> {
1006    /// One ASCII-alphanumeric token that exact lexical matching will verify.
1007    pub literal_token: &'query str,
1008    /// Optional exact path-or-descendant scope.
1009    pub path_prefix: Option<&'query str>,
1010    /// Maximum candidates returned before reporting overflow.
1011    pub limit: usize,
1012}
1013
1014/// One FTS-ranked candidate carrying only authoritative persisted metadata.
1015#[derive(Clone, Debug, PartialEq)]
1016pub struct FileTextFtsCandidate {
1017    /// Repository-relative file path used for budgeted content hydration.
1018    pub path: String,
1019    /// BLAKE3 content hash from the authoritative persisted text row.
1020    pub content_hash: Option<String>,
1021    /// Persisted UTF-8 source byte count used for pre-hydration budgets.
1022    pub byte_count: usize,
1023    /// Persisted line count.
1024    pub line_count: usize,
1025    /// `SQLite` FTS5 BM25 score; lower values rank first.
1026    pub bm25: f64,
1027}
1028
1029/// Bounded FTS candidate page for service-owned exact verification.
1030#[derive(Clone, Debug, PartialEq)]
1031pub struct FileTextFtsPage {
1032    /// Candidates ordered by BM25 and repository path.
1033    pub candidates: Vec<FileTextFtsCandidate>,
1034    /// Whether at least one more candidate matched the bound query.
1035    pub overflow: bool,
1036}
1037
1038/// Persisted file metadata available before source-content decoding.
1039#[derive(Clone, Debug, Eq, PartialEq)]
1040pub struct FileTextMetadata {
1041    /// Repository-relative file path using forward slashes.
1042    pub path: String,
1043    /// BLAKE3 content hash from the scanned file node.
1044    pub content_hash: Option<String>,
1045    /// Persisted UTF-8 source byte count.
1046    pub byte_count: usize,
1047    /// Persisted line count.
1048    pub line_count: usize,
1049}
1050
1051/// Service decision made before one fallback row decodes source content.
1052#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1053pub enum FileTextAdmission {
1054    /// Decode and visit this row's source content.
1055    Read,
1056    /// Continue without decoding this row's source content.
1057    Skip,
1058    /// Stop fallback iteration without decoding this row's source content.
1059    Stop,
1060}
1061
1062/// Content-free synchronization state for the rebuildable FTS projection.
1063#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1064pub struct FileTextFtsState {
1065    /// Authoritative persisted-text rows.
1066    pub source_rows: usize,
1067    /// Document rows currently represented by the FTS projection.
1068    pub indexed_rows: usize,
1069    /// Whether authoritative and projected document identities agree exactly.
1070    pub synchronized: bool,
1071}
1072
1073/// Agent-approved resolution for a deterministic health finding.
1074#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1075pub struct HealthResolution {
1076    /// Stable health finding id.
1077    pub finding_id: String,
1078    /// Finding category.
1079    pub category: String,
1080    /// Primary path.
1081    pub path: String,
1082    /// Related path, when any.
1083    pub related_path: Option<String>,
1084    /// Agent rationale for suppressing future repeats.
1085    pub rationale: String,
1086}
1087
1088/// Bounded health query used by agent-facing adapters.
1089#[derive(Clone, Debug, Eq, PartialEq)]
1090pub struct HealthQuery {
1091    /// Pagination start index after filters are applied.
1092    pub start_index: usize,
1093    /// Maximum findings to return.
1094    pub limit: usize,
1095    /// Optional finding category filter.
1096    pub category: Option<String>,
1097    /// Optional severity filter.
1098    pub severity: Option<Severity>,
1099    /// Optional repository-relative path prefix filter.
1100    pub path_prefix: Option<String>,
1101    /// Return counts without finding rows.
1102    pub summary_only: bool,
1103    /// Health and purpose-curation scope.
1104    pub scope: HealthScope,
1105}
1106
1107/// Resolution ownership used by bounded health queries.
1108#[derive(Clone, Copy)]
1109enum HealthResolutionFilter<'a> {
1110    /// Caller-owned compatibility filter.
1111    Explicit(&'a [String]),
1112    /// Durable resolutions owned by the current project database.
1113    Stored,
1114}
1115
1116/// Scope controls for bounded health and purpose-curation queries.
1117#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1118pub enum HealthScope {
1119    /// Include all indexed paths.
1120    All,
1121    /// Include only source files and folders with source descendants.
1122    SourceOnly,
1123    /// Include all folders plus high-impact files.
1124    PurposeDefault,
1125    /// Include all folders, high-impact files, and non-source files.
1126    PurposeWithAssets,
1127    /// Include all folders, high-impact files, and all source files.
1128    PurposeWithSourceFiles,
1129    /// Include every indexed file and folder.
1130    PurposeStrict,
1131}
1132
1133impl HealthScope {
1134    /// Scope matching unfiltered health output.
1135    pub fn all() -> Self {
1136        Self::All
1137    }
1138
1139    /// Scope restricted to source-relevant paths.
1140    pub fn source_only() -> Self {
1141        Self::SourceOnly
1142    }
1143
1144    /// Default agent purpose curation scope: folders plus high-impact files.
1145    pub fn purpose_default() -> Self {
1146        Self::PurposeDefault
1147    }
1148
1149    /// Purpose curation scope including non-source asset files.
1150    pub fn purpose_with_assets() -> Self {
1151        Self::PurposeWithAssets
1152    }
1153
1154    /// Purpose curation scope including all source files.
1155    pub fn purpose_with_source_files() -> Self {
1156        Self::PurposeWithSourceFiles
1157    }
1158
1159    /// Strict purpose curation scope including every indexed path.
1160    pub fn purpose_strict() -> Self {
1161        Self::PurposeStrict
1162    }
1163
1164    /// Whether this scope should be reported as source-focused in agent payloads.
1165    pub fn is_source_focused(self) -> bool {
1166        self.source_only_filter()
1167    }
1168
1169    /// Whether this scope uses the folder-first high-impact purpose queue.
1170    pub fn is_purpose_queue(self) -> bool {
1171        self.high_impact_queue()
1172    }
1173
1174    /// Whether source relevance should be applied before queue-specific filters.
1175    fn source_only_filter(self) -> bool {
1176        matches!(
1177            self,
1178            Self::SourceOnly | Self::PurposeDefault | Self::PurposeWithSourceFiles
1179        )
1180    }
1181
1182    /// Whether the scope should use folder-first purpose queue selection.
1183    fn high_impact_queue(self) -> bool {
1184        matches!(
1185            self,
1186            Self::PurposeDefault
1187                | Self::PurposeWithAssets
1188                | Self::PurposeWithSourceFiles
1189                | Self::PurposeStrict
1190        )
1191    }
1192
1193    /// Whether non-source asset files should be included in queue selection.
1194    fn include_assets(self) -> bool {
1195        matches!(self, Self::PurposeWithAssets)
1196    }
1197
1198    /// Whether all source files should be included in queue selection.
1199    fn include_source_files(self) -> bool {
1200        matches!(self, Self::PurposeWithSourceFiles | Self::PurposeStrict)
1201    }
1202
1203    /// Whether all files should be included in queue selection.
1204    fn include_all_files(self) -> bool {
1205        matches!(self, Self::PurposeStrict)
1206    }
1207}
1208
1209/// Bounded health findings page returned by the database layer.
1210#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1211pub struct HealthFindingsPage {
1212    /// Findings after filters are applied.
1213    pub total: usize,
1214    /// Findings before filters are applied, after resolved findings are removed.
1215    pub unfiltered_total: usize,
1216    /// Findings returned in this page.
1217    pub returned: usize,
1218    /// Pagination start index used for this page.
1219    pub start_index: usize,
1220    /// Maximum findings requested for this page.
1221    pub limit: usize,
1222    /// Returned health finding rows.
1223    pub findings: Vec<HealthFinding>,
1224}
1225
1226/// Static metadata for one purpose lifecycle health category.
1227#[derive(Clone, Copy, Debug)]
1228struct PurposeHealthSpec {
1229    /// Stored purpose status that emits this health category.
1230    status: &'static str,
1231    /// Health finding category for the lifecycle status.
1232    category: &'static str,
1233    /// Health finding message for every row in this lifecycle category.
1234    message: &'static str,
1235    /// Agent recommendation for resolving this lifecycle category.
1236    recommendation: &'static str,
1237}
1238
1239/// Purpose lifecycle health categories that can be paged directly in `SQLite`.
1240const PURPOSE_HEALTH_SPECS: [PurposeHealthSpec; 3] = [
1241    PurposeHealthSpec {
1242        status: PurposeStatus::Missing.as_str(),
1243        category: CATEGORY_MISSING_PURPOSE,
1244        message: MESSAGE_MISSING_PURPOSE,
1245        recommendation: RECOMMENDATION_MISSING_PURPOSE_QUEUE,
1246    },
1247    PurposeHealthSpec {
1248        status: PurposeStatus::Suggested.as_str(),
1249        category: CATEGORY_SUGGESTED_PURPOSE_REVIEW,
1250        message: MESSAGE_SUGGESTED_PURPOSE_REVIEW,
1251        recommendation: RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW_QUEUE,
1252    },
1253    PurposeHealthSpec {
1254        status: PurposeStatus::Stale.as_str(),
1255        category: CATEGORY_STALE_PURPOSE,
1256        message: MESSAGE_STALE_PURPOSE,
1257        recommendation: RECOMMENDATION_STALE_PURPOSE,
1258    },
1259];
1260
1261impl AtlasStore {
1262    /// Reject mutations while this connection owns an ordinary read snapshot.
1263    fn require_mutation_scope(&self) -> DbResult<()> {
1264        if self.read_snapshot_active.get() {
1265            Err(DbError::IndexReadSnapshotActive)
1266        } else {
1267            Ok(())
1268        }
1269    }
1270
1271    /// Open a nested-capable write scope after validating the active binding.
1272    fn validated_savepoint(&mut self) -> DbResult<rusqlite::Savepoint<'_>> {
1273        self.require_mutation_scope()?;
1274        let validate_binding = self.connection.is_autocommit();
1275        let expected_root = self.validated_project_root.clone();
1276        let expected_identity = self.validated_project_instance_id;
1277        let savepoint = self.connection.savepoint()?;
1278        if validate_binding {
1279            schema::validate_active_binding(
1280                &savepoint,
1281                expected_root.as_deref(),
1282                expected_identity,
1283            )?;
1284        }
1285        Ok(savepoint)
1286    }
1287
1288    /// Run one atomic standalone write, reusing an already validated parent transaction.
1289    fn with_validated_write<T>(
1290        &self,
1291        operation: impl FnOnce(&Connection) -> DbResult<T>,
1292    ) -> DbResult<T> {
1293        self.require_mutation_scope()?;
1294        if !self.connection.is_autocommit() {
1295            return operation(&self.connection);
1296        }
1297        let transaction =
1298            rusqlite::Transaction::new_unchecked(&self.connection, TransactionBehavior::Immediate)?;
1299        let result = schema::validate_active_binding(
1300            &transaction,
1301            self.validated_project_root.as_deref(),
1302            self.validated_project_instance_id,
1303        )
1304        .and_then(|()| operation(&transaction));
1305        match result {
1306            Ok(value) => {
1307                transaction.commit()?;
1308                Ok(value)
1309            }
1310            Err(operation) => match transaction.rollback() {
1311                Ok(()) => Err(operation),
1312                Err(rollback) => Err(DbError::TransactionRollback {
1313                    operation: Box::new(operation),
1314                    rollback,
1315                }),
1316            },
1317        }
1318    }
1319
1320    /// Run telemetry against this store's exact captured database binding.
1321    ///
1322    /// File-backed stores use a separate short-lived writable connection with
1323    /// an ancillary fail-fast busy budget. Read-only navigation stores release
1324    /// their read snapshot first. The connection is never discovered, attached,
1325    /// substituted, or shared with another project.
1326    fn with_telemetry_connection<T>(
1327        &self,
1328        operation: impl FnOnce(&Connection) -> DbResult<T>,
1329    ) -> DbResult<T> {
1330        if self.read_only {
1331            self.finish_index_read_snapshot()?;
1332        } else {
1333            self.require_mutation_scope()?;
1334        }
1335        let (Some(path), Some(location)) =
1336            (self.database_path.as_ref(), self.database_location.as_ref())
1337        else {
1338            if self.database_path.is_none() && self.database_location.is_none() {
1339                return operation(&self.connection);
1340            }
1341            return Err(DbError::TelemetryPathUnavailable);
1342        };
1343        let connection = open_writable_connection(
1344            path,
1345            OpenFlags::SQLITE_OPEN_READ_WRITE,
1346            location,
1347            SQLITE_TELEMETRY_BUSY_TIMEOUT,
1348            JournalModePolicy::RequireWal,
1349        )?;
1350        operation(&connection)
1351    }
1352
1353    /// Open or create an index store.
1354    ///
1355    /// # Errors
1356    ///
1357    /// Returns an error if `SQLite` setup or schema validation fails.
1358    pub fn open(path: &Path) -> DbResult<Self> {
1359        Self::open_with_project_root(path, None)
1360    }
1361
1362    /// Open or create an index store owned by one canonical project root.
1363    ///
1364    /// Existing databases bound to another root are rejected before writable
1365    /// access. A genuinely fresh database records the supplied root in the
1366    /// same transaction that creates its schema.
1367    ///
1368    /// # Errors
1369    ///
1370    /// Returns an error if read-only compatibility preflight, root validation,
1371    /// transactional migration, or `SQLite` setup fails.
1372    pub fn open_for_project(path: &Path, root: &Path) -> DbResult<Self> {
1373        let expected_root = normalize_native_path_display(root);
1374        Self::open_with_project_root(path, Some(&expected_root))
1375    }
1376
1377    /// Open with an optional source-owned project identity.
1378    fn open_with_project_root(path: &Path, expected_root: Option<&str>) -> DbResult<Self> {
1379        Self::open_with_binding_requirement(
1380            path,
1381            expected_root,
1382            ProjectIdentityRequirement::Required,
1383        )
1384    }
1385
1386    /// Open for an explicit root transition that owns identity repair.
1387    fn open_for_root_transition(path: &Path) -> DbResult<Self> {
1388        Self::open_with_binding_requirement(path, None, ProjectIdentityRequirement::TransitionOwned)
1389    }
1390
1391    /// Open with the root and identity validation required by the caller.
1392    fn open_with_binding_requirement(
1393        path: &Path,
1394        expected_root: Option<&str>,
1395        identity_requirement: ProjectIdentityRequirement,
1396    ) -> DbResult<Self> {
1397        let (preflight, location) = schema::preflight(path, expected_root)?;
1398        let validated_project_root = expected_root.map(str::to_owned).or(preflight.project_root);
1399        let connection = open_writable_connection(
1400            path,
1401            writable_open_flags(preflight.state, location.database_exists),
1402            &location,
1403            SQLITE_BUSY_TIMEOUT,
1404            writable_journal_policy(preflight.state),
1405        )?;
1406        let validated_project_instance_id = if preflight.state == SchemaState::Current {
1407            schema::revalidate_current_binding(
1408                &connection,
1409                validated_project_root.as_deref(),
1410                identity_requirement.is_required(),
1411            )?
1412        } else {
1413            schema::initialize(&connection, expected_root)?;
1414            project_identity::load_project_identity(&connection)?
1415        };
1416        if identity_requirement.is_required()
1417            && validated_project_root.is_some()
1418            && validated_project_instance_id.is_none()
1419        {
1420            return Err(DbError::ProjectInstanceIdentityMissing);
1421        }
1422        let database_location = if location.database_exists {
1423            location
1424        } else {
1425            sqlite_profile::inspect_database_location(path)?
1426        };
1427        Ok(Self {
1428            connection,
1429            read_snapshot_active: Cell::new(false),
1430            database_path: Some(path.to_path_buf()),
1431            database_location: Some(database_location),
1432            read_only: false,
1433            validated_project_root,
1434            validated_project_instance_id,
1435            library_usage_instances: RefCell::new(HashMap::new()),
1436        })
1437    }
1438
1439    /// Open an existing index without creating, migrating, or backfilling it.
1440    ///
1441    /// # Errors
1442    ///
1443    /// Returns an error if the database cannot be opened read-only or its
1444    /// schema version is not exactly supported by this runtime.
1445    pub fn open_read_only(path: &Path) -> DbResult<Self> {
1446        Self::open_read_only_with_project_root(path, None)
1447    }
1448
1449    /// Open one current read snapshot owned by a canonical project root.
1450    ///
1451    /// # Errors
1452    ///
1453    /// Returns an error if the database is incompatible, belongs to another
1454    /// root, or cannot be opened without database mutation.
1455    pub fn open_read_only_for_project(path: &Path, root: &Path) -> DbResult<Self> {
1456        let expected_root = normalize_native_path_display(root);
1457        Self::open_read_only_with_project_root(path, Some(&expected_root))
1458    }
1459
1460    /// Return whether this store is restricted to non-mutating queries.
1461    #[must_use]
1462    pub const fn is_read_only(&self) -> bool {
1463        self.read_only
1464    }
1465
1466    /// Return whether this store currently owns an open read snapshot.
1467    #[must_use]
1468    pub fn has_active_read_snapshot(&self) -> bool {
1469        self.read_snapshot_active.get()
1470    }
1471
1472    /// Open a current read snapshot with optional project identity validation.
1473    fn open_read_only_with_project_root(
1474        path: &Path,
1475        expected_root: Option<&str>,
1476    ) -> DbResult<Self> {
1477        let (connection, preflight) = schema::open_current_read_only(path, expected_root)?;
1478        let validated_project_instance_id = project_identity::load_project_identity(&connection)?;
1479        schema::validate_binding_completeness(
1480            preflight.project_root.as_deref(),
1481            validated_project_instance_id,
1482            true,
1483        )?;
1484        let database_location = sqlite_profile::inspect_database_location(path)?;
1485        Ok(Self {
1486            connection,
1487            read_snapshot_active: Cell::new(true),
1488            database_path: Some(path.to_path_buf()),
1489            database_location: Some(database_location),
1490            read_only: true,
1491            validated_project_root: preflight.project_root,
1492            validated_project_instance_id,
1493            library_usage_instances: RefCell::new(HashMap::new()),
1494        })
1495    }
1496
1497    /// Open an in-memory store for tests.
1498    ///
1499    /// # Errors
1500    ///
1501    /// Returns an error if schema setup fails.
1502    pub fn in_memory() -> DbResult<Self> {
1503        let store = Self {
1504            connection: Connection::open_in_memory()?,
1505            read_snapshot_active: Cell::new(false),
1506            database_path: None,
1507            database_location: None,
1508            read_only: false,
1509            validated_project_root: None,
1510            validated_project_instance_id: None,
1511            library_usage_instances: RefCell::new(HashMap::new()),
1512        };
1513        schema::initialize(&store.connection, None)?;
1514        Ok(store)
1515    }
1516
1517    /// Validate, initialize, or migrate the schema through the storage owner.
1518    ///
1519    /// # Errors
1520    ///
1521    /// Returns an error when schema compatibility, integrity, or migration fails.
1522    pub fn initialize_schema(&self) -> DbResult<()> {
1523        schema::initialize(&self.connection, None)
1524    }
1525
1526    /// Upsert a full scan result and mark previously seen missing paths absent.
1527    ///
1528    /// # Errors
1529    ///
1530    /// Returns an error if persistence fails.
1531    pub fn replace_scan(&mut self, nodes: &[Node]) -> DbResult<()> {
1532        let savepoint = self.validated_savepoint()?;
1533        mark_all_scan_nodes_absent(&savepoint)?;
1534        upsert_nodes(&savepoint, nodes)?;
1535        delete_absent_scan_projections(&savepoint)?;
1536        savepoint.commit()?;
1537        Ok(())
1538    }
1539
1540    /// Upsert a partial scan result without marking unrelated paths absent.
1541    ///
1542    /// # Errors
1543    ///
1544    /// Returns an error if persistence fails.
1545    pub fn upsert_scan_nodes(&mut self, nodes: &[Node]) -> DbResult<()> {
1546        let savepoint = self.validated_savepoint()?;
1547        upsert_nodes(&savepoint, nodes)?;
1548        savepoint.commit()?;
1549        Ok(())
1550    }
1551
1552    /// Mark paths and their descendants absent after filesystem delete events.
1553    ///
1554    /// # Errors
1555    ///
1556    /// Returns an error if persistence fails.
1557    pub fn mark_paths_absent(&mut self, paths: &[String]) -> DbResult<()> {
1558        let savepoint = self.validated_savepoint()?;
1559        let fts_revision = begin_file_text_fts_update(&savepoint)?;
1560        {
1561            let mut mark_nodes = savepoint.prepare_cached(
1562                "UPDATE nodes SET exists_now = 0 WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
1563            )?;
1564            let mut delete_relations = savepoint.prepare_cached(
1565                "DELETE FROM symbol_relations WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
1566            )?;
1567            let mut delete_symbols = savepoint.prepare_cached(
1568                "DELETE FROM symbols WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
1569            )?;
1570            let mut delete_parse_metadata = savepoint.prepare_cached(
1571                "DELETE FROM source_parse_metadata WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
1572            )?;
1573            let mut delete_text_fts = savepoint.prepare_cached(
1574                "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
1575                 SELECT 'delete', rowid, content FROM file_texts \
1576                 WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
1577            )?;
1578            let mut delete_text = savepoint.prepare_cached(
1579                "DELETE FROM file_texts WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
1580            )?;
1581            for path in paths {
1582                if path == "." || path.is_empty() {
1583                    continue;
1584                }
1585                let descendant_pattern = sqlite_descendant_pattern(path);
1586                mark_nodes.execute(params![path, descendant_pattern])?;
1587                delete_relations.execute(params![path, descendant_pattern])?;
1588                delete_symbols.execute(params![path, descendant_pattern])?;
1589                delete_parse_metadata.execute(params![path, descendant_pattern])?;
1590                delete_text_fts.execute(params![path, descendant_pattern])?;
1591                delete_text.execute(params![path, descendant_pattern])?;
1592            }
1593        }
1594        complete_file_text_fts_update(&savepoint, fts_revision)?;
1595        savepoint.commit()?;
1596        Ok(())
1597    }
1598
1599    /// Replace indexed text for scanned file paths.
1600    ///
1601    /// `paths` should contain every file path considered by the scan batch.
1602    /// Existing indexed text for those paths is cleared first so binary,
1603    /// deleted, or no-longer-UTF-8 files cannot leave stale searchable content.
1604    ///
1605    /// # Errors
1606    ///
1607    /// Returns an error if persistence fails.
1608    pub fn replace_file_texts_for_paths<'text>(
1609        &mut self,
1610        paths: &[String],
1611        texts: impl IntoIterator<Item = &'text IndexedFileText>,
1612    ) -> DbResult<()> {
1613        let texts = texts.into_iter().collect::<Vec<_>>();
1614        for text in &texts {
1615            validate_indexed_file_text(text)?;
1616        }
1617        let savepoint = self.validated_savepoint()?;
1618        let fts_revision = begin_file_text_fts_update(&savepoint)?;
1619        {
1620            let mut delete_fts = savepoint.prepare_cached(
1621                "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
1622                 SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
1623            )?;
1624            let mut delete = savepoint.prepare_cached("DELETE FROM file_texts WHERE path = ?1")?;
1625            for path in paths {
1626                delete_fts.execute([path])?;
1627                delete.execute([path])?;
1628            }
1629        }
1630        {
1631            let mut delete_current_fts = savepoint.prepare_cached(
1632                "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
1633                 SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
1634            )?;
1635            let mut upsert = savepoint.prepare_cached(
1636                "
1637                INSERT INTO file_texts(path, content_hash, byte_count, line_count, content, updated_at)
1638                VALUES(?1, ?2, ?3, ?4, ?5, CURRENT_TIMESTAMP)
1639                ON CONFLICT(path) DO UPDATE SET
1640                    content_hash = excluded.content_hash,
1641                    byte_count = excluded.byte_count,
1642                    line_count = excluded.line_count,
1643                    content = excluded.content,
1644                    updated_at = CURRENT_TIMESTAMP
1645                ",
1646            )?;
1647            let mut index = savepoint.prepare_cached(
1648                "INSERT INTO file_text_fts(rowid, content) \
1649                 SELECT rowid, content FROM file_texts WHERE path = ?1",
1650            )?;
1651            for text in texts {
1652                delete_current_fts.execute([&text.path])?;
1653                upsert.execute(params![
1654                    text.path,
1655                    text.content_hash.as_deref(),
1656                    usize_to_i64(text.byte_count),
1657                    usize_to_i64(text.line_count),
1658                    text.content
1659                ])?;
1660                index.execute([&text.path])?;
1661            }
1662        }
1663        complete_file_text_fts_update(&savepoint, fts_revision)?;
1664        savepoint.commit()?;
1665        Ok(())
1666    }
1667
1668    /// Load one indexed text row by repository path.
1669    ///
1670    /// # Errors
1671    ///
1672    /// Returns an error if reading fails or stored counts are invalid.
1673    pub fn load_file_text(&self, path: &str) -> DbResult<Option<IndexedFileText>> {
1674        let mut statement = self.connection.prepare(
1675            "
1676            SELECT path, content_hash, byte_count, line_count, content
1677            FROM file_texts
1678            WHERE path = ?1
1679            ",
1680        )?;
1681        let mut rows = statement.query([path])?;
1682        rows.next()?.map(file_text_from_row).transpose()
1683    }
1684
1685    /// Load a bounded FTS candidate superset for service-owned exact verification.
1686    ///
1687    /// The request accepts only one safe ASCII-alphanumeric token. The MATCH
1688    /// expression is bound as data. Only authoritative metadata is returned;
1689    /// callers hydrate selected paths with [`Self::load_file_text`] after
1690    /// applying their file, byte, and time budgets.
1691    ///
1692    /// # Errors
1693    ///
1694    /// Returns an error when the token shape or limit is unsafe, bounded work
1695    /// is canceled or reaches its deadline, or persisted rows are invalid.
1696    pub fn query_file_text_fts_candidates(
1697        &self,
1698        query: &FileTextFtsQuery<'_>,
1699        control: Option<&IndexWorkControl>,
1700    ) -> DbResult<FileTextFtsPage> {
1701        validate_file_text_fts_token(query.literal_token)?;
1702        if query.limit > MAX_FILE_TEXT_FTS_CANDIDATES {
1703            return Err(DbError::FileTextFtsCandidateLimit {
1704                requested: query.limit,
1705                maximum: MAX_FILE_TEXT_FTS_CANDIDATES,
1706            });
1707        }
1708        let match_expression = format!("\"{}\"", query.literal_token);
1709        let row_limit = usize_to_i64(query.limit.saturating_add(1));
1710        with_file_text_progress(&self.connection, control, || {
1711            let mut candidates = Vec::with_capacity(query.limit.saturating_add(1));
1712            if let Some(path_prefix) = normalized_file_text_path_prefix(query.path_prefix) {
1713                let descendant_pattern = sqlite_descendant_pattern(path_prefix);
1714                let mut statement = self.connection.prepare_cached(
1715                    "
1716                    SELECT
1717                        f.path,
1718                        f.content_hash,
1719                        f.byte_count,
1720                        f.line_count,
1721                        bm25(file_text_fts)
1722                    FROM file_text_fts
1723                    JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
1724                    WHERE file_text_fts MATCH ?1
1725                      AND (f.path = ?2 OR f.path LIKE ?3 ESCAPE '\\')
1726                    ORDER BY bm25(file_text_fts), f.path
1727                    LIMIT ?4
1728                    ",
1729                )?;
1730                let mut rows = statement.query(params![
1731                    match_expression,
1732                    path_prefix,
1733                    descendant_pattern,
1734                    row_limit,
1735                ])?;
1736                collect_file_text_fts_candidates(&mut rows, control, &mut candidates)?;
1737            } else {
1738                let mut statement = self.connection.prepare_cached(
1739                    "
1740                    SELECT
1741                        f.path,
1742                        f.content_hash,
1743                        f.byte_count,
1744                        f.line_count,
1745                        bm25(file_text_fts)
1746                    FROM file_text_fts
1747                    JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
1748                    WHERE file_text_fts MATCH ?1
1749                    ORDER BY bm25(file_text_fts), f.path
1750                    LIMIT ?2
1751                    ",
1752                )?;
1753                let mut rows = statement.query(params![match_expression, row_limit])?;
1754                collect_file_text_fts_candidates(&mut rows, control, &mut candidates)?;
1755            }
1756            let overflow = candidates.len() > query.limit;
1757            candidates.truncate(query.limit);
1758            Ok(FileTextFtsPage {
1759                candidates,
1760                overflow,
1761            })
1762        })
1763    }
1764
1765    /// Visit correctness-authoritative persisted text with predecode admission.
1766    ///
1767    /// `admit` receives path and byte metadata before the source `String` is
1768    /// decoded from `SQLite`. Returning [`FileTextAdmission::Skip`] or
1769    /// [`FileTextAdmission::Stop`] therefore avoids allocating excluded source
1770    /// content. Returning `false` from `visitor` stops after an admitted row.
1771    ///
1772    /// # Errors
1773    ///
1774    /// Returns an error when bounded work is canceled or reaches its deadline,
1775    /// persisted counts are invalid, or either callback returns an error.
1776    pub fn visit_file_texts_for_fallback<A, V>(
1777        &self,
1778        path_prefix: Option<&str>,
1779        control: Option<&IndexWorkControl>,
1780        mut admit: A,
1781        mut visitor: V,
1782    ) -> DbResult<()>
1783    where
1784        A: FnMut(&FileTextMetadata) -> DbResult<FileTextAdmission>,
1785        V: FnMut(IndexedFileText) -> DbResult<bool>,
1786    {
1787        with_file_text_progress(&self.connection, control, || {
1788            let mut content_statement = self
1789                .connection
1790                .prepare_cached(FILE_TEXT_FALLBACK_CONTENT_SQL)?;
1791            if let Some(path_prefix) = normalized_file_text_path_prefix(path_prefix) {
1792                let (descendant_start, descendant_end) = file_text_descendant_range(path_prefix);
1793                let mut statement = self
1794                    .connection
1795                    .prepare_cached(FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL)?;
1796                let mut rows =
1797                    statement.query(params![path_prefix, descendant_start, descendant_end])?;
1798                visit_file_text_fallback_rows(
1799                    &mut rows,
1800                    &mut content_statement,
1801                    control,
1802                    &mut admit,
1803                    &mut visitor,
1804                )
1805            } else {
1806                let mut statement = self
1807                    .connection
1808                    .prepare_cached(FILE_TEXT_FALLBACK_ALL_METADATA_SQL)?;
1809                let mut rows = statement.query([])?;
1810                visit_file_text_fallback_rows(
1811                    &mut rows,
1812                    &mut content_statement,
1813                    control,
1814                    &mut admit,
1815                    &mut visitor,
1816                )
1817            }
1818        })
1819    }
1820
1821    /// Return whether the transactional FTS projection matches authoritative text.
1822    ///
1823    /// This constant-work readiness check is suitable for the search hot path.
1824    /// Explicit settings diagnostics may additionally count projection rows.
1825    ///
1826    /// # Errors
1827    ///
1828    /// Returns an error when durable revision metadata is malformed or partial.
1829    pub fn file_text_fts_ready(&self) -> DbResult<bool> {
1830        Ok(load_file_text_fts_revisions(&self.connection)?
1831            .is_some_and(|(source, projection)| source == projection))
1832    }
1833
1834    /// Report content-free FTS synchronization state for settings/readiness.
1835    ///
1836    /// # Errors
1837    ///
1838    /// Returns an error when authoritative or projection row state cannot be
1839    /// inspected exactly.
1840    pub fn file_text_fts_state(&self) -> DbResult<FileTextFtsState> {
1841        let revision_synchronized = self.file_text_fts_ready()?;
1842        let (source_rows, indexed_rows, synchronized) = self.connection.query_row(
1843            "
1844            SELECT
1845                (SELECT COUNT(*) FROM file_texts),
1846                (SELECT COUNT(*) FROM file_text_fts_docsize),
1847                NOT EXISTS(
1848                    SELECT 1
1849                    FROM file_texts AS f
1850                    LEFT JOIN file_text_fts_docsize AS d ON d.id = f.rowid
1851                    WHERE d.id IS NULL
1852                )
1853                AND NOT EXISTS(
1854                    SELECT 1
1855                    FROM file_text_fts_docsize AS d
1856                    LEFT JOIN file_texts AS f ON f.rowid = d.id
1857                    WHERE f.rowid IS NULL
1858                )
1859            ",
1860            [],
1861            |row| {
1862                Ok((
1863                    row.get::<_, i64>(0)?,
1864                    row.get::<_, i64>(1)?,
1865                    row.get::<_, i64>(2)? != 0,
1866                ))
1867            },
1868        )?;
1869        Ok(FileTextFtsState {
1870            source_rows: count_to_usize("file_texts", source_rows)?,
1871            indexed_rows: count_to_usize("file_text_fts", indexed_rows)?,
1872            synchronized: revision_synchronized && synchronized,
1873        })
1874    }
1875
1876    /// Load indexed text rows for search.
1877    ///
1878    /// When `literal_pattern` is supplied, `SQLite` prefilters candidate files
1879    /// with a substring search before the service performs line-level matching.
1880    /// Regex and fuzzy searches pass `None` and still use the persisted text
1881    /// index instead of reopening source files from disk.
1882    ///
1883    /// # Errors
1884    ///
1885    /// Returns an error if reading fails or stored counts are invalid.
1886    pub fn load_file_texts_for_search(
1887        &self,
1888        literal_pattern: Option<&str>,
1889        case_sensitive: bool,
1890    ) -> DbResult<Vec<IndexedFileText>> {
1891        let mut texts = Vec::new();
1892        self.visit_file_texts_for_search(literal_pattern, case_sensitive, |text| {
1893            texts.push(text);
1894            Ok(true)
1895        })?;
1896        Ok(texts)
1897    }
1898
1899    /// Visit indexed text rows for search without materializing all rows.
1900    ///
1901    /// When `literal_pattern` is supplied, `SQLite` prefilters candidate files
1902    /// with a substring search before the service performs line-level matching.
1903    /// Returning `false` from `visitor` stops iteration early.
1904    ///
1905    /// # Errors
1906    ///
1907    /// Returns an error if reading fails, stored counts are invalid, or the
1908    /// visitor returns an error.
1909    pub fn visit_file_texts_for_search<F>(
1910        &self,
1911        literal_pattern: Option<&str>,
1912        case_sensitive: bool,
1913        mut visitor: F,
1914    ) -> DbResult<()>
1915    where
1916        F: FnMut(IndexedFileText) -> DbResult<bool>,
1917    {
1918        if let Some(pattern) = literal_pattern.filter(|pattern| !pattern.is_empty()) {
1919            if case_sensitive {
1920                let mut statement = self.connection.prepare(
1921                    "
1922                    SELECT path, content_hash, byte_count, line_count, content
1923                    FROM file_texts
1924                    WHERE instr(content, ?1) > 0
1925                    ORDER BY path
1926                    ",
1927                )?;
1928                let mut rows = statement.query([pattern])?;
1929                while let Some(row) = rows.next()? {
1930                    if !visitor(file_text_from_row(row)?)? {
1931                        return Ok(());
1932                    }
1933                }
1934            } else {
1935                let pattern = pattern.to_ascii_lowercase();
1936                let mut statement = self.connection.prepare(
1937                    "
1938                    SELECT path, content_hash, byte_count, line_count, content
1939                    FROM file_texts
1940                    WHERE instr(lower(content), ?1) > 0
1941                    ORDER BY path
1942                    ",
1943                )?;
1944                let mut rows = statement.query([pattern])?;
1945                while let Some(row) = rows.next()? {
1946                    if !visitor(file_text_from_row(row)?)? {
1947                        return Ok(());
1948                    }
1949                }
1950            }
1951        } else {
1952            let mut statement = self.connection.prepare(
1953                "
1954                SELECT path, content_hash, byte_count, line_count, content
1955                FROM file_texts
1956                ORDER BY path
1957                ",
1958            )?;
1959            let mut rows = statement.query([])?;
1960            while let Some(row) = rows.next()? {
1961                if !visitor(file_text_from_row(row)?)? {
1962                    return Ok(());
1963                }
1964            }
1965        }
1966        Ok(())
1967    }
1968
1969    /// Count files with persisted UTF-8 text for indexed search.
1970    ///
1971    /// # Errors
1972    ///
1973    /// Returns an error if reading fails.
1974    pub fn file_text_count(&self) -> DbResult<usize> {
1975        let count = self
1976            .connection
1977            .query_row("SELECT COUNT(*) FROM file_texts", [], |row| {
1978                row.get::<_, i64>(0)
1979            })?;
1980        count_to_usize("file_texts", count)
1981    }
1982
1983    /// Sum persisted UTF-8 source bytes used by indexed search.
1984    ///
1985    /// # Errors
1986    ///
1987    /// Returns an error if reading fails.
1988    pub fn file_text_byte_count(&self) -> DbResult<usize> {
1989        let count = self.connection.query_row(
1990            "SELECT COALESCE(SUM(byte_count), 0) FROM file_texts",
1991            [],
1992            |row| row.get::<_, i64>(0),
1993        )?;
1994        count_to_usize("file_text_bytes", count)
1995    }
1996
1997    /// Persist the canonical filesystem root for indexed repository files.
1998    ///
1999    /// # Errors
2000    ///
2001    /// Returns an error if persistence fails.
2002    pub fn set_project_root(&mut self, root: &Path) -> DbResult<()> {
2003        let value = normalize_metadata_path(root);
2004        let previous_identity = self.validated_project_instance_id;
2005        let savepoint = self.validated_savepoint()?;
2006        if let Some(found) = savepoint
2007            .query_row(
2008                "SELECT value FROM metadata WHERE key = ?1",
2009                [PROJECT_ROOT_KEY],
2010                |row| row.get::<_, String>(0),
2011            )
2012            .optional()?
2013        {
2014            if found == value {
2015                let (identity, identity_changed) =
2016                    project_identity::ensure_project_identity(&savepoint)?;
2017                savepoint.commit()?;
2018                self.validated_project_root = Some(value);
2019                self.validated_project_instance_id = Some(identity);
2020                if identity_changed {
2021                    self.library_usage_instances.get_mut().clear();
2022                }
2023                return Ok(());
2024            }
2025            return Err(DbError::ProjectRootMismatch {
2026                expected: value,
2027                found,
2028            });
2029        }
2030        set_metadata(&savepoint, PROJECT_ROOT_KEY, &value)?;
2031        let (identity, _) = project_identity::ensure_project_identity(&savepoint)?;
2032        let identity_changed = previous_identity != Some(identity);
2033        savepoint.commit()?;
2034        self.validated_project_root = Some(value);
2035        self.validated_project_instance_id = Some(identity);
2036        if identity_changed {
2037            self.library_usage_instances.get_mut().clear();
2038        }
2039        Ok(())
2040    }
2041
2042    /// Load the canonical filesystem root for indexed repository files.
2043    ///
2044    /// # Errors
2045    ///
2046    /// Returns an error if reading fails.
2047    pub fn project_root(&self) -> DbResult<Option<String>> {
2048        self.connection
2049            .query_row(
2050                "SELECT value FROM metadata WHERE key = ?1",
2051                [PROJECT_ROOT_KEY],
2052                |row| row.get::<_, String>(0),
2053            )
2054            .optional()
2055            .map_err(DbError::from)
2056    }
2057
2058    /// Return the project binding captured when this store was validated.
2059    ///
2060    /// This accessor performs no database read. It is intended for services
2061    /// that must bind a result to the exact root and identity selected at open.
2062    ///
2063    /// # Errors
2064    ///
2065    /// Returns an error when the store has no complete project binding.
2066    pub fn captured_project_binding(&self) -> DbResult<CapturedProjectBinding> {
2067        Ok(CapturedProjectBinding {
2068            project_instance_id: self
2069                .validated_project_instance_id
2070                .ok_or(DbError::ProjectInstanceIdentityMissing)?,
2071            project_root: self
2072                .validated_project_root
2073                .clone()
2074                .ok_or(DbError::ProjectRootMissing)?,
2075        })
2076    }
2077
2078    /// Revalidate the captured binding against a fresh database snapshot.
2079    ///
2080    /// File-backed stores open an independent read-only snapshot so a report
2081    /// connection pinned to an older WAL end mark cannot hide a concurrent
2082    /// same-root identity rotation. In-memory stores validate their only
2083    /// connection because no external database binding exists.
2084    ///
2085    /// # Errors
2086    ///
2087    /// Returns an error when the database, root, identity, schema, or
2088    /// filesystem binding no longer matches the state captured at open.
2089    pub fn revalidate_captured_project_binding(&self) -> DbResult<()> {
2090        let binding = self.captured_project_binding()?;
2091        let Some(path) = self.database_path.as_deref() else {
2092            return schema::validate_active_binding(
2093                &self.connection,
2094                Some(&binding.project_root),
2095                Some(binding.project_instance_id),
2096            );
2097        };
2098        let (connection, _) = schema::open_current_read_only(path, Some(&binding.project_root))?;
2099        schema::validate_active_binding(
2100            &connection,
2101            Some(&binding.project_root),
2102            Some(binding.project_instance_id),
2103        )
2104    }
2105
2106    /// Read the selected project identity and current purpose-work generation.
2107    fn purpose_curation_context(
2108        &self,
2109        connection: &Connection,
2110    ) -> DbResult<(ProjectInstanceId, IndexGeneration)> {
2111        let selected = self
2112            .validated_project_instance_id
2113            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
2114        let current = project_identity::load_project_identity(connection)?
2115            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
2116        if current != selected {
2117            return Err(DbError::GraphProjectIdentityMismatch {
2118                expected: selected.to_string(),
2119                found: current.to_string(),
2120            });
2121        }
2122        let generation =
2123            project_identity::load_graph_generation(connection)?.unwrap_or(IndexGeneration::ZERO);
2124        Ok((current, generation))
2125    }
2126
2127    /// Begin one exclusive full derived-index publication.
2128    ///
2129    /// Every nested projection write remains inside the returned guard's
2130    /// `SQLite` transaction. Other connections keep the prior complete
2131    /// generation queryable until [`IndexPublicationGuard::complete`] commits.
2132    ///
2133    /// # Errors
2134    ///
2135    /// Returns an error if the exclusive write transaction cannot begin.
2136    pub fn begin_index_publication(
2137        &mut self,
2138        contract_fingerprint: &str,
2139    ) -> DbResult<IndexPublicationGuard<'_>> {
2140        let base_generation = self
2141            .index_publication()?
2142            .map_or(IndexGeneration::ZERO, |publication| publication.generation);
2143        self.begin_index_publication_from(contract_fingerprint, base_generation)
2144    }
2145
2146    /// Begin one exclusive full derived-index publication only when its
2147    /// prepared base generation is still current.
2148    ///
2149    /// [`IndexGeneration::ZERO`] matches only an uninitialized store. The
2150    /// generation comparison occurs after `BEGIN IMMEDIATE` and before any
2151    /// publication metadata or projection row is changed.
2152    ///
2153    /// # Errors
2154    ///
2155    /// Returns an error if the exclusive write transaction cannot begin or
2156    /// another publisher completed a newer generation after work was prepared.
2157    pub fn begin_index_publication_from(
2158        &mut self,
2159        contract_fingerprint: &str,
2160        expected_base_generation: IndexGeneration,
2161    ) -> DbResult<IndexPublicationGuard<'_>> {
2162        self.begin_publication(
2163            PublicationContract::Full(contract_fingerprint.to_string()),
2164            Some(expected_base_generation),
2165        )
2166    }
2167
2168    /// Begin one exclusive symbol/projection refresh without replacing the
2169    /// established full-index contract.
2170    ///
2171    /// # Errors
2172    ///
2173    /// Returns an error if the index is incomplete, the established contract
2174    /// differs, or the exclusive write transaction cannot begin.
2175    pub fn begin_index_projection_refresh(
2176        &mut self,
2177        contract_fingerprint: &str,
2178    ) -> DbResult<IndexPublicationGuard<'_>> {
2179        let base_generation = self
2180            .index_publication()?
2181            .map_or(IndexGeneration::ZERO, |publication| publication.generation);
2182        self.begin_index_projection_refresh_from(contract_fingerprint, base_generation)
2183    }
2184
2185    /// Begin one exclusive symbol/projection refresh only when its prepared
2186    /// base generation is still current.
2187    ///
2188    /// # Errors
2189    ///
2190    /// Returns an error if the index is incomplete, the established contract
2191    /// differs, the base generation changed, or the exclusive write
2192    /// transaction cannot begin.
2193    pub fn begin_index_projection_refresh_from(
2194        &mut self,
2195        contract_fingerprint: &str,
2196        expected_base_generation: IndexGeneration,
2197    ) -> DbResult<IndexPublicationGuard<'_>> {
2198        self.begin_publication(
2199            PublicationContract::Projection(contract_fingerprint.to_string()),
2200            Some(expected_base_generation),
2201        )
2202    }
2203
2204    /// Begin one parent-owned atomic publication transaction.
2205    fn begin_publication(
2206        &mut self,
2207        contract: PublicationContract,
2208        expected_base_generation: Option<IndexGeneration>,
2209    ) -> DbResult<IndexPublicationGuard<'_>> {
2210        begin_immediate_publication(&self.connection)?;
2211        let setup = (|| {
2212            schema::validate_active_binding(
2213                &self.connection,
2214                self.validated_project_root.as_deref(),
2215                self.validated_project_instance_id,
2216            )?;
2217            let previous = load_index_publication(&self.connection)?;
2218            if let Some(expected) = expected_base_generation {
2219                let base_matches = match previous.as_ref() {
2220                    None => expected == IndexGeneration::ZERO,
2221                    Some(publication) => {
2222                        publication.state == IndexPublicationState::Complete
2223                            && publication.generation != IndexGeneration::ZERO
2224                            && publication.generation == expected
2225                    }
2226                };
2227                if !base_matches {
2228                    return Err(DbError::PublicationBaseGenerationChanged {
2229                        expected,
2230                        found: previous
2231                            .as_ref()
2232                            .map_or(IndexGeneration::ZERO, |publication| publication.generation),
2233                    });
2234                }
2235            }
2236            if let PublicationContract::Projection(expected) = &contract {
2237                let matches = previous.as_ref().is_some_and(|publication| {
2238                    publication.state == IndexPublicationState::Complete
2239                        && publication.contract_fingerprint.as_deref() == Some(expected.as_str())
2240                });
2241                if !matches {
2242                    return Err(DbError::PublicationContractChanged);
2243                }
2244            }
2245            set_metadata(
2246                &self.connection,
2247                INDEX_PUBLICATION_STATE_KEY,
2248                IndexPublicationState::Updating.as_str(),
2249            )?;
2250            Ok(previous.map_or(IndexGeneration::ZERO, |publication| publication.generation))
2251        })();
2252        let previous_generation = match setup {
2253            Ok(generation) => generation,
2254            Err(error) => {
2255                self.connection.execute_batch("ROLLBACK")?;
2256                return Err(error);
2257            }
2258        };
2259        Ok(IndexPublicationGuard {
2260            store: self,
2261            contract,
2262            previous_generation,
2263            scan_replacement_pending: false,
2264            active: true,
2265        })
2266    }
2267
2268    /// Start one stable read snapshot for freshness verification and every
2269    /// subsequent query used to construct the response.
2270    ///
2271    /// # Errors
2272    ///
2273    /// Returns an error if a snapshot is already active or `SQLite` cannot
2274    /// begin the transaction.
2275    pub fn begin_index_read_snapshot(&self) -> DbResult<()> {
2276        if self.read_snapshot_active.replace(true) {
2277            return Err(DbError::IndexReadSnapshotActive);
2278        }
2279        if let Err(error) = self.connection.execute_batch("BEGIN DEFERRED") {
2280            self.read_snapshot_active.set(false);
2281            return Err(error.into());
2282        }
2283        Ok(())
2284    }
2285
2286    /// Finish an active read snapshot before an optional telemetry write.
2287    ///
2288    /// This method is a no-op for stores that were not opened for a normal
2289    /// freshness-verified read.
2290    ///
2291    /// # Errors
2292    ///
2293    /// Returns an error if `SQLite` cannot finish the read transaction.
2294    pub fn finish_index_read_snapshot(&self) -> DbResult<()> {
2295        if !self.read_snapshot_active.get() {
2296            return Ok(());
2297        }
2298        self.connection.execute_batch("COMMIT")?;
2299        self.read_snapshot_active.set(false);
2300        Ok(())
2301    }
2302
2303    /// Load the current derived-index publication state when initialized.
2304    ///
2305    /// # Errors
2306    ///
2307    /// Returns an error if metadata is invalid or cannot be read.
2308    pub fn index_publication(&self) -> DbResult<Option<IndexPublication>> {
2309        load_index_publication(&self.connection)
2310    }
2311
2312    /// Replace the symbol graph for a file path.
2313    ///
2314    /// # Errors
2315    ///
2316    /// Returns an error if persistence fails.
2317    pub fn replace_symbol_graph(&mut self, graph: &SymbolGraph) -> DbResult<()> {
2318        let metadata = SourceParseMetadata::from_graph(graph);
2319        self.replace_symbol_graph_with_metadata(graph, &metadata)
2320    }
2321
2322    /// Replace one file's symbol graph while preserving independent source parse metadata.
2323    ///
2324    /// This permits a grammar-backed source parse to coexist with conservative fallback
2325    /// symbol and relationship facts without relabeling those facts as grammar-native.
2326    ///
2327    /// # Errors
2328    ///
2329    /// Returns an error if metadata identity/counts differ from the graph or persistence fails.
2330    pub fn replace_symbol_graph_with_metadata(
2331        &mut self,
2332        graph: &SymbolGraph,
2333        metadata: &SourceParseMetadata,
2334    ) -> DbResult<()> {
2335        if metadata.path != graph.path
2336            || metadata.language != graph.language
2337            || metadata.symbol_count != graph.symbols.len()
2338            || metadata.relation_count != graph.relations.len()
2339        {
2340            return Err(DbError::SymbolGraphRowShape {
2341                path: graph.path.clone(),
2342                reason: "source parse metadata identity or fact counts differ from the graph",
2343            });
2344        }
2345        let savepoint = self.validated_savepoint()?;
2346        let node_id = {
2347            let mut delete_symbols =
2348                savepoint.prepare_cached("DELETE FROM symbols WHERE path = ?1")?;
2349            let mut delete_relations =
2350                savepoint.prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?;
2351            delete_symbols.execute([&graph.path])?;
2352            delete_relations.execute([&graph.path])?;
2353
2354            let mut upsert_metadata = savepoint.prepare_cached(
2355                "
2356                INSERT INTO source_parse_metadata(
2357                    path,
2358                    language,
2359                    source_parser,
2360                    fact_parser,
2361                    symbol_count,
2362                    relation_count,
2363                    updated_at
2364                )
2365                VALUES(?1, ?2, ?3, ?4, ?5, ?6, CURRENT_TIMESTAMP)
2366                ON CONFLICT(path) DO UPDATE SET
2367                    language = excluded.language,
2368                    source_parser = excluded.source_parser,
2369                    fact_parser = excluded.fact_parser,
2370                    symbol_count = excluded.symbol_count,
2371                    relation_count = excluded.relation_count,
2372                    updated_at = CURRENT_TIMESTAMP
2373                ",
2374            )?;
2375            upsert_metadata.execute(params![
2376                metadata.path,
2377                metadata.language.as_deref(),
2378                metadata.parser.to_string(),
2379                graph.parser.to_string(),
2380                usize_to_i64(metadata.symbol_count),
2381                usize_to_i64(metadata.relation_count),
2382            ])?;
2383            let mut select_node = savepoint
2384                .prepare_cached("SELECT id FROM nodes WHERE path = ?1 AND exists_now = 1")?;
2385            let node_id = select_node
2386                .query_row([&graph.path], |row| row.get::<_, i64>(0))
2387                .optional()?;
2388
2389            let mut insert_symbol = savepoint.prepare_cached(
2390                "
2391                INSERT INTO symbols(
2392                    path,
2393                    language,
2394                    name,
2395                    kind,
2396                    signature,
2397                    exported,
2398                    documentation,
2399                    line_start,
2400                    line_end,
2401                    parent,
2402                    parser,
2403                    detail
2404                )
2405                VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
2406                ",
2407            )?;
2408            for symbol in &graph.symbols {
2409                insert_symbol.execute(params![
2410                    symbol.path,
2411                    symbol.language.as_deref(),
2412                    symbol.name,
2413                    symbol.kind.to_string(),
2414                    symbol.signature,
2415                    symbol.exported,
2416                    symbol.documentation.as_deref(),
2417                    usize_to_i64(symbol.line_start),
2418                    usize_to_i64(symbol.line_end),
2419                    symbol.parent.as_deref(),
2420                    symbol.parser.to_string(),
2421                    symbol.detail.as_deref(),
2422                ])?;
2423            }
2424
2425            let mut insert_relation = savepoint.prepare_cached(
2426                "
2427                INSERT INTO symbol_relations(
2428                    path,
2429                    source_name,
2430                    target_name,
2431                    kind,
2432                    line,
2433                    context,
2434                    parser
2435                )
2436                VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)
2437                ",
2438            )?;
2439            for relation in &graph.relations {
2440                insert_relation.execute(params![
2441                    relation.path,
2442                    relation.source_name,
2443                    relation.target_name,
2444                    relation.kind.to_string(),
2445                    usize_to_i64(relation.line),
2446                    relation.context,
2447                    relation.parser.to_string(),
2448                ])?;
2449            }
2450            node_id
2451        };
2452        if let Some(node_id) = node_id {
2453            replace_symbol_search_summary(
2454                &savepoint,
2455                node_id,
2456                symbol_search_summary(graph).as_deref(),
2457            )?;
2458        }
2459        savepoint.commit()?;
2460        Ok(())
2461    }
2462
2463    /// Clear source-derived intelligence for one live file path.
2464    ///
2465    /// This removes symbols, relations, and the node-level content summary so
2466    /// skipped or failed parser work cannot leave stale source facts visible.
2467    ///
2468    /// # Errors
2469    ///
2470    /// Returns an error if the path does not exist or persistence fails.
2471    pub fn clear_source_index_for_path(&self, path: &str) -> DbResult<()> {
2472        self.with_validated_write(|connection| {
2473            let node_id = self.node_id_for_path(path)?;
2474            connection
2475                .prepare_cached("DELETE FROM symbols WHERE path = ?1")?
2476                .execute([path])?;
2477            connection
2478                .prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?
2479                .execute([path])?;
2480            connection
2481                .prepare_cached("DELETE FROM source_parse_metadata WHERE path = ?1")?
2482                .execute([path])?;
2483            connection
2484                .prepare_cached(
2485                    "
2486            DELETE FROM summaries
2487            WHERE node_id = ?1
2488              AND (
2489                    (summary_level = 'node' AND subject = '')
2490                    OR (summary_level = 'search' AND subject = 'symbols')
2491                  )
2492            ",
2493                )?
2494                .execute([node_id])?;
2495            Ok(())
2496        })
2497    }
2498
2499    /// Clear symbols and relations for one live file path while preserving node summaries.
2500    ///
2501    /// # Errors
2502    ///
2503    /// Returns an error if persistence fails.
2504    pub fn clear_symbol_graph_for_path(&self, path: &str) -> DbResult<()> {
2505        self.with_validated_write(|connection| {
2506            let node_id = self.node_id_for_path(path)?;
2507            connection
2508                .prepare_cached("DELETE FROM symbols WHERE path = ?1")?
2509                .execute([path])?;
2510            connection
2511                .prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?
2512                .execute([path])?;
2513            connection
2514                .prepare_cached("DELETE FROM source_parse_metadata WHERE path = ?1")?
2515                .execute([path])?;
2516            connection
2517                .prepare_cached(
2518                    "
2519            DELETE FROM summaries
2520            WHERE node_id = ?1
2521              AND summary_level = 'search'
2522              AND subject = 'symbols'
2523            ",
2524                )?
2525                .execute([node_id])?;
2526            Ok(())
2527        })
2528    }
2529
2530    /// Persist an observed one-line summary for an indexed node.
2531    ///
2532    /// # Errors
2533    ///
2534    /// Returns an error if the path does not exist or persistence fails.
2535    pub fn set_node_summary(&self, path: &str, summary: &str) -> DbResult<()> {
2536        self.with_validated_write(|connection| {
2537            let node_id = self.node_id_for_path(path)?;
2538            connection
2539                .prepare_cached(
2540                    "
2541            INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
2542            VALUES(?1, 'node', '', ?2, CURRENT_TIMESTAMP)
2543            ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
2544                summary_level = 'node',
2545                subject = '',
2546                summary = excluded.summary,
2547                updated_at = CURRENT_TIMESTAMP
2548            ",
2549                )?
2550                .execute(params![node_id, summary])?;
2551            Ok(())
2552        })
2553    }
2554
2555    /// Remove the observed node-level summary for an indexed node.
2556    ///
2557    /// # Errors
2558    ///
2559    /// Returns an error if the path does not exist or persistence fails.
2560    pub fn clear_node_summary(&self, path: &str) -> DbResult<()> {
2561        self.with_validated_write(|connection| {
2562            let node_id = self.node_id_for_path(path)?;
2563            connection
2564                .prepare_cached(
2565                    "
2566            DELETE FROM summaries
2567            WHERE node_id = ?1
2568              AND summary_level = 'node'
2569              AND subject = ''
2570            ",
2571                )?
2572                .execute([node_id])?;
2573            Ok(())
2574        })
2575    }
2576
2577    /// Load symbols filtered by optional file path and query.
2578    ///
2579    /// # Errors
2580    ///
2581    /// Returns an error if reading fails.
2582    pub fn load_symbols(
2583        &self,
2584        file: Option<&str>,
2585        query: Option<&str>,
2586        limit: usize,
2587    ) -> DbResult<Vec<CodeSymbol>> {
2588        let max_rows = usize_to_i64(limit.max(1));
2589        match (file, query) {
2590            (Some(file), Some(query)) => self.query_symbols(
2591                "
2592                SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation
2593                FROM symbols
2594                WHERE path = ?1 AND (name LIKE ?2 OR signature LIKE ?2 OR documentation LIKE ?2)
2595                ORDER BY path, line_start, name
2596                LIMIT ?3
2597                ",
2598                params![file, like_query(query), max_rows],
2599            ),
2600            (Some(file), None) => self.query_symbols(
2601                "
2602                SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation
2603                FROM symbols
2604                WHERE path = ?1
2605                ORDER BY path, line_start, name
2606                LIMIT ?2
2607                ",
2608                params![file, max_rows],
2609            ),
2610            (None, Some(query)) => self.query_symbols(
2611                "
2612                SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation
2613                FROM symbols
2614                WHERE name LIKE ?1 OR signature LIKE ?1 OR documentation LIKE ?1 OR path LIKE ?1
2615                ORDER BY path, line_start, name
2616                LIMIT ?2
2617                ",
2618                params![like_query(query), max_rows],
2619            ),
2620            (None, None) => self.query_symbols(
2621                "
2622                SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation
2623                FROM symbols
2624                ORDER BY path, line_start, name
2625                LIMIT ?1
2626                ",
2627                params![max_rows],
2628            ),
2629        }
2630    }
2631
2632    /// Load one bounded deterministic symbol set for exact repository paths.
2633    ///
2634    /// The request uses indexed `symbols.path` predicates, chunks bindings
2635    /// below the `SQLite` variable ceiling, and preserves the store's active
2636    /// read snapshot. The service remains responsible for matching exact
2637    /// declaration identities within these admitted owning paths.
2638    ///
2639    /// # Errors
2640    ///
2641    /// Returns an error when a path/budget is invalid, cancellation or the
2642    /// request deadline fires, a row is corrupt, or `SQLite` iteration fails.
2643    pub fn load_symbols_for_paths_bounded(
2644        &self,
2645        paths: &[String],
2646        budget: SymbolBatchReadBudget,
2647        control: Option<&IndexWorkControl>,
2648    ) -> DbResult<SymbolBatchRead> {
2649        let path_limit =
2650            usize::try_from(budget.paths()).map_err(|source| DbError::InvalidCount {
2651                field: "symbol batch paths",
2652                value: i64::from(budget.paths()),
2653                source,
2654            })?;
2655        let mut exact_paths = BTreeSet::new();
2656        let mut reached_limit = None;
2657        for path in paths {
2658            if let Some(control) = control {
2659                control.check(IndexWorkStage::RepositoryTraversal)?;
2660            }
2661            exact_paths.insert(path.clone());
2662            if exact_paths.len() > path_limit {
2663                exact_paths.pop_last();
2664                reached_limit.get_or_insert(SymbolBatchReadLimit::Paths);
2665            }
2666        }
2667        if exact_paths.is_empty() {
2668            return Ok(SymbolBatchRead::default());
2669        }
2670        let exact_paths = exact_paths.into_iter().collect::<Vec<_>>();
2671        let row_limit = usize::try_from(budget.rows()).map_err(|source| DbError::InvalidCount {
2672            field: "symbol batch rows",
2673            value: i64::from(budget.rows()),
2674            source,
2675        })?;
2676        let mut symbols = Vec::new();
2677        let mut decoded_bytes = 0_u64;
2678        'chunks: for chunk in exact_paths.chunks(SYMBOL_BATCH_BIND_PATHS) {
2679            if let Some(control) = control {
2680                control.check(IndexWorkStage::RepositoryTraversal)?;
2681            }
2682            let remaining_rows = row_limit.saturating_sub(symbols.len());
2683            if remaining_rows == 0 {
2684                reached_limit.get_or_insert(SymbolBatchReadLimit::Rows);
2685                break;
2686            }
2687            let placeholders = numbered_placeholders(1, chunk.len());
2688            let limit_parameter = chunk.len().saturating_add(1);
2689            let sql = format!(
2690                "
2691                SELECT
2692                    path,
2693                    language,
2694                    name,
2695                    kind,
2696                    signature,
2697                    line_start,
2698                    line_end,
2699                    parent,
2700                    parser,
2701                    detail,
2702                    exported,
2703                    documentation,
2704                    length(CAST(path AS BLOB))
2705                        + COALESCE(length(CAST(language AS BLOB)), 0)
2706                        + length(CAST(name AS BLOB))
2707                        + length(CAST(signature AS BLOB))
2708                        + COALESCE(length(CAST(parent AS BLOB)), 0)
2709                        + COALESCE(length(CAST(detail AS BLOB)), 0)
2710                        + COALESCE(length(CAST(documentation AS BLOB)), 0)
2711                FROM symbols
2712                WHERE path IN ({placeholders})
2713                ORDER BY path, line_start, name
2714                LIMIT ?{limit_parameter}
2715                "
2716            );
2717            let mut bindings = chunk.iter().cloned().map(Value::Text).collect::<Vec<_>>();
2718            bindings.push(Value::Integer(usize_to_i64(
2719                remaining_rows.saturating_add(1),
2720            )));
2721            with_sqlite_read_progress(
2722                &self.connection,
2723                control,
2724                IndexWorkStage::RepositoryTraversal,
2725                || {
2726                    let mut statement = self.connection.prepare(&sql)?;
2727                    let mut rows = statement.query(params_from_iter(bindings.iter()))?;
2728                    while let Some(row) = rows.next()? {
2729                        if let Some(control) = control {
2730                            control.check(IndexWorkStage::RepositoryTraversal)?;
2731                        }
2732                        if symbols.len() >= row_limit {
2733                            reached_limit.get_or_insert(SymbolBatchReadLimit::Rows);
2734                            break;
2735                        }
2736                        let preflight_bytes = code_symbol_preflight_bytes(row)?;
2737                        if decoded_bytes.saturating_add(preflight_bytes) > budget.decoded_bytes() {
2738                            reached_limit.get_or_insert(SymbolBatchReadLimit::DecodedBytes);
2739                            break;
2740                        }
2741                        let symbol = code_symbol_from_row(row)?;
2742                        let row_bytes = code_symbol_decoded_bytes(&symbol)?;
2743                        if decoded_bytes.saturating_add(row_bytes) > budget.decoded_bytes() {
2744                            reached_limit.get_or_insert(SymbolBatchReadLimit::DecodedBytes);
2745                            break;
2746                        }
2747                        decoded_bytes = decoded_bytes.saturating_add(row_bytes);
2748                        symbols.push(symbol);
2749                    }
2750                    Ok(())
2751                },
2752            )?;
2753            if matches!(
2754                reached_limit,
2755                Some(SymbolBatchReadLimit::Rows | SymbolBatchReadLimit::DecodedBytes)
2756            ) {
2757                break 'chunks;
2758            }
2759        }
2760        symbols.sort_by(|left, right| {
2761            (&left.path, left.line_start, &left.name, &left.signature).cmp(&(
2762                &right.path,
2763                right.line_start,
2764                &right.name,
2765                &right.signature,
2766            ))
2767        });
2768        let symbols = symbols.into_boxed_slice().into_vec();
2769        Ok(SymbolBatchRead {
2770            work: SymbolBatchReadWork {
2771                requested_paths: u32::try_from(exact_paths.len()).unwrap_or(u32::MAX),
2772                returned_rows: u32::try_from(symbols.len()).unwrap_or(u32::MAX),
2773                decoded_bytes,
2774            },
2775            rows: symbols,
2776            truncated: reached_limit.is_some(),
2777            reached_limit,
2778        })
2779    }
2780
2781    /// Load symbols for a file and one or more exact kinds.
2782    ///
2783    /// # Errors
2784    ///
2785    /// Returns an error if reading fails.
2786    pub fn load_symbols_by_kinds(
2787        &self,
2788        file: &str,
2789        kinds: &[SymbolKind],
2790        limit: usize,
2791    ) -> DbResult<Vec<CodeSymbol>> {
2792        if kinds.is_empty() {
2793            return Ok(Vec::new());
2794        }
2795        let max_rows = usize_to_i64(limit.max(1));
2796        let placeholders = numbered_placeholders(2, kinds.len());
2797        let sql = format!(
2798            "
2799            SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation
2800            FROM symbols INDEXED BY idx_symbols_path
2801            WHERE path = ?1 AND kind IN ({placeholders})
2802            ORDER BY path, line_start, name
2803            LIMIT {max_rows}
2804            "
2805        );
2806        let mut values = Vec::with_capacity(kinds.len() + 1);
2807        values.push(file.to_string());
2808        values.extend(kinds.iter().map(ToString::to_string));
2809        self.query_symbols(&sql, params_from_iter(values.iter()))
2810    }
2811
2812    /// Count symbols for a file and one or more exact kinds.
2813    ///
2814    /// # Errors
2815    ///
2816    /// Returns an error if reading fails.
2817    pub fn count_symbols_by_kinds(&self, file: &str, kinds: &[SymbolKind]) -> DbResult<usize> {
2818        if kinds.is_empty() {
2819            return Ok(0);
2820        }
2821        let placeholders = numbered_placeholders(2, kinds.len());
2822        let sql = format!(
2823            "SELECT COUNT(*) FROM symbols INDEXED BY idx_symbols_path
2824             WHERE path = ?1 AND kind IN ({placeholders})"
2825        );
2826        let mut values = Vec::with_capacity(kinds.len() + 1);
2827        values.push(file.to_string());
2828        values.extend(kinds.iter().map(ToString::to_string));
2829        let count = self
2830            .connection
2831            .query_row(&sql, params_from_iter(values.iter()), |row| {
2832                row.get::<_, i64>(0)
2833            })?;
2834        Ok(i64_to_usize(count))
2835    }
2836
2837    /// Count indexed symbols grouped by exact name.
2838    ///
2839    /// # Errors
2840    ///
2841    /// Returns an error if reading fails.
2842    pub fn symbol_name_counts(&self, names: &[String]) -> DbResult<HashMap<String, usize>> {
2843        if names.is_empty() {
2844            return Ok(HashMap::new());
2845        }
2846        let placeholders = numbered_placeholders(1, names.len());
2847        let sql = format!(
2848            "SELECT name, COUNT(*) FROM symbols WHERE name IN ({placeholders}) GROUP BY name"
2849        );
2850        let mut statement = self.connection.prepare(&sql)?;
2851        let rows = statement.query_map(params_from_iter(names.iter()), |row| {
2852            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
2853        })?;
2854        let mut counts = HashMap::new();
2855        for row in rows {
2856            let (name, count) = row?;
2857            counts.insert(name, i64_to_usize(count));
2858        }
2859        Ok(counts)
2860    }
2861
2862    /// Load symbols with exact names.
2863    ///
2864    /// # Errors
2865    ///
2866    /// Returns an error if reading fails.
2867    pub fn load_symbols_by_names(&self, names: &[String]) -> DbResult<Vec<CodeSymbol>> {
2868        if names.is_empty() {
2869            return Ok(Vec::new());
2870        }
2871        let placeholders = numbered_placeholders(1, names.len());
2872        let sql = format!(
2873            "
2874            SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation
2875            FROM symbols
2876            WHERE name IN ({placeholders})
2877            ORDER BY path, line_start, name
2878            "
2879        );
2880        self.query_symbols(&sql, params_from_iter(names.iter()))
2881    }
2882
2883    /// Load exported symbol names for one file.
2884    ///
2885    /// # Errors
2886    ///
2887    /// Returns an error if reading fails.
2888    pub fn load_exported_symbol_names_for_path(
2889        &self,
2890        file: &str,
2891        limit: usize,
2892    ) -> DbResult<Vec<String>> {
2893        let max_rows = usize_to_i64(limit.max(1));
2894        let mut statement = self.connection.prepare(
2895            "
2896            SELECT DISTINCT name
2897            FROM symbols
2898            WHERE path = ?1 AND exported = 1
2899            ORDER BY name
2900            LIMIT ?2
2901            ",
2902        )?;
2903        let rows = statement.query_map(params![file, max_rows], |row| row.get::<_, String>(0))?;
2904        let mut names = Vec::new();
2905        for row in rows {
2906            names.push(row?);
2907        }
2908        Ok(names)
2909    }
2910
2911    /// Count exported symbol names for one file.
2912    ///
2913    /// # Errors
2914    ///
2915    /// Returns an error if reading fails.
2916    pub fn exported_symbol_count_for_path(&self, file: &str) -> DbResult<usize> {
2917        let count = self.connection.query_row(
2918            "SELECT COUNT(DISTINCT name) FROM symbols WHERE path = ?1 AND exported = 1",
2919            [file],
2920            |row| row.get::<_, i64>(0),
2921        )?;
2922        Ok(i64_to_usize(count))
2923    }
2924
2925    /// Load one symbol by exact file and name.
2926    ///
2927    /// # Errors
2928    ///
2929    /// Returns an error if reading fails.
2930    pub fn load_symbol_by_name(&self, file: &str, name: &str) -> DbResult<Option<CodeSymbol>> {
2931        let mut symbols = self.load_symbols(Some(file), Some(name), 100)?;
2932        symbols.retain(|symbol| symbol.name == name);
2933        Ok(symbols.into_iter().next())
2934    }
2935
2936    /// Load all symbols with an exact file and name.
2937    ///
2938    /// # Errors
2939    ///
2940    /// Returns an error if reading fails.
2941    pub fn load_symbols_by_exact_file_and_name(
2942        &self,
2943        file: &str,
2944        name: &str,
2945    ) -> DbResult<Vec<CodeSymbol>> {
2946        self.query_symbols(
2947            "
2948            SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation
2949            FROM symbols
2950            WHERE path = ?1 AND name = ?2
2951            ORDER BY line_start, line_end, kind, parent
2952            ",
2953            params![file, name],
2954        )
2955    }
2956
2957    /// Load one existing node with purpose state by repository path.
2958    ///
2959    /// # Errors
2960    ///
2961    /// Returns an error if reading or enum conversion fails.
2962    pub fn load_node_by_path(&self, path: &str) -> DbResult<Option<IndexedNode>> {
2963        let mut statement = self.connection.prepare(
2964            "
2965            SELECT
2966                n.path,
2967                n.kind,
2968                n.parent_path,
2969                n.extension,
2970                n.language,
2971                n.size_bytes,
2972                n.mtime_ns,
2973                n.content_hash,
2974                p.purpose,
2975                p.source,
2976                p.status,
2977                s.summary
2978            FROM nodes n
2979            JOIN purposes p ON p.node_id = n.id
2980            LEFT JOIN summaries s ON s.node_id = n.id
2981                AND s.summary_level = 'node'
2982                AND s.subject = ''
2983            WHERE n.exists_now = 1 AND n.path = ?1
2984            ",
2985        )?;
2986        let row = statement
2987            .query_row([path], |row| {
2988                let kind_value: String = row.get(1)?;
2989                let source_value: String = row.get(9)?;
2990                let status_value: String = row.get(10)?;
2991                Ok((
2992                    row.get::<_, String>(0)?,
2993                    kind_value,
2994                    row.get::<_, Option<String>>(2)?,
2995                    row.get::<_, Option<String>>(3)?,
2996                    row.get::<_, Option<String>>(4)?,
2997                    row.get::<_, Option<u64>>(5)?,
2998                    row.get::<_, Option<i64>>(6)?,
2999                    row.get::<_, Option<String>>(7)?,
3000                    row.get::<_, Option<String>>(8)?,
3001                    source_value,
3002                    status_value,
3003                    row.get::<_, Option<String>>(11)?,
3004                ))
3005            })
3006            .optional()?;
3007        row.map(indexed_node_from_parts).transpose()
3008    }
3009
3010    /// Load existing nodes for exact repository paths.
3011    ///
3012    /// # Errors
3013    ///
3014    /// Returns an error if reading or enum conversion fails.
3015    pub fn load_nodes_by_paths(&self, paths: &[String]) -> DbResult<Vec<IndexedNode>> {
3016        self.load_nodes_by_paths_controlled(paths, None)
3017    }
3018
3019    /// Load existing nodes for exact repository paths in bounded cancellable batches.
3020    ///
3021    /// # Errors
3022    ///
3023    /// Returns an error if cancellation fires or reading or enum conversion fails.
3024    pub fn load_nodes_by_paths_controlled(
3025        &self,
3026        paths: &[String],
3027        control: Option<&IndexWorkControl>,
3028    ) -> DbResult<Vec<IndexedNode>> {
3029        let mut unique_paths = paths.to_vec();
3030        unique_paths.sort();
3031        unique_paths.dedup();
3032        if unique_paths.is_empty() {
3033            return Ok(Vec::new());
3034        }
3035        let mut nodes = Vec::new();
3036        for chunk in unique_paths.chunks(MAX_PURPOSE_CURATION_BATCH_ROWS) {
3037            let hydrated = with_sqlite_read_progress(
3038                &self.connection,
3039                control,
3040                IndexWorkStage::RepositoryTraversal,
3041                || {
3042                    let sql = load_nodes_by_paths_sql(chunk.len());
3043                    let mut statement = self.connection.prepare(&sql)?;
3044                    let rows = statement.query_map(params_from_iter(chunk), |row| {
3045                        let kind_value: String = row.get(1)?;
3046                        let source_value: String = row.get(9)?;
3047                        let status_value: String = row.get(10)?;
3048                        Ok((
3049                            row.get::<_, String>(0)?,
3050                            kind_value,
3051                            row.get::<_, Option<String>>(2)?,
3052                            row.get::<_, Option<String>>(3)?,
3053                            row.get::<_, Option<String>>(4)?,
3054                            row.get::<_, Option<u64>>(5)?,
3055                            row.get::<_, Option<i64>>(6)?,
3056                            row.get::<_, Option<String>>(7)?,
3057                            row.get::<_, Option<String>>(8)?,
3058                            source_value,
3059                            status_value,
3060                            row.get::<_, Option<String>>(11)?,
3061                        ))
3062                    })?;
3063                    let mut selected = Vec::new();
3064                    for row in rows {
3065                        selected.push(indexed_node_from_parts(row?)?);
3066                    }
3067                    Ok(selected)
3068                },
3069            )?;
3070            nodes.extend(hydrated);
3071        }
3072        Ok(nodes)
3073    }
3074
3075    /// Hydrate every exact purpose-owner path under one graph read envelope.
3076    ///
3077    /// Rows are returned in caller order. Unlike the compatibility path loader,
3078    /// this graph-facing boundary evaluates every unique requested path in the
3079    /// selected project and generation. Existing rows retain caller order;
3080    /// absent ancestor candidates are represented by omission rather than a
3081    /// partial-query failure.
3082    ///
3083    /// # Errors
3084    ///
3085    /// Returns an error for duplicate paths, a stale project or generation,
3086    /// cancellation, corrupt node or purpose state, `SQLite`
3087    /// failure, or any returned-row, decoded-byte, or hydrated-path budget
3088    /// overrun. No partial batch is returned.
3089    pub fn load_purpose_owner_nodes_by_paths_controlled(
3090        &self,
3091        project: ProjectInstanceId,
3092        generation: IndexGeneration,
3093        paths: &[String],
3094        budget: RepositoryGraphReadBudget,
3095        control: Option<&IndexWorkControl>,
3096    ) -> DbResult<RepositoryGraphReadBatch<IndexedNode>> {
3097        if paths
3098            .iter()
3099            .map(String::as_str)
3100            .collect::<HashSet<_>>()
3101            .len()
3102            != paths.len()
3103        {
3104            return Err(GraphContractError::InvalidLimits {
3105                reason: "purpose-owner hydration paths must be unique",
3106            }
3107            .into());
3108        }
3109        self.require_repository_graph_snapshot(project, generation)?;
3110        let mut meter = repository_graph::RepositoryGraphReadMeter::new(budget, paths.len())?;
3111        let path_count =
3112            u32::try_from(paths.len()).map_err(|_source| GraphContractError::InvalidLimits {
3113                reason: "purpose-owner hydration path count overflowed",
3114            })?;
3115        if path_count > budget.returned_rows() {
3116            return Err(GraphContractError::InvalidLimits {
3117                reason: "purpose-owner paths exceed the return budget",
3118            }
3119            .into());
3120        }
3121        if path_count > budget.hydrated_paths() {
3122            return Err(GraphContractError::InvalidLimits {
3123                reason: "purpose-owner paths exceed the hydration budget",
3124            }
3125            .into());
3126        }
3127        if paths.is_empty() {
3128            return Ok(RepositoryGraphReadBatch {
3129                rows: Vec::new(),
3130                work: meter.finish(0)?,
3131            });
3132        }
3133
3134        let mut selected = HashMap::with_capacity(paths.len());
3135        for chunk in paths.chunks(MAX_PURPOSE_CURATION_BATCH_ROWS) {
3136            let hydrated = with_sqlite_read_progress(
3137                &self.connection,
3138                control,
3139                IndexWorkStage::RepositoryTraversal,
3140                || {
3141                    let sql = load_nodes_by_paths_sql(chunk.len());
3142                    let mut statement = self.connection.prepare(&sql)?;
3143                    let mut rows = statement.query(params_from_iter(chunk))?;
3144                    let mut nodes = Vec::new();
3145                    while let Some(row) = rows.next()? {
3146                        let parts = indexed_node_parts_from_sql_row(row)?;
3147                        meter.record_decoded_bytes(indexed_node_parts_decoded_bytes(&parts)?)?;
3148                        let node = indexed_node_from_parts(parts)?;
3149                        meter.record_hydrated_path(&node.node.path)?;
3150                        nodes.push(node);
3151                    }
3152                    Ok(nodes)
3153                },
3154            )?;
3155            for node in hydrated {
3156                let path = node.node.path.clone();
3157                if selected.insert(path, node).is_some() {
3158                    return Err(DbError::GraphRowShape {
3159                        table: "nodes",
3160                        reason: "purpose-owner hydration returned a duplicate path",
3161                    });
3162                }
3163            }
3164        }
3165
3166        let mut ordered = Vec::with_capacity(paths.len());
3167        for path in paths {
3168            if let Some(node) = selected.remove(path) {
3169                ordered.push(node);
3170            }
3171        }
3172        let work = meter.finish(ordered.len())?;
3173        Ok(RepositoryGraphReadBatch {
3174            rows: ordered,
3175            work,
3176        })
3177    }
3178
3179    /// Load one bounded stale-safe purpose-curation batch for exact paths.
3180    ///
3181    /// The node hydration is one prepared set query regardless of row count.
3182    /// Accepted purposes are intentionally omitted from curator work.
3183    ///
3184    /// # Errors
3185    ///
3186    /// Returns an error for an invalid task, oversized batch, changed project
3187    /// binding, invalid persisted state, or failed `SQLite` read.
3188    pub fn load_purpose_curation_batch(
3189        &self,
3190        task: &str,
3191        paths: &[String],
3192    ) -> DbResult<PurposeCurationBatch> {
3193        let task = normalize_purpose_curation_task(task)?;
3194        let mut unique_paths = paths.to_vec();
3195        unique_paths.sort();
3196        unique_paths.dedup();
3197        if unique_paths.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
3198            return Err(DbError::PurposeCurationBatchTooLarge {
3199                requested: unique_paths.len(),
3200                maximum: MAX_PURPOSE_CURATION_BATCH_ROWS,
3201            });
3202        }
3203        let (project_instance_id, active_generation) =
3204            self.purpose_curation_context(&self.connection)?;
3205        let items = self
3206            .load_nodes_by_paths(&unique_paths)?
3207            .into_iter()
3208            .filter(|node| {
3209                matches!(
3210                    node.purpose.status,
3211                    PurposeStatus::Missing | PurposeStatus::Suggested
3212                )
3213            })
3214            .map(|node| {
3215                purpose_curation_candidate(project_instance_id, active_generation, &task, node)
3216            })
3217            .collect::<Vec<_>>();
3218        let work_key =
3219            purpose_curation_batch_work_key(project_instance_id, active_generation, &task, &items);
3220        Ok(PurposeCurationBatch {
3221            project_instance_id,
3222            active_generation,
3223            task,
3224            work_key,
3225            items,
3226        })
3227    }
3228
3229    /// Load symbol relations filtered by optional file path and query.
3230    ///
3231    /// # Errors
3232    ///
3233    /// Returns an error if reading fails.
3234    pub fn load_symbol_relations(
3235        &self,
3236        file: Option<&str>,
3237        query: Option<&str>,
3238        limit: usize,
3239    ) -> DbResult<Vec<SymbolRelation>> {
3240        let max_rows = usize_to_i64(limit.max(1));
3241        match (file, query) {
3242            (Some(file), Some(query)) => self.query_relations(
3243                "
3244                SELECT path, source_name, target_name, kind, line, context, parser
3245                FROM symbol_relations
3246                WHERE path = ?1 AND (source_name LIKE ?2 OR target_name LIKE ?2 OR context LIKE ?2)
3247                ORDER BY path, line, source_name, target_name
3248                LIMIT ?3
3249                ",
3250                params![file, like_query(query), max_rows],
3251            ),
3252            (Some(file), None) => self.query_relations(
3253                "
3254                SELECT path, source_name, target_name, kind, line, context, parser
3255                FROM symbol_relations
3256                WHERE path = ?1
3257                ORDER BY path, line, source_name, target_name
3258                LIMIT ?2
3259                ",
3260                params![file, max_rows],
3261            ),
3262            (None, Some(query)) => self.query_relations(
3263                "
3264                SELECT path, source_name, target_name, kind, line, context, parser
3265                FROM symbol_relations
3266                WHERE source_name LIKE ?1 OR target_name LIKE ?1 OR context LIKE ?1 OR path LIKE ?1
3267                ORDER BY path, line, source_name, target_name
3268                LIMIT ?2
3269                ",
3270                params![like_query(query), max_rows],
3271            ),
3272            (None, None) => self.query_relations(
3273                "
3274                SELECT path, source_name, target_name, kind, line, context, parser
3275                FROM symbol_relations
3276                ORDER BY path, line, source_name, target_name
3277                LIMIT ?1
3278                ",
3279                params![max_rows],
3280            ),
3281        }
3282    }
3283
3284    /// Load symbol relations for a file and exact relation kind.
3285    ///
3286    /// # Errors
3287    ///
3288    /// Returns an error if reading fails.
3289    pub fn load_symbol_relations_by_kind(
3290        &self,
3291        file: &str,
3292        kind: RelationKind,
3293        limit: usize,
3294    ) -> DbResult<Vec<SymbolRelation>> {
3295        let max_rows = usize_to_i64(limit.max(1));
3296        self.query_relations(
3297            "
3298            SELECT path, source_name, target_name, kind, line, context, parser
3299            FROM symbol_relations
3300            WHERE path = ?1 AND kind = ?2
3301            ORDER BY path, line, source_name, target_name
3302            LIMIT ?3
3303            ",
3304            params![file, kind.to_string(), max_rows],
3305        )
3306    }
3307
3308    /// Count symbol relations for a file and exact relation kind.
3309    ///
3310    /// # Errors
3311    ///
3312    /// Returns an error if reading fails.
3313    pub fn count_symbol_relations_by_kind(
3314        &self,
3315        file: &str,
3316        kind: RelationKind,
3317    ) -> DbResult<usize> {
3318        let count = self.connection.query_row(
3319            "SELECT COUNT(*) FROM symbol_relations WHERE path = ?1 AND kind = ?2",
3320            params![file, kind.to_string()],
3321            |row| row.get::<_, i64>(0),
3322        )?;
3323        Ok(i64_to_usize(count))
3324    }
3325
3326    /// Load distinct relation targets for a file and exact relation kind.
3327    ///
3328    /// # Errors
3329    ///
3330    /// Returns an error if reading fails.
3331    pub fn load_distinct_relation_targets_by_kind(
3332        &self,
3333        file: &str,
3334        kind: RelationKind,
3335        limit: usize,
3336    ) -> DbResult<Vec<String>> {
3337        let max_rows = usize_to_i64(limit.max(1));
3338        let mut statement = self.connection.prepare(
3339            "
3340            SELECT DISTINCT target_name
3341            FROM symbol_relations
3342            WHERE path = ?1 AND kind = ?2
3343            ORDER BY target_name
3344            LIMIT ?3
3345            ",
3346        )?;
3347        let rows = statement.query_map(params![file, kind.to_string(), max_rows], |row| {
3348            row.get::<_, String>(0)
3349        })?;
3350        let mut targets = Vec::new();
3351        for row in rows {
3352            targets.push(row?);
3353        }
3354        Ok(targets)
3355    }
3356
3357    /// Count distinct relation targets for a file and exact relation kind.
3358    ///
3359    /// # Errors
3360    ///
3361    /// Returns an error if reading fails.
3362    pub fn count_distinct_relation_targets_by_kind(
3363        &self,
3364        file: &str,
3365        kind: RelationKind,
3366    ) -> DbResult<usize> {
3367        let count = self.connection.query_row(
3368            "SELECT COUNT(DISTINCT target_name) FROM symbol_relations WHERE path = ?1 AND kind = ?2",
3369            params![file, kind.to_string()],
3370            |row| row.get::<_, i64>(0),
3371        )?;
3372        Ok(i64_to_usize(count))
3373    }
3374
3375    /// Load call relations targeting any of the requested symbol names.
3376    ///
3377    /// # Errors
3378    ///
3379    /// Returns an error if reading fails.
3380    pub fn load_call_relations_to_targets(
3381        &self,
3382        target_names: &[String],
3383        limit_per_target: usize,
3384    ) -> DbResult<Vec<SymbolRelation>> {
3385        if target_names.is_empty() {
3386            return Ok(Vec::new());
3387        }
3388        let placeholders = numbered_placeholders(1, target_names.len());
3389        let limit_placeholder = target_names.len() + 1;
3390        let sql = format!(
3391            "
3392            SELECT path, source_name, target_name, kind, line, context, parser
3393            FROM (
3394                SELECT
3395                    path,
3396                    source_name,
3397                    target_name,
3398                    kind,
3399                    line,
3400                    context,
3401                    parser,
3402                    ROW_NUMBER() OVER (
3403                        PARTITION BY target_name
3404                        ORDER BY path, line, source_name, target_name
3405                    ) AS target_row
3406                FROM symbol_relations INDEXED BY idx_symbol_relations_target
3407                WHERE kind = 'calls' AND target_name IN ({placeholders})
3408            )
3409            WHERE target_row <= ?{limit_placeholder}
3410            ORDER BY path, line, source_name, target_name
3411            "
3412        );
3413        let mut values = target_names
3414            .iter()
3415            .map(|target| Value::Text(target.clone()))
3416            .collect::<Vec<_>>();
3417        values.push(Value::Integer(usize_to_i64(limit_per_target.max(1))));
3418        let mut relations = self.query_relations(&sql, params_from_iter(values.iter()))?;
3419        relations.sort_by(|left, right| {
3420            left.path
3421                .cmp(&right.path)
3422                .then_with(|| left.line.cmp(&right.line))
3423                .then_with(|| left.source_name.cmp(&right.source_name))
3424                .then_with(|| left.target_name.cmp(&right.target_name))
3425        });
3426        relations.dedup_by(|left, right| {
3427            left.path == right.path
3428                && left.source_name == right.source_name
3429                && left.target_name == right.target_name
3430                && left.kind == right.kind
3431                && left.line == right.line
3432        });
3433        Ok(relations)
3434    }
3435
3436    /// Load import relations whose persisted target text mentions any term.
3437    ///
3438    /// # Errors
3439    ///
3440    /// Returns an error if reading fails.
3441    pub fn load_import_relations_matching_targets(
3442        &self,
3443        terms: &[String],
3444        limit_per_term: usize,
3445    ) -> DbResult<Vec<StoredImportRelation>> {
3446        let mut unique_terms = terms.to_vec();
3447        unique_terms.sort();
3448        unique_terms.dedup();
3449        let mut relations = Vec::new();
3450        for term in unique_terms.iter().filter(|term| !term.trim().is_empty()) {
3451            let mut statement = self.connection.prepare_cached(
3452                "
3453                SELECT path, source_name, target_name, line
3454                FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
3455                WHERE kind = 'imports' AND target_name LIKE ?1 ESCAPE '\\'
3456                ORDER BY path, line, source_name, target_name
3457                LIMIT ?2
3458                ",
3459            )?;
3460            let rows = statement.query_map(
3461                params![
3462                    sqlite_like_pattern(term),
3463                    usize_to_i64(limit_per_term.max(1))
3464                ],
3465                stored_import_relation_from_row,
3466            )?;
3467            for row in rows {
3468                relations.push(row?);
3469            }
3470        }
3471        relations.sort_by(|left, right| {
3472            left.path
3473                .cmp(&right.path)
3474                .then_with(|| left.line.cmp(&right.line))
3475                .then_with(|| left.source_name.cmp(&right.source_name))
3476                .then_with(|| left.target_name.cmp(&right.target_name))
3477        });
3478        relations.dedup_by(|left, right| {
3479            left.path == right.path
3480                && left.source_name == right.source_name
3481                && left.target_name == right.target_name
3482                && left.line == right.line
3483        });
3484        Ok(relations)
3485    }
3486
3487    /// Load bounded import facts for one exact caller path.
3488    ///
3489    /// # Errors
3490    ///
3491    /// Returns an error if the covering indexed read fails.
3492    pub fn load_import_relations_for_path(
3493        &self,
3494        path: &str,
3495        limit: usize,
3496    ) -> DbResult<Vec<StoredImportRelation>> {
3497        let mut statement = self.connection.prepare_cached(
3498            "
3499            SELECT path, source_name, target_name, line
3500            FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
3501            WHERE kind = 'imports' AND path = ?1
3502            ORDER BY path, line, source_name, target_name
3503            LIMIT ?2
3504            ",
3505        )?;
3506        let rows = statement.query_map(
3507            params![path, usize_to_i64(limit.max(1))],
3508            stored_import_relation_from_row,
3509        )?;
3510        let mut relations = Vec::new();
3511        for row in rows {
3512            relations.push(row?);
3513        }
3514        Ok(relations)
3515    }
3516
3517    /// Count persisted symbols.
3518    ///
3519    /// # Errors
3520    ///
3521    /// Returns an error if reading fails.
3522    pub fn symbol_count(&self) -> DbResult<usize> {
3523        let count = self
3524            .connection
3525            .query_row("SELECT COUNT(*) FROM symbols", [], |row| {
3526                row.get::<_, i64>(0)
3527            })?;
3528        Ok(i64_to_usize(count))
3529    }
3530
3531    /// Count persisted symbol relations.
3532    ///
3533    /// # Errors
3534    ///
3535    /// Returns an error if reading fails.
3536    pub fn symbol_relation_count(&self) -> DbResult<usize> {
3537        let count =
3538            self.connection
3539                .query_row("SELECT COUNT(*) FROM symbol_relations", [], |row| {
3540                    row.get::<_, i64>(0)
3541                })?;
3542        count_to_usize("symbol_relations", count)
3543    }
3544
3545    /// Count persisted symbols for one file path.
3546    ///
3547    /// # Errors
3548    ///
3549    /// Returns an error if reading fails.
3550    pub fn symbol_count_for_path(&self, path: &str) -> DbResult<usize> {
3551        let count = self.connection.query_row(
3552            "SELECT COUNT(*) FROM symbols WHERE path = ?1",
3553            [path],
3554            |row| row.get::<_, i64>(0),
3555        )?;
3556        Ok(i64_to_usize(count))
3557    }
3558
3559    /// Count persisted symbols for a batch of file paths.
3560    ///
3561    /// Paths without symbols are omitted from the returned map.
3562    ///
3563    /// # Errors
3564    ///
3565    /// Returns an error if reading fails.
3566    pub fn symbol_counts_for_paths(&self, paths: &[String]) -> DbResult<HashMap<String, usize>> {
3567        let mut counts = HashMap::new();
3568        for chunk in paths.chunks(900) {
3569            if chunk.is_empty() {
3570                continue;
3571            }
3572            let placeholders = vec!["?"; chunk.len()].join(",");
3573            let sql = format!(
3574                "SELECT path, COUNT(*) FROM symbols WHERE path IN ({placeholders}) GROUP BY path"
3575            );
3576            let mut statement = self.connection.prepare(&sql)?;
3577            let rows = statement.query_map(params_from_iter(chunk.iter()), |row| {
3578                let path = row.get::<_, String>(0)?;
3579                let count = row.get::<_, i64>(1)?;
3580                Ok((path, i64_to_usize(count)))
3581            })?;
3582            for row in rows {
3583                let (path, count) = row?;
3584                counts.insert(path, count);
3585            }
3586        }
3587        Ok(counts)
3588    }
3589
3590    /// Return exact paths with persisted parser metadata from one bounded path set.
3591    ///
3592    /// # Errors
3593    ///
3594    /// Returns an error if reading or row decoding fails.
3595    pub fn source_parse_metadata_paths_for_paths(
3596        &self,
3597        paths: &[String],
3598    ) -> DbResult<HashSet<String>> {
3599        let mut indexed = HashSet::new();
3600        for chunk in paths.chunks(900) {
3601            if chunk.is_empty() {
3602                continue;
3603            }
3604            let placeholders = numbered_placeholders(1, chunk.len());
3605            let sql = format!(
3606                "SELECT path FROM source_parse_metadata
3607                 WHERE path IN ({placeholders})
3608                 ORDER BY path"
3609            );
3610            let mut statement = self.connection.prepare(&sql)?;
3611            let rows = statement.query_map(params_from_iter(chunk.iter()), |row| {
3612                row.get::<_, String>(0)
3613            })?;
3614            for row in rows {
3615                indexed.insert(row?);
3616            }
3617        }
3618        Ok(indexed)
3619    }
3620
3621    /// Return distinct parser strategies that produced symbols for one path.
3622    ///
3623    /// # Errors
3624    ///
3625    /// Returns an error if reading fails.
3626    pub fn symbol_parser_kinds_for_path(&self, path: &str) -> DbResult<Vec<ParserKind>> {
3627        let mut statement = self.connection.prepare(
3628            "
3629            SELECT DISTINCT parser
3630            FROM symbols
3631            WHERE path = ?1
3632            ORDER BY parser
3633            ",
3634        )?;
3635        let rows = statement.query_map([path], |row| {
3636            Ok(ParserKind::from_db(&row.get::<_, String>(0)?))
3637        })?;
3638        let mut parsers = Vec::new();
3639        for row in rows {
3640            parsers.push(row?);
3641        }
3642        Ok(parsers)
3643    }
3644
3645    /// Reconstruct persisted symbol graphs for exact repository paths in bounded batches.
3646    ///
3647    /// Paths without parser metadata are omitted. Any selected symbol or relation
3648    /// without matching metadata, or any metadata count mismatch, fails the whole
3649    /// operation instead of returning a partial graph set.
3650    ///
3651    /// # Errors
3652    ///
3653    /// Returns an error for `SQLite` failures, invalid persisted counts or enums,
3654    /// and inconsistent symbol-graph rows.
3655    pub fn load_symbol_graphs_for_paths(&self, paths: &[String]) -> DbResult<Vec<SymbolGraph>> {
3656        const PATHS_PER_QUERY: usize = 900;
3657
3658        let mut paths = paths.to_vec();
3659        paths.sort();
3660        paths.dedup();
3661        let mut graphs = Vec::with_capacity(paths.len());
3662        for chunk in paths.chunks(PATHS_PER_QUERY) {
3663            let placeholders = numbered_placeholders(1, chunk.len());
3664            let metadata_sql = format!(
3665                "SELECT path, language, source_parser, fact_parser, symbol_count, relation_count
3666                   FROM source_parse_metadata
3667                  WHERE path IN ({placeholders})
3668                  ORDER BY path"
3669            );
3670            let mut metadata_statement = self.connection.prepare(&metadata_sql)?;
3671            let metadata_rows =
3672                metadata_statement.query_map(params_from_iter(chunk.iter()), |row| {
3673                    Ok((
3674                        row.get::<_, String>(0)?,
3675                        row.get::<_, Option<String>>(1)?,
3676                        row.get::<_, String>(2)?,
3677                        row.get::<_, String>(3)?,
3678                        row.get::<_, i64>(4)?,
3679                        row.get::<_, i64>(5)?,
3680                    ))
3681                })?;
3682            let mut staged = BTreeMap::new();
3683            for row in metadata_rows {
3684                let (path, language, source_parser, fact_parser, symbol_count, relation_count) =
3685                    row?;
3686                staged.insert(
3687                    path.clone(),
3688                    (
3689                        SourceParseMetadata {
3690                            path,
3691                            language,
3692                            parser: ParserKind::from_db(&source_parser),
3693                            symbol_count: count_to_usize(
3694                                "source_parse_metadata.symbol_count",
3695                                symbol_count,
3696                            )?,
3697                            relation_count: count_to_usize(
3698                                "source_parse_metadata.relation_count",
3699                                relation_count,
3700                            )?,
3701                        },
3702                        ParserKind::from_db(&fact_parser),
3703                        Vec::new(),
3704                        Vec::new(),
3705                    ),
3706                );
3707            }
3708
3709            let symbol_sql = format!(
3710                "SELECT path, language, name, kind, signature, line_start, line_end,
3711                        parent, parser, detail, exported, documentation
3712                   FROM symbols
3713                  WHERE path IN ({placeholders})
3714                  ORDER BY path, line_start, line_end, name, kind"
3715            );
3716            for symbol in self.query_symbols(&symbol_sql, params_from_iter(chunk.iter()))? {
3717                let path = symbol.path.clone();
3718                let Some((_, _, symbols, _)) = staged.get_mut(&path) else {
3719                    return Err(DbError::SymbolGraphRowShape {
3720                        path,
3721                        reason: "symbol rows require matching parser metadata",
3722                    });
3723                };
3724                symbols.push(symbol);
3725            }
3726
3727            let relation_sql = format!(
3728                "SELECT path, source_name, target_name, kind, line, context, parser
3729                   FROM symbol_relations
3730                  WHERE path IN ({placeholders})
3731                  ORDER BY path, line, source_name, target_name, kind"
3732            );
3733            for relation in self.query_relations(&relation_sql, params_from_iter(chunk.iter()))? {
3734                let path = relation.path.clone();
3735                let Some((_, _, _, relations)) = staged.get_mut(&path) else {
3736                    return Err(DbError::SymbolGraphRowShape {
3737                        path,
3738                        reason: "relation rows require matching parser metadata",
3739                    });
3740                };
3741                relations.push(relation);
3742            }
3743
3744            for (path, (metadata, fact_parser, symbols, relations)) in staged {
3745                if metadata.symbol_count != symbols.len()
3746                    || metadata.relation_count != relations.len()
3747                {
3748                    return Err(DbError::SymbolGraphRowShape {
3749                        path,
3750                        reason: "parser metadata counts do not match persisted rows",
3751                    });
3752                }
3753                graphs.push(SymbolGraph {
3754                    path: metadata.path,
3755                    language: metadata.language,
3756                    parser: fact_parser,
3757                    symbols,
3758                    relations,
3759                });
3760            }
3761        }
3762        Ok(graphs)
3763    }
3764
3765    /// Load file-level parser metadata for one path.
3766    ///
3767    /// # Errors
3768    ///
3769    /// Returns an error if reading fails or stored counts are invalid.
3770    pub fn load_source_parse_metadata(&self, path: &str) -> DbResult<Option<SourceParseMetadata>> {
3771        self.connection
3772            .query_row(
3773                "
3774                SELECT path, language, source_parser, symbol_count, relation_count
3775                FROM source_parse_metadata
3776                WHERE path = ?1
3777                ",
3778                [path],
3779                |row| {
3780                    let symbol_count = row.get::<_, i64>(3)?;
3781                    let relation_count = row.get::<_, i64>(4)?;
3782                    Ok(SourceParseMetadata {
3783                        path: row.get(0)?,
3784                        language: row.get(1)?,
3785                        parser: ParserKind::from_db(&row.get::<_, String>(2)?),
3786                        symbol_count: i64_to_usize(symbol_count),
3787                        relation_count: i64_to_usize(relation_count),
3788                    })
3789                },
3790            )
3791            .optional()
3792            .map_err(Into::into)
3793    }
3794
3795    /// Load the maximum indexed symbol end line for one file path.
3796    ///
3797    /// # Errors
3798    ///
3799    /// Returns an error if reading fails.
3800    pub fn max_symbol_end_line_for_path(&self, path: &str) -> DbResult<usize> {
3801        let line = self.connection.query_row(
3802            "SELECT COALESCE(MAX(line_end), 0) FROM symbols WHERE path = ?1",
3803            [path],
3804            |row| row.get::<_, i64>(0),
3805        )?;
3806        Ok(i64_to_usize(line))
3807    }
3808
3809    /// Query symbols with a caller-provided statement and parameters.
3810    fn query_symbols<P>(&self, sql: &str, params: P) -> DbResult<Vec<CodeSymbol>>
3811    where
3812        P: rusqlite::Params,
3813    {
3814        let mut statement = self.connection.prepare(sql)?;
3815        let rows = statement.query_map(params, code_symbol_from_row)?;
3816        let mut symbols = Vec::new();
3817        for row in rows {
3818            symbols.push(row?);
3819        }
3820        Ok(symbols)
3821    }
3822
3823    /// Query relations with a caller-provided statement and parameters.
3824    fn query_relations<P>(&self, sql: &str, params: P) -> DbResult<Vec<SymbolRelation>>
3825    where
3826        P: rusqlite::Params,
3827    {
3828        let mut statement = self.connection.prepare(sql)?;
3829        let rows = statement.query_map(params, |row| {
3830            let kind_value: String = row.get(3)?;
3831            let relation_kind = RelationKind::from_db(&kind_value).ok_or_else(|| {
3832                rusqlite::Error::FromSqlConversionFailure(
3833                    3,
3834                    rusqlite::types::Type::Text,
3835                    Box::new(std::io::Error::other(format!(
3836                        "invalid relation kind {kind_value}"
3837                    ))),
3838                )
3839            })?;
3840            Ok(SymbolRelation {
3841                path: row.get(0)?,
3842                source_name: row.get(1)?,
3843                target_name: row.get(2)?,
3844                kind: relation_kind,
3845                line: i64_to_usize(row.get::<_, i64>(4)?),
3846                context: row.get(5)?,
3847                parser: ParserKind::from_db(&row.get::<_, String>(6)?),
3848            })
3849        })?;
3850        let mut relations = Vec::new();
3851        for row in rows {
3852            relations.push(row?);
3853        }
3854        Ok(relations)
3855    }
3856
3857    /// Return whether any accepted agent-authored purpose can affect navigation.
3858    ///
3859    /// # Errors
3860    ///
3861    /// Returns an error when the bounded indexed lookup fails.
3862    pub fn has_agent_approved_purpose(&self) -> DbResult<bool> {
3863        self.connection
3864            .query_row(
3865                "SELECT EXISTS(
3866                    SELECT 1
3867                    FROM purposes INDEXED BY idx_purposes_status
3868                    WHERE status = 'approved' AND source = 'agent'
3869                    LIMIT 1
3870                )",
3871                [],
3872                |row| row.get(0),
3873            )
3874            .map_err(Into::into)
3875    }
3876
3877    /// Persist a purpose for a path.
3878    ///
3879    /// # Errors
3880    ///
3881    /// Returns an error if the path does not exist or persistence fails.
3882    pub fn set_purpose(&self, path: &str, purpose: &str, source: PurposeSource) -> DbResult<()> {
3883        self.with_validated_write(|connection| {
3884            let node_id = self.node_id_for_path(path)?;
3885            let changed = connection
3886                .prepare_cached(
3887                    "
3888            INSERT INTO purposes(node_id, purpose, source, status, updated_at)
3889            VALUES(?1, ?2, ?3, 'approved', CURRENT_TIMESTAMP)
3890            ON CONFLICT(node_id) DO UPDATE SET
3891                purpose = excluded.purpose,
3892                source = excluded.source,
3893                status = 'approved',
3894                updated_at = CURRENT_TIMESTAMP
3895            WHERE purposes.purpose <> excluded.purpose
3896               OR purposes.source <> excluded.source
3897               OR purposes.status <> 'approved'
3898            ",
3899                )?
3900                .execute(params![node_id, purpose, source.to_string()])?;
3901            if changed > 0 {
3902                advance_authored_purpose_revision(connection)?;
3903            }
3904            Ok(())
3905        })
3906    }
3907
3908    /// Return the accepted authored-purpose revision captured by this connection.
3909    ///
3910    /// Databases created before the revision contract have revision zero until
3911    /// their first accepted purpose mutation.
3912    ///
3913    /// # Errors
3914    ///
3915    /// Returns an error when the metadata value is corrupt.
3916    pub fn authored_purpose_revision(&self) -> DbResult<u64> {
3917        load_authored_purpose_revision(&self.connection)
3918    }
3919
3920    /// Approve one purpose only while its curator work and unapproved row are current.
3921    ///
3922    /// This path never changes an accepted purpose. Call [`Self::set_purpose`]
3923    /// for a deliberate correction after an agent or user identifies one.
3924    ///
3925    /// # Errors
3926    ///
3927    /// Returns an error for invalid task input, binding/schema failure, or a
3928    /// failed atomic `SQLite` transaction. Stale and accepted rows are returned
3929    /// as typed non-error states.
3930    pub fn conditionally_set_purpose(
3931        &self,
3932        task: &str,
3933        path: &str,
3934        work_key: &str,
3935        state_token: &str,
3936        purpose: &str,
3937    ) -> DbResult<PurposeConditionalApplyState> {
3938        let request = PurposeConditionalApplyRequest {
3939            task: task.to_string(),
3940            path: path.to_string(),
3941            work_key: work_key.to_string(),
3942            state_token: state_token.to_string(),
3943            purpose: purpose.to_string(),
3944        };
3945        let mut results = self.conditionally_set_purposes(&[request])?;
3946        Ok(results
3947            .pop()
3948            .map_or(PurposeConditionalApplyState::PathUnavailable, |result| {
3949                result.state
3950            }))
3951    }
3952
3953    /// Apply a bounded stale-safe purpose-review batch in one writer transaction.
3954    ///
3955    /// Current-row lookup and conditional-update statements are cached and
3956    /// reused for every item. A stale item leaves independent matching rows
3957    /// eligible within the same host-owned batch.
3958    ///
3959    /// # Errors
3960    ///
3961    /// Returns an error for invalid task input, oversized input, binding/schema
3962    /// failure, or any failed statement/commit. A database error rolls back the
3963    /// complete batch.
3964    pub fn conditionally_set_purposes(
3965        &self,
3966        requests: &[PurposeConditionalApplyRequest],
3967    ) -> DbResult<Vec<PurposeConditionalApplyResult>> {
3968        if requests.is_empty() {
3969            return Ok(Vec::new());
3970        }
3971        if requests.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
3972            return Err(DbError::PurposeCurationBatchTooLarge {
3973                requested: requests.len(),
3974                maximum: MAX_PURPOSE_CURATION_BATCH_ROWS,
3975            });
3976        }
3977        let normalized = requests
3978            .iter()
3979            .map(|request| {
3980                normalize_purpose_curation_task(&request.task).map(|task| {
3981                    PurposeConditionalApplyRequest {
3982                        task,
3983                        path: request.path.clone(),
3984                        work_key: request.work_key.clone(),
3985                        state_token: request.state_token.clone(),
3986                        purpose: request.purpose.clone(),
3987                    }
3988                })
3989            })
3990            .collect::<DbResult<Vec<_>>>()?;
3991        self.with_validated_write(|connection| {
3992            let (project_instance_id, active_generation) =
3993                self.purpose_curation_context(connection)?;
3994            let results = normalized
3995                .iter()
3996                .map(|request| {
3997                    apply_conditional_purpose(
3998                        connection,
3999                        project_instance_id,
4000                        active_generation,
4001                        request,
4002                    )
4003                })
4004                .collect::<DbResult<Vec<_>>>()?;
4005            if results
4006                .iter()
4007                .any(|result| matches!(result.state, PurposeConditionalApplyState::Applied))
4008            {
4009                advance_authored_purpose_revision(connection)?;
4010            }
4011            Ok(results)
4012        })
4013    }
4014
4015    /// Persist a non-approved purpose suggestion for a path.
4016    ///
4017    /// # Errors
4018    ///
4019    /// Returns an error if the path does not exist or persistence fails.
4020    pub fn set_suggested_purpose(&self, path: &str, purpose: &str) -> DbResult<()> {
4021        self.with_validated_write(|connection| {
4022            let node_id = self.node_id_for_path(path)?;
4023            connection
4024                .prepare_cached(
4025                    "
4026            INSERT INTO purposes(node_id, purpose, source, status, updated_at)
4027            VALUES(?1, ?2, ?3, ?4, CURRENT_TIMESTAMP)
4028            ON CONFLICT(node_id) DO UPDATE SET
4029                purpose = excluded.purpose,
4030                source = excluded.source,
4031                status = excluded.status,
4032                updated_at = CURRENT_TIMESTAMP
4033            WHERE purposes.status IN (?5, ?6)
4034            ",
4035                )?
4036                .execute(params![
4037                    node_id,
4038                    purpose,
4039                    PurposeSource::Generated.to_string(),
4040                    PurposeStatus::Suggested.as_str(),
4041                    PurposeStatus::Missing.as_str(),
4042                    PurposeStatus::Suggested.as_str(),
4043                ])?;
4044            Ok(())
4045        })
4046    }
4047
4048    /// Load a node id for a repository path.
4049    fn node_id_for_path(&self, path: &str) -> DbResult<i64> {
4050        self.connection
4051            .prepare_cached("SELECT id FROM nodes WHERE path = ?1 AND exists_now = 1")?
4052            .query_row([path], |row| row.get::<_, i64>(0))
4053            .optional()?
4054            .ok_or_else(|| DbError::PathNotIndexed {
4055                path: path.to_string(),
4056            })
4057    }
4058
4059    /// Load existing nodes with purpose state.
4060    ///
4061    /// # Errors
4062    ///
4063    /// Returns an error if reading or enum conversion fails.
4064    pub fn load_nodes(&self) -> DbResult<Vec<IndexedNode>> {
4065        let mut statement = self.connection.prepare(
4066            "
4067            SELECT
4068                n.path,
4069                n.kind,
4070                n.parent_path,
4071                n.extension,
4072                n.language,
4073                n.size_bytes,
4074                n.mtime_ns,
4075                n.content_hash,
4076                p.purpose,
4077                p.source,
4078                p.status,
4079                s.summary
4080            FROM nodes n
4081            JOIN purposes p ON p.node_id = n.id
4082            LEFT JOIN summaries s ON s.node_id = n.id
4083                AND s.summary_level = 'node'
4084                AND s.subject = ''
4085            WHERE n.exists_now = 1
4086            ORDER BY n.path
4087            ",
4088        )?;
4089        let rows = statement.query_map([], |row| {
4090            let kind_value: String = row.get(1)?;
4091            let source_value: String = row.get(9)?;
4092            let status_value: String = row.get(10)?;
4093            Ok((
4094                row.get::<_, String>(0)?,
4095                kind_value,
4096                row.get::<_, Option<String>>(2)?,
4097                row.get::<_, Option<String>>(3)?,
4098                row.get::<_, Option<String>>(4)?,
4099                row.get::<_, Option<u64>>(5)?,
4100                row.get::<_, Option<i64>>(6)?,
4101                row.get::<_, Option<String>>(7)?,
4102                row.get::<_, Option<String>>(8)?,
4103                source_value,
4104                status_value,
4105                row.get::<_, Option<String>>(11)?,
4106            ))
4107        })?;
4108        let mut nodes = Vec::new();
4109        for row in rows {
4110            let (
4111                path,
4112                kind_value,
4113                parent_path,
4114                extension,
4115                language,
4116                size_bytes,
4117                mtime_ns,
4118                content_hash,
4119                purpose,
4120                source_value,
4121                status_value,
4122                summary,
4123            ) = row?;
4124            let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
4125                field: "kind",
4126                value: kind_value,
4127            })?;
4128            let source = parse_source(&source_value)?;
4129            let status =
4130                PurposeStatus::from_db(&status_value).ok_or_else(|| DbError::InvalidEnum {
4131                    field: "status",
4132                    value: status_value,
4133                })?;
4134            nodes.push(IndexedNode {
4135                node: Node {
4136                    path: path.clone(),
4137                    kind,
4138                    parent_path,
4139                    extension,
4140                    language,
4141                    size_bytes,
4142                    mtime_ns,
4143                    content_hash,
4144                },
4145                purpose: Purpose {
4146                    path,
4147                    purpose,
4148                    source,
4149                    status,
4150                },
4151                summary,
4152            });
4153        }
4154        Ok(nodes)
4155    }
4156
4157    /// Load a bounded ranked node list directly from `SQLite`.
4158    ///
4159    /// This is the hot path for agent orientation commands. It keeps large
4160    /// repositories from materializing every indexed path just to answer a
4161    /// top-N folder or file query.
4162    ///
4163    /// # Errors
4164    ///
4165    /// Returns an error if reading or enum conversion fails.
4166    pub fn load_ranked_nodes(
4167        &self,
4168        query: &str,
4169        kind: NodeKind,
4170        folder: Option<&str>,
4171        limit: usize,
4172        offset: usize,
4173    ) -> DbResult<Vec<IndexedNode>> {
4174        let terms = normalize_query_terms(query);
4175        let exact_query = normalize_exact_ranked_query(query);
4176        let exact_name_enabled = !exact_query.is_empty() && !exact_query.contains('/');
4177        let exact_name_pattern = format!("%/{}", sqlite_like_escape(&exact_query));
4178        let reviewed_match_expression = reviewed_purpose_match_expression(terms.len());
4179        let score_expression = ranked_score_expression(terms.len());
4180        let mut sql = format!(
4181            "
4182            SELECT path, kind, parent_path, extension, language, size_bytes, mtime_ns,
4183                   content_hash, purpose, source, status, summary
4184            FROM (
4185                SELECT
4186                    n.path,
4187                    n.kind,
4188                    n.parent_path,
4189                    n.extension,
4190                    n.language,
4191                    n.size_bytes,
4192                    n.mtime_ns,
4193                    n.content_hash,
4194                    p.purpose,
4195                    p.source,
4196                    p.status,
4197                    s.summary,
4198                    CASE WHEN lower(n.path) = ? THEN 1 ELSE 0 END AS exact_path,
4199                    CASE WHEN ? = 1
4200                              AND (lower(n.path) = ? OR lower(n.path) LIKE ? ESCAPE '\\')
4201                         THEN 1 ELSE 0 END AS exact_name,
4202                    {reviewed_match_expression} AS reviewed_purpose,
4203                    {score_expression} AS score
4204                FROM nodes n
4205                JOIN purposes p ON p.node_id = n.id
4206                LEFT JOIN summaries s ON s.node_id = n.id
4207                    AND s.summary_level = 'node'
4208                    AND s.subject = ''
4209                LEFT JOIN summaries symbol_summaries ON symbol_summaries.node_id = n.id
4210                    AND symbol_summaries.summary_level = 'search'
4211                    AND symbol_summaries.subject = 'symbols'
4212                WHERE n.exists_now = 1
4213                  AND n.kind = ?
4214            "
4215        );
4216        let mut values = vec![
4217            Value::from(exact_query.clone()),
4218            Value::from(i64::from(exact_name_enabled)),
4219            Value::from(exact_query),
4220            Value::from(exact_name_pattern),
4221        ];
4222        for term in &terms {
4223            values.push(Value::from(sqlite_like_pattern(term)));
4224        }
4225        for term in &terms {
4226            let pattern = sqlite_like_pattern(term);
4227            values.push(Value::from(pattern.clone()));
4228            values.push(Value::from(pattern.clone()));
4229            values.push(Value::from(pattern.clone()));
4230            values.push(Value::from(pattern.clone()));
4231            values.push(Value::from(pattern));
4232        }
4233        values.push(Value::from(kind.to_string()));
4234        if kind == NodeKind::File
4235            && let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".")
4236        {
4237            sql.push_str(" AND (n.parent_path = ? OR n.parent_path LIKE ? ESCAPE '\\')");
4238            values.push(Value::from(folder.to_string()));
4239            values.push(Value::from(sqlite_descendant_pattern(folder)));
4240        }
4241        sql.push_str(
4242            "
4243            )
4244            WHERE score > 0 OR exact_path > 0 OR exact_name > 0 OR reviewed_purpose > 0
4245            ORDER BY exact_path DESC, exact_name DESC, reviewed_purpose DESC, score DESC, path
4246            LIMIT ?
4247            OFFSET ?
4248            ",
4249        );
4250        values.push(Value::from(usize_to_i64(limit.max(1))));
4251        values.push(Value::from(usize_to_i64(offset)));
4252
4253        let mut statement = self.connection.prepare(&sql)?;
4254        let mut rows = statement.query(params_from_iter(values))?;
4255        let mut nodes = Vec::new();
4256        while let Some(row) = rows.next()? {
4257            nodes.push(indexed_node_from_sql_row(row)?);
4258        }
4259        Ok(nodes)
4260    }
4261
4262    /// Sum indexed source bytes represented by file nodes.
4263    ///
4264    /// # Errors
4265    ///
4266    /// Returns an error if reading fails or the aggregate cannot fit in `usize`.
4267    pub fn source_file_byte_count(&self, folder: Option<&str>) -> DbResult<usize> {
4268        let mut sql = String::from(
4269            "
4270            SELECT COALESCE(SUM(COALESCE(size_bytes, 0)), 0)
4271            FROM nodes
4272            WHERE exists_now = 1
4273              AND kind = 'file'
4274            ",
4275        );
4276        let mut values = Vec::new();
4277        if let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".") {
4278            sql.push_str(" AND (parent_path = ? OR parent_path LIKE ? ESCAPE '\\')");
4279            values.push(Value::from(folder.to_string()));
4280            values.push(Value::from(sqlite_descendant_pattern(folder)));
4281        }
4282        let count = self
4283            .connection
4284            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
4285        count_to_usize("source_file_bytes", count)
4286    }
4287
4288    /// Visit indexed file paths and source sizes for exact token baselines.
4289    ///
4290    /// # Errors
4291    ///
4292    /// Returns an error if reading fails, stored counts are invalid, or the
4293    /// visitor returns an error.
4294    pub fn visit_file_token_estimates<F>(
4295        &self,
4296        folder: Option<&str>,
4297        mut visitor: F,
4298    ) -> DbResult<()>
4299    where
4300        F: FnMut(String, Option<u64>) -> DbResult<bool>,
4301    {
4302        let mut sql = String::from(
4303            "
4304            SELECT path, size_bytes
4305            FROM nodes
4306            WHERE exists_now = 1
4307              AND kind = 'file'
4308            ",
4309        );
4310        let mut values = Vec::new();
4311        if let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".") {
4312            sql.push_str(" AND (parent_path = ? OR parent_path LIKE ? ESCAPE '\\')");
4313            values.push(Value::from(folder.to_string()));
4314            values.push(Value::from(sqlite_descendant_pattern(folder)));
4315        }
4316        sql.push_str(" ORDER BY path");
4317        let mut statement = self.connection.prepare(&sql)?;
4318        let mut rows = statement.query(params_from_iter(values))?;
4319        while let Some(row) = rows.next()? {
4320            if !visitor(row.get::<_, String>(0)?, row.get::<_, Option<u64>>(1)?)? {
4321                return Ok(());
4322            }
4323        }
4324        Ok(())
4325    }
4326
4327    /// Build unresolved health findings without loading the full node table.
4328    ///
4329    /// # Errors
4330    ///
4331    /// Returns an error if reading fails or stored enum values are invalid.
4332    pub fn unresolved_health_findings(
4333        &self,
4334        resolved_ids: &[String],
4335    ) -> DbResult<Vec<HealthFinding>> {
4336        let mut findings = Vec::new();
4337        self.visit_unresolved_health_findings(resolved_ids, |finding| {
4338            findings.push(finding);
4339            Ok(true)
4340        })?;
4341        Ok(findings)
4342    }
4343
4344    /// Build a bounded unresolved health findings page.
4345    ///
4346    /// # Errors
4347    ///
4348    /// Returns an error if reading fails or stored enum values are invalid.
4349    pub fn unresolved_health_findings_page(
4350        &self,
4351        resolved_ids: &[String],
4352        query: &HealthQuery,
4353    ) -> DbResult<HealthFindingsPage> {
4354        self.unresolved_health_findings_page_with_filter(
4355            HealthResolutionFilter::Explicit(resolved_ids),
4356            query,
4357        )
4358    }
4359
4360    /// Build a bounded page filtered by this store's durable resolutions.
4361    ///
4362    /// # Errors
4363    ///
4364    /// Returns an error if reading fails or stored enum values are invalid.
4365    pub fn unresolved_health_findings_page_current(
4366        &self,
4367        query: &HealthQuery,
4368    ) -> DbResult<HealthFindingsPage> {
4369        self.unresolved_health_findings_page_with_filter(HealthResolutionFilter::Stored, query)
4370    }
4371
4372    /// Build the actionable missing/suggested purpose queue for one low-scope request.
4373    ///
4374    /// Accepted and legacy-stale purposes are excluded; deliberate accepted-purpose
4375    /// correction remains owned by the explicit purpose-set/review path.
4376    ///
4377    /// # Errors
4378    ///
4379    /// Returns an error if counting, paging, or stored-state conversion fails.
4380    pub fn purpose_curation_findings_page_current(
4381        &self,
4382        query: &HealthQuery,
4383    ) -> DbResult<HealthFindingsPage> {
4384        let specs = &PURPOSE_HEALTH_SPECS[..2];
4385        let unfiltered_total = specs.iter().try_fold(0_usize, |total, spec| {
4386            self.count_purpose_status_findings(
4387                *spec,
4388                None,
4389                HealthResolutionFilter::Stored,
4390                HealthScope::all(),
4391            )
4392            .map(|count| total + count)
4393        })?;
4394        if query
4395            .severity
4396            .is_some_and(|severity| severity != Severity::Warning)
4397        {
4398            return Ok(HealthFindingsPage {
4399                total: 0,
4400                unfiltered_total,
4401                returned: 0,
4402                start_index: query.start_index,
4403                limit: query.limit,
4404                findings: Vec::new(),
4405            });
4406        }
4407
4408        let matching_specs = query.category.as_deref().map_or(specs, |category| {
4409            specs
4410                .iter()
4411                .find(|spec| spec.category == category)
4412                .map_or(&[][..], std::slice::from_ref)
4413        });
4414        let total = self.count_purpose_lifecycle_findings(
4415            matching_specs,
4416            query.path_prefix.as_deref(),
4417            HealthResolutionFilter::Stored,
4418            query.scope,
4419        )?;
4420        let findings = if query.summary_only {
4421            Vec::new()
4422        } else {
4423            self.load_purpose_lifecycle_findings_page(
4424                matching_specs,
4425                query.path_prefix.as_deref(),
4426                HealthResolutionFilter::Stored,
4427                query.scope,
4428                query.start_index,
4429                query.limit,
4430            )?
4431        };
4432        Ok(HealthFindingsPage {
4433            total,
4434            unfiltered_total,
4435            returned: findings.len(),
4436            start_index: query.start_index,
4437            limit: query.limit,
4438            findings,
4439        })
4440    }
4441
4442    /// Count all unresolved findings without materializing finding rows or resolution ids.
4443    ///
4444    /// # Errors
4445    ///
4446    /// Returns an error if reading fails or stored enum values are invalid.
4447    pub fn unresolved_health_finding_count_current(&self) -> DbResult<usize> {
4448        self.unresolved_health_findings_page_current(&HealthQuery {
4449            start_index: 0,
4450            limit: 0,
4451            category: None,
4452            severity: None,
4453            path_prefix: None,
4454            summary_only: true,
4455            scope: HealthScope::all(),
4456        })
4457        .map(|page| page.total)
4458    }
4459
4460    /// Build a bounded unresolved page with caller-owned or store-owned filtering.
4461    fn unresolved_health_findings_page_with_filter(
4462        &self,
4463        resolution_filter: HealthResolutionFilter<'_>,
4464        query: &HealthQuery,
4465    ) -> DbResult<HealthFindingsPage> {
4466        let mut unfiltered_total = 0_usize;
4467        let mut total = 0_usize;
4468        let mut findings = Vec::new();
4469
4470        for spec in PURPOSE_HEALTH_SPECS {
4471            unfiltered_total += self.count_purpose_status_findings(
4472                spec,
4473                None,
4474                resolution_filter,
4475                HealthScope::all(),
4476            )?;
4477        }
4478
4479        let scope = query.scope;
4480        if scope.high_impact_queue() && query.category.is_none() {
4481            let matching_count = if query
4482                .severity
4483                .is_none_or(|severity| severity == Severity::Warning)
4484            {
4485                self.count_purpose_lifecycle_findings(
4486                    &PURPOSE_HEALTH_SPECS,
4487                    query.path_prefix.as_deref(),
4488                    resolution_filter,
4489                    scope,
4490                )?
4491            } else {
4492                0
4493            };
4494            if !query.summary_only
4495                && findings.len() < query.limit
4496                && total + matching_count > query.start_index
4497            {
4498                let local_start = query.start_index.saturating_sub(total);
4499                let local_limit = query.limit - findings.len();
4500                findings.extend(self.load_purpose_lifecycle_findings_page(
4501                    &PURPOSE_HEALTH_SPECS,
4502                    query.path_prefix.as_deref(),
4503                    resolution_filter,
4504                    scope,
4505                    local_start,
4506                    local_limit,
4507                )?);
4508            }
4509            total += matching_count;
4510        } else {
4511            for spec in PURPOSE_HEALTH_SPECS {
4512                if !purpose_health_spec_matches_query(spec, query) {
4513                    continue;
4514                }
4515
4516                let matching_count = self.count_purpose_status_findings(
4517                    spec,
4518                    query.path_prefix.as_deref(),
4519                    resolution_filter,
4520                    scope,
4521                )?;
4522                if !query.summary_only
4523                    && findings.len() < query.limit
4524                    && total + matching_count > query.start_index
4525                {
4526                    let local_start = query.start_index.saturating_sub(total);
4527                    let local_limit = query.limit - findings.len();
4528                    findings.extend(self.load_purpose_status_findings_page(
4529                        spec,
4530                        query.path_prefix.as_deref(),
4531                        resolution_filter,
4532                        scope,
4533                        local_start,
4534                        local_limit,
4535                    )?);
4536                }
4537                total += matching_count;
4538            }
4539        }
4540
4541        for category in STRUCTURAL_HEALTH_CATEGORIES {
4542            let unfiltered_scope = if category == CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED {
4543                HealthScope::purpose_strict()
4544            } else {
4545                HealthScope::all()
4546            };
4547            let unfiltered_count = self.count_structural_health_findings(
4548                category,
4549                None,
4550                resolution_filter,
4551                unfiltered_scope,
4552            )?;
4553            unfiltered_total += unfiltered_count;
4554            if !health_category_matches_query(category, Severity::Warning, query) {
4555                continue;
4556            }
4557            let matching_count = self.count_structural_health_findings(
4558                category,
4559                query.path_prefix.as_deref(),
4560                resolution_filter,
4561                scope,
4562            )?;
4563            if !query.summary_only
4564                && findings.len() < query.limit
4565                && total + matching_count > query.start_index
4566            {
4567                let local_start = query.start_index.saturating_sub(total);
4568                let local_limit = query.limit - findings.len();
4569                findings.extend(self.load_structural_health_findings_page(
4570                    category,
4571                    query.path_prefix.as_deref(),
4572                    resolution_filter,
4573                    scope,
4574                    local_start,
4575                    local_limit,
4576                )?);
4577            }
4578            total += matching_count;
4579        }
4580        Ok(HealthFindingsPage {
4581            total,
4582            unfiltered_total,
4583            returned: findings.len(),
4584            start_index: query.start_index,
4585            limit: query.limit,
4586            findings,
4587        })
4588    }
4589
4590    /// Visit unresolved health findings without materializing the full table.
4591    fn visit_unresolved_health_findings<F>(
4592        &self,
4593        resolved_ids: &[String],
4594        mut visitor: F,
4595    ) -> DbResult<()>
4596    where
4597        F: FnMut(HealthFinding) -> DbResult<bool>,
4598    {
4599        let resolved = resolved_ids.iter().cloned().collect::<HashSet<_>>();
4600        if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[0], &resolved, &mut visitor)? {
4601            return Ok(());
4602        }
4603        if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[1], &resolved, &mut visitor)? {
4604            return Ok(());
4605        }
4606        if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[2], &resolved, &mut visitor)? {
4607            return Ok(());
4608        }
4609        if !self.visit_agent_review_required_findings(&resolved, &mut visitor)? {
4610            return Ok(());
4611        }
4612        self.visit_structural_health_findings(&resolved, &mut visitor)
4613    }
4614
4615    /// Visit structural health findings that are not simple purpose statuses.
4616    fn visit_structural_health_findings<F>(
4617        &self,
4618        resolved_ids: &HashSet<String>,
4619        mut visitor: F,
4620    ) -> DbResult<()>
4621    where
4622        F: FnMut(HealthFinding) -> DbResult<bool>,
4623    {
4624        if !self.visit_duplicate_purpose_findings(resolved_ids, &mut visitor)? {
4625            return Ok(());
4626        }
4627        if !self.visit_repeated_temp_folder_findings(resolved_ids, &mut visitor)? {
4628            return Ok(());
4629        }
4630        Ok(())
4631    }
4632
4633    /// Build findings for one purpose lifecycle status.
4634    fn visit_purpose_status_findings<F>(
4635        &self,
4636        spec: PurposeHealthSpec,
4637        resolved_ids: &HashSet<String>,
4638        visitor: &mut F,
4639    ) -> DbResult<bool>
4640    where
4641        F: FnMut(HealthFinding) -> DbResult<bool>,
4642    {
4643        let mut statement = self.connection.prepare(
4644            "
4645            SELECT n.path
4646            FROM nodes n
4647            JOIN purposes p ON p.node_id = n.id
4648            WHERE n.exists_now = 1
4649              AND p.status = ?1
4650            ORDER BY n.path
4651            ",
4652        )?;
4653        let rows = statement.query_map([spec.status], |row| row.get::<_, String>(0))?;
4654        for row in rows {
4655            let path = row?;
4656            let finding = HealthFinding {
4657                id: finding_id(spec.category, &path, None),
4658                severity: Severity::Warning,
4659                category: spec.category.to_string(),
4660                path,
4661                related_path: None,
4662                message: spec.message.to_string(),
4663                recommendation: spec.recommendation.to_string(),
4664            };
4665            if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
4666                return Ok(false);
4667            }
4668        }
4669        Ok(true)
4670    }
4671
4672    /// Count unresolved purpose lifecycle findings directly in `SQLite`.
4673    fn count_purpose_lifecycle_findings(
4674        &self,
4675        specs: &[PurposeHealthSpec],
4676        path_prefix: Option<&str>,
4677        resolution_filter: HealthResolutionFilter<'_>,
4678        scope: HealthScope,
4679    ) -> DbResult<usize> {
4680        if specs.is_empty() {
4681            return Ok(0);
4682        }
4683        let (where_clause, values) =
4684            purpose_lifecycle_where_clause(specs, path_prefix, resolution_filter, scope);
4685        let sql = format!(
4686            "
4687            SELECT COUNT(*)
4688            FROM nodes n
4689            JOIN purposes p ON p.node_id = n.id
4690            WHERE {where_clause}
4691            "
4692        );
4693        let count = self
4694            .connection
4695            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
4696        count_to_usize("health_purpose_lifecycle_count", count)
4697    }
4698
4699    /// Load one globally ordered purpose lifecycle page directly from `SQLite`.
4700    fn load_purpose_lifecycle_findings_page(
4701        &self,
4702        specs: &[PurposeHealthSpec],
4703        path_prefix: Option<&str>,
4704        resolution_filter: HealthResolutionFilter<'_>,
4705        scope: HealthScope,
4706        start_index: usize,
4707        limit: usize,
4708    ) -> DbResult<Vec<HealthFinding>> {
4709        if limit == 0 || specs.is_empty() {
4710            return Ok(Vec::new());
4711        }
4712        let (where_clause, mut values) =
4713            purpose_lifecycle_where_clause(specs, path_prefix, resolution_filter, scope);
4714        let limit_placeholder = values.len() + 1;
4715        let offset_placeholder = values.len() + 2;
4716        values.push(Value::from(usize_to_i64(limit)));
4717        values.push(Value::from(usize_to_i64(start_index)));
4718        let order_by = purpose_default_queue_order_expression("n", "p");
4719        let sql = format!(
4720            "
4721            SELECT n.path, p.status
4722            FROM nodes n
4723            JOIN purposes p ON p.node_id = n.id
4724            WHERE {where_clause}
4725            ORDER BY {order_by}
4726            LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
4727            "
4728        );
4729        let mut statement = self.connection.prepare(&sql)?;
4730        let rows = statement.query_map(params_from_iter(values), |row| {
4731            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
4732        })?;
4733        let mut findings = Vec::new();
4734        for row in rows {
4735            let (path, status) = row?;
4736            let spec = purpose_health_spec_for_status(&status)?;
4737            findings.push(HealthFinding {
4738                id: finding_id(spec.category, &path, None),
4739                severity: Severity::Warning,
4740                category: spec.category.to_string(),
4741                path,
4742                related_path: None,
4743                message: spec.message.to_string(),
4744                recommendation: spec.recommendation.to_string(),
4745            });
4746        }
4747        Ok(findings)
4748    }
4749
4750    /// Count unresolved purpose lifecycle findings directly in `SQLite`.
4751    fn count_purpose_status_findings(
4752        &self,
4753        spec: PurposeHealthSpec,
4754        path_prefix: Option<&str>,
4755        resolution_filter: HealthResolutionFilter<'_>,
4756        scope: HealthScope,
4757    ) -> DbResult<usize> {
4758        let (where_clause, values) =
4759            purpose_status_where_clause(spec, path_prefix, resolution_filter, scope);
4760        let sql = format!(
4761            "
4762            SELECT COUNT(*)
4763            FROM nodes n
4764            JOIN purposes p ON p.node_id = n.id
4765            WHERE {where_clause}
4766            "
4767        );
4768        let count = self
4769            .connection
4770            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
4771        count_to_usize("health_purpose_status_count", count)
4772    }
4773
4774    /// Load one bounded unresolved purpose lifecycle page directly from `SQLite`.
4775    fn load_purpose_status_findings_page(
4776        &self,
4777        spec: PurposeHealthSpec,
4778        path_prefix: Option<&str>,
4779        resolution_filter: HealthResolutionFilter<'_>,
4780        scope: HealthScope,
4781        start_index: usize,
4782        limit: usize,
4783    ) -> DbResult<Vec<HealthFinding>> {
4784        if limit == 0 {
4785            return Ok(Vec::new());
4786        }
4787        let (where_clause, mut values) =
4788            purpose_status_where_clause(spec, path_prefix, resolution_filter, scope);
4789        let limit_placeholder = values.len() + 1;
4790        let offset_placeholder = values.len() + 2;
4791        values.push(Value::from(usize_to_i64(limit)));
4792        values.push(Value::from(usize_to_i64(start_index)));
4793        let order_by = if scope.high_impact_queue() {
4794            purpose_default_queue_order_expression("n", "p")
4795        } else {
4796            "n.path".to_string()
4797        };
4798        let sql = format!(
4799            "
4800            SELECT n.path
4801            FROM nodes n
4802            JOIN purposes p ON p.node_id = n.id
4803            WHERE {where_clause}
4804            ORDER BY {order_by}
4805            LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
4806            "
4807        );
4808        let mut statement = self.connection.prepare(&sql)?;
4809        let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(0))?;
4810        let mut findings = Vec::new();
4811        for row in rows {
4812            let path = row?;
4813            findings.push(HealthFinding {
4814                id: finding_id(spec.category, &path, None),
4815                severity: Severity::Warning,
4816                category: spec.category.to_string(),
4817                path,
4818                related_path: None,
4819                message: spec.message.to_string(),
4820                recommendation: spec.recommendation.to_string(),
4821            });
4822        }
4823        Ok(findings)
4824    }
4825
4826    /// Count unresolved structural health findings directly in `SQLite`.
4827    fn count_structural_health_findings(
4828        &self,
4829        category: &str,
4830        path_prefix: Option<&str>,
4831        resolution_filter: HealthResolutionFilter<'_>,
4832        scope: HealthScope,
4833    ) -> DbResult<usize> {
4834        match category {
4835            CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => {
4836                self.count_agent_review_required_findings(path_prefix, resolution_filter, scope)
4837            }
4838            CATEGORY_DUPLICATE_PURPOSE => {
4839                self.count_duplicate_purpose_findings(path_prefix, resolution_filter, scope)
4840            }
4841            CATEGORY_REPEATED_TEMPORARY_FOLDER => {
4842                self.count_repeated_temp_folder_findings(path_prefix, resolution_filter, scope)
4843            }
4844            _ => Ok(0),
4845        }
4846    }
4847
4848    /// Load a bounded unresolved structural health page directly from `SQLite`.
4849    fn load_structural_health_findings_page(
4850        &self,
4851        category: &str,
4852        path_prefix: Option<&str>,
4853        resolution_filter: HealthResolutionFilter<'_>,
4854        scope: HealthScope,
4855        start_index: usize,
4856        limit: usize,
4857    ) -> DbResult<Vec<HealthFinding>> {
4858        match category {
4859            CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => self
4860                .load_agent_review_required_findings_page(
4861                    path_prefix,
4862                    resolution_filter,
4863                    scope,
4864                    start_index,
4865                    limit,
4866                ),
4867            CATEGORY_DUPLICATE_PURPOSE => self.load_duplicate_purpose_findings_page(
4868                path_prefix,
4869                resolution_filter,
4870                scope,
4871                start_index,
4872                limit,
4873            ),
4874            CATEGORY_REPEATED_TEMPORARY_FOLDER => self.load_repeated_temp_folder_findings_page(
4875                path_prefix,
4876                resolution_filter,
4877                scope,
4878                start_index,
4879                limit,
4880            ),
4881            _ => Ok(Vec::new()),
4882        }
4883    }
4884
4885    /// Visit approved navigation-critical purposes that still need agent review.
4886    fn visit_agent_review_required_findings<F>(
4887        &self,
4888        resolved_ids: &HashSet<String>,
4889        visitor: &mut F,
4890    ) -> DbResult<bool>
4891    where
4892        F: FnMut(HealthFinding) -> DbResult<bool>,
4893    {
4894        let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
4895        let high_impact = high_impact_file_path_expression("lower(n.path)");
4896        let approved_status = PurposeStatus::Approved.as_str();
4897        let sql = format!(
4898            "
4899            SELECT n.path
4900            FROM nodes n
4901            JOIN purposes p ON p.node_id = n.id
4902            WHERE n.exists_now = 1
4903              AND p.status = '{approved_status}'
4904              AND p.source NOT IN ({reviewed_sources})
4905              AND (n.kind = 'folder' OR (n.kind = 'file' AND {high_impact}))
4906            ORDER BY CASE WHEN n.kind = 'folder' THEN 0 ELSE 1 END, n.path
4907            "
4908        );
4909        let mut statement = self.connection.prepare(&sql)?;
4910        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
4911        for row in rows {
4912            let finding = agent_review_required_finding(row?);
4913            if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
4914                return Ok(false);
4915            }
4916        }
4917        Ok(true)
4918    }
4919
4920    /// Count approved navigation-critical purposes that still need agent review.
4921    fn count_agent_review_required_findings(
4922        &self,
4923        path_prefix: Option<&str>,
4924        resolution_filter: HealthResolutionFilter<'_>,
4925        scope: HealthScope,
4926    ) -> DbResult<usize> {
4927        let (where_clause, values) = structural_finding_where_clause(
4928            CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
4929            path_prefix,
4930            resolution_filter,
4931            scope,
4932            1,
4933        );
4934        let source_relevant = source_relevant_node_expression("n");
4935        let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
4936        let review_candidate = purpose_review_candidate_expression("n", scope);
4937        let approved_status = PurposeStatus::Approved.as_str();
4938        let sql = format!(
4939            "
4940            WITH findings AS (
4941                SELECT n.path,
4942                       n.kind,
4943                       n.language,
4944                       '' AS related_path,
4945                       {source_relevant} AS source_relevant
4946                FROM nodes n
4947                JOIN purposes p ON p.node_id = n.id
4948                WHERE n.exists_now = 1
4949                  AND p.status = '{approved_status}'
4950                  AND p.source NOT IN ({reviewed_sources})
4951                  AND {review_candidate}
4952            )
4953            SELECT COUNT(*)
4954            FROM findings
4955            {where_clause}
4956            "
4957        );
4958        let count = self
4959            .connection
4960            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
4961        count_to_usize("health_agent_review_required_count", count)
4962    }
4963
4964    /// Load approved navigation-critical purposes that still need agent review.
4965    fn load_agent_review_required_findings_page(
4966        &self,
4967        path_prefix: Option<&str>,
4968        resolution_filter: HealthResolutionFilter<'_>,
4969        scope: HealthScope,
4970        start_index: usize,
4971        limit: usize,
4972    ) -> DbResult<Vec<HealthFinding>> {
4973        if limit == 0 {
4974            return Ok(Vec::new());
4975        }
4976        let (where_clause, mut values) = structural_finding_where_clause(
4977            CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
4978            path_prefix,
4979            resolution_filter,
4980            scope,
4981            1,
4982        );
4983        let source_relevant = source_relevant_node_expression("n");
4984        let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
4985        let review_candidate = purpose_review_candidate_expression("n", scope);
4986        let approved_status = PurposeStatus::Approved.as_str();
4987        let limit_placeholder = values.len() + 1;
4988        let offset_placeholder = values.len() + 2;
4989        values.push(Value::from(usize_to_i64(limit)));
4990        values.push(Value::from(usize_to_i64(start_index)));
4991        let sql = format!(
4992            "
4993            WITH findings AS (
4994                SELECT n.path,
4995                       n.kind,
4996                       n.language,
4997                       '' AS related_path,
4998                       {source_relevant} AS source_relevant
4999                FROM nodes n
5000                JOIN purposes p ON p.node_id = n.id
5001                WHERE n.exists_now = 1
5002                  AND p.status = '{approved_status}'
5003                  AND p.source NOT IN ({reviewed_sources})
5004                  AND {review_candidate}
5005            )
5006            SELECT path
5007            FROM findings
5008            {where_clause}
5009            ORDER BY CASE WHEN kind = 'folder' THEN 0 ELSE 1 END, path
5010            LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5011            "
5012        );
5013        let mut statement = self.connection.prepare(&sql)?;
5014        let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(0))?;
5015        let mut findings = Vec::new();
5016        for row in rows {
5017            findings.push(agent_review_required_finding(row?));
5018        }
5019        Ok(findings)
5020    }
5021
5022    /// Count duplicate-purpose findings directly in `SQLite`.
5023    fn count_duplicate_purpose_findings(
5024        &self,
5025        path_prefix: Option<&str>,
5026        resolution_filter: HealthResolutionFilter<'_>,
5027        scope: HealthScope,
5028    ) -> DbResult<usize> {
5029        let (where_clause, values) = structural_finding_where_clause(
5030            CATEGORY_DUPLICATE_PURPOSE,
5031            path_prefix,
5032            resolution_filter,
5033            scope,
5034            1,
5035        );
5036        let source_relevant = source_relevant_node_expression("n");
5037        let duplicate_scope =
5038            "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
5039        let approved_status = PurposeStatus::Approved.as_str();
5040        let sql = format!(
5041            "
5042            WITH duplicate_rows AS (
5043                SELECT n.path,
5044                       n.kind,
5045                       n.language,
5046                       p.purpose,
5047                       {source_relevant} AS source_relevant,
5048                       FIRST_VALUE(n.path) OVER (
5049                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5050                           ORDER BY n.path
5051                       ) AS related_path,
5052                       ROW_NUMBER() OVER (
5053                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5054                           ORDER BY n.path
5055                       ) AS duplicate_rank,
5056                       COUNT(*) OVER (
5057                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5058                       ) AS duplicate_count
5059                FROM nodes n
5060                JOIN purposes p ON p.node_id = n.id
5061                WHERE n.exists_now = 1
5062                  AND p.status = '{approved_status}'
5063                  AND p.purpose IS NOT NULL
5064            ),
5065            findings AS (
5066                SELECT path, kind, language, purpose, related_path, source_relevant
5067                FROM duplicate_rows
5068                WHERE duplicate_count > 1
5069                  AND duplicate_rank > 1
5070            )
5071            SELECT COUNT(*)
5072            FROM findings
5073            {where_clause}
5074            "
5075        );
5076        let count = self
5077            .connection
5078            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5079        count_to_usize("health_duplicate_purpose_count", count)
5080    }
5081
5082    /// Load a bounded duplicate-purpose findings page directly in `SQLite`.
5083    fn load_duplicate_purpose_findings_page(
5084        &self,
5085        path_prefix: Option<&str>,
5086        resolution_filter: HealthResolutionFilter<'_>,
5087        scope: HealthScope,
5088        start_index: usize,
5089        limit: usize,
5090    ) -> DbResult<Vec<HealthFinding>> {
5091        if limit == 0 {
5092            return Ok(Vec::new());
5093        }
5094        let (where_clause, mut values) = structural_finding_where_clause(
5095            CATEGORY_DUPLICATE_PURPOSE,
5096            path_prefix,
5097            resolution_filter,
5098            scope,
5099            1,
5100        );
5101        let source_relevant = source_relevant_node_expression("n");
5102        let duplicate_scope =
5103            "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
5104        let approved_status = PurposeStatus::Approved.as_str();
5105        let limit_placeholder = values.len() + 1;
5106        let offset_placeholder = values.len() + 2;
5107        values.push(Value::from(usize_to_i64(limit)));
5108        values.push(Value::from(usize_to_i64(start_index)));
5109        let sql = format!(
5110            "
5111            WITH duplicate_rows AS (
5112                SELECT n.path,
5113                       n.kind,
5114                       n.language,
5115                       p.purpose,
5116                       {source_relevant} AS source_relevant,
5117                       FIRST_VALUE(n.path) OVER (
5118                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5119                           ORDER BY n.path
5120                       ) AS related_path,
5121                       ROW_NUMBER() OVER (
5122                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5123                           ORDER BY n.path
5124                       ) AS duplicate_rank,
5125                       COUNT(*) OVER (
5126                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5127                       ) AS duplicate_count
5128                FROM nodes n
5129                JOIN purposes p ON p.node_id = n.id
5130                WHERE n.exists_now = 1
5131                  AND p.status = '{approved_status}'
5132                  AND p.purpose IS NOT NULL
5133            ),
5134            findings AS (
5135                SELECT path, kind, language, purpose, related_path, source_relevant
5136                FROM duplicate_rows
5137                WHERE duplicate_count > 1
5138                  AND duplicate_rank > 1
5139            )
5140            SELECT path, kind, related_path
5141            FROM findings
5142            {where_clause}
5143            ORDER BY kind, lower(purpose), path
5144            LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5145            "
5146        );
5147        let mut statement = self.connection.prepare(&sql)?;
5148        let rows = statement.query_map(params_from_iter(values), |row| {
5149            Ok((
5150                row.get::<_, String>(0)?,
5151                row.get::<_, String>(1)?,
5152                row.get::<_, String>(2)?,
5153            ))
5154        })?;
5155        let mut findings = Vec::new();
5156        for row in rows {
5157            let (path, kind_value, related_path) = row?;
5158            let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
5159                field: "kind",
5160                value: kind_value,
5161            })?;
5162            findings.push(HealthFinding {
5163                id: finding_id(CATEGORY_DUPLICATE_PURPOSE, &path, Some(&related_path)),
5164                severity: Severity::Warning,
5165                category: CATEGORY_DUPLICATE_PURPOSE.to_string(),
5166                path,
5167                related_path: Some(related_path),
5168                message: format!("Multiple {kind} nodes share the same purpose."),
5169                recommendation: RECOMMENDATION_DUPLICATE_PURPOSE.to_string(),
5170            });
5171        }
5172        Ok(findings)
5173    }
5174
5175    /// Count repeated temporary-folder findings directly in `SQLite`.
5176    fn count_repeated_temp_folder_findings(
5177        &self,
5178        path_prefix: Option<&str>,
5179        resolution_filter: HealthResolutionFilter<'_>,
5180        scope: HealthScope,
5181    ) -> DbResult<usize> {
5182        let mut total = 0_usize;
5183        for bucket in TEMP_FOLDER_BUCKETS {
5184            total += self.count_repeated_temp_folder_bucket_findings(
5185                bucket,
5186                path_prefix,
5187                resolution_filter,
5188                scope,
5189            )?;
5190        }
5191        Ok(total)
5192    }
5193
5194    /// Count one repeated temporary-folder bucket directly in `SQLite`.
5195    fn count_repeated_temp_folder_bucket_findings(
5196        &self,
5197        bucket: &str,
5198        path_prefix: Option<&str>,
5199        resolution_filter: HealthResolutionFilter<'_>,
5200        scope: HealthScope,
5201    ) -> DbResult<usize> {
5202        let exact = bucket.to_string();
5203        let suffix = format!("%/{bucket}");
5204        let (where_clause, mut filter_values) = structural_finding_where_clause(
5205            CATEGORY_REPEATED_TEMPORARY_FOLDER,
5206            path_prefix,
5207            resolution_filter,
5208            scope,
5209            3,
5210        );
5211        let mut values = vec![Value::from(exact), Value::from(suffix)];
5212        values.append(&mut filter_values);
5213        let source_relevant = source_relevant_node_expression("n");
5214        let sql = format!(
5215            "
5216            WITH bucket_rows AS (
5217                SELECT n.path,
5218                       n.kind,
5219                       n.language,
5220                       {source_relevant} AS source_relevant,
5221                       FIRST_VALUE(n.path) OVER (ORDER BY n.path) AS related_path,
5222                       ROW_NUMBER() OVER (ORDER BY path) AS duplicate_rank,
5223                       COUNT(*) OVER () AS duplicate_count
5224                FROM nodes n
5225                WHERE n.exists_now = 1
5226                  AND n.kind = 'folder'
5227                  AND (lower(n.path) = ?1 OR lower(n.path) LIKE ?2)
5228            ),
5229            findings AS (
5230                SELECT path, kind, language, related_path, source_relevant
5231                FROM bucket_rows
5232                WHERE duplicate_count > 1
5233                  AND duplicate_rank > 1
5234            )
5235            SELECT COUNT(*)
5236            FROM findings
5237            {where_clause}
5238            "
5239        );
5240        let count = self
5241            .connection
5242            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5243        count_to_usize("health_repeated_temp_count", count)
5244    }
5245
5246    /// Load a bounded repeated temporary-folder findings page directly in `SQLite`.
5247    fn load_repeated_temp_folder_findings_page(
5248        &self,
5249        path_prefix: Option<&str>,
5250        resolution_filter: HealthResolutionFilter<'_>,
5251        scope: HealthScope,
5252        start_index: usize,
5253        limit: usize,
5254    ) -> DbResult<Vec<HealthFinding>> {
5255        if limit == 0 {
5256            return Ok(Vec::new());
5257        }
5258        let mut total = 0_usize;
5259        let mut findings = Vec::new();
5260        for bucket in TEMP_FOLDER_BUCKETS {
5261            let matching_count = self.count_repeated_temp_folder_bucket_findings(
5262                bucket,
5263                path_prefix,
5264                resolution_filter,
5265                scope,
5266            )?;
5267            if findings.len() < limit && total + matching_count > start_index {
5268                let local_start = start_index.saturating_sub(total);
5269                let local_limit = limit - findings.len();
5270                findings.extend(self.load_repeated_temp_folder_bucket_findings_page(
5271                    bucket,
5272                    path_prefix,
5273                    resolution_filter,
5274                    scope,
5275                    local_start,
5276                    local_limit,
5277                )?);
5278            }
5279            total += matching_count;
5280            if findings.len() >= limit {
5281                break;
5282            }
5283        }
5284        Ok(findings)
5285    }
5286
5287    /// Load one repeated temporary-folder bucket directly in `SQLite`.
5288    fn load_repeated_temp_folder_bucket_findings_page(
5289        &self,
5290        bucket: &str,
5291        path_prefix: Option<&str>,
5292        resolution_filter: HealthResolutionFilter<'_>,
5293        scope: HealthScope,
5294        start_index: usize,
5295        limit: usize,
5296    ) -> DbResult<Vec<HealthFinding>> {
5297        if limit == 0 {
5298            return Ok(Vec::new());
5299        }
5300        let exact = bucket.to_string();
5301        let suffix = format!("%/{bucket}");
5302        let (where_clause, mut filter_values) = structural_finding_where_clause(
5303            CATEGORY_REPEATED_TEMPORARY_FOLDER,
5304            path_prefix,
5305            resolution_filter,
5306            scope,
5307            3,
5308        );
5309        let mut values = vec![Value::from(exact), Value::from(suffix)];
5310        values.append(&mut filter_values);
5311        let source_relevant = source_relevant_node_expression("n");
5312        let limit_placeholder = values.len() + 1;
5313        let offset_placeholder = values.len() + 2;
5314        values.push(Value::from(usize_to_i64(limit)));
5315        values.push(Value::from(usize_to_i64(start_index)));
5316        let sql = format!(
5317            "
5318            WITH bucket_rows AS (
5319                SELECT n.path,
5320                       n.kind,
5321                       n.language,
5322                       {source_relevant} AS source_relevant,
5323                       FIRST_VALUE(n.path) OVER (ORDER BY n.path) AS related_path,
5324                       ROW_NUMBER() OVER (ORDER BY n.path) AS duplicate_rank,
5325                       COUNT(*) OVER () AS duplicate_count
5326                FROM nodes n
5327                WHERE n.exists_now = 1
5328                  AND n.kind = 'folder'
5329                  AND (lower(n.path) = ?1 OR lower(n.path) LIKE ?2)
5330            ),
5331            findings AS (
5332                SELECT path, kind, language, related_path, source_relevant
5333                FROM bucket_rows
5334                WHERE duplicate_count > 1
5335                  AND duplicate_rank > 1
5336            )
5337            SELECT path, related_path
5338            FROM findings
5339            {where_clause}
5340            ORDER BY path
5341            LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5342            "
5343        );
5344        let mut statement = self.connection.prepare(&sql)?;
5345        let rows = statement.query_map(params_from_iter(values), |row| {
5346            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5347        })?;
5348        let mut findings = Vec::new();
5349        for row in rows {
5350            let (path, related_path) = row?;
5351            findings.push(HealthFinding {
5352                id: finding_id(
5353                    CATEGORY_REPEATED_TEMPORARY_FOLDER,
5354                    &path,
5355                    Some(&related_path),
5356                ),
5357                severity: Severity::Warning,
5358                category: CATEGORY_REPEATED_TEMPORARY_FOLDER.to_string(),
5359                path,
5360                related_path: Some(related_path),
5361                message: format!("Repeated temporary/generated folder name `{bucket}` found."),
5362                recommendation: RECOMMENDATION_REPEATED_TEMPORARY_FOLDER.to_string(),
5363            });
5364        }
5365        Ok(findings)
5366    }
5367
5368    /// Visit duplicate-purpose health findings through grouped SQL candidates.
5369    fn visit_duplicate_purpose_findings<F>(
5370        &self,
5371        resolved_ids: &HashSet<String>,
5372        visitor: &mut F,
5373    ) -> DbResult<bool>
5374    where
5375        F: FnMut(HealthFinding) -> DbResult<bool>,
5376    {
5377        let duplicate_scope =
5378            "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
5379        let approved_status = PurposeStatus::Approved.as_str();
5380        let sql = format!(
5381            "
5382            WITH duplicate_rows AS (
5383                SELECT n.path,
5384                       n.kind,
5385                       p.purpose,
5386                       FIRST_VALUE(n.path) OVER (
5387                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5388                           ORDER BY n.path
5389                       ) AS related_path,
5390                       ROW_NUMBER() OVER (
5391                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5392                           ORDER BY n.path
5393                       ) AS duplicate_rank,
5394                       COUNT(*) OVER (
5395                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5396                       ) AS duplicate_count
5397                FROM nodes n
5398                JOIN purposes p ON p.node_id = n.id
5399                WHERE n.exists_now = 1
5400                  AND p.status = '{approved_status}'
5401                  AND p.purpose IS NOT NULL
5402            )
5403            SELECT path, kind, purpose, related_path
5404            FROM duplicate_rows
5405            WHERE duplicate_count > 1
5406              AND duplicate_rank > 1
5407            ORDER BY kind, lower(purpose), path
5408            "
5409        );
5410        let mut statement = self.connection.prepare(&sql)?;
5411        let rows = statement.query_map([], |row| {
5412            Ok((
5413                row.get::<_, String>(0)?,
5414                row.get::<_, String>(1)?,
5415                row.get::<_, String>(2)?,
5416                row.get::<_, String>(3)?,
5417            ))
5418        })?;
5419        for row in rows {
5420            let (path, kind_value, _purpose, related_path) = row?;
5421            let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
5422                field: "kind",
5423                value: kind_value.clone(),
5424            })?;
5425            let finding = HealthFinding {
5426                id: finding_id(CATEGORY_DUPLICATE_PURPOSE, &path, Some(&related_path)),
5427                severity: Severity::Warning,
5428                category: CATEGORY_DUPLICATE_PURPOSE.to_string(),
5429                path,
5430                related_path: Some(related_path),
5431                message: format!("Multiple {kind} nodes share the same purpose."),
5432                recommendation: RECOMMENDATION_DUPLICATE_PURPOSE.to_string(),
5433            };
5434            if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
5435                return Ok(false);
5436            }
5437        }
5438        Ok(true)
5439    }
5440
5441    /// Visit repeated temporary/generated folder findings.
5442    fn visit_repeated_temp_folder_findings<F>(
5443        &self,
5444        resolved_ids: &HashSet<String>,
5445        visitor: &mut F,
5446    ) -> DbResult<bool>
5447    where
5448        F: FnMut(HealthFinding) -> DbResult<bool>,
5449    {
5450        for bucket in TEMP_FOLDER_BUCKETS {
5451            let exact = bucket.to_string();
5452            let suffix = format!("%/{bucket}");
5453            let mut statement = self.connection.prepare(
5454                "
5455                SELECT path
5456                FROM nodes
5457                WHERE exists_now = 1
5458                  AND kind = 'folder'
5459                  AND (lower(path) = ?1 OR lower(path) LIKE ?2)
5460                ORDER BY path
5461                ",
5462            )?;
5463            let rows =
5464                statement.query_map(params![exact, suffix], |row| row.get::<_, String>(0))?;
5465            let mut first_path = None;
5466            for row in rows {
5467                let path = row?;
5468                let Some(first_path) = first_path.as_ref() else {
5469                    first_path = Some(path);
5470                    continue;
5471                };
5472                let finding = HealthFinding {
5473                    id: finding_id(
5474                        CATEGORY_REPEATED_TEMPORARY_FOLDER,
5475                        &path,
5476                        Some(first_path.as_str()),
5477                    ),
5478                    severity: Severity::Warning,
5479                    category: CATEGORY_REPEATED_TEMPORARY_FOLDER.to_string(),
5480                    path,
5481                    related_path: Some(first_path.clone()),
5482                    message: format!("Repeated temporary/generated folder name `{bucket}` found."),
5483                    recommendation: RECOMMENDATION_REPEATED_TEMPORARY_FOLDER.to_string(),
5484                };
5485                if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
5486                    return Ok(false);
5487                }
5488            }
5489        }
5490        Ok(true)
5491    }
5492
5493    /// Compute an overview from the current index.
5494    ///
5495    /// # Errors
5496    ///
5497    /// Returns an error if the aggregate query fails or a count is invalid.
5498    pub fn overview(&self) -> DbResult<Overview> {
5499        let missing_status = PurposeStatus::Missing.as_str();
5500        let stale_status = PurposeStatus::Stale.as_str();
5501        let approved_status = PurposeStatus::Approved.as_str();
5502        let suggested_status = PurposeStatus::Suggested.as_str();
5503        let sql = format!(
5504            "
5505            SELECT
5506                COALESCE(SUM(CASE WHEN n.kind = 'file' THEN 1 ELSE 0 END), 0),
5507                COALESCE(SUM(CASE WHEN n.kind = 'folder' THEN 1 ELSE 0 END), 0),
5508                COALESCE(SUM(CASE WHEN p.status = '{missing_status}' THEN 1 ELSE 0 END), 0),
5509                COALESCE(SUM(CASE WHEN p.status = '{stale_status}' THEN 1 ELSE 0 END), 0),
5510                COALESCE(SUM(CASE WHEN p.status = '{approved_status}' THEN 1 ELSE 0 END), 0),
5511                COALESCE(SUM(CASE WHEN p.status = '{suggested_status}' THEN 1 ELSE 0 END), 0)
5512            FROM nodes n
5513            JOIN purposes p ON p.node_id = n.id
5514            WHERE n.exists_now = 1
5515            "
5516        );
5517        let counts = self.connection.query_row(&sql, [], |row| {
5518            Ok((
5519                row.get::<_, i64>(0)?,
5520                row.get::<_, i64>(1)?,
5521                row.get::<_, i64>(2)?,
5522                row.get::<_, i64>(3)?,
5523                row.get::<_, i64>(4)?,
5524                row.get::<_, i64>(5)?,
5525            ))
5526        })?;
5527        Ok(Overview {
5528            files: count_to_usize("files", counts.0)?,
5529            folders: count_to_usize("folders", counts.1)?,
5530            missing_purposes: count_to_usize("missing_purposes", counts.2)?,
5531            stale_purposes: count_to_usize("stale_purposes", counts.3)?,
5532            approved_purposes: count_to_usize("approved_purposes", counts.4)?,
5533            suggested_purposes: count_to_usize("suggested_purposes", counts.5)?,
5534        })
5535    }
5536
5537    /// Record a usage event.
5538    ///
5539    /// # Errors
5540    ///
5541    /// Returns an error if persistence fails.
5542    pub fn record_usage(&self, event: &UsageEvent) -> DbResult<()> {
5543        telemetry::validate_event(event, TelemetryRetentionPolicy::default())?;
5544        let instance_id = self.library_usage_instance(&event.session_id, false)?;
5545        match self.record_usage_for_instance(
5546            instance_id,
5547            UsageInstanceOwner::LibraryHandle,
5548            event,
5549            false,
5550        ) {
5551            Err(DbError::TelemetryInstanceInactive) => {
5552                let replacement = self.library_usage_instance(&event.session_id, true)?;
5553                self.record_usage_for_instance(
5554                    replacement,
5555                    UsageInstanceOwner::LibraryHandle,
5556                    event,
5557                    false,
5558                )
5559            }
5560            Err(DbError::TelemetryBaselineCapacity) => {
5561                let replacement = telemetry::generate_usage_instance_id()?;
5562                self.seal_usage_instance(instance_id)?;
5563                self.library_usage_instances
5564                    .borrow_mut()
5565                    .insert(event.session_id.clone(), Some(replacement));
5566                self.record_usage_for_instance(
5567                    replacement,
5568                    UsageInstanceOwner::LibraryHandle,
5569                    event,
5570                    false,
5571                )
5572            }
5573            result => result,
5574        }
5575    }
5576
5577    /// Return or rotate one bounded direct-library runtime instance per caller label.
5578    fn library_usage_instance(
5579        &self,
5580        caller_label: &str,
5581        rotate: bool,
5582    ) -> DbResult<UsageInstanceId> {
5583        let mut instances = self.library_usage_instances.borrow_mut();
5584        if !rotate && let Some(instance) = instances.get(caller_label) {
5585            return instance.ok_or(DbError::TelemetryIdentityUnavailable);
5586        }
5587        if !instances.contains_key(caller_label)
5588            && instances.len() >= TelemetryRetentionPolicy::default().max_retained_labels
5589        {
5590            return Err(DbError::TelemetryInstanceCapacity);
5591        }
5592        let instance = telemetry::generate_usage_instance_id().ok();
5593        instances.insert(caller_label.to_string(), instance);
5594        instance.ok_or(DbError::TelemetryIdentityUnavailable)
5595    }
5596
5597    /// Record a usage event for one bounded runtime or invocation instance.
5598    ///
5599    /// The internal instance is deliberately separate from the optional
5600    /// caller-visible label carried by [`UsageEvent::session_id`].
5601    ///
5602    /// # Errors
5603    ///
5604    /// Returns an error when the selected binding changes, the instance is
5605    /// inactive, a retention bound rejects the event, or `SQLite` cannot commit
5606    /// the complete telemetry transaction.
5607    pub fn record_usage_for_instance(
5608        &self,
5609        instance_id: UsageInstanceId,
5610        owner: UsageInstanceOwner,
5611        event: &UsageEvent,
5612        seal_after_record: bool,
5613    ) -> DbResult<()> {
5614        let policy = TelemetryRetentionPolicy::default();
5615        let project_instance_id = self
5616            .validated_project_instance_id
5617            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
5618        self.with_telemetry_connection(|connection| {
5619            let transaction =
5620                rusqlite::Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?;
5621            let operation = schema::validate_active_binding(
5622                &transaction,
5623                self.validated_project_root.as_deref(),
5624                Some(project_instance_id),
5625            )
5626            .and_then(|()| {
5627                telemetry::record_usage_for_project(
5628                    &transaction,
5629                    project_instance_id,
5630                    instance_id,
5631                    owner,
5632                    event,
5633                    policy,
5634                    seal_after_record,
5635                )
5636            });
5637            match operation {
5638                Ok(()) => transaction.commit().map_err(DbError::from),
5639                Err(operation) => match transaction.rollback() {
5640                    Ok(()) => Err(operation),
5641                    Err(rollback) => Err(DbError::TransactionRollback {
5642                        operation: Box::new(operation),
5643                        rollback,
5644                    }),
5645                },
5646            }?;
5647            // The event is already committed. Passive maintenance remains
5648            // observable through retention state and must never make callers
5649            // retry a successfully persisted event.
5650            drop(telemetry::maintain_after_commit_for_project(
5651                connection,
5652                project_instance_id,
5653                policy,
5654            ));
5655            Ok(())
5656        })
5657    }
5658
5659    /// Seal one cleanly completed runtime or invocation instance.
5660    ///
5661    /// # Errors
5662    ///
5663    /// Returns an error when the selected binding changed, the instance is
5664    /// already inactive, or `SQLite` cannot commit the state transition.
5665    pub fn seal_usage_instance(&self, instance_id: UsageInstanceId) -> DbResult<()> {
5666        self.with_telemetry_connection(|connection| {
5667            let transaction =
5668                rusqlite::Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?;
5669            let operation = schema::validate_active_binding(
5670                &transaction,
5671                self.validated_project_root.as_deref(),
5672                self.validated_project_instance_id,
5673            )
5674            .and_then(|()| telemetry::seal_usage_instance(&transaction, instance_id));
5675            match operation {
5676                Ok(()) => transaction.commit().map_err(Into::into),
5677                Err(operation) => match transaction.rollback() {
5678                    Ok(()) => Err(operation),
5679                    Err(rollback) => Err(DbError::TransactionRollback {
5680                        operation: Box::new(operation),
5681                        rollback,
5682                    }),
5683                },
5684            }
5685        })
5686    }
5687
5688    /// Return content-free bounded telemetry retention and maintenance state.
5689    ///
5690    /// # Errors
5691    ///
5692    /// Returns an error when persisted state is missing, corrupt, or cannot be read.
5693    pub fn telemetry_retention_state(&self) -> DbResult<TelemetryRetentionState> {
5694        telemetry::retention_state(&self.connection)
5695    }
5696
5697    /// Load usage events.
5698    ///
5699    /// # Errors
5700    ///
5701    /// Returns an error if loading fails.
5702    pub fn usage_events(&self, session_id: Option<&str>) -> DbResult<Vec<UsageEvent>> {
5703        telemetry::usage_events(&self.connection, session_id)
5704    }
5705
5706    /// Build a token overview.
5707    ///
5708    /// # Errors
5709    ///
5710    /// Returns an error if loading events fails.
5711    pub fn token_overview(&self, session_id: Option<&str>) -> DbResult<TokenOverview> {
5712        telemetry::token_overview(&self.connection, session_id)
5713    }
5714
5715    /// Build token trend aggregates grouped by day, week, month, or year.
5716    ///
5717    /// # Errors
5718    ///
5719    /// Returns an error if the window is unsupported or loading events fails.
5720    pub fn token_trends(
5721        &self,
5722        session_id: Option<&str>,
5723        window: TokenTrendWindow,
5724    ) -> DbResult<TokenTrendReport> {
5725        telemetry::token_trends(&self.connection, session_id, window)
5726    }
5727
5728    /// Mark a deterministic health finding as agent-resolved.
5729    ///
5730    /// # Errors
5731    ///
5732    /// Returns an error if the finding is not active or persistence fails.
5733    pub fn resolve_health_finding(&self, resolution: &HealthResolution) -> DbResult<()> {
5734        self.with_validated_write(|connection| {
5735            if !self.active_health_finding_matches(resolution)? {
5736                return Err(DbError::HealthFindingNotActive {
5737                    finding_id: resolution.finding_id.clone(),
5738                    category: resolution.category.clone(),
5739                    path: resolution.path.clone(),
5740                });
5741            }
5742            connection.execute(
5743                "
5744            INSERT INTO health_resolutions(
5745                finding_id,
5746                category,
5747                path,
5748                related_path,
5749                rationale,
5750                resolved_by,
5751                resolved_at
5752            )
5753            VALUES(?1, ?2, ?3, ?4, ?5, 'agent', CURRENT_TIMESTAMP)
5754            ON CONFLICT(finding_id) DO UPDATE SET
5755                category = excluded.category,
5756                path = excluded.path,
5757                related_path = excluded.related_path,
5758                rationale = excluded.rationale,
5759                resolved_by = 'agent',
5760                resolved_at = CURRENT_TIMESTAMP
5761            ",
5762                params![
5763                    resolution.finding_id,
5764                    resolution.category,
5765                    resolution.path,
5766                    resolution.related_path,
5767                    resolution.rationale,
5768                ],
5769            )?;
5770            Ok(())
5771        })
5772    }
5773
5774    /// Return whether the visible SQL health surface contains the exact finding.
5775    fn active_health_finding_matches(&self, resolution: &HealthResolution) -> DbResult<bool> {
5776        const PAGE_SIZE: usize = 256;
5777        let mut start_index = 0_usize;
5778        loop {
5779            let page = self.unresolved_health_findings_page_current(&HealthQuery {
5780                start_index,
5781                limit: PAGE_SIZE,
5782                category: Some(resolution.category.clone()),
5783                severity: Some(Severity::Warning),
5784                path_prefix: Some(resolution.path.clone()),
5785                summary_only: false,
5786                scope: HealthScope::all(),
5787            })?;
5788            if page.findings.iter().any(|finding| {
5789                finding.id == resolution.finding_id
5790                    && finding.category == resolution.category
5791                    && finding.path == resolution.path
5792                    && finding.related_path == resolution.related_path
5793            }) {
5794                return Ok(true);
5795            }
5796            if page.returned == 0 || start_index + page.returned >= page.total {
5797                return Ok(false);
5798            }
5799            start_index += page.returned;
5800        }
5801    }
5802
5803    /// Load resolved health finding ids.
5804    ///
5805    /// # Errors
5806    ///
5807    /// Returns an error if reading fails.
5808    pub fn resolved_health_ids(&self) -> DbResult<Vec<String>> {
5809        let mut statement = self
5810            .connection
5811            .prepare("SELECT finding_id FROM health_resolutions ORDER BY finding_id")?;
5812        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
5813        let mut ids = Vec::new();
5814        for row in rows {
5815            ids.push(row?);
5816        }
5817        Ok(ids)
5818    }
5819}
5820
5821/// Acquire the publication writer without waiting after exact input validation.
5822fn begin_immediate_publication(connection: &Connection) -> DbResult<()> {
5823    connection.busy_timeout(SQLITE_PUBLICATION_ACQUIRE_TIMEOUT)?;
5824    let begin_result = connection.execute_batch("BEGIN IMMEDIATE");
5825    let restore_result = connection.busy_timeout(SQLITE_BUSY_TIMEOUT);
5826    match (begin_result, restore_result) {
5827        (Ok(()), Ok(())) => Ok(()),
5828        (Err(error), Ok(())) => Err(error.into()),
5829        (Ok(()), Err(error)) => Err(schema::rollback_after_error(connection, error.into())),
5830        (Err(operation), Err(restore)) => Err(DbError::PublicationAcquirePolicyRestore {
5831            operation: Box::new(operation),
5832            restore: Box::new(restore),
5833        }),
5834    }
5835}
5836
5837/// Read the recorded project root without creating or migrating a database.
5838///
5839/// # Errors
5840///
5841/// Returns an error if `SQLite` cannot open or query the database read-only.
5842pub fn read_project_root_read_only(path: &Path) -> DbResult<Option<String>> {
5843    schema::read_project_root(path)
5844}
5845
5846/// Verify the current project database schema, identity, and full integrity read-only.
5847///
5848/// # Errors
5849///
5850/// Returns an error when the database is missing, incompatible, corrupt, or belongs to another
5851/// project root.
5852pub fn verify_project_database(path: &Path, project_root: &Path) -> DbResult<()> {
5853    schema::verify_current_integrity(path, Some(&normalize_native_path_display(project_root)))
5854}
5855
5856/// Normalize a filesystem path stored in `SQLite` metadata.
5857fn normalize_metadata_path(path: &Path) -> String {
5858    normalize_native_path_display(path)
5859}
5860
5861/// Upsert one metadata value through the caller's active connection/transaction.
5862fn set_metadata(connection: &Connection, key: &str, value: &str) -> DbResult<()> {
5863    connection.execute(
5864        "
5865        INSERT INTO metadata(key, value)
5866        VALUES(?1, ?2)
5867        ON CONFLICT(key) DO UPDATE SET value = excluded.value
5868        ",
5869        [key, value],
5870    )?;
5871    Ok(())
5872}
5873
5874/// Load the accepted authored-purpose revision without scanning purpose rows.
5875fn load_authored_purpose_revision(connection: &Connection) -> DbResult<u64> {
5876    let value = connection
5877        .prepare_cached("SELECT value FROM metadata WHERE key = ?1")?
5878        .query_row([AUTHORED_PURPOSE_REVISION_KEY], |row| {
5879            row.get::<_, String>(0)
5880        })
5881        .optional()?;
5882    value.map_or(Ok(0), |value| {
5883        value
5884            .parse::<u64>()
5885            .map_err(|source| DbError::InvalidInteger {
5886                field: AUTHORED_PURPOSE_REVISION_KEY,
5887                value,
5888                source,
5889            })
5890    })
5891}
5892
5893/// Advance the accepted authored-purpose revision in the caller's transaction.
5894fn advance_authored_purpose_revision(connection: &Connection) -> DbResult<u64> {
5895    let current = load_authored_purpose_revision(connection)?;
5896    let revision = current
5897        .checked_add(1)
5898        .ok_or(DbError::IntegerMetadataOverflow {
5899            field: AUTHORED_PURPOSE_REVISION_KEY,
5900            value: current,
5901        })?;
5902    set_metadata(
5903        connection,
5904        AUTHORED_PURPOSE_REVISION_KEY,
5905        &revision.to_string(),
5906    )?;
5907    Ok(revision)
5908}
5909
5910/// Load the authoritative and projection revisions without scanning text rows.
5911fn load_file_text_fts_revisions(connection: &Connection) -> DbResult<Option<(u64, u64)>> {
5912    let (source, projection) = connection.query_row(
5913        "
5914        SELECT
5915            (SELECT value FROM metadata WHERE key = ?1),
5916            (SELECT value FROM metadata WHERE key = ?2)
5917        ",
5918        params![
5919            FILE_TEXT_FTS_SOURCE_REVISION_KEY,
5920            FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
5921        ],
5922        |row| {
5923            Ok((
5924                row.get::<_, Option<String>>(0)?,
5925                row.get::<_, Option<String>>(1)?,
5926            ))
5927        },
5928    )?;
5929    if source.is_none() && projection.is_none() {
5930        return Ok(None);
5931    }
5932    let (Some(source), Some(projection)) = (source, projection) else {
5933        return Err(DbError::FileTextFtsStateInvalid {
5934            reason: "only one FTS revision is present",
5935        });
5936    };
5937    let source_revision =
5938        source
5939            .parse::<u64>()
5940            .map_err(|source_error| DbError::InvalidInteger {
5941                field: FILE_TEXT_FTS_SOURCE_REVISION_KEY,
5942                value: source,
5943                source: source_error,
5944            })?;
5945    let projection_revision =
5946        projection
5947            .parse::<u64>()
5948            .map_err(|source_error| DbError::InvalidInteger {
5949                field: FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
5950                value: projection,
5951                source: source_error,
5952            })?;
5953    Ok(Some((source_revision, projection_revision)))
5954}
5955
5956/// Mark an authoritative text mutation before changing its FTS projection.
5957fn begin_file_text_fts_update(connection: &Connection) -> DbResult<u64> {
5958    let (source, projection) =
5959        load_file_text_fts_revisions(connection)?.ok_or(DbError::FileTextFtsStateInvalid {
5960            reason: "FTS revisions are missing",
5961        })?;
5962    if source != projection {
5963        return Err(DbError::FileTextFtsStateInvalid {
5964            reason: "an earlier FTS revision is incomplete",
5965        });
5966    }
5967    let revision = source
5968        .checked_add(1)
5969        .ok_or(DbError::FileTextFtsStateInvalid {
5970            reason: "FTS revision overflowed",
5971        })?;
5972    set_metadata(
5973        connection,
5974        FILE_TEXT_FTS_SOURCE_REVISION_KEY,
5975        &revision.to_string(),
5976    )?;
5977    Ok(revision)
5978}
5979
5980/// Publish the matching projection revision after every FTS mutation succeeds.
5981fn complete_file_text_fts_update(connection: &Connection, revision: u64) -> DbResult<()> {
5982    let (source, projection) =
5983        load_file_text_fts_revisions(connection)?.ok_or(DbError::FileTextFtsStateInvalid {
5984            reason: "FTS revisions disappeared during an update",
5985        })?;
5986    if source != revision || projection.checked_add(1) != Some(revision) {
5987        return Err(DbError::FileTextFtsStateInvalid {
5988            reason: "FTS revisions changed during an update",
5989        });
5990    }
5991    set_metadata(
5992        connection,
5993        FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
5994        &revision.to_string(),
5995    )
5996}
5997
5998/// Persist one usage event in the immutable released schema-8 fixture shape.
5999#[cfg(test)]
6000fn record_released_schema_eight_usage_event(
6001    connection: &Connection,
6002    event: &UsageEvent,
6003) -> DbResult<()> {
6004    connection.execute(
6005        "
6006        INSERT INTO usage_events(
6007            session_id,
6008            command,
6009            path,
6010            query,
6011            estimated_tokens_without_projectatlas,
6012            estimated_tokens_with_projectatlas,
6013            estimated_tokens_saved,
6014            token_savings_bucket,
6015            provider,
6016            model,
6017            tokenizer_backend,
6018            accuracy,
6019            baseline_kind,
6020            confidence,
6021            calculation_trace,
6022            accounting_layer,
6023            estimate_method,
6024            denominator_kind,
6025            baseline_identity,
6026            baseline_fingerprint,
6027            dedupe_scope,
6028            created_at
6029        )
6030        VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, CURRENT_TIMESTAMP)
6031        ",
6032        params![
6033            event.session_id,
6034            event.command,
6035            event.path,
6036            event.query,
6037            event.estimated_tokens_without_projectatlas,
6038            event.estimated_tokens_with_projectatlas,
6039            event.estimated_tokens_saved,
6040            event.token_savings_bucket,
6041            event.provider,
6042            event.model,
6043            event.tokenizer_backend,
6044            event.accuracy,
6045            event.baseline_kind,
6046            event.confidence,
6047            event.calculation_trace,
6048            event.accounting_layer,
6049            event.estimate_method,
6050            event.denominator_kind,
6051            event.baseline_identity,
6052            event.baseline_fingerprint,
6053            event.dedupe_scope
6054        ],
6055    )?;
6056    Ok(())
6057}
6058
6059/// Load durable publication metadata from one connection snapshot.
6060fn load_index_publication(connection: &Connection) -> DbResult<Option<IndexPublication>> {
6061    let row = connection
6062        .query_row(
6063            "
6064            SELECT state.value, fingerprint.value, generation.value
6065            FROM metadata AS state
6066            LEFT JOIN metadata AS fingerprint ON fingerprint.key = ?2
6067            LEFT JOIN metadata AS generation ON generation.key = ?3
6068            WHERE state.key = ?1
6069            ",
6070            params![
6071                INDEX_PUBLICATION_STATE_KEY,
6072                INDEX_PUBLICATION_FINGERPRINT_KEY,
6073                INDEX_PUBLICATION_GENERATION_KEY,
6074            ],
6075            |row| {
6076                Ok((
6077                    row.get::<_, String>(0)?,
6078                    row.get::<_, Option<String>>(1)?,
6079                    row.get::<_, Option<String>>(2)?,
6080                ))
6081            },
6082        )
6083        .optional()?;
6084    let Some((state, contract_fingerprint, generation)) = row else {
6085        return Ok(None);
6086    };
6087    let generation = generation.map_or(Ok(IndexGeneration::ZERO), |value| {
6088        value
6089            .parse::<u64>()
6090            .map(IndexGeneration::new)
6091            .map_err(|source| DbError::InvalidInteger {
6092                field: INDEX_PUBLICATION_GENERATION_KEY,
6093                value,
6094                source,
6095            })
6096    })?;
6097    Ok(Some(IndexPublication {
6098        state: IndexPublicationState::from_db(state)?,
6099        contract_fingerprint,
6100        generation,
6101    }))
6102}
6103
6104/// Mark every indexed node absent before a complete scan replacement.
6105fn mark_all_scan_nodes_absent(connection: &Connection) -> DbResult<()> {
6106    connection.execute("UPDATE nodes SET exists_now = 0", [])?;
6107    Ok(())
6108}
6109
6110/// Delete derived rows whose owning scan node remained absent.
6111fn delete_absent_scan_projections(connection: &Connection) -> DbResult<()> {
6112    let fts_revision = begin_file_text_fts_update(connection)?;
6113    connection.execute(
6114        "DELETE FROM symbol_relations WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6115        [],
6116    )?;
6117    connection.execute(
6118        "DELETE FROM symbols WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6119        [],
6120    )?;
6121    connection.execute(
6122        "DELETE FROM source_parse_metadata WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6123        [],
6124    )?;
6125    connection.execute(
6126        "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
6127         SELECT 'delete', rowid, content FROM file_texts \
6128         WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6129        [],
6130    )?;
6131    connection.execute(
6132        "DELETE FROM file_texts WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6133        [],
6134    )?;
6135    complete_file_text_fts_update(connection, fts_revision)?;
6136    Ok(())
6137}
6138
6139/// Upsert scanned nodes through transaction-owned prepared statements.
6140fn upsert_nodes(connection: &Connection, nodes: &[Node]) -> DbResult<()> {
6141    let mut select_existing = connection.prepare_cached(
6142        "
6143        SELECT content_hash
6144        FROM nodes
6145        WHERE path = ?1
6146        ",
6147    )?;
6148    let mut upsert_node = connection.prepare_cached(
6149        "
6150        INSERT INTO nodes(path, kind, parent_path, extension, language, size_bytes, mtime_ns, content_hash, exists_now)
6151        VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)
6152        ON CONFLICT(path) DO UPDATE SET
6153            kind = excluded.kind,
6154            parent_path = excluded.parent_path,
6155            extension = excluded.extension,
6156            language = excluded.language,
6157            size_bytes = excluded.size_bytes,
6158            mtime_ns = excluded.mtime_ns,
6159            content_hash = excluded.content_hash,
6160            exists_now = 1,
6161            last_seen_at = CURRENT_TIMESTAMP,
6162            last_indexed_at = CURRENT_TIMESTAMP
6163        ",
6164    )?;
6165    let mut select_node_id = connection.prepare_cached("SELECT id FROM nodes WHERE path = ?1")?;
6166    let mut ensure_purpose = connection.prepare_cached(
6167        "
6168        INSERT INTO purposes(node_id, purpose, source, status)
6169        VALUES(?1, NULL, 'missing', 'missing')
6170        ON CONFLICT(node_id) DO NOTHING
6171        ",
6172    )?;
6173    let mut upsert_summary = connection.prepare_cached(
6174        "
6175        INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
6176        VALUES(?1, 'node', '', ?2, CURRENT_TIMESTAMP)
6177        ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
6178            summary = CASE WHEN ?3 THEN excluded.summary ELSE summaries.summary END,
6179            updated_at = CURRENT_TIMESTAMP
6180        ",
6181    )?;
6182    for node in nodes {
6183        let existing = select_existing
6184            .query_row([&node.path], |row| row.get::<_, Option<String>>(0))
6185            .optional()?;
6186        let content_changed = existing.as_ref().is_some_and(|old_hash| {
6187            node.kind == NodeKind::File
6188                && old_hash.is_some()
6189                && node.content_hash.is_some()
6190                && old_hash != &node.content_hash
6191        });
6192        upsert_node.execute(params![
6193            node.path,
6194            node.kind.to_string(),
6195            node.parent_path,
6196            node.extension,
6197            node.language,
6198            node.size_bytes,
6199            node.mtime_ns,
6200            node.content_hash
6201        ])?;
6202        let node_id = select_node_id.query_row([&node.path], |row| row.get::<_, i64>(0))?;
6203        ensure_purpose.execute([node_id])?;
6204        upsert_summary.execute(params![
6205            node_id,
6206            generate_node_summary(node),
6207            content_changed
6208        ])?;
6209    }
6210    Ok(())
6211}
6212
6213/// Read one persisted indexed text row.
6214fn file_text_from_row(row: &rusqlite::Row<'_>) -> DbResult<IndexedFileText> {
6215    let byte_count = count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?;
6216    let line_count = count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?;
6217    let text = IndexedFileText {
6218        path: row.get(0)?,
6219        content_hash: row.get(1)?,
6220        byte_count,
6221        line_count,
6222        content: row.get(4)?,
6223    };
6224    validate_indexed_file_text(&text)?;
6225    Ok(text)
6226}
6227
6228/// Verify that persisted byte/line accounting matches authoritative content.
6229fn validate_indexed_file_text(text: &IndexedFileText) -> DbResult<()> {
6230    let actual_bytes = text.content.len();
6231    if text.byte_count != actual_bytes {
6232        return Err(DbError::FileTextMetadataMismatch {
6233            path: text.path.clone(),
6234            field: "byte_count",
6235            recorded: text.byte_count,
6236            actual: actual_bytes,
6237        });
6238    }
6239    let actual_lines = text.content.lines().count();
6240    if text.line_count != actual_lines {
6241        return Err(DbError::FileTextMetadataMismatch {
6242            path: text.path.clone(),
6243            field: "line_count",
6244            recorded: text.line_count,
6245            actual: actual_lines,
6246        });
6247    }
6248    Ok(())
6249}
6250
6251/// Virtual-machine operations between bounded database-read progress checks.
6252const SQLITE_READ_PROGRESS_OPS: i32 = 1_000;
6253
6254/// Deterministic observation of production `SQLite` progress boundaries for downstream tests.
6255#[cfg(feature = "sqlite-progress-test-observer")]
6256pub mod sqlite_progress_test_observer {
6257    use projectatlas_core::IndexWorkStage;
6258    use std::cell::RefCell;
6259
6260    /// One observable event emitted by the production `SQLite` read-progress boundary.
6261    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
6262    pub enum SqliteReadProgressEvent {
6263        /// A repository relation-family query entered after its live-control precheck.
6264        RepositoryRelationFamilyQueryEntered,
6265        /// A repository relation-family query returned from `SQLite`.
6266        RepositoryRelationFamilyQueryExited,
6267        /// `SQLite` invoked the production progress callback.
6268        CallbackEntered {
6269            /// Typed work stage owned by the guarded query.
6270            stage: IndexWorkStage,
6271        },
6272        /// The production progress callback evaluated its control.
6273        CallbackEvaluated {
6274            /// Typed work stage owned by the guarded query.
6275            stage: IndexWorkStage,
6276            /// Whether the callback requested `SQLite` interruption.
6277            interrupted: bool,
6278        },
6279    }
6280
6281    /// Thread-local callback used only while a downstream test owns observation.
6282    type Observer = Box<dyn FnMut(SqliteReadProgressEvent)>;
6283
6284    thread_local! {
6285        /// Observer scoped to the synchronous test thread running the SQLite connection.
6286        static OBSERVER: RefCell<Option<Observer>> = RefCell::new(None);
6287    }
6288
6289    /// Restores a prior nested observer on every return or unwind path.
6290    struct ObserverGuard {
6291        /// Observer replaced for the current scope.
6292        previous: Option<Observer>,
6293    }
6294
6295    impl Drop for ObserverGuard {
6296        fn drop(&mut self) {
6297            OBSERVER.with(|slot| {
6298                drop(slot.replace(self.previous.take()));
6299            });
6300        }
6301    }
6302
6303    /// Run one operation while observing its synchronous `SQLite` progress events.
6304    pub fn observe_sqlite_read_progress<T>(
6305        observer: impl FnMut(SqliteReadProgressEvent) + 'static,
6306        operation: impl FnOnce() -> T,
6307    ) -> T {
6308        let previous = OBSERVER.with(|slot| slot.replace(Some(Box::new(observer))));
6309        let _guard = ObserverGuard { previous };
6310        operation()
6311    }
6312
6313    /// Notify the current thread-local observer when one is installed.
6314    pub(crate) fn notify(event: SqliteReadProgressEvent) {
6315        OBSERVER.with(|slot| {
6316            if let Some(observer) = slot.borrow_mut().as_mut() {
6317                observer(event);
6318            }
6319        });
6320    }
6321}
6322
6323/// Clears a connection-local `SQLite` progress handler on every exit path.
6324pub(crate) struct SqliteReadProgressGuard<'connection> {
6325    /// Connection whose temporary progress callback is armed.
6326    connection: &'connection Connection,
6327    /// Whether this guard installed a callback that must be removed.
6328    armed: bool,
6329}
6330
6331impl<'connection> SqliteReadProgressGuard<'connection> {
6332    /// Install one cooperative progress callback for the owning work stage.
6333    pub(crate) fn new(
6334        connection: &'connection Connection,
6335        control: Option<&IndexWorkControl>,
6336        stage: IndexWorkStage,
6337    ) -> DbResult<Self> {
6338        let armed = if let Some(control) = control {
6339            control.check(stage)?;
6340            let progress_control = control.clone();
6341            connection.progress_handler(
6342                SQLITE_READ_PROGRESS_OPS,
6343                Some(move || {
6344                    #[cfg(feature = "sqlite-progress-test-observer")]
6345                    sqlite_progress_test_observer::notify(
6346                        sqlite_progress_test_observer::SqliteReadProgressEvent::CallbackEntered {
6347                            stage,
6348                        },
6349                    );
6350                    let interrupted = progress_control.check(stage).is_err();
6351                    #[cfg(feature = "sqlite-progress-test-observer")]
6352                    sqlite_progress_test_observer::notify(
6353                        sqlite_progress_test_observer::SqliteReadProgressEvent::CallbackEvaluated {
6354                            stage,
6355                            interrupted,
6356                        },
6357                    );
6358                    interrupted
6359                }),
6360            );
6361            true
6362        } else {
6363            false
6364        };
6365        Ok(Self { connection, armed })
6366    }
6367}
6368
6369impl Drop for SqliteReadProgressGuard<'_> {
6370    fn drop(&mut self) {
6371        if self.armed {
6372            self.connection.progress_handler(0, None::<fn() -> bool>);
6373        }
6374    }
6375}
6376
6377/// Run one read with a temporary cooperative progress handler.
6378pub(crate) fn with_sqlite_read_progress<T>(
6379    connection: &Connection,
6380    control: Option<&IndexWorkControl>,
6381    stage: IndexWorkStage,
6382    operation: impl FnOnce() -> DbResult<T>,
6383) -> DbResult<T> {
6384    let guard = SqliteReadProgressGuard::new(connection, control, stage)?;
6385    let result = operation();
6386    drop(guard);
6387    if result.as_ref().is_err_and(|error| {
6388        matches!(
6389            error,
6390            DbError::Sqlite(sqlite)
6391                if sqlite.sqlite_error_code() == Some(ErrorCode::OperationInterrupted)
6392        )
6393    }) && let Some(control) = control
6394    {
6395        control.check(stage)?;
6396    }
6397    result
6398}
6399
6400/// Run one text-index read through the shared bounded database-read guard.
6401fn with_file_text_progress<T>(
6402    connection: &Connection,
6403    control: Option<&IndexWorkControl>,
6404    operation: impl FnOnce() -> DbResult<T>,
6405) -> DbResult<T> {
6406    with_sqlite_read_progress(connection, control, IndexWorkStage::TextIndex, operation)
6407}
6408
6409/// Enforce the single safe-token contract used by FTS candidate lookup.
6410fn validate_file_text_fts_token(token: &str) -> DbResult<()> {
6411    if token.len() < 3 {
6412        return Err(DbError::FileTextFtsTokenUnsafe {
6413            reason: "token is shorter than three ASCII bytes",
6414        });
6415    }
6416    if !token.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
6417        return Err(DbError::FileTextFtsTokenUnsafe {
6418            reason: "token is not exclusively ASCII alphanumeric",
6419        });
6420    }
6421    Ok(())
6422}
6423
6424/// Normalize only the root/trailing-separator cases of an already-relative scope.
6425fn normalized_file_text_path_prefix(path_prefix: Option<&str>) -> Option<&str> {
6426    path_prefix
6427        .map(|prefix| prefix.trim_end_matches('/'))
6428        .filter(|prefix| !prefix.is_empty() && *prefix != ".")
6429}
6430
6431/// Build one binary-collation range containing exactly a path's descendants.
6432fn file_text_descendant_range(path_prefix: &str) -> (String, String) {
6433    (format!("{path_prefix}/"), format!("{path_prefix}0"))
6434}
6435
6436/// Decode one bounded page of FTS metadata candidates without source text.
6437fn collect_file_text_fts_candidates(
6438    rows: &mut rusqlite::Rows<'_>,
6439    control: Option<&IndexWorkControl>,
6440    candidates: &mut Vec<FileTextFtsCandidate>,
6441) -> DbResult<()> {
6442    while let Some(row) = rows.next()? {
6443        if let Some(control) = control {
6444            control.check(IndexWorkStage::TextIndex)?;
6445        }
6446        let path = row.get::<_, String>(0)?;
6447        let bm25 = row.get::<_, f64>(4)?;
6448        if !bm25.is_finite() {
6449            return Err(DbError::FileTextFtsScoreInvalid { path });
6450        }
6451        candidates.push(FileTextFtsCandidate {
6452            path,
6453            content_hash: row.get(1)?,
6454            byte_count: count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?,
6455            line_count: count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?,
6456            bm25,
6457        });
6458    }
6459    Ok(())
6460}
6461
6462/// Decode persisted file metadata before the potentially large source column.
6463fn file_text_metadata_from_row(row: &rusqlite::Row<'_>) -> DbResult<FileTextMetadata> {
6464    Ok(FileTextMetadata {
6465        path: row.get(0)?,
6466        content_hash: row.get(1)?,
6467        byte_count: count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?,
6468        line_count: count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?,
6469    })
6470}
6471
6472/// Visit fallback rows with metadata-first admission and cooperative stops.
6473fn visit_file_text_fallback_rows<A, V>(
6474    rows: &mut rusqlite::Rows<'_>,
6475    content_statement: &mut rusqlite::CachedStatement<'_>,
6476    control: Option<&IndexWorkControl>,
6477    admit: &mut A,
6478    visitor: &mut V,
6479) -> DbResult<()>
6480where
6481    A: FnMut(&FileTextMetadata) -> DbResult<FileTextAdmission>,
6482    V: FnMut(IndexedFileText) -> DbResult<bool>,
6483{
6484    while let Some(row) = rows.next()? {
6485        if let Some(control) = control {
6486            control.check(IndexWorkStage::TextIndex)?;
6487        }
6488        let metadata = file_text_metadata_from_row(row)?;
6489        match admit(&metadata)? {
6490            FileTextAdmission::Skip => continue,
6491            FileTextAdmission::Stop => return Ok(()),
6492            FileTextAdmission::Read => {}
6493        }
6494        let content =
6495            content_statement.query_row([&metadata.path], |content_row| content_row.get(0))?;
6496        let text = IndexedFileText {
6497            path: metadata.path,
6498            content_hash: metadata.content_hash,
6499            byte_count: metadata.byte_count,
6500            line_count: metadata.line_count,
6501            content,
6502        };
6503        validate_indexed_file_text(&text)?;
6504        if !visitor(text)? {
6505            return Ok(());
6506        }
6507    }
6508    Ok(())
6509}
6510
6511/// Raw standard node columns retained until typed enum validation succeeds.
6512type IndexedNodeParts = (
6513    String,
6514    String,
6515    Option<String>,
6516    Option<String>,
6517    Option<String>,
6518    Option<u64>,
6519    Option<i64>,
6520    Option<String>,
6521    Option<String>,
6522    String,
6523    String,
6524    Option<String>,
6525);
6526
6527/// Decode the standard node select column order without interpreting enums.
6528fn indexed_node_parts_from_sql_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<IndexedNodeParts> {
6529    let kind_value: String = row.get(1)?;
6530    let source_value: String = row.get(9)?;
6531    let status_value: String = row.get(10)?;
6532    Ok((
6533        row.get::<_, String>(0)?,
6534        kind_value,
6535        row.get::<_, Option<String>>(2)?,
6536        row.get::<_, Option<String>>(3)?,
6537        row.get::<_, Option<String>>(4)?,
6538        row.get::<_, Option<u64>>(5)?,
6539        row.get::<_, Option<i64>>(6)?,
6540        row.get::<_, Option<String>>(7)?,
6541        row.get::<_, Option<String>>(8)?,
6542        source_value,
6543        status_value,
6544        row.get::<_, Option<String>>(11)?,
6545    ))
6546}
6547
6548/// Build an indexed node from the standard node select column order.
6549fn indexed_node_from_sql_row(row: &rusqlite::Row<'_>) -> DbResult<IndexedNode> {
6550    indexed_node_from_parts(indexed_node_parts_from_sql_row(row)?)
6551}
6552
6553/// Count exact variable-width payload and fixed scalar slots for one node row.
6554fn indexed_node_parts_decoded_bytes(row: &IndexedNodeParts) -> DbResult<u64> {
6555    let lengths = [
6556        row.0.len(),
6557        row.1.len(),
6558        row.2.as_ref().map_or(0, String::len),
6559        row.3.as_ref().map_or(0, String::len),
6560        row.4.as_ref().map_or(0, String::len),
6561        row.7.as_ref().map_or(0, String::len),
6562        row.8.as_ref().map_or(0, String::len),
6563        row.9.len(),
6564        row.10.len(),
6565        row.11.as_ref().map_or(0, String::len),
6566    ];
6567    let mut bytes = 16_u64;
6568    for length in lengths {
6569        let length =
6570            u64::try_from(length).map_err(|_source| GraphContractError::InvalidLimits {
6571                reason: "purpose-owner decoded field length overflowed",
6572            })?;
6573        bytes = bytes
6574            .checked_add(length)
6575            .ok_or(GraphContractError::InvalidLimits {
6576                reason: "purpose-owner decoded row size overflowed",
6577            })?;
6578    }
6579    Ok(bytes)
6580}
6581
6582/// Build an indexed node from database row parts.
6583fn indexed_node_from_parts(row: IndexedNodeParts) -> DbResult<IndexedNode> {
6584    let (
6585        path,
6586        kind_value,
6587        parent_path,
6588        extension,
6589        language,
6590        size_bytes,
6591        mtime_ns,
6592        content_hash,
6593        purpose,
6594        source_value,
6595        status_value,
6596        summary,
6597    ) = row;
6598    let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
6599        field: "kind",
6600        value: kind_value,
6601    })?;
6602    let source = parse_source(&source_value)?;
6603    let status = PurposeStatus::from_db(&status_value).ok_or_else(|| DbError::InvalidEnum {
6604        field: "status",
6605        value: status_value,
6606    })?;
6607    Ok(IndexedNode {
6608        node: Node {
6609            path: path.clone(),
6610            kind,
6611            parent_path,
6612            extension,
6613            language,
6614            size_bytes,
6615            mtime_ns,
6616            content_hash,
6617        },
6618        purpose: Purpose {
6619            path,
6620            purpose,
6621            source,
6622            status,
6623        },
6624        summary,
6625    })
6626}
6627
6628/// Split a user query into lowercase terms for SQL ranking.
6629fn normalize_query_terms(query: &str) -> Vec<String> {
6630    query
6631        .split(|character: char| !character.is_alphanumeric())
6632        .filter(|term| !term.is_empty())
6633        .map(str::to_lowercase)
6634        .collect()
6635}
6636
6637/// Normalize the complete query used by exact path and basename admission tiers.
6638fn normalize_exact_ranked_query(query: &str) -> String {
6639    query.trim().replace('\\', "/").to_lowercase()
6640}
6641
6642/// Build the reviewed-purpose admission predicate for normalized query terms.
6643fn reviewed_purpose_match_expression(term_count: usize) -> String {
6644    if term_count == 0 {
6645        return "0".to_string();
6646    }
6647    let matches = std::iter::repeat_n(
6648        "lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\'",
6649        term_count,
6650    )
6651    .collect::<Vec<_>>()
6652    .join(" OR ");
6653    format!(
6654        "CASE WHEN p.status = 'approved' AND p.source IN ('agent', 'human') \
6655                    AND ({matches}) THEN 1 ELSE 0 END"
6656    )
6657}
6658
6659/// Build the SQL score expression for ranked node lookup.
6660fn ranked_score_expression(term_count: usize) -> String {
6661    if term_count == 0 {
6662        return "1".to_string();
6663    }
6664    (0..term_count)
6665        .map(|_| {
6666            "(CASE WHEN lower(n.path) LIKE ? ESCAPE '\\' THEN 20 ELSE 0 END \
6667             + CASE WHEN p.status = 'approved' AND p.source IN ('agent', 'human') \
6668                          AND lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\' THEN 30 ELSE 0 END \
6669             + CASE WHEN NOT (p.status = 'approved' AND p.source IN ('agent', 'human')) \
6670                          AND lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\' THEN 2 ELSE 0 END \
6671             + CASE WHEN lower(COALESCE(s.summary, '')) LIKE ? ESCAPE '\\' THEN 10 ELSE 0 END \
6672             + CASE WHEN lower(COALESCE(symbol_summaries.summary, '')) LIKE ? ESCAPE '\\' THEN 25 ELSE 0 END)"
6673                .to_string()
6674        })
6675        .collect::<Vec<_>>()
6676        .join(" + ")
6677}
6678
6679/// Convert a normalized term into a `SQLite` LIKE pattern.
6680fn sqlite_like_pattern(term: &str) -> String {
6681    format!("%{}%", sqlite_like_escape(term))
6682}
6683
6684/// Build a `SQLite` LIKE descendant pattern for a repository path prefix.
6685fn sqlite_descendant_pattern(path: &str) -> String {
6686    format!("{}/%", sqlite_like_escape(path))
6687}
6688
6689/// Escape user or path text for `SQLite` LIKE patterns with backslash escaping.
6690fn sqlite_like_escape(value: &str) -> String {
6691    value
6692        .replace('\\', "\\\\")
6693        .replace('%', "\\%")
6694        .replace('_', "\\_")
6695}
6696
6697/// Replace the denormalized symbol-name search summary for one file node.
6698fn replace_symbol_search_summary(
6699    connection: &Connection,
6700    node_id: i64,
6701    summary: Option<&str>,
6702) -> DbResult<()> {
6703    if let Some(summary) = summary {
6704        connection
6705            .prepare_cached(
6706                "
6707                INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
6708                VALUES(?1, 'search', 'symbols', ?2, CURRENT_TIMESTAMP)
6709                ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
6710                    summary = excluded.summary,
6711                    updated_at = CURRENT_TIMESTAMP
6712                ",
6713            )?
6714            .execute(params![node_id, summary])?;
6715    } else {
6716        connection
6717            .prepare_cached(
6718                "
6719                DELETE FROM summaries
6720                WHERE node_id = ?1
6721                  AND summary_level = 'search'
6722                  AND subject = 'symbols'
6723                ",
6724            )?
6725            .execute([node_id])?;
6726    }
6727    Ok(())
6728}
6729
6730/// Build a bounded search-only summary from symbol names.
6731fn symbol_search_summary(graph: &SymbolGraph) -> Option<String> {
6732    let mut names = graph
6733        .symbols
6734        .iter()
6735        .filter(|symbol| !matches!(symbol.kind, SymbolKind::Import | SymbolKind::Unknown))
6736        .map(|symbol| symbol.name.trim())
6737        .filter(|name| !name.is_empty())
6738        .map(ToString::to_string)
6739        .collect::<Vec<_>>();
6740    names.sort();
6741    names.dedup();
6742    if names.is_empty() {
6743        return None;
6744    }
6745    let summary = format!("symbols {}", names.join(" "));
6746    Some(truncate_summary_chars(
6747        &summary,
6748        MAX_SYMBOL_SEARCH_SUMMARY_CHARS,
6749    ))
6750}
6751
6752/// Truncate a summary at a valid UTF-8 boundary.
6753fn truncate_summary_chars(value: &str, max_chars: usize) -> String {
6754    if value.chars().count() <= max_chars {
6755        return value.to_string();
6756    }
6757    value.chars().take(max_chars).collect()
6758}
6759
6760/// Parse a stored purpose source value into the domain enum.
6761fn parse_source(value: &str) -> DbResult<PurposeSource> {
6762    let source = match value {
6763        value if value == PurposeSource::Missing.as_str() => PurposeSource::Missing,
6764        value if value == PurposeSource::Imported.as_str() => PurposeSource::Imported,
6765        value if value == PurposeSource::Generated.as_str() => PurposeSource::Generated,
6766        // Older databases could contain `human`; ProjectAtlas now treats
6767        // explicit approval as agent-owned and serializes new writes as `agent`.
6768        value if value == PurposeSource::Agent.as_str() || value == LEGACY_HUMAN_PURPOSE_SOURCE => {
6769            PurposeSource::Agent
6770        }
6771        _ => {
6772            return Err(DbError::InvalidEnum {
6773                field: "source",
6774                value: value.to_string(),
6775            });
6776        }
6777    };
6778    Ok(source)
6779}
6780
6781/// Normalize a bounded host-owned task label used only for curator work identity.
6782fn normalize_purpose_curation_task(task: &str) -> DbResult<String> {
6783    let normalized = task.split_whitespace().collect::<Vec<_>>().join(" ");
6784    if normalized.is_empty() {
6785        return Err(DbError::PurposeCurationTaskInvalid {
6786            reason: "task must not be blank",
6787        });
6788    }
6789    if normalized.len() > MAX_PURPOSE_CURATION_TASK_BYTES {
6790        return Err(DbError::PurposeCurationTaskInvalid {
6791            reason: "task exceeds the UTF-8 byte limit",
6792        });
6793    }
6794    if normalized.chars().any(char::is_control) {
6795        return Err(DbError::PurposeCurationTaskInvalid {
6796            reason: "task contains control characters",
6797        });
6798    }
6799    Ok(normalized)
6800}
6801
6802/// Load one current purpose row inside the caller's read or write snapshot.
6803fn load_current_purpose_state(
6804    connection: &Connection,
6805    path: &str,
6806) -> DbResult<Option<(i64, Purpose)>> {
6807    let row = connection
6808        .prepare_cached(
6809            "
6810            SELECT n.id, p.purpose, p.source, p.status
6811            FROM nodes n
6812            JOIN purposes p ON p.node_id = n.id
6813            WHERE n.exists_now = 1 AND n.path = ?1
6814            ",
6815        )?
6816        .query_row([path], |row| {
6817            Ok((
6818                row.get::<_, i64>(0)?,
6819                row.get::<_, Option<String>>(1)?,
6820                row.get::<_, String>(2)?,
6821                row.get::<_, String>(3)?,
6822            ))
6823        })
6824        .optional()?;
6825    row.map(|(node_id, purpose, source, status)| {
6826        let source = parse_source(&source)?;
6827        let status = PurposeStatus::from_db(&status).ok_or_else(|| DbError::InvalidEnum {
6828            field: "purpose_status",
6829            value: status,
6830        })?;
6831        Ok((
6832            node_id,
6833            Purpose {
6834                path: path.to_string(),
6835                purpose,
6836                source,
6837                status,
6838            },
6839        ))
6840    })
6841    .transpose()
6842}
6843
6844/// Apply one queue item inside the caller's validated writer transaction.
6845fn apply_conditional_purpose(
6846    connection: &Connection,
6847    project: ProjectInstanceId,
6848    generation: IndexGeneration,
6849    request: &PurposeConditionalApplyRequest,
6850) -> DbResult<PurposeConditionalApplyResult> {
6851    let current = load_current_purpose_state(connection, &request.path)?;
6852    let Some((node_id, current_purpose)) = current else {
6853        return Ok(PurposeConditionalApplyResult {
6854            path: request.path.clone(),
6855            state: PurposeConditionalApplyState::PathUnavailable,
6856            current_purpose: None,
6857        });
6858    };
6859    if !matches!(
6860        current_purpose.status,
6861        PurposeStatus::Missing | PurposeStatus::Suggested
6862    ) {
6863        return Ok(PurposeConditionalApplyResult {
6864            path: request.path.clone(),
6865            state: PurposeConditionalApplyState::Accepted,
6866            current_purpose: Some(current_purpose),
6867        });
6868    }
6869    let current_work_key =
6870        purpose_curation_item_work_key(project, generation, &request.task, &request.path);
6871    let current_state_token = purpose_curation_state_token(&current_work_key, &current_purpose);
6872    if current_work_key != request.work_key || current_state_token != request.state_token {
6873        return Ok(PurposeConditionalApplyResult {
6874            path: request.path.clone(),
6875            state: PurposeConditionalApplyState::Stale,
6876            current_purpose: Some(current_purpose),
6877        });
6878    }
6879    let generation_sql =
6880        i64::try_from(generation.get()).map_err(|_source| DbError::GraphCountOverflow {
6881            field: "project_identity.active_generation",
6882            value: generation.get(),
6883        })?;
6884    let changed = connection
6885        .prepare_cached(
6886            "
6887            UPDATE purposes
6888            SET purpose = ?2,
6889                source = ?3,
6890                status = ?4,
6891                updated_at = CURRENT_TIMESTAMP
6892            WHERE node_id = ?1
6893              AND status = ?5
6894              AND source = ?6
6895              AND purpose IS ?7
6896              AND EXISTS (
6897                  SELECT 1
6898                  FROM nodes n
6899                  JOIN project_identity pi ON pi.singleton = 1
6900                  WHERE n.id = ?1
6901                    AND n.exists_now = 1
6902                    AND n.path = ?8
6903                    AND pi.project_instance_id = ?9
6904                    AND pi.active_generation = ?10
6905              )
6906            ",
6907        )?
6908        .execute(params![
6909            node_id,
6910            request.purpose,
6911            PurposeSource::Agent.as_str(),
6912            PurposeStatus::Approved.as_str(),
6913            current_purpose.status.as_str(),
6914            current_purpose.source.as_str(),
6915            current_purpose.purpose,
6916            request.path,
6917            &project.as_bytes()[..],
6918            generation_sql,
6919        ])?;
6920    if changed == 1 {
6921        Ok(PurposeConditionalApplyResult {
6922            path: request.path.clone(),
6923            state: PurposeConditionalApplyState::Applied,
6924            current_purpose: Some(Purpose {
6925                path: request.path.clone(),
6926                purpose: Some(request.purpose.clone()),
6927                source: PurposeSource::Agent,
6928                status: PurposeStatus::Approved,
6929            }),
6930        })
6931    } else {
6932        Ok(PurposeConditionalApplyResult {
6933            path: request.path.clone(),
6934            state: PurposeConditionalApplyState::Stale,
6935            current_purpose: Some(current_purpose),
6936        })
6937    }
6938}
6939
6940/// Construct one candidate with deterministic work and stale-state identities.
6941fn purpose_curation_candidate(
6942    project: ProjectInstanceId,
6943    generation: IndexGeneration,
6944    task: &str,
6945    node: IndexedNode,
6946) -> PurposeCurationCandidate {
6947    let work_key = purpose_curation_item_work_key(project, generation, task, &node.node.path);
6948    let state_token = purpose_curation_state_token(&work_key, &node.purpose);
6949    PurposeCurationCandidate {
6950        node,
6951        work_key,
6952        state_token,
6953    }
6954}
6955
6956/// Derive one stable project/generation/task/path work identity.
6957fn purpose_curation_item_work_key(
6958    project: ProjectInstanceId,
6959    generation: IndexGeneration,
6960    task: &str,
6961    path: &str,
6962) -> String {
6963    digest_fields(
6964        PURPOSE_CURATION_ITEM_KEY_DOMAIN,
6965        &[
6966            &project.as_bytes(),
6967            &generation.get().to_le_bytes(),
6968            task.as_bytes(),
6969            path.as_bytes(),
6970        ],
6971    )
6972}
6973
6974/// Bind conditional apply to the exact unapproved purpose row selected by the queue.
6975fn purpose_curation_state_token(work_key: &str, purpose: &Purpose) -> String {
6976    let purpose_presence = [u8::from(purpose.purpose.is_some())];
6977    digest_fields(
6978        PURPOSE_CURATION_STATE_TOKEN_DOMAIN,
6979        &[
6980            work_key.as_bytes(),
6981            &purpose_presence,
6982            purpose.purpose.as_deref().unwrap_or_default().as_bytes(),
6983            purpose.source.as_str().as_bytes(),
6984            purpose.status.as_str().as_bytes(),
6985        ],
6986    )
6987}
6988
6989/// Derive a deterministic identity for one complete returned candidate set.
6990fn purpose_curation_batch_work_key(
6991    project: ProjectInstanceId,
6992    generation: IndexGeneration,
6993    task: &str,
6994    items: &[PurposeCurationCandidate],
6995) -> String {
6996    let mut hasher = Hasher::new();
6997    digest_field(&mut hasher, PURPOSE_CURATION_BATCH_KEY_DOMAIN.as_bytes());
6998    digest_field(&mut hasher, &project.as_bytes());
6999    digest_field(&mut hasher, &generation.get().to_le_bytes());
7000    digest_field(&mut hasher, task.as_bytes());
7001    for item in items {
7002        digest_field(&mut hasher, item.work_key.as_bytes());
7003        digest_field(&mut hasher, item.state_token.as_bytes());
7004    }
7005    hasher.finalize().to_hex().to_string()
7006}
7007
7008/// Hash length-delimited fields under one stable domain separator.
7009fn digest_fields(domain: &str, fields: &[&[u8]]) -> String {
7010    let mut hasher = Hasher::new();
7011    digest_field(&mut hasher, domain.as_bytes());
7012    for field in fields {
7013        digest_field(&mut hasher, field);
7014    }
7015    hasher.finalize().to_hex().to_string()
7016}
7017
7018/// Append one unambiguous field to a deterministic digest.
7019fn digest_field(hasher: &mut Hasher, value: &[u8]) {
7020    let length = u64::try_from(value.len()).unwrap_or(u64::MAX);
7021    hasher.update(&length.to_le_bytes());
7022    hasher.update(value);
7023}
7024
7025/// Convert an aggregate database count into a platform `usize`.
7026fn count_to_usize(field: &'static str, value: i64) -> DbResult<usize> {
7027    usize::try_from(value).map_err(|source| DbError::InvalidCount {
7028        field,
7029        value,
7030        source,
7031    })
7032}
7033
7034/// Convert a usize to i64 with saturation for database storage.
7035fn usize_to_i64(value: usize) -> i64 {
7036    i64::try_from(value).unwrap_or(i64::MAX)
7037}
7038
7039/// Convert a non-negative i64 to usize for database reads.
7040fn i64_to_usize(value: i64) -> usize {
7041    usize::try_from(value.max(0)).unwrap_or(usize::MAX)
7042}
7043
7044/// Decode one persisted symbol row through the shared column contract.
7045fn code_symbol_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CodeSymbol> {
7046    Ok(CodeSymbol {
7047        path: row.get(0)?,
7048        language: row.get(1)?,
7049        name: row.get(2)?,
7050        kind: SymbolKind::from_db(row.get_ref(3)?.as_str()?),
7051        signature: row.get(4)?,
7052        line_start: i64_to_usize(row.get::<_, i64>(5)?),
7053        line_end: i64_to_usize(row.get::<_, i64>(6)?),
7054        parent: row.get(7)?,
7055        parser: ParserKind::from_db(row.get_ref(8)?.as_str()?),
7056        detail: row.get(9)?,
7057        exported: row.get::<_, i64>(10)? != 0,
7058        documentation: row.get(11)?,
7059    })
7060}
7061
7062/// Read the exact owned-string byte floor before hydrating one symbol row.
7063fn code_symbol_preflight_bytes(row: &rusqlite::Row<'_>) -> DbResult<u64> {
7064    let string_bytes = row.get::<_, i64>(12)?;
7065    let string_bytes = u64::try_from(string_bytes).map_err(|source| DbError::InvalidCount {
7066        field: "symbol persisted field bytes",
7067        value: string_bytes,
7068        source,
7069    })?;
7070    let row_bytes = u64::try_from(std::mem::size_of::<CodeSymbol>()).map_err(|source| {
7071        DbError::InvalidCount {
7072            field: "symbol persisted field bytes",
7073            value: i64::MAX,
7074            source,
7075        }
7076    })?;
7077    row_bytes.checked_add(string_bytes).ok_or_else(|| {
7078        GraphContractError::InvalidLimits {
7079            reason: "symbol persisted row bytes overflowed",
7080        }
7081        .into()
7082    })
7083}
7084
7085/// Decode the covering persisted import projection.
7086fn stored_import_relation_from_row(
7087    row: &rusqlite::Row<'_>,
7088) -> rusqlite::Result<StoredImportRelation> {
7089    Ok(StoredImportRelation {
7090        path: row.get(0)?,
7091        source_name: row.get(1)?,
7092        target_name: row.get(2)?,
7093        line: i64_to_usize(row.get::<_, i64>(3)?),
7094    })
7095}
7096
7097/// Count the retained Rust row plus its owned string allocation capacities.
7098fn code_symbol_decoded_bytes(symbol: &CodeSymbol) -> DbResult<u64> {
7099    let capacities = [
7100        symbol.path.capacity(),
7101        symbol.language.as_ref().map_or(0, String::capacity),
7102        symbol.name.capacity(),
7103        symbol.signature.capacity(),
7104        symbol.documentation.as_ref().map_or(0, String::capacity),
7105        symbol.parent.as_ref().map_or(0, String::capacity),
7106        symbol.detail.as_ref().map_or(0, String::capacity),
7107    ];
7108    let mut bytes = u64::try_from(std::mem::size_of::<CodeSymbol>()).map_err(|source| {
7109        DbError::InvalidCount {
7110            field: "symbol decoded field bytes",
7111            value: i64::MAX,
7112            source,
7113        }
7114    })?;
7115    for capacity in capacities {
7116        let capacity = u64::try_from(capacity).map_err(|source| DbError::InvalidCount {
7117            field: "symbol decoded field bytes",
7118            value: i64::MAX,
7119            source,
7120        })?;
7121        bytes = bytes
7122            .checked_add(capacity)
7123            .ok_or(GraphContractError::InvalidLimits {
7124                reason: "symbol decoded row bytes overflowed",
7125            })?;
7126    }
7127    Ok(bytes)
7128}
7129
7130/// Wrap a query string for a SQL LIKE expression.
7131fn like_query(query: &str) -> String {
7132    format!("%{query}%")
7133}
7134
7135/// Build the single set-oriented query used to hydrate exact purpose-work paths.
7136fn load_nodes_by_paths_sql(path_count: usize) -> String {
7137    let placeholders = numbered_placeholders(1, path_count);
7138    format!(
7139        "
7140        SELECT
7141            n.path,
7142            n.kind,
7143            n.parent_path,
7144            n.extension,
7145            n.language,
7146            n.size_bytes,
7147            n.mtime_ns,
7148            n.content_hash,
7149            p.purpose,
7150            p.source,
7151            p.status,
7152            s.summary
7153        FROM nodes n
7154        JOIN purposes p ON p.node_id = n.id
7155        LEFT JOIN summaries s ON s.node_id = n.id
7156            AND s.summary_level = 'node'
7157            AND s.subject = ''
7158        WHERE n.exists_now = 1 AND n.path IN ({placeholders})
7159        ORDER BY n.path
7160        "
7161    )
7162}
7163
7164/// Build numbered SQL placeholders starting at a caller-selected index.
7165fn numbered_placeholders(start: usize, count: usize) -> String {
7166    (start..start + count)
7167        .map(|index| format!("?{index}"))
7168        .collect::<Vec<_>>()
7169        .join(", ")
7170}
7171
7172/// Generate the durable node-level content summary.
7173fn generate_node_summary(node: &Node) -> String {
7174    match node.kind {
7175        NodeKind::Folder => format!("Folder for {}", path_label(&node.path)),
7176        NodeKind::File => file_summary(node),
7177    }
7178}
7179
7180/// Generate a one-line observed file summary from scan metadata.
7181fn file_summary(node: &Node) -> String {
7182    let language = node
7183        .language
7184        .as_deref()
7185        .or(node.extension.as_deref())
7186        .unwrap_or("unknown");
7187    let size = node.size_bytes.map_or_else(
7188        || "unknown size".to_string(),
7189        |bytes| format!("{bytes} bytes"),
7190    );
7191    format!("{language} file, {size}")
7192}
7193
7194/// Return a readable label for a repository-relative path.
7195fn path_label(path: &str) -> String {
7196    if path == "." {
7197        return "repository root".to_string();
7198    }
7199    path.rsplit('/')
7200        .next()
7201        .filter(|value| !value.is_empty())
7202        .unwrap_or(path)
7203        .replace(['-', '_'], " ")
7204}
7205
7206/// Emit a health finding when it has not already been resolved.
7207fn emit_unresolved_finding<F>(
7208    finding: HealthFinding,
7209    resolved_ids: &HashSet<String>,
7210    visitor: &mut F,
7211) -> DbResult<bool>
7212where
7213    F: FnMut(HealthFinding) -> DbResult<bool>,
7214{
7215    if resolved_ids.contains(&finding.id) {
7216        return Ok(true);
7217    }
7218    visitor(finding)
7219}
7220
7221/// Return whether a purpose-status source can match a bounded health query.
7222fn purpose_health_spec_matches_query(spec: PurposeHealthSpec, query: &HealthQuery) -> bool {
7223    health_category_matches_query(spec.category, Severity::Warning, query)
7224}
7225
7226/// Return whether a health category/severity can match a bounded query.
7227fn health_category_matches_query(category: &str, severity: Severity, query: &HealthQuery) -> bool {
7228    query
7229        .category
7230        .as_deref()
7231        .is_none_or(|requested| category.eq_ignore_ascii_case(requested))
7232        && query.severity.is_none_or(|requested| severity == requested)
7233}
7234
7235/// Return purpose health metadata for a stored purpose status.
7236fn purpose_health_spec_for_status(status: &str) -> DbResult<PurposeHealthSpec> {
7237    PURPOSE_HEALTH_SPECS
7238        .iter()
7239        .copied()
7240        .find(|spec| spec.status == status)
7241        .ok_or_else(|| DbError::InvalidEnum {
7242            field: "status",
7243            value: status.to_string(),
7244        })
7245}
7246
7247/// Build the health finding for an approved purpose that still needs agent review.
7248fn agent_review_required_finding(path: String) -> HealthFinding {
7249    HealthFinding {
7250        id: finding_id(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED, &path, None),
7251        severity: Severity::Warning,
7252        category: CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
7253        path,
7254        related_path: None,
7255        message: MESSAGE_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
7256        recommendation: RECOMMENDATION_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
7257    }
7258}
7259
7260/// Build the shared SQL filter for globally ordered purpose lifecycle findings.
7261fn purpose_lifecycle_where_clause(
7262    specs: &[PurposeHealthSpec],
7263    path_prefix: Option<&str>,
7264    resolution_filter: HealthResolutionFilter<'_>,
7265    scope: HealthScope,
7266) -> (String, Vec<Value>) {
7267    let statuses = specs
7268        .iter()
7269        .map(|spec| format!("'{}'", spec.status))
7270        .collect::<Vec<_>>()
7271        .join(", ");
7272    let mut clauses = vec![
7273        "n.exists_now = 1".to_string(),
7274        format!("p.status IN ({statuses})"),
7275    ];
7276    let mut values = Vec::new();
7277
7278    if source_filter_applies_before_queue(scope) {
7279        clauses.push(source_relevant_node_expression("n"));
7280    }
7281    if scope.high_impact_queue() {
7282        clauses.push(purpose_default_queue_node_expression("n", "p", scope));
7283    }
7284
7285    let normalized_prefix = path_prefix
7286        .map(normalize_repo_path_prefix)
7287        .filter(|prefix| prefix != ".");
7288    if let Some(prefix) = normalized_prefix {
7289        clauses.push(format!(
7290            "(n.path = ?{} OR n.path LIKE ?{} ESCAPE '\\')",
7291            values.len() + 1,
7292            values.len() + 2
7293        ));
7294        values.push(Value::from(prefix.clone()));
7295        values.push(Value::from(sqlite_descendant_pattern(&prefix)));
7296    }
7297
7298    match resolution_filter {
7299        HealthResolutionFilter::Explicit(resolved_ids) => {
7300            for spec in specs {
7301                let resolved_paths = resolved_purpose_paths(resolved_ids, spec.category);
7302                if !resolved_paths.is_empty() {
7303                    clauses.push(format!(
7304                        "NOT (p.status = '{}' AND n.path IN ({}))",
7305                        spec.status,
7306                        numbered_placeholders(values.len() + 1, resolved_paths.len())
7307                    ));
7308                    values.extend(resolved_paths.into_iter().map(Value::from));
7309                }
7310            }
7311        }
7312        HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(
7313            &purpose_lifecycle_finding_id_expression(specs, "n", "p"),
7314        )),
7315    }
7316
7317    (clauses.join(" AND "), values)
7318}
7319
7320/// Build the shared SQL filter for purpose lifecycle health findings.
7321fn purpose_status_where_clause(
7322    spec: PurposeHealthSpec,
7323    path_prefix: Option<&str>,
7324    resolution_filter: HealthResolutionFilter<'_>,
7325    scope: HealthScope,
7326) -> (String, Vec<Value>) {
7327    let mut clauses = vec!["n.exists_now = 1".to_string(), "p.status = ?1".to_string()];
7328    let mut values = vec![Value::from(spec.status.to_string())];
7329
7330    if source_filter_applies_before_queue(scope) {
7331        clauses.push(source_relevant_node_expression("n"));
7332    }
7333    if scope.high_impact_queue() {
7334        clauses.push(purpose_default_queue_node_expression("n", "p", scope));
7335    }
7336
7337    let normalized_prefix = path_prefix
7338        .map(normalize_repo_path_prefix)
7339        .filter(|prefix| prefix != ".");
7340    if let Some(prefix) = normalized_prefix {
7341        clauses.push(format!(
7342            "(n.path = ?{} OR n.path LIKE ?{} ESCAPE '\\')",
7343            values.len() + 1,
7344            values.len() + 2
7345        ));
7346        values.push(Value::from(prefix.clone()));
7347        values.push(Value::from(sqlite_descendant_pattern(&prefix)));
7348    }
7349
7350    match resolution_filter {
7351        HealthResolutionFilter::Explicit(resolved_ids) => {
7352            let resolved_paths = resolved_purpose_paths(resolved_ids, spec.category);
7353            if !resolved_paths.is_empty() {
7354                clauses.push(format!(
7355                    "n.path NOT IN ({})",
7356                    numbered_placeholders(values.len() + 1, resolved_paths.len())
7357                ));
7358                values.extend(resolved_paths.into_iter().map(Value::from));
7359            }
7360        }
7361        HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(&format!(
7362            "'{}:' || n.path || ':'",
7363            spec.category
7364        ))),
7365    }
7366
7367    (clauses.join(" AND "), values)
7368}
7369
7370/// Build a structural-health SQL filter over `findings` CTE columns.
7371fn structural_finding_where_clause(
7372    category: &str,
7373    path_prefix: Option<&str>,
7374    resolution_filter: HealthResolutionFilter<'_>,
7375    scope: HealthScope,
7376    first_placeholder: usize,
7377) -> (String, Vec<Value>) {
7378    let mut placeholder = first_placeholder;
7379    let mut clauses = Vec::new();
7380    let mut values = Vec::new();
7381
7382    if source_filter_applies_before_queue(scope) {
7383        clauses.push("source_relevant = 1".to_string());
7384    }
7385    if scope.high_impact_queue() {
7386        clauses.push(purpose_default_queue_finding_expression(scope));
7387    }
7388
7389    let normalized_prefix = path_prefix
7390        .map(normalize_repo_path_prefix)
7391        .filter(|prefix| prefix != ".");
7392    if let Some(prefix) = normalized_prefix {
7393        clauses.push(format!(
7394            "((path = ?{path_exact} OR path LIKE ?{path_descendant} ESCAPE '\\') \
7395              OR (related_path = ?{related_exact} OR related_path LIKE ?{related_descendant} ESCAPE '\\'))",
7396            path_exact = placeholder,
7397            path_descendant = placeholder + 1,
7398            related_exact = placeholder + 2,
7399            related_descendant = placeholder + 3
7400        ));
7401        values.push(Value::from(prefix.clone()));
7402        values.push(Value::from(sqlite_descendant_pattern(&prefix)));
7403        values.push(Value::from(prefix.clone()));
7404        values.push(Value::from(sqlite_descendant_pattern(&prefix)));
7405        placeholder += 4;
7406    }
7407
7408    match resolution_filter {
7409        HealthResolutionFilter::Explicit(resolved_ids) => {
7410            let resolved_ids = resolved_ids_for_category(resolved_ids, category);
7411            if !resolved_ids.is_empty() {
7412                clauses.push(format!(
7413                    "('{category}:' || path || ':' || related_path) NOT IN ({})",
7414                    numbered_placeholders(placeholder, resolved_ids.len())
7415                ));
7416                values.extend(resolved_ids.into_iter().map(Value::from));
7417            }
7418        }
7419        HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(&format!(
7420            "'{category}:' || path || ':' || related_path"
7421        ))),
7422    }
7423
7424    if clauses.is_empty() {
7425        (String::new(), values)
7426    } else {
7427        (format!("WHERE {}", clauses.join(" AND ")), values)
7428    }
7429}
7430
7431/// Build the exact stored finding-id expression for mixed purpose lifecycle rows.
7432fn purpose_lifecycle_finding_id_expression(
7433    specs: &[PurposeHealthSpec],
7434    node_alias: &str,
7435    purpose_alias: &str,
7436) -> String {
7437    let category_cases = specs
7438        .iter()
7439        .map(|spec| format!("WHEN '{}' THEN '{}:'", spec.status, spec.category))
7440        .collect::<Vec<_>>()
7441        .join(" ");
7442    format!(
7443        "(CASE {purpose_alias}.status {category_cases} ELSE '' END || {node_alias}.path || ':')"
7444    )
7445}
7446
7447/// Build an indexed anti-lookup against durable health resolutions.
7448fn stored_resolution_filter_clause(finding_id_expression: &str) -> String {
7449    format!(
7450        "NOT EXISTS (SELECT 1 FROM health_resolutions hr WHERE hr.finding_id = {finding_id_expression})"
7451    )
7452}
7453
7454/// SQL expression for approved purposes that need agent review at the requested scope.
7455fn purpose_review_candidate_expression(node_alias: &str, scope: HealthScope) -> String {
7456    let scope = match scope {
7457        HealthScope::All => HealthScope::PurposeStrict,
7458        other => other,
7459    };
7460    purpose_default_queue_node_expression(node_alias, "p", scope)
7461}
7462
7463/// SQL expression for paths that belong in the default purpose queue.
7464fn purpose_default_queue_node_expression(
7465    node_alias: &str,
7466    purpose_alias: &str,
7467    scope: HealthScope,
7468) -> String {
7469    let asset_clause = if scope.include_assets() {
7470        format!(
7471            " OR ({node_alias}.kind = 'file' AND NOT ({}))",
7472            source_relevant_node_expression(node_alias)
7473        )
7474    } else {
7475        String::new()
7476    };
7477    let source_file_clause = if scope.include_source_files() {
7478        format!(" OR ({node_alias}.kind = 'file' AND COALESCE({node_alias}.language, '') <> '')")
7479    } else {
7480        String::new()
7481    };
7482    let all_file_clause = if scope.include_all_files() {
7483        format!(" OR {node_alias}.kind = 'file'")
7484    } else {
7485        String::new()
7486    };
7487    let stale_queue_sources = sql_string_literals(STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES);
7488    format!(
7489        "({node_alias}.kind = 'folder' \
7490          OR ({node_alias}.kind = 'file' \
7491              AND {purpose_alias}.status = 'stale' \
7492              AND {purpose_alias}.source IN ({stale_queue_sources})) \
7493          OR ({node_alias}.kind = 'file' AND {}){source_file_clause}{all_file_clause}{asset_clause})",
7494        high_impact_file_path_expression(&format!("lower({node_alias}.path)")),
7495    )
7496}
7497
7498/// SQL expression for finding CTE columns that belong in the default purpose queue.
7499fn purpose_default_queue_finding_expression(scope: HealthScope) -> String {
7500    let asset_clause = if scope.include_assets() {
7501        " OR (kind = 'file' AND COALESCE(language, '') = '')"
7502    } else {
7503        ""
7504    };
7505    let source_file_clause = if scope.include_source_files() {
7506        " OR (kind = 'file' AND COALESCE(language, '') <> '')"
7507    } else {
7508        ""
7509    };
7510    let all_file_clause = if scope.include_all_files() {
7511        " OR kind = 'file'"
7512    } else {
7513        ""
7514    };
7515    format!(
7516        "(kind = 'folder' OR (kind = 'file' AND {}){source_file_clause}{all_file_clause}{asset_clause})",
7517        high_impact_file_path_expression("lower(path)")
7518    )
7519}
7520
7521/// SQL ORDER BY expression that keeps folder-purpose work ahead of file cleanup.
7522fn purpose_default_queue_order_expression(node_alias: &str, purpose_alias: &str) -> String {
7523    let stale_queue_sources = sql_string_literals(STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES);
7524    format!(
7525        "CASE \
7526            WHEN {node_alias}.kind = 'folder' THEN 0 \
7527            WHEN {node_alias}.kind = 'file' \
7528                AND {purpose_alias}.status = 'stale' \
7529                AND ({purpose_alias}.source IN ({stale_queue_sources}) OR {}) THEN 1 \
7530            WHEN {node_alias}.kind = 'file' AND {} THEN 2 \
7531            ELSE 3 \
7532        END, {node_alias}.path",
7533        high_impact_file_path_expression(&format!("lower({node_alias}.path)")),
7534        high_impact_file_path_expression(&format!("lower({node_alias}.path)"))
7535    )
7536}
7537
7538/// Purpose sources whose stale file purposes stay in the default queue regardless of path impact.
7539const STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES: &[&str] = &["human", "imported"];
7540
7541/// Return whether `source_only` should run before queue-specific folder/file selection.
7542fn source_filter_applies_before_queue(scope: HealthScope) -> bool {
7543    scope.source_only_filter() && !scope.high_impact_queue()
7544}
7545
7546/// Render trusted static strings as SQL string literals.
7547fn sql_string_literals(values: &[&str]) -> String {
7548    values
7549        .iter()
7550        .map(|value| format!("'{}'", value.replace('\'', "''")))
7551        .collect::<Vec<_>>()
7552        .join(", ")
7553}
7554
7555/// SQL expression mirroring the path-based high-impact file heuristic.
7556fn high_impact_file_path_expression(lower_path: &str) -> String {
7557    let name_matches = HIGH_IMPACT_FILE_NAMES
7558        .iter()
7559        .map(|name| format!("{lower_path} = '{name}' OR {lower_path} LIKE '%/{name}'"))
7560        .collect::<Vec<_>>()
7561        .join(" OR ");
7562    let prefix_matches = HIGH_IMPACT_PATH_PREFIXES
7563        .iter()
7564        .map(|prefix| format!("{lower_path} LIKE '{prefix}%'"))
7565        .collect::<Vec<_>>()
7566        .join(" OR ");
7567    let segment_matches = HIGH_IMPACT_PATH_SEGMENTS
7568        .iter()
7569        .map(|segment| format!("{lower_path} LIKE '%{segment}%'"))
7570        .collect::<Vec<_>>()
7571        .join(" OR ");
7572    format!("({name_matches} OR {prefix_matches} OR {segment_matches})")
7573}
7574
7575/// Return a SQL expression that treats source files and folders with source descendants as source-relevant.
7576fn source_relevant_node_expression(alias: &str) -> String {
7577    format!(
7578        "(({alias}.kind = 'file' AND COALESCE({alias}.language, '') <> '') \
7579          OR ({alias}.kind = 'folder' AND EXISTS (\
7580              SELECT 1 FROM nodes source_child \
7581              WHERE source_child.exists_now = 1 \
7582                AND source_child.kind = 'file' \
7583                AND COALESCE(source_child.language, '') <> '' \
7584                AND (\
7585                    {alias}.path = '.' \
7586                    OR source_child.parent_path = {alias}.path \
7587                    OR substr(source_child.parent_path, 1, length({alias}.path) + 1) = {alias}.path || '/'\
7588                )\
7589          )))"
7590    )
7591}
7592
7593/// Extract resolved primary paths for lifecycle categories without related paths.
7594fn resolved_purpose_paths(resolved_ids: &[String], category: &str) -> Vec<String> {
7595    let prefix = format!("{category}:");
7596    resolved_ids
7597        .iter()
7598        .filter_map(|id| {
7599            id.strip_prefix(&prefix)
7600                .and_then(|rest| rest.strip_suffix(':'))
7601                .filter(|path| !path.is_empty())
7602                .map(ToOwned::to_owned)
7603        })
7604        .collect()
7605}
7606
7607/// Extract resolved full ids for categories that include related paths.
7608fn resolved_ids_for_category(resolved_ids: &[String], category: &str) -> Vec<String> {
7609    let prefix = format!("{category}:");
7610    resolved_ids
7611        .iter()
7612        .filter(|id| id.starts_with(&prefix))
7613        .cloned()
7614        .collect()
7615}
7616
7617#[cfg(test)]
7618mod tests {
7619    use super::*;
7620    use projectatlas_core::telemetry::{
7621        READ_AVOIDANCE_CONFIDENCE_MODELED, READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED,
7622        READ_AVOIDANCE_CONFIDENCE_OBSERVED, TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
7623        TOKEN_ACCURACY_HEURISTIC, TOKEN_BASELINE_SELECTED_CANDIDATES,
7624        TOKEN_BUCKET_FULL_FILE_COMPRESSION, TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
7625        TOKEN_COMMAND_SEARCH, TOKEN_CONFIDENCE_INFERRED, TOKEN_DEDUPE_SCOPE_EVENT,
7626        usage_from_estimates, usage_from_estimates_with_accounting,
7627        usage_from_estimates_with_context, usage_from_text,
7628    };
7629    use projectatlas_core::{NodeKind, normalized_parent};
7630    use std::error::Error;
7631    use std::fmt::Debug;
7632    use std::fs;
7633    use std::io;
7634    use std::time::Instant;
7635
7636    #[test]
7637    fn stores_nodes_and_overview() -> Result<(), Box<dyn Error>> {
7638        let mut store = AtlasStore::in_memory()?;
7639        let node = Node {
7640            path: "src/main.rs".to_string(),
7641            kind: NodeKind::File,
7642            parent_path: normalized_parent("src/main.rs"),
7643            extension: Some(".rs".to_string()),
7644            language: Some("rust".to_string()),
7645            size_bytes: Some(12),
7646            mtime_ns: Some(10),
7647            content_hash: Some("abc".to_string()),
7648        };
7649        store.replace_scan(&[node])?;
7650        let overview = store.overview()?;
7651        require_eq(&overview.files, &1, "file count")?;
7652        require_eq(&overview.missing_purposes, &1, "missing purpose count")?;
7653        let nodes = store.load_nodes()?;
7654        require_eq(
7655            &nodes[0].purpose.purpose,
7656            &None,
7657            "purpose remains separate from summary",
7658        )?;
7659        require_eq(
7660            &nodes[0].summary,
7661            &Some("rust file, 12 bytes".to_string()),
7662            "node-level summary",
7663        )?;
7664        let loaded = store
7665            .load_node_by_path("src/main.rs")?
7666            .ok_or_else(|| io::Error::other("indexed node was not found by path"))?;
7667        require_eq(
7668            &loaded.node.path,
7669            &"src/main.rs".to_string(),
7670            "targeted path lookup",
7671        )?;
7672        require_eq(
7673            &store.load_node_by_path("src/missing.rs")?.is_none(),
7674            &true,
7675            "missing targeted path lookup",
7676        )?;
7677        Ok(())
7678    }
7679
7680    #[test]
7681    fn validated_existing_database_paths_are_never_recreated() -> Result<(), Box<dyn Error>> {
7682        let temp = tempfile::tempdir()?;
7683
7684        let current_path = temp.path().join("current.db");
7685        drop(AtlasStore::open(&current_path)?);
7686        let (current, current_location) = schema::preflight(&current_path, None)?;
7687        require_eq(
7688            &current.state,
7689            &SchemaState::Current,
7690            "current preflight state",
7691        )?;
7692        fs::remove_file(&current_path)?;
7693        if Connection::open_with_flags(
7694            &current_path,
7695            writable_open_flags(current.state, current_location.database_exists),
7696        )
7697        .is_ok()
7698        {
7699            return Err(io::Error::other("current database path was recreated").into());
7700        }
7701        require_eq(&current_path.exists(), &false, "current path stays absent")?;
7702
7703        let released_path = temp.path().join("released.db");
7704        let released_root = temp.path().join("released-root");
7705        write_released_schema_eight_compatibility_fixture(&released_path, &released_root)?;
7706        let (released, released_location) = schema::preflight(&released_path, None)?;
7707        require_eq(
7708            &released.state,
7709            &SchemaState::UpgradeRequired,
7710            "released preflight state",
7711        )?;
7712        fs::remove_file(&released_path)?;
7713        if Connection::open_with_flags(
7714            &released_path,
7715            writable_open_flags(released.state, released_location.database_exists),
7716        )
7717        .is_ok()
7718        {
7719            return Err(io::Error::other("released database path was recreated").into());
7720        }
7721        require_eq(
7722            &released_path.exists(),
7723            &false,
7724            "released path stays absent",
7725        )?;
7726
7727        let fresh_path = temp.path().join("fresh.db");
7728        let (fresh, _) = schema::preflight(&fresh_path, None)?;
7729        require_eq(&fresh.state, &SchemaState::Fresh, "fresh preflight state")?;
7730        drop(AtlasStore::open(&fresh_path)?);
7731        require_eq(&fresh_path.is_file(), &true, "fresh path is created")?;
7732        drop(AtlasStore::open_read_only(&fresh_path)?);
7733        Ok(())
7734    }
7735
7736    #[test]
7737    fn released_schema_layouts_upgrade_without_losing_local_state() -> Result<(), Box<dyn Error>> {
7738        let layouts: [(&str, fn(&Path, &Path) -> Result<(), Box<dyn Error>>); 2] = [
7739            (
7740                "fresh-v0.3.26",
7741                write_released_schema_eight_compatibility_fixture,
7742            ),
7743            (
7744                "evolved-v0.3.11-to-v0.3.26",
7745                write_evolved_released_schema_eight_compatibility_fixture,
7746            ),
7747        ];
7748        let mut previous_binding = None;
7749        for (label, write_fixture) in layouts {
7750            let binding =
7751                assert_released_schema_upgrade_preserves_local_state(label, write_fixture)?;
7752            if previous_binding
7753                .as_ref()
7754                .is_some_and(|previous| previous == &binding)
7755            {
7756                return Err(io::Error::other(
7757                    "independent released-schema databases shared one project binding",
7758                )
7759                .into());
7760            }
7761            previous_binding = Some(binding);
7762        }
7763        Ok(())
7764    }
7765
7766    /// Verify one released schema layout through migration, reopen, and publication.
7767    fn assert_released_schema_upgrade_preserves_local_state(
7768        label: &str,
7769        write_fixture: fn(&Path, &Path) -> Result<(), Box<dyn Error>>,
7770    ) -> Result<CapturedProjectBinding, Box<dyn Error>> {
7771        let temp = tempfile::tempdir()?;
7772        let db_path = temp.path().join("projectatlas.db");
7773        let root = temp.path().join("repository");
7774        write_fixture(&db_path, &root)?;
7775        let database_before_read = fs::read(&db_path)?;
7776
7777        let Err(read_error) = AtlasStore::open_read_only(&db_path) else {
7778            return Err(io::Error::other("schema-8 read-only open unexpectedly succeeded").into());
7779        };
7780        require_eq(
7781            &matches!(
7782                read_error,
7783                DbError::SchemaVersion {
7784                    found: PREVIOUS_SCHEMA_VERSION,
7785                    expected: SCHEMA_VERSION,
7786                }
7787            ),
7788            &true,
7789            "schema-8 read-only rejection",
7790        )?;
7791        require_eq(
7792            &fs::read(&db_path)?,
7793            &database_before_read,
7794            "read-only rejection leaves database unchanged",
7795        )?;
7796
7797        let store = AtlasStore::open(&db_path)?;
7798        let stored_schema = store.connection.query_row(
7799            "SELECT value FROM metadata WHERE key = ?1",
7800            [SCHEMA_VERSION_KEY],
7801            |row| row.get::<_, String>(0),
7802        )?;
7803        require_eq(
7804            &stored_schema,
7805            &SCHEMA_VERSION.to_string(),
7806            "upgraded schema version",
7807        )?;
7808        let migrated_binding = store.captured_project_binding()?;
7809        drop(store);
7810        schema::verify_current_integrity(&db_path, Some(&normalize_native_path_display(&root)))?;
7811        let mut store = AtlasStore::open_for_project(&db_path, &root)?;
7812        require_eq(
7813            &store.captured_project_binding()?,
7814            &migrated_binding,
7815            &format!("{label} identity survives reopen"),
7816        )?;
7817        require_eq(
7818            &store.project_root()?,
7819            &Some(normalize_native_path_display(&root)),
7820            "upgraded project root",
7821        )?;
7822        let node = store
7823            .load_node_by_path("src/lib.rs")?
7824            .ok_or_else(|| io::Error::other("upgraded source node missing"))?;
7825        require_eq(
7826            &node.node.content_hash,
7827            &Some("hash-legacy".to_string()),
7828            "upgraded source row",
7829        )?;
7830        require_eq(
7831            &node.purpose.purpose,
7832            &Some("Schema compatibility source".to_string()),
7833            "upgraded purpose text",
7834        )?;
7835        require_eq(
7836            &node.purpose.source,
7837            &PurposeSource::Agent,
7838            "upgraded purpose source",
7839        )?;
7840        require_eq(
7841            &node.purpose.status,
7842            &PurposeStatus::Approved,
7843            "upgraded purpose review state",
7844        )?;
7845        require_eq(
7846            &store.resolved_health_ids()?,
7847            &vec!["schema-review".to_string()],
7848            "upgraded authored review",
7849        )?;
7850        let custom_setting = store.connection.query_row(
7851            "SELECT value FROM metadata WHERE key = 'custom_setting'",
7852            [],
7853            |row| row.get::<_, String>(0),
7854        )?;
7855        require_eq(
7856            &custom_setting,
7857            &"preserved".to_string(),
7858            "upgraded compatible metadata",
7859        )?;
7860        let telemetry = store.token_overview(Some("schema-session"))?;
7861        require_eq(&telemetry.calls, &1, "upgraded telemetry call count")?;
7862        require_eq(
7863            &telemetry.estimated_saved,
7864            &80,
7865            "upgraded telemetry savings",
7866        )?;
7867        require_eq(
7868            &store.index_publication()?,
7869            &None,
7870            "untrusted publication metadata invalidated",
7871        )?;
7872        let remaining_publication_keys = store.connection.query_row(
7873            "SELECT COUNT(*) FROM metadata WHERE key IN (?1, ?2, ?3)",
7874            params![
7875                INDEX_PUBLICATION_STATE_KEY,
7876                INDEX_PUBLICATION_FINGERPRINT_KEY,
7877                INDEX_PUBLICATION_GENERATION_KEY,
7878            ],
7879            |row| row.get::<_, i64>(0),
7880        )?;
7881        require_eq(
7882            &remaining_publication_keys,
7883            &0,
7884            "all untrusted publication keys removed",
7885        )?;
7886
7887        {
7888            let mut publication = store.begin_index_publication("schema-9-contract")?;
7889            write_test_projection(&mut publication, "fresh")?;
7890            publication.complete()?;
7891        }
7892        require_test_projection(&store, 1, "fresh")?;
7893        require_eq(
7894            &store
7895                .index_publication()?
7896                .and_then(|publication| publication.contract_fingerprint),
7897            &Some("schema-9-contract".to_string()),
7898            &format!("{label} fresh publication contract"),
7899        )?;
7900        Ok(migrated_binding)
7901    }
7902
7903    #[test]
7904    fn future_schema_rejection_preserves_source_and_authored_rows() -> Result<(), Box<dyn Error>> {
7905        let temp = tempfile::tempdir()?;
7906        let db_path = temp.path().join("projectatlas.db");
7907        let root = temp.path().join("repository");
7908        let future_schema = SCHEMA_VERSION + 1;
7909        write_schema_compatibility_fixture(&db_path, &root, future_schema, "future")?;
7910        let database_before = fs::read(&db_path)?;
7911        let Err(open_error) = AtlasStore::open(&db_path) else {
7912            return Err(
7913                io::Error::other("future schema writable open unexpectedly succeeded").into(),
7914            );
7915        };
7916        require_eq(
7917            &matches!(
7918                open_error,
7919                DbError::SchemaVersion {
7920                    found,
7921                    expected: SCHEMA_VERSION,
7922                } if found == future_schema
7923            ),
7924            &true,
7925            "future schema rejection",
7926        )?;
7927        require_eq(
7928            &fs::read(&db_path)?,
7929            &database_before,
7930            "future schema bytes remain unchanged",
7931        )?;
7932        let connection = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
7933        let stored_schema = connection.query_row(
7934            "SELECT value FROM metadata WHERE key = ?1",
7935            [SCHEMA_VERSION_KEY],
7936            |row| row.get::<_, String>(0),
7937        )?;
7938        require_eq(
7939            &stored_schema,
7940            &future_schema.to_string(),
7941            "future schema remains unchanged",
7942        )?;
7943        let stored_root = connection.query_row(
7944            "SELECT value FROM metadata WHERE key = 'project_root'",
7945            [],
7946            |row| row.get::<_, String>(0),
7947        )?;
7948        require_eq(
7949            &stored_root,
7950            &normalize_native_path_display(&root),
7951            "future project root remains unchanged",
7952        )?;
7953        let source_state = connection.query_row(
7954            "
7955            SELECT n.content_hash, p.purpose, p.source, p.status
7956            FROM nodes AS n
7957            JOIN purposes AS p ON p.node_id = n.id
7958            WHERE n.path = 'src/lib.rs'
7959            ",
7960            [],
7961            |row| {
7962                Ok((
7963                    row.get::<_, Option<String>>(0)?,
7964                    row.get::<_, Option<String>>(1)?,
7965                    row.get::<_, String>(2)?,
7966                    row.get::<_, String>(3)?,
7967                ))
7968            },
7969        )?;
7970        require_eq(
7971            &source_state,
7972            &(
7973                Some("hash-future".to_string()),
7974                Some("Schema compatibility source".to_string()),
7975                PurposeSource::Agent.to_string(),
7976                PurposeStatus::Approved.as_str().to_string(),
7977            ),
7978            "future source and purpose rows remain unchanged",
7979        )?;
7980        let review_rationale = connection.query_row(
7981            "SELECT rationale FROM health_resolutions WHERE finding_id = 'schema-review'",
7982            [],
7983            |row| row.get::<_, String>(0),
7984        )?;
7985        require_eq(
7986            &review_rationale,
7987            &"Reviewed schema fixture".to_string(),
7988            "future authored review remains unchanged",
7989        )?;
7990        let telemetry_state = connection.query_row(
7991            "
7992            SELECT COUNT(*), SUM(e.estimated_tokens_saved)
7993            FROM usage_events AS e
7994            JOIN usage_instances AS i USING(instance_row_id)
7995            WHERE i.caller_label = 'schema-session'
7996            ",
7997            [],
7998            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<i64>>(1)?)),
7999        )?;
8000        require_eq(
8001            &telemetry_state,
8002            &(1, Some(80)),
8003            "future telemetry remains unchanged",
8004        )?;
8005        Ok(())
8006    }
8007
8008    #[test]
8009    fn projection_refresh_cannot_replace_publication_contract() -> Result<(), Box<dyn Error>> {
8010        let mut store = AtlasStore::in_memory()?;
8011        store.begin_index_publication("contract-a")?.complete()?;
8012
8013        store
8014            .begin_index_projection_refresh("contract-a")?
8015            .complete()?;
8016        require_eq(
8017            &store.index_publication()?,
8018            &Some(IndexPublication {
8019                state: IndexPublicationState::Complete,
8020                contract_fingerprint: Some("contract-a".to_string()),
8021                generation: IndexGeneration::new(2),
8022            }),
8023            "matching projection refresh",
8024        )?;
8025
8026        let publication = store.begin_index_projection_refresh("contract-a")?;
8027        set_metadata(
8028            &publication.connection,
8029            INDEX_PUBLICATION_FINGERPRINT_KEY,
8030            "contract-b",
8031        )?;
8032        let Err(mismatch) = publication.complete() else {
8033            return Err(io::Error::other(
8034                "projection refresh replaced the global publication contract",
8035            )
8036            .into());
8037        };
8038        if !matches!(mismatch, DbError::PublicationContractChanged) {
8039            return Err(io::Error::other(format!(
8040                "unexpected projection refresh mismatch: {mismatch}"
8041            ))
8042            .into());
8043        }
8044        require_eq(
8045            &store.index_publication()?,
8046            &Some(IndexPublication {
8047                state: IndexPublicationState::Complete,
8048                contract_fingerprint: Some("contract-a".to_string()),
8049                generation: IndexGeneration::new(2),
8050            }),
8051            "mismatched projection refresh rolls back",
8052        )?;
8053        Ok(())
8054    }
8055
8056    #[test]
8057    fn read_snapshots_expose_only_complete_publications() -> Result<(), Box<dyn Error>> {
8058        let temp = tempfile::tempdir()?;
8059        let db_path = temp.path().join("projectatlas.db");
8060        let independent_db_path = temp.path().join("independent.db");
8061        let mut writer_a = AtlasStore::open(&db_path)?;
8062        require_writable_connection_profile(&writer_a.connection)?;
8063        {
8064            let mut publication = writer_a.begin_index_publication("contract")?;
8065            write_test_projection(&mut publication, "old")?;
8066            publication.complete()?;
8067        }
8068        let mut writer_b = AtlasStore::open(&db_path)?;
8069        let mut independent_writer = AtlasStore::open(&independent_db_path)?;
8070        let old_reader = AtlasStore::open_read_only(&db_path)?;
8071        require_read_connection_profile(&old_reader.connection)?;
8072        if old_reader
8073            .connection
8074            .execute("DELETE FROM metadata", [])
8075            .is_ok()
8076        {
8077            return Err(io::Error::other("read-only connection accepted a mutation").into());
8078        }
8079        require_test_projection(&old_reader, 1, "old")?;
8080
8081        {
8082            let mut publication = writer_a.begin_index_publication("contract")?;
8083            write_test_projection(&mut publication, "new")?;
8084            require_test_projection(&old_reader, 1, "old")?;
8085
8086            let started = std::time::Instant::now();
8087            let Err(contention) = writer_b.begin_index_publication("contract") else {
8088                return Err(io::Error::other(
8089                    "second writer entered an active publication transaction",
8090                )
8091                .into());
8092            };
8093            require_eq(
8094                &matches!(
8095                    contention,
8096                    DbError::Sqlite(ref error)
8097                        if matches!(
8098                            error.sqlite_error_code(),
8099                            Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
8100                        )
8101                ),
8102                &true,
8103                "same-database writer contention",
8104            )?;
8105            require_eq(
8106                &(started.elapsed() < Duration::from_secs(2)),
8107                &true,
8108                "fail-fast same-database writer acquisition",
8109            )?;
8110            let restored_busy_timeout =
8111                writer_b
8112                    .connection
8113                    .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, u64>(0))?;
8114            require_eq(
8115                &u128::from(restored_busy_timeout),
8116                &SQLITE_BUSY_TIMEOUT.as_millis(),
8117                "ordinary busy timeout after failed publication acquisition",
8118            )?;
8119
8120            independent_writer
8121                .begin_index_publication("independent-contract")?
8122                .complete()?;
8123            require_eq(
8124                &independent_writer
8125                    .index_publication()?
8126                    .ok_or_else(|| io::Error::other("independent publication missing"))?
8127                    .generation,
8128                &IndexGeneration::new(1),
8129                "independent database generation",
8130            )?;
8131
8132            drop(publication);
8133        }
8134        let rolled_back_reader = AtlasStore::open_read_only(&db_path)?;
8135        require_test_projection(&rolled_back_reader, 1, "old")?;
8136        rolled_back_reader.finish_index_read_snapshot()?;
8137
8138        {
8139            let mut publication = writer_a.begin_index_publication("contract")?;
8140            write_test_projection(&mut publication, "new")?;
8141            publication.complete()?;
8142        }
8143        let restored_busy_timeout =
8144            writer_a
8145                .connection
8146                .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, u64>(0))?;
8147        require_eq(
8148            &u128::from(restored_busy_timeout),
8149            &SQLITE_BUSY_TIMEOUT.as_millis(),
8150            "ordinary busy timeout after successful publication acquisition",
8151        )?;
8152        require_test_projection(&old_reader, 1, "old")?;
8153        let new_reader = AtlasStore::open_read_only(&db_path)?;
8154        require_test_projection(&new_reader, 2, "new")?;
8155        new_reader.finish_index_read_snapshot()?;
8156        old_reader.finish_index_read_snapshot()?;
8157        Ok(())
8158    }
8159
8160    #[test]
8161    fn authored_write_waits_for_the_existing_writer_before_validation() -> Result<(), Box<dyn Error>>
8162    {
8163        let temp = tempfile::tempdir()?;
8164        let root = temp.path().join("repository");
8165        fs::create_dir_all(&root)?;
8166        let db_path = temp.path().join("projectatlas.db");
8167        let mut store = AtlasStore::open_for_project(&db_path, &root)?;
8168        store.replace_scan(&[test_file_node("src/lib.rs", "initial")])?;
8169
8170        let blocking_writer = Connection::open(&db_path)?;
8171        blocking_writer.busy_timeout(SQLITE_BUSY_TIMEOUT)?;
8172        blocking_writer.execute_batch("BEGIN IMMEDIATE")?;
8173        let release = std::thread::spawn(move || {
8174            std::thread::sleep(Duration::from_millis(1_250));
8175            blocking_writer.execute_batch("ROLLBACK")
8176        });
8177
8178        let started = Instant::now();
8179        store.set_purpose(
8180            "src/lib.rs",
8181            "Own the library source.",
8182            PurposeSource::Agent,
8183        )?;
8184        let elapsed = started.elapsed();
8185        release
8186            .join()
8187            .map_err(|_panic| io::Error::other("blocking writer thread panicked"))??;
8188
8189        require_eq(
8190            &(elapsed >= Duration::from_secs(1)),
8191            &true,
8192            "authored write waited for the existing writer",
8193        )?;
8194        require_eq(
8195            &(elapsed < SQLITE_BUSY_TIMEOUT),
8196            &true,
8197            "authored write stayed within the ordinary busy timeout",
8198        )?;
8199        require_eq(
8200            &store
8201                .load_node_by_path("src/lib.rs")?
8202                .ok_or_else(|| io::Error::other("purpose node missing"))?
8203                .purpose
8204                .purpose,
8205            &Some("Own the library source.".to_string()),
8206            "purpose after bounded writer contention",
8207        )
8208    }
8209
8210    #[test]
8211    fn writable_mutators_reject_an_active_read_snapshot() -> Result<(), Box<dyn Error>> {
8212        let temp = tempfile::tempdir()?;
8213        let db_path = temp.path().join("projectatlas.db");
8214        let mut store = AtlasStore::open(&db_path)?;
8215        store.replace_scan(&[test_file_node("src/lib.rs", "initial")])?;
8216        store.begin_index_read_snapshot()?;
8217
8218        let Err(purpose_error) = store.set_purpose(
8219            "src/lib.rs",
8220            "Must not be written through a read snapshot.",
8221            PurposeSource::Agent,
8222        ) else {
8223            return Err(io::Error::other("purpose wrote through a read snapshot").into());
8224        };
8225        require_eq(
8226            &matches!(purpose_error, DbError::IndexReadSnapshotActive),
8227            &true,
8228            "read-snapshot purpose rejection",
8229        )?;
8230
8231        let Err(scan_error) = store.replace_scan(&[]) else {
8232            return Err(io::Error::other("scan wrote through a read snapshot").into());
8233        };
8234        require_eq(
8235            &matches!(scan_error, DbError::IndexReadSnapshotActive),
8236            &true,
8237            "read-snapshot scan rejection",
8238        )?;
8239
8240        store.finish_index_read_snapshot()?;
8241        require_eq(
8242            &store.load_node_by_path("src/lib.rs")?.is_some(),
8243            &true,
8244            "read-snapshot rejected writes preserved indexed state",
8245        )?;
8246        Ok(())
8247    }
8248
8249    #[test]
8250    fn stale_publication_base_is_rejected_before_batch_mutation() -> Result<(), Box<dyn Error>> {
8251        let temp = tempfile::tempdir()?;
8252        let db_path = temp.path().join("projectatlas.db");
8253        let mut writer_a = AtlasStore::open(&db_path)?;
8254        {
8255            let mut publication =
8256                writer_a.begin_index_publication_from("contract", IndexGeneration::ZERO)?;
8257            write_test_projection(&mut publication, "base")?;
8258            publication.complete()?;
8259        }
8260        let prepared_base = writer_a
8261            .index_publication()?
8262            .ok_or_else(|| io::Error::other("prepared base publication missing"))?
8263            .generation;
8264
8265        let incomplete_result = {
8266            let mut publication =
8267                writer_a.begin_index_publication_from("contract", prepared_base)?;
8268            publication.begin_scan_replacement()?;
8269            publication.complete()
8270        };
8271        if !matches!(incomplete_result, Err(DbError::ScanReplacementIncomplete)) {
8272            return Err(io::Error::other(
8273                "unfinished scan replacement was allowed to complete publication",
8274            )
8275            .into());
8276        }
8277        require_test_projection(&writer_a, 1, "base")?;
8278
8279        let mut writer_b = AtlasStore::open(&db_path)?;
8280        {
8281            let mut publication = writer_b.begin_index_publication("contract")?;
8282            write_test_projection(&mut publication, "winner")?;
8283            publication.complete()?;
8284        }
8285
8286        let Err(conflict) = writer_a.begin_index_publication_from("contract", prepared_base) else {
8287            return Err(io::Error::other("stale prepared publication was accepted").into());
8288        };
8289        require_eq(
8290            &matches!(
8291                conflict,
8292                DbError::PublicationBaseGenerationChanged { expected, found }
8293                    if expected == IndexGeneration::new(1)
8294                        && found == IndexGeneration::new(2)
8295            ),
8296            &true,
8297            "stale publication base conflict",
8298        )?;
8299        require_test_projection(&writer_a, 2, "winner")?;
8300        require_eq(
8301            &writer_a.load_symbols(Some("src/lib.rs"), None, 10)?.len(),
8302            &1,
8303            "rejected batch symbol row count",
8304        )?;
8305        require_eq(
8306            &writer_a
8307                .load_symbol_relations(Some("src/lib.rs"), None, 10)?
8308                .len(),
8309            &1,
8310            "rejected batch relation row count",
8311        )?;
8312
8313        let Err(zero_conflict) =
8314            writer_a.begin_index_publication_from("contract", IndexGeneration::ZERO)
8315        else {
8316            return Err(io::Error::other("zero base matched an initialized store").into());
8317        };
8318        require_eq(
8319            &matches!(
8320                zero_conflict,
8321                DbError::PublicationBaseGenerationChanged { expected, found }
8322                    if expected == IndexGeneration::ZERO
8323                        && found == IndexGeneration::new(2)
8324            ),
8325            &true,
8326            "zero publication base conflict",
8327        )?;
8328        require_test_projection(&writer_a, 2, "winner")?;
8329        Ok(())
8330    }
8331
8332    #[test]
8333    fn records_token_overview() -> Result<(), Box<dyn Error>> {
8334        let project = tempfile::tempdir()?;
8335        let mut store = AtlasStore::in_memory()?;
8336        store.set_project_root(project.path())?;
8337        let mut session_event = usage_from_estimates(
8338            "session",
8339            "outline",
8340            Some("src/main.rs".to_string()),
8341            None,
8342            100,
8343            20,
8344        );
8345        session_event.estimated_tokens_saved = Some(1);
8346        store.record_usage(&session_event)?;
8347        let mut unknown_event = usage_from_estimates("session", "unknown", None, None, 0, 0);
8348        unknown_event.estimated_tokens_without_projectatlas = None;
8349        unknown_event.estimated_tokens_with_projectatlas = None;
8350        unknown_event.estimated_tokens_saved = None;
8351        store.record_usage(&unknown_event)?;
8352        store.record_usage(&usage_from_estimates(
8353            "other-session",
8354            "outline",
8355            Some("src/lib.rs".to_string()),
8356            None,
8357            200,
8358            50,
8359        ))?;
8360        let overview = store.token_overview(Some("session"))?;
8361        require_eq(&overview.calls, &1, "usage call count")?;
8362        require_eq(&overview.estimated_saved, &80, "saved token count")?;
8363        require_eq(&overview.buckets.len(), &1, "usage bucket count")?;
8364        require_eq(
8365            &overview.buckets[0].accuracy,
8366            &TOKEN_ACCURACY_HEURISTIC.to_string(),
8367            "usage bucket accuracy",
8368        )?;
8369        let all_sessions = store.token_overview(None)?;
8370        require_eq(&all_sessions.calls, &2, "all-session usage call count")?;
8371        require_eq(
8372            &all_sessions.estimated_without_projectatlas,
8373            &300,
8374            "all-session baseline tokens",
8375        )?;
8376        require_eq(
8377            &all_sessions.estimated_with_projectatlas,
8378            &70,
8379            "all-session atlas tokens",
8380        )?;
8381        require_eq(
8382            &all_sessions.estimated_saved,
8383            &230,
8384            "all-session saved tokens",
8385        )?;
8386        require_eq(
8387            &all_sessions.likely_file_reads_avoided,
8388            &0,
8389            "non-search estimate events do not count as avoided file reads",
8390        )?;
8391        require_eq(
8392            &all_sessions.read_avoidance_confidence,
8393            &READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED.to_string(),
8394            "non-search estimate read avoidance confidence",
8395        )?;
8396
8397        store.record_usage(&usage_from_text(
8398            "bucketed",
8399            "summary",
8400            Some("src/main.rs".to_string()),
8401            None,
8402            "abcdefghijkl",
8403            "abcd",
8404        ))?;
8405        store.record_usage(&usage_from_estimates(
8406            "bucketed", "folders", None, None, 100, 20,
8407        ))?;
8408        let bucketed = store.token_overview(Some("bucketed"))?;
8409        require_eq(&bucketed.buckets.len(), &2, "bucketed overview count")?;
8410        require_eq(
8411            &bucketed.buckets[0].token_savings_bucket,
8412            &TOKEN_BUCKET_FULL_FILE_COMPRESSION.to_string(),
8413            "source compression bucket",
8414        )?;
8415        require_eq(
8416            &bucketed.buckets[1].token_savings_bucket,
8417            &TOKEN_BUCKET_NAVIGATION_AVOIDANCE.to_string(),
8418            "navigation bucket",
8419        )?;
8420        require_eq(
8421            &bucketed.observed_file_read_replacements,
8422            &1,
8423            "observed read replacement count",
8424        )?;
8425        require_eq(
8426            &bucketed.modeled_file_reads_avoided,
8427            &0,
8428            "folder navigation does not count as modeled file-read avoidance",
8429        )?;
8430        require_eq(
8431            &bucketed.likely_file_reads_avoided,
8432            &1,
8433            "observed-only likely file reads avoided",
8434        )?;
8435        require_eq(
8436            &bucketed.read_avoidance_confidence,
8437            &READ_AVOIDANCE_CONFIDENCE_OBSERVED.to_string(),
8438            "observed-only read avoidance confidence",
8439        )?;
8440
8441        store.record_usage(&usage_from_text(
8442            "deduped",
8443            "summary",
8444            Some("src/lib.rs".to_string()),
8445            None,
8446            "abcdabcd",
8447            "ab",
8448        ))?;
8449        store.record_usage(&usage_from_estimates(
8450            "deduped",
8451            TOKEN_COMMAND_SEARCH,
8452            None,
8453            Some("token".to_string()),
8454            400,
8455            40,
8456        ))?;
8457        store.record_usage(&usage_from_estimates(
8458            "deduped",
8459            TOKEN_COMMAND_SEARCH,
8460            None,
8461            Some("token".to_string()),
8462            400,
8463            30,
8464        ))?;
8465        let deduped = store.token_overview(Some("deduped"))?;
8466        require_eq(
8467            &deduped.legacy_gross_estimated_saved,
8468            &731,
8469            "legacy gross saved tokens remains available",
8470        )?;
8471        require_eq(
8472            &deduped.measured_tokens_saved,
8473            &1,
8474            "measured saved tokens remain separate",
8475        )?;
8476        require_eq(
8477            &deduped.gross_modeled_tokens_avoided,
8478            &730,
8479            "gross modeled avoided tokens remains available",
8480        )?;
8481        require_eq(
8482            &deduped.deduped_modeled_tokens_avoided,
8483            &330,
8484            "modeled avoided tokens are deduped by baseline",
8485        )?;
8486        require_eq(
8487            &deduped.tokens_avoided,
8488            &331,
8489            "headline avoided tokens use measured plus deduped modeled",
8490        )?;
8491        require_eq(
8492            &deduped.observed_file_read_replacements,
8493            &1,
8494            "deduped observed read replacements",
8495        )?;
8496        require_eq(
8497            &deduped.modeled_file_reads_avoided,
8498            &2,
8499            "deduped raw search events remain likely file reads avoided",
8500        )?;
8501        require_eq(
8502            &deduped.likely_file_reads_avoided,
8503            &3,
8504            "deduped likely file reads avoided",
8505        )?;
8506        require_eq(
8507            &deduped.read_avoidance_confidence,
8508            &READ_AVOIDANCE_CONFIDENCE_MODELED.to_string(),
8509            "deduped read avoidance confidence",
8510        )?;
8511
8512        store.record_usage(&usage_from_estimates_with_accounting(
8513            "event-scoped",
8514            "folders",
8515            None,
8516            Some("token".to_string()),
8517            400,
8518            40,
8519            TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
8520            TOKEN_BASELINE_SELECTED_CANDIDATES,
8521            TOKEN_CONFIDENCE_INFERRED,
8522            TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
8523            TOKEN_BASELINE_SELECTED_CANDIDATES,
8524            TOKEN_DEDUPE_SCOPE_EVENT,
8525        ))?;
8526        store.record_usage(&usage_from_estimates_with_accounting(
8527            "event-scoped",
8528            "folders",
8529            None,
8530            Some("token".to_string()),
8531            400,
8532            30,
8533            TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
8534            TOKEN_BASELINE_SELECTED_CANDIDATES,
8535            TOKEN_CONFIDENCE_INFERRED,
8536            TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
8537            TOKEN_BASELINE_SELECTED_CANDIDATES,
8538            TOKEN_DEDUPE_SCOPE_EVENT,
8539        ))?;
8540        let event_scoped = store.token_overview(Some("event-scoped"))?;
8541        require_eq(
8542            &event_scoped.gross_modeled_tokens_avoided,
8543            &730,
8544            "event-scoped gross modeled avoided tokens",
8545        )?;
8546        require_eq(
8547            &event_scoped.deduped_modeled_tokens_avoided,
8548            &730,
8549            "event-scoped modeled events are not collapsed",
8550        )?;
8551        require_eq(
8552            &event_scoped.repeated_baselines_deduped,
8553            &0,
8554            "event-scoped modeled events do not count as deduped repeats",
8555        )?;
8556        require_eq(
8557            &event_scoped.likely_file_reads_avoided,
8558            &0,
8559            "folder navigation events do not count as likely file reads avoided",
8560        )?;
8561        require_eq(
8562            &event_scoped.read_avoidance_confidence,
8563            &READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED.to_string(),
8564            "folder navigation read avoidance confidence",
8565        )?;
8566
8567        let mut negative_event = usage_from_estimates("negative", "outline", None, None, 20, 50);
8568        negative_event.estimated_tokens_saved = Some(999);
8569        store.record_usage(&negative_event)?;
8570        let negative = store.token_overview(Some("negative"))?;
8571        require_eq(&negative.calls, &1, "negative session call count")?;
8572        require_eq(
8573            &negative.estimated_saved,
8574            &-30,
8575            "negative session recomputed delta",
8576        )?;
8577        require_eq(
8578            &negative.savings_rate,
8579            &Some(-1.5),
8580            "negative session savings rate",
8581        )?;
8582
8583        let mut zero_event = usage_from_estimates("zero-baseline", "outline", None, None, 0, 12);
8584        zero_event.estimated_tokens_saved = Some(999);
8585        store.record_usage(&zero_event)?;
8586        let zero_baseline = store.token_overview(Some("zero-baseline"))?;
8587        require_eq(&zero_baseline.calls, &1, "zero baseline call count")?;
8588        require_eq(
8589            &zero_baseline.estimated_saved,
8590            &-12,
8591            "zero baseline recomputed delta",
8592        )?;
8593        require_eq(
8594            &zero_baseline.savings_rate,
8595            &None,
8596            "zero baseline savings rate",
8597        )?;
8598
8599        let large_project = tempfile::tempdir()?;
8600        let mut large_store = AtlasStore::in_memory()?;
8601        large_store.set_project_root(large_project.path())?;
8602        let maximum_estimate = usize::try_from(i64::MAX)?;
8603        large_store.record_usage(&usage_from_estimates(
8604            "large-primary",
8605            "large",
8606            None,
8607            None,
8608            maximum_estimate,
8609            0,
8610        ))?;
8611        let Err(overflow) = large_store.record_usage(&usage_from_estimates(
8612            "large-rejected",
8613            "large",
8614            None,
8615            None,
8616            maximum_estimate,
8617            0,
8618        )) else {
8619            return Err(io::Error::other("overflowing telemetry aggregate was committed").into());
8620        };
8621        require_eq(
8622            &matches!(overflow, DbError::TelemetryIntegerOverflow { .. }),
8623            &true,
8624            "overflowing telemetry aggregate error",
8625        )?;
8626        let large = large_store.token_overview(Some("large-primary"))?;
8627        require_eq(
8628            &large.estimated_saved,
8629            &isize::MAX,
8630            "largest accepted aggregate narrows at the public boundary",
8631        )?;
8632        require_eq(
8633            &large_store.token_overview(Some("large-rejected"))?.calls,
8634            &0,
8635            "rejected aggregate left no partial report",
8636        )?;
8637        Ok(())
8638    }
8639
8640    #[test]
8641    fn direct_library_usage_rotates_at_baseline_capacity_without_reopening_the_old_scope()
8642    -> Result<(), Box<dyn Error>> {
8643        let project = tempfile::tempdir()?;
8644        let mut store = AtlasStore::in_memory()?;
8645        store.set_project_root(project.path())?;
8646        let policy = TelemetryRetentionPolicy::default();
8647        let label = "bounded-library-label";
8648
8649        for index in 0..policy.max_baselines_per_instance {
8650            store.record_usage(&usage_from_estimates(
8651                label,
8652                "search",
8653                None,
8654                Some(format!("query-{index}")),
8655                100,
8656                20,
8657            ))?;
8658        }
8659        let old_instance = store
8660            .library_usage_instances
8661            .borrow()
8662            .get(label)
8663            .copied()
8664            .flatten()
8665            .ok_or_else(|| io::Error::other("library instance was not retained"))?;
8666
8667        let boundary_event = usage_from_estimates(
8668            label,
8669            "search",
8670            None,
8671            Some("capacity-boundary".to_string()),
8672            100,
8673            20,
8674        );
8675        store.record_usage(&boundary_event)?;
8676        let replacement = store
8677            .library_usage_instances
8678            .borrow()
8679            .get(label)
8680            .copied()
8681            .flatten()
8682            .ok_or_else(|| io::Error::other("replacement library instance was not retained"))?;
8683        require_eq(
8684            &(replacement != old_instance),
8685            &true,
8686            "capacity rotation created a new internal instance",
8687        )?;
8688        require_eq(
8689            &store.connection.query_row(
8690                "SELECT COUNT(*) FROM usage_instances WHERE state = 'sealed'",
8691                [],
8692                |row| row.get::<_, i64>(0),
8693            )?,
8694            &1,
8695            "sealed predecessor instances",
8696        )?;
8697        require_eq(
8698            &store.connection.query_row(
8699                "SELECT COUNT(*) FROM usage_instances WHERE state = 'active'",
8700                [],
8701                |row| row.get::<_, i64>(0),
8702            )?,
8703            &1,
8704            "active replacement instances",
8705        )?;
8706        require_eq(
8707            &store.connection.query_row(
8708                "SELECT COUNT(*) FROM usage_instance_baselines",
8709                [],
8710                |row| row.get::<_, i64>(0),
8711            )?,
8712            &1,
8713            "only replacement baseline witnesses remain",
8714        )?;
8715        require_eq(
8716            &store.token_overview(Some(label))?.calls,
8717            &(policy.max_baselines_per_instance + 1),
8718            "caller-label report spans both bounded instances",
8719        )?;
8720        require_eq(
8721            &store.token_overview(None)?.estimated_saved,
8722            &(isize::try_from(policy.max_baselines_per_instance + 1)? * 80),
8723            "global totals remain exact across rotation",
8724        )?;
8725        require_eq(
8726            &matches!(
8727                store.record_usage_for_instance(
8728                    old_instance,
8729                    UsageInstanceOwner::LibraryHandle,
8730                    &boundary_event,
8731                    false,
8732                ),
8733                Err(DbError::TelemetryInstanceInactive)
8734            ),
8735            &true,
8736            "sealed predecessor cannot be reopened",
8737        )?;
8738        Ok(())
8739    }
8740
8741    #[test]
8742    fn rejected_library_labels_do_not_consume_the_bounded_identity_map()
8743    -> Result<(), Box<dyn Error>> {
8744        let project = tempfile::tempdir()?;
8745        let mut store = AtlasStore::in_memory()?;
8746        store.set_project_root(project.path())?;
8747        let maximum = TelemetryRetentionPolicy::default().max_label_bytes;
8748
8749        for extra in 1..=4 {
8750            let event =
8751                usage_from_estimates(&"x".repeat(maximum + extra), "summary", None, None, 100, 20);
8752            require_eq(
8753                &matches!(
8754                    store.record_usage(&event),
8755                    Err(DbError::TelemetryFieldTooLarge {
8756                        field: "session_id",
8757                        ..
8758                    })
8759                ),
8760                &true,
8761                "oversized caller label rejection",
8762            )?;
8763        }
8764        require_eq(
8765            &store.library_usage_instances.borrow().len(),
8766            &0,
8767            "rejected labels retained no identity-map entries",
8768        )?;
8769        Ok(())
8770    }
8771
8772    #[test]
8773    fn token_trends_group_usage_by_period_and_bucket() -> Result<(), Box<dyn Error>> {
8774        let project = tempfile::tempdir()?;
8775        let mut store = AtlasStore::in_memory()?;
8776        store.set_project_root(project.path())?;
8777        for (session, bucket, baseline_kind, confidence, without, with) in [
8778            (
8779                "session",
8780                TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
8781                "selected_candidates",
8782                "inferred",
8783                100_usize,
8784                25_usize,
8785            ),
8786            (
8787                "session",
8788                TOKEN_BUCKET_FULL_FILE_COMPRESSION,
8789                "full_file",
8790                "observed",
8791                50_usize,
8792                10_usize,
8793            ),
8794            (
8795                "session",
8796                TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
8797                "selected_candidates",
8798                "inferred",
8799                80_usize,
8800                20_usize,
8801            ),
8802            (
8803                "other",
8804                TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
8805                "selected_candidates",
8806                "inferred",
8807                999_usize,
8808                1_usize,
8809            ),
8810        ] {
8811            store.record_usage(&usage_from_estimates_with_context(
8812                session,
8813                "trend",
8814                None,
8815                None,
8816                without,
8817                with,
8818                bucket,
8819                baseline_kind,
8820                confidence,
8821            ))?;
8822        }
8823
8824        let trends = store.token_trends(Some("session"), TokenTrendWindow::Day)?;
8825        require_eq(&trends.periods.len(), &1, "current daily period")?;
8826        require_eq(&trends.periods[0].calls, &3, "session trend call count")?;
8827        require_eq(
8828            &trends.periods[0].estimated_saved,
8829            &175,
8830            "session trend saved tokens",
8831        )?;
8832        require_eq(
8833            &trends.periods[0].buckets.len(),
8834            &2,
8835            "trend preserves evidence buckets",
8836        )?;
8837        require_eq(
8838            &trends.periods[0].buckets[0].token_savings_bucket,
8839            &TOKEN_BUCKET_FULL_FILE_COMPRESSION.to_string(),
8840            "full-file bucket remains visible",
8841        )?;
8842        require_eq(
8843            &trends.periods[0].buckets[0].confidence,
8844            &"observed".to_string(),
8845            "bucket confidence remains visible",
8846        )?;
8847        let all_labels = store.token_trends(None, TokenTrendWindow::Day)?;
8848        require_eq(&all_labels.periods.len(), &1, "all-label daily period")?;
8849        require_eq(&all_labels.periods[0].calls, &4, "all-label trend calls")?;
8850        Ok(())
8851    }
8852
8853    #[test]
8854    fn unsupported_legacy_schema_is_refused_without_mutation() -> Result<(), Box<dyn Error>> {
8855        let temp = tempfile::tempdir()?;
8856        let db_path = temp.path().join("legacy.db");
8857        {
8858            let connection = Connection::open(&db_path)?;
8859            connection.execute_batch(
8860                "
8861                CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL);
8862                INSERT INTO metadata(key, value) VALUES('schema_version', '7');
8863                CREATE TABLE usage_events (
8864                    id INTEGER PRIMARY KEY,
8865                    session_id TEXT NOT NULL,
8866                    command TEXT NOT NULL,
8867                    path TEXT,
8868                    query TEXT,
8869                    estimated_tokens_without_projectatlas INTEGER,
8870                    estimated_tokens_with_projectatlas INTEGER,
8871                    estimated_tokens_saved INTEGER,
8872                    token_savings_bucket TEXT NOT NULL DEFAULT 'navigation_avoidance',
8873                    provider TEXT NOT NULL DEFAULT 'heuristic',
8874                    model TEXT NOT NULL DEFAULT 'unknown',
8875                    tokenizer_backend TEXT NOT NULL DEFAULT 'chars_div_4',
8876                    accuracy TEXT NOT NULL DEFAULT 'heuristic_estimate',
8877                    baseline_kind TEXT NOT NULL DEFAULT 'selected_candidates',
8878                    confidence TEXT NOT NULL DEFAULT 'inferred',
8879                    calculation_trace TEXT NOT NULL DEFAULT 'heuristic=ceil(chars_or_bytes/4)'
8880                );
8881                INSERT INTO usage_events(
8882                    session_id,
8883                    command,
8884                    estimated_tokens_without_projectatlas,
8885                    estimated_tokens_with_projectatlas,
8886                    estimated_tokens_saved
8887                )
8888                VALUES('legacy-session', 'legacy', 100, 20, 80);
8889                ",
8890            )?;
8891        }
8892
8893        let database_before = fs::read(&db_path)?;
8894        let Err(open_error) = AtlasStore::open(&db_path) else {
8895            return Err(io::Error::other("unsupported schema unexpectedly opened").into());
8896        };
8897        require_eq(
8898            &matches!(
8899                open_error,
8900                DbError::SchemaVersion {
8901                    found: 7,
8902                    expected: SCHEMA_VERSION,
8903                }
8904            ),
8905            &true,
8906            "unsupported schema rejection",
8907        )?;
8908        require_eq(
8909            &fs::read(&db_path)?,
8910            &database_before,
8911            "unsupported schema bytes remain unchanged",
8912        )?;
8913        Ok(())
8914    }
8915
8916    #[test]
8917    fn stores_project_root_in_metadata() -> Result<(), Box<dyn Error>> {
8918        let mut store = AtlasStore::in_memory()?;
8919        store.set_project_root(Path::new("C:/workspace/example"))?;
8920        require_eq(
8921            &store.project_root()?,
8922            &Some("C:/workspace/example".to_string()),
8923            "project root metadata",
8924        )?;
8925        store.set_project_root(Path::new(r"\\?\C:\workspace\example"))?;
8926        require_eq(
8927            &store.project_root()?,
8928            &Some("C:/workspace/example".to_string()),
8929            "windows extended project root metadata",
8930        )?;
8931        let Err(rebind_error) = store.set_project_root(Path::new(r"\\?\UNC\server\share\repo"))
8932        else {
8933            return Err(io::Error::other("project identity was rebound implicitly").into());
8934        };
8935        require_eq(
8936            &matches!(rebind_error, DbError::ProjectRootMismatch { .. }),
8937            &true,
8938            "project identity rebind rejection",
8939        )?;
8940
8941        let mut unc_store = AtlasStore::in_memory()?;
8942        unc_store.set_project_root(Path::new(r"\\?\UNC\server\share\repo"))?;
8943        require_eq(
8944            &unc_store.project_root()?,
8945            &Some("//server/share/repo".to_string()),
8946            "windows unc project root metadata",
8947        )?;
8948        Ok(())
8949    }
8950
8951    #[test]
8952    fn read_project_root_read_only_does_not_change_database_bytes() -> Result<(), Box<dyn Error>> {
8953        let temp = tempfile::tempdir()?;
8954        let root = temp.path().join("repo with spaces");
8955        let atlas_dir = root.join(".projectatlas");
8956        fs::create_dir_all(&atlas_dir)?;
8957        let db_path = atlas_dir.join("projectatlas.db");
8958        {
8959            let mut store = AtlasStore::open(&db_path)?;
8960            store.set_project_root(&root)?;
8961            store
8962                .connection
8963                .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
8964        }
8965        let wal_path = db_path.with_extension("db-wal");
8966        let shm_path = db_path.with_extension("db-shm");
8967        for sidecar_path in [&wal_path, &shm_path] {
8968            match fs::remove_file(sidecar_path) {
8969                Ok(()) => {}
8970                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
8971                Err(error) => return Err(error.into()),
8972            }
8973        }
8974        let database_before = fs::read(&db_path)?;
8975
8976        require_eq(
8977            &read_project_root_read_only(&db_path)?,
8978            &Some(normalize_native_path_display(&root)),
8979            "read-only project root",
8980        )?;
8981        require_eq(
8982            &fs::read(&db_path)?,
8983            &database_before,
8984            "read-only project-root database bytes",
8985        )?;
8986        Ok(())
8987    }
8988
8989    #[test]
8990    fn read_project_root_read_only_observes_active_wal_state() -> Result<(), Box<dyn Error>> {
8991        let temp = tempfile::tempdir()?;
8992        let root = temp.path().join("repo-Δ");
8993        let atlas_dir = root.join(".projectatlas");
8994        fs::create_dir_all(&atlas_dir)?;
8995        let db_path = atlas_dir.join("projectatlas.db");
8996        let mut store = AtlasStore::open(&db_path)?;
8997        store
8998            .connection
8999            .execute_batch("PRAGMA wal_autocheckpoint = 0")?;
9000        store.set_project_root(&root)?;
9001
9002        require_eq(
9003            &sqlite_sidecar_path(&db_path, "-wal").exists(),
9004            &true,
9005            "active WAL exists",
9006        )?;
9007        require_eq(
9008            &read_project_root_read_only(&db_path)?,
9009            &Some(normalize_native_path_display(&root)),
9010            "WAL-aware read-only project root",
9011        )?;
9012        Ok(())
9013    }
9014
9015    #[test]
9016    fn read_only_telemetry_revalidates_project_identity_before_write() -> Result<(), Box<dyn Error>>
9017    {
9018        let temp = tempfile::tempdir()?;
9019        let root = temp.path().join("repository");
9020        fs::create_dir_all(&root)?;
9021        let db_path = temp.path().join("projectatlas.db");
9022        drop(AtlasStore::open_for_project(&db_path, &root)?);
9023
9024        let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
9025        reader.finish_index_read_snapshot()?;
9026        AtlasStore::transition_project_root(&db_path, &root, ProjectRootTransition::Detach)?;
9027
9028        let event = usage_from_estimates(
9029            "identity-race",
9030            "summary",
9031            Some("src/lib.rs".to_string()),
9032            None,
9033            100,
9034            20,
9035        );
9036        let Err(error) = reader.record_usage(&event) else {
9037            return Err(io::Error::other("telemetry wrote through replaced identity").into());
9038        };
9039        require_eq(
9040            &matches!(error, DbError::ProjectRootTransitionChanged { .. }),
9041            &true,
9042            "telemetry project identity recheck",
9043        )?;
9044        let current = AtlasStore::open_read_only_for_project(&db_path, &root)?;
9045        require_eq(
9046            &current.token_overview(Some("identity-race"))?.calls,
9047            &0,
9048            "telemetry refused after identity replacement",
9049        )?;
9050        Ok(())
9051    }
9052
9053    #[test]
9054    fn telemetry_contention_uses_ancillary_busy_budget() -> Result<(), Box<dyn Error>> {
9055        let temp = tempfile::tempdir()?;
9056        let root = temp.path().join("repository");
9057        fs::create_dir_all(&root)?;
9058        let db_path = temp.path().join("projectatlas.db");
9059        drop(AtlasStore::open_for_project(&db_path, &root)?);
9060
9061        let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
9062        let writer = Connection::open(&db_path)?;
9063        writer.execute_batch("BEGIN IMMEDIATE")?;
9064        let started = Instant::now();
9065        let result = reader.record_usage(&usage_from_estimates(
9066            "contended",
9067            "overview",
9068            None,
9069            None,
9070            100,
9071            20,
9072        ));
9073        let elapsed = started.elapsed();
9074        writer.execute_batch("ROLLBACK")?;
9075
9076        let Err(error) = result else {
9077            return Err(
9078                io::Error::other("contended telemetry write unexpectedly succeeded").into(),
9079            );
9080        };
9081        require_eq(
9082            &error.is_write_unavailable(),
9083            &true,
9084            "contended telemetry error kind",
9085        )?;
9086        require_eq(
9087            &(elapsed < Duration::from_millis(500)),
9088            &true,
9089            "ancillary telemetry busy latency",
9090        )?;
9091        require_eq(
9092            &reader.token_overview(Some("contended"))?.calls,
9093            &0,
9094            "contended telemetry rollback",
9095        )?;
9096        Ok(())
9097    }
9098
9099    #[cfg(unix)]
9100    #[test]
9101    fn read_only_telemetry_does_not_recreate_a_removed_database() -> Result<(), Box<dyn Error>> {
9102        let temp = tempfile::tempdir()?;
9103        let root = temp.path().join("repository");
9104        fs::create_dir_all(&root)?;
9105        let db_path = temp.path().join("projectatlas.db");
9106        drop(AtlasStore::open_for_project(&db_path, &root)?);
9107
9108        let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
9109        fs::remove_file(&db_path)?;
9110        let event = usage_from_estimates(
9111            "removed-database",
9112            "summary",
9113            Some("src/lib.rs".to_string()),
9114            None,
9115            100,
9116            20,
9117        );
9118        if reader.record_usage(&event).is_ok() {
9119            return Err(io::Error::other("telemetry reopened a removed database").into());
9120        }
9121        require_eq(
9122            &db_path.exists(),
9123            &false,
9124            "telemetry must not recreate a removed database",
9125        )?;
9126        Ok(())
9127    }
9128
9129    #[test]
9130    fn read_only_store_opens_checkpointed_wal_without_existing_sidecars()
9131    -> Result<(), Box<dyn Error>> {
9132        let temp = tempfile::tempdir()?;
9133        let root = temp.path().join("repo with spaces");
9134        let atlas_dir = root.join(".projectatlas");
9135        fs::create_dir_all(&atlas_dir)?;
9136        let db_path = atlas_dir.join("projectatlas.db");
9137        {
9138            let mut store = AtlasStore::open(&db_path)?;
9139            store.set_project_root(&root)?;
9140            store
9141                .connection
9142                .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
9143        }
9144        for sidecar_path in [
9145            sqlite_sidecar_path(&db_path, "-wal"),
9146            sqlite_sidecar_path(&db_path, "-shm"),
9147        ] {
9148            match fs::remove_file(sidecar_path) {
9149                Ok(()) => {}
9150                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9151                Err(error) => return Err(error.into()),
9152            }
9153        }
9154
9155        let store = AtlasStore::open_read_only(&db_path)?;
9156        require_eq(
9157            &store.project_root()?,
9158            &Some(normalize_native_path_display(&root)),
9159            "read-only checkpointed project root",
9160        )?;
9161        store.finish_index_read_snapshot()?;
9162        Ok(())
9163    }
9164
9165    #[test]
9166    fn partial_scan_updates_and_absents_paths() -> Result<(), Box<dyn Error>> {
9167        let mut store = AtlasStore::in_memory()?;
9168        store.replace_scan(&[
9169            test_file_node("src/a.rs", "hash-a"),
9170            test_file_node("src/b.rs", "hash-b"),
9171        ])?;
9172        store.upsert_scan_nodes(&[test_file_node("src/a.rs", "hash-a2")])?;
9173        let updated = store
9174            .load_node_by_path("src/a.rs")?
9175            .ok_or_else(|| io::Error::other("updated node missing"))?;
9176        require_eq(
9177            &updated.node.content_hash,
9178            &Some("hash-a2".to_string()),
9179            "partial content hash",
9180        )?;
9181        require_eq(
9182            &store.load_node_by_path("src/b.rs")?.is_some(),
9183            &true,
9184            "unrelated node remains indexed",
9185        )?;
9186        store.mark_paths_absent(&["src/b.rs".to_string()])?;
9187        require_eq(
9188            &store.load_node_by_path("src/b.rs")?.is_none(),
9189            &true,
9190            "absent path is no longer returned",
9191        )?;
9192        Ok(())
9193    }
9194
9195    #[test]
9196    fn approved_purpose_survives_incremental_file_hash_changes() -> Result<(), Box<dyn Error>> {
9197        let mut store = AtlasStore::in_memory()?;
9198        store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
9199        store.set_purpose(
9200            "src/main.rs",
9201            "Application entry point",
9202            PurposeSource::Agent,
9203        )?;
9204        store.upsert_scan_nodes(&[test_file_node("src/main.rs", "hash-b")])?;
9205
9206        let node = store
9207            .load_node_by_path("src/main.rs")?
9208            .ok_or_else(|| io::Error::other("changed node missing"))?;
9209        require_eq(
9210            &node.purpose.status,
9211            &PurposeStatus::Approved,
9212            "changed approved file purpose status",
9213        )?;
9214        require_eq(
9215            &node.purpose.agent_reviewed(),
9216            &true,
9217            "changed approved file remains agent reviewed",
9218        )?;
9219        Ok(())
9220    }
9221
9222    #[test]
9223    fn ranked_nodes_are_loaded_bounded_from_sql() -> Result<(), Box<dyn Error>> {
9224        let mut store = AtlasStore::in_memory()?;
9225        let mut gradle_task_node = test_file_node("build.gradle.kts", "hash-gradle");
9226        gradle_task_node.extension = Some(".kts".to_string());
9227        gradle_task_node.language = Some("kotlin".to_string());
9228        store.replace_scan(&[
9229            test_folder_node("src/auth"),
9230            test_folder_node("src/ui"),
9231            test_file_node("src/auth/login.rs", "hash-login"),
9232            test_file_node("src/ui/button.rs", "hash-button"),
9233            gradle_task_node,
9234        ])?;
9235        store.set_purpose(
9236            "src/auth",
9237            "Authentication workflow folder",
9238            PurposeSource::Agent,
9239        )?;
9240        store.set_purpose("src/ui", "User interface folder", PurposeSource::Agent)?;
9241        store.set_node_summary("src/auth/login.rs", "rust source defining login flow")?;
9242
9243        let folders = store.load_ranked_nodes("authentication", NodeKind::Folder, None, 1, 0)?;
9244        require_eq(&folders.len(), &1, "bounded folder ranking")?;
9245        require_eq(
9246            &folders[0].node.path,
9247            &"src/auth".to_string(),
9248            "semantic folder ranking",
9249        )?;
9250
9251        let files = store.load_ranked_nodes("login", NodeKind::File, Some("src/auth"), 10, 0)?;
9252        require_eq(&files.len(), &1, "folder-constrained file ranking")?;
9253        require_eq(
9254            &files[0].node.path,
9255            &"src/auth/login.rs".to_string(),
9256            "ranked file path",
9257        )?;
9258        store.replace_symbol_graph(&SymbolGraph {
9259            path: "build.gradle.kts".to_string(),
9260            language: Some("kotlin".to_string()),
9261            parser: ParserKind::TreeSitter,
9262            symbols: vec![CodeSymbol {
9263                path: "build.gradle.kts".to_string(),
9264                language: Some("kotlin".to_string()),
9265                name: "bootRunE2E".to_string(),
9266                kind: SymbolKind::Function,
9267                signature: "tasks.register<BootRun>(\"bootRunE2E\")".to_string(),
9268                exported: false,
9269                documentation: None,
9270                line_start: 1,
9271                line_end: 1,
9272                parent: None,
9273                parser: ParserKind::TreeSitter,
9274                detail: Some("gradle-kotlin-dsl-task".to_string()),
9275            }],
9276            relations: Vec::new(),
9277        })?;
9278        let gradle_files = store.load_ranked_nodes("bootRunE2E", NodeKind::File, None, 10, 0)?;
9279        require_eq(&gradle_files.len(), &1, "symbol-ranked file count")?;
9280        require_eq(
9281            &gradle_files[0].node.path,
9282            &"build.gradle.kts".to_string(),
9283            "symbol-ranked file path",
9284        )?;
9285        store.clear_symbol_graph_for_path("build.gradle.kts")?;
9286        let cleared_gradle_files =
9287            store.load_ranked_nodes("bootRunE2E", NodeKind::File, None, 10, 0)?;
9288        require_eq(
9289            &cleared_gradle_files.len(),
9290            &0,
9291            "cleared symbol-ranked file count",
9292        )?;
9293        Ok(())
9294    }
9295
9296    #[test]
9297    fn ranked_node_admission_preserves_dominant_tiers_before_candidate_cap()
9298    -> Result<(), Box<dyn Error>> {
9299        let mut store = AtlasStore::in_memory()?;
9300        let mut nodes = vec![
9301            test_file_node("needle", "hash-exact"),
9302            test_file_node("deep/needle", "hash-name"),
9303            test_file_node("reviewed.rs", "hash-reviewed"),
9304        ];
9305        nodes.extend(
9306            (0..130).map(|index| test_file_node(&format!("weak/needle-{index:03}.rs"), "hash")),
9307        );
9308        store.replace_scan(&nodes)?;
9309        store.set_purpose(
9310            "reviewed.rs",
9311            "Own needle responsibility",
9312            PurposeSource::Agent,
9313        )?;
9314        for index in 0..130 {
9315            let path = format!("weak/needle-{index:03}.rs");
9316            store.set_suggested_purpose(&path, "Generated needle suggestion")?;
9317            store.set_node_summary(&path, "Observed needle summary")?;
9318        }
9319
9320        let selected = store.load_ranked_nodes("needle", NodeKind::File, None, 100, 0)?;
9321        require_eq(&selected.len(), &100, "bounded adversarial candidate count")?;
9322        require_eq(
9323            &selected[..3]
9324                .iter()
9325                .map(|node| node.node.path.as_str())
9326                .collect::<Vec<_>>(),
9327            &vec!["needle", "deep/needle", "reviewed.rs"],
9328            "pre-cap exact path basename and reviewed-purpose admission",
9329        )?;
9330        Ok(())
9331    }
9332
9333    #[test]
9334    fn folder_like_filters_treat_wildcards_as_literal_path_text() -> Result<(), Box<dyn Error>> {
9335        let mut store = AtlasStore::in_memory()?;
9336        store.replace_scan(&[
9337            test_folder_node("src/a%b"),
9338            test_folder_node("src/axb"),
9339            test_folder_node("src/a_b"),
9340            test_folder_node("src/acb"),
9341            test_file_node("src/a%b/target.rs", "hash-percent-target"),
9342            test_file_node("src/axb/false.rs", "hash-percent-false"),
9343            test_file_node("src/a_b/target.rs", "hash-underscore-target"),
9344            test_file_node("src/acb/false.rs", "hash-underscore-false"),
9345        ])?;
9346        for path in [
9347            "src/a%b/target.rs",
9348            "src/axb/false.rs",
9349            "src/a_b/target.rs",
9350            "src/acb/false.rs",
9351        ] {
9352            store.set_node_summary(path, "needle indexed summary")?;
9353        }
9354
9355        let percent_files =
9356            store.load_ranked_nodes("needle", NodeKind::File, Some("src/a%b"), 10, 0)?;
9357        require_eq(&percent_files.len(), &1, "percent folder ranked count")?;
9358        require_eq(
9359            &percent_files[0].node.path,
9360            &"src/a%b/target.rs".to_string(),
9361            "percent folder ranked path",
9362        )?;
9363        require_eq(
9364            &store.source_file_byte_count(Some("src/a%b"))?,
9365            &12,
9366            "percent folder byte count",
9367        )?;
9368
9369        let mut visited = Vec::new();
9370        store.visit_file_token_estimates(Some("src/a_b"), |path, _size| {
9371            visited.push(path);
9372            Ok(true)
9373        })?;
9374        require_eq(
9375            &visited,
9376            &vec!["src/a_b/target.rs".to_string()],
9377            "underscore folder token paths",
9378        )?;
9379
9380        store.mark_paths_absent(&["src/a%b".to_string(), "src/a_b".to_string()])?;
9381        require_eq(
9382            &store.load_node_by_path("src/axb/false.rs")?.is_some(),
9383            &true,
9384            "percent-like sibling remains indexed",
9385        )?;
9386        require_eq(
9387            &store.load_node_by_path("src/acb/false.rs")?.is_some(),
9388            &true,
9389            "underscore-like sibling remains indexed",
9390        )?;
9391        require_eq(
9392            &store.load_node_by_path("src/a%b/target.rs")?.is_none(),
9393            &true,
9394            "percent folder target removed",
9395        )?;
9396        require_eq(
9397            &store.load_node_by_path("src/a_b/target.rs")?.is_none(),
9398            &true,
9399            "underscore folder target removed",
9400        )?;
9401        Ok(())
9402    }
9403
9404    #[test]
9405    fn sql_health_findings_match_resolution_ids() -> Result<(), Box<dyn Error>> {
9406        let mut store = AtlasStore::in_memory()?;
9407        store.replace_scan(&[
9408            test_file_node("src/a.rs", "hash-a"),
9409            test_file_node("src/b.rs", "hash-b"),
9410        ])?;
9411        store.set_purpose("src/a.rs", "Shared purpose", PurposeSource::Agent)?;
9412        store.set_purpose("src/b.rs", "Shared purpose", PurposeSource::Agent)?;
9413
9414        let findings = store.unresolved_health_findings(&[])?;
9415        let duplicate = findings
9416            .iter()
9417            .find(|finding| finding.category == "duplicate-purpose")
9418            .ok_or_else(|| io::Error::other("duplicate-purpose finding missing"))?;
9419        store.resolve_health_finding(&HealthResolution {
9420            finding_id: duplicate.id.clone(),
9421            category: duplicate.category.clone(),
9422            path: duplicate.path.clone(),
9423            related_path: duplicate.related_path.clone(),
9424            rationale: "Intentional mirror for test.".to_string(),
9425        })?;
9426        let remaining = store.unresolved_health_findings(&store.resolved_health_ids()?)?;
9427        require_eq(&remaining.is_empty(), &true, "resolved SQL health finding")?;
9428        Ok(())
9429    }
9430
9431    #[test]
9432    fn unresolved_health_findings_page_filters_and_bounds_rows() -> Result<(), Box<dyn Error>> {
9433        let mut store = AtlasStore::in_memory()?;
9434        store.replace_scan(&[
9435            test_folder_node("."),
9436            test_folder_node("src"),
9437            test_file_node("src/a.rs", "hash-a"),
9438            test_file_node("docs/a.rs", "hash-doc"),
9439        ])?;
9440        let query = HealthQuery {
9441            start_index: 1,
9442            limit: 1,
9443            category: Some("missing-purpose".to_string()),
9444            severity: Some(Severity::Warning),
9445            path_prefix: Some("src".to_string()),
9446            summary_only: false,
9447            scope: HealthScope::all(),
9448        };
9449
9450        let page = store.unresolved_health_findings_page(&[], &query)?;
9451        require_eq(&page.unfiltered_total, &4, "unfiltered health total")?;
9452        require_eq(&page.total, &2, "filtered health total")?;
9453        require_eq(&page.returned, &1, "returned health rows")?;
9454        require_eq(
9455            &page.findings[0].path,
9456            &"src/a.rs".to_string(),
9457            "paged path",
9458        )?;
9459
9460        let summary_page = store.unresolved_health_findings_page(
9461            &[],
9462            &HealthQuery {
9463                summary_only: true,
9464                ..query
9465            },
9466        )?;
9467        require_eq(&summary_page.total, &2, "summary-only total")?;
9468        require_eq(
9469            &summary_page.findings.is_empty(),
9470            &true,
9471            "summary-only rows",
9472        )?;
9473        Ok(())
9474    }
9475
9476    #[test]
9477    fn unresolved_health_findings_page_skips_resolved_lifecycle_rows_before_paging()
9478    -> Result<(), Box<dyn Error>> {
9479        let mut store = AtlasStore::in_memory()?;
9480        store.replace_scan(&[
9481            test_file_node("src/a.rs", "hash-a"),
9482            test_file_node("src/b.rs", "hash-b"),
9483            test_file_node("src/c.rs", "hash-c"),
9484            test_file_node("src/d.rs", "hash-d"),
9485        ])?;
9486        store.resolve_health_finding(&HealthResolution {
9487            finding_id: finding_id("missing-purpose", "src/b.rs", None),
9488            category: "missing-purpose".to_string(),
9489            path: "src/b.rs".to_string(),
9490            related_path: None,
9491            rationale: "Resolved for pagination regression.".to_string(),
9492        })?;
9493
9494        let page = store.unresolved_health_findings_page_current(&HealthQuery {
9495            start_index: 0,
9496            limit: 2,
9497            category: Some("missing-purpose".to_string()),
9498            severity: Some(Severity::Warning),
9499            path_prefix: Some("src".to_string()),
9500            summary_only: false,
9501            scope: HealthScope::all(),
9502        })?;
9503
9504        require_eq(&page.total, &3, "filtered unresolved missing total")?;
9505        require_eq(&page.returned, &2, "returned unresolved missing rows")?;
9506        require_eq(
9507            &page
9508                .findings
9509                .iter()
9510                .map(|finding| finding.path.as_str())
9511                .collect::<Vec<_>>(),
9512            &vec!["src/a.rs", "src/c.rs"],
9513            "resolved row skipped before limit",
9514        )?;
9515        Ok(())
9516    }
9517
9518    #[test]
9519    fn stored_health_resolution_filter_stays_indexed_and_bind_bounded() -> Result<(), Box<dyn Error>>
9520    {
9521        const HISTORICAL_RESOLUTIONS: usize = 1_500;
9522
9523        let temp = tempfile::tempdir()?;
9524        let root = temp.path().join("repository");
9525        fs::create_dir_all(root.join("src"))?;
9526        let database = temp.path().join("projectatlas.db");
9527        let mut store = AtlasStore::open_for_project(&database, &root)?;
9528        store.replace_scan(&[test_file_node("src/current.rs", "hash-current")])?;
9529        {
9530            let transaction = store.connection.transaction()?;
9531            {
9532                let mut statement = transaction.prepare(
9533                    "
9534                    INSERT INTO health_resolutions(
9535                        finding_id, category, path, related_path, rationale, resolved_by
9536                    )
9537                    VALUES(?1, 'missing-purpose', ?2, NULL, 'historical', 'agent')
9538                    ",
9539                )?;
9540                for index in 0..HISTORICAL_RESOLUTIONS {
9541                    let path = format!("removed/{index}.rs");
9542                    statement.execute(params![finding_id("missing-purpose", &path, None), path])?;
9543                }
9544            }
9545            transaction.commit()?;
9546        }
9547
9548        drop(store);
9549        let store = AtlasStore::open_read_only_for_project(&database, &root)?;
9550        let page = store.unresolved_health_findings_page_current(&HealthQuery {
9551            start_index: 0,
9552            limit: 2,
9553            category: Some("missing-purpose".to_string()),
9554            severity: Some(Severity::Warning),
9555            path_prefix: Some("src".to_string()),
9556            summary_only: false,
9557            scope: HealthScope::all(),
9558        })?;
9559        require_eq(&page.total, &1, "current unresolved total")?;
9560        require_eq(
9561            &page.findings[0].path,
9562            &"src/current.rs".to_string(),
9563            "current unresolved path",
9564        )?;
9565
9566        let (where_clause, values) = purpose_status_where_clause(
9567            PURPOSE_HEALTH_SPECS[0],
9568            None,
9569            HealthResolutionFilter::Stored,
9570            HealthScope::all(),
9571        );
9572        require_eq(&values.len(), &1, "stored filter bind count")?;
9573        let plan_sql = format!(
9574            "EXPLAIN QUERY PLAN
9575             SELECT COUNT(*)
9576             FROM nodes n
9577             JOIN purposes p ON p.node_id = n.id
9578             WHERE {where_clause}"
9579        );
9580        let mut statement = store.connection.prepare(&plan_sql)?;
9581        let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(3))?;
9582        let mut plan = Vec::new();
9583        for row in rows {
9584            plan.push(row?);
9585        }
9586        if !plan.iter().any(|detail| {
9587            detail.contains("health_resolutions")
9588                && detail.contains("finding_id")
9589                && detail.contains("SEARCH")
9590        }) {
9591            return Err(io::Error::other(format!(
9592                "stored resolution anti-lookup did not use the finding-id index: {plan:?}"
9593            ))
9594            .into());
9595        }
9596        store.finish_index_read_snapshot()?;
9597        Ok(())
9598    }
9599
9600    #[test]
9601    fn unresolved_health_findings_page_streams_duplicate_and_temp_rows()
9602    -> Result<(), Box<dyn Error>> {
9603        let mut store = AtlasStore::in_memory()?;
9604        store.replace_scan(&[
9605            test_folder_node("."),
9606            test_folder_node("tmp"),
9607            test_folder_node("src"),
9608            test_folder_node("src/tmp"),
9609            test_file_node("src/a.rs", "hash-a"),
9610            test_file_node("src/b.rs", "hash-b"),
9611        ])?;
9612        store.set_purpose(".", "Repository root", PurposeSource::Agent)?;
9613        store.set_purpose("src", "Source folder", PurposeSource::Agent)?;
9614        store.set_purpose("tmp", "Temporary output", PurposeSource::Agent)?;
9615        store.set_purpose("src/tmp", "Source temporary output", PurposeSource::Agent)?;
9616        store.set_purpose("src/a.rs", "Shared implementation", PurposeSource::Agent)?;
9617        store.set_purpose("src/b.rs", "Shared implementation", PurposeSource::Agent)?;
9618
9619        let duplicate_page = store.unresolved_health_findings_page(
9620            &[],
9621            &HealthQuery {
9622                start_index: 0,
9623                limit: 1,
9624                category: Some("duplicate-purpose".to_string()),
9625                severity: Some(Severity::Warning),
9626                path_prefix: Some("src".to_string()),
9627                summary_only: false,
9628                scope: HealthScope::all(),
9629            },
9630        )?;
9631        require_eq(&duplicate_page.total, &1, "duplicate total")?;
9632        require_eq(&duplicate_page.returned, &1, "duplicate returned")?;
9633        require_eq(
9634            &duplicate_page.findings[0].category,
9635            &"duplicate-purpose".to_string(),
9636            "duplicate category",
9637        )?;
9638
9639        let temp_page = store.unresolved_health_findings_page(
9640            &[],
9641            &HealthQuery {
9642                start_index: 0,
9643                limit: 1,
9644                category: Some("repeated-temporary-folder".to_string()),
9645                severity: Some(Severity::Warning),
9646                path_prefix: Some(".".to_string()),
9647                summary_only: false,
9648                scope: HealthScope::all(),
9649            },
9650        )?;
9651        require_eq(&temp_page.total, &1, "temp total")?;
9652        require_eq(&temp_page.returned, &1, "temp returned")?;
9653        require_eq(
9654            &temp_page.findings[0].category,
9655            &"repeated-temporary-folder".to_string(),
9656            "temp category",
9657        )?;
9658        Ok(())
9659    }
9660
9661    #[test]
9662    fn unresolved_health_findings_page_source_only_filters_asset_noise()
9663    -> Result<(), Box<dyn Error>> {
9664        let mut store = AtlasStore::in_memory()?;
9665        let asset_file = Node {
9666            path: "assets/logo.png".to_string(),
9667            kind: NodeKind::File,
9668            parent_path: Some("assets".to_string()),
9669            extension: Some(".png".to_string()),
9670            language: None,
9671            size_bytes: Some(42),
9672            mtime_ns: Some(10),
9673            content_hash: Some("hash-logo".to_string()),
9674        };
9675        store.replace_scan(&[
9676            test_folder_node("."),
9677            test_folder_node("src"),
9678            test_file_node("src/main.rs", "hash-main"),
9679            test_folder_node("assets"),
9680            asset_file,
9681        ])?;
9682
9683        let page = store.unresolved_health_findings_page(
9684            &[],
9685            &HealthQuery {
9686                start_index: 0,
9687                limit: 10,
9688                category: Some("missing-purpose".to_string()),
9689                severity: Some(Severity::Warning),
9690                path_prefix: Some(".".to_string()),
9691                summary_only: false,
9692                scope: HealthScope::source_only(),
9693            },
9694        )?;
9695
9696        require_eq(&page.unfiltered_total, &5, "all unresolved rows")?;
9697        require_eq(&page.total, &3, "source-only missing total")?;
9698        require_eq(
9699            &page
9700                .findings
9701                .iter()
9702                .map(|finding| finding.path.as_str())
9703                .collect::<Vec<_>>(),
9704            &vec![".", "src", "src/main.rs"],
9705            "source-only paths",
9706        )?;
9707        Ok(())
9708    }
9709
9710    #[test]
9711    fn high_impact_purpose_queue_filters_low_priority_files() -> Result<(), Box<dyn Error>> {
9712        let mut store = AtlasStore::in_memory()?;
9713        store.replace_scan(&[
9714            test_folder_node("."),
9715            test_folder_node("src"),
9716            test_file_node("src/main.rs", "hash-main"),
9717            test_file_node("src/helper.rs", "hash-helper"),
9718            test_file_node("build.gradle.kts", "hash-gradle"),
9719        ])?;
9720
9721        let page = store.unresolved_health_findings_page(
9722            &[],
9723            &HealthQuery {
9724                start_index: 0,
9725                limit: 10,
9726                category: Some("missing-purpose".to_string()),
9727                severity: Some(Severity::Warning),
9728                path_prefix: Some(".".to_string()),
9729                summary_only: false,
9730                scope: HealthScope::purpose_default(),
9731            },
9732        )?;
9733
9734        require_eq(&page.unfiltered_total, &5, "all missing rows")?;
9735        require_eq(&page.total, &4, "default actionable rows")?;
9736        require_eq(
9737            &page
9738                .findings
9739                .iter()
9740                .map(|finding| finding.path.as_str())
9741                .collect::<Vec<_>>(),
9742            &vec![".", "src", "build.gradle.kts", "src/main.rs"],
9743            "folder-first high-impact queue paths",
9744        )?;
9745
9746        let broad_page = store.unresolved_health_findings_page(
9747            &[],
9748            &HealthQuery {
9749                start_index: 0,
9750                limit: 10,
9751                category: Some("missing-purpose".to_string()),
9752                severity: Some(Severity::Warning),
9753                path_prefix: Some(".".to_string()),
9754                summary_only: false,
9755                scope: HealthScope::all(),
9756            },
9757        )?;
9758        require_eq(&broad_page.total, &5, "explicit broad queue rows")?;
9759        Ok(())
9760    }
9761
9762    #[test]
9763    fn high_impact_purpose_queue_keeps_asset_only_folders_without_asset_files()
9764    -> Result<(), Box<dyn Error>> {
9765        let mut store = AtlasStore::in_memory()?;
9766        let asset_file = Node {
9767            path: "assets/logo.svg".to_string(),
9768            kind: NodeKind::File,
9769            parent_path: Some("assets".to_string()),
9770            extension: Some(".svg".to_string()),
9771            language: None,
9772            size_bytes: Some(42),
9773            mtime_ns: Some(10),
9774            content_hash: Some("hash-logo".to_string()),
9775        };
9776        store.replace_scan(&[
9777            test_folder_node("."),
9778            test_folder_node("assets"),
9779            test_folder_node("src"),
9780            asset_file,
9781            test_file_node("src/helper.rs", "hash-helper"),
9782        ])?;
9783
9784        let page = store.unresolved_health_findings_page(
9785            &[],
9786            &HealthQuery {
9787                start_index: 0,
9788                limit: 10,
9789                category: Some("missing-purpose".to_string()),
9790                severity: Some(Severity::Warning),
9791                path_prefix: Some(".".to_string()),
9792                summary_only: false,
9793                scope: HealthScope::purpose_default(),
9794            },
9795        )?;
9796
9797        require_eq(&page.unfiltered_total, &5, "all missing rows")?;
9798        require_eq(&page.total, &3, "all folders without asset files")?;
9799        require_eq(
9800            &page
9801                .findings
9802                .iter()
9803                .map(|finding| finding.path.as_str())
9804                .collect::<Vec<_>>(),
9805            &vec![".", "assets", "src"],
9806            "folder-first default queue keeps asset-only folders",
9807        )?;
9808        Ok(())
9809    }
9810
9811    #[test]
9812    fn high_impact_purpose_queue_pages_folders_before_files() -> Result<(), Box<dyn Error>> {
9813        let mut store = AtlasStore::in_memory()?;
9814        store.replace_scan(&[
9815            test_folder_node("."),
9816            test_folder_node("src"),
9817            test_file_node("Cargo.toml", "hash-cargo"),
9818            test_file_node("package.json", "hash-package"),
9819            test_file_node("pyproject.toml", "hash-python"),
9820        ])?;
9821
9822        let page = store.unresolved_health_findings_page(
9823            &[],
9824            &HealthQuery {
9825                start_index: 0,
9826                limit: 2,
9827                category: Some("missing-purpose".to_string()),
9828                severity: Some(Severity::Warning),
9829                path_prefix: Some(".".to_string()),
9830                summary_only: false,
9831                scope: HealthScope::purpose_default(),
9832            },
9833        )?;
9834
9835        require_eq(&page.total, &5, "default actionable total")?;
9836        require_eq(
9837            &page
9838                .findings
9839                .iter()
9840                .map(|finding| finding.path.as_str())
9841                .collect::<Vec<_>>(),
9842            &vec![".", "src"],
9843            "small page keeps folders first",
9844        )?;
9845        Ok(())
9846    }
9847
9848    #[test]
9849    fn high_impact_purpose_queue_omits_low_value_stale_files() -> Result<(), Box<dyn Error>> {
9850        let mut store = AtlasStore::in_memory()?;
9851        store.replace_scan(&[
9852            test_file_node("src/helper.rs", "hash-a"),
9853            test_file_node("Cargo.toml", "hash-cargo"),
9854            test_file_node("package.json", "hash-package"),
9855        ])?;
9856        store.set_purpose(
9857            "src/helper.rs",
9858            "Reviewed helper implementation.",
9859            PurposeSource::Agent,
9860        )?;
9861        store.set_purpose(
9862            "Cargo.toml",
9863            "Reviewed Cargo manifest.",
9864            PurposeSource::Agent,
9865        )?;
9866        store.connection.execute(
9867            "UPDATE purposes
9868                SET status = 'stale'
9869              WHERE node_id IN (
9870                  SELECT id FROM nodes WHERE path IN ('src/helper.rs', 'Cargo.toml')
9871              )",
9872            [],
9873        )?;
9874        store.replace_scan(&[
9875            test_file_node("src/helper.rs", "hash-b"),
9876            test_file_node("Cargo.toml", "hash-cargo-new"),
9877            test_file_node("package.json", "hash-package"),
9878        ])?;
9879
9880        let page = store.unresolved_health_findings_page(
9881            &[],
9882            &HealthQuery {
9883                start_index: 0,
9884                limit: 1,
9885                category: None,
9886                severity: Some(Severity::Warning),
9887                path_prefix: Some(".".to_string()),
9888                summary_only: false,
9889                scope: HealthScope::purpose_default(),
9890            },
9891        )?;
9892
9893        require_eq(&page.total, &2, "default actionable total")?;
9894        require_eq(&page.returned, &1, "small page returned")?;
9895        require_eq(
9896            &page.findings[0].category,
9897            &"stale-purpose".to_string(),
9898            "stale high-impact file is still prioritized",
9899        )?;
9900        require_eq(
9901            &page.findings[0].path,
9902            &"Cargo.toml".to_string(),
9903            "stale high-impact file path",
9904        )?;
9905
9906        let broad_page = store.unresolved_health_findings_page(
9907            &[],
9908            &HealthQuery {
9909                start_index: 0,
9910                limit: 10,
9911                category: Some(CATEGORY_STALE_PURPOSE.to_string()),
9912                severity: Some(Severity::Warning),
9913                path_prefix: Some(".".to_string()),
9914                summary_only: false,
9915                scope: HealthScope::purpose_with_source_files(),
9916            },
9917        )?;
9918        require_eq(&broad_page.total, &2, "broad source stale rows")?;
9919        require_eq(
9920            &health_paths(&broad_page),
9921            &vec!["Cargo.toml", "src/helper.rs"],
9922            "broad scope includes low-value stale source files",
9923        )?;
9924        Ok(())
9925    }
9926
9927    #[test]
9928    fn include_assets_queue_includes_asset_files_not_low_priority_source()
9929    -> Result<(), Box<dyn Error>> {
9930        let mut store = AtlasStore::in_memory()?;
9931        let asset_file = Node {
9932            path: "assets/logo.svg".to_string(),
9933            kind: NodeKind::File,
9934            parent_path: Some("assets".to_string()),
9935            extension: Some(".svg".to_string()),
9936            language: None,
9937            size_bytes: Some(42),
9938            mtime_ns: Some(10),
9939            content_hash: Some("hash-logo".to_string()),
9940        };
9941        store.replace_scan(&[
9942            test_folder_node("."),
9943            test_folder_node("assets"),
9944            test_folder_node("src"),
9945            asset_file,
9946            test_file_node("src/helper.rs", "hash-helper"),
9947        ])?;
9948
9949        let page = store.unresolved_health_findings_page(
9950            &[],
9951            &HealthQuery {
9952                start_index: 0,
9953                limit: 10,
9954                category: Some("missing-purpose".to_string()),
9955                severity: Some(Severity::Warning),
9956                path_prefix: Some(".".to_string()),
9957                summary_only: false,
9958                scope: HealthScope::purpose_with_assets(),
9959            },
9960        )?;
9961
9962        require_eq(&page.unfiltered_total, &5, "all missing rows")?;
9963        require_eq(
9964            &page.total,
9965            &4,
9966            "assets included without broad source cleanup",
9967        )?;
9968        require_eq(
9969            &page
9970                .findings
9971                .iter()
9972                .map(|finding| finding.path.as_str())
9973                .collect::<Vec<_>>(),
9974            &vec![".", "assets", "src", "assets/logo.svg"],
9975            "asset files included and low-priority source omitted",
9976        )?;
9977        Ok(())
9978    }
9979
9980    #[test]
9981    fn legacy_human_stale_files_remain_in_default_queue() -> Result<(), Box<dyn Error>> {
9982        let mut store = AtlasStore::in_memory()?;
9983        store.replace_scan(&[test_file_node("src/helper.rs", "hash-a")])?;
9984        store.set_purpose(
9985            "src/helper.rs",
9986            "Legacy reviewed helper implementation.",
9987            PurposeSource::Agent,
9988        )?;
9989        store.connection.execute(
9990            "
9991            UPDATE purposes
9992            SET source = 'human', status = 'stale'
9993            WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/helper.rs')
9994            ",
9995            [],
9996        )?;
9997        store.replace_scan(&[test_file_node("src/helper.rs", "hash-b")])?;
9998
9999        let page = store.unresolved_health_findings_page(
10000            &[],
10001            &HealthQuery {
10002                start_index: 0,
10003                limit: 10,
10004                category: Some("stale-purpose".to_string()),
10005                severity: Some(Severity::Warning),
10006                path_prefix: Some(".".to_string()),
10007                summary_only: false,
10008                scope: HealthScope::purpose_default(),
10009            },
10010        )?;
10011
10012        require_eq(&page.total, &1, "legacy reviewed stale row total")?;
10013        require_eq(
10014            &page.findings[0].path,
10015            &"src/helper.rs".to_string(),
10016            "legacy reviewed stale file",
10017        )?;
10018        Ok(())
10019    }
10020
10021    #[test]
10022    fn stale_imported_files_remain_in_default_queue() -> Result<(), Box<dyn Error>> {
10023        let mut store = AtlasStore::in_memory()?;
10024        store.replace_scan(&[
10025            test_file_node("src/imported.rs", "hash-a"),
10026            test_file_node("Cargo.toml", "hash-cargo"),
10027        ])?;
10028        store.set_purpose(
10029            "src/imported.rs",
10030            "Imported helper implementation.",
10031            PurposeSource::Imported,
10032        )?;
10033        store.connection.execute(
10034            "UPDATE purposes
10035                SET status = 'stale'
10036              WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/imported.rs')",
10037            [],
10038        )?;
10039        store.replace_scan(&[
10040            test_file_node("src/imported.rs", "hash-b"),
10041            test_file_node("Cargo.toml", "hash-cargo"),
10042        ])?;
10043
10044        let page = store.unresolved_health_findings_page(
10045            &[],
10046            &HealthQuery {
10047                start_index: 0,
10048                limit: 10,
10049                category: None,
10050                severity: Some(Severity::Warning),
10051                path_prefix: Some(".".to_string()),
10052                summary_only: false,
10053                scope: HealthScope::purpose_default(),
10054            },
10055        )?;
10056
10057        require_eq(
10058            &page.total,
10059            &2,
10060            "default queue includes stale imported file",
10061        )?;
10062        require_eq(
10063            &health_paths(&page),
10064            &vec!["src/imported.rs", "Cargo.toml"],
10065            "stale imported file is queued before high-impact files",
10066        )?;
10067        require_eq(
10068            &page.findings[0].category,
10069            &"stale-purpose".to_string(),
10070            "stale imported finding category",
10071        )?;
10072        Ok(())
10073    }
10074
10075    #[test]
10076    fn duplicate_purpose_health_is_contextual_for_folders() -> Result<(), Box<dyn Error>> {
10077        let mut store = AtlasStore::in_memory()?;
10078        store.replace_scan(&[
10079            test_folder_node("."),
10080            test_folder_node("customers"),
10081            test_folder_node("customers/service"),
10082            test_folder_node("settings"),
10083            test_folder_node("settings/service"),
10084        ])?;
10085        store.set_purpose("customers/service", "Service layer", PurposeSource::Agent)?;
10086        store.set_purpose("settings/service", "Service layer", PurposeSource::Agent)?;
10087
10088        let page = store.unresolved_health_findings_page(
10089            &[],
10090            &HealthQuery {
10091                start_index: 0,
10092                limit: 10,
10093                category: Some("duplicate-purpose".to_string()),
10094                severity: Some(Severity::Warning),
10095                path_prefix: Some(".".to_string()),
10096                summary_only: false,
10097                scope: HealthScope::all(),
10098            },
10099        )?;
10100
10101        require_eq(&page.total, &0, "folder duplicates scoped by parent")?;
10102        Ok(())
10103    }
10104
10105    #[test]
10106    fn contextual_folder_duplicate_identity_matches_unpaged_health_and_resolution()
10107    -> Result<(), Box<dyn Error>> {
10108        let mut store = AtlasStore::in_memory()?;
10109        store.replace_scan(&[
10110            test_folder_node("."),
10111            test_folder_node("customers"),
10112            test_folder_node("customers/service"),
10113            test_folder_node("settings"),
10114            test_folder_node("settings/service"),
10115            test_folder_node("settings/worker"),
10116        ])?;
10117        store.set_purpose("customers/service", "Service layer", PurposeSource::Agent)?;
10118        store.set_purpose("settings/service", "Service layer", PurposeSource::Agent)?;
10119        store.set_purpose("settings/worker", "Service layer", PurposeSource::Agent)?;
10120
10121        let page = store.unresolved_health_findings_page(
10122            &[],
10123            &HealthQuery {
10124                start_index: 0,
10125                limit: 10,
10126                category: Some("duplicate-purpose".to_string()),
10127                severity: Some(Severity::Warning),
10128                path_prefix: Some(".".to_string()),
10129                summary_only: false,
10130                scope: HealthScope::all(),
10131            },
10132        )?;
10133        require_eq(&page.total, &1, "contextual duplicate total")?;
10134        let paged_finding = page
10135            .findings
10136            .first()
10137            .ok_or_else(|| io::Error::other("paged duplicate missing"))?;
10138        require_eq(
10139            &paged_finding.path,
10140            &"settings/worker".to_string(),
10141            "paged duplicate path",
10142        )?;
10143        require_eq(
10144            &paged_finding.related_path,
10145            &Some("settings/service".to_string()),
10146            "paged related path",
10147        )?;
10148
10149        let unpaged_duplicates = store
10150            .unresolved_health_findings(&[])?
10151            .into_iter()
10152            .filter(|finding| finding.category == "duplicate-purpose")
10153            .collect::<Vec<_>>();
10154        require_eq(
10155            &unpaged_duplicates,
10156            &page.findings,
10157            "unpaged duplicate identity",
10158        )?;
10159
10160        store.resolve_health_finding(&HealthResolution {
10161            finding_id: paged_finding.id.clone(),
10162            category: paged_finding.category.clone(),
10163            path: paged_finding.path.clone(),
10164            related_path: paged_finding.related_path.clone(),
10165            rationale: "Settings service and worker intentionally share a layer purpose."
10166                .to_string(),
10167        })?;
10168        let has_remaining_duplicate = store
10169            .unresolved_health_findings(&store.resolved_health_ids()?)?
10170            .into_iter()
10171            .any(|finding| finding.category == "duplicate-purpose");
10172        require_eq(
10173            &has_remaining_duplicate,
10174            &false,
10175            "resolved contextual duplicate",
10176        )?;
10177        Ok(())
10178    }
10179
10180    #[test]
10181    fn agent_review_required_scope_expands_from_low_to_strict() -> Result<(), Box<dyn Error>> {
10182        let mut store = AtlasStore::in_memory()?;
10183        let asset_file = Node {
10184            path: "assets/logo.svg".to_string(),
10185            kind: NodeKind::File,
10186            parent_path: Some("assets".to_string()),
10187            extension: Some(".svg".to_string()),
10188            language: None,
10189            size_bytes: Some(42),
10190            mtime_ns: Some(10),
10191            content_hash: Some("hash-logo".to_string()),
10192        };
10193        store.replace_scan(&[
10194            test_folder_node("."),
10195            test_folder_node("assets"),
10196            test_folder_node("src"),
10197            test_file_node("Cargo.toml", "hash-cargo"),
10198            test_file_node("src/detail.rs", "hash-detail"),
10199            asset_file,
10200        ])?;
10201        for (path, purpose) in [
10202            (".", "Imported repository root"),
10203            ("assets", "Imported asset folder"),
10204            ("src", "Imported Rust source folder"),
10205            ("Cargo.toml", "Imported Rust manifest"),
10206            ("src/detail.rs", "Imported implementation detail"),
10207            ("assets/logo.svg", "Imported SVG brand asset"),
10208        ] {
10209            store.set_purpose(path, purpose, PurposeSource::Imported)?;
10210        }
10211
10212        let low = store.unresolved_health_findings_page(
10213            &[],
10214            &HealthQuery {
10215                start_index: 0,
10216                limit: 20,
10217                category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
10218                severity: Some(Severity::Warning),
10219                path_prefix: Some(".".to_string()),
10220                summary_only: false,
10221                scope: HealthScope::purpose_default(),
10222            },
10223        )?;
10224        require_eq(
10225            &health_paths(&low),
10226            &vec![".", "assets", "src", "Cargo.toml"],
10227            "low purpose review scope",
10228        )?;
10229        require_eq(
10230            &low.unfiltered_total,
10231            &6,
10232            "agent-review findings are counted once in unfiltered total",
10233        )?;
10234
10235        let asset_scope = store.unresolved_health_findings_page(
10236            &[],
10237            &HealthQuery {
10238                scope: HealthScope::purpose_with_assets(),
10239                ..low_query()
10240            },
10241        )?;
10242        require_eq(
10243            &health_paths(&asset_scope),
10244            &vec![".", "assets", "src", "Cargo.toml", "assets/logo.svg"],
10245            "asset purpose review scope",
10246        )?;
10247
10248        let medium = store.unresolved_health_findings_page(
10249            &[],
10250            &HealthQuery {
10251                scope: HealthScope::purpose_with_source_files(),
10252                ..low_query()
10253            },
10254        )?;
10255        require_eq(
10256            &health_paths(&medium),
10257            &vec![".", "assets", "src", "Cargo.toml", "src/detail.rs"],
10258            "medium purpose review scope",
10259        )?;
10260
10261        let strict = store.unresolved_health_findings_page(
10262            &[],
10263            &HealthQuery {
10264                scope: HealthScope::purpose_strict(),
10265                ..low_query()
10266            },
10267        )?;
10268        require_eq(
10269            &health_paths(&strict),
10270            &vec![
10271                ".",
10272                "assets",
10273                "src",
10274                "Cargo.toml",
10275                "assets/logo.svg",
10276                "src/detail.rs",
10277            ],
10278            "strict purpose review scope",
10279        )?;
10280
10281        let all = store.unresolved_health_findings_page(
10282            &[],
10283            &HealthQuery {
10284                scope: HealthScope::all(),
10285                ..low_query()
10286            },
10287        )?;
10288        require_eq(
10289            &health_paths(&all),
10290            &health_paths(&strict),
10291            "all health scope should include every purpose review candidate",
10292        )?;
10293        Ok(())
10294    }
10295
10296    #[test]
10297    fn replace_scan_preserves_curated_purposes_and_reconciles_changed_paths()
10298    -> Result<(), Box<dyn Error>> {
10299        let mut store = AtlasStore::in_memory()?;
10300        store.replace_scan(&[
10301            test_folder_node("."),
10302            test_folder_node("src"),
10303            test_file_node("src/main.rs", "hash-a"),
10304        ])?;
10305        store.set_purpose(".", "Agent-reviewed repository root", PurposeSource::Agent)?;
10306        store.set_purpose(
10307            "src",
10308            "Agent-reviewed Rust source folder",
10309            PurposeSource::Agent,
10310        )?;
10311        store.set_purpose(
10312            "src/main.rs",
10313            "Agent-reviewed Rust entry point",
10314            PurposeSource::Agent,
10315        )?;
10316
10317        store.replace_scan(&[
10318            test_folder_node("."),
10319            test_folder_node("src"),
10320            test_file_node("src/main.rs", "hash-a"),
10321            test_file_node("src/new.rs", "hash-new"),
10322        ])?;
10323        let nodes = store.load_nodes_by_paths(&[
10324            ".".to_string(),
10325            "src".to_string(),
10326            "src/main.rs".to_string(),
10327            "src/new.rs".to_string(),
10328        ])?;
10329        let by_path = nodes
10330            .iter()
10331            .map(|node| (node.node.path.as_str(), node))
10332            .collect::<HashMap<_, _>>();
10333        require_eq(
10334            &by_path["src/main.rs"].purpose.purpose,
10335            &Some("Agent-reviewed Rust entry point".to_string()),
10336            "unchanged file purpose preserved",
10337        )?;
10338        require_eq(
10339            &by_path["src/main.rs"].purpose.status,
10340            &PurposeStatus::Approved,
10341            "unchanged file purpose stays approved",
10342        )?;
10343        require_eq(
10344            &by_path["src/new.rs"].purpose.status,
10345            &PurposeStatus::Missing,
10346            "new file starts missing",
10347        )?;
10348
10349        store.replace_scan(&[
10350            test_folder_node("."),
10351            test_folder_node("src"),
10352            test_file_node("src/main.rs", "hash-b"),
10353            test_file_node("src/new.rs", "hash-new"),
10354        ])?;
10355        let changed = store
10356            .load_nodes_by_paths(&["src/main.rs".to_string()])?
10357            .pop()
10358            .ok_or_else(|| io::Error::other("changed node missing"))?;
10359        require_eq(
10360            &changed.purpose.purpose,
10361            &Some("Agent-reviewed Rust entry point".to_string()),
10362            "changed file purpose text preserved",
10363        )?;
10364        require_eq(
10365            &changed.purpose.status,
10366            &PurposeStatus::Approved,
10367            "changed file purpose stays approved",
10368        )?;
10369
10370        store.replace_scan(&[test_folder_node("."), test_folder_node("src")])?;
10371        let removed = store.load_nodes_by_paths(&["src/main.rs".to_string()])?;
10372        require_eq(&removed.is_empty(), &true, "removed file is inactive")?;
10373
10374        let dormant = store.connection.query_row(
10375            "SELECT n.exists_now, p.purpose, p.status
10376               FROM nodes AS n
10377               JOIN purposes AS p ON p.node_id = n.id
10378              WHERE n.path = 'src/main.rs'",
10379            [],
10380            |row| {
10381                Ok((
10382                    row.get::<_, i64>(0)?,
10383                    row.get::<_, Option<String>>(1)?,
10384                    row.get::<_, String>(2)?,
10385                ))
10386            },
10387        )?;
10388        require_eq(
10389            &dormant,
10390            &(
10391                0,
10392                Some("Agent-reviewed Rust entry point".to_string()),
10393                PurposeStatus::Approved.as_str().to_string(),
10394            ),
10395            "removed file keeps a dormant approved purpose",
10396        )?;
10397
10398        store.replace_scan(&[
10399            test_folder_node("."),
10400            test_folder_node("src"),
10401            test_file_node("src/renamed.rs", "hash-b"),
10402        ])?;
10403        let renamed = store
10404            .load_node_by_path("src/renamed.rs")?
10405            .ok_or_else(|| io::Error::other("renamed path missing"))?;
10406        require_eq(
10407            &renamed.purpose.status,
10408            &PurposeStatus::Missing,
10409            "rename does not transfer approval",
10410        )?;
10411
10412        store.replace_scan(&[
10413            test_folder_node("."),
10414            test_folder_node("src"),
10415            test_file_node("src/main.rs", "hash-c"),
10416        ])?;
10417        let reactivated = store
10418            .load_node_by_path("src/main.rs")?
10419            .ok_or_else(|| io::Error::other("reactivated path missing"))?;
10420        require_eq(
10421            &reactivated.purpose.purpose,
10422            &Some("Agent-reviewed Rust entry point".to_string()),
10423            "exact-path reactivation restores the dormant purpose",
10424        )?;
10425        require_eq(
10426            &reactivated.purpose.status,
10427            &PurposeStatus::Approved,
10428            "exact-path reactivation restores approval",
10429        )?;
10430        Ok(())
10431    }
10432
10433    #[test]
10434    fn file_token_estimates_are_visited_without_loading_nodes() -> Result<(), Box<dyn Error>> {
10435        let mut store = AtlasStore::in_memory()?;
10436        store.replace_scan(&[
10437            test_file_node("src/a.rs", "hash-a"),
10438            test_file_node("tests/b.rs", "hash-b"),
10439        ])?;
10440
10441        let mut visited = Vec::new();
10442        store.visit_file_token_estimates(Some("src"), |path, size_bytes| {
10443            visited.push((path, size_bytes));
10444            Ok(true)
10445        })?;
10446        require_eq(
10447            &visited,
10448            &vec![("src/a.rs".to_string(), Some(12))],
10449            "folder-scoped token estimate rows",
10450        )?;
10451        Ok(())
10452    }
10453
10454    #[test]
10455    fn indexed_file_text_replaces_and_clears_stale_rows() -> Result<(), Box<dyn Error>> {
10456        let mut store = AtlasStore::in_memory()?;
10457        store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
10458        store.replace_file_texts_for_paths(
10459            &["src/main.rs".to_string()],
10460            &[IndexedFileText {
10461                path: "src/main.rs".to_string(),
10462                content_hash: Some("hash-a".to_string()),
10463                byte_count: "needle old\n".len(),
10464                line_count: 1,
10465                content: "needle old\n".to_string(),
10466            }],
10467        )?;
10468        let texts = store.load_file_texts_for_search(Some("needle"), true)?;
10469        require_eq(&texts.len(), &1, "indexed text row count")?;
10470
10471        store.replace_file_texts_for_paths(&["src/main.rs".to_string()], &[])?;
10472        let missing = store.load_file_text("src/main.rs")?;
10473        require_eq(&missing.is_none(), &true, "cleared stale indexed text")?;
10474        Ok(())
10475    }
10476
10477    #[test]
10478    fn indexed_file_text_search_can_stop_without_collecting_all_rows() -> Result<(), Box<dyn Error>>
10479    {
10480        let mut store = AtlasStore::in_memory()?;
10481        store.replace_scan(&[
10482            test_file_node("src/a.rs", "hash-a"),
10483            test_file_node("src/b.rs", "hash-b"),
10484        ])?;
10485        store.replace_file_texts_for_paths(
10486            &["src/a.rs".to_string(), "src/b.rs".to_string()],
10487            &[
10488                IndexedFileText {
10489                    path: "src/a.rs".to_string(),
10490                    content_hash: Some("hash-a".to_string()),
10491                    byte_count: "needle first\n".len(),
10492                    line_count: 1,
10493                    content: "needle first\n".to_string(),
10494                },
10495                IndexedFileText {
10496                    path: "src/b.rs".to_string(),
10497                    content_hash: Some("hash-b".to_string()),
10498                    byte_count: "needle second\n".len(),
10499                    line_count: 1,
10500                    content: "needle second\n".to_string(),
10501                },
10502            ],
10503        )?;
10504
10505        let mut visited = Vec::new();
10506        store.visit_file_texts_for_search(Some("needle"), true, |text| {
10507            visited.push(text.path);
10508            Ok(false)
10509        })?;
10510        require_eq(&visited, &vec!["src/a.rs".to_string()], "early stop rows")?;
10511        Ok(())
10512    }
10513
10514    #[test]
10515    fn file_text_fts_candidates_are_bounded_scoped_safe_and_metadata_only()
10516    -> Result<(), Box<dyn Error>> {
10517        let mut store = AtlasStore::in_memory()?;
10518        store.replace_scan(&[
10519            test_file_node("src/a.rs", "hash-a"),
10520            test_file_node("src/b.rs", "hash-b"),
10521            test_file_node("tests/c.rs", "hash-c"),
10522        ])?;
10523        store.replace_file_texts_for_paths(
10524            &[
10525                "src/a.rs".to_string(),
10526                "src/b.rs".to_string(),
10527                "tests/c.rs".to_string(),
10528            ],
10529            &[
10530                IndexedFileText {
10531                    path: "src/a.rs".to_string(),
10532                    content_hash: Some("hash-a".to_string()),
10533                    byte_count: 13,
10534                    line_count: 1,
10535                    content: "needle alpha\n".to_string(),
10536                },
10537                IndexedFileText {
10538                    path: "src/b.rs".to_string(),
10539                    content_hash: Some("hash-b".to_string()),
10540                    byte_count: 19,
10541                    line_count: 1,
10542                    content: "prefixneedlesuffix\n".to_string(),
10543                },
10544                IndexedFileText {
10545                    path: "tests/c.rs".to_string(),
10546                    content_hash: Some("hash-c".to_string()),
10547                    byte_count: 32,
10548                    line_count: 1,
10549                    content: "needle gamma AND NOT NEAR terms\n".to_string(),
10550                },
10551            ],
10552        )?;
10553
10554        let bounded = store.query_file_text_fts_candidates(
10555            &FileTextFtsQuery {
10556                literal_token: "needle",
10557                path_prefix: None,
10558                limit: 1,
10559            },
10560            None,
10561        )?;
10562        require_eq(&bounded.candidates.len(), &1, "bounded FTS row count")?;
10563        require_eq(&bounded.overflow, &true, "bounded FTS overflow")?;
10564        require(
10565            bounded.candidates[0].bm25.is_finite(),
10566            "FTS rank was non-finite",
10567        )?;
10568        let zero_limit = store.query_file_text_fts_candidates(
10569            &FileTextFtsQuery {
10570                literal_token: "needle",
10571                path_prefix: None,
10572                limit: 0,
10573            },
10574            None,
10575        )?;
10576        require_eq(
10577            &zero_limit,
10578            &FileTextFtsPage {
10579                candidates: Vec::new(),
10580                overflow: true,
10581            },
10582            "zero-limit FTS overflow",
10583        )?;
10584
10585        let scoped = store.query_file_text_fts_candidates(
10586            &FileTextFtsQuery {
10587                literal_token: "needle",
10588                path_prefix: Some("src/"),
10589                limit: 10,
10590            },
10591            None,
10592        )?;
10593        require_eq(
10594            &scoped
10595                .candidates
10596                .iter()
10597                .map(|candidate| candidate.path.as_str())
10598                .collect::<Vec<_>>(),
10599            &vec!["src/a.rs", "src/b.rs"],
10600            "path-scoped FTS rank order",
10601        )?;
10602        let exact_path = store.query_file_text_fts_candidates(
10603            &FileTextFtsQuery {
10604                literal_token: "needle",
10605                path_prefix: Some("src/a.rs"),
10606                limit: 10,
10607            },
10608            None,
10609        )?;
10610        require_eq(
10611            &exact_path.candidates[0].path,
10612            &"src/a.rs".to_string(),
10613            "exact-path FTS scope",
10614        )?;
10615        for reserved_token in ["AND", "NOT", "NEAR"] {
10616            let reserved = store.query_file_text_fts_candidates(
10617                &FileTextFtsQuery {
10618                    literal_token: reserved_token,
10619                    path_prefix: Some("tests/c.rs"),
10620                    limit: 10,
10621                },
10622                None,
10623            )?;
10624            require_eq(
10625                &reserved.candidates.len(),
10626                &1,
10627                "quoted FTS reserved-token candidate",
10628            )?;
10629        }
10630        let mut plan_statement = store.connection.prepare(
10631            "EXPLAIN QUERY PLAN
10632             SELECT f.path, f.content_hash, f.byte_count, f.line_count, bm25(file_text_fts)
10633             FROM file_text_fts
10634             JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
10635             WHERE file_text_fts MATCH ?1
10636             ORDER BY bm25(file_text_fts), f.path
10637             LIMIT ?2",
10638        )?;
10639        let plan_details = plan_statement
10640            .query_map(params!["needle", 11], |row| row.get::<_, String>(3))?
10641            .collect::<Result<Vec<_>, _>>()?;
10642        require(
10643            plan_details
10644                .iter()
10645                .any(|detail| detail.contains("VIRTUAL TABLE INDEX")),
10646            "FTS candidate query did not use the virtual-table index",
10647        )?;
10648        require(
10649            plan_details
10650                .iter()
10651                .any(|detail| detail.contains("INTEGER PRIMARY KEY")),
10652            "FTS candidate query did not hydrate metadata by file_texts rowid",
10653        )?;
10654
10655        for unsafe_token in ["ab", "foo_bar", "needle\" OR content:*", "néedle"] {
10656            let result = store.query_file_text_fts_candidates(
10657                &FileTextFtsQuery {
10658                    literal_token: unsafe_token,
10659                    path_prefix: None,
10660                    limit: 10,
10661                },
10662                None,
10663            );
10664            require(
10665                matches!(result, Err(DbError::FileTextFtsTokenUnsafe { .. })),
10666                "unsafe FTS token was accepted",
10667            )?;
10668        }
10669        let over_cap = store.query_file_text_fts_candidates(
10670            &FileTextFtsQuery {
10671                literal_token: "needle",
10672                path_prefix: None,
10673                limit: MAX_FILE_TEXT_FTS_CANDIDATES + 1,
10674            },
10675            None,
10676        );
10677        require(
10678            matches!(over_cap, Err(DbError::FileTextFtsCandidateLimit { .. })),
10679            "over-cap FTS request was accepted",
10680        )?;
10681
10682        store.connection.execute(
10683            "UPDATE file_texts SET content = x'80' WHERE path = 'src/a.rs'",
10684            [],
10685        )?;
10686        let metadata_only = store.query_file_text_fts_candidates(
10687            &FileTextFtsQuery {
10688                literal_token: "needle",
10689                path_prefix: Some("src/a.rs"),
10690                limit: 1,
10691            },
10692            None,
10693        )?;
10694        require_eq(
10695            &metadata_only.candidates[0].byte_count,
10696            &13,
10697            "metadata-only candidate byte count",
10698        )?;
10699        require(
10700            store.load_file_text("src/a.rs").is_err(),
10701            "content corruption fixture did not prove candidate pre-hydration",
10702        )?;
10703        Ok(())
10704    }
10705
10706    #[test]
10707    fn file_text_fts_mutations_are_atomic_and_state_reports_identity_drift()
10708    -> Result<(), Box<dyn Error>> {
10709        let mut store = AtlasStore::in_memory()?;
10710        store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
10711        let invalid_metadata = IndexedFileText {
10712            path: "src/main.rs".to_string(),
10713            content_hash: Some("hash-invalid".to_string()),
10714            byte_count: 0,
10715            line_count: 1,
10716            content: "not empty\n".to_string(),
10717        };
10718        require(
10719            matches!(
10720                store.replace_file_texts_for_paths(
10721                    std::slice::from_ref(&invalid_metadata.path),
10722                    std::slice::from_ref(&invalid_metadata),
10723                ),
10724                Err(DbError::FileTextMetadataMismatch {
10725                    field: "byte_count",
10726                    ..
10727                })
10728            ),
10729            "invalid file-text byte metadata was accepted",
10730        )?;
10731        require_eq(
10732            &store.load_file_text(&invalid_metadata.path)?,
10733            &None,
10734            "invalid metadata write rollback",
10735        )?;
10736        let invalid_line_count = IndexedFileText {
10737            byte_count: invalid_metadata.content.len(),
10738            line_count: 0,
10739            ..invalid_metadata.clone()
10740        };
10741        require(
10742            matches!(
10743                store.replace_file_texts_for_paths(
10744                    std::slice::from_ref(&invalid_line_count.path),
10745                    std::slice::from_ref(&invalid_line_count),
10746                ),
10747                Err(DbError::FileTextMetadataMismatch {
10748                    field: "line_count",
10749                    ..
10750                })
10751            ),
10752            "invalid file-text line metadata was accepted",
10753        )?;
10754        let original = IndexedFileText {
10755            path: "src/main.rs".to_string(),
10756            content_hash: Some("hash-a".to_string()),
10757            byte_count: "needle old\n".len(),
10758            line_count: 1,
10759            content: "needle old\n".to_string(),
10760        };
10761        store.replace_file_texts_for_paths(
10762            std::slice::from_ref(&original.path),
10763            std::slice::from_ref(&original),
10764        )?;
10765        require_eq(
10766            &store.file_text_fts_state()?,
10767            &FileTextFtsState {
10768                source_rows: 1,
10769                indexed_rows: 1,
10770                synchronized: true,
10771            },
10772            "initial FTS synchronization state",
10773        )?;
10774
10775        store.connection.execute_batch(
10776            "CREATE TRIGGER fail_file_text_insert
10777             BEFORE INSERT ON file_texts
10778             BEGIN
10779                 SELECT RAISE(ABORT, 'injected file-text failure');
10780             END;",
10781        )?;
10782        let replacement = IndexedFileText {
10783            path: original.path.clone(),
10784            content_hash: Some("hash-b".to_string()),
10785            byte_count: 11,
10786            line_count: 1,
10787            content: "beacon new\n".to_string(),
10788        };
10789        require(
10790            store
10791                .replace_file_texts_for_paths(
10792                    std::slice::from_ref(&replacement.path),
10793                    std::slice::from_ref(&replacement),
10794                )
10795                .is_err(),
10796            "fault-injected replacement unexpectedly committed",
10797        )?;
10798        store
10799            .connection
10800            .execute_batch("DROP TRIGGER fail_file_text_insert")?;
10801        require_eq(
10802            &store.load_file_text(&original.path)?,
10803            &Some(original.clone()),
10804            "rolled-back authoritative text",
10805        )?;
10806        require_eq(
10807            &store
10808                .query_file_text_fts_candidates(
10809                    &FileTextFtsQuery {
10810                        literal_token: "needle",
10811                        path_prefix: None,
10812                        limit: 10,
10813                    },
10814                    None,
10815                )?
10816                .candidates
10817                .len(),
10818            &1,
10819            "rolled-back FTS document",
10820        )?;
10821        require_eq(
10822            &store.file_text_fts_state()?.synchronized,
10823            &true,
10824            "rollback FTS synchronization",
10825        )?;
10826
10827        store.replace_file_texts_for_paths(
10828            std::slice::from_ref(&replacement.path),
10829            std::slice::from_ref(&replacement),
10830        )?;
10831        require_eq(
10832            &store
10833                .query_file_text_fts_candidates(
10834                    &FileTextFtsQuery {
10835                        literal_token: "needle",
10836                        path_prefix: None,
10837                        limit: 10,
10838                    },
10839                    None,
10840                )?
10841                .candidates
10842                .len(),
10843            &0,
10844            "replaced token removed from FTS",
10845        )?;
10846        require_eq(
10847            &store
10848                .query_file_text_fts_candidates(
10849                    &FileTextFtsQuery {
10850                        literal_token: "beacon",
10851                        path_prefix: None,
10852                        limit: 10,
10853                    },
10854                    None,
10855                )?
10856                .candidates
10857                .len(),
10858            &1,
10859            "replacement token added to FTS",
10860        )?;
10861
10862        store.connection.execute(
10863            "INSERT INTO file_text_fts(file_text_fts, rowid, content)
10864             SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
10865            [&replacement.path],
10866        )?;
10867        require_eq(
10868            &store.file_text_fts_state()?,
10869            &FileTextFtsState {
10870                source_rows: 1,
10871                indexed_rows: 0,
10872                synchronized: false,
10873            },
10874            "desynchronized FTS identity state",
10875        )?;
10876        store.connection.execute(
10877            "INSERT INTO file_text_fts(file_text_fts) VALUES('rebuild')",
10878            [],
10879        )?;
10880        let (source_revision, projection_revision) =
10881            load_file_text_fts_revisions(&store.connection)?.ok_or_else(|| {
10882                io::Error::other("FTS revisions disappeared after synchronized writes")
10883            })?;
10884        require_eq(
10885            &source_revision,
10886            &projection_revision,
10887            "synchronized FTS revisions",
10888        )?;
10889        set_metadata(
10890            &store.connection,
10891            FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
10892            &projection_revision.saturating_sub(1).to_string(),
10893        )?;
10894        require_eq(
10895            &store.file_text_fts_ready()?,
10896            &false,
10897            "incomplete FTS revision readiness",
10898        )?;
10899        set_metadata(
10900            &store.connection,
10901            FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
10902            &source_revision.to_string(),
10903        )?;
10904        require_eq(
10905            &store.file_text_fts_ready()?,
10906            &true,
10907            "restored FTS revision readiness",
10908        )?;
10909        store.mark_paths_absent(&["src".to_string()])?;
10910        require_eq(
10911            &store.file_text_fts_state()?,
10912            &FileTextFtsState {
10913                source_rows: 0,
10914                indexed_rows: 0,
10915                synchronized: true,
10916            },
10917            "absent-path FTS synchronization",
10918        )?;
10919
10920        store.replace_scan(&[test_file_node("src/main.rs", "hash-c")])?;
10921        let rescanned = IndexedFileText {
10922            path: "src/main.rs".to_string(),
10923            content_hash: Some("hash-c".to_string()),
10924            byte_count: 13,
10925            line_count: 1,
10926            content: "rescan token\n".to_string(),
10927        };
10928        store.replace_file_texts_for_paths(
10929            std::slice::from_ref(&rescanned.path),
10930            std::slice::from_ref(&rescanned),
10931        )?;
10932        store.replace_scan(&[])?;
10933        require_eq(
10934            &store.file_text_fts_state()?,
10935            &FileTextFtsState {
10936                source_rows: 0,
10937                indexed_rows: 0,
10938                synchronized: true,
10939            },
10940            "full-scan deletion FTS synchronization",
10941        )?;
10942        Ok(())
10943    }
10944
10945    #[test]
10946    fn file_text_fts_migration_backfills_existing_text_and_survives_reopen()
10947    -> Result<(), Box<dyn Error>> {
10948        let temp = tempfile::tempdir()?;
10949        let root = temp.path().join("project");
10950        fs::create_dir(&root)?;
10951        let database = temp.path().join("atlas.db");
10952        let mut store = AtlasStore::open_for_project(&database, &root)?;
10953        store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
10954        store.replace_file_texts_for_paths(
10955            &["src/main.rs".to_string()],
10956            &[IndexedFileText {
10957                path: "src/main.rs".to_string(),
10958                content_hash: Some("hash-a".to_string()),
10959                byte_count: 17,
10960                line_count: 1,
10961                content: "migration needle\n".to_string(),
10962            }],
10963        )?;
10964        store.connection.execute_batch("DROP TABLE file_text_fts")?;
10965        store.connection.execute(
10966            "DELETE FROM metadata WHERE key IN (?1, ?2)",
10967            params![
10968                FILE_TEXT_FTS_SOURCE_REVISION_KEY,
10969                FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
10970            ],
10971        )?;
10972        set_metadata(
10973            &store.connection,
10974            SCHEMA_VERSION_KEY,
10975            &crate::schema::COVERAGE_DISCOVERY_SCHEMA_VERSION.to_string(),
10976        )?;
10977        drop(store);
10978
10979        let migrated = AtlasStore::open_for_project(&database, &root)?;
10980        require_eq(
10981            &migrated.file_text_fts_state()?,
10982            &FileTextFtsState {
10983                source_rows: 1,
10984                indexed_rows: 1,
10985                synchronized: true,
10986            },
10987            "migrated FTS synchronization state",
10988        )?;
10989        require_eq(
10990            &migrated
10991                .query_file_text_fts_candidates(
10992                    &FileTextFtsQuery {
10993                        literal_token: "needle",
10994                        path_prefix: None,
10995                        limit: 10,
10996                    },
10997                    None,
10998                )?
10999                .candidates[0]
11000                .path,
11001            &"src/main.rs".to_string(),
11002            "migrated FTS backfill candidate",
11003        )?;
11004        drop(migrated);
11005
11006        let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
11007        require_eq(
11008            &reopened.file_text_fts_state()?.synchronized,
11009            &true,
11010            "reopened FTS synchronization",
11011        )?;
11012        require_eq(
11013            &reopened
11014                .query_file_text_fts_candidates(
11015                    &FileTextFtsQuery {
11016                        literal_token: "migration",
11017                        path_prefix: Some("src"),
11018                        limit: 1,
11019                    },
11020                    None,
11021                )?
11022                .candidates
11023                .len(),
11024            &1,
11025            "reopened FTS candidate count",
11026        )?;
11027        Ok(())
11028    }
11029
11030    #[test]
11031    fn sqlite_read_progress_interrupts_active_repository_traversal_and_clears_handler()
11032    -> Result<(), Box<dyn Error>> {
11033        let store = AtlasStore::in_memory()?;
11034        let control = IndexWorkControl::with_deadline(
11035            projectatlas_core::IndexCancellation::new(),
11036            Instant::now() + std::time::Duration::from_millis(50),
11037        );
11038        let interrupted = with_sqlite_read_progress(
11039            &store.connection,
11040            Some(&control),
11041            IndexWorkStage::RepositoryTraversal,
11042            || {
11043                store
11044                    .connection
11045                    .query_row(
11046                        "WITH RECURSIVE numbers(value) AS (
11047                             VALUES(1)
11048                             UNION ALL
11049                             SELECT value + 1 FROM numbers WHERE value < 100000000
11050                         )
11051                         SELECT SUM(value) FROM numbers",
11052                        [],
11053                        |row| row.get::<_, i64>(0),
11054                    )
11055                    .map_err(DbError::from)
11056            },
11057        );
11058        require(
11059            matches!(
11060                interrupted,
11061                Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
11062                    stage: IndexWorkStage::RepositoryTraversal
11063                }))
11064            ),
11065            "active SQLite traversal was not interrupted with its typed deadline",
11066        )?;
11067        require_eq(
11068            &store
11069                .connection
11070                .query_row("SELECT 1", [], |row| row.get::<_, i64>(0))?,
11071            &1,
11072            "cleared repository traversal progress handler",
11073        )?;
11074        store
11075            .connection
11076            .execute("CREATE TEMP TABLE follow_up(value INTEGER NOT NULL)", [])?;
11077        store
11078            .connection
11079            .execute("INSERT INTO follow_up(value) VALUES(1)", [])?;
11080        Ok(())
11081    }
11082
11083    #[cfg(feature = "sqlite-progress-test-observer")]
11084    #[test]
11085    fn sqlite_read_progress_propagates_cancellation_during_an_active_batch()
11086    -> Result<(), Box<dyn Error>> {
11087        use crate::sqlite_progress_test_observer::{
11088            SqliteReadProgressEvent, observe_sqlite_read_progress,
11089        };
11090        use std::cell::Cell;
11091        use std::rc::Rc;
11092
11093        let store = AtlasStore::in_memory()?;
11094        let cancellation = projectatlas_core::IndexCancellation::new();
11095        let control = IndexWorkControl::new(cancellation.clone(), None);
11096        let callback_seen = Rc::new(Cell::new(false));
11097        let cancelled = observe_sqlite_read_progress(
11098            {
11099                let callback_seen = Rc::clone(&callback_seen);
11100                move |event| {
11101                    if matches!(
11102                        event,
11103                        SqliteReadProgressEvent::CallbackEntered {
11104                            stage: IndexWorkStage::RepositoryTraversal
11105                        }
11106                    ) {
11107                        callback_seen.set(true);
11108                        cancellation.cancel();
11109                    }
11110                }
11111            },
11112            || {
11113                with_sqlite_read_progress(
11114                    &store.connection,
11115                    Some(&control),
11116                    IndexWorkStage::RepositoryTraversal,
11117                    || {
11118                        store
11119                            .connection
11120                            .query_row(
11121                                "WITH RECURSIVE numbers(value) AS (
11122                                     VALUES(1)
11123                                     UNION ALL
11124                                     SELECT value + 1 FROM numbers WHERE value < 100000000
11125                                 )
11126                                 SELECT SUM(value) FROM numbers",
11127                                [],
11128                                |row| row.get::<_, i64>(0),
11129                            )
11130                            .map_err(DbError::from)
11131                    },
11132                )
11133            },
11134        );
11135        require(
11136            callback_seen.get()
11137                && matches!(
11138                    cancelled,
11139                    Err(DbError::IndexWork(IndexWorkFailure::Cancelled {
11140                        stage: IndexWorkStage::RepositoryTraversal
11141                    }))
11142                ),
11143            "active SQLite batch did not propagate typed cancellation",
11144        )?;
11145        store.connection.execute(
11146            "CREATE TEMP TABLE cancellation_follow_up(value INTEGER)",
11147            [],
11148        )?;
11149        Ok(())
11150    }
11151
11152    #[test]
11153    fn fallback_admission_precedes_content_decode_and_clears_progress_handlers()
11154    -> Result<(), Box<dyn Error>> {
11155        let mut store = AtlasStore::in_memory()?;
11156        store.replace_scan(&[test_file_node("src/good.rs", "hash-good")])?;
11157        store.replace_file_texts_for_paths(
11158            &["src/good.rs".to_string()],
11159            &[IndexedFileText {
11160                path: "src/good.rs".to_string(),
11161                content_hash: Some("hash-good".to_string()),
11162                byte_count: 12,
11163                line_count: 1,
11164                content: "needle good\n".to_string(),
11165            }],
11166        )?;
11167        store.connection.execute(
11168            "INSERT INTO file_texts(path, content_hash, byte_count, line_count, content)
11169             VALUES('src/bad.rs', 'hash-bad', 1, 1, x'80')",
11170            [],
11171        )?;
11172
11173        let mut admitted = Vec::new();
11174        let mut visited = Vec::new();
11175        store.visit_file_texts_for_fallback(
11176            Some("src"),
11177            None,
11178            |metadata| {
11179                admitted.push(metadata.path.clone());
11180                Ok(if metadata.path == "src/bad.rs" {
11181                    FileTextAdmission::Skip
11182                } else {
11183                    FileTextAdmission::Read
11184                })
11185            },
11186            |text| {
11187                visited.push(text.path);
11188                Ok(true)
11189            },
11190        )?;
11191        require_eq(
11192            &admitted,
11193            &vec!["src/bad.rs".to_string(), "src/good.rs".to_string()],
11194            "fallback metadata admission rows",
11195        )?;
11196        require_eq(
11197            &visited,
11198            &vec!["src/good.rs".to_string()],
11199            "fallback decoded rows",
11200        )?;
11201        let mut scoped_plan = store.connection.prepare(&format!(
11202            "EXPLAIN QUERY PLAN {FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL}"
11203        ))?;
11204        let scoped_plan_details = scoped_plan
11205            .query_map(params!["src", "src/", "src0"], |row| {
11206                row.get::<_, String>(3)
11207            })?
11208            .collect::<Result<Vec<_>, _>>()?;
11209        require(
11210            scoped_plan_details
11211                .iter()
11212                .any(|detail| detail.contains("SEARCH file_texts USING INDEX"))
11213                && scoped_plan_details
11214                    .iter()
11215                    .all(|detail| !detail.contains("SCAN file_texts")),
11216            "path-scoped fallback did not use bounded binary index ranges",
11217        )?;
11218        let mut scoped_opcodes = store
11219            .connection
11220            .prepare(&format!("EXPLAIN {FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL}"))?;
11221        let scoped_opcode_rows = scoped_opcodes
11222            .query_map(params!["src", "src/", "src0"], |row| {
11223                Ok((row.get::<_, String>(1)?, row.get::<_, i64>(3)?))
11224            })?
11225            .collect::<Result<Vec<_>, _>>()?;
11226        require(
11227            scoped_opcode_rows
11228                .iter()
11229                .all(|(opcode, column)| opcode != "Column" || *column != 4),
11230            "fallback metadata cursor read source content before admission",
11231        )?;
11232        require(
11233            store
11234                .visit_file_texts_for_fallback(
11235                    Some("src/bad.rs"),
11236                    None,
11237                    |_| Ok(FileTextAdmission::Read),
11238                    |_| Ok(true),
11239                )
11240                .is_err(),
11241            "invalid source content decoded without error",
11242        )?;
11243
11244        let success_cancellation = projectatlas_core::IndexCancellation::new();
11245        let success_control = IndexWorkControl::new(success_cancellation.clone(), None);
11246        store.query_file_text_fts_candidates(
11247            &FileTextFtsQuery {
11248                literal_token: "needle",
11249                path_prefix: None,
11250                limit: 10,
11251            },
11252            Some(&success_control),
11253        )?;
11254        success_cancellation.cancel();
11255        let recursive_sum = store.connection.query_row(
11256            "WITH RECURSIVE numbers(value) AS (
11257                 VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 5000
11258             ) SELECT SUM(value) FROM numbers",
11259            [],
11260            |row| row.get::<_, i64>(0),
11261        )?;
11262        require_eq(
11263            &recursive_sum,
11264            &12_502_500,
11265            "cleared success progress handler",
11266        )?;
11267
11268        let cancellation = projectatlas_core::IndexCancellation::new();
11269        let control = IndexWorkControl::new(cancellation.clone(), None);
11270        let mut rows_seen = 0;
11271        let cancelled = store.visit_file_texts_for_fallback(
11272            Some("src"),
11273            Some(&control),
11274            |_| {
11275                rows_seen += 1;
11276                cancellation.cancel();
11277                Ok(FileTextAdmission::Skip)
11278            },
11279            |_| Ok(true),
11280        );
11281        require_eq(&rows_seen, &1, "rows before fallback cancellation")?;
11282        require(
11283            matches!(
11284                cancelled,
11285                Err(DbError::IndexWork(IndexWorkFailure::Cancelled {
11286                    stage: IndexWorkStage::TextIndex
11287                }))
11288            ),
11289            "fallback cancellation was not typed",
11290        )?;
11291        let post_error_sum = store.connection.query_row(
11292            "WITH RECURSIVE numbers(value) AS (
11293                 VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 5000
11294             ) SELECT SUM(value) FROM numbers",
11295            [],
11296            |row| row.get::<_, i64>(0),
11297        )?;
11298        require_eq(
11299            &post_error_sum,
11300            &12_502_500,
11301            "cleared error progress handler",
11302        )?;
11303
11304        let expired = IndexWorkControl::with_deadline(
11305            projectatlas_core::IndexCancellation::new(),
11306            Instant::now(),
11307        );
11308        let deadline = store.query_file_text_fts_candidates(
11309            &FileTextFtsQuery {
11310                literal_token: "needle",
11311                path_prefix: None,
11312                limit: 10,
11313            },
11314            Some(&expired),
11315        );
11316        require(
11317            matches!(
11318                deadline,
11319                Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
11320                    stage: IndexWorkStage::TextIndex
11321                }))
11322            ),
11323            "FTS deadline was not typed",
11324        )?;
11325        Ok(())
11326    }
11327
11328    #[test]
11329    fn suggested_purpose_is_not_approved() -> Result<(), Box<dyn Error>> {
11330        let mut store = AtlasStore::in_memory()?;
11331        let node = Node {
11332            path: "src/main.rs".to_string(),
11333            kind: NodeKind::File,
11334            parent_path: normalized_parent("src/main.rs"),
11335            extension: Some(".rs".to_string()),
11336            language: Some("rust".to_string()),
11337            size_bytes: Some(12),
11338            mtime_ns: Some(10),
11339            content_hash: Some("abc".to_string()),
11340        };
11341        store.replace_scan(&[node])?;
11342        store.set_suggested_purpose("src/main.rs", "Maybe application entry point")?;
11343        let nodes = store.load_nodes()?;
11344        require_eq(
11345            &nodes[0].purpose.source,
11346            &PurposeSource::Generated,
11347            "suggested source",
11348        )?;
11349        require_eq(
11350            &nodes[0].purpose.status,
11351            &PurposeStatus::Suggested,
11352            "suggested status",
11353        )?;
11354        store.set_purpose(
11355            "src/main.rs",
11356            "Application entry point",
11357            PurposeSource::Agent,
11358        )?;
11359        let nodes = store.load_nodes()?;
11360        require_eq(
11361            &nodes[0].purpose.status,
11362            &PurposeStatus::Approved,
11363            "agent-approved status",
11364        )?;
11365        store.set_suggested_purpose("src/main.rs", "Late generated suggestion")?;
11366        let nodes = store.load_nodes()?;
11367        require_eq(
11368            &nodes[0].purpose.purpose,
11369            &Some("Application entry point".to_string()),
11370            "approved purpose survives a late suggestion",
11371        )?;
11372        require_eq(
11373            &nodes[0].purpose.source,
11374            &PurposeSource::Agent,
11375            "approved purpose source survives a late suggestion",
11376        )?;
11377        require_eq(
11378            &nodes[0].purpose.status,
11379            &PurposeStatus::Approved,
11380            "approved purpose status survives a late suggestion",
11381        )?;
11382
11383        store.connection.execute(
11384            "
11385            UPDATE purposes
11386            SET status = ?1
11387            WHERE node_id = (SELECT id FROM nodes WHERE path = ?2)
11388            ",
11389            params![PurposeStatus::Stale.as_str(), "src/main.rs"],
11390        )?;
11391        store.set_suggested_purpose("src/main.rs", "Later generated suggestion")?;
11392        let nodes = store.load_nodes()?;
11393        require_eq(
11394            &nodes[0].purpose.purpose,
11395            &Some("Application entry point".to_string()),
11396            "stale reviewed purpose survives a late suggestion",
11397        )?;
11398        require_eq(
11399            &nodes[0].purpose.source,
11400            &PurposeSource::Agent,
11401            "stale reviewed source survives a late suggestion",
11402        )?;
11403        require_eq(
11404            &nodes[0].purpose.status,
11405            &PurposeStatus::Stale,
11406            "stale reviewed status survives a late suggestion",
11407        )?;
11408        Ok(())
11409    }
11410
11411    #[test]
11412    fn agent_reviewed_marker_depends_on_agent_approved_source() -> Result<(), Box<dyn Error>> {
11413        let mut store = AtlasStore::in_memory()?;
11414        store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
11415
11416        store.set_suggested_purpose("src/main.rs", "Maybe application entry point")?;
11417        let nodes = store.load_nodes()?;
11418        require_eq(
11419            &nodes[0].purpose.agent_reviewed(),
11420            &false,
11421            "generated suggestion is not agent reviewed",
11422        )?;
11423
11424        store.set_purpose(
11425            "src/main.rs",
11426            "Imported application entry point",
11427            PurposeSource::Imported,
11428        )?;
11429        let nodes = store.load_nodes()?;
11430        require_eq(
11431            &nodes[0].purpose.agent_reviewed(),
11432            &false,
11433            "imported purpose is not agent reviewed",
11434        )?;
11435
11436        store.set_purpose(
11437            "src/main.rs",
11438            "Agent-reviewed application entry point",
11439            PurposeSource::Agent,
11440        )?;
11441        let nodes = store.load_nodes()?;
11442        require_eq(
11443            &nodes[0].purpose.agent_reviewed(),
11444            &true,
11445            "agent-approved purpose is agent reviewed",
11446        )?;
11447
11448        store.connection.execute(
11449            "
11450            UPDATE purposes
11451            SET source = 'human'
11452            WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/main.rs')
11453            ",
11454            [],
11455        )?;
11456        let nodes = store.load_nodes()?;
11457        require_eq(
11458            &nodes[0].purpose.source,
11459            &PurposeSource::Agent,
11460            "legacy human source normalizes to agent",
11461        )?;
11462        require_eq(
11463            &nodes[0].purpose.agent_reviewed(),
11464            &true,
11465            "legacy approved human row remains reviewed",
11466        )?;
11467
11468        store.replace_scan(&[test_file_node("src/main.rs", "hash-b")])?;
11469        let nodes = store.load_nodes()?;
11470        require_eq(
11471            &nodes[0].purpose.status,
11472            &PurposeStatus::Approved,
11473            "changed reviewed purpose stays approved",
11474        )?;
11475        require_eq(
11476            &nodes[0].purpose.agent_reviewed(),
11477            &true,
11478            "changed approved purpose remains agent reviewed",
11479        )?;
11480        Ok(())
11481    }
11482
11483    #[test]
11484    fn purpose_curation_batch_coalesces_current_unapproved_rows() -> Result<(), Box<dyn Error>> {
11485        let temp = tempfile::tempdir()?;
11486        let root = temp.path().join("project");
11487        fs::create_dir(&root)?;
11488        let database = temp.path().join("projectatlas.db");
11489        let mut store = AtlasStore::open_for_project(&database, &root)?;
11490        store.replace_scan(&[
11491            test_file_node("src/a.rs", "hash-a"),
11492            test_file_node("src/b.rs", "hash-b"),
11493            test_file_node("src/accepted.rs", "hash-accepted"),
11494        ])?;
11495        store.set_suggested_purpose("src/b.rs", "Generated B suggestion")?;
11496        store.set_purpose("src/accepted.rs", "Accepted purpose", PurposeSource::Agent)?;
11497
11498        let selected = vec![
11499            "src/b.rs".to_string(),
11500            "src/a.rs".to_string(),
11501            "src/a.rs".to_string(),
11502            "src/accepted.rs".to_string(),
11503        ];
11504        let first = store.load_purpose_curation_batch(" issue   308 ", &selected)?;
11505        let second = store.load_purpose_curation_batch(
11506            "issue 308",
11507            &selected.iter().rev().cloned().collect::<Vec<_>>(),
11508        )?;
11509        require_eq(&first.task, &"issue 308".to_string(), "normalized task")?;
11510        require_eq(&first.work_key, &second.work_key, "deterministic batch key")?;
11511        require_eq(&first.items, &second.items, "deterministic candidate rows")?;
11512        require_eq(&first.items.len(), &2, "coalesced unapproved row count")?;
11513        require_eq(
11514            &first
11515                .items
11516                .iter()
11517                .map(|item| item.node.node.path.as_str())
11518                .collect::<Vec<_>>(),
11519            &vec!["src/a.rs", "src/b.rs"],
11520            "sorted actionable paths",
11521        )?;
11522        require_eq(
11523            &first
11524                .items
11525                .iter()
11526                .all(|item| item.work_key.len() == 64 && item.state_token.len() == 64),
11527            &true,
11528            "bounded opaque item identities",
11529        )?;
11530
11531        let page = store.purpose_curation_findings_page_current(&HealthQuery {
11532            start_index: 0,
11533            limit: 20,
11534            category: None,
11535            severity: Some(Severity::Warning),
11536            path_prefix: None,
11537            summary_only: false,
11538            scope: HealthScope::all(),
11539        })?;
11540        require_eq(&page.total, &2, "actionable queue count")?;
11541        require_eq(
11542            &page
11543                .findings
11544                .iter()
11545                .any(|finding| finding.path == "src/accepted.rs"),
11546            &false,
11547            "accepted purpose omitted from automatic curation",
11548        )?;
11549
11550        store.set_purpose(
11551            "src/accepted.rs",
11552            "Deliberately corrected purpose",
11553            PurposeSource::Agent,
11554        )?;
11555        let corrected = store
11556            .load_node_by_path("src/accepted.rs")?
11557            .ok_or_else(|| io::Error::other("corrected purpose path disappeared"))?;
11558        require_eq(
11559            &corrected.purpose.purpose,
11560            &Some("Deliberately corrected purpose".to_string()),
11561            "explicit accepted-purpose correction",
11562        )?;
11563        Ok(())
11564    }
11565
11566    #[test]
11567    fn conditional_purpose_batch_is_atomic_and_rejects_changed_work() -> Result<(), Box<dyn Error>>
11568    {
11569        let temp = tempfile::tempdir()?;
11570        let root = temp.path().join("project");
11571        fs::create_dir(&root)?;
11572        let database = temp.path().join("projectatlas.db");
11573        let mut store = AtlasStore::open_for_project(&database, &root)?;
11574        let nodes = vec![
11575            test_file_node("src/a.rs", "hash-a"),
11576            test_file_node("src/b.rs", "hash-b"),
11577            test_file_node("src/changed.rs", "hash-changed"),
11578            test_file_node("src/accepted.rs", "hash-accepted"),
11579            test_file_node("src/generation.rs", "hash-generation"),
11580            test_file_node("src/rollback-a.rs", "hash-rollback-a"),
11581            test_file_node("src/rollback-b.rs", "hash-rollback-b"),
11582        ];
11583        store.replace_scan(&nodes)?;
11584        store.set_suggested_purpose("src/b.rs", "Generated B suggestion")?;
11585        store.set_suggested_purpose("src/changed.rs", "Original suggestion")?;
11586        require_eq(
11587            &store.authored_purpose_revision()?,
11588            &0,
11589            "suggestions do not advance authored-purpose revision",
11590        )?;
11591        let task = "issue-308-purpose-curation";
11592
11593        let apply_batch = store
11594            .load_purpose_curation_batch(task, &["src/a.rs".to_string(), "src/b.rs".to_string()])?;
11595        let requests = apply_batch
11596            .items
11597            .iter()
11598            .map(|candidate| {
11599                conditional_purpose_request(
11600                    task,
11601                    candidate,
11602                    &format!("Reviewed {}", candidate.node.node.path),
11603                )
11604            })
11605            .collect::<Vec<_>>();
11606        let applied = store.conditionally_set_purposes(&requests)?;
11607        require_eq(
11608            &applied
11609                .iter()
11610                .map(|result| result.state)
11611                .collect::<Vec<_>>(),
11612            &vec![
11613                PurposeConditionalApplyState::Applied,
11614                PurposeConditionalApplyState::Applied,
11615            ],
11616            "one-transaction batch outcomes",
11617        )?;
11618        require_eq(
11619            &applied.iter().all(|result| {
11620                result.current_purpose.as_ref().is_some_and(|purpose| {
11621                    purpose.status == PurposeStatus::Approved
11622                        && purpose.source == PurposeSource::Agent
11623                })
11624            }),
11625            &true,
11626            "applied transaction-owned purpose metadata",
11627        )?;
11628        require_eq(
11629            &store.authored_purpose_revision()?,
11630            &1,
11631            "one accepted batch advances one authored-purpose revision",
11632        )?;
11633
11634        let changed = store
11635            .load_purpose_curation_batch(task, &["src/changed.rs".to_string()])?
11636            .items
11637            .into_iter()
11638            .next()
11639            .ok_or_else(|| io::Error::other("changed candidate missing"))?;
11640        store.set_suggested_purpose("src/changed.rs", "Concurrent suggestion")?;
11641        let changed_result = store
11642            .conditionally_set_purposes(&[conditional_purpose_request(
11643                task,
11644                &changed,
11645                "Stale curator answer",
11646            )])?
11647            .pop()
11648            .ok_or_else(|| io::Error::other("changed conditional result missing"))?;
11649        require_eq(
11650            &changed_result.state,
11651            &PurposeConditionalApplyState::Stale,
11652            "changed suggestion state",
11653        )?;
11654        require_eq(
11655            &changed_result
11656                .current_purpose
11657                .as_ref()
11658                .map(|purpose| (purpose.status, purpose.source, purpose.purpose.as_deref())),
11659            &Some((
11660                PurposeStatus::Suggested,
11661                PurposeSource::Generated,
11662                Some("Concurrent suggestion"),
11663            )),
11664            "stale transaction-owned purpose metadata",
11665        )?;
11666        require_eq(
11667            &store.authored_purpose_revision()?,
11668            &1,
11669            "stale conditional purpose leaves revision unchanged",
11670        )?;
11671
11672        let accepted = store
11673            .load_purpose_curation_batch(task, &["src/accepted.rs".to_string()])?
11674            .items
11675            .into_iter()
11676            .next()
11677            .ok_or_else(|| io::Error::other("accepted candidate missing"))?;
11678        store.set_purpose(
11679            "src/accepted.rs",
11680            "Concurrent accepted purpose",
11681            PurposeSource::Agent,
11682        )?;
11683        require_eq(
11684            &store.authored_purpose_revision()?,
11685            &2,
11686            "explicit accepted correction advances the authored-purpose revision",
11687        )?;
11688        store.set_purpose(
11689            "src/accepted.rs",
11690            "Concurrent accepted purpose",
11691            PurposeSource::Agent,
11692        )?;
11693        require_eq(
11694            &store.authored_purpose_revision()?,
11695            &2,
11696            "identical accepted purpose leaves the authored-purpose revision unchanged",
11697        )?;
11698        let accepted_result = store
11699            .conditionally_set_purposes(&[conditional_purpose_request(
11700                task,
11701                &accepted,
11702                "Stale curator overwrite",
11703            )])?
11704            .pop()
11705            .ok_or_else(|| io::Error::other("accepted conditional result missing"))?;
11706        require_eq(
11707            &accepted_result.state,
11708            &PurposeConditionalApplyState::Accepted,
11709            "accepted concurrent state",
11710        )?;
11711        require_eq(
11712            &accepted_result
11713                .current_purpose
11714                .as_ref()
11715                .map(|purpose| (purpose.status, purpose.source, purpose.purpose.as_deref())),
11716            &Some((
11717                PurposeStatus::Approved,
11718                PurposeSource::Agent,
11719                Some("Concurrent accepted purpose"),
11720            )),
11721            "accepted transaction-owned purpose metadata",
11722        )?;
11723        require_eq(
11724            &store.authored_purpose_revision()?,
11725            &2,
11726            "accepted conditional no-op leaves revision unchanged",
11727        )?;
11728
11729        let unavailable_result = store
11730            .conditionally_set_purposes(&[PurposeConditionalApplyRequest {
11731                task: task.to_string(),
11732                path: "src/unavailable.rs".to_string(),
11733                work_key: "unavailable-work".to_string(),
11734                state_token: "unavailable-state".to_string(),
11735                purpose: "Unavailable purpose".to_string(),
11736            }])?
11737            .pop()
11738            .ok_or_else(|| io::Error::other("unavailable conditional result missing"))?;
11739        require_eq(
11740            &unavailable_result.state,
11741            &PurposeConditionalApplyState::PathUnavailable,
11742            "unavailable path state",
11743        )?;
11744        require_eq(
11745            &unavailable_result.current_purpose,
11746            &None,
11747            "unavailable transaction-owned purpose metadata",
11748        )?;
11749
11750        let generation = store
11751            .load_purpose_curation_batch(task, &["src/generation.rs".to_string()])?
11752            .items
11753            .into_iter()
11754            .next()
11755            .ok_or_else(|| io::Error::other("generation candidate missing"))?;
11756        let project = store
11757            .project_instance_id()?
11758            .ok_or_else(|| io::Error::other("project identity missing"))?;
11759        {
11760            let mut publication = store.begin_index_publication("purpose-curation-test")?;
11761            publication.begin_scan_replacement()?;
11762            publication.upsert_scan_node_batch(&nodes)?;
11763            publication.finish_scan_replacement()?;
11764            publication.replace_repository_graph(project, &[], &[], &[], &[])?;
11765            publication.complete()?;
11766        }
11767        let generation_state = store.conditionally_set_purpose(
11768            task,
11769            "src/generation.rs",
11770            &generation.work_key,
11771            &generation.state_token,
11772            "Prior-generation answer",
11773        )?;
11774        require_eq(
11775            &generation_state,
11776            &PurposeConditionalApplyState::Stale,
11777            "generation-bound state",
11778        )?;
11779
11780        let current = store.load_nodes_by_paths(&[
11781            "src/changed.rs".to_string(),
11782            "src/accepted.rs".to_string(),
11783            "src/generation.rs".to_string(),
11784        ])?;
11785        let purposes = current
11786            .iter()
11787            .map(|node| {
11788                (
11789                    node.node.path.as_str(),
11790                    node.purpose.purpose.as_deref(),
11791                    node.purpose.status,
11792                )
11793            })
11794            .collect::<Vec<_>>();
11795        require_eq(
11796            &purposes,
11797            &vec![
11798                (
11799                    "src/accepted.rs",
11800                    Some("Concurrent accepted purpose"),
11801                    PurposeStatus::Approved,
11802                ),
11803                (
11804                    "src/changed.rs",
11805                    Some("Concurrent suggestion"),
11806                    PurposeStatus::Suggested,
11807                ),
11808                ("src/generation.rs", None, PurposeStatus::Missing),
11809            ],
11810            "conflicting rows remain untouched",
11811        )?;
11812
11813        let rollback_batch = store.load_purpose_curation_batch(
11814            task,
11815            &[
11816                "src/rollback-a.rs".to_string(),
11817                "src/rollback-b.rs".to_string(),
11818            ],
11819        )?;
11820        let rollback_requests = rollback_batch
11821            .items
11822            .iter()
11823            .map(|candidate| {
11824                conditional_purpose_request(
11825                    task,
11826                    candidate,
11827                    &format!("Reviewed {}", candidate.node.node.path),
11828                )
11829            })
11830            .collect::<Vec<_>>();
11831        store.connection.execute_batch(
11832            "
11833            CREATE TEMP TRIGGER abort_second_conditional_purpose_update
11834            BEFORE UPDATE ON purposes
11835            WHEN OLD.node_id = (
11836                SELECT id FROM nodes WHERE path = 'src/rollback-b.rs'
11837            )
11838            BEGIN
11839                SELECT RAISE(ABORT, 'forced conditional purpose rollback');
11840            END;
11841            ",
11842        )?;
11843        if store.conditionally_set_purposes(&rollback_requests).is_ok() {
11844            return Err(io::Error::other("injected conditional batch failure succeeded").into());
11845        }
11846        drop(store);
11847        let reopened = AtlasStore::open_for_project(&database, &root)?;
11848        let rolled_back = reopened.load_nodes_by_paths(&[
11849            "src/rollback-a.rs".to_string(),
11850            "src/rollback-b.rs".to_string(),
11851        ])?;
11852        require_eq(
11853            &rolled_back
11854                .iter()
11855                .map(|node| node.purpose.status)
11856                .collect::<Vec<_>>(),
11857            &vec![PurposeStatus::Missing, PurposeStatus::Missing],
11858            "failed conditional batch rolled back after reopen",
11859        )?;
11860        Ok(())
11861    }
11862
11863    #[test]
11864    fn concurrent_purpose_curators_cannot_overwrite_the_winner() -> Result<(), Box<dyn Error>> {
11865        let temp = tempfile::tempdir()?;
11866        let root = temp.path().join("project");
11867        fs::create_dir(&root)?;
11868        let database = temp.path().join("projectatlas.db");
11869        let mut store = AtlasStore::open_for_project(&database, &root)?;
11870        store.replace_scan(&[test_file_node("src/main.rs", "hash-main")])?;
11871        let candidate = store
11872            .load_purpose_curation_batch("concurrent-curators", &["src/main.rs".to_string()])?
11873            .items
11874            .into_iter()
11875            .next()
11876            .ok_or_else(|| io::Error::other("concurrent candidate missing"))?;
11877        drop(store);
11878
11879        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
11880        let mut handles = Vec::new();
11881        for purpose in ["First reviewed purpose", "Second reviewed purpose"] {
11882            let database = database.clone();
11883            let root = root.clone();
11884            let barrier = std::sync::Arc::clone(&barrier);
11885            let candidate = candidate.clone();
11886            handles.push(std::thread::spawn(move || {
11887                let store = AtlasStore::open_for_project(&database, &root)?;
11888                barrier.wait();
11889                store.conditionally_set_purpose(
11890                    "concurrent-curators",
11891                    "src/main.rs",
11892                    &candidate.work_key,
11893                    &candidate.state_token,
11894                    purpose,
11895                )
11896            }));
11897        }
11898        let outcomes = handles
11899            .into_iter()
11900            .map(|handle| {
11901                handle
11902                    .join()
11903                    .map_err(|_panic| io::Error::other("curator thread panicked"))?
11904                    .map_err(io::Error::other)
11905            })
11906            .collect::<Result<Vec<_>, io::Error>>()?;
11907        require_eq(
11908            &outcomes
11909                .iter()
11910                .filter(|state| **state == PurposeConditionalApplyState::Applied)
11911                .count(),
11912            &1,
11913            "single concurrent winner",
11914        )?;
11915        require_eq(
11916            &outcomes
11917                .iter()
11918                .filter(|state| **state == PurposeConditionalApplyState::Accepted)
11919                .count(),
11920            &1,
11921            "accepted concurrent loser",
11922        )?;
11923
11924        let reopened = AtlasStore::open_for_project(&database, &root)?;
11925        let node = reopened
11926            .load_node_by_path("src/main.rs")?
11927            .ok_or_else(|| io::Error::other("concurrent path disappeared"))?;
11928        require_eq(
11929            &node.purpose.status,
11930            &PurposeStatus::Approved,
11931            "persisted concurrent winner status",
11932        )?;
11933        require_eq(
11934            &matches!(
11935                node.purpose.purpose.as_deref(),
11936                Some("First reviewed purpose" | "Second reviewed purpose")
11937            ),
11938            &true,
11939            "persisted concurrent winner value",
11940        )?;
11941        Ok(())
11942    }
11943
11944    #[test]
11945    fn purpose_curation_hydration_uses_existing_unique_indexes() -> Result<(), Box<dyn Error>> {
11946        let mut store = AtlasStore::in_memory()?;
11947        store.replace_scan(&[
11948            test_file_node("src/a.rs", "hash-a"),
11949            test_file_node("src/b.rs", "hash-b"),
11950        ])?;
11951        let sql = format!("EXPLAIN QUERY PLAN {}", load_nodes_by_paths_sql(2));
11952        let mut statement = store.connection.prepare(&sql)?;
11953        let details = statement
11954            .query_map(params!["src/a.rs", "src/b.rs"], |row| {
11955                row.get::<_, String>(3)
11956            })?
11957            .collect::<Result<Vec<_>, _>>()?;
11958        let plan = details.join("\n");
11959        require_eq(
11960            &plan.contains("SEARCH n USING INDEX sqlite_autoindex_nodes_"),
11961            &true,
11962            "unique path index query plan",
11963        )?;
11964        require_eq(
11965            &plan.contains("SEARCH p USING INTEGER PRIMARY KEY"),
11966            &true,
11967            "purpose primary-key query plan",
11968        )?;
11969        require_eq(&plan.contains("SCAN n"), &false, "no node-table scan")?;
11970        Ok(())
11971    }
11972
11973    #[test]
11974    fn updates_content_summary_without_approving_purpose() -> Result<(), Box<dyn Error>> {
11975        let mut store = AtlasStore::in_memory()?;
11976        let node = Node {
11977            path: "src/lib.rs".to_string(),
11978            kind: NodeKind::File,
11979            parent_path: normalized_parent("src/lib.rs"),
11980            extension: Some(".rs".to_string()),
11981            language: Some("rust".to_string()),
11982            size_bytes: Some(24),
11983            mtime_ns: Some(10),
11984            content_hash: Some("def".to_string()),
11985        };
11986        store.replace_scan(&[node])?;
11987        store.set_node_summary(
11988            "src/lib.rs",
11989            "rust source defining library entry functions.",
11990        )?;
11991        let nodes = store.load_nodes()?;
11992        require_eq(
11993            &nodes[0].summary,
11994            &Some("rust source defining library entry functions.".to_string()),
11995            "updated content summary",
11996        )?;
11997        require_eq(
11998            &nodes[0].purpose.status,
11999            &PurposeStatus::Missing,
12000            "summary update does not approve purpose",
12001        )?;
12002        require_eq(
12003            &store.has_agent_approved_purpose()?,
12004            &false,
12005            "missing purpose does not activate graph hydration",
12006        )?;
12007        store.set_purpose(
12008            "src/lib.rs",
12009            "Owns the library entry points.",
12010            PurposeSource::Agent,
12011        )?;
12012        require_eq(
12013            &store.has_agent_approved_purpose()?,
12014            &true,
12015            "agent-approved purpose activates graph hydration",
12016        )?;
12017        Ok(())
12018    }
12019
12020    #[test]
12021    fn replaces_symbol_graph_idempotently() -> Result<(), Box<dyn Error>> {
12022        let mut store = AtlasStore::in_memory()?;
12023        let graph = SymbolGraph {
12024            path: "src/main.rs".to_string(),
12025            language: Some("rust".to_string()),
12026            parser: ParserKind::TreeSitter,
12027            symbols: vec![CodeSymbol {
12028                path: "src/main.rs".to_string(),
12029                language: Some("rust".to_string()),
12030                name: "main".to_string(),
12031                kind: SymbolKind::Function,
12032                signature: "fn main()".to_string(),
12033                exported: true,
12034                documentation: Some("Run the application.".to_string()),
12035                line_start: 1,
12036                line_end: 3,
12037                parent: None,
12038                parser: ParserKind::TreeSitter,
12039                detail: Some("function_item".to_string()),
12040            }],
12041            relations: vec![SymbolRelation {
12042                path: "src/main.rs".to_string(),
12043                source_name: "main".to_string(),
12044                target_name: "println!".to_string(),
12045                kind: RelationKind::Calls,
12046                line: 2,
12047                context: "println!(\"hello\")".to_string(),
12048                parser: ParserKind::TreeSitter,
12049            }],
12050        };
12051
12052        store.replace_symbol_graph(&graph)?;
12053        store.replace_symbol_graph(&graph)?;
12054        let symbols = store.load_symbols(Some("src/main.rs"), Some("main"), 10)?;
12055        let relations = store.load_symbol_relations(Some("src/main.rs"), Some("println"), 10)?;
12056        let metadata = store
12057            .load_source_parse_metadata("src/main.rs")?
12058            .ok_or_else(|| io::Error::other("missing source parse metadata"))?;
12059        require_eq(&symbols.len(), &1, "symbol count after replace")?;
12060        require_eq(&relations.len(), &1, "relation count after replace")?;
12061        require_eq(&metadata.parser, &ParserKind::TreeSitter, "metadata parser")?;
12062        require_eq(&metadata.symbol_count, &1, "metadata symbol count")?;
12063        require_eq(&metadata.relation_count, &1, "metadata relation count")?;
12064        require_eq(&symbols[0].exported, &true, "exported metadata")?;
12065        require_eq(
12066            &symbols[0].documentation,
12067            &Some("Run the application.".to_string()),
12068            "documentation metadata",
12069        )?;
12070        Ok(())
12071    }
12072
12073    #[test]
12074    fn import_alias_lookup_uses_kind_first_covering_index() -> Result<(), Box<dyn Error>> {
12075        let mut store = AtlasStore::in_memory()?;
12076        store.replace_symbol_graph(&SymbolGraph {
12077            path: "src/main.rs".to_string(),
12078            language: Some("rust".to_string()),
12079            parser: ParserKind::TreeSitter,
12080            symbols: Vec::new(),
12081            relations: vec![
12082                SymbolRelation {
12083                    path: "src/main.rs".to_string(),
12084                    source_name: "main".to_string(),
12085                    target_name: "use crate::service::run".to_string(),
12086                    kind: RelationKind::Imports,
12087                    line: 1,
12088                    context: "use crate::service::run;".to_string(),
12089                    parser: ParserKind::TreeSitter,
12090                },
12091                SymbolRelation {
12092                    path: "src/main.rs".to_string(),
12093                    source_name: "main".to_string(),
12094                    target_name: "use crate::other::Thing".to_string(),
12095                    kind: RelationKind::Imports,
12096                    line: 2,
12097                    context: "use crate::other::Thing;".to_string(),
12098                    parser: ParserKind::TreeSitter,
12099                },
12100            ],
12101        })?;
12102
12103        let relations =
12104            store.load_import_relations_matching_targets(&["service".to_string()], 10)?;
12105        require_eq(&relations.len(), &1, "matched import relation count")?;
12106        require_eq(
12107            &store
12108                .load_import_relations_for_path("src/main.rs", 10)?
12109                .len(),
12110            &2,
12111            "exact-path import relation count",
12112        )?;
12113        let mut statement = store.connection.prepare(
12114            "EXPLAIN QUERY PLAN
12115             SELECT path, source_name, target_name, line
12116             FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
12117             WHERE kind = 'imports' AND target_name LIKE '%service%' ESCAPE '\\'
12118             ORDER BY path, line, source_name, target_name
12119             LIMIT 10",
12120        )?;
12121        let plan = statement
12122            .query_map([], |row| row.get::<_, String>(3))?
12123            .collect::<Result<Vec<_>, _>>()?
12124            .join("\n");
12125        require(
12126            plan.contains(
12127                "SEARCH symbol_relations USING COVERING INDEX idx_symbol_import_alias_lookup (kind=?)",
12128            ),
12129            &format!("import alias lookup missed kind-first covering index: {plan}"),
12130        )?;
12131        Ok(())
12132    }
12133
12134    #[test]
12135    fn file_scoped_symbol_kind_lookup_uses_path_index() -> Result<(), Box<dyn Error>> {
12136        let store = AtlasStore::in_memory()?;
12137        for sql in [
12138            "EXPLAIN QUERY PLAN
12139             SELECT path, language, name, kind, signature, line_start, line_end,
12140                    parent, parser, detail, exported, documentation
12141             FROM symbols INDEXED BY idx_symbols_path
12142             WHERE path = 'src/lib.rs' AND kind IN ('function')
12143             ORDER BY path, line_start, name
12144             LIMIT 25",
12145            "EXPLAIN QUERY PLAN
12146             SELECT COUNT(*) FROM symbols INDEXED BY idx_symbols_path
12147             WHERE path = 'src/lib.rs' AND kind IN ('function')",
12148        ] {
12149            let mut statement = store.connection.prepare(sql)?;
12150            let plan = statement
12151                .query_map([], |row| row.get::<_, String>(3))?
12152                .collect::<Result<Vec<_>, _>>()?
12153                .join("\n");
12154            require(
12155                plan.contains("SEARCH symbols USING INDEX idx_symbols_path (path=?)"),
12156                &format!("file-scoped symbol lookup missed exact-path index: {plan}"),
12157            )?;
12158        }
12159        Ok(())
12160    }
12161
12162    #[test]
12163    fn preserves_source_parse_and_fact_parser_provenance_independently()
12164    -> Result<(), Box<dyn Error>> {
12165        let temp = tempfile::tempdir()?;
12166        let database = temp.path().join("projectatlas.db");
12167        let mut store = AtlasStore::open(&database)?;
12168        let graph = SymbolGraph {
12169            path: "src/optional.lang".to_string(),
12170            language: Some("optional-language".to_string()),
12171            parser: ParserKind::Fallback,
12172            symbols: vec![CodeSymbol {
12173                path: "src/optional.lang".to_string(),
12174                language: Some("optional-language".to_string()),
12175                name: "entry".to_string(),
12176                kind: SymbolKind::Function,
12177                signature: "entry()".to_string(),
12178                exported: false,
12179                documentation: None,
12180                line_start: 1,
12181                line_end: 1,
12182                parent: None,
12183                parser: ParserKind::Fallback,
12184                detail: None,
12185            }],
12186            relations: vec![SymbolRelation {
12187                path: "src/optional.lang".to_string(),
12188                source_name: "entry".to_string(),
12189                target_name: "helper".to_string(),
12190                kind: RelationKind::Calls,
12191                line: 1,
12192                context: "entry()".to_string(),
12193                parser: ParserKind::Fallback,
12194            }],
12195        };
12196        let metadata = SourceParseMetadata {
12197            path: graph.path.clone(),
12198            language: graph.language.clone(),
12199            parser: ParserKind::TreeSitter,
12200            symbol_count: graph.symbols.len(),
12201            relation_count: graph.relations.len(),
12202        };
12203
12204        store.replace_symbol_graph_with_metadata(&graph, &metadata)?;
12205
12206        let stored_metadata = store
12207            .load_source_parse_metadata(&graph.path)?
12208            .ok_or_else(|| io::Error::other("missing independent source parse metadata"))?;
12209        let symbols = store.load_symbols(Some(&graph.path), Some("entry"), 10)?;
12210        let relations = store.load_symbol_relations(Some(&graph.path), Some("helper"), 10)?;
12211        require_eq(
12212            &stored_metadata.parser,
12213            &ParserKind::TreeSitter,
12214            "grammar-backed source parser",
12215        )?;
12216        require_eq(
12217            &symbols[0].parser,
12218            &ParserKind::Fallback,
12219            "fallback symbol provenance",
12220        )?;
12221        require_eq(
12222            &relations[0].parser,
12223            &ParserKind::Fallback,
12224            "fallback relation provenance",
12225        )?;
12226
12227        let invalid_metadata = SourceParseMetadata {
12228            symbol_count: 0,
12229            ..metadata
12230        };
12231        let Err(error) = store.replace_symbol_graph_with_metadata(&graph, &invalid_metadata) else {
12232            return Err(io::Error::other("mismatched explicit metadata was accepted").into());
12233        };
12234        if !matches!(error, DbError::SymbolGraphRowShape { .. }) {
12235            return Err(io::Error::other(format!(
12236                "mismatched explicit metadata returned the wrong error: {error}"
12237            ))
12238            .into());
12239        }
12240
12241        let empty_graph = SymbolGraph {
12242            path: "src/empty.optional".to_string(),
12243            language: Some("optional-language".to_string()),
12244            parser: ParserKind::Fallback,
12245            symbols: Vec::new(),
12246            relations: Vec::new(),
12247        };
12248        let empty_metadata = SourceParseMetadata {
12249            path: empty_graph.path.clone(),
12250            language: empty_graph.language.clone(),
12251            parser: ParserKind::TreeSitter,
12252            symbol_count: 0,
12253            relation_count: 0,
12254        };
12255        store.replace_symbol_graph_with_metadata(&empty_graph, &empty_metadata)?;
12256        drop(store);
12257
12258        let reader = AtlasStore::open_read_only(&database)?;
12259        let reopened_metadata = reader
12260            .load_source_parse_metadata(&graph.path)?
12261            .ok_or_else(|| io::Error::other("missing reopened source parse metadata"))?;
12262        let metadata_paths = reader.source_parse_metadata_paths_for_paths(&[
12263            "src/missing.optional".to_string(),
12264            empty_graph.path.clone(),
12265            graph.path.clone(),
12266            graph.path.clone(),
12267        ])?;
12268        let reopened_graphs =
12269            reader.load_symbol_graphs_for_paths(&[graph.path.clone(), empty_graph.path.clone()])?;
12270        require_eq(
12271            &reopened_metadata.parser,
12272            &ParserKind::TreeSitter,
12273            "reopened source parser provenance",
12274        )?;
12275        require_eq(
12276            &metadata_paths,
12277            &HashSet::from([graph.path.clone(), empty_graph.path.clone()]),
12278            "batched source parse metadata paths",
12279        )?;
12280        require_eq(
12281            &reopened_graphs,
12282            &vec![empty_graph, graph],
12283            "reopened fact graph provenance",
12284        )?;
12285        reader.finish_index_read_snapshot()?;
12286        Ok(())
12287    }
12288
12289    #[test]
12290    fn reconstructs_exact_symbol_graph_batches_from_disk_and_fails_closed()
12291    -> Result<(), Box<dyn Error>> {
12292        let temp = tempfile::tempdir()?;
12293        let db_path = temp.path().join("projectatlas.db");
12294        let mut store = AtlasStore::open(&db_path)?;
12295        store.replace_scan(&[
12296            test_file_node("src/a.rs", "hash-a"),
12297            test_file_node("src/b.rs", "hash-b"),
12298        ])?;
12299        let graph = SymbolGraph {
12300            path: "src/a.rs".to_string(),
12301            language: Some("rust".to_string()),
12302            parser: ParserKind::TreeSitter,
12303            symbols: vec![CodeSymbol {
12304                path: "src/a.rs".to_string(),
12305                language: Some("rust".to_string()),
12306                name: "owner".to_string(),
12307                kind: SymbolKind::Function,
12308                signature: "fn owner()".to_string(),
12309                exported: true,
12310                documentation: None,
12311                line_start: 1,
12312                line_end: 2,
12313                parent: None,
12314                parser: ParserKind::TreeSitter,
12315                detail: Some("function_item".to_string()),
12316            }],
12317            relations: vec![SymbolRelation {
12318                path: "src/a.rs".to_string(),
12319                source_name: "owner".to_string(),
12320                target_name: "dependency".to_string(),
12321                kind: RelationKind::Calls,
12322                line: 2,
12323                context: "dependency()".to_string(),
12324                parser: ParserKind::TreeSitter,
12325            }],
12326        };
12327        store.replace_symbol_graph(&graph)?;
12328        let empty_graph = SymbolGraph {
12329            path: "src/b.rs".to_string(),
12330            language: Some("rust".to_string()),
12331            parser: ParserKind::TreeSitter,
12332            symbols: Vec::new(),
12333            relations: Vec::new(),
12334        };
12335        store.replace_symbol_graph(&empty_graph)?;
12336        drop(store);
12337
12338        let reader = AtlasStore::open_read_only(&db_path)?;
12339        let loaded = reader.load_symbol_graphs_for_paths(&[
12340            "src/b.rs".to_string(),
12341            "src/a.rs".to_string(),
12342            "src/a.rs".to_string(),
12343            "src/missing.rs".to_string(),
12344        ])?;
12345        require_eq(
12346            &loaded,
12347            &vec![graph, empty_graph],
12348            "batched symbol graph round trip",
12349        )?;
12350        for (table, expected_index) in [
12351            (
12352                "source_parse_metadata",
12353                "sqlite_autoindex_source_parse_metadata_1",
12354            ),
12355            ("symbols", "idx_symbols_path"),
12356            ("symbol_relations", "idx_symbol_relations_path"),
12357        ] {
12358            let sql = format!(
12359                "EXPLAIN QUERY PLAN SELECT path FROM {table}
12360                  WHERE path IN ('src/a.rs', 'src/b.rs')"
12361            );
12362            let mut statement = reader.connection.prepare(&sql)?;
12363            let plan = statement
12364                .query_map([], |row| row.get::<_, String>(3))?
12365                .collect::<Result<Vec<_>, _>>()?
12366                .join("\n");
12367            if !plan.contains(expected_index) {
12368                return Err(io::Error::other(format!(
12369                    "{table} batch lookup missed {expected_index}: {plan}"
12370                ))
12371                .into());
12372            }
12373        }
12374        reader.finish_index_read_snapshot()?;
12375        drop(reader);
12376
12377        let writer = AtlasStore::open(&db_path)?;
12378        writer.connection.execute(
12379            "UPDATE source_parse_metadata SET symbol_count = 2 WHERE path = 'src/a.rs'",
12380            [],
12381        )?;
12382        let Err(error) = writer.load_symbol_graphs_for_paths(&["src/a.rs".to_string()]) else {
12383            return Err(io::Error::other("metadata count corruption was accepted").into());
12384        };
12385        if !matches!(error, DbError::SymbolGraphRowShape { .. }) {
12386            return Err(io::Error::other(format!(
12387                "metadata count corruption returned the wrong error: {error}"
12388            ))
12389            .into());
12390        }
12391        Ok(())
12392    }
12393
12394    #[test]
12395    fn bounded_symbol_batch_streams_exact_paths_and_uses_path_index() -> Result<(), Box<dyn Error>>
12396    {
12397        let mut store = AtlasStore::in_memory()?;
12398        store.replace_scan(&[
12399            test_file_node("src/a.rs", "hash-a"),
12400            test_file_node("src/b.rs", "hash-b"),
12401        ])?;
12402        store.replace_symbol_graph(&SymbolGraph {
12403            path: "src/a.rs".to_string(),
12404            language: Some("rust".to_string()),
12405            parser: ParserKind::TreeSitter,
12406            symbols: vec![
12407                batch_test_symbol("src/a.rs", "alpha", 1, 32),
12408                batch_test_symbol("src/a.rs", "beta", 2, 32),
12409            ],
12410            relations: Vec::new(),
12411        })?;
12412        store.replace_symbol_graph(&SymbolGraph {
12413            path: "src/b.rs".to_string(),
12414            language: Some("rust".to_string()),
12415            parser: ParserKind::TreeSitter,
12416            symbols: vec![batch_test_symbol("src/b.rs", "gamma", 1, 32)],
12417            relations: Vec::new(),
12418        })?;
12419
12420        let complete = store.load_symbols_for_paths_bounded(
12421            &[
12422                "src/b.rs".to_string(),
12423                "src/a.rs".to_string(),
12424                "src/a.rs".to_string(),
12425            ],
12426            SymbolBatchReadBudget::new(2, 3, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
12427            None,
12428        )?;
12429        require_eq(
12430            &complete
12431                .rows
12432                .iter()
12433                .map(|symbol| symbol.name.as_str())
12434                .collect::<Vec<_>>(),
12435            &vec!["alpha", "beta", "gamma"],
12436            "deterministic exact-path symbol order",
12437        )?;
12438        require_eq(&complete.truncated, &false, "complete symbol batch")?;
12439        require_eq(&complete.reached_limit, &None, "complete batch limit")?;
12440        require_eq(&complete.work.requested_paths, &2, "unique admitted paths")?;
12441        require_eq(&complete.work.returned_rows, &3, "complete returned rows")?;
12442
12443        let row_limited = store.load_symbols_for_paths_bounded(
12444            &["src/b.rs".to_string(), "src/a.rs".to_string()],
12445            SymbolBatchReadBudget::new(2, 1, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
12446            None,
12447        )?;
12448        require_eq(
12449            &row_limited.reached_limit,
12450            &Some(SymbolBatchReadLimit::Rows),
12451            "row-bound classification",
12452        )?;
12453        require_eq(&row_limited.rows.len(), &1, "row-bound retained rows")?;
12454
12455        let byte_limited = store.load_symbols_for_paths_bounded(
12456            &["src/a.rs".to_string()],
12457            SymbolBatchReadBudget::new(1, 3, 1)?,
12458            None,
12459        )?;
12460        require_eq(
12461            &byte_limited.reached_limit,
12462            &Some(SymbolBatchReadLimit::DecodedBytes),
12463            "decoded-byte-bound classification",
12464        )?;
12465        require_eq(
12466            &byte_limited.rows.len(),
12467            &0,
12468            "tiny byte budget retained no partial row",
12469        )?;
12470        require_eq(
12471            &byte_limited.work.decoded_bytes,
12472            &0,
12473            "tiny byte budget retained no row payload",
12474        )?;
12475
12476        let gamma_bytes = code_symbol_decoded_bytes(&complete.rows[2])?;
12477        let exact_byte_boundary = store.load_symbols_for_paths_bounded(
12478            &["src/b.rs".to_string()],
12479            SymbolBatchReadBudget::new(1, 1, gamma_bytes)?,
12480            None,
12481        )?;
12482        require_eq(
12483            &exact_byte_boundary.rows.len(),
12484            &1,
12485            "exact decoded-byte boundary retained the complete row",
12486        )?;
12487        require_eq(
12488            &exact_byte_boundary.work.decoded_bytes,
12489            &gamma_bytes,
12490            "exact decoded-byte boundary accounting",
12491        )?;
12492
12493        let oversized_signature =
12494            "x".repeat(usize::try_from(MAX_SYMBOL_BATCH_DECODED_BYTES)?.saturating_add(1));
12495        store.connection.execute(
12496            "UPDATE symbols SET signature = ?1, line_start = 'invalid' WHERE path = 'src/b.rs'",
12497            [&oversized_signature],
12498        )?;
12499        let oversized_row = store.load_symbols_for_paths_bounded(
12500            &["src/b.rs".to_string()],
12501            SymbolBatchReadBudget::new(1, 1, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
12502            None,
12503        )?;
12504        require_eq(
12505            &oversized_row.reached_limit,
12506            &Some(SymbolBatchReadLimit::DecodedBytes),
12507            "oversized persisted row was rejected before hydration",
12508        )?;
12509        require_eq(
12510            &oversized_row.rows.len(),
12511            &0,
12512            "oversized persisted row retained no allocation",
12513        )?;
12514        store.connection.execute(
12515            "UPDATE symbols SET signature = 'fn gamma()', line_start = 1 WHERE path = 'src/b.rs'",
12516            [],
12517        )?;
12518
12519        let many_paths = (0..65)
12520            .rev()
12521            .map(|index| format!("generated/{index:04}.rs"))
12522            .collect::<Vec<_>>();
12523        for index in 0..65 {
12524            let path = format!("generated/{index:04}.rs");
12525            store.replace_symbol_graph(&SymbolGraph {
12526                path: path.clone(),
12527                language: Some("rust".to_string()),
12528                parser: ParserKind::TreeSitter,
12529                symbols: vec![batch_test_symbol(
12530                    &path,
12531                    &format!("generated_{index:04}"),
12532                    1,
12533                    0,
12534                )],
12535                relations: Vec::new(),
12536            })?;
12537        }
12538        let path_limited = store.load_symbols_for_paths_bounded(
12539            &many_paths,
12540            SymbolBatchReadBudget::new(
12541                MAX_SYMBOL_BATCH_PATHS,
12542                MAX_SYMBOL_BATCH_ROWS,
12543                MAX_SYMBOL_BATCH_DECODED_BYTES,
12544            )?,
12545            None,
12546        )?;
12547        require_eq(
12548            &path_limited.reached_limit,
12549            &Some(SymbolBatchReadLimit::Paths),
12550            "path-bound classification",
12551        )?;
12552        require_eq(
12553            &path_limited.work.requested_paths,
12554            &MAX_SYMBOL_BATCH_PATHS,
12555            "bounded path admission",
12556        )?;
12557        require_eq(
12558            &path_limited.rows.len(),
12559            &usize::try_from(MAX_SYMBOL_BATCH_PATHS)?,
12560            "all admitted paths loaded across binding chunks",
12561        )?;
12562        require(
12563            path_limited
12564                .rows
12565                .iter()
12566                .any(|symbol| symbol.name == "generated_0063"),
12567            "last deterministically admitted path was not loaded",
12568        )?;
12569        require(
12570            path_limited
12571                .rows
12572                .iter()
12573                .all(|symbol| symbol.name != "generated_0064"),
12574            "path outside the deterministic admission set was loaded",
12575        )?;
12576
12577        let expired = IndexWorkControl::with_deadline(
12578            projectatlas_core::IndexCancellation::new(),
12579            Instant::now(),
12580        );
12581        let expired_result = store.load_symbols_for_paths_bounded(
12582            &["src/a.rs".to_string()],
12583            SymbolBatchReadBudget::new(1, 3, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
12584            Some(&expired),
12585        );
12586        require(
12587            matches!(
12588                expired_result,
12589                Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
12590                    stage: IndexWorkStage::RepositoryTraversal
12591                }))
12592            ),
12593            "expired symbol control returned a partial batch instead of a typed error",
12594        )?;
12595
12596        let mut plan_statement = store.connection.prepare(
12597            "EXPLAIN QUERY PLAN
12598             SELECT
12599                 path,
12600                 language,
12601                 name,
12602                 kind,
12603                 signature,
12604                 line_start,
12605                 line_end,
12606                 parent,
12607                 parser,
12608                 detail,
12609                 exported,
12610                 documentation,
12611                 length(CAST(path AS BLOB))
12612                     + COALESCE(length(CAST(language AS BLOB)), 0)
12613                     + length(CAST(name AS BLOB))
12614                     + length(CAST(signature AS BLOB))
12615                     + COALESCE(length(CAST(parent AS BLOB)), 0)
12616                     + COALESCE(length(CAST(detail AS BLOB)), 0)
12617                     + COALESCE(length(CAST(documentation AS BLOB)), 0)
12618             FROM symbols
12619             WHERE path IN (?1, ?2)
12620             ORDER BY path, line_start, name
12621             LIMIT ?3",
12622        )?;
12623        let plan = plan_statement
12624            .query_map(params!["src/a.rs", "src/b.rs", 4], |row| {
12625                row.get::<_, String>(3)
12626            })?
12627            .collect::<Result<Vec<_>, _>>()?
12628            .join("\n");
12629        require(
12630            plan.contains("idx_symbols_path"),
12631            &format!("bounded symbol batch missed idx_symbols_path: {plan}"),
12632        )?;
12633        Ok(())
12634    }
12635
12636    #[test]
12637    fn full_scan_removal_clears_source_parse_metadata() -> Result<(), Box<dyn Error>> {
12638        let mut store = AtlasStore::in_memory()?;
12639        store.replace_scan(&[test_file_node("src/a.rs", "hash-a")])?;
12640        store.replace_symbol_graph(&SymbolGraph {
12641            path: "src/a.rs".to_string(),
12642            language: Some("rust".to_string()),
12643            parser: ParserKind::TreeSitter,
12644            symbols: Vec::new(),
12645            relations: Vec::new(),
12646        })?;
12647        require_eq(
12648            &store.load_source_parse_metadata("src/a.rs")?.is_some(),
12649            &true,
12650            "metadata exists before removal",
12651        )?;
12652
12653        store.replace_scan(&[test_file_node("src/b.rs", "hash-b")])?;
12654        require_eq(
12655            &store.load_source_parse_metadata("src/a.rs")?,
12656            &None,
12657            "metadata cleared after full scan removal",
12658        )?;
12659        Ok(())
12660    }
12661
12662    #[test]
12663    fn call_relations_are_limited_per_target() -> Result<(), Box<dyn Error>> {
12664        let mut store = AtlasStore::in_memory()?;
12665        let mut relations = Vec::new();
12666        for index in 0..5 {
12667            relations.push(SymbolRelation {
12668                path: format!("src/a{index}.rs"),
12669                source_name: format!("alpha_caller_{index}"),
12670                target_name: "alpha".to_string(),
12671                kind: RelationKind::Calls,
12672                line: index + 1,
12673                context: "alpha();".to_string(),
12674                parser: ParserKind::TreeSitter,
12675            });
12676        }
12677        relations.push(SymbolRelation {
12678            path: "src/z.rs".to_string(),
12679            source_name: "beta_caller".to_string(),
12680            target_name: "beta".to_string(),
12681            kind: RelationKind::Calls,
12682            line: 99,
12683            context: "beta();".to_string(),
12684            parser: ParserKind::TreeSitter,
12685        });
12686        store.replace_symbol_graph(&SymbolGraph {
12687            path: "src/main.rs".to_string(),
12688            language: Some("rust".to_string()),
12689            parser: ParserKind::TreeSitter,
12690            symbols: Vec::new(),
12691            relations,
12692        })?;
12693
12694        let loaded =
12695            store.load_call_relations_to_targets(&["alpha".to_string(), "beta".to_string()], 2)?;
12696        let alpha_count = loaded
12697            .iter()
12698            .filter(|relation| relation.target_name == "alpha")
12699            .count();
12700        let beta_count = loaded
12701            .iter()
12702            .filter(|relation| relation.target_name == "beta")
12703            .count();
12704        require_eq(&alpha_count, &2, "alpha per-target limit")?;
12705        require_eq(&beta_count, &1, "beta preserved despite alpha skew")?;
12706        let mut statement = store.connection.prepare(
12707            "EXPLAIN QUERY PLAN
12708             SELECT path, source_name, target_name, kind, line, context, parser
12709             FROM (
12710                 SELECT path, source_name, target_name, kind, line, context, parser,
12711                     ROW_NUMBER() OVER (
12712                         PARTITION BY target_name
12713                         ORDER BY path, line, source_name, target_name
12714                     ) AS target_row
12715                 FROM symbol_relations INDEXED BY idx_symbol_relations_target
12716                 WHERE kind = 'calls' AND target_name IN ('alpha', 'beta')
12717             )
12718             WHERE target_row <= 2
12719             ORDER BY path, line, source_name, target_name",
12720        )?;
12721        let plan = statement
12722            .query_map([], |row| row.get::<_, String>(3))?
12723            .collect::<Result<Vec<_>, _>>()?
12724            .join("\n");
12725        require(
12726            plan.contains(
12727                "SEARCH symbol_relations USING INDEX idx_symbol_relations_target (target_name=?)",
12728            ),
12729            &format!("call target lookup missed exact-target index: {plan}"),
12730        )?;
12731        Ok(())
12732    }
12733
12734    #[test]
12735    fn stores_health_resolution_ids() -> Result<(), Box<dyn Error>> {
12736        let mut store = AtlasStore::in_memory()?;
12737        store.replace_scan(&[
12738            test_file_node("src/a.rs", "hash-a"),
12739            test_file_node("src/b.rs", "hash-b"),
12740        ])?;
12741        store.set_purpose("src/a.rs", "Shared purpose", PurposeSource::Agent)?;
12742        store.set_purpose("src/b.rs", "Shared purpose", PurposeSource::Agent)?;
12743        let duplicate = store
12744            .unresolved_health_findings(&[])?
12745            .into_iter()
12746            .find(|finding| finding.category == "duplicate-purpose")
12747            .ok_or_else(|| io::Error::other("duplicate-purpose finding missing"))?;
12748        let duplicate_id = duplicate.id.clone();
12749        store.resolve_health_finding(&HealthResolution {
12750            finding_id: duplicate_id.clone(),
12751            category: duplicate.category,
12752            path: duplicate.path,
12753            related_path: duplicate.related_path,
12754            rationale: "Paths intentionally mirror agent skill variants.".to_string(),
12755        })?;
12756        let ids = store.resolved_health_ids()?;
12757        require_eq(&ids, &vec![duplicate_id], "resolved ids")?;
12758        Ok(())
12759    }
12760
12761    #[test]
12762    fn health_resolution_accepts_all_scope_agent_review_findings() -> Result<(), Box<dyn Error>> {
12763        let mut store = AtlasStore::in_memory()?;
12764        let mut asset_file = test_file_node("assets/logo.svg", "hash-logo");
12765        asset_file.extension = Some(".svg".to_string());
12766        asset_file.language = None;
12767        store.replace_scan(&[test_folder_node("assets"), asset_file])?;
12768        store.set_purpose(
12769            "assets/logo.svg",
12770            "Imported SVG brand asset purpose",
12771            PurposeSource::Imported,
12772        )?;
12773
12774        let page = store.unresolved_health_findings_page(
12775            &[],
12776            &HealthQuery {
12777                start_index: 0,
12778                limit: 20,
12779                category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
12780                severity: Some(Severity::Warning),
12781                path_prefix: Some(".".to_string()),
12782                summary_only: false,
12783                scope: HealthScope::all(),
12784            },
12785        )?;
12786        let finding = page
12787            .findings
12788            .iter()
12789            .find(|finding| finding.path == "assets/logo.svg")
12790            .ok_or_else(|| io::Error::other("asset review finding missing"))?;
12791        store.resolve_health_finding(&HealthResolution {
12792            finding_id: finding.id.clone(),
12793            category: finding.category.clone(),
12794            path: finding.path.clone(),
12795            related_path: finding.related_path.clone(),
12796            rationale: "Asset purpose imported from legacy metadata and intentionally accepted."
12797                .to_string(),
12798        })?;
12799
12800        let remaining = store.unresolved_health_findings_page(
12801            &store.resolved_health_ids()?,
12802            &HealthQuery {
12803                start_index: 0,
12804                limit: 20,
12805                category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
12806                severity: Some(Severity::Warning),
12807                path_prefix: Some(".".to_string()),
12808                summary_only: false,
12809                scope: HealthScope::all(),
12810            },
12811        )?;
12812        require_eq(
12813            &health_paths(&remaining).contains(&"assets/logo.svg"),
12814            &false,
12815            "resolved all-scope asset review finding",
12816        )?;
12817        Ok(())
12818    }
12819
12820    #[test]
12821    fn purpose_set_reports_unindexed_path_without_sqlite_leak() -> Result<(), Box<dyn Error>> {
12822        let mut store = AtlasStore::in_memory()?;
12823        store.replace_scan(&[test_file_node("src/main.rs", "hash")])?;
12824        let error = match store.set_purpose("no/such/file.rs", "Missing file", PurposeSource::Agent)
12825        {
12826            Ok(()) => return Err(io::Error::other("missing path should fail").into()),
12827            Err(error) => error,
12828        };
12829
12830        require_eq(
12831            &error.to_string().contains("no/such/file.rs"),
12832            &true,
12833            "path named in error",
12834        )?;
12835        require_eq(
12836            &error.to_string().contains("sqlite error"),
12837            &false,
12838            "raw sqlite error hidden",
12839        )?;
12840        store.replace_scan(&[])?;
12841        let error = match store.set_purpose("src/main.rs", "Removed file", PurposeSource::Agent) {
12842            Ok(()) => return Err(io::Error::other("stale indexed path should fail").into()),
12843            Err(error) => error,
12844        };
12845        require_eq(
12846            &error.to_string().contains("src/main.rs"),
12847            &true,
12848            "stale path named in error",
12849        )?;
12850        Ok(())
12851    }
12852
12853    #[test]
12854    fn health_resolution_requires_active_finding_tuple() -> Result<(), Box<dyn Error>> {
12855        let mut store = AtlasStore::in_memory()?;
12856        store.replace_scan(&[test_file_node("src/main.rs", "hash")])?;
12857        let error = match store.resolve_health_finding(&HealthResolution {
12858            finding_id: "missing-id".to_string(),
12859            category: "duplicate-purpose".to_string(),
12860            path: "no/such/file.rs".to_string(),
12861            related_path: None,
12862            rationale: "typo".to_string(),
12863        }) {
12864            Ok(()) => {
12865                return Err(io::Error::other("nonexistent health finding should fail").into());
12866            }
12867            Err(error) => error,
12868        };
12869
12870        require_eq(
12871            &error.to_string().contains("not active"),
12872            &true,
12873            "inactive finding rejected",
12874        )?;
12875        Ok(())
12876    }
12877
12878    /// Write released schema-8 source, authored, telemetry, and publication state.
12879    fn write_released_schema_eight_compatibility_fixture(
12880        db_path: &Path,
12881        root: &Path,
12882    ) -> Result<(), Box<dyn Error>> {
12883        write_schema_eight_compatibility_fixture(
12884            db_path,
12885            root,
12886            schema::create_released_schema_eight,
12887        )
12888    }
12889
12890    /// Write evolved released schema-8 source, authored, telemetry, and publication state.
12891    fn write_evolved_released_schema_eight_compatibility_fixture(
12892        db_path: &Path,
12893        root: &Path,
12894    ) -> Result<(), Box<dyn Error>> {
12895        write_schema_eight_compatibility_fixture(
12896            db_path,
12897            root,
12898            schema::create_evolved_released_schema_eight,
12899        )
12900    }
12901
12902    /// Write one captured schema-8 layout with representative durable state.
12903    fn write_schema_eight_compatibility_fixture(
12904        db_path: &Path,
12905        root: &Path,
12906        create_schema: fn(&Connection) -> DbResult<()>,
12907    ) -> Result<(), Box<dyn Error>> {
12908        let connection = Connection::open(db_path)?;
12909        create_schema(&connection)?;
12910        schema::configure_writable(&connection)?;
12911        connection.execute_batch("BEGIN IMMEDIATE")?;
12912        let write_result = (|| -> DbResult<()> {
12913            set_metadata(
12914                &connection,
12915                PROJECT_ROOT_KEY,
12916                &normalize_native_path_display(root),
12917            )?;
12918            set_metadata(&connection, "custom_setting", "preserved")?;
12919            set_metadata(&connection, INDEX_PUBLICATION_STATE_KEY, "complete")?;
12920            set_metadata(
12921                &connection,
12922                INDEX_PUBLICATION_FINGERPRINT_KEY,
12923                "untrusted-contract",
12924            )?;
12925            set_metadata(&connection, INDEX_PUBLICATION_GENERATION_KEY, "7")?;
12926            connection.execute_batch(
12927                "
12928                INSERT INTO nodes(
12929                    id, path, kind, parent_path, extension, language,
12930                    size_bytes, mtime_ns, content_hash
12931                )
12932                VALUES(1, 'src/lib.rs', 'file', 'src', '.rs', 'rust', 12, 10, 'hash-legacy');
12933
12934                INSERT INTO purposes(node_id, purpose, source, status, updated_by)
12935                VALUES(1, 'Schema compatibility source', 'agent', 'approved', 'agent');
12936
12937                INSERT INTO summaries(node_id, summary_level, subject, summary)
12938                VALUES(1, 'node', '', 'released schema source');
12939
12940                INSERT INTO file_texts(path, content_hash, byte_count, line_count, content)
12941                VALUES('src/lib.rs', 'hash-legacy', 6, 1, 'legacy');
12942                ",
12943            )?;
12944            connection.execute(
12945                "
12946                INSERT INTO health_resolutions(
12947                    finding_id, category, path, rationale
12948                )
12949                VALUES('schema-review', ?1, 'src/lib.rs', 'Reviewed schema fixture')
12950                ",
12951                [CATEGORY_DUPLICATE_PURPOSE],
12952            )?;
12953            record_released_schema_eight_usage_event(
12954                &connection,
12955                &usage_from_estimates(
12956                    "schema-session",
12957                    "summary",
12958                    Some("src/lib.rs".to_string()),
12959                    None,
12960                    100,
12961                    20,
12962                ),
12963            )
12964        })();
12965        match write_result {
12966            Ok(()) => connection.execute_batch("COMMIT")?,
12967            Err(error) => {
12968                if let Err(rollback) = connection.execute_batch("ROLLBACK") {
12969                    return Err(DbError::TransactionRollback {
12970                        operation: Box::new(error),
12971                        rollback,
12972                    }
12973                    .into());
12974                }
12975                return Err(error.into());
12976            }
12977        }
12978        Ok(())
12979    }
12980
12981    /// Write representative source, authored, telemetry, and publication state at one schema.
12982    fn write_schema_compatibility_fixture(
12983        db_path: &Path,
12984        root: &Path,
12985        schema_version: i64,
12986        label: &str,
12987    ) -> Result<(), Box<dyn Error>> {
12988        let mut store = AtlasStore::open(db_path)?;
12989        store.set_project_root(root)?;
12990        {
12991            let mut publication = store.begin_index_publication("untrusted-contract")?;
12992            write_test_projection(&mut publication, label)?;
12993            publication.complete()?;
12994        }
12995        store.set_purpose(
12996            "src/lib.rs",
12997            "Schema compatibility source",
12998            PurposeSource::Agent,
12999        )?;
13000        store.connection.execute(
13001            "
13002            INSERT INTO health_resolutions(
13003                finding_id,
13004                category,
13005                path,
13006                rationale
13007            )
13008            VALUES('schema-review', ?1, 'src/lib.rs', 'Reviewed schema fixture')
13009            ",
13010            [CATEGORY_DUPLICATE_PURPOSE],
13011        )?;
13012        store.record_usage(&usage_from_estimates(
13013            "schema-session",
13014            "summary",
13015            Some("src/lib.rs".to_string()),
13016            None,
13017            100,
13018            20,
13019        ))?;
13020        set_metadata(&store.connection, "custom_setting", "preserved")?;
13021        set_metadata(
13022            &store.connection,
13023            SCHEMA_VERSION_KEY,
13024            &schema_version.to_string(),
13025        )?;
13026        store
13027            .connection
13028            .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
13029        Ok(())
13030    }
13031
13032    /// Build a representative Rust file node for store tests.
13033    fn test_file_node(path: &str, hash: &str) -> Node {
13034        Node {
13035            path: path.to_string(),
13036            kind: NodeKind::File,
13037            parent_path: normalized_parent(path),
13038            extension: Some(".rs".to_string()),
13039            language: Some("rust".to_string()),
13040            size_bytes: Some(12),
13041            mtime_ns: Some(10),
13042            content_hash: Some(hash.to_string()),
13043        }
13044    }
13045
13046    /// Build one persisted symbol with configurable retained payload.
13047    fn batch_test_symbol(
13048        path: &str,
13049        name: &str,
13050        line_start: usize,
13051        documentation_bytes: usize,
13052    ) -> CodeSymbol {
13053        CodeSymbol {
13054            path: path.to_string(),
13055            language: Some("rust".to_string()),
13056            name: name.to_string(),
13057            kind: SymbolKind::Function,
13058            signature: format!("fn {name}()"),
13059            exported: false,
13060            documentation: Some("d".repeat(documentation_bytes)),
13061            line_start,
13062            line_end: line_start.saturating_add(1),
13063            parent: None,
13064            parser: ParserKind::TreeSitter,
13065            detail: Some("function_item".to_string()),
13066        }
13067    }
13068
13069    /// Copy one selected queue candidate into the public conditional-write contract.
13070    fn conditional_purpose_request(
13071        task: &str,
13072        candidate: &PurposeCurationCandidate,
13073        purpose: &str,
13074    ) -> PurposeConditionalApplyRequest {
13075        PurposeConditionalApplyRequest {
13076            task: task.to_string(),
13077            path: candidate.node.node.path.clone(),
13078            work_key: candidate.work_key.clone(),
13079            state_token: candidate.state_token.clone(),
13080            purpose: purpose.to_string(),
13081        }
13082    }
13083
13084    /// Replace every source-derived projection used by publication tests.
13085    fn write_test_projection(store: &mut AtlasStore, label: &str) -> DbResult<()> {
13086        let path = "src/lib.rs";
13087        let hash = format!("hash-{label}");
13088        store.replace_scan(&[test_file_node(path, &hash)])?;
13089        store.replace_file_texts_for_paths(
13090            &[path.to_string()],
13091            &[IndexedFileText {
13092                path: path.to_string(),
13093                content_hash: Some(hash),
13094                byte_count: label.len(),
13095                line_count: 1,
13096                content: label.to_string(),
13097            }],
13098        )?;
13099        store.replace_symbol_graph(&SymbolGraph {
13100            path: path.to_string(),
13101            language: Some("rust".to_string()),
13102            parser: ParserKind::TreeSitter,
13103            symbols: vec![CodeSymbol {
13104                path: path.to_string(),
13105                language: Some("rust".to_string()),
13106                name: format!("{label}_symbol"),
13107                kind: SymbolKind::Function,
13108                signature: format!("fn {label}_symbol()"),
13109                exported: true,
13110                documentation: None,
13111                line_start: 1,
13112                line_end: 1,
13113                parent: None,
13114                parser: ParserKind::TreeSitter,
13115                detail: Some("function_item".to_string()),
13116            }],
13117            relations: vec![SymbolRelation {
13118                path: path.to_string(),
13119                source_name: format!("{label}_symbol"),
13120                target_name: format!("{label}_target"),
13121                kind: RelationKind::Calls,
13122                line: 1,
13123                context: format!("{label}_target();"),
13124                parser: ParserKind::TreeSitter,
13125            }],
13126        })?;
13127        store.set_node_summary(path, &format!("{label} summary"))?;
13128        Ok(())
13129    }
13130
13131    /// Assert that one snapshot exposes a coherent projection generation.
13132    fn require_test_projection(
13133        store: &AtlasStore,
13134        generation: u64,
13135        label: &str,
13136    ) -> Result<(), Box<dyn Error>> {
13137        let path = "src/lib.rs";
13138        let publication = store
13139            .index_publication()?
13140            .ok_or_else(|| io::Error::other("publication missing"))?;
13141        require_eq(
13142            &publication.generation,
13143            &IndexGeneration::new(generation),
13144            "publication generation",
13145        )?;
13146        let node = store
13147            .load_node_by_path(path)?
13148            .ok_or_else(|| io::Error::other("projection node missing"))?;
13149        require_eq(
13150            &node.node.content_hash,
13151            &Some(format!("hash-{label}")),
13152            "projection node hash",
13153        )?;
13154        require_eq(
13155            &node.summary,
13156            &Some(format!("{label} summary")),
13157            "projection node summary",
13158        )?;
13159        let text = store
13160            .load_file_text(path)?
13161            .ok_or_else(|| io::Error::other("projection text missing"))?;
13162        require_eq(&text.content, &label.to_string(), "projection text")?;
13163        let symbols = store.load_symbols(Some(path), None, 10)?;
13164        require_eq(
13165            &symbols.first().map(|symbol| symbol.name.as_str()),
13166            &Some(format!("{label}_symbol")).as_deref(),
13167            "projection symbol",
13168        )?;
13169        let relations = store.load_symbol_relations(Some(path), None, 10)?;
13170        require_eq(
13171            &relations
13172                .first()
13173                .map(|relation| relation.target_name.as_str()),
13174            &Some(format!("{label}_target")).as_deref(),
13175            "projection relation",
13176        )?;
13177        let metadata = store
13178            .load_source_parse_metadata(path)?
13179            .ok_or_else(|| io::Error::other("projection parse metadata missing"))?;
13180        require_eq(&metadata.symbol_count, &1, "projection symbol metadata")?;
13181        require_eq(&metadata.relation_count, &1, "projection relation metadata")?;
13182        Ok(())
13183    }
13184
13185    /// Assert the connection settings required for every production writer.
13186    fn require_writable_connection_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
13187        let foreign_keys =
13188            connection.pragma_query_value(None, "foreign_keys", |row| row.get::<_, i64>(0))?;
13189        require_eq(&foreign_keys, &1, "writable foreign-key enforcement")?;
13190        require_wal_profile(connection)?;
13191        let synchronous =
13192            connection.pragma_query_value(None, "synchronous", |row| row.get::<_, i64>(0))?;
13193        require_eq(&synchronous, &2, "writable FULL synchronous mode")?;
13194        require_busy_timeout(connection)
13195    }
13196
13197    /// Assert the connection settings required for every production reader.
13198    fn require_read_connection_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
13199        let query_only =
13200            connection.pragma_query_value(None, "query_only", |row| row.get::<_, i64>(0))?;
13201        require_eq(&query_only, &1, "read query-only mode")?;
13202        require_wal_profile(connection)?;
13203        require_busy_timeout(connection)
13204    }
13205
13206    /// Assert that a connection observes the selected durable journal mode.
13207    fn require_wal_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
13208        let journal_mode =
13209            connection.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))?;
13210        require_eq(
13211            &journal_mode.to_ascii_lowercase(),
13212            &"wal".to_string(),
13213            "WAL journal mode",
13214        )
13215    }
13216
13217    /// Assert the bounded contention wait shared by ordinary connections.
13218    fn require_busy_timeout(connection: &Connection) -> Result<(), Box<dyn Error>> {
13219        let busy_timeout =
13220            connection.pragma_query_value(None, "busy_timeout", |row| row.get::<_, u64>(0))?;
13221        require_eq(
13222            &u128::from(busy_timeout),
13223            &SQLITE_BUSY_TIMEOUT.as_millis(),
13224            "bounded connection busy timeout",
13225        )
13226    }
13227
13228    /// Build a representative folder node for store tests.
13229    fn test_folder_node(path: &str) -> Node {
13230        Node {
13231            path: path.to_string(),
13232            kind: NodeKind::Folder,
13233            parent_path: normalized_parent(path),
13234            extension: None,
13235            language: None,
13236            size_bytes: None,
13237            mtime_ns: Some(10),
13238            content_hash: None,
13239        }
13240    }
13241
13242    /// Return the default low-cost purpose review query used by agent linting.
13243    fn low_query() -> HealthQuery {
13244        HealthQuery {
13245            start_index: 0,
13246            limit: 20,
13247            category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
13248            severity: Some(Severity::Warning),
13249            path_prefix: Some(".".to_string()),
13250            summary_only: false,
13251            scope: HealthScope::purpose_default(),
13252        }
13253    }
13254
13255    /// Collect health finding paths in returned order.
13256    fn health_paths(page: &HealthFindingsPage) -> Vec<&str> {
13257        page.findings
13258            .iter()
13259            .map(|finding| finding.path.as_str())
13260            .collect()
13261    }
13262
13263    /// Require a test condition without panicking.
13264    fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
13265        if condition {
13266            Ok(())
13267        } else {
13268            Err(io::Error::other(message).into())
13269        }
13270    }
13271
13272    /// Require two test values to be equal without panicking.
13273    fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
13274    where
13275        T: Debug + PartialEq,
13276    {
13277        if actual == expected {
13278            Ok(())
13279        } else {
13280            Err(io::Error::other(format!(
13281                "{label} mismatch: expected {expected:?}, got {actual:?}"
13282            ))
13283            .into())
13284        }
13285    }
13286}