Skip to main content

projectatlas_db/
project_identity.rs

1//! Durable project identity and explicit root-binding transitions.
2
3use super::{
4    AtlasStore, DbError, DbResult, ProjectRootMismatchIdentities, normalize_metadata_path,
5    set_metadata,
6};
7use crate::schema::{self, PROJECT_ROOT_KEY, SchemaState};
8use projectatlas_core::graph::ProjectInstanceId;
9use projectatlas_core::{CanonicalProjectRoot, IndexGeneration};
10use rusqlite::{Connection, OptionalExtension};
11use std::fs;
12use std::path::Path;
13
14/// Maximum attempts to obtain a nonzero identity distinct from an existing one.
15const PROJECT_IDENTITY_GENERATION_ATTEMPTS: usize = 8;
16
17/// Explicit root-binding behavior selected by a caller.
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub enum ProjectRootTransition {
20    /// Initialize a missing binding or verify an identical existing binding.
21    Bind,
22    /// Preserve identity while moving a database whose previous root is absent.
23    Move,
24    /// Rotate identity for an independent copy, clone, or worktree.
25    Detach,
26    /// Explicitly adopt the selected native root for an intact schema-19 database.
27    AdoptLegacy,
28}
29
30/// Result of one completed root transition.
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct ProjectRootTransitionResult {
33    /// Transition selected by the caller.
34    pub transition: ProjectRootTransition,
35    /// Lossless UTF-8 display of the native root stored before the transition,
36    /// when one existed and had a display projection.
37    pub previous_root: Option<String>,
38    /// Lossless UTF-8 display of the canonical root stored after the
39    /// transition, when one exists.
40    ///
41    /// `None` is the typed unavailable state for a native non-UTF-8 root.
42    pub project_root: Option<String>,
43    /// Durable identity owned by the destination after the transition.
44    pub project_instance_id: ProjectInstanceId,
45    /// Whether this operation created or replaced the project identity.
46    pub identity_changed: bool,
47    /// Whether derived publication trust was invalidated.
48    pub publication_invalidated: bool,
49}
50
51impl AtlasStore {
52    /// Repair a missing native root identity through an explicit initializer.
53    ///
54    /// Ordinary project opens remain fail-closed when a current database has
55    /// the native identity table but no singleton row. Initialization has the
56    /// caller's selected root as explicit authority, so it may restore that
57    /// row only after proving the selected root is equivalent to the existing
58    /// legacy metadata. The proof is repeated under the write transaction to
59    /// keep a concurrent metadata change from authorizing a different root.
60    ///
61    /// Predecessor and fresh databases are left to their normal initializer;
62    /// this narrow repair handles only an otherwise-current database whose
63    /// native row is incomplete.
64    ///
65    /// # Errors
66    ///
67    /// Returns an error when the selected root cannot be proven equivalent to
68    /// the existing binding, the current schema or project identity is
69    /// incomplete, or SQLite cannot complete the atomic repair.
70    pub fn repair_missing_project_root_identity(
71        database_path: &Path,
72        destination: &Path,
73    ) -> DbResult<()> {
74        let destination_identity = validate_project_root_destination(destination)?;
75        let (preflight, _) = schema::preflight(database_path, None)?;
76        if preflight.state != SchemaState::Current {
77            return Ok(());
78        }
79        if let Some(found) = read_current_project_root_identity(database_path)? {
80            prove_existing_root_equivalence(destination_identity.as_path(), found.as_path())?;
81            return Ok(());
82        }
83        let legacy = preflight
84            .project_root
85            .as_deref()
86            .ok_or(DbError::ProjectRootMissing)?;
87        prove_existing_root_equivalence(destination_identity.as_path(), Path::new(legacy))?;
88
89        let store = Self::open_with_binding_requirement(
90            database_path,
91            None,
92            None,
93            super::ProjectIdentityRequirement::TransitionOwned,
94            super::StoreInitializationPolicy::AllowFresh,
95        )?;
96        let transaction = rusqlite::Transaction::new_unchecked(
97            &store.connection,
98            rusqlite::TransactionBehavior::Immediate,
99        )?;
100        let result = (|| {
101            schema::validate_current_schema_version(&transaction)?;
102            if load_project_identity(&transaction)?.is_none() {
103                return Err(DbError::ProjectInstanceIdentityMissing);
104            }
105            let found_identity = load_project_root_identity(&transaction)?;
106            let selected = if let Some(found) = found_identity.as_ref() {
107                prove_existing_root_equivalence(destination_identity.as_path(), found.as_path())?
108            } else {
109                let legacy = transaction
110                    .query_row(
111                        "SELECT value FROM metadata WHERE key = ?1",
112                        [PROJECT_ROOT_KEY],
113                        |row| row.get::<_, String>(0),
114                    )
115                    .optional()?
116                    .ok_or(DbError::ProjectRootMissing)?;
117                prove_existing_root_equivalence(destination_identity.as_path(), Path::new(&legacy))?
118            };
119            set_project_root_identity(&transaction, &selected)?;
120            set_project_root_metadata(&transaction, &selected)?;
121            Ok(())
122        })();
123        match result {
124            Ok(()) => {
125                transaction.commit()?;
126                Ok(())
127            }
128            Err(operation) => match transaction.rollback() {
129                Ok(()) => Err(operation),
130                Err(rollback) => Err(DbError::TransactionRollback {
131                    operation: Box::new(operation),
132                    rollback,
133                }),
134            },
135        }
136    }
137
138    /// Apply an explicit root-binding transition to one database path.
139    ///
140    /// `destination` must be an absolute existing project directory and is
141    /// canonicalized before database preflight. `Bind` preserves the old compatible behavior. `Move` preserves identity
142    /// only after the recorded root is proven absent. `Detach` rotates identity
143    /// and discards project-qualified graph rows while preserving authored data.
144    /// `AdoptLegacy` explicitly supplies native authority for an intact schema-19
145    /// database at the selected root's conventional project-local location;
146    /// migration and root publication preserve its existing project identity.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error for incompatible storage, an implicit rebind, an
151    /// unproven move, a concurrent transition, identity corruption, or any
152    /// transactional `SQLite` failure.
153    pub fn transition_project_root(
154        database_path: &Path,
155        destination: &Path,
156        transition: ProjectRootTransition,
157    ) -> DbResult<ProjectRootTransitionResult> {
158        let destination_identity = validate_project_root_destination(destination)?;
159        let destination = destination_identity.display_string().ok();
160        if transition == ProjectRootTransition::AdoptLegacy {
161            let previous = schema::adopt_legacy_project_root(database_path, &destination_identity)?;
162            return Ok(ProjectRootTransitionResult {
163                transition,
164                previous_root: None,
165                project_root: destination,
166                project_instance_id: previous
167                    .project_instance_id
168                    .ok_or(DbError::ProjectInstanceIdentityMissing)?,
169                identity_changed: false,
170                publication_invalidated: false,
171            });
172        }
173        let (preflight, _) = schema::preflight(database_path, None)?;
174        let previous_root = preflight.project_root.clone();
175        let previous_identity = preflight.project_instance_id;
176        if preflight.state == SchemaState::UpgradeRequired
177            && schema::legacy_root_requires_native_authority(previous_root.as_deref())
178        {
179            return Err(DbError::ProjectRootIdentityMissing);
180        }
181        let previous_root_identity = if preflight.state == SchemaState::Current {
182            read_current_project_root_identity(database_path)?
183        } else if (transition == ProjectRootTransition::Bind
184            || transition == ProjectRootTransition::Detach)
185            && preflight.state == SchemaState::UpgradeRequired
186        {
187            previous_root
188                .as_deref()
189                .map(Path::new)
190                .map(CanonicalProjectRoot::from_path)
191                .transpose()?
192        } else {
193            None
194        };
195        match transition {
196            ProjectRootTransition::AdoptLegacy => Err(DbError::LegacyRootAdoptionUnavailable),
197            ProjectRootTransition::Bind => {
198                if let Some(found) = previous_root_identity.as_ref() {
199                    prove_existing_root_equivalence(
200                        destination_identity.as_path(),
201                        found.as_path(),
202                    )?;
203                } else if let Some(found) = previous_root.as_deref() {
204                    prove_existing_root_equivalence(
205                        destination_identity.as_path(),
206                        Path::new(found),
207                    )?;
208                }
209                let store = Self::open_for_project(database_path, destination_identity.as_path())?;
210                let project_instance_id = store
211                    .project_instance_id()?
212                    .ok_or(DbError::ProjectInstanceIdentityMissing)?;
213                Ok(ProjectRootTransitionResult {
214                    transition,
215                    previous_root: previous_root_identity
216                        .as_ref()
217                        .and_then(|root| root.display_string().ok()),
218                    project_root: destination,
219                    project_instance_id,
220                    identity_changed: previous_identity != Some(project_instance_id),
221                    publication_invalidated: false,
222                })
223            }
224            ProjectRootTransition::Move | ProjectRootTransition::Detach => {
225                let previous_root_identity = match previous_root_identity.as_ref() {
226                    Some(identity) => identity,
227                    None if preflight.state == SchemaState::UpgradeRequired => {
228                        return Err(DbError::ProjectRootIdentityMissing);
229                    }
230                    None => return Err(DbError::ProjectRootTransitionRequiresExistingRoot),
231                };
232                if transition == ProjectRootTransition::Move {
233                    if previous_root_identity == &destination_identity {
234                        return Err(DbError::ProjectRootTransitionRequiresDifferentRoot {
235                            root: destination_identity.display_string_lossy(),
236                        });
237                    }
238                    verify_root_absent(previous_root_identity)?;
239                }
240
241                let mut store = Self::open_for_root_transition(database_path)?;
242                let opened_identity = store.project_instance_id()?;
243                let upgrade_transaction = !store.connection.is_autocommit();
244                if previous_identity.is_some() && opened_identity != previous_identity {
245                    let error = project_transition_changed(
246                        previous_root_identity.display_string().ok(),
247                        store.project_root()?,
248                        previous_identity,
249                        opened_identity,
250                    );
251                    return Err(if upgrade_transaction {
252                        schema::rollback_after_error(&store.connection, error)
253                    } else {
254                        error
255                    });
256                }
257                let mut result = if upgrade_transaction {
258                    let operation = apply_root_transition_in_transaction(
259                        &mut store,
260                        transition,
261                        Some(previous_root_identity),
262                        opened_identity,
263                        &destination_identity,
264                    );
265                    match operation {
266                        Ok(result) => {
267                            if let Err(source) = store.connection.execute_batch("COMMIT") {
268                                return Err(schema::rollback_after_error(
269                                    &store.connection,
270                                    DbError::Sqlite(source),
271                                ));
272                            }
273                            result
274                        }
275                        Err(error) => {
276                            return Err(schema::rollback_after_error(&store.connection, error));
277                        }
278                    }
279                } else {
280                    apply_root_transition(
281                        &mut store,
282                        transition,
283                        Some(previous_root_identity),
284                        opened_identity,
285                        &destination_identity,
286                    )?
287                };
288                result.identity_changed = previous_identity != Some(result.project_instance_id);
289                Ok(result)
290            }
291        }
292    }
293
294    /// Return the durable project instance identity, when initialized.
295    ///
296    /// # Errors
297    ///
298    /// Returns an error when the singleton row is malformed or cannot be read.
299    pub fn project_instance_id(&self) -> DbResult<Option<ProjectInstanceId>> {
300        load_project_identity(&self.connection)
301    }
302}
303
304/// Validate and canonicalize a transition destination before touching its database.
305fn validate_project_root_destination(destination: &Path) -> DbResult<CanonicalProjectRoot> {
306    let root = normalize_metadata_path(destination);
307    if !destination.is_absolute() {
308        return Err(DbError::ProjectRootDestinationInvalid {
309            root,
310            source: std::io::Error::new(
311                std::io::ErrorKind::InvalidInput,
312                "project root destination is not absolute",
313            ),
314        });
315    }
316    CanonicalProjectRoot::from_path(destination).map_err(|error| match error {
317        projectatlas_core::CoreError::CanonicalProjectRootIo { source, .. } => {
318            DbError::ProjectRootDestinationInvalid { root, source }
319        }
320        projectatlas_core::CoreError::InvalidCanonicalProjectRoot { reason, .. } => {
321            DbError::ProjectRootDestinationInvalid {
322                root,
323                source: std::io::Error::new(std::io::ErrorKind::InvalidInput, reason),
324            }
325        }
326        other => DbError::from(other),
327    })
328}
329
330/// Apply move or detach after non-mutating preflight has captured expected state.
331fn apply_root_transition(
332    store: &mut AtlasStore,
333    transition: ProjectRootTransition,
334    expected_root_identity: Option<&CanonicalProjectRoot>,
335    expected_identity: Option<ProjectInstanceId>,
336    destination: &CanonicalProjectRoot,
337) -> DbResult<ProjectRootTransitionResult> {
338    store.connection.execute_batch("BEGIN IMMEDIATE")?;
339    let operation = apply_root_transition_in_transaction(
340        store,
341        transition,
342        expected_root_identity,
343        expected_identity,
344        destination,
345    );
346    match operation {
347        Ok(result) => {
348            if let Err(source) = store.connection.execute_batch("COMMIT") {
349                return Err(schema::rollback_after_error(
350                    &store.connection,
351                    DbError::Sqlite(source),
352                ));
353            }
354            store.validated_project_root = destination.display_string().ok();
355            store.validated_project_root_identity = Some(destination.clone());
356            store.validated_project_instance_id = Some(result.project_instance_id);
357            if result.identity_changed {
358                store.library_usage_instances.get_mut().clear();
359            }
360            Ok(result)
361        }
362        Err(error) => Err(schema::rollback_after_error(&store.connection, error)),
363    }
364}
365
366/// Apply a move or detach while the caller owns the write transaction.
367fn apply_root_transition_in_transaction(
368    store: &mut AtlasStore,
369    transition: ProjectRootTransition,
370    expected_root_identity: Option<&CanonicalProjectRoot>,
371    expected_identity: Option<ProjectInstanceId>,
372    destination: &CanonicalProjectRoot,
373) -> DbResult<ProjectRootTransitionResult> {
374    schema::validate_current_schema_version(&store.connection)?;
375    let found_root = store.project_root()?;
376    let found_root_identity = load_project_root_identity(&store.connection)?;
377    let found_identity = load_project_identity(&store.connection)?;
378    if found_root_identity.as_ref() != expected_root_identity || found_identity != expected_identity
379    {
380        return Err(project_transition_changed(
381            expected_root_identity.map(CanonicalProjectRoot::display_string_lossy),
382            found_root,
383            expected_identity,
384            found_identity,
385        ));
386    }
387
388    if transition == ProjectRootTransition::Detach
389        && let Some(previous_identity) = found_identity
390    {
391        crate::telemetry::seal_project_usage_instances(&store.connection, previous_identity)?;
392    }
393    set_project_root_metadata(&store.connection, destination)?;
394    set_project_root_identity(&store.connection, destination)?;
395    schema::invalidate_derived_publication(&store.connection)?;
396    let (project_instance_id, identity_changed) = match transition {
397        ProjectRootTransition::Bind => unreachable!("bind does not use transition mutation"),
398        ProjectRootTransition::AdoptLegacy => return Err(DbError::LegacyRootAdoptionUnavailable),
399        ProjectRootTransition::Move => {
400            let (identity, identity_changed) = ensure_project_identity(&store.connection)?;
401            set_graph_generation(&store.connection, IndexGeneration::ZERO)?;
402            (identity, identity_changed)
403        }
404        ProjectRootTransition::Detach => {
405            store
406                .connection
407                .execute("DELETE FROM graph_resolution_keys", [])?;
408            store.connection.execute("DELETE FROM graph_coverage", [])?;
409            store
410                .connection
411                .execute("DELETE FROM graph_identity_rejections", [])?;
412            store
413                .connection
414                .execute("DELETE FROM graph_relations", [])?;
415            store.connection.execute("DELETE FROM graph_entities", [])?;
416            let identity = generate_project_identity(&store.connection, found_identity)?;
417            set_project_identity(&store.connection, identity)?;
418            (identity, true)
419        }
420    };
421
422    Ok(ProjectRootTransitionResult {
423        transition,
424        previous_root: expected_root_identity.and_then(|root| root.display_string().ok()),
425        project_root: destination.display_string().ok(),
426        project_instance_id,
427        identity_changed,
428        publication_invalidated: true,
429    })
430}
431
432/// Prove the recorded old root path entry is absent without following links.
433fn verify_root_absent(root: &CanonicalProjectRoot) -> DbResult<()> {
434    classify_root_absence(
435        &root.display_string_lossy(),
436        fs::symlink_metadata(root.as_path()).map(|_| ()),
437    )
438}
439
440/// Classify a non-following filesystem probe without weakening uncertain failures.
441fn classify_root_absence(root: &str, result: std::io::Result<()>) -> DbResult<()> {
442    match result {
443        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
444        Ok(()) => Err(DbError::ProjectRootStillPresent {
445            root: root.to_string(),
446        }),
447        Err(source) => Err(DbError::ProjectRootAbsenceUncertain {
448            root: root.to_string(),
449            source,
450        }),
451    }
452}
453
454/// Load a current database's native project-root identity without mutation.
455fn read_current_project_root_identity(path: &Path) -> DbResult<Option<CanonicalProjectRoot>> {
456    let (connection, _) = schema::open_current_read_only(path, None)?;
457    load_project_root_identity(&connection)
458}
459
460/// Read and validate the project singleton identity.
461pub(crate) fn load_project_identity(
462    connection: &Connection,
463) -> DbResult<Option<ProjectInstanceId>> {
464    let bytes = connection
465        .query_row(
466            "SELECT project_instance_id FROM project_identity WHERE singleton = 1",
467            [],
468            |row| row.get::<_, Vec<u8>>(0),
469        )
470        .optional()?;
471    bytes.map(project_identity_from_blob).transpose()
472}
473
474/// Load the lossless native project-root identity owned by the database.
475pub(crate) fn load_project_root_identity(
476    connection: &Connection,
477) -> DbResult<Option<CanonicalProjectRoot>> {
478    let encoded = connection
479        .query_row(
480            "SELECT codec_version, root FROM project_root_identity WHERE singleton = 1",
481            [],
482            |row| {
483                let version = row.get::<_, i64>(0)?;
484                let root = row.get::<_, Vec<u8>>(1)?;
485                Ok((version, root))
486            },
487        )
488        .optional()?;
489    encoded
490        .map(|(version, root)| {
491            if version
492                != i64::from(projectatlas_core::project_root::CANONICAL_PROJECT_ROOT_CODEC_VERSION)
493            {
494                return Err(DbError::ProjectRootIdentity(
495                    projectatlas_core::CoreError::CanonicalProjectRootCodec {
496                        reason: "unsupported codec version",
497                    },
498                ));
499            }
500            CanonicalProjectRoot::decode(&root).map_err(DbError::ProjectRootIdentity)
501        })
502        .transpose()
503}
504
505/// Insert or replace the native project-root identity in the caller's transaction.
506pub(crate) fn set_project_root_identity(
507    connection: &Connection,
508    identity: &CanonicalProjectRoot,
509) -> DbResult<()> {
510    let encoded = identity.encode()?;
511    connection.execute(
512        "INSERT INTO project_root_identity(singleton, codec_version, root)
513         VALUES(1, ?1, ?2)
514         ON CONFLICT(singleton) DO UPDATE SET
515            codec_version = excluded.codec_version,
516            root = excluded.root",
517        rusqlite::params![
518            i64::from(projectatlas_core::project_root::CANONICAL_PROJECT_ROOT_CODEC_VERSION),
519            encoded,
520        ],
521    )?;
522    Ok(())
523}
524
525/// Re-canonicalize two existing roots and return the fresh selected identity
526/// only when their native paths are exactly equal.
527///
528/// This is intentionally an admission-only proof. It must not be used for a
529/// move's recorded-root absence check: a move has a different contract and
530/// requires the old native path to remain an exact, absent witness.
531pub(crate) fn prove_existing_root_equivalence(
532    selected: &Path,
533    persisted: &Path,
534) -> DbResult<CanonicalProjectRoot> {
535    let selected = CanonicalProjectRoot::from_path(selected)?;
536    let persisted = CanonicalProjectRoot::from_path(persisted)?;
537    if selected.as_path() != persisted.as_path() {
538        return Err(DbError::ProjectRootMismatch {
539            expected: selected.display_string_lossy(),
540            found: persisted.display_string_lossy(),
541            identities: Some(Box::new(ProjectRootMismatchIdentities {
542                expected: selected,
543                found: persisted,
544            })),
545        });
546    }
547    Ok(selected)
548}
549
550/// Validate and atomically repair the root metadata and native identity.
551pub(crate) fn ensure_project_root_identity(
552    connection: &Connection,
553    expected: &CanonicalProjectRoot,
554) -> DbResult<()> {
555    if load_project_identity(connection)?.is_none() {
556        return Err(DbError::ProjectInstanceIdentityMissing);
557    }
558    let metadata = connection
559        .query_row(
560            "SELECT value FROM metadata WHERE key = ?1",
561            [PROJECT_ROOT_KEY],
562            |row| row.get::<_, String>(0),
563        )
564        .optional()?;
565    if let Some(found) = load_project_root_identity(connection)? {
566        let selected = prove_existing_root_equivalence(expected.as_path(), found.as_path())?;
567        if metadata.as_deref() == selected.display_string().ok().as_deref() {
568            schema::validate_current_schema_version(connection)?;
569            return Ok(());
570        }
571    }
572    connection.execute_batch("BEGIN IMMEDIATE")?;
573    let result = ensure_project_root_identity_after_lock(connection, expected);
574    match result {
575        Ok(()) => connection.execute_batch("COMMIT").map_err(Into::into),
576        Err(error) => Err(schema::rollback_after_error(connection, error)),
577    }
578}
579
580/// Recheck required instance identity after the repair transaction acquires its write lock.
581fn ensure_project_root_identity_after_lock(
582    connection: &Connection,
583    expected: &CanonicalProjectRoot,
584) -> DbResult<()> {
585    schema::validate_current_schema_version(connection)?;
586    if load_project_identity(connection)?.is_none() {
587        return Err(DbError::ProjectInstanceIdentityMissing);
588    }
589    ensure_project_root_identity_in_transaction(connection, expected)
590}
591
592/// Repair root identity while the caller already owns the transaction.
593pub(crate) fn ensure_project_root_identity_in_transaction(
594    connection: &Connection,
595    expected: &CanonicalProjectRoot,
596) -> DbResult<()> {
597    let found_metadata = connection
598        .query_row(
599            "SELECT value FROM metadata WHERE key = ?1",
600            [PROJECT_ROOT_KEY],
601            |row| row.get::<_, String>(0),
602        )
603        .optional()?;
604    let found_identity = load_project_root_identity(connection)?;
605    let selected = if let Some(found) = found_identity.as_ref() {
606        prove_existing_root_equivalence(expected.as_path(), found.as_path())?
607    } else {
608        let native_identity_table_exists = connection
609            .query_row(
610                "SELECT 1 FROM sqlite_master
611                  WHERE type = 'table' AND name = 'project_root_identity'",
612                [],
613                |row| row.get::<_, i64>(0),
614            )
615            .optional()?
616            .is_some();
617        if native_identity_table_exists {
618            return Err(DbError::ProjectRootIdentityMissing);
619        }
620        let Some(legacy) = found_metadata.as_deref() else {
621            return Err(DbError::ProjectRootMissing);
622        };
623        prove_existing_root_equivalence(expected.as_path(), Path::new(legacy))?
624    };
625    set_project_root_identity(connection, &selected)?;
626    set_project_root_metadata(connection, &selected)?;
627    Ok(())
628}
629
630/// Keep the legacy text projection only when it is lossless UTF-8.
631///
632/// Native identity remains authoritative for every current binding. An
633/// unrepresentable root clears the compatibility metadata rather than
634/// persisting replacement characters that could name a different directory.
635pub(crate) fn set_project_root_metadata(
636    connection: &Connection,
637    identity: &CanonicalProjectRoot,
638) -> DbResult<()> {
639    if let Ok(display) = identity.display_string() {
640        set_metadata(connection, PROJECT_ROOT_KEY, &display)?;
641    } else {
642        connection.execute("DELETE FROM metadata WHERE key = ?1", [PROJECT_ROOT_KEY])?;
643    }
644    Ok(())
645}
646
647/// Read the typed graph generation owned by the project singleton.
648pub(crate) fn load_graph_generation(connection: &Connection) -> DbResult<Option<IndexGeneration>> {
649    let generation = connection
650        .query_row(
651            "SELECT active_generation FROM project_identity WHERE singleton = 1",
652            [],
653            |row| row.get::<_, i64>(0),
654        )
655        .optional()?;
656    generation
657        .map(|value| {
658            let value = u64::try_from(value).map_err(|source| DbError::InvalidCount {
659                field: "project_identity.active_generation",
660                value,
661                source,
662            })?;
663            Ok(IndexGeneration::new(value))
664        })
665        .transpose()
666}
667
668/// Return whether the project singleton exists and matches the selected identity.
669pub(crate) fn verify_project_identity(
670    connection: &Connection,
671    expected: ProjectInstanceId,
672) -> DbResult<bool> {
673    let Some(found) = load_project_identity(connection)? else {
674        return Ok(false);
675    };
676    require_project_identity(expected, found)?;
677    Ok(true)
678}
679
680/// Require the already-bound destination identity for graph publication.
681pub(crate) fn require_bound_project_identity(
682    connection: &Connection,
683    expected: ProjectInstanceId,
684) -> DbResult<()> {
685    let found =
686        load_project_identity(connection)?.ok_or(DbError::ProjectInstanceIdentityMissing)?;
687    require_project_identity(expected, found)
688}
689
690/// Create a project identity when a bound database has not initialized one yet.
691pub(crate) fn ensure_project_identity(
692    connection: &Connection,
693) -> DbResult<(ProjectInstanceId, bool)> {
694    if let Some(identity) = load_project_identity(connection)? {
695        return Ok((identity, false));
696    }
697    let identity = generate_project_identity(connection, None)?;
698    set_project_identity(connection, identity)?;
699    Ok((identity, true))
700}
701
702/// Set the graph generation after a validated publication or invalidation.
703pub(crate) fn set_graph_generation(
704    connection: &Connection,
705    generation: IndexGeneration,
706) -> DbResult<()> {
707    let generation =
708        i64::try_from(generation.get()).map_err(|_source| DbError::GraphCountOverflow {
709            field: "project_identity.active_generation",
710            value: generation.get(),
711        })?;
712    connection.execute(
713        "UPDATE project_identity SET active_generation = ?1 WHERE singleton = 1",
714        [generation],
715    )?;
716    Ok(())
717}
718
719/// Generate a nonzero SQLite-owned identity distinct from an optional predecessor.
720fn generate_project_identity(
721    connection: &Connection,
722    predecessor: Option<ProjectInstanceId>,
723) -> DbResult<ProjectInstanceId> {
724    for _ in 0..PROJECT_IDENTITY_GENERATION_ATTEMPTS {
725        let bytes =
726            connection.query_row("SELECT randomblob(16)", [], |row| row.get::<_, Vec<u8>>(0))?;
727        if let Ok(identity) = project_identity_from_blob(bytes)
728            && Some(identity) != predecessor
729        {
730            return Ok(identity);
731        }
732    }
733    Err(DbError::ProjectInstanceIdentityGenerationFailed)
734}
735
736/// Insert or replace the singleton identity after dependent graph rows are removed.
737pub(crate) fn set_project_identity(
738    connection: &Connection,
739    identity: ProjectInstanceId,
740) -> DbResult<()> {
741    connection.execute(
742        "INSERT INTO project_identity(singleton, project_instance_id, active_generation)
743         VALUES(1, ?1, 0)
744         ON CONFLICT(singleton) DO UPDATE SET
745            project_instance_id = excluded.project_instance_id,
746            active_generation = 0",
747        [&identity.as_bytes()[..]],
748    )?;
749    Ok(())
750}
751
752/// Convert the fixed persisted identity representation into its domain newtype.
753fn project_identity_from_blob(value: Vec<u8>) -> DbResult<ProjectInstanceId> {
754    let found = value.len();
755    let bytes: [u8; 16] = value
756        .try_into()
757        .map_err(|_value| DbError::InvalidBlobLength {
758            field: "project_identity.project_instance_id",
759            expected: 16,
760            found,
761        })?;
762    ProjectInstanceId::from_bytes(bytes).map_err(Into::into)
763}
764
765/// Require identical project ownership with a typed mismatch diagnostic.
766fn require_project_identity(expected: ProjectInstanceId, found: ProjectInstanceId) -> DbResult<()> {
767    if expected != found {
768        return Err(DbError::GraphProjectIdentityMismatch {
769            expected: expected.to_string(),
770            found: found.to_string(),
771        });
772    }
773    Ok(())
774}
775
776/// Build a typed concurrent-transition failure with both captured states.
777fn project_transition_changed(
778    expected_root: Option<String>,
779    found_root: Option<String>,
780    expected_identity: Option<ProjectInstanceId>,
781    found_identity: Option<ProjectInstanceId>,
782) -> DbError {
783    DbError::ProjectRootTransitionChanged {
784        expected_root,
785        found_root,
786        expected_identity: expected_identity.map(|identity| identity.to_string()),
787        found_identity: found_identity.map(|identity| identity.to_string()),
788    }
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794    use crate::HealthResolution;
795    use projectatlas_core::graph::{
796        CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
797        CoverageState, EntityResolutionKey, EntitySelector, GraphEntity, GraphIdentityText,
798        GraphRelationKind, LogicalRelation, RelationDependencyKey, RelationOccurrence,
799        RelationResolution, RepositoryFilePath, ResolutionKeyDomain, SourceSpan,
800    };
801    use projectatlas_core::symbols::RelationKind;
802    use projectatlas_core::telemetry::usage_from_estimates;
803    use std::error::Error;
804    use std::fmt::Debug;
805    use std::io;
806
807    #[cfg(unix)]
808    #[test]
809    #[allow(clippy::panic_in_result_fn)]
810    fn canonical_root_missing_identity_refuses_even_for_equivalent_alias()
811    -> Result<(), Box<dyn Error>> {
812        use std::os::unix::fs::symlink;
813
814        let temp = tempfile::tempdir()?;
815        let target = temp.path().join("private-var");
816        let alias = temp.path().join("var");
817        fs::create_dir(&target)?;
818        symlink(&target, &alias)?;
819        let database = temp.path().join("projectatlas.db");
820        drop(AtlasStore::open_for_project(&database, &target)?);
821
822        let connection = Connection::open(&database)?;
823        connection.execute(
824            "UPDATE metadata SET value = ?1 WHERE key = ?2",
825            rusqlite::params![normalize_metadata_path(&alias), PROJECT_ROOT_KEY],
826        )?;
827        connection.execute("DELETE FROM project_root_identity", [])?;
828        drop(connection);
829
830        let database_before = fs::read(&database)?;
831        let error = AtlasStore::open_for_project(&database, &alias)
832            .err()
833            .ok_or_else(|| io::Error::other("missing native identity unexpectedly repaired"))?;
834        require(
835            matches!(error, DbError::ProjectRootIdentityMissing),
836            "missing native identity returned the wrong error",
837        )?;
838        require_eq(
839            &fs::read(&database)?,
840            &database_before,
841            "missing native identity database bytes",
842        )?;
843        let connection = Connection::open(&database)?;
844        require_eq(
845            &connection.query_row(
846                "SELECT COUNT(*) FROM project_root_identity WHERE singleton = 1",
847                [],
848                |row| row.get::<_, i64>(0),
849            )?,
850            &0,
851            "missing native identity row",
852        )?;
853        require_eq(
854            &connection.query_row(
855                "SELECT value FROM metadata WHERE key = ?1",
856                [PROJECT_ROOT_KEY],
857                |row| row.get::<_, String>(0),
858            )?,
859            &normalize_metadata_path(&alias),
860            "missing native identity legacy metadata",
861        )?;
862        Ok(())
863    }
864
865    #[cfg(unix)]
866    #[test]
867    #[allow(clippy::panic_in_result_fn)]
868    fn canonical_root_repair_rolls_back_when_identity_write_fails() -> Result<(), Box<dyn Error>> {
869        use std::os::unix::fs::symlink;
870
871        let temp = tempfile::tempdir()?;
872        let target = temp.path().join("private-var");
873        let alias = temp.path().join("var");
874        fs::create_dir(&target)?;
875        symlink(&target, &alias)?;
876        let database = temp.path().join("projectatlas.db");
877        drop(AtlasStore::open_for_project(&database, &target)?);
878        let connection = Connection::open(&database)?;
879        connection.execute(
880            "UPDATE metadata SET value = ?1 WHERE key = ?2",
881            rusqlite::params![normalize_metadata_path(&alias), PROJECT_ROOT_KEY],
882        )?;
883        connection.execute_batch(
884            "CREATE TEMP TRIGGER fail_project_root_identity_update
885             BEFORE UPDATE OF root ON project_root_identity
886             BEGIN SELECT RAISE(ABORT, 'injected root identity failure'); END;",
887        )?;
888        let expected = CanonicalProjectRoot::from_path(&alias)?;
889        let error = ensure_project_root_identity(&connection, &expected)
890            .err()
891            .ok_or_else(|| io::Error::other("repair unexpectedly succeeded"))?;
892        assert!(matches!(error, DbError::Sqlite(_)));
893        let metadata = connection.query_row(
894            "SELECT value FROM metadata WHERE key = ?1",
895            [PROJECT_ROOT_KEY],
896            |row| row.get::<_, String>(0),
897        )?;
898        let identity_rows = connection.query_row(
899            "SELECT COUNT(*) FROM project_root_identity WHERE singleton = 1",
900            [],
901            |row| row.get::<_, i64>(0),
902        )?;
903        assert_eq!(metadata, normalize_metadata_path(&alias));
904        assert_eq!(identity_rows, 1);
905        drop(connection);
906
907        let connection = Connection::open(&database)?;
908        let reopened_metadata = connection.query_row(
909            "SELECT value FROM metadata WHERE key = ?1",
910            [PROJECT_ROOT_KEY],
911            |row| row.get::<_, String>(0),
912        )?;
913        let reopened_identity_rows = connection.query_row(
914            "SELECT COUNT(*) FROM project_root_identity WHERE singleton = 1",
915            [],
916            |row| row.get::<_, i64>(0),
917        )?;
918        assert_eq!(reopened_metadata, normalize_metadata_path(&alias));
919        assert_eq!(reopened_identity_rows, 1);
920        Ok(())
921    }
922
923    #[cfg(unix)]
924    #[test]
925    fn root_repair_rechecks_identity_after_lock_without_mutation() -> Result<(), Box<dyn Error>> {
926        use std::os::unix::fs::symlink;
927
928        let temp = tempfile::tempdir()?;
929        let target = temp.path().join("private-var");
930        let alias = temp.path().join("var");
931        fs::create_dir(&target)?;
932        symlink(&target, &alias)?;
933        let database = temp.path().join("repair-after-lock.db");
934
935        let mut initial = AtlasStore::open_for_project(&database, &target)?;
936        let project = initial
937            .project_instance_id()?
938            .ok_or_else(|| io::Error::other("repair-after-lock fixture identity is missing"))?;
939        seed_authored_and_graph_state(&mut initial, project)?;
940        let publication_before = initial.index_publication()?;
941        let usage_before = initial.usage_events(Some("identity-test"))?;
942        let overview_before = initial.token_overview(Some("identity-test"))?;
943        let generation_before = load_graph_generation(&initial.connection)?;
944        assert_authored_state(&initial)?;
945        assert_usage_report(&initial, true)?;
946        assert_runtime_scope(&initial, project, 1, 0, 1)?;
947        assert_graph_counts(&initial, [2, 1, 1, 1, 1, 1, 1])?;
948
949        initial.connection.execute(
950            "UPDATE metadata SET value = ?1 WHERE key = ?2",
951            rusqlite::params![normalize_metadata_path(&alias), PROJECT_ROOT_KEY],
952        )?;
953        initial
954            .connection
955            .execute_batch("DELETE FROM project_root_identity; PRAGMA wal_checkpoint(TRUNCATE);")?;
956        drop(initial);
957
958        // Establish the same read-only baseline used by the real admission
959        // path before forcing the writer-phase identity loss.
960        drop(AtlasStore::open_read_only(&database)?);
961        let baseline = AtlasStore::open_read_only(&database)?;
962        let metadata_before = baseline.project_root()?;
963        let native_before = baseline.project_root_identity()?;
964        let instance_before = baseline.project_instance_id()?;
965        require_eq(
966            &metadata_before,
967            &Some(normalize_metadata_path(&alias)),
968            "repair-after-lock legacy metadata before fault",
969        )?;
970        require_eq(
971            &native_before,
972            &None,
973            "repair-after-lock native identity before fault",
974        )?;
975        require_eq(
976            &instance_before,
977            &Some(project),
978            "repair-after-lock instance before fault",
979        )?;
980        require_eq(
981            &load_graph_generation(&baseline.connection)?,
982            &generation_before,
983            "repair-after-lock generation before fault",
984        )?;
985        require_eq(
986            &baseline.index_publication()?,
987            &publication_before,
988            "repair-after-lock publication before fault",
989        )?;
990        require_eq(
991            &baseline.usage_events(Some("identity-test"))?,
992            &usage_before,
993            "repair-after-lock usage before fault",
994        )?;
995        require_eq(
996            &baseline.token_overview(Some("identity-test"))?,
997            &overview_before,
998            "repair-after-lock overview before fault",
999        )?;
1000        assert_authored_state(&baseline)?;
1001        assert_usage_report(&baseline, true)?;
1002        assert_runtime_scope(&baseline, project, 1, 0, 1)?;
1003        assert_graph_counts(&baseline, [2, 1, 1, 1, 1, 1, 1])?;
1004        drop(baseline);
1005
1006        let database_before = fs::read(&database)?;
1007        let is_sqlite_sidecar = |name: &str| {
1008            [
1009                "repair-after-lock.db-wal",
1010                "repair-after-lock.db-shm",
1011                "repair-after-lock.db-journal",
1012            ]
1013            .contains(&name)
1014        };
1015        let inventory_before = {
1016            let mut entries = fs::read_dir(temp.path())?
1017                .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
1018                .filter(|entry| match entry {
1019                    Ok(name) => !is_sqlite_sidecar(name),
1020                    Err(_) => true,
1021                })
1022                .collect::<Result<Vec<_>, _>>()?;
1023            entries.sort();
1024            entries
1025        };
1026
1027        let connection = Connection::open(&database)?;
1028        connection.execute_batch("BEGIN IMMEDIATE; PRAGMA defer_foreign_keys = ON;")?;
1029        connection.execute("DELETE FROM project_identity", [])?;
1030        let expected = CanonicalProjectRoot::from_path(&alias)?;
1031        let error = ensure_project_root_identity_after_lock(&connection, &expected)
1032            .err()
1033            .ok_or_else(|| io::Error::other("post-lock root repair unexpectedly succeeded"))?;
1034        require(
1035            matches!(error, DbError::ProjectInstanceIdentityMissing),
1036            "post-lock root repair returned the wrong error",
1037        )?;
1038        let rollback_error = schema::rollback_after_error(&connection, error);
1039        require(
1040            matches!(rollback_error, DbError::ProjectInstanceIdentityMissing),
1041            "post-lock rollback changed the initiating error",
1042        )?;
1043        drop(connection);
1044
1045        require_eq(
1046            &fs::read(&database)?,
1047            &database_before,
1048            "post-lock root repair database bytes",
1049        )?;
1050        let mut inventory_after = fs::read_dir(temp.path())?
1051            .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
1052            .filter(|entry| match entry {
1053                Ok(name) => !is_sqlite_sidecar(name),
1054                Err(_) => true,
1055            })
1056            .collect::<Result<Vec<_>, _>>()?;
1057        inventory_after.sort();
1058        require_eq(
1059            &inventory_after,
1060            &inventory_before,
1061            "post-lock root repair inventory",
1062        )?;
1063
1064        let reopened = AtlasStore::open_read_only(&database)?;
1065        require_eq(
1066            &reopened.project_root()?,
1067            &metadata_before,
1068            "post-lock root repair legacy metadata",
1069        )?;
1070        require_eq(
1071            &reopened.project_root_identity()?,
1072            &native_before,
1073            "post-lock root repair native identity",
1074        )?;
1075        require_eq(
1076            &reopened.project_instance_id()?,
1077            &instance_before,
1078            "post-lock root repair instance",
1079        )?;
1080        require_eq(
1081            &load_graph_generation(&reopened.connection)?,
1082            &generation_before,
1083            "post-lock root repair generation",
1084        )?;
1085        require_eq(
1086            &reopened.index_publication()?,
1087            &publication_before,
1088            "post-lock root repair publication",
1089        )?;
1090        require_eq(
1091            &reopened.usage_events(Some("identity-test"))?,
1092            &usage_before,
1093            "post-lock root repair usage",
1094        )?;
1095        require_eq(
1096            &reopened.token_overview(Some("identity-test"))?,
1097            &overview_before,
1098            "post-lock root repair overview",
1099        )?;
1100        assert_authored_state(&reopened)?;
1101        assert_usage_report(&reopened, true)?;
1102        assert_runtime_scope(&reopened, project, 1, 0, 1)?;
1103        assert_graph_counts(&reopened, [2, 1, 1, 1, 1, 1, 1])?;
1104        Ok(())
1105    }
1106
1107    #[test]
1108    fn current_no_repair_open_rechecks_schema_after_preflight() -> Result<(), Box<dyn Error>> {
1109        let temp = tempfile::tempdir()?;
1110        let root = temp.path().join("schema-race-root");
1111        fs::create_dir(&root)?;
1112        let database = temp.path().join("schema-race.db");
1113        drop(AtlasStore::open_for_project(&database, &root)?);
1114        let expected = CanonicalProjectRoot::from_path(&root)?;
1115
1116        let stale = Connection::open(&database)?;
1117        let (preflight, _) = schema::preflight_for_project(&database, &expected)?;
1118        require_eq(
1119            &preflight.state,
1120            &SchemaState::Current,
1121            "schema-race preflight",
1122        )?;
1123        let updater = Connection::open(&database)?;
1124        let future_schema = schema::SCHEMA_VERSION + 1;
1125        updater.execute(
1126            "UPDATE metadata SET value = ?2 WHERE key = ?1",
1127            rusqlite::params![schema::SCHEMA_VERSION_KEY, future_schema.to_string()],
1128        )?;
1129        drop(updater);
1130        let before = fs::read(&database)?;
1131
1132        let repair_error = ensure_project_root_identity(&stale, &expected)
1133            .err()
1134            .ok_or_else(|| io::Error::other("stale no-repair open unexpectedly succeeded"))?;
1135        require(
1136            matches!(
1137                repair_error,
1138                DbError::SchemaVersion { found, expected }
1139                    if found == future_schema && expected == schema::SCHEMA_VERSION
1140            ),
1141            "stale no-repair open returned the wrong error",
1142        )?;
1143        require_eq(
1144            &fs::read(&database)?,
1145            &before,
1146            "stale no-repair repair mutation",
1147        )?;
1148
1149        let revalidation_error = schema::revalidate_current_native_binding(&stale, &expected, true)
1150            .err()
1151            .ok_or_else(|| io::Error::other("stale revalidation unexpectedly succeeded"))?;
1152        require(
1153            matches!(
1154                revalidation_error,
1155                DbError::SchemaVersion { found, expected }
1156                    if found == future_schema && expected == schema::SCHEMA_VERSION
1157            ),
1158            "stale revalidation returned the wrong error",
1159        )?;
1160        require_eq(
1161            &fs::read(&database)?,
1162            &before,
1163            "stale revalidation mutation",
1164        )?;
1165        Ok(())
1166    }
1167
1168    #[test]
1169    fn current_root_repair_and_transitions_recheck_schema_before_mutation()
1170    -> Result<(), Box<dyn Error>> {
1171        let temp = tempfile::tempdir()?;
1172        let snapshot = |database: &Path| {
1173            let database_name = database
1174                .file_name()
1175                .ok_or_else(|| io::Error::other("schema snapshot database name is missing"))?
1176                .to_string_lossy()
1177                .into_owned();
1178            let sidecars =
1179                ["-wal", "-shm", "-journal"].map(|suffix| format!("{database_name}{suffix}"));
1180            let mut inventory = fs::read_dir(temp.path())?
1181                .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
1182                .filter(|entry| match entry {
1183                    Ok(name) => !sidecars.iter().any(|sidecar| sidecar == name),
1184                    Err(_) => true,
1185                })
1186                .collect::<Result<Vec<_>, _>>()?;
1187            inventory.sort();
1188            Ok::<_, Box<dyn Error>>((fs::read(database)?, inventory))
1189        };
1190
1191        let repair_root = temp.path().join("repair-root");
1192        fs::create_dir(&repair_root)?;
1193        let repair_database = temp.path().join("repair-schema.db");
1194        let repair_store = AtlasStore::open_for_project(&repair_database, &repair_root)?;
1195        let repair_project = repair_store
1196            .project_instance_id()?
1197            .ok_or_else(|| io::Error::other("repair schema fixture identity is missing"))?;
1198        drop(repair_store);
1199        let repair_connection = Connection::open(&repair_database)?;
1200        repair_connection.execute("DELETE FROM project_root_identity", [])?;
1201        drop(repair_connection);
1202        let repair_expected = CanonicalProjectRoot::from_path(&repair_root)?;
1203        let repair_stale = Connection::open(&repair_database)?;
1204        let (repair_preflight, _) = schema::preflight(&repair_database, None)?;
1205        require_eq(
1206            &repair_preflight.state,
1207            &SchemaState::Current,
1208            "repair schema preflight",
1209        )?;
1210        let repair_updater = Connection::open(&repair_database)?;
1211        let future_schema = schema::SCHEMA_VERSION + 1;
1212        repair_updater.execute(
1213            "UPDATE metadata SET value = ?2 WHERE key = ?1",
1214            rusqlite::params![schema::SCHEMA_VERSION_KEY, future_schema.to_string()],
1215        )?;
1216        drop(repair_updater);
1217        let repair_before = snapshot(&repair_database)?;
1218        let repair_error = ensure_project_root_identity(&repair_stale, &repair_expected)
1219            .err()
1220            .ok_or_else(|| io::Error::other("stale root repair unexpectedly succeeded"))?;
1221        require(
1222            matches!(
1223                repair_error,
1224                DbError::SchemaVersion { found, expected }
1225                    if found == future_schema && expected == schema::SCHEMA_VERSION
1226            ),
1227            "stale root repair returned the wrong error",
1228        )?;
1229        require_eq(
1230            &snapshot(&repair_database)?,
1231            &repair_before,
1232            "stale root repair mutation",
1233        )?;
1234        drop(repair_stale);
1235        let repair_restore = Connection::open(&repair_database)?;
1236        repair_restore.execute(
1237            "UPDATE metadata SET value = ?2 WHERE key = ?1",
1238            rusqlite::params![
1239                schema::SCHEMA_VERSION_KEY,
1240                schema::SCHEMA_VERSION.to_string()
1241            ],
1242        )?;
1243        drop(repair_restore);
1244        let repair_reopened = AtlasStore::open_read_only(&repair_database)?;
1245        require_eq(
1246            &repair_reopened.project_root_identity()?,
1247            &None,
1248            "stale root repair native identity",
1249        )?;
1250        require_eq(
1251            &repair_reopened.project_instance_id()?,
1252            &Some(repair_project),
1253            "stale root repair project identity",
1254        )?;
1255
1256        for (name, transition) in [
1257            ("detach", ProjectRootTransition::Detach),
1258            ("move", ProjectRootTransition::Move),
1259        ] {
1260            let source = temp.path().join(format!("{name}-source"));
1261            let destination = temp.path().join(format!("{name}-destination"));
1262            fs::create_dir(&source)?;
1263            fs::create_dir(&destination)?;
1264            let database = temp.path().join(format!("{name}-schema.db"));
1265            let mut seeded = AtlasStore::open_for_project(&database, &source)?;
1266            let project = seeded
1267                .project_instance_id()?
1268                .ok_or_else(|| io::Error::other("transition schema fixture identity is missing"))?;
1269            seed_authored_and_graph_state(&mut seeded, project)?;
1270            let source_identity = seeded
1271                .project_root_identity()?
1272                .ok_or_else(|| io::Error::other("transition schema fixture root is missing"))?;
1273            drop(seeded);
1274            let mut stale = AtlasStore::open_for_project(&database, &source)?;
1275            if transition == ProjectRootTransition::Move {
1276                fs::remove_dir(&source)?;
1277            }
1278            let updater = Connection::open(&database)?;
1279            updater.execute(
1280                "UPDATE metadata SET value = ?2 WHERE key = ?1",
1281                rusqlite::params![schema::SCHEMA_VERSION_KEY, future_schema.to_string()],
1282            )?;
1283            drop(updater);
1284            let before = snapshot(&database)?;
1285            let error = apply_root_transition(
1286                &mut stale,
1287                transition,
1288                Some(&source_identity),
1289                Some(project),
1290                &CanonicalProjectRoot::from_path(&destination)?,
1291            )
1292            .err()
1293            .ok_or_else(|| io::Error::other("stale transition unexpectedly succeeded"))?;
1294            require(
1295                matches!(
1296                    error,
1297                    DbError::SchemaVersion { found, expected }
1298                        if found == future_schema && expected == schema::SCHEMA_VERSION
1299                ),
1300                "stale transition returned the wrong error",
1301            )?;
1302            require_eq(&snapshot(&database)?, &before, "stale transition mutation")?;
1303            drop(stale);
1304            let restore = Connection::open(&database)?;
1305            restore.execute(
1306                "UPDATE metadata SET value = ?2 WHERE key = ?1",
1307                rusqlite::params![
1308                    schema::SCHEMA_VERSION_KEY,
1309                    schema::SCHEMA_VERSION.to_string()
1310                ],
1311            )?;
1312            drop(restore);
1313            let reopened = AtlasStore::open_read_only(&database)?;
1314            require_eq(
1315                &reopened.project_root_identity()?,
1316                &Some(source_identity),
1317                "stale transition native identity",
1318            )?;
1319            require_eq(
1320                &reopened.project_instance_id()?,
1321                &Some(project),
1322                "stale transition project identity",
1323            )?;
1324            assert_authored_state(&reopened)?;
1325            assert_usage_report(&reopened, true)?;
1326            assert_graph_counts(&reopened, [2, 1, 1, 1, 1, 1, 1])?;
1327        }
1328        Ok(())
1329    }
1330
1331    #[cfg(unix)]
1332    #[test]
1333    #[allow(clippy::panic_in_result_fn)]
1334    fn root_bind_accepts_equivalent_native_alias() -> Result<(), Box<dyn Error>> {
1335        use std::os::unix::fs::symlink;
1336
1337        let temp = tempfile::tempdir()?;
1338        let target = temp.path().join("private-var");
1339        let alias = temp.path().join("var");
1340        fs::create_dir(&target)?;
1341        symlink(&target, &alias)?;
1342        let database = temp.path().join("projectatlas.db");
1343        let initial = AtlasStore::open_for_project(&database, &target)?;
1344        let project = initial
1345            .project_instance_id()?
1346            .ok_or_else(|| io::Error::other("project identity missing"))?;
1347        drop(initial);
1348        let connection = Connection::open(&database)?;
1349        connection.execute(
1350            "UPDATE metadata SET value = ?1 WHERE key = ?2",
1351            rusqlite::params![normalize_metadata_path(&alias), PROJECT_ROOT_KEY],
1352        )?;
1353        drop(connection);
1354
1355        let rebound =
1356            AtlasStore::transition_project_root(&database, &alias, ProjectRootTransition::Bind)?;
1357        assert_eq!(rebound.project_instance_id, project);
1358        assert!(!rebound.identity_changed);
1359        Ok(())
1360    }
1361
1362    #[test]
1363    fn root_transitions_preserve_authored_state_and_isolate_copies() -> Result<(), Box<dyn Error>> {
1364        let temp = tempfile::tempdir()?;
1365        let root_a = temp.path().join("source-Δ");
1366        let root_b = temp.path().join("detached-copy");
1367        let root_c = temp.path().join("moved-source");
1368        fs::create_dir_all(&root_a)?;
1369        fs::create_dir_all(&root_b)?;
1370        fs::create_dir_all(&root_c)?;
1371        let source_db = temp.path().join("source.db");
1372        let copy_db = temp.path().join("copy.db");
1373        let rollback_db = temp.path().join("rollback.db");
1374
1375        let bound =
1376            AtlasStore::transition_project_root(&source_db, &root_a, ProjectRootTransition::Bind)?;
1377        require(
1378            bound.previous_root.is_none(),
1379            "fresh bind had a previous root",
1380        )?;
1381        require(bound.identity_changed, "fresh bind did not create identity")?;
1382        require(
1383            !bound.publication_invalidated,
1384            "fresh bind invalidated publication",
1385        )?;
1386        let source_identity = bound.project_instance_id;
1387        let rebound =
1388            AtlasStore::transition_project_root(&source_db, &root_a, ProjectRootTransition::Bind)?;
1389        require_eq(
1390            &rebound.project_instance_id,
1391            &source_identity,
1392            "same-root bind identity",
1393        )?;
1394        require(!rebound.identity_changed, "same-root bind changed identity")?;
1395
1396        let mut source = AtlasStore::open_for_project(&source_db, &root_a)?;
1397        seed_authored_and_graph_state(&mut source, source_identity)?;
1398        let foreign_identity = ProjectInstanceId::from_bytes([0x7a; 16])?;
1399        let foreign_project = GraphEntity::new(
1400            foreign_identity,
1401            EntitySelector::Project,
1402            IndexGeneration::new(2),
1403        )?;
1404        let mut rejected_publication = source.begin_index_publication("foreign-project")?;
1405        let foreign_error = require_error(
1406            rejected_publication.replace_repository_graph(
1407                foreign_identity,
1408                &[foreign_project],
1409                &[],
1410                &[],
1411                &[],
1412            ),
1413            "foreign graph publication replaced destination identity",
1414        )?;
1415        require(
1416            matches!(foreign_error, DbError::GraphProjectIdentityMismatch { .. }),
1417            "foreign graph publication returned the wrong error",
1418        )?;
1419        drop(rejected_publication);
1420        source
1421            .connection
1422            .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
1423        drop(source);
1424        fs::copy(&source_db, &copy_db)?;
1425        fs::copy(&source_db, &rollback_db)?;
1426        let copied_bytes = fs::read(&copy_db)?;
1427
1428        let invalid_file_root = temp.path().join("not-a-project-directory");
1429        let missing_root = temp.path().join("missing-project-root");
1430        fs::write(&invalid_file_root, "not a directory")?;
1431        for (destination, label) in [
1432            (Path::new("relative-project-root"), "relative destination"),
1433            (missing_root.as_path(), "missing destination"),
1434            (invalid_file_root.as_path(), "file destination"),
1435        ] {
1436            let invalid_error = require_error(
1437                AtlasStore::transition_project_root(
1438                    &copy_db,
1439                    destination,
1440                    ProjectRootTransition::Detach,
1441                ),
1442                "invalid transition destination was accepted",
1443            )?;
1444            require(
1445                matches!(invalid_error, DbError::ProjectRootDestinationInvalid { .. }),
1446                "invalid destination returned the wrong error",
1447            )?;
1448            assert_database_unchanged(&copy_db, &copied_bytes, label)?;
1449        }
1450
1451        let bind_error = require_error(
1452            AtlasStore::transition_project_root(&copy_db, &root_b, ProjectRootTransition::Bind),
1453            "copied database accepted an implicit rebind",
1454        )?;
1455        require(
1456            matches!(bind_error, DbError::ProjectRootMismatch { .. }),
1457            "copied database bind returned the wrong error",
1458        )?;
1459        assert_database_unchanged(&copy_db, &copied_bytes, "rejected bind database")?;
1460        let move_error = require_error(
1461            AtlasStore::transition_project_root(&copy_db, &root_b, ProjectRootTransition::Move),
1462            "copy preserved identity while original root was accessible",
1463        )?;
1464        require(
1465            matches!(move_error, DbError::ProjectRootStillPresent { .. }),
1466            "accessible-root move returned the wrong error",
1467        )?;
1468        assert_database_unchanged(&copy_db, &copied_bytes, "rejected move database")?;
1469        let rejected_store = AtlasStore::open_read_only_for_project(&copy_db, &root_a)?;
1470        require_eq(
1471            &rejected_store.project_instance_id()?,
1472            &Some(source_identity),
1473            "rejected transition identity",
1474        )?;
1475        assert_authored_state(&rejected_store)?;
1476        assert_usage_report(&rejected_store, true)?;
1477        assert_runtime_scope(&rejected_store, source_identity, 1, 0, 1)?;
1478        assert_graph_counts(&rejected_store, [2, 1, 1, 1, 1, 1, 1])?;
1479        require(
1480            rejected_store.index_publication()?.is_some(),
1481            "rejected transition invalidated publication",
1482        )?;
1483        drop(rejected_store);
1484
1485        let regular_root = temp.path().join("former-root-now-file");
1486        let regular_db = temp.path().join("regular-root.db");
1487        fs::create_dir(&regular_root)?;
1488        AtlasStore::transition_project_root(
1489            &regular_db,
1490            &regular_root,
1491            ProjectRootTransition::Bind,
1492        )?;
1493        fs::remove_dir(&regular_root)?;
1494        fs::write(&regular_root, "the old root path still exists")?;
1495        let regular_bytes = fs::read(&regular_db)?;
1496        let regular_error = require_error(
1497            AtlasStore::transition_project_root(&regular_db, &root_c, ProjectRootTransition::Move),
1498            "regular file at the old root was treated as absent",
1499        )?;
1500        require(
1501            matches!(regular_error, DbError::ProjectRootStillPresent { .. }),
1502            "regular-file move returned the wrong error",
1503        )?;
1504        assert_database_unchanged(&regular_db, &regular_bytes, "regular-file move database")?;
1505
1506        let link_root = temp.path().join("former-root-now-link");
1507        let missing_link_target = temp.path().join("missing-link-target");
1508        let link_db = temp.path().join("linked-root.db");
1509        fs::create_dir(&link_root)?;
1510        AtlasStore::transition_project_root(&link_db, &link_root, ProjectRootTransition::Bind)?;
1511        fs::remove_dir(&link_root)?;
1512        if create_dangling_directory_link(&missing_link_target, &link_root)? {
1513            let link_bytes = fs::read(&link_db)?;
1514            let link_error = require_error(
1515                AtlasStore::transition_project_root(&link_db, &root_c, ProjectRootTransition::Move),
1516                "dangling root link was treated as absent",
1517            )?;
1518            require(
1519                matches!(link_error, DbError::ProjectRootStillPresent { .. }),
1520                "dangling-link move returned the wrong error",
1521            )?;
1522            assert_database_unchanged(&link_db, &link_bytes, "dangling-link move database")?;
1523            fs::remove_file(&link_root)?;
1524        }
1525
1526        let relative_root = temp.path().join("relative-root-source");
1527        let relative_db = temp.path().join("relative-root.db");
1528        fs::create_dir(&relative_root)?;
1529        AtlasStore::transition_project_root(
1530            &relative_db,
1531            &relative_root,
1532            ProjectRootTransition::Bind,
1533        )?;
1534        {
1535            let relative_store = AtlasStore::open(&relative_db)?;
1536            set_metadata(
1537                &relative_store.connection,
1538                PROJECT_ROOT_KEY,
1539                "relative/stored/root",
1540            )?;
1541            relative_store
1542                .connection
1543                .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
1544        }
1545        let relative_bytes = fs::read(&relative_db)?;
1546        let relative_error = require_error(
1547            AtlasStore::transition_project_root(&relative_db, &root_c, ProjectRootTransition::Move),
1548            "non-absolute stored root was treated as a verified move",
1549        )?;
1550        require(
1551            matches!(relative_error, DbError::ProjectRootStillPresent { .. }),
1552            "native stored root returned the wrong error",
1553        )?;
1554        assert_database_unchanged(&relative_db, &relative_bytes, "relative-root move database")?;
1555
1556        let uncertain_error = require_error(
1557            classify_root_absence(
1558                "C:/uncertain-project-root",
1559                Err(std::io::Error::new(
1560                    std::io::ErrorKind::PermissionDenied,
1561                    "injected permission denial",
1562                )),
1563            ),
1564            "permission uncertainty was treated as absence",
1565        )?;
1566        require(
1567            matches!(uncertain_error, DbError::ProjectRootAbsenceUncertain { .. }),
1568            "permission uncertainty returned the wrong error",
1569        )?;
1570
1571        let legacy_db = temp.path().join("legacy-root.db");
1572        let legacy_old_root = temp.path().join("legacy-old-root");
1573        let legacy_destination = temp.path().join("legacy-destination");
1574        fs::create_dir(&legacy_destination)?;
1575        {
1576            let legacy = Connection::open(&legacy_db)?;
1577            schema::create_released_schema_eight(&legacy)?;
1578            set_metadata(
1579                &legacy,
1580                PROJECT_ROOT_KEY,
1581                &normalize_metadata_path(&legacy_old_root),
1582            )?;
1583        }
1584        let legacy_bytes = fs::read(&legacy_db)?;
1585        let legacy_error = require_error(
1586            AtlasStore::transition_project_root(
1587                &legacy_db,
1588                &legacy_destination,
1589                ProjectRootTransition::Move,
1590            ),
1591            "legacy move repaired a missing root without native identity proof",
1592        )?;
1593        require(
1594            matches!(legacy_error, DbError::ProjectRootIdentityMissing),
1595            "legacy missing-root move returned the wrong error",
1596        )?;
1597        assert_database_unchanged(&legacy_db, &legacy_bytes, "legacy missing-root move")?;
1598
1599        let current_missing_identity_db = temp.path().join("current-missing-identity.db");
1600        let current_missing_identity_root = temp.path().join("current-missing-identity-root");
1601        let current_missing_identity_destination =
1602            temp.path().join("current-missing-identity-destination");
1603        fs::create_dir(&current_missing_identity_root)?;
1604        fs::create_dir(&current_missing_identity_destination)?;
1605        AtlasStore::transition_project_root(
1606            &current_missing_identity_db,
1607            &current_missing_identity_root,
1608            ProjectRootTransition::Bind,
1609        )?;
1610        {
1611            let current = AtlasStore::open(&current_missing_identity_db)?;
1612            current
1613                .connection
1614                .execute("DELETE FROM project_identity", [])?;
1615            current
1616                .connection
1617                .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
1618        }
1619        fs::remove_dir(&current_missing_identity_root)?;
1620        let repaired_current_move = AtlasStore::transition_project_root(
1621            &current_missing_identity_db,
1622            &current_missing_identity_destination,
1623            ProjectRootTransition::Move,
1624        )?;
1625        require(
1626            repaired_current_move.identity_changed,
1627            "current bound database move did not report its repaired identity",
1628        )?;
1629        let repaired_current = AtlasStore::open_read_only_for_project(
1630            &current_missing_identity_db,
1631            &current_missing_identity_destination,
1632        )?;
1633        require_eq(
1634            &repaired_current.project_instance_id()?,
1635            &Some(repaired_current_move.project_instance_id),
1636            "current bound database repaired identity",
1637        )?;
1638
1639        let detached =
1640            AtlasStore::transition_project_root(&copy_db, &root_b, ProjectRootTransition::Detach)?;
1641        require(
1642            detached.project_instance_id != source_identity,
1643            "detach preserved copied identity",
1644        )?;
1645        require(detached.identity_changed, "detach did not change identity")?;
1646        require(
1647            detached.publication_invalidated,
1648            "detach did not invalidate publication",
1649        )?;
1650        let detached_store = AtlasStore::open_read_only_for_project(&copy_db, &root_b)?;
1651        require_eq(
1652            &detached_store.project_instance_id()?,
1653            &Some(detached.project_instance_id),
1654            "detached identity",
1655        )?;
1656        assert_authored_state(&detached_store)?;
1657        assert_usage_report(&detached_store, false)?;
1658        assert_runtime_scope(&detached_store, source_identity, 0, 1, 0)?;
1659        assert_graph_counts(&detached_store, [0, 0, 0, 0, 0, 0, 0])?;
1660        require(
1661            detached_store.index_publication()?.is_none(),
1662            "detach retained publication",
1663        )?;
1664
1665        let source_store = AtlasStore::open_read_only_for_project(&source_db, &root_a)?;
1666        require_eq(
1667            &source_store.project_instance_id()?,
1668            &Some(source_identity),
1669            "source identity after copy detach",
1670        )?;
1671        assert_authored_state(&source_store)?;
1672        assert_usage_report(&source_store, true)?;
1673        assert_runtime_scope(&source_store, source_identity, 1, 0, 1)?;
1674        assert_graph_counts(&source_store, [2, 1, 1, 1, 1, 1, 1])?;
1675        drop(source_store);
1676
1677        let mut rollback_store = AtlasStore::open(&rollback_db)?;
1678        let rollback_source_root = CanonicalProjectRoot::from_path(&root_a)?;
1679        let rollback_destination_root = CanonicalProjectRoot::from_path(&root_b)?;
1680        rollback_store.connection.execute_batch(
1681            "CREATE TEMP TRIGGER fail_detach_graph
1682             BEFORE DELETE ON graph_entities
1683             BEGIN SELECT RAISE(ABORT, 'injected detach failure'); END;",
1684        )?;
1685        let rollback_error = require_error(
1686            apply_root_transition(
1687                &mut rollback_store,
1688                ProjectRootTransition::Detach,
1689                Some(&rollback_source_root),
1690                Some(source_identity),
1691                &rollback_destination_root,
1692            ),
1693            "late detach failure committed partial identity state",
1694        )?;
1695        require(
1696            matches!(rollback_error, DbError::Sqlite(_)),
1697            "late detach failure returned the wrong error",
1698        )?;
1699        require_eq(
1700            &rollback_store.project_root()?,
1701            &Some(normalize_metadata_path(&root_a)),
1702            "rollback project root",
1703        )?;
1704        require_eq(
1705            &rollback_store.project_instance_id()?,
1706            &Some(source_identity),
1707            "rollback project identity",
1708        )?;
1709        assert_authored_state(&rollback_store)?;
1710        assert_usage_report(&rollback_store, true)?;
1711        assert_runtime_scope(&rollback_store, source_identity, 1, 0, 1)?;
1712        assert_graph_counts(&rollback_store, [2, 1, 1, 1, 1, 1, 1])?;
1713        require(
1714            rollback_store.index_publication()?.is_some(),
1715            "rollback invalidated prior publication",
1716        )?;
1717        drop(rollback_store);
1718
1719        fs::remove_dir(&root_a)?;
1720        let moved =
1721            AtlasStore::transition_project_root(&source_db, &root_c, ProjectRootTransition::Move)?;
1722        require_eq(
1723            &moved.project_instance_id,
1724            &source_identity,
1725            "moved identity",
1726        )?;
1727        require(!moved.identity_changed, "move changed identity")?;
1728        require(
1729            moved.publication_invalidated,
1730            "move did not invalidate publication",
1731        )?;
1732        let moved_store = AtlasStore::open_read_only_for_project(&source_db, &root_c)?;
1733        require_eq(
1734            &moved_store.project_instance_id()?,
1735            &Some(source_identity),
1736            "stored moved identity",
1737        )?;
1738        assert_authored_state(&moved_store)?;
1739        assert_usage_report(&moved_store, true)?;
1740        assert_runtime_scope(&moved_store, source_identity, 1, 0, 1)?;
1741        assert_graph_counts(&moved_store, [2, 1, 1, 1, 1, 1, 1])?;
1742        require(
1743            moved_store.index_publication()?.is_none(),
1744            "move retained publication",
1745        )?;
1746        let active_generation = moved_store.connection.query_row(
1747            "SELECT active_generation FROM project_identity WHERE singleton = 1",
1748            [],
1749            |row| row.get::<_, i64>(0),
1750        )?;
1751        require_eq(&active_generation, &0, "moved graph generation")?;
1752        Ok(())
1753    }
1754
1755    #[cfg(windows)]
1756    #[test]
1757    fn schema_nineteen_detach_migrates_legacy_identity_before_transition()
1758    -> Result<(), Box<dyn Error>> {
1759        let temp = tempfile::tempdir()?;
1760        let source_root = temp.path().join("schema-19-detach-source");
1761        let destination_root = temp.path().join("schema-19-detach-destination");
1762        fs::create_dir(&source_root)?;
1763        fs::create_dir(&destination_root)?;
1764        let database = temp.path().join("schema-19-detach.db");
1765
1766        let mut store = AtlasStore::open_for_project(&database, &source_root)?;
1767        let previous_project = store
1768            .project_instance_id()?
1769            .ok_or_else(|| io::Error::other("schema-19 detach fixture identity is missing"))?;
1770        seed_authored_and_graph_state(&mut store, previous_project)?;
1771        crate::schema::drop_worktree_native_identity_schema(&store.connection)?;
1772        store.connection.execute_batch(
1773            "DROP TABLE project_root_identity;
1774             DROP TABLE IF EXISTS graph_identity_rejections;
1775             UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
1776        )?;
1777        drop(store);
1778
1779        let detached = AtlasStore::transition_project_root(
1780            &database,
1781            &destination_root,
1782            ProjectRootTransition::Detach,
1783        )?;
1784        require(
1785            detached.identity_changed && detached.project_instance_id != previous_project,
1786            "schema-19 detach did not rotate the project identity",
1787        )?;
1788        require_eq(
1789            &detached.project_root,
1790            &Some(normalize_metadata_path(&destination_root)),
1791            "schema-19 detach destination",
1792        )?;
1793
1794        let reopened = AtlasStore::open_read_only_for_project(&database, &destination_root)?;
1795        let schema_version = reopened.connection.query_row(
1796            "SELECT value FROM metadata WHERE key = 'schema_version'",
1797            [],
1798            |row| row.get::<_, String>(0),
1799        )?;
1800        require_eq(
1801            &schema_version,
1802            &schema::SCHEMA_VERSION.to_string(),
1803            "schema-19 detach schema",
1804        )?;
1805        require_eq(
1806            &reopened.project_root_identity()?,
1807            &Some(CanonicalProjectRoot::from_path(&destination_root)?),
1808            "schema-19 detach native destination identity",
1809        )?;
1810        require_eq(
1811            &reopened.project_instance_id()?,
1812            &Some(detached.project_instance_id),
1813            "schema-19 detach project identity",
1814        )?;
1815        assert_authored_state(&reopened)?;
1816        assert_usage_report(&reopened, false)?;
1817        assert_runtime_scope(&reopened, previous_project, 0, 1, 0)?;
1818        assert_graph_counts(&reopened, [0, 0, 0, 0, 0, 0, 0])?;
1819        require(
1820            reopened.index_publication()?.is_none(),
1821            "schema-19 detach retained derived publication",
1822        )?;
1823        drop(reopened);
1824
1825        let missing_database = temp.path().join("schema-19-detach-missing.db");
1826        let missing_store = AtlasStore::open_for_project(&missing_database, &source_root)?;
1827        crate::schema::drop_worktree_native_identity_schema(&missing_store.connection)?;
1828        missing_store
1829            .connection
1830            .execute_batch("DROP TABLE project_root_identity; DROP TABLE IF EXISTS graph_identity_rejections; UPDATE metadata SET value = '19' WHERE key = 'schema_version';")?;
1831        drop(missing_store);
1832        let missing_before = fs::read(&missing_database)?;
1833        fs::remove_dir(&source_root)?;
1834        let missing_error = require_error(
1835            AtlasStore::transition_project_root(
1836                &missing_database,
1837                &destination_root,
1838                ProjectRootTransition::Detach,
1839            ),
1840            "schema-19 detach repaired a missing legacy root",
1841        )?;
1842        require(
1843            !matches!(missing_error, DbError::ProjectRootTransitionChanged { .. }),
1844            "schema-19 missing-root detach reached mutable transition validation",
1845        )?;
1846        assert_database_unchanged(
1847            &missing_database,
1848            &missing_before,
1849            "schema-19 missing-root detach",
1850        )?;
1851        Ok(())
1852    }
1853
1854    #[cfg(windows)]
1855    #[test]
1856    fn schema_nineteen_detach_rolls_back_migration_when_transition_fails()
1857    -> Result<(), Box<dyn Error>> {
1858        let temp = tempfile::tempdir()?;
1859        let source_root = temp.path().join("schema-19-atomic-source");
1860        let destination_root = temp.path().join("schema-19-atomic-destination");
1861        fs::create_dir(&source_root)?;
1862        fs::create_dir(&destination_root)?;
1863        let database = temp.path().join("schema-19-atomic.db");
1864
1865        let mut store = AtlasStore::open_for_project(&database, &source_root)?;
1866        let previous_project = store
1867            .project_instance_id()?
1868            .ok_or_else(|| io::Error::other("schema-19 atomic fixture identity is missing"))?;
1869        seed_authored_and_graph_state(&mut store, previous_project)?;
1870        let publication_before = store.index_publication()?;
1871        let usage_before = store.usage_events(Some("identity-test"))?;
1872        let overview_before = store.token_overview(Some("identity-test"))?;
1873        crate::schema::drop_worktree_native_identity_schema(&store.connection)?;
1874        store.connection.execute_batch(
1875            "DROP TABLE project_root_identity;
1876             DROP TABLE IF EXISTS graph_identity_rejections;
1877             UPDATE metadata SET value = '19' WHERE key = 'schema_version';
1878             PRAGMA wal_checkpoint(TRUNCATE);",
1879        )?;
1880
1881        drop(store);
1882        let database_before = fs::read(&database)?;
1883        let sidecars_before = ["-wal", "-shm", "-journal"].map(|suffix| {
1884            fs::read(database.with_file_name(format!("schema-19-atomic.db{suffix}"))).ok()
1885        });
1886        let mut inventory_before = fs::read_dir(temp.path())?
1887            .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
1888            .collect::<Result<Vec<_>, _>>()?;
1889        inventory_before.sort();
1890
1891        let source_identity = CanonicalProjectRoot::from_path(&source_root)?;
1892        let destination_identity = CanonicalProjectRoot::from_path(&destination_root)?;
1893        let mut store = AtlasStore::open_for_root_transition(&database)?;
1894        require(
1895            !store.connection.is_autocommit(),
1896            "schema-19 transition opener committed migration before transition",
1897        )?;
1898        store.connection.execute_batch(
1899            "CREATE TRIGGER fail_destination_root_metadata_update
1900             BEFORE UPDATE OF value ON metadata
1901             WHEN OLD.key = 'project_root' AND NEW.value <> OLD.value
1902             BEGIN SELECT RAISE(ABORT, 'injected destination metadata failure'); END;",
1903        )?;
1904        let transition_error = require_error(
1905            apply_root_transition_in_transaction(
1906                &mut store,
1907                ProjectRootTransition::Detach,
1908                Some(&source_identity),
1909                Some(previous_project),
1910                &destination_identity,
1911            ),
1912            "schema-19 detach committed migration before its transition failure",
1913        )?;
1914        require(
1915            matches!(transition_error, DbError::Sqlite(_)),
1916            "schema-19 detach failure returned the wrong error",
1917        )?;
1918        let rollback_error = schema::rollback_after_error(&store.connection, transition_error);
1919        require(
1920            matches!(rollback_error, DbError::Sqlite(_)),
1921            "schema-19 detach rollback changed the initiating error",
1922        )?;
1923        let schema_version = store.connection.query_row(
1924            "SELECT value FROM metadata WHERE key = 'schema_version'",
1925            [],
1926            |row| row.get::<_, String>(0),
1927        )?;
1928        require_eq(
1929            &schema_version,
1930            &"19".to_string(),
1931            "schema-19 detach failed schema marker",
1932        )?;
1933        let native_identity_table = store.connection.query_row(
1934            "SELECT COUNT(*) FROM sqlite_master
1935             WHERE type = 'table' AND name = 'project_root_identity'",
1936            [],
1937            |row| row.get::<_, i64>(0),
1938        )?;
1939        require_eq(
1940            &native_identity_table,
1941            &0,
1942            "schema-19 detach failed native identity table",
1943        )?;
1944        require_eq(
1945            &store.project_root()?,
1946            &Some(normalize_metadata_path(&source_root)),
1947            "schema-19 detach failed source metadata",
1948        )?;
1949        require_eq(
1950            &store.project_instance_id()?,
1951            &Some(previous_project),
1952            "schema-19 detach failed project identity",
1953        )?;
1954        require_eq(
1955            &store.index_publication()?,
1956            &publication_before,
1957            "schema-19 detach failed publication",
1958        )?;
1959        require_eq(
1960            &store.usage_events(Some("identity-test"))?,
1961            &usage_before,
1962            "schema-19 detach failed usage",
1963        )?;
1964        require_eq(
1965            &store.token_overview(Some("identity-test"))?,
1966            &overview_before,
1967            "schema-19 detach failed telemetry overview",
1968        )?;
1969        assert_authored_state(&store)?;
1970        assert_usage_report(&store, true)?;
1971        assert_runtime_scope(&store, previous_project, 1, 0, 1)?;
1972        assert_graph_counts(&store, [2, 1, 1, 1, 1, 1, 1])?;
1973        let trigger_count = store.connection.query_row(
1974            "SELECT COUNT(*) FROM sqlite_master
1975             WHERE type = 'trigger' AND name = 'fail_destination_root_metadata_update'",
1976            [],
1977            |row| row.get::<_, i64>(0),
1978        )?;
1979        require_eq(&trigger_count, &0, "schema-19 detach rollback trigger")?;
1980        drop(store);
1981        require_eq(
1982            &fs::read(&database)?,
1983            &database_before,
1984            "schema-19 detach failed database bytes",
1985        )?;
1986        let sidecars_after = ["-wal", "-shm", "-journal"].map(|suffix| {
1987            fs::read(database.with_file_name(format!("schema-19-atomic.db{suffix}"))).ok()
1988        });
1989        require_eq(
1990            &sidecars_after,
1991            &sidecars_before,
1992            "schema-19 detach failed sidecars",
1993        )?;
1994        let mut inventory_after = fs::read_dir(temp.path())?
1995            .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
1996            .collect::<Result<Vec<_>, _>>()?;
1997        inventory_after.sort();
1998        require_eq(
1999            &inventory_after,
2000            &inventory_before,
2001            "schema-19 detach failed inventory",
2002        )?;
2003
2004        let detached = AtlasStore::transition_project_root(
2005            &database,
2006            &destination_root,
2007            ProjectRootTransition::Detach,
2008        )?;
2009        require(
2010            detached.identity_changed && detached.project_instance_id != previous_project,
2011            "schema-19 detach retry did not rotate the project identity",
2012        )?;
2013        let reopened = AtlasStore::open_read_only_for_project(&database, &destination_root)?;
2014        require_eq(
2015            &reopened.project_root_identity()?,
2016            &Some(CanonicalProjectRoot::from_path(&destination_root)?),
2017            "schema-19 detach retry native destination",
2018        )?;
2019        Ok(())
2020    }
2021
2022    #[cfg(windows)]
2023    #[test]
2024    fn schema_nineteen_same_root_bind_reports_preserved_identity_and_state()
2025    -> Result<(), Box<dyn Error>> {
2026        let temp = tempfile::tempdir()?;
2027        let root = temp.path().join("schema-19-bind-root");
2028        let database = temp.path().join("schema-19-bind.db");
2029        fs::create_dir(&root)?;
2030
2031        let mut store = AtlasStore::open_for_project(&database, &root)?;
2032        let project = store
2033            .project_instance_id()?
2034            .ok_or_else(|| io::Error::other("schema-19 bind fixture identity is missing"))?;
2035        seed_authored_and_graph_state(&mut store, project)?;
2036        let publication = store.index_publication()?;
2037        assert_authored_state(&store)?;
2038        assert_usage_report(&store, true)?;
2039        assert_runtime_scope(&store, project, 1, 0, 1)?;
2040        assert_graph_counts(&store, [2, 1, 1, 1, 1, 1, 1])?;
2041        crate::schema::drop_worktree_native_identity_schema(&store.connection)?;
2042        store.connection.execute_batch(
2043            "DROP TABLE project_root_identity;
2044             DROP TABLE IF EXISTS graph_identity_rejections;
2045             UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
2046        )?;
2047        drop(store);
2048
2049        let bound =
2050            AtlasStore::transition_project_root(&database, &root, ProjectRootTransition::Bind)?;
2051        let expected_display = normalize_metadata_path(&root);
2052        require_eq(
2053            &bound.previous_root,
2054            &Some(expected_display.clone()),
2055            "schema-19 bind previous root",
2056        )?;
2057        require_eq(
2058            &bound.project_root,
2059            &Some(expected_display),
2060            "schema-19 bind destination root",
2061        )?;
2062        require_eq(
2063            &bound.project_instance_id,
2064            &project,
2065            "schema-19 bind identity",
2066        )?;
2067        require(!bound.identity_changed, "schema-19 bind changed identity")?;
2068        require(
2069            !bound.publication_invalidated,
2070            "schema-19 bind invalidated publication",
2071        )?;
2072
2073        let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
2074        require_eq(
2075            &reopened.project_root_identity()?,
2076            &Some(CanonicalProjectRoot::from_path(&root)?),
2077            "schema-19 bind native root",
2078        )?;
2079        require_eq(
2080            &reopened.project_instance_id()?,
2081            &Some(project),
2082            "schema-19 bind reopened identity",
2083        )?;
2084        require_eq(
2085            &reopened.index_publication()?,
2086            &publication,
2087            "schema-19 bind publication",
2088        )?;
2089        assert_authored_state(&reopened)?;
2090        assert_usage_report(&reopened, true)?;
2091        assert_runtime_scope(&reopened, project, 1, 0, 1)?;
2092        assert_graph_counts(&reopened, [2, 1, 1, 1, 1, 1, 1])?;
2093        Ok(())
2094    }
2095
2096    #[cfg(windows)]
2097    #[test]
2098    fn case_only_root_rename_reopens_same_persisted_identity() -> Result<(), Box<dyn Error>> {
2099        let temp = tempfile::tempdir()?;
2100        let original_path = temp.path().join("CaseOnlyRoot");
2101        let staging_path = temp.path().join("CaseOnlyRootStaging");
2102        let renamed_path = temp.path().join("caseonlyroot");
2103        fs::create_dir(&original_path)?;
2104        let database = temp.path().join("case-only-root.db");
2105
2106        let initial = AtlasStore::open_for_project(&database, &original_path)?;
2107        let project = initial
2108            .project_instance_id()?
2109            .ok_or_else(|| io::Error::other("case-only root fixture identity is missing"))?;
2110        let initial_root = initial
2111            .project_root_identity()?
2112            .ok_or_else(|| io::Error::other("case-only root fixture native identity is missing"))?;
2113        drop(initial);
2114
2115        fs::rename(&original_path, &staging_path)?;
2116        fs::rename(&staging_path, &renamed_path)?;
2117        let renamed_root = CanonicalProjectRoot::from_path(&renamed_path)?;
2118        if initial_root.encode()? == renamed_root.encode()? {
2119            return Err("case-only root rename did not retain distinct native spelling".into());
2120        }
2121        // A case-sensitive Windows directory intentionally cannot resolve the
2122        // old spelling; the dedicated refusal test covers that namespace.
2123        let Ok(recanonicalized_root) = CanonicalProjectRoot::from_path(&original_path) else {
2124            return Ok(());
2125        };
2126        require_eq(
2127            &recanonicalized_root,
2128            &renamed_root,
2129            "re-canonicalized case-only root",
2130        )?;
2131
2132        let reopened = AtlasStore::open_for_project(&database, &renamed_path)?;
2133        require_eq(
2134            &reopened.project_instance_id()?,
2135            &Some(project),
2136            "case-only root project identity",
2137        )?;
2138        require_eq(
2139            &reopened.project_root_identity()?,
2140            &Some(renamed_root),
2141            "case-only root native identity",
2142        )?;
2143        require_eq(
2144            &reopened.project_root()?,
2145            &Some(normalize_metadata_path(&renamed_path)),
2146            "case-only root display metadata",
2147        )?;
2148        Ok(())
2149    }
2150
2151    #[cfg(windows)]
2152    #[test]
2153    fn verbatim_root_reopens_file_backed_store_without_identity_drift() -> Result<(), Box<dyn Error>>
2154    {
2155        let temp = tempfile::tempdir()?;
2156        let base = temp
2157            .path()
2158            .to_str()
2159            .ok_or("temporary directory was not UTF-8")?;
2160        let long_component = "a".repeat(220);
2161        let root = std::path::PathBuf::from(format!(r"\\?\{base}\{long_component}"));
2162        fs::create_dir(&root)?;
2163        let database = root.join(".projectatlas/projectatlas.db");
2164        fs::create_dir_all(
2165            database
2166                .parent()
2167                .ok_or("verbatim root database has no parent")?,
2168        )?;
2169
2170        let initial = AtlasStore::open_for_project(&database, &root)?;
2171        let project = initial
2172            .project_instance_id()?
2173            .ok_or_else(|| io::Error::other("verbatim root project identity is missing"))?;
2174        let native_root = initial
2175            .project_root_identity()?
2176            .ok_or_else(|| io::Error::other("verbatim root native identity is missing"))?;
2177        let display = native_root.display_string()?;
2178        if !display.starts_with(r"\\?\") {
2179            return Err("verbatim root display lost its extended prefix".into());
2180        }
2181        drop(initial);
2182
2183        let reopened = AtlasStore::open_for_project(&database, &root)?;
2184        require_eq(
2185            &reopened.project_instance_id()?,
2186            &Some(project),
2187            "verbatim root project identity",
2188        )?;
2189        require_eq(
2190            &reopened.project_root_identity()?,
2191            &Some(native_root),
2192            "verbatim root native identity",
2193        )?;
2194        Ok(())
2195    }
2196
2197    #[cfg(windows)]
2198    #[test]
2199    fn case_sensitive_root_namespace_rejects_distinct_binding_without_mutation()
2200    -> Result<(), Box<dyn Error>> {
2201        use std::process::Command;
2202
2203        let temp = tempfile::tempdir()?;
2204        let case_sensitive_parent = temp.path().join("case-sensitive-parent");
2205        fs::create_dir(&case_sensitive_parent)?;
2206        let enabled = Command::new("fsutil")
2207            .args(["file", "SetCaseSensitiveInfo"])
2208            .arg(&case_sensitive_parent)
2209            .arg("enable")
2210            .status()
2211            .is_ok_and(|status| status.success());
2212        if !enabled {
2213            // Case-sensitive directory support is filesystem/host-policy
2214            // dependent; skip this negative proof when it cannot be enabled.
2215            return Ok(());
2216        }
2217
2218        let upper_path = case_sensitive_parent.join("Repo");
2219        let lower_path = case_sensitive_parent.join("repo");
2220        if fs::create_dir(&upper_path).is_err() || fs::create_dir(&lower_path).is_err() {
2221            return Ok(());
2222        }
2223        let upper_identity = CanonicalProjectRoot::from_path(&upper_path)?;
2224        let lower_identity = CanonicalProjectRoot::from_path(&lower_path)?;
2225        if upper_identity == lower_identity {
2226            return Ok(());
2227        }
2228
2229        let database = temp.path().join("case-sensitive.db");
2230        let mut store = AtlasStore::open_for_project(&database, &upper_path)?;
2231        let project = store
2232            .project_instance_id()?
2233            .ok_or_else(|| io::Error::other("case-sensitive root identity is missing"))?;
2234        seed_authored_and_graph_state(&mut store, project)?;
2235        drop(store);
2236
2237        // Keep one validated read snapshot open so SQLite's WAL shared-memory
2238        // sidecar already exists before the rejected admission is attempted.
2239        // The refusal itself must not create or remove any sidecar.
2240        let read_guard = AtlasStore::open_read_only_for_project(&database, &upper_path)?;
2241        let database_before = fs::read(&database)?;
2242        let sidecars_before = ["-wal", "-shm", "-journal"].map(|suffix| {
2243            fs::read(database.with_file_name(format!("case-sensitive.db{suffix}"))).ok()
2244        });
2245        let mut inventory_before = fs::read_dir(temp.path())?
2246            .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
2247            .collect::<Result<Vec<_>, _>>()?;
2248        inventory_before.sort();
2249
2250        let Some(error) = AtlasStore::open_for_project(&database, &lower_path).err() else {
2251            return Err(io::Error::other(
2252                "case-sensitive sibling root was admitted as the persisted binding",
2253            )
2254            .into());
2255        };
2256        if !matches!(error, DbError::ProjectRootMismatch { .. }) {
2257            return Err(io::Error::other(format!(
2258                "case-sensitive sibling returned the wrong error: {error}"
2259            ))
2260            .into());
2261        }
2262        let database_unchanged = fs::read(&database)? == database_before;
2263        let sidecars_unchanged = ["-wal", "-shm", "-journal"].map(|suffix| {
2264            fs::read(database.with_file_name(format!("case-sensitive.db{suffix}"))).ok()
2265        }) == sidecars_before;
2266        if !database_unchanged || !sidecars_unchanged {
2267            return Err(io::Error::other(format!(
2268                "case-sensitive sibling refusal changed database or sidecar bytes (database_unchanged={database_unchanged}, sidecars_unchanged={sidecars_unchanged})"
2269            ))
2270            .into());
2271        }
2272        let mut inventory_after = fs::read_dir(temp.path())?
2273            .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
2274            .collect::<Result<Vec<_>, _>>()?;
2275        inventory_after.sort();
2276        if inventory_after != inventory_before {
2277            return Err(io::Error::other(
2278                "case-sensitive sibling refusal changed sidecar inventory",
2279            )
2280            .into());
2281        }
2282
2283        let reopened = AtlasStore::open_read_only_for_project(&database, &upper_path)?;
2284        require_eq(
2285            &reopened.project_instance_id()?,
2286            &Some(project),
2287            "case-sensitive root project identity",
2288        )?;
2289        require_eq(
2290            &reopened.project_root_identity()?,
2291            &Some(upper_identity),
2292            "case-sensitive root native identity",
2293        )?;
2294        assert_authored_state(&reopened)?;
2295        assert_usage_report(&reopened, true)?;
2296        assert_runtime_scope(&reopened, project, 1, 0, 1)?;
2297        assert_graph_counts(&reopened, [2, 1, 1, 1, 1, 1, 1])?;
2298        require(
2299            reopened.index_publication()?.is_some(),
2300            "case-sensitive sibling refusal changed publication",
2301        )?;
2302        drop(read_guard);
2303        Ok(())
2304    }
2305
2306    #[cfg(windows)]
2307    #[test]
2308    fn schema_nineteen_case_sensitive_sibling_refuses_before_migration_without_mutation()
2309    -> Result<(), Box<dyn Error>> {
2310        use rusqlite::OpenFlags;
2311        use std::process::Command;
2312
2313        let temp = tempfile::tempdir()?;
2314        let case_sensitive_parent = temp.path().join("schema-19-case-sensitive-parent");
2315        fs::create_dir(&case_sensitive_parent)?;
2316        let enabled = Command::new("fsutil")
2317            .args(["file", "SetCaseSensitiveInfo"])
2318            .arg(&case_sensitive_parent)
2319            .arg("enable")
2320            .status()
2321            .is_ok_and(|status| status.success());
2322        if !enabled {
2323            // Case-sensitive directory support is filesystem/host-policy
2324            // dependent; skip this negative proof when it cannot be enabled.
2325            return Ok(());
2326        }
2327
2328        let upper_path = case_sensitive_parent.join("Repo");
2329        let lower_path = case_sensitive_parent.join("repo");
2330        if fs::create_dir(&upper_path).is_err() || fs::create_dir(&lower_path).is_err() {
2331            return Ok(());
2332        }
2333        let upper_identity = CanonicalProjectRoot::from_path(&upper_path)?;
2334        let lower_identity = CanonicalProjectRoot::from_path(&lower_path)?;
2335        if upper_identity == lower_identity {
2336            return Ok(());
2337        }
2338
2339        let database = temp.path().join("schema-19-case-sensitive.db");
2340        let mut store = AtlasStore::open_for_project(&database, &upper_path)?;
2341        let project = store
2342            .project_instance_id()?
2343            .ok_or_else(|| io::Error::other("schema-19 root fixture identity is missing"))?;
2344        seed_authored_and_graph_state(&mut store, project)?;
2345        let publication_before = store.index_publication()?;
2346        let usage_before = store.usage_events(Some("identity-test"))?;
2347        let overview_before = store.token_overview(Some("identity-test"))?;
2348        assert_authored_state(&store)?;
2349        assert_usage_report(&store, true)?;
2350        assert_runtime_scope(&store, project, 1, 0, 1)?;
2351        assert_graph_counts(&store, [2, 1, 1, 1, 1, 1, 1])?;
2352
2353        crate::schema::drop_worktree_native_identity_schema(&store.connection)?;
2354        store.connection.execute_batch(
2355            "DROP TABLE project_root_identity;
2356             DROP TABLE IF EXISTS graph_identity_rejections;
2357             UPDATE metadata SET value = '19' WHERE key = 'schema_version';
2358             PRAGMA wal_checkpoint(TRUNCATE);",
2359        )?;
2360
2361        let schema_before = store.connection.query_row(
2362            "SELECT value FROM metadata WHERE key = 'schema_version'",
2363            [],
2364            |row| row.get::<_, String>(0),
2365        )?;
2366        let legacy_root_before = store
2367            .connection
2368            .query_row(
2369                "SELECT value FROM metadata WHERE key = 'project_root'",
2370                [],
2371                |row| row.get::<_, String>(0),
2372            )
2373            .optional()?;
2374        let (project_bytes_before, generation_before) = store.connection.query_row(
2375            "SELECT project_instance_id, active_generation
2376             FROM project_identity WHERE singleton = 1",
2377            [],
2378            |row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?)),
2379        )?;
2380        let identity_table_before = store.connection.query_row(
2381            "SELECT COUNT(*) FROM sqlite_master WHERE name = 'project_root_identity'",
2382            [],
2383            |row| row.get::<_, i64>(0),
2384        )?;
2385        require_eq(
2386            &schema_before,
2387            &"19".to_string(),
2388            "schema-19 fixture marker",
2389        )?;
2390        require_eq(
2391            &identity_table_before,
2392            &0,
2393            "schema-19 fixture identity table absence",
2394        )?;
2395
2396        // Keep a read-only connection open while taking the byte and inventory
2397        // snapshots. The rejected legacy admission must not create or remove
2398        // SQLite sidecars while it is still in read-only preflight.
2399        let read_guard = Connection::open_with_flags(&database, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
2400        let database_before = fs::read(&database)?;
2401        let sidecars_before = ["-wal", "-shm", "-journal"].map(|suffix| {
2402            fs::read(database.with_file_name(format!("schema-19-case-sensitive.db{suffix}"))).ok()
2403        });
2404        let mut inventory_before = fs::read_dir(temp.path())?
2405            .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
2406            .collect::<Result<Vec<_>, _>>()?;
2407        inventory_before.sort();
2408
2409        let Some(error) = AtlasStore::open_for_project(&database, &lower_path).err() else {
2410            return Err(io::Error::other(
2411                "schema-19 case-sensitive sibling reached migration or was admitted",
2412            )
2413            .into());
2414        };
2415        if !matches!(error, DbError::ProjectRootMismatch { .. }) {
2416            return Err(io::Error::other(format!(
2417                "schema-19 case-sensitive sibling returned the wrong error: {error}"
2418            ))
2419            .into());
2420        }
2421
2422        require_eq(
2423            &fs::read(&database)?,
2424            &database_before,
2425            "schema-19 case-sensitive refusal database bytes",
2426        )?;
2427        let sidecars_after = ["-wal", "-shm", "-journal"].map(|suffix| {
2428            fs::read(database.with_file_name(format!("schema-19-case-sensitive.db{suffix}"))).ok()
2429        });
2430        require_eq(
2431            &sidecars_after,
2432            &sidecars_before,
2433            "schema-19 case-sensitive refusal sidecar bytes",
2434        )?;
2435        let mut inventory_after = fs::read_dir(temp.path())?
2436            .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
2437            .collect::<Result<Vec<_>, _>>()?;
2438        inventory_after.sort();
2439        require_eq(
2440            &inventory_after,
2441            &inventory_before,
2442            "schema-19 case-sensitive refusal inventory",
2443        )?;
2444
2445        require_eq(
2446            &store.connection.query_row(
2447                "SELECT value FROM metadata WHERE key = 'schema_version'",
2448                [],
2449                |row| row.get::<_, String>(0),
2450            )?,
2451            &schema_before,
2452            "schema-19 refusal schema marker",
2453        )?;
2454        require_eq(
2455            &store.connection.query_row(
2456                "SELECT value FROM metadata WHERE key = 'project_root'",
2457                [],
2458                |row| row.get::<_, Option<String>>(0),
2459            )?,
2460            &legacy_root_before,
2461            "schema-19 refusal legacy root metadata",
2462        )?;
2463        let (project_bytes_after, generation_after) = store.connection.query_row(
2464            "SELECT project_instance_id, active_generation
2465             FROM project_identity WHERE singleton = 1",
2466            [],
2467            |row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?)),
2468        )?;
2469        require_eq(
2470            &project_bytes_after,
2471            &project_bytes_before,
2472            "schema-19 refusal project instance",
2473        )?;
2474        require_eq(
2475            &generation_after,
2476            &generation_before,
2477            "schema-19 refusal generation",
2478        )?;
2479        require_eq(
2480            &store.index_publication()?,
2481            &publication_before,
2482            "schema-19 refusal publication",
2483        )?;
2484        require_eq(
2485            &store.usage_events(Some("identity-test"))?,
2486            &usage_before,
2487            "schema-19 refusal usage events",
2488        )?;
2489        require_eq(
2490            &store.token_overview(Some("identity-test"))?,
2491            &overview_before,
2492            "schema-19 refusal usage overview",
2493        )?;
2494        let identity_table_after = store.connection.query_row(
2495            "SELECT COUNT(*) FROM sqlite_master WHERE name = 'project_root_identity'",
2496            [],
2497            |row| row.get::<_, i64>(0),
2498        )?;
2499        require_eq(
2500            &identity_table_after,
2501            &identity_table_before,
2502            "schema-19 refusal identity table",
2503        )?;
2504        assert_authored_state(&store)?;
2505        assert_usage_report(&store, true)?;
2506        assert_runtime_scope(&store, project, 1, 0, 1)?;
2507        assert_graph_counts(&store, [2, 1, 1, 1, 1, 1, 1])?;
2508        drop(read_guard);
2509        Ok(())
2510    }
2511
2512    #[cfg(unix)]
2513    #[test]
2514    fn non_utf8_root_transitions_use_native_identity_not_display_projection()
2515    -> Result<(), Box<dyn Error>> {
2516        use std::os::unix::ffi::OsStringExt;
2517
2518        let temp = tempfile::tempdir()?;
2519        let native_name = std::ffi::OsString::from_vec(vec![b'r', b'o', b'o', b't', 0x80]);
2520        let root = temp.path().join(&native_name);
2521        let display_collision = temp.path().join("root-�");
2522        let destination_collision = temp.path().join("dest-�");
2523        let destination_native_name =
2524            std::ffi::OsString::from_vec(vec![b'd', b'e', b's', b't', 0x81]);
2525        let destination_native = temp.path().join(&destination_native_name);
2526        fs::create_dir(&root)?;
2527        fs::create_dir(&display_collision)?;
2528        fs::create_dir(&destination_collision)?;
2529        fs::create_dir(&destination_native)?;
2530
2531        let database = temp.path().join("non-utf8-root.db");
2532        let bound =
2533            AtlasStore::transition_project_root(&database, &root, ProjectRootTransition::Bind)?;
2534        require(
2535            bound.project_root.is_none(),
2536            "raw root exposed a lossy transition display",
2537        )?;
2538        let native_identity = CanonicalProjectRoot::from_path(&root)?;
2539        let opened = AtlasStore::open_read_only_for_project(&database, &root)?;
2540        require_eq(
2541            &opened.project_root_identity()?,
2542            &Some(native_identity),
2543            "non-UTF-8 bound native identity",
2544        )?;
2545        require_eq(
2546            &opened.captured_project_binding()?.project_root,
2547            &None,
2548            "non-UTF-8 typed display availability",
2549        )?;
2550        require_eq(
2551            &opened.project_root()?,
2552            &None,
2553            "non-UTF-8 compatibility metadata",
2554        )?;
2555        drop(opened);
2556        let before_collision = fs::read(&database)?;
2557        let collision_error = require_error(
2558            AtlasStore::transition_project_root(
2559                &database,
2560                &display_collision,
2561                ProjectRootTransition::Bind,
2562            ),
2563            "replacement-character root collision was accepted",
2564        )?;
2565        require(
2566            matches!(collision_error, DbError::ProjectRootMismatch { .. }),
2567            "non-UTF-8 root collision returned the wrong error",
2568        )?;
2569        assert_database_unchanged(&database, &before_collision, "non-UTF-8 root collision")?;
2570        crate::verify_project_database(&database, &root)?;
2571
2572        let mut stale = AtlasStore::open_for_project(&database, &root)?;
2573        seed_authored_and_graph_state(&mut stale, bound.project_instance_id)?;
2574        stale
2575            .begin_index_publication("non-utf8-before-move")?
2576            .complete()?;
2577        fs::remove_dir(&root)?;
2578        let moved = AtlasStore::transition_project_root(
2579            &database,
2580            &display_collision,
2581            ProjectRootTransition::Move,
2582        )?;
2583        require_eq(
2584            &moved.project_instance_id,
2585            &bound.project_instance_id,
2586            "non-UTF-8 move identity",
2587        )?;
2588        require_eq(
2589            &moved.previous_root,
2590            &None,
2591            "non-UTF-8 move previous display availability",
2592        )?;
2593        let destination_identity = CanonicalProjectRoot::from_path(&display_collision)?;
2594        let moved_store = AtlasStore::open_read_only_for_project(&database, &display_collision)?;
2595        require_eq(
2596            &moved_store.project_root_identity()?,
2597            &Some(destination_identity),
2598            "non-UTF-8 moved native identity",
2599        )?;
2600        assert_authored_state(&moved_store)?;
2601        assert_usage_report(&moved_store, true)?;
2602        drop(moved_store);
2603        crate::verify_project_database(&database, &display_collision)?;
2604
2605        let before_stale_publication = fs::read(&database)?;
2606        let publication_error = require_error(
2607            stale.begin_index_publication("stale-non-utf8-after-move"),
2608            "stale non-UTF-8 store entered publication after native root move",
2609        )?;
2610        require(
2611            matches!(
2612                publication_error,
2613                DbError::ProjectRootTransitionChanged { .. }
2614            ),
2615            "stale non-UTF-8 publication returned the wrong error",
2616        )?;
2617        assert_database_unchanged(
2618            &database,
2619            &before_stale_publication,
2620            "stale non-UTF-8 publication",
2621        )?;
2622        let destination_state =
2623            AtlasStore::open_read_only_for_project(&database, &display_collision)?;
2624        require(
2625            destination_state.index_publication()?.is_none(),
2626            "stale non-UTF-8 publication mutated derived state",
2627        )?;
2628        drop(destination_state);
2629        drop(stale);
2630
2631        let before_destination_collision = fs::read(&database)?;
2632        let destination_collision_error = require_error(
2633            AtlasStore::transition_project_root(
2634                &database,
2635                &destination_collision,
2636                ProjectRootTransition::Bind,
2637            ),
2638            "replacement-character destination collision was accepted",
2639        )?;
2640        require(
2641            matches!(
2642                destination_collision_error,
2643                DbError::ProjectRootMismatch { .. }
2644            ),
2645            "non-UTF-8 destination collision returned the wrong error",
2646        )?;
2647        assert_database_unchanged(
2648            &database,
2649            &before_destination_collision,
2650            "non-UTF-8 destination collision",
2651        )?;
2652
2653        let detached = AtlasStore::transition_project_root(
2654            &database,
2655            &destination_native,
2656            ProjectRootTransition::Detach,
2657        )?;
2658        require(
2659            detached.project_instance_id != bound.project_instance_id,
2660            "non-UTF-8 detach did not rotate project identity",
2661        )?;
2662        require_eq(
2663            &detached.previous_root,
2664            &Some(normalize_metadata_path(&display_collision)),
2665            "non-UTF-8 detach previous display",
2666        )?;
2667        require(
2668            detached.project_root.is_none(),
2669            "non-UTF-8 detach exposed a lossy destination display",
2670        )?;
2671        let detached_store =
2672            AtlasStore::open_read_only_for_project(&database, &destination_native)?;
2673        require_eq(
2674            &detached_store.project_root_identity()?,
2675            &Some(CanonicalProjectRoot::from_path(&destination_native)?),
2676            "non-UTF-8 detached native identity",
2677        )?;
2678        require_eq(
2679            &detached_store.captured_project_binding()?.project_root,
2680            &None,
2681            "non-UTF-8 detached display availability",
2682        )?;
2683        assert_authored_state(&detached_store)?;
2684        assert_usage_report(&detached_store, false)?;
2685        assert_graph_counts(&detached_store, [0, 0, 0, 0, 0, 0, 0])?;
2686        Ok(())
2687    }
2688
2689    #[cfg(unix)]
2690    #[test]
2691    fn set_project_root_preserves_non_utf8_binding_against_replacement_sibling()
2692    -> Result<(), Box<dyn Error>> {
2693        use std::os::unix::ffi::OsStringExt;
2694
2695        let temp = tempfile::tempdir()?;
2696        let raw_root = temp.path().join(std::ffi::OsString::from_vec(vec![
2697            b'r', b'e', b'p', b'o', 0x80,
2698        ]));
2699        let replacement_root = temp.path().join("repo-�");
2700        fs::create_dir(&raw_root)?;
2701        fs::create_dir(&replacement_root)?;
2702        let database = temp.path().join("set-project-root-non-utf8.db");
2703        let bound =
2704            AtlasStore::transition_project_root(&database, &raw_root, ProjectRootTransition::Bind)?;
2705        let native_identity = CanonicalProjectRoot::from_path(&raw_root)?;
2706        let mut store = AtlasStore::open_for_project(&database, &raw_root)?;
2707        require_eq(
2708            &store.project_root_identity()?,
2709            &Some(native_identity.clone()),
2710            "non-UTF-8 set_project_root native identity",
2711        )?;
2712        require_eq(
2713            &store.project_root()?,
2714            &None,
2715            "non-UTF-8 set_project_root compatibility metadata",
2716        )?;
2717        seed_authored_and_graph_state(&mut store, bound.project_instance_id)?;
2718        let before_database = fs::read(&database)?;
2719        let same_store_error = require_error(
2720            store.set_project_root(&replacement_root),
2721            "set_project_root rebound a non-UTF-8 root to its replacement sibling",
2722        )?;
2723        require(
2724            matches!(same_store_error, DbError::ProjectRootMismatch { .. }),
2725            "same-store non-UTF-8 set_project_root returned the wrong error",
2726        )?;
2727        require_eq(
2728            &store.project_root_identity()?,
2729            &Some(native_identity.clone()),
2730            "same-store native identity after rejected set_project_root",
2731        )?;
2732        require_eq(
2733            &store.project_root()?,
2734            &None,
2735            "same-store metadata after rejected set_project_root",
2736        )?;
2737        require_eq(
2738            &store.project_instance_id()?,
2739            &Some(bound.project_instance_id),
2740            "same-store project identity after rejected set_project_root",
2741        )?;
2742        assert_authored_state(&store)?;
2743        assert_usage_report(&store, true)?;
2744        assert_graph_counts(&store, [2, 1, 1, 1, 1, 1, 1])?;
2745        assert_database_unchanged(
2746            &database,
2747            &before_database,
2748            "same-store rejected non-UTF-8 set_project_root",
2749        )?;
2750        drop(store);
2751
2752        let mut reopened = AtlasStore::open_for_project(&database, &raw_root)?;
2753        let before_reopened_attempt = fs::read(&database)?;
2754        let reopened_error = require_error(
2755            reopened.set_project_root(&replacement_root),
2756            "reopened store rebound a non-UTF-8 root to its replacement sibling",
2757        )?;
2758        require(
2759            matches!(reopened_error, DbError::ProjectRootMismatch { .. }),
2760            "reopened non-UTF-8 set_project_root returned the wrong error",
2761        )?;
2762        require_eq(
2763            &reopened.project_root_identity()?,
2764            &Some(native_identity),
2765            "reopened native identity after rejected set_project_root",
2766        )?;
2767        require_eq(
2768            &reopened.project_root()?,
2769            &None,
2770            "reopened metadata after rejected set_project_root",
2771        )?;
2772        require_eq(
2773            &reopened.project_instance_id()?,
2774            &Some(bound.project_instance_id),
2775            "reopened project identity after rejected set_project_root",
2776        )?;
2777        assert_authored_state(&reopened)?;
2778        assert_usage_report(&reopened, true)?;
2779        assert_graph_counts(&reopened, [2, 1, 1, 1, 1, 1, 1])?;
2780        assert_database_unchanged(
2781            &database,
2782            &before_reopened_attempt,
2783            "reopened rejected non-UTF-8 set_project_root",
2784        )?;
2785        Ok(())
2786    }
2787
2788    #[test]
2789    fn stale_stores_cannot_write_after_binding_transitions() -> Result<(), Box<dyn Error>> {
2790        let temp = tempfile::tempdir()?;
2791        let root = temp.path().join("same-root");
2792        fs::create_dir(&root)?;
2793        let database = temp.path().join("same-root.db");
2794        let initial =
2795            AtlasStore::transition_project_root(&database, &root, ProjectRootTransition::Bind)?;
2796        let mut stale = AtlasStore::open_for_project(&database, &root)?;
2797        seed_authored_and_graph_state(&mut stale, initial.project_instance_id)?;
2798
2799        let detached =
2800            AtlasStore::transition_project_root(&database, &root, ProjectRootTransition::Detach)?;
2801        require(
2802            detached.project_instance_id != initial.project_instance_id,
2803            "same-root detach did not rotate identity",
2804        )?;
2805        let purpose_error = require_error(
2806            stale.set_purpose(
2807                "src/lib.rs",
2808                "Stale store must not replace this purpose.",
2809                projectatlas_core::PurposeSource::Agent,
2810            ),
2811            "stale store wrote purpose after same-root detach",
2812        )?;
2813        require(
2814            matches!(purpose_error, DbError::ProjectRootTransitionChanged { .. }),
2815            "same-root stale purpose returned the wrong error",
2816        )?;
2817        let scan_error = require_error(
2818            stale.replace_scan(&[]),
2819            "stale store replaced scan state after same-root detach",
2820        )?;
2821        require(
2822            matches!(scan_error, DbError::ProjectRootTransitionChanged { .. }),
2823            "same-root stale scan returned the wrong error",
2824        )?;
2825        let telemetry_error = require_error(
2826            stale.record_usage(&usage_from_estimates(
2827                "stale-after-detach",
2828                "summary",
2829                Some("src/lib.rs".to_string()),
2830                None,
2831                100,
2832                20,
2833            )),
2834            "stale store recorded telemetry after same-root detach",
2835        )?;
2836        require(
2837            matches!(
2838                telemetry_error,
2839                DbError::ProjectRootTransitionChanged { .. }
2840            ),
2841            "same-root stale telemetry returned the wrong error",
2842        )?;
2843        let health_error = require_error(
2844            stale.resolve_health_finding(&HealthResolution {
2845                finding_id: "stale-resolution".to_string(),
2846                category: "missing-purpose".to_string(),
2847                path: "src/lib.rs".to_string(),
2848                related_path: None,
2849                rationale: "A stale store must not persist this resolution.".to_string(),
2850            }),
2851            "stale store resolved health state after same-root detach",
2852        )?;
2853        require(
2854            matches!(health_error, DbError::ProjectRootTransitionChanged { .. }),
2855            "same-root stale health resolution returned the wrong error",
2856        )?;
2857        let publication_error = require_error(
2858            stale.begin_index_publication("stale-after-detach"),
2859            "stale store began publication after same-root detach",
2860        )?;
2861        require(
2862            matches!(
2863                publication_error,
2864                DbError::ProjectRootTransitionChanged { .. }
2865            ),
2866            "same-root stale publication returned the wrong error",
2867        )?;
2868        let detached_store = AtlasStore::open_read_only_for_project(&database, &root)?;
2869        assert_authored_state(&detached_store)?;
2870        assert_usage_report(&detached_store, false)?;
2871        assert_runtime_scope(&detached_store, initial.project_instance_id, 0, 1, 0)?;
2872        assert_graph_counts(&detached_store, [0, 0, 0, 0, 0, 0, 0])?;
2873        require(
2874            detached_store.index_publication()?.is_none(),
2875            "stale writes restored invalidated publication state",
2876        )?;
2877        let active_generation = detached_store.connection.query_row(
2878            "SELECT active_generation FROM project_identity WHERE singleton = 1",
2879            [],
2880            |row| row.get::<_, i64>(0),
2881        )?;
2882        require_eq(
2883            &active_generation,
2884            &0,
2885            "active generation after rejected stale writes",
2886        )?;
2887        drop(detached_store);
2888        drop(stale);
2889
2890        let move_root = temp.path().join("move-source");
2891        let destination = temp.path().join("move-destination");
2892        fs::create_dir(&move_root)?;
2893        fs::create_dir(&destination)?;
2894        let move_database = temp.path().join("move.db");
2895        AtlasStore::transition_project_root(
2896            &move_database,
2897            &move_root,
2898            ProjectRootTransition::Bind,
2899        )?;
2900        let mut stale_move = AtlasStore::open_for_project(&move_database, &move_root)?;
2901        fs::remove_dir(&move_root)?;
2902        AtlasStore::transition_project_root(
2903            &move_database,
2904            &destination,
2905            ProjectRootTransition::Move,
2906        )?;
2907        let move_error = require_error(
2908            stale_move.begin_index_publication("stale-after-move"),
2909            "stale store began publication after root move",
2910        )?;
2911        require(
2912            matches!(
2913                move_error,
2914                DbError::ProjectRootMismatch { .. } | DbError::ProjectRootTransitionChanged { .. }
2915            ),
2916            "moved stale publication returned the wrong error",
2917        )?;
2918        drop(AtlasStore::open_read_only_for_project(
2919            &move_database,
2920            &destination,
2921        )?);
2922        Ok(())
2923    }
2924
2925    /// Require a test condition without panicking in a fallible test.
2926    fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
2927        if condition {
2928            Ok(())
2929        } else {
2930            Err(io::Error::other(message).into())
2931        }
2932    }
2933
2934    /// Require equality while retaining useful mismatch context.
2935    fn require_eq<T: Debug + PartialEq>(
2936        actual: &T,
2937        expected: &T,
2938        label: &str,
2939    ) -> Result<(), Box<dyn Error>> {
2940        if actual == expected {
2941            Ok(())
2942        } else {
2943            Err(io::Error::other(format!(
2944                "{label} mismatch: expected {expected:?}, got {actual:?}"
2945            ))
2946            .into())
2947        }
2948    }
2949
2950    /// Require one database operation to fail without panic-based assertions.
2951    fn require_error<T>(result: DbResult<T>, message: &str) -> Result<DbError, Box<dyn Error>> {
2952        match result {
2953            Ok(_) => Err(io::Error::other(message).into()),
2954            Err(error) => Ok(error),
2955        }
2956    }
2957
2958    /// Create a dangling directory link when the current platform permits it.
2959    #[cfg(unix)]
2960    fn create_dangling_directory_link(target: &Path, link: &Path) -> Result<bool, Box<dyn Error>> {
2961        std::os::unix::fs::symlink(target, link)?;
2962        Ok(true)
2963    }
2964
2965    /// Create a dangling directory reparse point when Windows policy permits it.
2966    #[cfg(windows)]
2967    fn create_dangling_directory_link(target: &Path, link: &Path) -> Result<bool, Box<dyn Error>> {
2968        match std::os::windows::fs::symlink_dir(target, link) {
2969            Ok(()) => Ok(true),
2970            Err(error)
2971                if error.kind() == std::io::ErrorKind::PermissionDenied
2972                    || error.raw_os_error() == Some(1314) =>
2973            {
2974                Ok(false)
2975            }
2976            Err(error) => Err(error.into()),
2977        }
2978    }
2979
2980    /// Other targets have no standard-library directory-link fixture.
2981    #[cfg(not(any(unix, windows)))]
2982    fn create_dangling_directory_link(
2983        _target: &Path,
2984        _link: &Path,
2985    ) -> Result<bool, Box<dyn Error>> {
2986        Ok(false)
2987    }
2988
2989    fn seed_authored_and_graph_state(
2990        store: &mut AtlasStore,
2991        project: ProjectInstanceId,
2992    ) -> Result<(), Box<dyn Error>> {
2993        store.connection.execute_batch(
2994            "INSERT INTO nodes(path, kind, parent_path) VALUES('.', 'folder', NULL);
2995             INSERT INTO nodes(path, kind, parent_path) VALUES('src/lib.rs', 'file', '.');
2996             INSERT INTO file_content_classifications(path, classification)
2997                VALUES('src/lib.rs', 'source');
2998             INSERT INTO purposes(node_id, purpose, source, status, updated_by)
2999                SELECT id, 'Own the local source entry point.', 'agent', 'approved', 'agent'
3000                  FROM nodes WHERE path = 'src/lib.rs';
3001             INSERT INTO metadata(key, value) VALUES('custom.identity-test-setting', 'retain-me');
3002             INSERT INTO health_resolutions(
3003                finding_id, category, path, related_path, rationale, resolved_by
3004             ) VALUES(
3005                'resolved-purpose', 'duplicate-purpose', 'src/lib.rs', 'src/main.rs',
3006                'The responsibilities are intentionally distinct.', 'agent'
3007             );",
3008        )?;
3009        store.record_usage(&identity_transition_usage_event())?;
3010        let generation = IndexGeneration::new(1);
3011        let project_entity = GraphEntity::new(project, EntitySelector::Project, generation)?;
3012        let file_entity = GraphEntity::new(
3013            project,
3014            EntitySelector::File {
3015                path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3016            },
3017            generation,
3018        )?;
3019        let relation = LogicalRelation::new(
3020            &file_entity,
3021            GraphRelationKind::Legacy(RelationKind::DependsOn),
3022            RelationResolution::Unresolved {
3023                reference: GraphIdentityText::new("src/lib.rs")?,
3024            },
3025            ConfidenceClass::Exact,
3026            Completeness::Complete,
3027            generation,
3028        )?;
3029        let occurrence = RelationOccurrence::new(
3030            &relation,
3031            RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3032            SourceSpan::new(1, 0, 1, 1)?,
3033            generation,
3034        )?;
3035        let coverage = CoverageRecord::new(
3036            CoverageScope::Project,
3037            None,
3038            CoverageState::Complete,
3039            1,
3040            0,
3041            generation,
3042            None,
3043            None,
3044        )?;
3045        let resolution_key = CanonicalResolutionKey::new(
3046            project,
3047            ResolutionKeyDomain::Declaration,
3048            &GraphIdentityText::new("identity-transition")?,
3049            &GraphIdentityText::new("rust")?,
3050            None,
3051            None,
3052            Some(GraphRelationKind::Legacy(RelationKind::DependsOn)),
3053            &GraphIdentityText::new("src/lib.rs")?,
3054        );
3055        let entity_export =
3056            EntityResolutionKey::new(file_entity.key().clone(), resolution_key.clone())?;
3057        let relation_dependency =
3058            RelationDependencyKey::new(relation.key().clone(), resolution_key)?;
3059        let mut publication = store.begin_index_publication("identity-transition")?;
3060        publication.replace_repository_graph_with_resolution_keys(
3061            project,
3062            &[project_entity, file_entity],
3063            &[relation],
3064            &[occurrence],
3065            &[coverage],
3066            &[entity_export],
3067            &[relation_dependency],
3068        )?;
3069        publication.complete()?;
3070        Ok(())
3071    }
3072
3073    fn assert_authored_state(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
3074        let mut nodes = store
3075            .connection
3076            .prepare("SELECT path, kind, parent_path FROM nodes ORDER BY path")?;
3077        let nodes = nodes
3078            .query_map([], |row| {
3079                Ok((
3080                    row.get::<_, String>(0)?,
3081                    row.get::<_, String>(1)?,
3082                    row.get::<_, Option<String>>(2)?,
3083                ))
3084            })?
3085            .collect::<Result<Vec<_>, _>>()?;
3086        require_eq(
3087            &nodes,
3088            &vec![
3089                (".".to_string(), "folder".to_string(), None),
3090                (
3091                    "src/lib.rs".to_string(),
3092                    "file".to_string(),
3093                    Some(".".to_string()),
3094                ),
3095            ],
3096            "node anchors",
3097        )?;
3098
3099        let purpose = store.connection.query_row(
3100            "SELECT n.path, p.purpose, p.source, p.status, p.updated_by
3101               FROM purposes p JOIN nodes n ON n.id = p.node_id",
3102            [],
3103            |row| {
3104                Ok((
3105                    row.get::<_, String>(0)?,
3106                    row.get::<_, Option<String>>(1)?,
3107                    row.get::<_, String>(2)?,
3108                    row.get::<_, String>(3)?,
3109                    row.get::<_, Option<String>>(4)?,
3110                ))
3111            },
3112        )?;
3113        require_eq(
3114            &purpose,
3115            &(
3116                "src/lib.rs".to_string(),
3117                Some("Own the local source entry point.".to_string()),
3118                "agent".to_string(),
3119                "approved".to_string(),
3120                Some("agent".to_string()),
3121            ),
3122            "purpose content and review ownership",
3123        )?;
3124
3125        let custom_setting = store.connection.query_row(
3126            "SELECT value FROM metadata WHERE key = 'custom.identity-test-setting'",
3127            [],
3128            |row| row.get::<_, String>(0),
3129        )?;
3130        require_eq(&custom_setting, &"retain-me".to_string(), "custom metadata")?;
3131
3132        let health_resolution = store.connection.query_row(
3133            "SELECT finding_id, category, path, related_path, rationale, resolved_by
3134               FROM health_resolutions",
3135            [],
3136            |row| {
3137                Ok((
3138                    row.get::<_, String>(0)?,
3139                    row.get::<_, String>(1)?,
3140                    row.get::<_, String>(2)?,
3141                    row.get::<_, Option<String>>(3)?,
3142                    row.get::<_, String>(4)?,
3143                    row.get::<_, String>(5)?,
3144                ))
3145            },
3146        )?;
3147        require_eq(
3148            &health_resolution,
3149            &(
3150                "resolved-purpose".to_string(),
3151                "duplicate-purpose".to_string(),
3152                "src/lib.rs".to_string(),
3153                Some("src/main.rs".to_string()),
3154                "The responsibilities are intentionally distinct.".to_string(),
3155                "agent".to_string(),
3156            ),
3157            "health resolution content",
3158        )?;
3159
3160        Ok(())
3161    }
3162
3163    /// Build the modeled event used to prove project-scoped runtime lifecycle.
3164    fn identity_transition_usage_event() -> projectatlas_core::telemetry::UsageEvent {
3165        usage_from_estimates(
3166            "identity-test",
3167            "summary",
3168            Some("src/lib.rs".to_string()),
3169            Some("identity transition".to_string()),
3170            120,
3171            20,
3172        )
3173    }
3174
3175    /// Assert the current project report without reading schema-private raw columns.
3176    fn assert_usage_report(store: &AtlasStore, retained: bool) -> Result<(), Box<dyn Error>> {
3177        let events = store.usage_events(Some("identity-test"))?;
3178        let overview = store.token_overview(Some("identity-test"))?;
3179        if retained {
3180            require_eq(
3181                &events,
3182                &vec![identity_transition_usage_event()],
3183                "project-scoped usage event",
3184            )?;
3185            require_eq(&overview.calls, &1, "project-scoped usage calls")?;
3186        } else {
3187            require(
3188                events.is_empty(),
3189                "detached project exposed prior raw usage",
3190            )?;
3191            require_eq(&overview.calls, &0, "detached project usage calls")?;
3192        }
3193        Ok(())
3194    }
3195
3196    /// Assert active/sealed instance and baseline ownership for one project identity.
3197    fn assert_runtime_scope(
3198        store: &AtlasStore,
3199        project: ProjectInstanceId,
3200        expected_active: i64,
3201        expected_sealed: i64,
3202        expected_baselines: i64,
3203    ) -> Result<(), Box<dyn Error>> {
3204        const ACTIVE: &str = "active";
3205        const SEALED: &str = "sealed";
3206        let project_bytes = project.as_bytes();
3207        let (active, sealed) = store.connection.query_row(
3208            "SELECT
3209                 COALESCE(SUM(CASE WHEN state = ?2 THEN 1 ELSE 0 END), 0),
3210                 COALESCE(SUM(CASE WHEN state = ?3 THEN 1 ELSE 0 END), 0)
3211             FROM usage_instances
3212             WHERE project_instance_id = ?1",
3213            rusqlite::params![project_bytes.as_slice(), ACTIVE, SEALED],
3214            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
3215        )?;
3216        let baselines = store.connection.query_row(
3217            "SELECT COUNT(*)
3218             FROM usage_instance_baselines AS b
3219             JOIN usage_instances AS i USING(instance_row_id)
3220             WHERE i.project_instance_id = ?1",
3221            [project_bytes.as_slice()],
3222            |row| row.get::<_, i64>(0),
3223        )?;
3224        require_eq(&active, &expected_active, "active runtime instances")?;
3225        require_eq(&sealed, &expected_sealed, "sealed runtime instances")?;
3226        require_eq(&baselines, &expected_baselines, "active baseline witnesses")
3227    }
3228
3229    /// Require that a rejected transition left the main database bytes unchanged.
3230    fn assert_database_unchanged(
3231        database_path: &Path,
3232        expected: &[u8],
3233        label: &str,
3234    ) -> Result<(), Box<dyn Error>> {
3235        require_eq(&fs::read(database_path)?, &expected.to_vec(), label)
3236    }
3237
3238    fn assert_graph_counts(store: &AtlasStore, expected: [i64; 7]) -> Result<(), Box<dyn Error>> {
3239        let mut counts = Vec::new();
3240        for table in [
3241            "graph_entities",
3242            "graph_relations",
3243            "graph_relation_occurrences",
3244            "graph_coverage",
3245            "graph_resolution_keys",
3246            "graph_entity_exports",
3247            "graph_relation_dependencies",
3248        ] {
3249            counts.push(store.connection.query_row(
3250                &format!("SELECT COUNT(*) FROM {table}"),
3251                [],
3252                |row| row.get::<_, i64>(0),
3253            )?);
3254        }
3255        require_eq(&counts, &expected.to_vec(), "graph row counts")?;
3256        let quick_check = store
3257            .connection
3258            .query_row("PRAGMA quick_check(1)", [], |row| row.get::<_, String>(0))?;
3259        require_eq(&quick_check, &"ok".to_string(), "database integrity")?;
3260        let foreign_key_failures = store.connection.query_row(
3261            "SELECT COUNT(*) FROM pragma_foreign_key_check",
3262            [],
3263            |row| row.get::<_, i64>(0),
3264        )?;
3265        require_eq(&foreign_key_failures, &0, "foreign key integrity")?;
3266        Ok(())
3267    }
3268}