Skip to main content

projectatlas_db/
derived_snapshot.rs

1//! Portable, derived-only repository graph snapshots.
2
3use crate::project_identity::{load_graph_generation, load_project_identity};
4use crate::repository_graph;
5use crate::schema::SCHEMA_VERSION;
6use crate::{
7    AtlasStore, DbError, DbResult, FileContentClassification, IndexPublicationState,
8    MAX_FILE_CONTENT_CLASSIFICATION_PATHS, load_index_publication,
9};
10use blake3::Hasher;
11use projectatlas_core::IndexGeneration;
12use projectatlas_core::graph::{
13    CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
14    CoverageState, DocumentTargetUnresolvedReason, EntityResolutionKey, EntitySelector,
15    ExtendedRelationKind, GraphEntity, GraphIdentityText, GraphLimitKind, GraphRelationKind,
16    LogicalRelation, LogicalRelationKey, PortableResolutionKey, ProjectInstanceId,
17    RelationDependencyKey, RelationOccurrence, RelationResolution, RepositoryFilePath, SourceSpan,
18};
19use projectatlas_core::language::ContentClassification;
20use rusqlite::Connection;
21use rusqlite::backup::Backup;
22use serde::{Deserialize, Serialize};
23use std::collections::{BTreeMap, BTreeSet};
24use std::num::NonZeroU32;
25use std::time::Duration;
26
27/// Stable portable payload version.
28const DERIVED_SNAPSHOT_FORMAT_VERSION: u32 = 1;
29/// Logical repository root used by portable paths.
30const DERIVED_SNAPSHOT_ROOT: &str = ".";
31/// Maximum encoded JSON accepted before deserialization.
32pub const MAX_DERIVED_SNAPSHOT_JSON_BYTES: u64 = 512 * 1024 * 1024;
33/// Maximum decoded graph rows admitted to one explicit snapshot operation.
34const MAX_DERIVED_SNAPSHOT_ROWS: u64 = 1_000_000;
35/// Maximum decoded row payload retained while constructing a snapshot.
36const MAX_DERIVED_SNAPSHOT_RETAINED_BYTES: u64 = 256 * 1024 * 1024;
37/// Conservative retained allocation charged for each decoded object.
38const DERIVED_SNAPSHOT_DECODE_OBJECT_BYTES: u64 = 128;
39/// Retained allocation charged for each decoded sequence or string header.
40const DERIVED_SNAPSHOT_DECODE_HEADER_BYTES: u64 = 24;
41/// Retained allocation charged for one primitive value.
42const DERIVED_SNAPSHOT_DECODE_PRIMITIVE_BYTES: u64 = 16;
43/// Maximum raw bytes admitted for one JSON string before serde may allocate it.
44const MAX_DERIVED_SNAPSHOT_JSON_STRING_BYTES: usize = 256 * 1024;
45/// Maximum private `SQLite` capture size.
46const MAX_PRIVATE_CAPTURE_BYTES: u64 = 16 * 1024 * 1024 * 1024;
47/// Maximum live node rows hashed into one source-state identity.
48const MAX_SOURCE_STATE_ROWS: u64 = 5_000_000;
49/// Maximum source-state metadata bytes hashed by one operation.
50const MAX_SOURCE_STATE_BYTES: u64 = 512 * 1024 * 1024;
51/// Fixed BLAKE3 lowercase hexadecimal length.
52const BLAKE3_HEX_BYTES: usize = 64;
53
54/// Portable snapshot metadata that contains no project or machine identity.
55#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
56pub struct DerivedGraphSnapshotMetadata {
57    /// Runtime version that wrote the portable contract.
58    pub runtime_version: String,
59    /// `SQLite` schema understood by the writer.
60    pub schema_version: i64,
61    /// Logical archive root; always `.`.
62    pub root: String,
63    /// Complete source graph generation captured privately.
64    pub source_generation: IndexGeneration,
65    /// Digest of current repository-relative node identities and content hashes.
66    pub source_state_digest: String,
67    /// Complete index capability/registry contract fingerprint.
68    pub capability_fingerprint: String,
69}
70
71/// Exact portable columns and row count exported from one derived owner.
72#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
73pub struct DerivedSnapshotContent {
74    /// Source derived table.
75    pub table: String,
76    /// Portable allowlisted columns or transformed equivalents.
77    pub columns: Vec<String>,
78    /// Number of exported logical rows.
79    pub rows: u64,
80}
81
82/// Integrity-checked, project-independent graph snapshot.
83#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
84pub struct DerivedGraphSnapshot {
85    /// Stable payload format.
86    format_version: u32,
87    /// BLAKE3 digest of metadata, inventory, and graph rows.
88    digest: String,
89    /// Portable source/capability identity.
90    metadata: DerivedGraphSnapshotMetadata,
91    /// Exact allowlist and row inventory.
92    content: Vec<DerivedSnapshotContent>,
93    /// Typed portable graph.
94    graph: PortableGraph,
95}
96
97impl DerivedGraphSnapshot {
98    /// Borrow validated portable metadata.
99    #[must_use]
100    pub const fn metadata(&self) -> &DerivedGraphSnapshotMetadata {
101        &self.metadata
102    }
103
104    /// Borrow the exact derived content inventory.
105    #[must_use]
106    pub fn content(&self) -> &[DerivedSnapshotContent] {
107        &self.content
108    }
109
110    /// Borrow the lowercase content digest.
111    #[must_use]
112    pub fn digest(&self) -> &str {
113        &self.digest
114    }
115
116    /// Encode a validated snapshot as deterministic JSON.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error when the snapshot was mutated into an invalid shape or
121    /// its encoded representation exceeds the declared limit.
122    pub fn to_json(&self) -> DbResult<Vec<u8>> {
123        self.validate()?;
124        let encoded = serde_json::to_vec(self)?;
125        require_limit(
126            "encoded JSON bytes",
127            usize_to_u64(encoded.len())?,
128            MAX_DERIVED_SNAPSHOT_JSON_BYTES,
129        )?;
130        require_decode_budget(&encoded)?;
131        Ok(encoded)
132    }
133
134    /// Decode and validate one bounded JSON snapshot.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error for oversized, malformed, incompatible, or
139    /// integrity-mismatched payloads.
140    pub fn from_json(encoded: &[u8]) -> DbResult<Self> {
141        require_limit(
142            "encoded JSON bytes",
143            usize_to_u64(encoded.len())?,
144            MAX_DERIVED_SNAPSHOT_JSON_BYTES,
145        )?;
146        require_decode_budget(encoded)?;
147        let snapshot = serde_json::from_slice::<Self>(encoded)?;
148        snapshot.validate()?;
149        Ok(snapshot)
150    }
151
152    /// Validate versions, inventory, referential shape, and content digest.
153    fn validate(&self) -> DbResult<()> {
154        if self.format_version != DERIVED_SNAPSHOT_FORMAT_VERSION {
155            return invalid("unsupported portable format version");
156        }
157        if self.metadata.runtime_version != env!("CARGO_PKG_VERSION") {
158            return invalid("snapshot runtime version does not match this runtime");
159        }
160        if self.metadata.schema_version != SCHEMA_VERSION {
161            return invalid("snapshot schema version does not match this runtime");
162        }
163        if self.metadata.root != DERIVED_SNAPSHOT_ROOT {
164            return invalid("snapshot root is not the portable repository root");
165        }
166        if self.metadata.source_generation == IndexGeneration::ZERO {
167            return invalid("snapshot source generation is zero");
168        }
169        if !valid_digest(&self.metadata.source_state_digest) {
170            return invalid("snapshot source-state digest is malformed");
171        }
172        if self.metadata.capability_fingerprint.is_empty()
173            || self.metadata.capability_fingerprint.len() > 4_096
174            || self
175                .metadata
176                .capability_fingerprint
177                .chars()
178                .any(char::is_control)
179        {
180            return invalid("snapshot capability fingerprint is invalid");
181        }
182        self.graph.validate()?;
183        if self.content != expected_content(&self.graph)? {
184            return invalid("snapshot content inventory does not match the portable graph");
185        }
186        let digest = snapshot_digest(&self.metadata, &self.content, &self.graph)?;
187        if self.digest != digest {
188            return invalid("snapshot content digest does not match");
189        }
190        Ok(())
191    }
192}
193
194/// Result of one normal projection publication from a portable snapshot.
195#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
196pub struct DerivedGraphSnapshotImport {
197    /// Complete generation visible before import.
198    pub previous_generation: IndexGeneration,
199    /// Complete generation published by import.
200    pub published_generation: IndexGeneration,
201    /// Snapshot content digest that was activated.
202    pub digest: String,
203    /// Portable derived row inventory.
204    pub content: Vec<DerivedSnapshotContent>,
205}
206
207/// Complete typed graph collected from one private `SQLite` backup.
208pub(crate) struct CapturedGraph {
209    /// Captured file classifications in exact repository-path order.
210    pub(crate) file_classifications: Vec<FileContentClassification>,
211    /// Captured graph entities.
212    pub(crate) entities: Vec<GraphEntity>,
213    /// Captured logical relations.
214    pub(crate) relations: Vec<LogicalRelation>,
215    /// Closed reasons for captured unresolved document relations.
216    pub(crate) document_unresolved_reasons: Vec<([u8; 32], DocumentTargetUnresolvedReason)>,
217    /// Captured exact relation occurrences.
218    pub(crate) occurrences: Vec<RelationOccurrence>,
219    /// Captured graph coverage.
220    pub(crate) coverage: Vec<CoverageRecord>,
221    /// Captured entity resolution exports.
222    pub(crate) entity_exports: Vec<([u8; 32], CanonicalResolutionKey)>,
223    /// Captured relation resolution dependencies.
224    pub(crate) relation_dependencies: Vec<([u8; 32], CanonicalResolutionKey)>,
225}
226
227/// Shared construction budget used while decoding the private backup.
228pub(crate) struct SnapshotBudget {
229    /// Rows admitted so far.
230    rows: u64,
231    /// Estimated retained bytes admitted so far.
232    retained_bytes: u64,
233}
234
235impl SnapshotBudget {
236    /// Create an empty snapshot budget.
237    pub(crate) const fn new() -> Self {
238        Self {
239            rows: 0,
240            retained_bytes: 0,
241        }
242    }
243
244    /// Admit one decoded row of the supplied retained size.
245    pub(crate) fn admit(&mut self, bytes: u64) -> DbResult<()> {
246        self.rows = self
247            .rows
248            .checked_add(1)
249            .ok_or(DbError::DerivedSnapshotInvalid {
250                reason: "snapshot row count overflowed",
251            })?;
252        require_limit("decoded rows", self.rows, MAX_DERIVED_SNAPSHOT_ROWS)?;
253        self.retained_bytes = self
254            .retained_bytes
255            .checked_add(bytes)
256            .and_then(|value| value.checked_add(128))
257            .ok_or(DbError::DerivedSnapshotInvalid {
258                reason: "snapshot retained byte count overflowed",
259            })?;
260        require_limit(
261            "decoded retained bytes",
262            self.retained_bytes,
263            MAX_DERIVED_SNAPSHOT_RETAINED_BYTES,
264        )
265    }
266}
267
268impl AtlasStore {
269    /// Build a portable graph snapshot from a private `SQLite` backup.
270    ///
271    /// # Errors
272    ///
273    /// Returns an error for incomplete publications, corrupt or oversized
274    /// captures, private temporary-file failures, or invalid graph rows.
275    pub fn export_derived_graph_snapshot(&self) -> DbResult<DerivedGraphSnapshot> {
276        let capture_dir = tempfile::tempdir().map_err(|source| DbError::DerivedSnapshotIo {
277            path: std::env::temp_dir(),
278            source,
279        })?;
280        let capture_path = capture_dir.path().join("derived-graph-capture.sqlite");
281        let mut capture = Connection::open(&capture_path).map_err(DbError::from)?;
282        require_private_capture_size(&self.connection)?;
283        {
284            let backup = Backup::new(&self.connection, &mut capture)?;
285            backup.run_to_completion(256, Duration::from_millis(1), None)?;
286        }
287        snapshot_from_stable_capture(&capture)
288    }
289
290    /// Build a portable graph snapshot from a private stable database copy.
291    pub(crate) fn export_derived_graph_snapshot_from_stable_copy(
292        &self,
293    ) -> DbResult<DerivedGraphSnapshot> {
294        snapshot_from_stable_capture(&self.connection)
295    }
296
297    /// Rebind and publish a source-exact baseline into a detached hydration candidate.
298    pub(crate) fn import_worktree_hydration_snapshot(
299        &mut self,
300        snapshot: &DerivedGraphSnapshot,
301    ) -> DbResult<DerivedGraphSnapshotImport> {
302        snapshot.validate()?;
303        if self.index_publication()?.is_some()
304            || load_graph_generation(&self.connection)? != Some(IndexGeneration::ZERO)
305        {
306            return invalid("worktree hydration destination is already published");
307        }
308        if source_state_digest(&self.connection)? != snapshot.metadata.source_state_digest {
309            return invalid("worktree hydration source state changed before rebinding");
310        }
311        let project = self
312            .project_instance_id()?
313            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
314        self.publish_snapshot(snapshot, project, IndexGeneration::ZERO, false, || Ok(()))
315    }
316
317    /// Publish a validated graph snapshot through the full or projection contract.
318    fn publish_snapshot(
319        &mut self,
320        snapshot: &DerivedGraphSnapshot,
321        project: ProjectInstanceId,
322        base_generation: IndexGeneration,
323        projection: bool,
324        before_publication: impl FnOnce() -> DbResult<()>,
325    ) -> DbResult<DerivedGraphSnapshotImport> {
326        let next_generation = base_generation
327            .checked_next()
328            .ok_or(DbError::PublicationGenerationOverflow)?;
329        let graph = snapshot.graph.bind(project, next_generation)?;
330        validate_snapshot_classification_coverage(&self.connection, &graph.file_classifications)?;
331        before_publication()?;
332        let mut guard = if projection {
333            self.begin_index_projection_refresh_from(
334                &snapshot.metadata.capability_fingerprint,
335                base_generation,
336            )?
337        } else {
338            self.begin_index_publication_from(
339                &snapshot.metadata.capability_fingerprint,
340                base_generation,
341            )?
342        };
343        if source_state_digest(&guard.connection)? != snapshot.metadata.source_state_digest {
344            return invalid("destination source state does not match the snapshot");
345        }
346        validate_snapshot_classification_coverage(&guard.connection, &graph.file_classifications)?;
347        for rows in graph
348            .file_classifications
349            .chunks(MAX_FILE_CONTENT_CLASSIFICATION_PATHS)
350        {
351            guard.upsert_file_content_classification_batch(rows)?;
352        }
353        guard.replace_repository_graph_with_resolution_keys(
354            project,
355            &graph.entities,
356            &graph.relations,
357            &graph.occurrences,
358            &graph.coverage,
359            &graph.entity_exports,
360            &graph.relation_dependencies,
361        )?;
362        guard.set_document_unresolved_reasons(&graph.document_unresolved_reasons)?;
363        guard.complete()?;
364        Ok(DerivedGraphSnapshotImport {
365            previous_generation: base_generation,
366            published_generation: next_generation,
367            digest: snapshot.digest.clone(),
368            content: snapshot.content.clone(),
369        })
370    }
371
372    /// Validate and atomically publish a portable graph into this project.
373    ///
374    /// The destination must already have the same current source state and full
375    /// capability contract. Only derived graph rows are replaced; destination
376    /// identity, source projections, purposes, health state, settings, and
377    /// telemetry stay owned by the destination database.
378    ///
379    /// # Errors
380    ///
381    /// Returns an error before publication for incompatible source/capability
382    /// state or malformed content. Publication conflicts and `SQLite` failures
383    /// roll the existing generation back through the normal guard.
384    pub fn import_derived_graph_snapshot(
385        &mut self,
386        snapshot: &DerivedGraphSnapshot,
387    ) -> DbResult<DerivedGraphSnapshotImport> {
388        self.import_derived_graph_snapshot_with_prepublication(snapshot, || Ok(()))
389    }
390
391    /// Import with one internal seam immediately before publication locking.
392    fn import_derived_graph_snapshot_with_prepublication(
393        &mut self,
394        snapshot: &DerivedGraphSnapshot,
395        before_publication: impl FnOnce() -> DbResult<()>,
396    ) -> DbResult<DerivedGraphSnapshotImport> {
397        snapshot.validate()?;
398        let publication = self
399            .index_publication()?
400            .filter(|publication| {
401                publication.state == IndexPublicationState::Complete
402                    && publication.generation != IndexGeneration::ZERO
403            })
404            .ok_or(DbError::GraphPublicationUnavailable)?;
405        if publication.contract_fingerprint.as_deref()
406            != Some(snapshot.metadata.capability_fingerprint.as_str())
407        {
408            return invalid("destination capability fingerprint does not match the snapshot");
409        }
410        if source_state_digest(&self.connection)? != snapshot.metadata.source_state_digest {
411            return invalid("destination source state does not match the snapshot");
412        }
413        let project = self
414            .project_instance_id()?
415            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
416        self.publish_snapshot(
417            snapshot,
418            project,
419            publication.generation,
420            true,
421            before_publication,
422        )
423    }
424}
425
426/// Validate and decode one stable private `SQLite` capture.
427fn snapshot_from_stable_capture(capture: &Connection) -> DbResult<DerivedGraphSnapshot> {
428    require_private_capture_size(capture)?;
429    let quick_check = capture.query_row("PRAGMA quick_check", [], |row| row.get::<_, String>(0))?;
430    if quick_check != "ok" {
431        return invalid("private SQLite capture failed integrity check");
432    }
433
434    let publication = load_index_publication(capture)?
435        .filter(|publication| {
436            publication.state == IndexPublicationState::Complete
437                && publication.generation != IndexGeneration::ZERO
438        })
439        .ok_or(DbError::GraphPublicationUnavailable)?;
440    let capability_fingerprint = publication
441        .contract_fingerprint
442        .filter(|value| !value.is_empty())
443        .ok_or(DbError::DerivedSnapshotInvalid {
444            reason: "complete publication has no capability fingerprint",
445        })?;
446    let project = load_project_identity(capture)?.ok_or(DbError::ProjectInstanceIdentityMissing)?;
447    if load_graph_generation(capture)? != Some(publication.generation) {
448        return invalid("private capture graph generation is not complete");
449    }
450    let source_state_digest = source_state_digest(capture)?;
451    let mut budget = SnapshotBudget::new();
452    let mut captured = repository_graph::capture_derived_graph(
453        capture,
454        project,
455        publication.generation,
456        &mut budget,
457    )?;
458    captured.file_classifications = capture_file_classifications(capture, &mut budget)?;
459    DerivedGraphSnapshot::from_capture(
460        captured,
461        publication.generation,
462        source_state_digest,
463        capability_fingerprint,
464    )
465}
466
467impl DerivedGraphSnapshot {
468    /// Assemble and validate one snapshot from a private typed capture.
469    fn from_capture(
470        captured: CapturedGraph,
471        source_generation: IndexGeneration,
472        source_state_digest: String,
473        capability_fingerprint: String,
474    ) -> DbResult<Self> {
475        let graph = PortableGraph::from_capture(captured)?;
476        let metadata = DerivedGraphSnapshotMetadata {
477            runtime_version: env!("CARGO_PKG_VERSION").to_string(),
478            schema_version: SCHEMA_VERSION,
479            root: DERIVED_SNAPSHOT_ROOT.to_string(),
480            source_generation,
481            source_state_digest,
482            capability_fingerprint,
483        };
484        let content = expected_content(&graph)?;
485        let digest = snapshot_digest(&metadata, &content, &graph)?;
486        let snapshot = Self {
487            format_version: DERIVED_SNAPSHOT_FORMAT_VERSION,
488            digest,
489            metadata,
490            content,
491            graph,
492        };
493        snapshot.validate()?;
494        Ok(snapshot)
495    }
496}
497
498/// Project-independent normalized graph.
499#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
500struct PortableGraph {
501    /// One closed content role for every admitted repository file.
502    file_classifications: Vec<PortableFileClassification>,
503    /// Entity selectors in portable index order.
504    entities: Vec<EntitySelector>,
505    /// Logical relations using portable entity indexes.
506    relations: Vec<PortableRelation>,
507    /// Exact source occurrences using portable relation indexes.
508    occurrences: Vec<PortableOccurrence>,
509    /// Coverage without a source publication generation.
510    coverage: Vec<PortableCoverage>,
511    /// Entity exports using portable entity indexes.
512    entity_exports: Vec<PortableEntityResolutionKey>,
513    /// Relation dependencies using portable relation indexes.
514    relation_dependencies: Vec<PortableRelationResolutionKey>,
515}
516
517impl PortableGraph {
518    /// Convert a project-bound graph capture into a portable graph.
519    fn from_capture(captured: CapturedGraph) -> DbResult<Self> {
520        let file_classifications = captured
521            .file_classifications
522            .into_iter()
523            .map(PortableFileClassification::from)
524            .collect();
525        let mut entity_indexes = BTreeMap::new();
526        for (index, entity) in captured.entities.iter().enumerate() {
527            let index = usize_to_u32(index)?;
528            if entity_indexes
529                .insert(entity.key().digest_bytes()?, index)
530                .is_some()
531            {
532                return invalid("snapshot contains duplicate entity identities");
533            }
534        }
535        let mut relation_indexes = BTreeMap::new();
536        for (index, relation) in captured.relations.iter().enumerate() {
537            let index = usize_to_u32(index)?;
538            if relation_indexes
539                .insert(relation.key().digest_bytes()?, index)
540                .is_some()
541            {
542                return invalid("snapshot contains duplicate relation identities");
543            }
544        }
545        let document_unresolved_reasons = captured
546            .document_unresolved_reasons
547            .into_iter()
548            .collect::<BTreeMap<_, _>>();
549        let entities = captured
550            .entities
551            .iter()
552            .map(|entity| entity.selector().clone())
553            .collect();
554        let relations = captured
555            .relations
556            .iter()
557            .map(|relation| {
558                PortableRelation::from_relation(
559                    relation,
560                    document_unresolved_reasons
561                        .get(&relation.key().digest_bytes()?)
562                        .copied(),
563                    &entity_indexes,
564                )
565            })
566            .collect::<DbResult<Vec<_>>>()?;
567        let occurrences = captured
568            .occurrences
569            .iter()
570            .map(|occurrence| {
571                Ok(PortableOccurrence {
572                    relation: required_index(
573                        &relation_indexes,
574                        occurrence.relation().digest_bytes()?,
575                        "snapshot occurrence owner relation is absent",
576                    )?,
577                    file: occurrence.file().clone(),
578                    span: occurrence.span(),
579                })
580            })
581            .collect::<DbResult<Vec<_>>>()?;
582        let coverage = captured
583            .coverage
584            .iter()
585            .map(PortableCoverage::from)
586            .collect();
587        let entity_exports = captured
588            .entity_exports
589            .into_iter()
590            .map(|(entity, key)| {
591                Ok(PortableEntityResolutionKey {
592                    entity: required_index(
593                        &entity_indexes,
594                        entity,
595                        "snapshot resolution export entity is absent",
596                    )?,
597                    key: key.portable()?,
598                })
599            })
600            .collect::<DbResult<Vec<_>>>()?;
601        let relation_dependencies = captured
602            .relation_dependencies
603            .into_iter()
604            .map(|(relation, key)| {
605                Ok(PortableRelationResolutionKey {
606                    relation: required_index(
607                        &relation_indexes,
608                        relation,
609                        "snapshot resolution dependency relation is absent",
610                    )?,
611                    key: key.portable()?,
612                })
613            })
614            .collect::<DbResult<Vec<_>>>()?;
615        Ok(Self {
616            file_classifications,
617            entities,
618            relations,
619            occurrences,
620            coverage,
621            entity_exports,
622            relation_dependencies,
623        })
624    }
625
626    /// Validate row limits and all portable indexes.
627    fn validate(&self) -> DbResult<()> {
628        let total = [
629            self.file_classifications.len(),
630            self.entities.len(),
631            self.relations.len(),
632            self.occurrences.len(),
633            self.coverage.len(),
634            self.entity_exports.len(),
635            self.relation_dependencies.len(),
636        ]
637        .into_iter()
638        .try_fold(0_u64, |total, rows| {
639            total
640                .checked_add(usize_to_u64(rows)?)
641                .ok_or(DbError::DerivedSnapshotInvalid {
642                    reason: "snapshot row count overflowed",
643                })
644        })?;
645        require_limit("decoded rows", total, MAX_DERIVED_SNAPSHOT_ROWS)?;
646        let mut classified_paths = BTreeSet::new();
647        for row in &self.file_classifications {
648            RepositoryFilePath::new(std::path::Path::new(&row.path))?;
649            if !classified_paths.insert(row.path.as_str()) {
650                return invalid("snapshot contains duplicate file classifications");
651            }
652        }
653        for relation in &self.relations {
654            require_vector_index(
655                relation.source,
656                self.entities.len(),
657                "snapshot relation source index is invalid",
658            )?;
659            match relation.resolution {
660                PortableRelationResolution::Resolved { target }
661                | PortableRelationResolution::External { target } => require_vector_index(
662                    target,
663                    self.entities.len(),
664                    "snapshot relation target index is invalid",
665                )?,
666                PortableRelationResolution::Ambiguous { .. }
667                | PortableRelationResolution::Unresolved { .. } => {}
668            }
669            let requires_reason = relation.kind
670                == GraphRelationKind::Extended(ExtendedRelationKind::Documents)
671                && matches!(
672                    relation.resolution,
673                    PortableRelationResolution::Unresolved { .. }
674                );
675            if requires_reason != relation.document_unresolved_reason.is_some() {
676                return invalid(
677                    "snapshot document reason contradicts relation family or resolution",
678                );
679            }
680        }
681        for occurrence in &self.occurrences {
682            require_vector_index(
683                occurrence.relation,
684                self.relations.len(),
685                "snapshot occurrence relation index is invalid",
686            )?;
687        }
688        for export in &self.entity_exports {
689            require_vector_index(
690                export.entity,
691                self.entities.len(),
692                "snapshot export entity index is invalid",
693            )?;
694        }
695        for dependency in &self.relation_dependencies {
696            require_vector_index(
697                dependency.relation,
698                self.relations.len(),
699                "snapshot dependency relation index is invalid",
700            )?;
701        }
702        Ok(())
703    }
704
705    /// Rebind the portable graph to one destination project and generation.
706    fn bind(
707        &self,
708        project: ProjectInstanceId,
709        generation: IndexGeneration,
710    ) -> DbResult<BoundGraph> {
711        self.validate()?;
712        let entities = self
713            .entities
714            .iter()
715            .cloned()
716            .map(|selector| GraphEntity::new(project, selector, generation).map_err(Into::into))
717            .collect::<DbResult<Vec<_>>>()?;
718        let file_classifications = self
719            .file_classifications
720            .iter()
721            .cloned()
722            .map(FileContentClassification::from)
723            .collect();
724        let relations = self
725            .relations
726            .iter()
727            .map(|relation| relation.bind(&entities, generation))
728            .collect::<DbResult<Vec<_>>>()?;
729        let occurrences = self
730            .occurrences
731            .iter()
732            .map(|occurrence| {
733                let relation = indexed(
734                    &relations,
735                    occurrence.relation,
736                    "snapshot occurrence relation index is invalid",
737                )?;
738                RelationOccurrence::new(
739                    relation,
740                    occurrence.file.clone(),
741                    occurrence.span,
742                    generation,
743                )
744                .map_err(Into::into)
745            })
746            .collect::<DbResult<Vec<_>>>()?;
747        let coverage = self
748            .coverage
749            .iter()
750            .map(|coverage| coverage.bind(generation))
751            .collect::<DbResult<Vec<_>>>()?;
752        let entity_exports = self
753            .entity_exports
754            .iter()
755            .map(|export| {
756                EntityResolutionKey::new(
757                    indexed(
758                        &entities,
759                        export.entity,
760                        "snapshot export entity index is invalid",
761                    )?
762                    .key()
763                    .clone(),
764                    export.key.bind(project),
765                )
766                .map_err(Into::into)
767            })
768            .collect::<DbResult<Vec<_>>>()?;
769        let relation_dependencies = self
770            .relation_dependencies
771            .iter()
772            .map(|dependency| {
773                RelationDependencyKey::new(
774                    indexed(
775                        &relations,
776                        dependency.relation,
777                        "snapshot dependency relation index is invalid",
778                    )?
779                    .key()
780                    .clone(),
781                    dependency.key.bind(project),
782                )
783                .map_err(Into::into)
784            })
785            .collect::<DbResult<Vec<_>>>()?;
786        let document_unresolved_reasons = self
787            .relations
788            .iter()
789            .zip(&relations)
790            .filter_map(|(portable, relation)| {
791                portable
792                    .document_unresolved_reason
793                    .map(|reason| (relation.key().clone(), reason))
794            })
795            .collect();
796        Ok(BoundGraph {
797            file_classifications,
798            entities,
799            relations,
800            occurrences,
801            coverage,
802            entity_exports,
803            relation_dependencies,
804            document_unresolved_reasons,
805        })
806    }
807}
808
809/// Project-independent file classification row.
810#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
811struct PortableFileClassification {
812    /// Exact repository-relative file path.
813    path: String,
814    /// Registry-owned closed content role.
815    classification: ContentClassification,
816}
817
818impl From<FileContentClassification> for PortableFileClassification {
819    fn from(row: FileContentClassification) -> Self {
820        Self {
821            path: row.path,
822            classification: row.classification,
823        }
824    }
825}
826
827impl From<PortableFileClassification> for FileContentClassification {
828    fn from(row: PortableFileClassification) -> Self {
829        Self {
830            path: row.path,
831            classification: row.classification,
832        }
833    }
834}
835
836/// Portable logical relation using entity indexes instead of project keys.
837#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
838struct PortableRelation {
839    /// Source entity index.
840    source: u32,
841    /// Typed relation kind.
842    kind: GraphRelationKind,
843    /// Project-independent resolution state.
844    resolution: PortableRelationResolution,
845    /// Relation confidence.
846    confidence: ConfidenceClass,
847    /// Relation completeness.
848    completeness: Completeness,
849    /// Closed reason retained only for an unresolved document relation.
850    #[serde(skip_serializing_if = "Option::is_none")]
851    document_unresolved_reason: Option<DocumentTargetUnresolvedReason>,
852}
853
854impl PortableRelation {
855    /// Convert one project-bound relation to portable entity indexes.
856    fn from_relation(
857        relation: &LogicalRelation,
858        document_unresolved_reason: Option<DocumentTargetUnresolvedReason>,
859        entity_indexes: &BTreeMap<[u8; 32], u32>,
860    ) -> DbResult<Self> {
861        let resolution = match relation.resolution() {
862            RelationResolution::Resolved { target, .. } => PortableRelationResolution::Resolved {
863                target: required_index(
864                    entity_indexes,
865                    target.digest_bytes()?,
866                    "snapshot resolved target is absent",
867                )?,
868            },
869            RelationResolution::Ambiguous {
870                reference,
871                candidates,
872            } => PortableRelationResolution::Ambiguous {
873                reference: reference.clone(),
874                candidates: *candidates,
875            },
876            RelationResolution::Unresolved { reference } => {
877                PortableRelationResolution::Unresolved {
878                    reference: reference.clone(),
879                }
880            }
881            RelationResolution::External { target, .. } => PortableRelationResolution::External {
882                target: required_index(
883                    entity_indexes,
884                    target.digest_bytes()?,
885                    "snapshot external target is absent",
886                )?,
887            },
888        };
889        Ok(Self {
890            source: required_index(
891                entity_indexes,
892                relation.source().digest_bytes()?,
893                "snapshot relation source is absent",
894            )?,
895            kind: relation.kind(),
896            resolution,
897            confidence: relation.confidence(),
898            completeness: relation.completeness(),
899            document_unresolved_reason,
900        })
901    }
902
903    /// Bind one portable relation to destination entities.
904    fn bind(
905        &self,
906        entities: &[GraphEntity],
907        generation: IndexGeneration,
908    ) -> DbResult<LogicalRelation> {
909        let source = indexed(
910            entities,
911            self.source,
912            "snapshot relation source index is invalid",
913        )?;
914        let resolution = match &self.resolution {
915            PortableRelationResolution::Resolved { target } => {
916                RelationResolution::resolved(indexed(
917                    entities,
918                    *target,
919                    "snapshot resolved target index is invalid",
920                )?)?
921            }
922            PortableRelationResolution::Ambiguous {
923                reference,
924                candidates,
925            } => RelationResolution::Ambiguous {
926                reference: reference.clone(),
927                candidates: *candidates,
928            },
929            PortableRelationResolution::Unresolved { reference } => {
930                RelationResolution::Unresolved {
931                    reference: reference.clone(),
932                }
933            }
934            PortableRelationResolution::External { target } => {
935                RelationResolution::external(indexed(
936                    entities,
937                    *target,
938                    "snapshot external target index is invalid",
939                )?)?
940            }
941        };
942        LogicalRelation::new(
943            source,
944            self.kind,
945            resolution,
946            self.confidence,
947            self.completeness,
948            generation,
949        )
950        .map_err(Into::into)
951    }
952}
953
954/// Portable resolution state without project-qualified stable keys.
955#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
956#[serde(tag = "status", rename_all = "snake_case")]
957enum PortableRelationResolution {
958    /// Relation resolved to one indexed entity.
959    Resolved {
960        /// Target entity index.
961        target: u32,
962    },
963    /// Relation has multiple candidate targets.
964    Ambiguous {
965        /// Original unresolved reference.
966        reference: GraphIdentityText,
967        /// Number of candidate targets.
968        candidates: NonZeroU32,
969    },
970    /// Relation has no resolved target.
971    Unresolved {
972        /// Original unresolved reference.
973        reference: GraphIdentityText,
974    },
975    /// Relation resolves to an external indexed entity.
976    External {
977        /// Target entity index.
978        target: u32,
979    },
980}
981
982/// Portable exact source occurrence.
983#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
984struct PortableOccurrence {
985    /// Owning relation index.
986    relation: u32,
987    /// Repository-relative evidence file.
988    file: RepositoryFilePath,
989    /// Exact source span.
990    span: SourceSpan,
991}
992
993/// Portable graph coverage without a source publication generation.
994#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
995struct PortableCoverage {
996    /// Covered graph scope.
997    scope: CoverageScope,
998    /// Optional covered relation kind.
999    relation: Option<GraphRelationKind>,
1000    /// Coverage state.
1001    state: CoverageState,
1002    /// Covered row count.
1003    covered: u64,
1004    /// Omitted row count.
1005    omitted: u64,
1006    /// Optional omission reason.
1007    reason: Option<GraphIdentityText>,
1008    /// Optional reached graph limit.
1009    reached_limit: Option<GraphLimitKind>,
1010}
1011
1012impl From<&CoverageRecord> for PortableCoverage {
1013    fn from(coverage: &CoverageRecord) -> Self {
1014        Self {
1015            scope: coverage.scope().clone(),
1016            relation: coverage.relation(),
1017            state: coverage.state(),
1018            covered: coverage.covered(),
1019            omitted: coverage.omitted(),
1020            reason: coverage.reason().cloned(),
1021            reached_limit: coverage.reached_limit(),
1022        }
1023    }
1024}
1025
1026impl PortableCoverage {
1027    /// Bind portable coverage to the destination generation.
1028    fn bind(&self, generation: IndexGeneration) -> DbResult<CoverageRecord> {
1029        CoverageRecord::new(
1030            self.scope.clone(),
1031            self.relation,
1032            self.state,
1033            self.covered,
1034            self.omitted,
1035            generation,
1036            self.reason.clone(),
1037            self.reached_limit,
1038        )
1039        .map_err(Into::into)
1040    }
1041}
1042
1043/// Portable entity export key.
1044#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1045struct PortableEntityResolutionKey {
1046    /// Exporting entity index.
1047    entity: u32,
1048    /// Project-independent canonical key.
1049    key: PortableResolutionKey,
1050}
1051
1052/// Portable relation dependency key.
1053#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1054struct PortableRelationResolutionKey {
1055    /// Dependent relation index.
1056    relation: u32,
1057    /// Project-independent canonical key.
1058    key: PortableResolutionKey,
1059}
1060
1061/// Destination-bound graph ready for the existing publication transaction.
1062struct BoundGraph {
1063    /// Destination-path file classifications.
1064    file_classifications: Vec<FileContentClassification>,
1065    /// Destination-bound graph entities.
1066    entities: Vec<GraphEntity>,
1067    /// Destination-bound logical relations.
1068    relations: Vec<LogicalRelation>,
1069    /// Destination-bound exact occurrences.
1070    occurrences: Vec<RelationOccurrence>,
1071    /// Destination-bound coverage.
1072    coverage: Vec<CoverageRecord>,
1073    /// Destination-bound entity exports.
1074    entity_exports: Vec<EntityResolutionKey>,
1075    /// Destination-bound relation dependencies.
1076    relation_dependencies: Vec<RelationDependencyKey>,
1077    /// Destination-bound closed unresolved-document reasons.
1078    document_unresolved_reasons: Vec<(LogicalRelationKey, DocumentTargetUnresolvedReason)>,
1079}
1080
1081/// Digest body used to avoid hashing the digest field itself.
1082#[derive(Serialize)]
1083struct SnapshotDigestBody<'a> {
1084    /// Portable source and capability metadata.
1085    metadata: &'a DerivedGraphSnapshotMetadata,
1086    /// Exact exported content inventory.
1087    content: &'a [DerivedSnapshotContent],
1088    /// Portable graph body.
1089    graph: &'a PortableGraph,
1090}
1091
1092/// Compute the deterministic digest over snapshot content.
1093fn snapshot_digest(
1094    metadata: &DerivedGraphSnapshotMetadata,
1095    content: &[DerivedSnapshotContent],
1096    graph: &PortableGraph,
1097) -> DbResult<String> {
1098    let encoded = serde_json::to_vec(&SnapshotDigestBody {
1099        metadata,
1100        content,
1101        graph,
1102    })?;
1103    require_limit(
1104        "digest body bytes",
1105        usize_to_u64(encoded.len())?,
1106        MAX_DERIVED_SNAPSHOT_JSON_BYTES,
1107    )?;
1108    Ok(blake3::hash(&encoded).to_hex().to_string())
1109}
1110
1111/// Construct the exact allowlisted content inventory.
1112fn expected_content(graph: &PortableGraph) -> DbResult<Vec<DerivedSnapshotContent>> {
1113    Ok(vec![
1114        content(
1115            "file_content_classifications",
1116            &["path", "classification"],
1117            graph.file_classifications.len(),
1118        )?,
1119        content("graph_entities", &["entity_selector"], graph.entities.len())?,
1120        content(
1121            "graph_relations",
1122            &[
1123                "source_entity",
1124                "relation_kind",
1125                "resolution",
1126                "document_unresolved_reason",
1127                "confidence",
1128                "completeness",
1129            ],
1130            graph.relations.len(),
1131        )?,
1132        content(
1133            "graph_relation_occurrences",
1134            &["relation", "file_path", "source_span"],
1135            graph.occurrences.len(),
1136        )?,
1137        content(
1138            "graph_coverage",
1139            &[
1140                "scope",
1141                "relation_kind",
1142                "state",
1143                "covered",
1144                "omitted",
1145                "reason",
1146                "reached_limit",
1147            ],
1148            graph.coverage.len(),
1149        )?,
1150        content(
1151            "graph_entity_exports+graph_resolution_keys",
1152            &["entity", "resolution_domain", "portable_canonical_identity"],
1153            graph.entity_exports.len(),
1154        )?,
1155        content(
1156            "graph_relation_dependencies+graph_resolution_keys",
1157            &[
1158                "relation",
1159                "resolution_domain",
1160                "portable_canonical_identity",
1161            ],
1162            graph.relation_dependencies.len(),
1163        )?,
1164    ])
1165}
1166
1167/// Construct one content inventory row.
1168fn content(table: &str, columns: &[&str], rows: usize) -> DbResult<DerivedSnapshotContent> {
1169    Ok(DerivedSnapshotContent {
1170        table: table.to_string(),
1171        columns: columns.iter().map(|column| (*column).to_string()).collect(),
1172        rows: usize_to_u64(rows)?,
1173    })
1174}
1175
1176/// Capture the complete closed file-role projection from the private backup.
1177fn capture_file_classifications(
1178    connection: &Connection,
1179    budget: &mut SnapshotBudget,
1180) -> DbResult<Vec<FileContentClassification>> {
1181    let mut statement = connection.prepare(
1182        "SELECT path, classification
1183           FROM file_content_classifications
1184          ORDER BY path",
1185    )?;
1186    let mut rows = statement.query([])?;
1187    let mut captured = Vec::new();
1188    while let Some(row) = rows.next()? {
1189        let path = row.get::<_, String>(0)?;
1190        let raw = row.get::<_, String>(1)?;
1191        let classification =
1192            ContentClassification::from_db(&raw).ok_or_else(|| DbError::InvalidEnum {
1193                field: "file_content_classifications.classification",
1194                value: raw.clone(),
1195            })?;
1196        budget.admit(
1197            usize_to_u64(path.len())?
1198                .saturating_add(usize_to_u64(raw.len())?)
1199                .saturating_add(DERIVED_SNAPSHOT_DECODE_OBJECT_BYTES),
1200        )?;
1201        captured.push(FileContentClassification {
1202            path,
1203            classification,
1204        });
1205    }
1206    Ok(captured)
1207}
1208
1209/// Require snapshot classifications to cover the destination's exact current file set.
1210fn validate_snapshot_classification_coverage(
1211    connection: &Connection,
1212    classifications: &[FileContentClassification],
1213) -> DbResult<()> {
1214    let mut statement = connection.prepare(
1215        "SELECT path
1216           FROM nodes
1217          WHERE exists_now = 1 AND kind = 'file'
1218          ORDER BY path",
1219    )?;
1220    let mut rows = statement.query([])?;
1221    let mut index = 0_usize;
1222    while let Some(row) = rows.next()? {
1223        let path = row.get::<_, String>(0)?;
1224        if classifications.get(index).map(|row| row.path.as_str()) != Some(path.as_str()) {
1225            return invalid("snapshot classifications do not exactly cover current files");
1226        }
1227        index = index.saturating_add(1);
1228    }
1229    if index != classifications.len() {
1230        return invalid("snapshot classifications do not exactly cover current files");
1231    }
1232    Ok(())
1233}
1234
1235/// Hash the current repository-relative source state.
1236fn source_state_digest(connection: &Connection) -> DbResult<String> {
1237    let mut statement = connection.prepare(
1238        "SELECT path, kind, extension, language, size_bytes, content_hash
1239           FROM nodes
1240          WHERE exists_now = 1
1241          ORDER BY path",
1242    )?;
1243    let mut rows = statement.query([])?;
1244    let mut hasher = Hasher::new();
1245    hasher.update(b"projectatlas.derived-snapshot.source-state.v1");
1246    let mut row_count = 0_u64;
1247    let mut byte_count = 0_u64;
1248    while let Some(row) = rows.next()? {
1249        row_count = row_count
1250            .checked_add(1)
1251            .ok_or(DbError::DerivedSnapshotInvalid {
1252                reason: "source-state row count overflowed",
1253            })?;
1254        require_limit("source-state rows", row_count, MAX_SOURCE_STATE_ROWS)?;
1255        let fields = [
1256            row.get::<_, String>(0)?,
1257            row.get::<_, String>(1)?,
1258            row.get::<_, Option<String>>(2)?.unwrap_or_default(),
1259            row.get::<_, Option<String>>(3)?.unwrap_or_default(),
1260            row.get::<_, Option<i64>>(4)?
1261                .map_or_else(String::new, |value| value.to_string()),
1262            row.get::<_, Option<String>>(5)?.unwrap_or_default(),
1263        ];
1264        for field in fields {
1265            byte_count = byte_count.checked_add(usize_to_u64(field.len())?).ok_or(
1266                DbError::DerivedSnapshotInvalid {
1267                    reason: "source-state byte count overflowed",
1268                },
1269            )?;
1270            require_limit(
1271                "source-state metadata bytes",
1272                byte_count,
1273                MAX_SOURCE_STATE_BYTES,
1274            )?;
1275            hash_field(&mut hasher, field.as_bytes())?;
1276        }
1277    }
1278    hasher.update(&row_count.to_le_bytes());
1279    Ok(hasher.finalize().to_hex().to_string())
1280}
1281
1282/// Hash one length-framed source-state field.
1283fn hash_field(hasher: &mut Hasher, value: &[u8]) -> DbResult<()> {
1284    let length = usize_to_u64(value.len())?;
1285    hasher.update(&length.to_le_bytes());
1286    hasher.update(value);
1287    Ok(())
1288}
1289
1290/// Reject a private capture larger than the explicit snapshot ceiling.
1291fn require_private_capture_size(connection: &Connection) -> DbResult<()> {
1292    let page_count =
1293        u64::try_from(connection.query_row("PRAGMA page_count", [], |row| row.get::<_, i64>(0))?)
1294            .map_err(|_source| DbError::DerivedSnapshotInvalid {
1295            reason: "private SQLite capture page count was negative",
1296        })?;
1297    let page_size =
1298        u64::try_from(connection.query_row("PRAGMA page_size", [], |row| row.get::<_, i64>(0))?)
1299            .map_err(|_source| DbError::DerivedSnapshotInvalid {
1300                reason: "private SQLite capture page size was negative",
1301            })?;
1302    let bytes = page_count
1303        .checked_mul(page_size)
1304        .ok_or(DbError::DerivedSnapshotInvalid {
1305            reason: "private SQLite capture size overflowed",
1306        })?;
1307    require_limit(
1308        "private SQLite capture bytes",
1309        bytes,
1310        MAX_PRIVATE_CAPTURE_BYTES,
1311    )
1312}
1313
1314/// Resolve a captured digest to its portable index.
1315fn required_index(
1316    indexes: &BTreeMap<[u8; 32], u32>,
1317    key: [u8; 32],
1318    reason: &'static str,
1319) -> DbResult<u32> {
1320    indexes
1321        .get(&key)
1322        .copied()
1323        .ok_or(DbError::DerivedSnapshotInvalid { reason })
1324}
1325
1326/// Validate that a portable index addresses the supplied vector.
1327fn require_vector_index(index: u32, length: usize, reason: &'static str) -> DbResult<()> {
1328    if usize::try_from(index).is_ok_and(|index| index < length) {
1329        Ok(())
1330    } else {
1331        invalid(reason)
1332    }
1333}
1334
1335/// Reject JSON shapes whose decoded allocation estimate exceeds the snapshot ceiling.
1336fn require_decode_budget(encoded: &[u8]) -> DbResult<()> {
1337    let mut retained_bytes = 0_u64;
1338    let mut string_bytes = 0_usize;
1339    let mut in_string = false;
1340    let mut escaped = false;
1341    let mut in_primitive = false;
1342
1343    for byte in encoded {
1344        if in_string {
1345            if escaped {
1346                escaped = false;
1347            } else if *byte == b'\\' {
1348                escaped = true;
1349            } else if *byte == b'"' {
1350                in_string = false;
1351                in_primitive = true;
1352                admit_decode_bytes(&mut retained_bytes, usize_to_u64(string_bytes)?)?;
1353                string_bytes = 0;
1354                continue;
1355            }
1356            string_bytes = string_bytes
1357                .checked_add(1)
1358                .ok_or(DbError::DerivedSnapshotInvalid {
1359                    reason: "snapshot JSON string size overflowed",
1360                })?;
1361            if string_bytes > MAX_DERIVED_SNAPSHOT_JSON_STRING_BYTES {
1362                return Err(DbError::DerivedSnapshotLimit {
1363                    resource: "encoded JSON string bytes",
1364                    found: usize_to_u64(string_bytes)?,
1365                    maximum: usize_to_u64(MAX_DERIVED_SNAPSHOT_JSON_STRING_BYTES)?,
1366                });
1367            }
1368            continue;
1369        }
1370
1371        match *byte {
1372            b'"' => {
1373                in_string = true;
1374                escaped = false;
1375                in_primitive = false;
1376                admit_decode_bytes(&mut retained_bytes, DERIVED_SNAPSHOT_DECODE_HEADER_BYTES)?;
1377            }
1378            b'{' => {
1379                in_primitive = false;
1380                admit_decode_bytes(&mut retained_bytes, DERIVED_SNAPSHOT_DECODE_OBJECT_BYTES)?;
1381            }
1382            b'[' => {
1383                in_primitive = false;
1384                admit_decode_bytes(&mut retained_bytes, DERIVED_SNAPSHOT_DECODE_HEADER_BYTES)?;
1385            }
1386            b',' | b':' | b'}' | b']' | b' ' | b'\t' | b'\r' | b'\n' => {
1387                in_primitive = false;
1388            }
1389            _ if !in_primitive => {
1390                in_primitive = true;
1391                admit_decode_bytes(&mut retained_bytes, DERIVED_SNAPSHOT_DECODE_PRIMITIVE_BYTES)?;
1392            }
1393            _ => {}
1394        }
1395    }
1396    Ok(())
1397}
1398
1399/// Admit one conservative decoded-allocation estimate.
1400fn admit_decode_bytes(retained_bytes: &mut u64, bytes: u64) -> DbResult<()> {
1401    *retained_bytes = retained_bytes
1402        .checked_add(bytes)
1403        .ok_or(DbError::DerivedSnapshotInvalid {
1404            reason: "snapshot retained byte count overflowed",
1405        })?;
1406    require_limit(
1407        "decoded retained bytes",
1408        *retained_bytes,
1409        MAX_DERIVED_SNAPSHOT_RETAINED_BYTES,
1410    )
1411}
1412
1413/// Return one vector item addressed by a validated portable index.
1414fn indexed<'a, T>(values: &'a [T], index: u32, reason: &'static str) -> DbResult<&'a T> {
1415    values
1416        .get(usize::try_from(index).map_err(|_source| DbError::DerivedSnapshotInvalid { reason })?)
1417        .ok_or(DbError::DerivedSnapshotInvalid { reason })
1418}
1419
1420/// Enforce one named resource ceiling.
1421fn require_limit(resource: &'static str, found: u64, maximum: u64) -> DbResult<()> {
1422    if found <= maximum {
1423        Ok(())
1424    } else {
1425        Err(DbError::DerivedSnapshotLimit {
1426            resource,
1427            found,
1428            maximum,
1429        })
1430    }
1431}
1432
1433/// Return whether a digest is lowercase BLAKE3 hexadecimal.
1434fn valid_digest(value: &str) -> bool {
1435    value.len() == BLAKE3_HEX_BYTES
1436        && value
1437            .bytes()
1438            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1439}
1440
1441/// Convert an in-memory length into a portable count.
1442fn usize_to_u64(value: usize) -> DbResult<u64> {
1443    u64::try_from(value).map_err(|_source| DbError::DerivedSnapshotInvalid {
1444        reason: "snapshot size cannot be represented",
1445    })
1446}
1447
1448/// Convert an in-memory index into its portable representation.
1449fn usize_to_u32(value: usize) -> DbResult<u32> {
1450    u32::try_from(value).map_err(|_source| DbError::DerivedSnapshotLimit {
1451        resource: "portable row indexes",
1452        found: u64::try_from(value).unwrap_or(u64::MAX),
1453        maximum: u64::from(u32::MAX),
1454    })
1455}
1456
1457/// Return a typed invalid-snapshot error.
1458fn invalid<T>(reason: &'static str) -> DbResult<T> {
1459    Err(DbError::DerivedSnapshotInvalid { reason })
1460}
1461
1462#[cfg(test)]
1463mod tests {
1464    use super::{
1465        DerivedGraphSnapshot, MAX_DERIVED_SNAPSHOT_JSON_STRING_BYTES, expected_content, invalid,
1466        snapshot_digest,
1467    };
1468    use crate::{AtlasStore, DbError, FileContentClassification};
1469    use projectatlas_core::graph::{
1470        CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
1471        CoverageState, DocumentTargetUnresolvedReason, EntityResolutionKey, EntitySelector,
1472        ExtendedRelationKind, GraphEntity, GraphIdentityText, GraphRelationKind, LogicalRelation,
1473        RelationDependencyKey, RelationOccurrence, RelationResolution, RepositoryFilePath,
1474        ResolutionKeyDomain, SourceSpan, SymbolSelector,
1475    };
1476    use projectatlas_core::language::ContentClassification;
1477    use projectatlas_core::symbols::{RelationKind, SymbolKind};
1478    use projectatlas_core::telemetry::UsageEvent;
1479    use projectatlas_core::{IndexGeneration, Node, NodeKind, PurposeSource};
1480    use serde_json::json;
1481    use std::error::Error;
1482    use std::fs;
1483    use std::path::Path;
1484
1485    const CONTRACT: &str = "snapshot-test-contract";
1486    const PRIVATE_SENTINEL: &str = "TOP_SECRET_SNAPSHOT_SENTINEL";
1487    const DELETED_PRIVATE_SENTINEL: &str = "DELETED_SNAPSHOT_PAGE_SENTINEL";
1488
1489    fn node(
1490        path: &str,
1491        kind: NodeKind,
1492        parent_path: Option<&str>,
1493        content_hash: Option<&str>,
1494    ) -> Node {
1495        Node {
1496            path: path.to_string(),
1497            kind,
1498            parent_path: parent_path.map(str::to_string),
1499            extension: (kind == NodeKind::File).then(|| ".rs".to_string()),
1500            language: (kind == NodeKind::File).then(|| "rust".to_string()),
1501            size_bytes: (kind == NodeKind::File).then_some(32),
1502            mtime_ns: (kind == NodeKind::File).then_some(1),
1503            content_hash: content_hash.map(str::to_string),
1504        }
1505    }
1506
1507    fn open_store(root: &Path) -> Result<AtlasStore, Box<dyn Error>> {
1508        let atlas = root.join(".projectatlas");
1509        fs::create_dir_all(&atlas)?;
1510        Ok(AtlasStore::open_for_project(
1511            &atlas.join("projectatlas.db"),
1512            root,
1513        )?)
1514    }
1515
1516    fn publish_fixture(
1517        store: &mut AtlasStore,
1518        content_hash: &str,
1519        with_graph: bool,
1520    ) -> Result<(), Box<dyn Error>> {
1521        let project = store
1522            .project_instance_id()?
1523            .ok_or("fixture project identity is missing")?;
1524        let generation = IndexGeneration::new(1);
1525        let mut publication = store.begin_index_publication(CONTRACT)?;
1526        publication.begin_scan_replacement()?;
1527        publication.upsert_scan_node_batch(&[
1528            node(".", NodeKind::Folder, None, None),
1529            node("src", NodeKind::Folder, Some("."), None),
1530            node(
1531                "src/lib.rs",
1532                NodeKind::File,
1533                Some("src"),
1534                Some(content_hash),
1535            ),
1536        ])?;
1537        publication.finish_scan_replacement()?;
1538        publication.upsert_file_content_classification_batch(&[FileContentClassification {
1539            path: "src/lib.rs".to_string(),
1540            classification: if with_graph {
1541                ContentClassification::Source
1542            } else {
1543                ContentClassification::Documentation
1544            },
1545        }])?;
1546        if with_graph {
1547            let project_entity = GraphEntity::new(project, EntitySelector::Project, generation)?;
1548            let file = GraphEntity::new(
1549                project,
1550                EntitySelector::File {
1551                    path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
1552                },
1553                generation,
1554            )?;
1555            let symbol = GraphEntity::new(
1556                project,
1557                EntitySelector::Symbol {
1558                    symbol: SymbolSelector {
1559                        file: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
1560                        name: GraphIdentityText::new("answer")?,
1561                        kind: SymbolKind::Function,
1562                        parent: None,
1563                        signature: GraphIdentityText::new("fn answer() -> u32")?,
1564                    },
1565                },
1566                generation,
1567            )?;
1568            let relation = LogicalRelation::new(
1569                &file,
1570                GraphRelationKind::Legacy(RelationKind::Calls),
1571                RelationResolution::resolved(&symbol)?,
1572                ConfidenceClass::Exact,
1573                Completeness::Complete,
1574                generation,
1575            )?;
1576            let unresolved_document = LogicalRelation::new(
1577                &file,
1578                GraphRelationKind::Extended(ExtendedRelationKind::Documents),
1579                RelationResolution::Unresolved {
1580                    reference: GraphIdentityText::new("docs/missing.md")?,
1581                },
1582                ConfidenceClass::High,
1583                Completeness::Complete,
1584                generation,
1585            )?;
1586            let occurrence = RelationOccurrence::new(
1587                &relation,
1588                RepositoryFilePath::new(Path::new("src/lib.rs"))?,
1589                SourceSpan::new(1, 0, 1, 6)?,
1590                generation,
1591            )?;
1592            let document_occurrence = RelationOccurrence::new(
1593                &unresolved_document,
1594                RepositoryFilePath::new(Path::new("src/lib.rs"))?,
1595                SourceSpan::new(2, 0, 2, 15)?,
1596                generation,
1597            )?;
1598            let coverage = CoverageRecord::new(
1599                CoverageScope::Project,
1600                None,
1601                CoverageState::Complete,
1602                1,
1603                0,
1604                generation,
1605                None,
1606                None,
1607            )?;
1608            let key = CanonicalResolutionKey::new(
1609                project,
1610                ResolutionKeyDomain::Declaration,
1611                &GraphIdentityText::new("tree-sitter")?,
1612                &GraphIdentityText::new("rust")?,
1613                None,
1614                Some(&GraphIdentityText::new("crate")?),
1615                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
1616                &GraphIdentityText::new("answer")?,
1617            );
1618            publication.replace_repository_graph_with_resolution_keys(
1619                project,
1620                &[project_entity, file, symbol.clone()],
1621                &[relation.clone(), unresolved_document.clone()],
1622                &[occurrence, document_occurrence],
1623                &[coverage],
1624                &[EntityResolutionKey::new(symbol.key().clone(), key.clone())?],
1625                &[RelationDependencyKey::new(relation.key().clone(), key)?],
1626            )?;
1627            publication.set_document_unresolved_reasons(&[(
1628                unresolved_document.key().clone(),
1629                DocumentTargetUnresolvedReason::Missing,
1630            )])?;
1631        } else {
1632            publication.replace_repository_graph_with_resolution_keys(
1633                project,
1634                &[],
1635                &[],
1636                &[],
1637                &[],
1638                &[],
1639                &[],
1640            )?;
1641        }
1642        publication.complete()?;
1643        Ok(())
1644    }
1645
1646    fn seed_private_state(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
1647        store.set_purpose("src/lib.rs", PRIVATE_SENTINEL, PurposeSource::Agent)?;
1648        store.connection.execute(
1649            "INSERT INTO health_resolutions(
1650                 finding_id, category, path, related_path, rationale
1651             ) VALUES('snapshot-private-health', 'snapshot-private', 'src/lib.rs', NULL, ?1)",
1652            [PRIVATE_SENTINEL],
1653        )?;
1654        let usage = serde_json::from_value::<UsageEvent>(json!({
1655            "session_id": "snapshot-private",
1656            "command": "summary",
1657            "query": PRIVATE_SENTINEL
1658        }))?;
1659        store.record_usage(&usage)?;
1660        store.connection.execute(
1661            "INSERT INTO metadata(key, value) VALUES('snapshot.private.setting', ?1)",
1662            [PRIVATE_SENTINEL],
1663        )?;
1664        store.connection.execute_batch(
1665            "CREATE TABLE future_memory_atlas(secret TEXT);
1666             INSERT INTO future_memory_atlas(secret)
1667             VALUES('TOP_SECRET_SNAPSHOT_SENTINEL');",
1668        )?;
1669        store.connection.execute(
1670            "INSERT INTO metadata(key, value) VALUES('snapshot.deleted.secret', ?1)",
1671            [DELETED_PRIVATE_SENTINEL],
1672        )?;
1673        store.connection.execute(
1674            "DELETE FROM metadata WHERE key = 'snapshot.deleted.secret'",
1675            [],
1676        )?;
1677        Ok(())
1678    }
1679
1680    #[test]
1681    fn malformed_snapshot_json_is_rejected_before_use() {
1682        let result = DerivedGraphSnapshot::from_json(br#"{"format_version":1}"#);
1683        assert!(result.is_err());
1684        assert!(invalid::<()>("fixture").is_err());
1685    }
1686
1687    #[test]
1688    fn snapshot_json_decode_budget_rejects_large_shapes_before_deserialization() {
1689        let mut wide = String::from("[");
1690        for index in 0..2_100_000 {
1691            if index != 0 {
1692                wide.push(',');
1693            }
1694            wide.push_str("{}");
1695        }
1696        wide.push(']');
1697        assert!(matches!(
1698            DerivedGraphSnapshot::from_json(wide.as_bytes()),
1699            Err(DbError::DerivedSnapshotLimit {
1700                resource: "decoded retained bytes",
1701                ..
1702            })
1703        ));
1704
1705        let oversized_string = format!(
1706            r#"{{"value":"{}"}}"#,
1707            "x".repeat(MAX_DERIVED_SNAPSHOT_JSON_STRING_BYTES + 1)
1708        );
1709        assert!(matches!(
1710            DerivedGraphSnapshot::from_json(oversized_string.as_bytes()),
1711            Err(DbError::DerivedSnapshotLimit {
1712                resource: "encoded JSON string bytes",
1713                ..
1714            })
1715        ));
1716    }
1717
1718    #[test]
1719    fn large_valid_snapshot_round_trips_without_charging_json_delimiters()
1720    -> Result<(), Box<dyn Error>> {
1721        let source_root = tempfile::tempdir()?;
1722        let mut source = open_store(source_root.path())?;
1723        publish_fixture(&mut source, "large-round-trip", true)?;
1724        let mut snapshot = source.export_derived_graph_snapshot()?;
1725        let coverage = snapshot
1726            .graph
1727            .coverage
1728            .first()
1729            .ok_or("fixture coverage is missing")?
1730            .clone();
1731        snapshot.graph.coverage = vec![coverage; 40_000];
1732        snapshot.content = expected_content(&snapshot.graph)?;
1733        snapshot.digest = snapshot_digest(&snapshot.metadata, &snapshot.content, &snapshot.graph)?;
1734
1735        let encoded = snapshot.to_json()?;
1736        let decoded = DerivedGraphSnapshot::from_json(&encoded)?;
1737        if decoded != snapshot {
1738            return Err("large valid snapshot changed during round trip".into());
1739        }
1740        Ok(())
1741    }
1742
1743    #[test]
1744    #[allow(clippy::panic_in_result_fn)]
1745    fn derived_snapshot_excludes_private_state_and_rebinds_atomically() -> Result<(), Box<dyn Error>>
1746    {
1747        let source_root = tempfile::tempdir()?;
1748        let destination_root = tempfile::tempdir()?;
1749        let mut source = open_store(source_root.path())?;
1750        let mut destination = open_store(destination_root.path())?;
1751        publish_fixture(&mut source, "same-content", true)?;
1752        publish_fixture(&mut destination, "same-content", false)?;
1753        seed_private_state(&source)?;
1754        seed_private_state(&destination)?;
1755
1756        let source_identity = source
1757            .project_instance_id()?
1758            .ok_or("source identity is missing")?;
1759        let destination_identity = destination
1760            .project_instance_id()?
1761            .ok_or("destination identity is missing")?;
1762        assert_ne!(source_identity, destination_identity);
1763
1764        let exported = source.export_derived_graph_snapshot()?;
1765        let encoded = exported.to_json()?;
1766        let encoded_text = String::from_utf8(encoded.clone())?;
1767        assert!(!encoded_text.contains(PRIVATE_SENTINEL));
1768        assert!(!encoded_text.contains(DELETED_PRIVATE_SENTINEL));
1769        assert!(!encoded_text.contains(&source_identity.to_string()));
1770        let escaped_source_root =
1771            serde_json::to_string(source_root.path().to_string_lossy().as_ref())?;
1772        assert!(!encoded_text.contains(escaped_source_root.trim_matches('"')));
1773        assert_eq!(
1774            exported.content().iter().map(|row| row.rows).sum::<u64>(),
1775            11
1776        );
1777
1778        let decoded = DerivedGraphSnapshot::from_json(&encoded)?;
1779        let report = destination.import_derived_graph_snapshot(&decoded)?;
1780        assert_eq!(report.previous_generation, IndexGeneration::new(1));
1781        assert_eq!(report.published_generation, IndexGeneration::new(2));
1782        assert_eq!(
1783            destination.project_instance_id()?,
1784            Some(destination_identity)
1785        );
1786        let entities = destination.repository_graph_entities_by_path(
1787            destination_identity,
1788            &projectatlas_core::graph::RepositoryNodePath::new(Path::new("src/lib.rs"))?,
1789            10,
1790        )?;
1791        assert_eq!(entities.rows.len(), 2);
1792        assert_eq!(
1793            destination.file_content_classifications_for_paths(&["src/lib.rs".to_string()])?[0]
1794                .classification,
1795            ContentClassification::Source
1796        );
1797        assert_eq!(
1798            destination.connection.query_row(
1799                "SELECT COUNT(*) FROM graph_relations
1800                  WHERE relation_scope = 'extended'
1801                    AND relation_kind = 'documents'
1802                    AND resolution_status = 'unresolved'
1803                    AND document_unresolved_reason = 'missing'",
1804                [],
1805                |row| row.get::<_, i64>(0),
1806            )?,
1807            1
1808        );
1809        let indexed = destination.load_nodes_by_paths(&["src/lib.rs".to_string()])?;
1810        assert_eq!(
1811            indexed[0].purpose.purpose.as_deref(),
1812            Some(PRIVATE_SENTINEL)
1813        );
1814        assert_eq!(
1815            destination.connection.query_row(
1816                "SELECT rationale FROM health_resolutions
1817                  WHERE finding_id = 'snapshot-private-health'",
1818                [],
1819                |row| row.get::<_, String>(0),
1820            )?,
1821            PRIVATE_SENTINEL
1822        );
1823        assert_eq!(
1824            destination.connection.query_row(
1825                "SELECT value FROM metadata WHERE key = 'snapshot.private.setting'",
1826                [],
1827                |row| row.get::<_, String>(0),
1828            )?,
1829            PRIVATE_SENTINEL
1830        );
1831        assert_eq!(
1832            destination.connection.query_row(
1833                "SELECT query FROM usage_events ORDER BY id DESC LIMIT 1",
1834                [],
1835                |row| row.get::<_, String>(0),
1836            )?,
1837            PRIVATE_SENTINEL
1838        );
1839        assert_eq!(
1840            destination.connection.query_row(
1841                "SELECT secret FROM future_memory_atlas",
1842                [],
1843                |row| row.get::<_, String>(0),
1844            )?,
1845            PRIVATE_SENTINEL
1846        );
1847        Ok(())
1848    }
1849
1850    #[test]
1851    #[allow(clippy::panic_in_result_fn)]
1852    fn derived_snapshot_rejects_document_reason_drift_and_rolls_back_update_failure()
1853    -> Result<(), Box<dyn Error>> {
1854        let source_root = tempfile::tempdir()?;
1855        let destination_root = tempfile::tempdir()?;
1856        let mut source = open_store(source_root.path())?;
1857        let mut destination = open_store(destination_root.path())?;
1858        publish_fixture(&mut source, "same-content", true)?;
1859        publish_fixture(&mut destination, "same-content", false)?;
1860
1861        let snapshot = source.export_derived_graph_snapshot()?;
1862        let mut malformed = snapshot.clone();
1863        let document = malformed
1864            .graph
1865            .relations
1866            .iter_mut()
1867            .find(|relation| {
1868                relation.kind == GraphRelationKind::Extended(ExtendedRelationKind::Documents)
1869            })
1870            .ok_or("document relation is missing")?;
1871        document.document_unresolved_reason = None;
1872        malformed.content = expected_content(&malformed.graph)?;
1873        malformed.digest =
1874            snapshot_digest(&malformed.metadata, &malformed.content, &malformed.graph)?;
1875        assert!(matches!(
1876            malformed.to_json(),
1877            Err(DbError::DerivedSnapshotInvalid {
1878                reason: "snapshot document reason contradicts relation family or resolution"
1879            })
1880        ));
1881
1882        let mut forbidden = snapshot.clone();
1883        let calls = forbidden
1884            .graph
1885            .relations
1886            .iter_mut()
1887            .find(|relation| relation.kind == GraphRelationKind::Legacy(RelationKind::Calls))
1888            .ok_or("calls relation is missing")?;
1889        calls.document_unresolved_reason = Some(DocumentTargetUnresolvedReason::Missing);
1890        forbidden.content = expected_content(&forbidden.graph)?;
1891        forbidden.digest =
1892            snapshot_digest(&forbidden.metadata, &forbidden.content, &forbidden.graph)?;
1893        assert!(matches!(
1894            forbidden.to_json(),
1895            Err(DbError::DerivedSnapshotInvalid {
1896                reason: "snapshot document reason contradicts relation family or resolution"
1897            })
1898        ));
1899
1900        destination.connection.execute_batch(
1901            "CREATE TEMP TRIGGER fail_snapshot_document_reason
1902             BEFORE UPDATE OF document_unresolved_reason ON graph_relations
1903             BEGIN
1904                 SELECT RAISE(ABORT, 'injected snapshot reason failure');
1905             END;",
1906        )?;
1907        let before = destination
1908            .index_publication()?
1909            .ok_or("destination publication is missing")?;
1910        assert!(
1911            destination
1912                .import_derived_graph_snapshot(&snapshot)
1913                .is_err()
1914        );
1915        assert_eq!(
1916            destination
1917                .index_publication()?
1918                .ok_or("destination publication is missing")?,
1919            before
1920        );
1921        assert_eq!(
1922            destination.connection.query_row(
1923                "SELECT COUNT(*) FROM graph_relations",
1924                [],
1925                |row| { row.get::<_, i64>(0) }
1926            )?,
1927            0
1928        );
1929        Ok(())
1930    }
1931
1932    #[test]
1933    #[allow(clippy::panic_in_result_fn)]
1934    fn derived_snapshot_rejects_tampering_and_source_mismatch_without_publication()
1935    -> Result<(), Box<dyn Error>> {
1936        let source_root = tempfile::tempdir()?;
1937        let destination_root = tempfile::tempdir()?;
1938        let mut source = open_store(source_root.path())?;
1939        let mut destination = open_store(destination_root.path())?;
1940        publish_fixture(&mut source, "source-content", true)?;
1941        publish_fixture(&mut destination, "different-content", false)?;
1942
1943        let snapshot = source.export_derived_graph_snapshot()?;
1944        let mut tampered = snapshot;
1945        tampered.metadata.source_state_digest =
1946            "0".repeat(tampered.metadata.source_state_digest.len());
1947        assert!(tampered.to_json().is_err());
1948
1949        let mut internally_consistent = tampered;
1950        internally_consistent.digest = snapshot_digest(
1951            &internally_consistent.metadata,
1952            &internally_consistent.content,
1953            &internally_consistent.graph,
1954        )?;
1955        let before = destination
1956            .index_publication()?
1957            .ok_or("publication missing")?;
1958        assert!(
1959            destination
1960                .import_derived_graph_snapshot(&internally_consistent)
1961                .is_err()
1962        );
1963        assert_eq!(
1964            destination
1965                .index_publication()?
1966                .ok_or("publication missing")?,
1967            before
1968        );
1969        assert_eq!(
1970            destination.repository_graph_generation()?,
1971            Some(before.generation)
1972        );
1973        Ok(())
1974    }
1975
1976    #[test]
1977    #[allow(clippy::panic_in_result_fn)]
1978    fn derived_snapshot_rechecks_source_state_inside_publication_transaction()
1979    -> Result<(), Box<dyn Error>> {
1980        let source_root = tempfile::tempdir()?;
1981        let destination_root = tempfile::tempdir()?;
1982        let mut source = open_store(source_root.path())?;
1983        let mut destination = open_store(destination_root.path())?;
1984        publish_fixture(&mut source, "same-content", true)?;
1985        publish_fixture(&mut destination, "same-content", false)?;
1986        let mut concurrent = open_store(destination_root.path())?;
1987        let snapshot = source.export_derived_graph_snapshot()?;
1988        let before = destination
1989            .index_publication()?
1990            .ok_or("destination publication is missing")?;
1991
1992        let result =
1993            destination.import_derived_graph_snapshot_with_prepublication(&snapshot, || {
1994                concurrent.upsert_scan_nodes(&[node(
1995                    "src/lib.rs",
1996                    NodeKind::File,
1997                    Some("src"),
1998                    Some("concurrent-content"),
1999                )])
2000            });
2001        assert!(matches!(
2002            result,
2003            Err(DbError::DerivedSnapshotInvalid {
2004                reason: "destination source state does not match the snapshot"
2005            })
2006        ));
2007        assert_eq!(
2008            destination
2009                .index_publication()?
2010                .ok_or("destination publication is missing")?,
2011            before
2012        );
2013        assert_eq!(
2014            destination.repository_graph_generation()?,
2015            Some(before.generation)
2016        );
2017        assert_eq!(
2018            destination.load_nodes_by_paths(&["src/lib.rs".to_string()])?[0]
2019                .node
2020                .content_hash
2021                .as_deref(),
2022            Some("concurrent-content")
2023        );
2024        Ok(())
2025    }
2026}