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