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