projectatlas_db/
repository_graph.rs

1//! Normalized repository-graph persistence and bounded prepared queries.
2
3use super::{
4    AtlasStore, DbError, DbResult, IndexPublicationGuard, IndexPublicationState, count_to_usize,
5    with_sqlite_read_progress,
6};
7use crate::derived_snapshot::{CapturedGraph, SnapshotBudget};
8use crate::project_identity::{
9    load_graph_generation, load_project_identity, require_bound_project_identity,
10    set_graph_generation, set_project_identity, verify_project_identity,
11};
12use crate::schema::PROJECT_ROOT_KEY;
13use projectatlas_core::graph::{
14    CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
15    CoverageState, EntityResolutionKey, EntitySelector, ExtendedRelationKind, ExternalSelector,
16    GraphContractError, GraphEntity, GraphEntityKey, GraphIdentityText, GraphLimitKind,
17    GraphLimits, GraphRelationKind, LogicalRelation, PackageSelector, ProjectInstanceId,
18    RelationDependencyKey, RelationOccurrence, RelationResolution, RepositoryFilePath,
19    RepositoryNodePath, ResolutionKeyDomain, SourceSpan, SymbolSelector,
20};
21use projectatlas_core::symbols::{ParserKind, RelationKind, SymbolKind};
22use projectatlas_core::{
23    IndexGeneration, IndexWorkControl, IndexWorkStage, NodeKind, RankedConnection,
24    RankedConnectionCount, RankedConnectionDirection, RankedConnectionKind, RankedConnectionTarget,
25    normalize_native_path_display,
26};
27use rusqlite::types::Value;
28use rusqlite::{
29    Connection, OpenFlags, OptionalExtension, Row, Transaction, params, params_from_iter,
30};
31use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
32use std::num::{NonZeroU32, NonZeroU64};
33use std::path::Path;
34
35/// Disposable typed repository-graph writer used before main index publication.
36pub struct RepositoryGraphStagingGuard<'store> {
37    /// Transaction owning the staged graph rows.
38    transaction: Transaction<'store>,
39    /// Project identity accepted by every staged row.
40    project: ProjectInstanceId,
41    /// Generation that the main publication must consume.
42    generation: IndexGeneration,
43}
44
45/// Metadata key proving a database was created only as a disposable graph stage.
46const GRAPH_STAGING_MARKER_KEY: &str = "repository_graph_staging";
47/// Current disposable graph-staging marker value.
48const GRAPH_STAGING_MARKER_VALUE: &str = "v1";
49
50/// One bounded page of typed normalized graph rows.
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub struct RepositoryGraphPage<T> {
53    /// Fully validated rows in deterministic storage order.
54    pub rows: Vec<T>,
55    /// Whether at least one additional validated row exists.
56    pub truncated: bool,
57}
58
59/// Validated resource envelope for one bounded repository-graph database read.
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub struct RepositoryGraphReadBudget {
62    /// Maximum input keys, frontier selectors, or purpose-owner paths.
63    requested_rows: NonZeroU32,
64    /// Maximum fully reconstructed rows returned to the caller.
65    returned_rows: NonZeroU32,
66    /// Maximum raw `SQLite` payload bytes decoded by the complete batch.
67    decoded_bytes: NonZeroU64,
68    /// Maximum unique entities reconstructed by the complete batch.
69    hydrated_entities: NonZeroU32,
70    /// Maximum unique purpose-owning repository paths retained from entities.
71    hydrated_paths: NonZeroU32,
72}
73
74impl RepositoryGraphReadBudget {
75    /// Absolute compact-key or purpose-path request ceiling for one batch.
76    pub const MAX_REQUESTED_ROWS: u32 = GraphLimits::MAX_ROWS;
77    /// Absolute reconstructed-row ceiling for one batch.
78    pub const MAX_RETURNED_ROWS: u32 = GraphLimits::MAX_ROWS;
79    /// Absolute decoded payload ceiling for one hydration batch.
80    pub const MAX_DECODED_BYTES: u64 = 32 * 1_024 * 1_024;
81    /// Absolute unique-entity ceiling including one adjacency sentinel.
82    pub const MAX_HYDRATED_ENTITIES: u32 = 2 * (GraphLimits::MAX_ROWS + 1);
83    /// Absolute unique purpose-owner path ceiling for one hydration batch.
84    pub const MAX_HYDRATED_PATHS: u32 = 2 * (GraphLimits::MAX_ROWS + 1);
85
86    /// Construct one bounded repository-graph read envelope.
87    ///
88    /// # Errors
89    ///
90    /// Returns an error when a limit is zero or above its absolute batch
91    /// ceiling.
92    pub fn new(
93        requested_rows: u32,
94        returned_rows: u32,
95        decoded_bytes: u64,
96        hydrated_entities: u32,
97        hydrated_paths: u32,
98    ) -> Result<Self, GraphContractError> {
99        if requested_rows == 0 || requested_rows > Self::MAX_REQUESTED_ROWS {
100            return Err(GraphContractError::InvalidLimits {
101                reason: "graph read requested-row budget is zero or above the batch ceiling",
102            });
103        }
104        if returned_rows == 0 || returned_rows > Self::MAX_RETURNED_ROWS {
105            return Err(GraphContractError::InvalidLimits {
106                reason: "graph read returned-row budget is zero or above the batch ceiling",
107            });
108        }
109        if decoded_bytes == 0 || decoded_bytes > Self::MAX_DECODED_BYTES {
110            return Err(GraphContractError::InvalidLimits {
111                reason: "graph read decoded-byte budget is zero or above the batch ceiling",
112            });
113        }
114        if hydrated_entities == 0 || hydrated_entities > Self::MAX_HYDRATED_ENTITIES {
115            return Err(GraphContractError::InvalidLimits {
116                reason: "graph read entity budget is zero or above the batch ceiling",
117            });
118        }
119        if hydrated_paths == 0 || hydrated_paths > Self::MAX_HYDRATED_PATHS {
120            return Err(GraphContractError::InvalidLimits {
121                reason: "graph read path budget is zero or above the batch ceiling",
122            });
123        }
124        Ok(Self {
125            requested_rows: NonZeroU32::new(requested_rows).ok_or(
126                GraphContractError::InvalidLimits {
127                    reason: "graph read requested-row budget must be nonzero",
128                },
129            )?,
130            returned_rows: NonZeroU32::new(returned_rows).ok_or(
131                GraphContractError::InvalidLimits {
132                    reason: "graph read returned-row budget must be nonzero",
133                },
134            )?,
135            decoded_bytes: NonZeroU64::new(decoded_bytes).ok_or(
136                GraphContractError::InvalidLimits {
137                    reason: "graph read decoded-byte budget must be nonzero",
138                },
139            )?,
140            hydrated_entities: NonZeroU32::new(hydrated_entities).ok_or(
141                GraphContractError::InvalidLimits {
142                    reason: "graph read entity budget must be nonzero",
143                },
144            )?,
145            hydrated_paths: NonZeroU32::new(hydrated_paths).ok_or(
146                GraphContractError::InvalidLimits {
147                    reason: "graph read path budget must be nonzero",
148                },
149            )?,
150        })
151    }
152
153    /// Maximum input keys, frontier selectors, or purpose-owner paths.
154    #[must_use]
155    pub const fn requested_rows(self) -> u32 {
156        self.requested_rows.get()
157    }
158
159    /// Maximum fully reconstructed rows.
160    #[must_use]
161    pub const fn returned_rows(self) -> u32 {
162        self.returned_rows.get()
163    }
164
165    /// Maximum decoded raw payload bytes.
166    #[must_use]
167    pub const fn decoded_bytes(self) -> u64 {
168        self.decoded_bytes.get()
169    }
170
171    /// Maximum unique hydrated entities.
172    #[must_use]
173    pub const fn hydrated_entities(self) -> u32 {
174        self.hydrated_entities.get()
175    }
176
177    /// Maximum unique purpose-owner paths.
178    #[must_use]
179    pub const fn hydrated_paths(self) -> u32 {
180        self.hydrated_paths.get()
181    }
182}
183
184/// Exact work observed while hydrating one stable-key graph batch.
185#[derive(Clone, Copy, Debug, Eq, PartialEq)]
186pub struct RepositoryGraphReadWork {
187    /// Input keys, frontier selectors, or purpose-owner paths supplied.
188    pub requested_rows: u32,
189    /// Fully reconstructed rows returned to the caller.
190    pub returned_rows: u32,
191    /// Raw `SQLite` BLOB, TEXT, and fixed scalar bytes decoded.
192    pub decoded_bytes: u64,
193    /// Unique entities reconstructed from normalized rows.
194    pub hydrated_entities: u32,
195    /// Unique purpose-owning repository paths retained from those entities.
196    pub hydrated_paths: u32,
197}
198
199/// Fully reconstructed graph rows plus their exact bounded read work.
200#[derive(Clone, Debug, Eq, PartialEq)]
201pub struct RepositoryGraphReadBatch<T> {
202    /// Fully validated rows in caller key order.
203    pub rows: Vec<T>,
204    /// Exact resource use for the complete successful batch.
205    pub work: RepositoryGraphReadWork,
206}
207
208/// One stable paged graph result plus exact database work for the page attempt.
209#[derive(Clone, Debug, Eq, PartialEq)]
210pub struct RepositoryGraphReadPage<T> {
211    /// Stable bounded page, including its truncation sentinel result.
212    pub page: RepositoryGraphPage<T>,
213    /// Exact work for all decoded rows, including a removed sentinel.
214    pub work: RepositoryGraphReadWork,
215}
216
217/// Ordered per-owner graph pages plus exact aggregate database work.
218#[derive(Clone, Debug, Eq, PartialEq)]
219pub struct RepositoryGraphReadPages<T> {
220    /// Pages in caller owner order.
221    pub pages: Vec<RepositoryGraphPage<T>>,
222    /// Exact aggregate work across every page and truncation sentinel.
223    pub work: RepositoryGraphReadWork,
224}
225
226/// Bounded filters for opt-in project-wide coverage discovery.
227#[derive(Clone, Debug, Eq, PartialEq)]
228pub struct RepositoryCoverageQuery {
229    /// Zero-based result offset after filters are applied.
230    pub start_index: u32,
231    /// Maximum rows returned before the overflow sentinel.
232    pub limit: u32,
233    /// Optional normalized repository path prefix.
234    pub path_prefix: Option<String>,
235    /// Optional source parser pass.
236    pub parser: Option<ParserKind>,
237    /// Optional derived-fact provider pass.
238    pub provider: Option<ParserKind>,
239    /// Optional relation family.
240    pub relation: Option<GraphRelationKind>,
241    /// Optional coverage lifecycle state.
242    pub state: Option<CoverageState>,
243    /// Optional exact persisted reason.
244    pub reason: Option<String>,
245}
246
247/// One discovered coverage row with parse and fact-provider provenance.
248#[derive(Clone, Debug, Eq, PartialEq)]
249pub struct RepositoryCoverageRow {
250    /// Validated normalized graph coverage record.
251    pub coverage: CoverageRecord,
252    /// Source parser pass for path-scoped coverage.
253    pub parser: Option<ParserKind>,
254    /// Fact provider pass for path-scoped coverage.
255    pub provider: Option<ParserKind>,
256}
257
258/// One folder or file whose current graph context should enrich navigation.
259#[derive(Clone, Debug, Eq, PartialEq)]
260pub struct RepositoryNavigationNode {
261    /// Exact repository-relative path.
262    pub path: String,
263    /// Folder or file ownership semantics used by the set query.
264    pub kind: NodeKind,
265}
266
267/// Bounded current graph evidence for one folder or file navigation row.
268#[derive(Clone, Debug, Eq, PartialEq)]
269pub struct RepositoryNavigationConnections {
270    /// Exact repository-relative owner path.
271    pub path: String,
272    /// Sparse stable-order family counts.
273    pub counts: Vec<RankedConnectionCount>,
274    /// Bounded stable-order connection sample.
275    pub connections: Vec<RankedConnection>,
276    /// Whether the bounded sample omitted any validated relation through family or global overflow.
277    pub truncated: bool,
278}
279
280/// Maximum owners admitted to one generated set-oriented navigation statement.
281const NAVIGATION_CONNECTION_OWNER_CHUNK: usize = 8;
282
283/// Stable family order and normalized persisted selectors for navigation context.
284const NAVIGATION_CONNECTION_FAMILIES: &[(RankedConnectionKind, &str, &str)] = &[
285    (RankedConnectionKind::Package, "legacy", "depends-on"),
286    (RankedConnectionKind::Import, "legacy", "imports"),
287    (RankedConnectionKind::Call, "legacy", "calls"),
288    (RankedConnectionKind::Reference, "extended", "references"),
289    (RankedConnectionKind::Test, "extended", "tests"),
290    (RankedConnectionKind::Route, "extended", "routes-to"),
291    (RankedConnectionKind::Config, "extended", "configures"),
292];
293
294/// Conservative persisted footprint owned by exact affected source paths.
295#[derive(Clone, Copy, Debug, Eq, PartialEq)]
296pub struct RepositoryAffectedSourceFootprint {
297    /// Existing persisted rows, including a conservative resolution-witness allowance.
298    pub rows: u64,
299    /// UTF-8, BLOB, and fixed-width scalar bytes represented by those rows.
300    pub retained_bytes: u64,
301    /// Whether `rows` reached the caller's `LIMIT + 1` overflow sentinel.
302    pub truncated: bool,
303}
304
305/// One persisted export candidate paired with the canonical key that selected it.
306#[derive(Clone, Debug, Eq, PartialEq)]
307pub struct RepositoryResolutionCandidate {
308    /// Exact canonical key exported by the entity.
309    key: CanonicalResolutionKey,
310    /// Typed entity that currently exports the key.
311    entity: GraphEntity,
312}
313
314impl RepositoryResolutionCandidate {
315    /// Borrow the canonical key that selected this candidate.
316    #[must_use]
317    pub const fn key(&self) -> &CanonicalResolutionKey {
318        &self.key
319    }
320
321    /// Borrow the typed export candidate.
322    #[must_use]
323    pub const fn entity(&self) -> &GraphEntity {
324        &self.entity
325    }
326}
327
328/// Closed relation lookup shapes owned by normalized graph storage.
329#[derive(Clone, Debug, Eq, PartialEq)]
330pub enum RepositoryGraphRelationQuery {
331    /// Relations whose source is one exact stable entity.
332    Outbound {
333        /// Exact project-qualified source key.
334        source: GraphEntityKey,
335    },
336    /// Relations whose resolved or external target is one exact stable entity.
337    Inbound {
338        /// Exact project-qualified target key.
339        target: GraphEntityKey,
340    },
341    /// Relations in one typed legacy or extended family.
342    Family {
343        /// Exact relation family.
344        relation: GraphRelationKind,
345    },
346}
347
348/// Direction of one batched normalized-graph adjacency read.
349#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
350pub enum RepositoryGraphDirection {
351    /// Relations whose source is in the selected frontier.
352    Outbound,
353    /// Relations whose retained target is in the selected frontier.
354    Inbound,
355}
356
357/// One normalized relation with the endpoint entities already hydrated.
358#[derive(Clone, Debug, Eq, PartialEq)]
359pub struct RepositoryGraphRelationRow {
360    /// Fully reconstructed normalized relation.
361    pub relation: LogicalRelation,
362    /// Exact source entity named by the relation.
363    pub source: GraphEntity,
364    /// Retained resolved or external target, when the relation has one.
365    pub target: Option<GraphEntity>,
366}
367
368/// Opaque keyset used only to continue one bounded adjacency request.
369#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
370pub struct RepositoryGraphAdjacencyContinuation {
371    /// Project whose read snapshot produced this keyset.
372    project: ProjectInstanceId,
373    /// Complete graph generation whose read snapshot produced this keyset.
374    generation: IndexGeneration,
375    /// Direction whose stable order produced this keyset.
376    direction: RepositoryGraphDirection,
377    /// Optional exact relation family whose stable order produced this keyset.
378    relation: Option<GraphRelationKind>,
379    /// Ordered frontier identity whose result order produced this keyset.
380    frontier: Vec<[u8; 32]>,
381    /// Zero-based frontier position of the last returned relation.
382    frontier_index: u32,
383    /// Persisted relation family scope of the last returned relation.
384    relation_scope: String,
385    /// Persisted relation family value of the last returned relation.
386    relation_kind: String,
387    /// Stable compact key of the last returned relation.
388    relation_key: [u8; 32],
389}
390
391/// One logical relation paired with the frontier entity that selected it.
392#[derive(Clone, Debug, Eq, PartialEq)]
393pub struct RepositoryGraphAdjacencyRow {
394    /// Zero-based position of the selecting entity in the request frontier.
395    pub frontier_index: u32,
396    /// Exact project-qualified entity that selected this relation.
397    pub frontier: GraphEntityKey,
398    /// Direction relative to the selecting frontier entity.
399    pub direction: RepositoryGraphDirection,
400    /// Normalized relation and its already-hydrated endpoints.
401    pub detail: RepositoryGraphRelationRow,
402}
403
404/// One bounded direction-specific adjacency page.
405#[derive(Clone, Debug, Eq, PartialEq)]
406pub struct RepositoryGraphAdjacencyPage {
407    /// Fully validated rows in deterministic frontier and relation order.
408    pub rows: Vec<RepositoryGraphAdjacencyRow>,
409    /// Whether at least one additional validated relation exists.
410    pub truncated: bool,
411    /// Opaque continuation for the same frontier and direction when truncated.
412    pub continuation: Option<RepositoryGraphAdjacencyContinuation>,
413}
414
415/// One bounded adjacency page plus exact database work for the page attempt.
416#[derive(Clone, Debug, Eq, PartialEq)]
417pub struct RepositoryGraphAdjacencyReadPage {
418    /// Stable direction-specific page and continuation state.
419    pub page: RepositoryGraphAdjacencyPage,
420    /// Exact raw-row and endpoint hydration work for the complete page.
421    pub work: RepositoryGraphReadWork,
422}
423
424/// Maximum unique entities admitted to one batched adjacency statement.
425pub const MAX_REPOSITORY_GRAPH_FRONTIER: usize = 256;
426
427/// Maximum relation rows admitted across all per-frontier query branches.
428const MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS: usize = GraphLimits::MAX_ROWS as usize + 1;
429
430/// Maximum stable entity keys hydrated through one prepared `VALUES` join.
431const GRAPH_ENTITY_HYDRATION_CHUNK: usize = 128;
432
433/// Raw normalized entity row collected before domain reconstruction.
434struct EntityRow {
435    /// Compact stable entity key.
436    key: Vec<u8>,
437    /// Owning project identity.
438    project: Vec<u8>,
439    /// Canonical collision witness.
440    canonical: String,
441    /// Normalized selector variant.
442    kind: String,
443    /// Folder, file, or symbol repository path.
444    repository_path: Option<String>,
445    /// Package ecosystem.
446    package_manager: Option<String>,
447    /// Manifest package name.
448    package_name: Option<String>,
449    /// Owning package manifest.
450    manifest_path: Option<String>,
451    /// Declaration name.
452    symbol_name: Option<String>,
453    /// Declaration kind.
454    symbol_kind: Option<String>,
455    /// Optional containing declaration.
456    symbol_parent: Option<String>,
457    /// Stable declaration signature.
458    symbol_signature: Option<String>,
459    /// External namespace.
460    external_system: Option<String>,
461    /// Identity inside the external namespace.
462    external_identity: Option<String>,
463}
464
465/// Raw normalized relation row collected before domain reconstruction.
466struct RelationRow {
467    /// Compact stable relation key.
468    key: Vec<u8>,
469    /// Owning project identity.
470    project: Vec<u8>,
471    /// Canonical collision witness.
472    canonical: String,
473    /// Stable source entity key.
474    source: Vec<u8>,
475    /// Legacy or extended family scope.
476    relation_scope: String,
477    /// Family spelling within the scope.
478    relation_kind: String,
479    /// Resolution lifecycle state.
480    resolution_status: String,
481    /// Optional resolved or external target key.
482    target: Option<Vec<u8>>,
483    /// Optional unresolved reference text.
484    reference: Option<String>,
485    /// Optional ambiguous candidate count.
486    candidate_count: Option<i64>,
487    /// Coarse trust class.
488    confidence: String,
489    /// Producer completeness.
490    completeness: String,
491}
492
493/// One raw relation paired with its selecting frontier position.
494struct AdjacencyRelationRow {
495    /// Zero-based position inside the request frontier.
496    frontier_index: u32,
497    /// Raw normalized relation selected by the indexed adjacency branch.
498    relation: RelationRow,
499}
500
501/// Raw normalized relation occurrence row.
502struct OccurrenceRow {
503    /// Stable logical relation key.
504    relation: Vec<u8>,
505    /// Exact repository-local source file.
506    file_path: String,
507    /// First one-based source line.
508    start_line: i64,
509    /// First zero-based source column.
510    start_column: i64,
511    /// Last one-based source line.
512    end_line: i64,
513    /// Exclusive zero-based end column.
514    end_column: i64,
515}
516
517/// Raw normalized graph coverage row.
518struct CoverageRow {
519    /// Owning project identity.
520    project: Vec<u8>,
521    /// Project or path scope discriminator.
522    scope_kind: String,
523    /// Optional repository path scope.
524    scope_path: Option<String>,
525    /// Optional legacy or extended relation scope.
526    relation_scope: Option<String>,
527    /// Optional relation family spelling.
528    relation_kind: Option<String>,
529    /// Coverage lifecycle state.
530    state: String,
531    /// Persisted total items in scope.
532    total: i64,
533    /// Successfully covered items.
534    covered: i64,
535    /// Omitted or untrusted items.
536    omitted: i64,
537    /// Optional actionable explanation.
538    reason: Option<String>,
539    /// Optional reached product limit.
540    reached_limit: Option<String>,
541    /// Optional source parser pass joined from file metadata.
542    parser: Option<String>,
543    /// Optional derived-fact provider pass joined from file metadata.
544    provider: Option<String>,
545}
546
547/// Mutable accounting retained only for one bounded hydration call.
548pub(crate) struct RepositoryGraphReadMeter {
549    /// Validated caller envelope.
550    budget: RepositoryGraphReadBudget,
551    /// Input selectors admitted before any query runs.
552    requested_rows: u32,
553    /// Raw payload bytes decoded so far.
554    decoded_bytes: u64,
555    /// Unique entities reconstructed so far.
556    hydrated_entities: u32,
557    /// Unique purpose-owning paths retained from hydrated entities.
558    hydrated_paths: HashSet<String>,
559}
560
561impl RepositoryGraphReadMeter {
562    /// Admit one request before any `SQLite` work begins.
563    pub(crate) fn new(budget: RepositoryGraphReadBudget, requested_rows: usize) -> DbResult<Self> {
564        let requested_rows =
565            u32::try_from(requested_rows).map_err(|_source| GraphContractError::InvalidLimits {
566                reason: "graph read requested-row count overflowed",
567            })?;
568        if requested_rows > budget.requested_rows() {
569            return Err(GraphContractError::InvalidLimits {
570                reason: "graph read requested rows exceed the batch budget",
571            }
572            .into());
573        }
574        Ok(Self {
575            budget,
576            requested_rows,
577            decoded_bytes: 0,
578            hydrated_entities: 0,
579            hydrated_paths: HashSet::new(),
580        })
581    }
582
583    /// Charge one decoded raw row before it leaves the row iterator.
584    pub(crate) fn record_decoded_bytes(&mut self, bytes: u64) -> DbResult<()> {
585        let decoded_bytes =
586            self.decoded_bytes
587                .checked_add(bytes)
588                .ok_or(GraphContractError::InvalidLimits {
589                    reason: "graph read decoded-byte accounting overflowed",
590                })?;
591        if decoded_bytes > self.budget.decoded_bytes() {
592            return Err(GraphContractError::InvalidLimits {
593                reason: "graph read decoded bytes exceed the batch budget",
594            }
595            .into());
596        }
597        self.decoded_bytes = decoded_bytes;
598        Ok(())
599    }
600
601    /// Charge one unique reconstructed entity and its exact purpose owner path.
602    fn record_entity(&mut self, entity: &GraphEntity) -> DbResult<()> {
603        let hydrated_entities =
604            self.hydrated_entities
605                .checked_add(1)
606                .ok_or(GraphContractError::InvalidLimits {
607                    reason: "graph read entity accounting overflowed",
608                })?;
609        if hydrated_entities > self.budget.hydrated_entities() {
610            return Err(GraphContractError::InvalidLimits {
611                reason: "graph read hydrated entities exceed the batch budget",
612            }
613            .into());
614        }
615        if let Some(path) = graph_entity_purpose_owner(entity) {
616            self.record_hydrated_path(path)?;
617        }
618        self.hydrated_entities = hydrated_entities;
619        Ok(())
620    }
621
622    /// Charge one unique repository path hydrated from authoritative node state.
623    pub(crate) fn record_hydrated_path(&mut self, path: &str) -> DbResult<()> {
624        if self.hydrated_paths.contains(path) {
625            return Ok(());
626        }
627        let hydrated_paths = u32::try_from(self.hydrated_paths.len()).map_err(|_source| {
628            GraphContractError::InvalidLimits {
629                reason: "graph read path accounting overflowed",
630            }
631        })?;
632        if hydrated_paths >= self.budget.hydrated_paths() {
633            return Err(GraphContractError::InvalidLimits {
634                reason: "graph read hydrated paths exceed the batch budget",
635            }
636            .into());
637        }
638        self.hydrated_paths.insert(path.to_string());
639        Ok(())
640    }
641
642    /// Finish exact work only after every requested row was reconstructed.
643    pub(crate) fn finish(self, returned_rows: usize) -> DbResult<RepositoryGraphReadWork> {
644        let returned_rows =
645            u32::try_from(returned_rows).map_err(|_source| GraphContractError::InvalidLimits {
646                reason: "graph read returned-row count overflowed",
647            })?;
648        if returned_rows > self.budget.returned_rows() {
649            return Err(GraphContractError::InvalidLimits {
650                reason: "graph read returned rows exceed the batch budget",
651            }
652            .into());
653        }
654        let hydrated_paths = u32::try_from(self.hydrated_paths.len()).map_err(|_source| {
655            GraphContractError::InvalidLimits {
656                reason: "graph read path accounting overflowed",
657            }
658        })?;
659        Ok(RepositoryGraphReadWork {
660            requested_rows: self.requested_rows,
661            returned_rows,
662            decoded_bytes: self.decoded_bytes,
663            hydrated_entities: self.hydrated_entities,
664            hydrated_paths,
665        })
666    }
667}
668
669/// One bounded flattened relation row used only for navigation enrichment.
670struct NavigationConnectionRow {
671    /// Zero-based owner position inside the current statement chunk.
672    owner_index: usize,
673    /// Closed connection family selected by the query branch.
674    kind: RankedConnectionKind,
675    /// Direction relative to the owner.
676    direction: RankedConnectionDirection,
677    /// Stable relation key used for deterministic ordering and deduplication.
678    relation_key: Vec<u8>,
679    /// Persisted resolution lifecycle state.
680    resolution_status: String,
681    /// Persisted unresolved or ambiguous identity.
682    reference: Option<String>,
683    /// Related normalized entity kind, absent only for unresolved output.
684    entity_kind: Option<String>,
685    /// Related repository path.
686    repository_path: Option<String>,
687    /// Related package ecosystem.
688    package_manager: Option<String>,
689    /// Related package name.
690    package_name: Option<String>,
691    /// Related package manifest.
692    manifest_path: Option<String>,
693    /// Related declaration name.
694    symbol_name: Option<String>,
695    /// Related external namespace.
696    external_system: Option<String>,
697    /// Related external identity.
698    external_identity: Option<String>,
699}
700
701/// Return one empty navigation page while retaining the requested owner path.
702fn empty_navigation_connections(path: &str) -> RepositoryNavigationConnections {
703    RepositoryNavigationConnections {
704        path: path.to_string(),
705        counts: Vec::new(),
706        connections: Vec::new(),
707        truncated: false,
708    }
709}
710
711/// Load one bounded chunk with a single compound set-oriented statement.
712fn collect_navigation_connection_rows(
713    connection: &Connection,
714    owners: &[RepositoryNavigationNode],
715    family_limit_plus_one: i64,
716) -> DbResult<Vec<NavigationConnectionRow>> {
717    let mut branches = Vec::with_capacity(owners.len() * NAVIGATION_CONNECTION_FAMILIES.len() * 2);
718    let mut values = Vec::new();
719    for (owner_index, owner) in owners.iter().enumerate() {
720        for &(kind, scope, relation) in NAVIGATION_CONNECTION_FAMILIES {
721            branches.push(navigation_connection_branch(
722                owner_index,
723                owner,
724                kind,
725                scope,
726                relation,
727                RankedConnectionDirection::Outbound,
728                family_limit_plus_one,
729                &mut values,
730            ));
731            if owner.kind != NodeKind::Folder || owner.path != "." {
732                branches.push(navigation_connection_branch(
733                    owner_index,
734                    owner,
735                    kind,
736                    scope,
737                    relation,
738                    RankedConnectionDirection::Inbound,
739                    family_limit_plus_one,
740                    &mut values,
741                ));
742            }
743        }
744    }
745    let sql = branches.join(" UNION ALL ");
746    let mut statement = connection.prepare(&sql)?;
747    let mut rows = statement.query(params_from_iter(values))?;
748    let mut collected = Vec::new();
749    while let Some(row) = rows.next()? {
750        collected.push(navigation_connection_row(row)?);
751    }
752    Ok(collected)
753}
754
755/// Build one indexed outbound or inbound query branch for one owner and family.
756fn navigation_connection_branch(
757    owner_index: usize,
758    owner: &RepositoryNavigationNode,
759    kind: RankedConnectionKind,
760    scope: &'static str,
761    relation: &'static str,
762    direction: RankedConnectionDirection,
763    family_limit_plus_one: i64,
764    values: &mut Vec<Value>,
765) -> String {
766    values.push(Value::Integer(owner_index as i64));
767    values.push(Value::Text(
768        navigation_connection_kind_name(kind).to_string(),
769    ));
770    if owner.kind == NodeKind::Folder
771        && owner.path == "."
772        && direction == RankedConnectionDirection::Outbound
773    {
774        values.push(Value::Text(scope.to_string()));
775        values.push(Value::Text(relation.to_string()));
776        values.push(Value::Integer(family_limit_plus_one));
777        return "SELECT * FROM (
778                    SELECT ? AS owner_index, ? AS expected_kind, 'outbound' AS direction,
779                           r.relation_key, r.relation_scope, r.relation_kind,
780                           r.resolution_status, r.reference_text,
781                           related.entity_kind, related.repository_path,
782                           related.package_manager, related.package_name, related.manifest_path,
783                           related.symbol_name, related.external_system, related.external_identity
784                      FROM graph_relations r INDEXED BY idx_graph_relations_kind_order
785                      LEFT JOIN graph_entities related
786                        ON related.entity_key = r.target_entity_key
787                     WHERE r.relation_scope = ? AND r.relation_kind = ?
788                     ORDER BY r.relation_key
789                     LIMIT ?
790                )"
791        .to_string();
792    }
793    let (relation_key, related_key, index, direction_name) = match direction {
794        RankedConnectionDirection::Outbound => (
795            "source_entity_key",
796            "target_entity_key",
797            "idx_graph_relations_source_kind",
798            "outbound",
799        ),
800        RankedConnectionDirection::Inbound => (
801            "target_entity_key",
802            "source_entity_key",
803            "idx_graph_relations_target_kind",
804            "inbound",
805        ),
806    };
807    let owned = navigation_owned_entity_sql(owner, values);
808    let exclude_internal = if direction == RankedConnectionDirection::Inbound {
809        let owned_sources = navigation_owned_entity_sql(owner, values);
810        format!(" AND r.source_entity_key NOT IN ({owned_sources})")
811    } else {
812        String::new()
813    };
814    values.push(Value::Text(scope.to_string()));
815    values.push(Value::Text(relation.to_string()));
816    values.push(Value::Integer(family_limit_plus_one));
817    format!(
818        "SELECT * FROM (
819             SELECT ? AS owner_index, ? AS expected_kind, '{direction_name}' AS direction,
820                    r.relation_key, r.relation_scope, r.relation_kind,
821                    r.resolution_status, r.reference_text,
822                    related.entity_kind, related.repository_path,
823                    related.package_manager, related.package_name, related.manifest_path,
824                    related.symbol_name, related.external_system, related.external_identity
825               FROM graph_relations r INDEXED BY {index}
826               LEFT JOIN graph_entities related ON related.entity_key = r.{related_key}
827              WHERE r.{relation_key} IN ({owned})
828                {exclude_internal}
829                AND r.relation_scope = ? AND r.relation_kind = ?
830              ORDER BY r.relation_key
831              LIMIT ?
832         )"
833    )
834}
835
836/// Build the indexed entity-key ownership set for one navigation owner.
837fn navigation_owned_entity_sql(
838    owner: &RepositoryNavigationNode,
839    values: &mut Vec<Value>,
840) -> String {
841    match owner.kind {
842        NodeKind::File => {
843            values.push(Value::Text(owner.path.clone()));
844            values.push(Value::Text(owner.path.clone()));
845            "SELECT entity_key
846               FROM graph_entities INDEXED BY idx_graph_entities_path
847              WHERE repository_path = ?
848             UNION
849             SELECT entity_key
850               FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
851              WHERE manifest_path = ?"
852                .to_string()
853        }
854        NodeKind::Folder if owner.path == "." => "SELECT entity_key
855               FROM graph_entities INDEXED BY idx_graph_entities_path
856              WHERE repository_path IS NOT NULL
857             UNION
858             SELECT entity_key
859               FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
860              WHERE manifest_path IS NOT NULL"
861            .to_string(),
862        NodeKind::Folder => {
863            let lower = format!("{}/", owner.path);
864            let upper = format!("{}0", owner.path);
865            values.push(Value::Text(owner.path.clone()));
866            values.push(Value::Text(lower.clone()));
867            values.push(Value::Text(upper.clone()));
868            values.push(Value::Text(owner.path.clone()));
869            values.push(Value::Text(lower));
870            values.push(Value::Text(upper));
871            "SELECT entity_key
872               FROM graph_entities INDEXED BY idx_graph_entities_path
873              WHERE repository_path = ?
874             UNION
875             SELECT entity_key
876               FROM graph_entities INDEXED BY idx_graph_entities_path
877              WHERE repository_path >= ? AND repository_path < ?
878             UNION
879             SELECT entity_key
880               FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
881              WHERE manifest_path = ?
882             UNION
883             SELECT entity_key
884               FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
885              WHERE manifest_path >= ? AND manifest_path < ?"
886                .to_string()
887        }
888    }
889}
890
891/// Decode one flattened navigation row without accepting partial corruption.
892fn navigation_connection_row(row: &Row<'_>) -> DbResult<NavigationConnectionRow> {
893    let owner_index = count_to_usize("navigation_connection.owner_index", row.get(0)?)?;
894    let expected_kind: String = row.get(1)?;
895    let direction = match row.get::<_, String>(2)?.as_str() {
896        "outbound" => RankedConnectionDirection::Outbound,
897        "inbound" => RankedConnectionDirection::Inbound,
898        _ => {
899            return Err(DbError::GraphRowShape {
900                table: "graph_relations",
901                reason: "navigation relation direction is invalid",
902            });
903        }
904    };
905    let relation_key: Vec<u8> = row.get(3)?;
906    if relation_key.len() != 32 {
907        return Err(DbError::InvalidBlobLength {
908            field: "graph_relations.relation_key",
909            expected: 32,
910            found: relation_key.len(),
911        });
912    }
913    let relation_scope: String = row.get(4)?;
914    let relation_kind: String = row.get(5)?;
915    let kind = navigation_connection_kind(&relation_scope, &relation_kind)?;
916    if navigation_connection_kind_name(kind) != expected_kind {
917        return Err(DbError::GraphRowShape {
918            table: "graph_relations",
919            reason: "navigation relation family does not match its query branch",
920        });
921    }
922    let resolution_status: String = row.get(6)?;
923    if !matches!(
924        resolution_status.as_str(),
925        "resolved" | "ambiguous" | "unresolved" | "external"
926    ) {
927        return Err(DbError::InvalidEnum {
928            field: "graph_relations.resolution_status",
929            value: resolution_status,
930        });
931    }
932    Ok(NavigationConnectionRow {
933        owner_index,
934        kind,
935        direction,
936        relation_key,
937        resolution_status,
938        reference: row.get(7)?,
939        entity_kind: row.get(8)?,
940        repository_path: row.get(9)?,
941        package_manager: row.get(10)?,
942        package_name: row.get(11)?,
943        manifest_path: row.get(12)?,
944        symbol_name: row.get(13)?,
945        external_system: row.get(14)?,
946        external_identity: row.get(15)?,
947    })
948}
949
950/// Compose deterministic per-owner pages from fully decoded rows.
951fn navigation_connection_pages(
952    owners: &[RepositoryNavigationNode],
953    mut rows: Vec<NavigationConnectionRow>,
954    family_limit: usize,
955    sample_limit: usize,
956) -> DbResult<Vec<RepositoryNavigationConnections>> {
957    rows.sort_by(|left, right| {
958        left.owner_index
959            .cmp(&right.owner_index)
960            .then_with(|| left.kind.cmp(&right.kind))
961            .then_with(|| {
962                navigation_direction_order(left.direction)
963                    .cmp(&navigation_direction_order(right.direction))
964            })
965            .then_with(|| left.relation_key.cmp(&right.relation_key))
966    });
967    let mut grouped =
968        BTreeMap::<(usize, RankedConnectionKind), Vec<NavigationConnectionRow>>::new();
969    for row in rows {
970        if row.owner_index >= owners.len() {
971            return Err(DbError::GraphRowShape {
972                table: "graph_relations",
973                reason: "navigation relation owner index is outside its request chunk",
974            });
975        }
976        grouped
977            .entry((row.owner_index, row.kind))
978            .or_default()
979            .push(row);
980    }
981
982    owners
983        .iter()
984        .enumerate()
985        .map(|(owner_index, owner)| {
986            let mut page = empty_navigation_connections(&owner.path);
987            for &(kind, _, _) in NAVIGATION_CONNECTION_FAMILIES {
988                let Some(rows) = grouped.remove(&(owner_index, kind)) else {
989                    continue;
990                };
991                let mut seen = HashSet::new();
992                let unique = rows
993                    .into_iter()
994                    .filter(|row| seen.insert(row.relation_key.clone()))
995                    .collect::<Vec<_>>();
996                let truncated = unique.len() > family_limit;
997                let count = unique.len().min(family_limit);
998                page.counts.push(RankedConnectionCount {
999                    kind,
1000                    count,
1001                    truncated,
1002                });
1003                for (family_index, row) in unique.into_iter().enumerate() {
1004                    let target = navigation_connection_target(&row)?;
1005                    if family_index < family_limit && page.connections.len() < sample_limit {
1006                        page.connections.push(RankedConnection {
1007                            kind,
1008                            direction: row.direction,
1009                            target,
1010                        });
1011                    } else if family_index < family_limit {
1012                        page.truncated = true;
1013                    }
1014                }
1015                page.truncated |= truncated;
1016            }
1017            Ok(page)
1018        })
1019        .collect()
1020}
1021
1022/// Convert a persisted endpoint or unresolved reference into its compact target.
1023fn navigation_connection_target(row: &NavigationConnectionRow) -> DbResult<RankedConnectionTarget> {
1024    if row.direction == RankedConnectionDirection::Outbound
1025        && matches!(row.resolution_status.as_str(), "ambiguous" | "unresolved")
1026    {
1027        let reference = row.reference.clone().ok_or(DbError::GraphRowShape {
1028            table: "graph_relations",
1029            reason: "unresolved navigation relation is missing its reference",
1030        })?;
1031        if row.entity_kind.is_some() {
1032            return Err(DbError::GraphRowShape {
1033                table: "graph_relations",
1034                reason: "unresolved navigation relation retained a target entity",
1035            });
1036        }
1037        return Ok(RankedConnectionTarget::Unresolved { reference });
1038    }
1039    if !matches!(row.resolution_status.as_str(), "resolved" | "external") {
1040        return Err(DbError::GraphRowShape {
1041            table: "graph_relations",
1042            reason: "inbound navigation relation has no resolved target",
1043        });
1044    }
1045    match row.entity_kind.as_deref() {
1046        Some("project") => Ok(RankedConnectionTarget::Local {
1047            path: ".".to_string(),
1048            symbol: None,
1049        }),
1050        Some("folder" | "file") => Ok(RankedConnectionTarget::Local {
1051            path: required_navigation_target(
1052                row.repository_path.as_deref(),
1053                "local navigation target is missing its repository path",
1054            )?,
1055            symbol: None,
1056        }),
1057        Some("symbol") => Ok(RankedConnectionTarget::Local {
1058            path: required_navigation_target(
1059                row.repository_path.as_deref(),
1060                "symbol navigation target is missing its repository path",
1061            )?,
1062            symbol: Some(required_navigation_target(
1063                row.symbol_name.as_deref(),
1064                "symbol navigation target is missing its declaration name",
1065            )?),
1066        }),
1067        Some("package") => Ok(RankedConnectionTarget::Package {
1068            manager: required_navigation_target(
1069                row.package_manager.as_deref(),
1070                "package navigation target is missing its manager",
1071            )?,
1072            name: required_navigation_target(
1073                row.package_name.as_deref(),
1074                "package navigation target is missing its name",
1075            )?,
1076            manifest: required_navigation_target(
1077                row.manifest_path.as_deref(),
1078                "package navigation target is missing its manifest",
1079            )?,
1080        }),
1081        Some("external") => Ok(RankedConnectionTarget::External {
1082            system: required_navigation_target(
1083                row.external_system.as_deref(),
1084                "external navigation target is missing its system",
1085            )?,
1086            identity: required_navigation_target(
1087                row.external_identity.as_deref(),
1088                "external navigation target is missing its identity",
1089            )?,
1090        }),
1091        Some(_) => Err(DbError::GraphRowShape {
1092            table: "graph_entities",
1093            reason: "navigation endpoint has an unsupported entity kind",
1094        }),
1095        None => Err(DbError::GraphRowShape {
1096            table: "graph_relations",
1097            reason: "resolved navigation relation target is missing",
1098        }),
1099    }
1100}
1101
1102/// Clone one required nonempty target field.
1103fn required_navigation_target(value: Option<&str>, reason: &'static str) -> DbResult<String> {
1104    value
1105        .filter(|value| !value.is_empty())
1106        .map(str::to_string)
1107        .ok_or(DbError::GraphRowShape {
1108            table: "graph_entities",
1109            reason,
1110        })
1111}
1112
1113/// Map persisted relation family fields to the navigation family inventory.
1114fn navigation_connection_kind(scope: &str, relation: &str) -> DbResult<RankedConnectionKind> {
1115    NAVIGATION_CONNECTION_FAMILIES
1116        .iter()
1117        .find_map(|&(kind, expected_scope, expected_relation)| {
1118            (scope == expected_scope && relation == expected_relation).then_some(kind)
1119        })
1120        .ok_or(DbError::GraphRowShape {
1121            table: "graph_relations",
1122            reason: "relation family is not available to navigation enrichment",
1123        })
1124}
1125
1126/// Return the stable compact name for one navigation family.
1127const fn navigation_connection_kind_name(kind: RankedConnectionKind) -> &'static str {
1128    match kind {
1129        RankedConnectionKind::Package => "package",
1130        RankedConnectionKind::Import => "import",
1131        RankedConnectionKind::Call => "call",
1132        RankedConnectionKind::Reference => "reference",
1133        RankedConnectionKind::Test => "test",
1134        RankedConnectionKind::Route => "route",
1135        RankedConnectionKind::Config => "config",
1136    }
1137}
1138
1139/// Return stable outbound-before-inbound sample order.
1140const fn navigation_direction_order(direction: RankedConnectionDirection) -> u8 {
1141    match direction {
1142        RankedConnectionDirection::Outbound => 0,
1143        RankedConnectionDirection::Inbound => 1,
1144    }
1145}
1146
1147impl AtlasStore {
1148    /// Load bounded current graph context for folder and file navigation rows.
1149    ///
1150    /// Owners are processed through a fixed-size set-oriented statement per
1151    /// chunk. Each family uses separate indexed outbound and inbound branches,
1152    /// exact file plus manifest ownership, or bounded folder-prefix ownership.
1153    /// No partial result is returned if any statement or row fails.
1154    ///
1155    /// # Errors
1156    ///
1157    /// Returns an error for invalid paths or limits, unavailable publication
1158    /// state, `SQLite` failures, or any corrupt relation or endpoint row.
1159    pub fn repository_navigation_connections(
1160        &self,
1161        owners: &[RepositoryNavigationNode],
1162        family_limit: u32,
1163        sample_limit: usize,
1164    ) -> DbResult<Vec<RepositoryNavigationConnections>> {
1165        let family_limit_plus_one = validated_limit_plus_one(
1166            family_limit,
1167            GraphLimits::MAX_ROWS,
1168            "navigation connection rows must be nonzero and within the product ceiling",
1169        )?;
1170        if sample_limit == 0 || sample_limit > GraphLimits::MAX_ROWS as usize {
1171            return Err(GraphContractError::InvalidLimits {
1172                reason: "navigation connection sample must be nonzero and within the product ceiling",
1173            }
1174            .into());
1175        }
1176        for owner in owners {
1177            match owner.kind {
1178                NodeKind::Folder => {
1179                    RepositoryNodePath::new(Path::new(&owner.path))?;
1180                }
1181                NodeKind::File => {
1182                    RepositoryFilePath::new(Path::new(&owner.path))?;
1183                }
1184            }
1185        }
1186        if owners.is_empty() {
1187            return Ok(Vec::new());
1188        }
1189        if self.repository_graph_generation()?.is_none() {
1190            return Ok(owners
1191                .iter()
1192                .map(|owner| empty_navigation_connections(&owner.path))
1193                .collect());
1194        }
1195        let project = load_project_identity(&self.connection)?
1196            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
1197        require_bound_project_identity(&self.connection, project)?;
1198
1199        let mut result = Vec::with_capacity(owners.len());
1200        for chunk in owners.chunks(NAVIGATION_CONNECTION_OWNER_CHUNK) {
1201            let rows =
1202                collect_navigation_connection_rows(&self.connection, chunk, family_limit_plus_one)?;
1203            result.extend(navigation_connection_pages(
1204                chunk,
1205                rows,
1206                family_limit as usize,
1207                sample_limit,
1208            )?);
1209        }
1210        Ok(result)
1211    }
1212
1213    /// Load one typed graph entity by its compact stable key.
1214    ///
1215    /// # Errors
1216    ///
1217    /// Returns an error when publication state, project identity, row shape,
1218    /// canonical identity, or persisted key material is invalid.
1219    pub fn repository_graph_entity(&self, key: &GraphEntityKey) -> DbResult<Option<GraphEntity>> {
1220        let Some(generation) = self.repository_graph_generation()? else {
1221            return Ok(None);
1222        };
1223        if !verify_project_identity(&self.connection, key.project())? {
1224            return Ok(None);
1225        }
1226        Ok(self
1227            .repository_graph_entity_bounded(
1228                key,
1229                generation,
1230                maximum_repository_graph_read_budget()?,
1231                None,
1232            )?
1233            .rows
1234            .into_iter()
1235            .next())
1236    }
1237
1238    /// Load one optional exact entity under a stable graph read envelope.
1239    ///
1240    /// # Errors
1241    ///
1242    /// Returns an error for a stale project or generation, cancellation,
1243    /// `SQLite` failure, corrupt entity state, or any decoded-byte, entity, or
1244    /// path hydration overrun. A missing exact key returns an empty successful
1245    /// batch with exact zero returned work.
1246    pub fn repository_graph_entity_bounded(
1247        &self,
1248        key: &GraphEntityKey,
1249        generation: IndexGeneration,
1250        budget: RepositoryGraphReadBudget,
1251        control: Option<&IndexWorkControl>,
1252    ) -> DbResult<RepositoryGraphReadBatch<GraphEntity>> {
1253        self.require_repository_graph_snapshot(key.project(), generation)?;
1254        let digest = key.digest_bytes()?;
1255        let mut meter = RepositoryGraphReadMeter::new(budget, 1)?;
1256        let mut entities = load_graph_entities_by_digest_metered(
1257            self,
1258            &[digest],
1259            key.project(),
1260            generation,
1261            control,
1262            Some(&mut meter),
1263        )?;
1264        let rows = entities.remove(&digest).into_iter().collect::<Vec<_>>();
1265        let work = meter.finish(rows.len())?;
1266        Ok(RepositoryGraphReadBatch { rows, work })
1267    }
1268
1269    /// Hydrate an ordered unique set of graph entities from compact stable keys.
1270    ///
1271    /// # Errors
1272    ///
1273    /// Returns an error for duplicate or oversized input, a stale project or
1274    /// generation, cancellation, a missing entity, `SQLite` failure, or any
1275    /// invalid persisted key or canonical identity. No partial set is returned.
1276    pub fn repository_graph_entities_by_digest(
1277        &self,
1278        project: ProjectInstanceId,
1279        generation: IndexGeneration,
1280        digests: &[[u8; 32]],
1281        budget: RepositoryGraphReadBudget,
1282        control: Option<&IndexWorkControl>,
1283    ) -> DbResult<RepositoryGraphReadBatch<GraphEntity>> {
1284        validate_graph_hydration_request(digests)?;
1285        self.require_repository_graph_snapshot(project, generation)?;
1286        let mut meter = RepositoryGraphReadMeter::new(budget, digests.len())?;
1287        let mut entities = load_graph_entities_by_digest_metered(
1288            self,
1289            digests,
1290            project,
1291            generation,
1292            control,
1293            Some(&mut meter),
1294        )?;
1295        let mut ordered = Vec::with_capacity(digests.len());
1296        for digest in digests {
1297            ordered.push(entities.remove(digest).ok_or(DbError::GraphRowShape {
1298                table: "graph_entities",
1299                reason: "requested graph entity is missing",
1300            })?);
1301        }
1302        let work = meter.finish(ordered.len())?;
1303        Ok(RepositoryGraphReadBatch {
1304            rows: ordered,
1305            work,
1306        })
1307    }
1308
1309    /// Load a bounded page of entities that use one exact repository path.
1310    ///
1311    /// # Errors
1312    ///
1313    /// Returns an error for invalid limits, unavailable publication state,
1314    /// project mismatch, `SQLite` failure, or any corrupt row in the page.
1315    pub fn repository_graph_entities_by_path(
1316        &self,
1317        project: ProjectInstanceId,
1318        path: &RepositoryNodePath,
1319        limit: u32,
1320    ) -> DbResult<RepositoryGraphPage<GraphEntity>> {
1321        validated_limit_plus_one(
1322            limit,
1323            GraphLimits::MAX_ROWS,
1324            "graph rows must be nonzero and within the product ceiling",
1325        )?;
1326        let Some(generation) = self.repository_graph_generation()? else {
1327            return Ok(empty_page());
1328        };
1329        if !verify_project_identity(&self.connection, project)? {
1330            return Ok(empty_page());
1331        }
1332        Ok(self
1333            .repository_graph_entities_by_path_bounded(
1334                project,
1335                generation,
1336                path,
1337                limit,
1338                maximum_repository_graph_read_budget()?,
1339                None,
1340            )?
1341            .page)
1342    }
1343
1344    /// Load one exact-path entity page under a stable graph read envelope.
1345    ///
1346    /// # Errors
1347    ///
1348    /// Returns the same fail-closed errors as
1349    /// [`Self::repository_graph_entities_by_path`], rejects a stale project or
1350    /// generation and any returned-row, decoded-byte, entity, or path hydration
1351    /// overrun, and meters the raw truncation sentinel.
1352    pub fn repository_graph_entities_by_path_bounded(
1353        &self,
1354        project: ProjectInstanceId,
1355        generation: IndexGeneration,
1356        path: &RepositoryNodePath,
1357        limit: u32,
1358        budget: RepositoryGraphReadBudget,
1359        control: Option<&IndexWorkControl>,
1360    ) -> DbResult<RepositoryGraphReadPage<GraphEntity>> {
1361        let limit_plus_one = validated_limit_plus_one(
1362            limit,
1363            GraphLimits::MAX_ROWS,
1364            "graph rows must be nonzero and within the product ceiling",
1365        )?;
1366        self.require_repository_graph_snapshot(project, generation)?;
1367        let mut meter = RepositoryGraphReadMeter::new(budget, 1)?;
1368        let raw = with_sqlite_read_progress(
1369            &self.connection,
1370            control,
1371            IndexWorkStage::RepositoryTraversal,
1372            || {
1373                let mut statement = self.connection.prepare_cached(
1374                    "SELECT entity_key, project_instance_id, canonical_identity, entity_kind,
1375                            repository_path, package_manager, package_name, manifest_path,
1376                            symbol_name, symbol_kind, symbol_parent, symbol_signature,
1377                            external_system, external_identity
1378                       FROM graph_entities
1379                      WHERE project_instance_id = ?1 AND repository_path = ?2
1380                      ORDER BY entity_kind, entity_key
1381                      LIMIT ?3",
1382                )?;
1383                collect_entity_rows_metered(
1384                    statement.query(params![
1385                        &project.as_bytes()[..],
1386                        path.as_str(),
1387                        limit_plus_one
1388                    ])?,
1389                    &mut meter,
1390                )
1391            },
1392        )?;
1393        let page = page_from_raw(raw, limit, |row| {
1394            let entity = entity_from_row(row, project, generation)?;
1395            meter.record_entity(&entity)?;
1396            Ok(entity)
1397        })?;
1398        let work = meter.finish(page.rows.len())?;
1399        Ok(RepositoryGraphReadPage { page, work })
1400    }
1401
1402    /// Load bounded graph entities that export one exact canonical resolution key.
1403    ///
1404    /// # Errors
1405    ///
1406    /// Returns an error for invalid limits, a project mismatch, a conflicting
1407    /// canonical witness, corrupt graph rows, or `SQLite` failure.
1408    pub fn repository_resolution_candidates(
1409        &self,
1410        key: &CanonicalResolutionKey,
1411        limit: u32,
1412    ) -> DbResult<RepositoryGraphPage<GraphEntity>> {
1413        let limit_plus_one = validated_limit_plus_one(
1414            limit,
1415            GraphLimits::MAX_ROWS,
1416            "resolution candidates must be nonzero and within the product ceiling",
1417        )?;
1418        let Some(generation) = self.repository_graph_generation()? else {
1419            return Ok(empty_page());
1420        };
1421        if !verify_project_identity(&self.connection, key.project())? {
1422            return Ok(empty_page());
1423        }
1424        if !validate_persisted_resolution_key(&self.connection, key)? {
1425            return Ok(empty_page());
1426        }
1427        let raw = {
1428            let mut statement = self.connection.prepare_cached(
1429                "SELECT entity.entity_key, entity.project_instance_id,
1430                        entity.canonical_identity, entity.entity_kind,
1431                        entity.repository_path, entity.package_manager,
1432                        entity.package_name, entity.manifest_path,
1433                        entity.symbol_name, entity.symbol_kind,
1434                        entity.symbol_parent, entity.symbol_signature,
1435                        entity.external_system, entity.external_identity
1436                   FROM graph_entity_exports AS export
1437                        INDEXED BY idx_graph_entity_exports_key
1438                   JOIN graph_entities AS entity
1439                     ON entity.project_instance_id = export.project_instance_id
1440                    AND entity.entity_key = export.entity_key
1441                  WHERE export.project_instance_id = ?1
1442                    AND export.resolution_domain = ?2
1443                    AND export.key_digest = ?3
1444                  ORDER BY export.entity_key
1445                  LIMIT ?4",
1446            )?;
1447            collect_entity_rows(statement.query(params![
1448                &key.project().as_bytes()[..],
1449                key.domain().as_str(),
1450                &key.digest_bytes()[..],
1451                limit_plus_one,
1452            ])?)?
1453        };
1454        page_from_raw(raw, limit, |row| {
1455            entity_from_row(row, key.project(), generation)
1456        })
1457    }
1458
1459    /// Load bounded export candidates for a canonical-key batch in one set-oriented pass.
1460    ///
1461    /// Results retain the selecting key so callers can resolve several relation
1462    /// occurrences without issuing one query per dependency key. Duplicate input
1463    /// keys and duplicate candidate bindings are returned once in stable order.
1464    ///
1465    /// # Errors
1466    ///
1467    /// Returns an error for invalid limits, mixed projects, a conflicting
1468    /// canonical witness, corrupt graph rows, or `SQLite` failure. A terminal row
1469    /// conversion failure rejects the complete operation rather than returning a
1470    /// partial candidate set.
1471    pub fn repository_resolution_candidates_for_keys(
1472        &self,
1473        project: ProjectInstanceId,
1474        keys: &[CanonicalResolutionKey],
1475        limit: u32,
1476    ) -> DbResult<RepositoryGraphPage<RepositoryResolutionCandidate>> {
1477        validated_limit_plus_one(
1478            limit,
1479            GraphLimits::MAX_ROWS,
1480            "resolution candidates must be nonzero and within the product ceiling",
1481        )?;
1482        let Some(generation) = self.repository_graph_generation()? else {
1483            return Ok(empty_page());
1484        };
1485        if !verify_project_identity(&self.connection, project)? {
1486            return Ok(empty_page());
1487        }
1488        let keys = normalized_resolution_keys(project, keys)?;
1489        validate_persisted_resolution_keys(&self.connection, &keys)?;
1490        let mut candidates = Vec::new();
1491        for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
1492            if chunk.is_empty() {
1493                continue;
1494            }
1495            let values_clause = anonymous_values_clause(chunk.len(), 4);
1496            let sql = format!(
1497                "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
1498                      AS (VALUES {values_clause})
1499                 SELECT entity.entity_key, entity.project_instance_id,
1500                        entity.canonical_identity, entity.entity_kind,
1501                        entity.repository_path, entity.package_manager,
1502                        entity.package_name, entity.manifest_path,
1503                        entity.symbol_name, entity.symbol_kind,
1504                        entity.symbol_parent, entity.symbol_signature,
1505                        entity.external_system, entity.external_identity,
1506                        stored.project_instance_id, stored.resolution_domain,
1507                        stored.key_digest, stored.canonical_identity
1508                   FROM requested
1509                   JOIN graph_resolution_keys AS stored
1510                     ON stored.project_instance_id = requested.project_instance_id
1511                    AND stored.resolution_domain = requested.resolution_domain
1512                    AND stored.key_digest = requested.key_digest
1513                    AND stored.canonical_identity = requested.canonical_identity
1514                   JOIN graph_entity_exports AS export
1515                        INDEXED BY idx_graph_entity_exports_key
1516                     ON export.project_instance_id = stored.project_instance_id
1517                    AND export.resolution_domain = stored.resolution_domain
1518                    AND export.key_digest = stored.key_digest
1519                   JOIN graph_entities AS entity
1520                     ON entity.project_instance_id = export.project_instance_id
1521                    AND entity.entity_key = export.entity_key
1522                  ORDER BY stored.resolution_domain, stored.key_digest, export.entity_key
1523                  LIMIT ?"
1524            );
1525            let mut values = resolution_key_values(chunk, true);
1526            values.push(Value::Integer(i64::from(limit) + 1));
1527            let mut statement = self.connection.prepare(&sql)?;
1528            let mut rows = statement.query(params_from_iter(values.iter()))?;
1529            while let Some(row) = rows.next()? {
1530                let entity = entity_from_row(entity_row(row)?, project, generation)?;
1531                let key_project = project_from_blob(
1532                    "graph_resolution_keys.project_instance_id",
1533                    row.get::<_, Vec<u8>>(14)?,
1534                )?;
1535                require_project(project, key_project)?;
1536                let domain_text = row.get::<_, String>(15)?;
1537                let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
1538                let digest = fixed_bytes::<32>(
1539                    "graph_resolution_keys.key_digest",
1540                    row.get::<_, Vec<u8>>(16)?,
1541                )?;
1542                let key = CanonicalResolutionKey::from_persisted(
1543                    key_project,
1544                    domain,
1545                    digest,
1546                    row.get(17)?,
1547                )?;
1548                candidates.push(RepositoryResolutionCandidate { key, entity });
1549            }
1550            if candidates.len() > limit as usize {
1551                break;
1552            }
1553        }
1554        candidates.sort_by(|left, right| {
1555            left.key
1556                .cmp(&right.key)
1557                .then_with(|| left.entity.key().digest().cmp(right.entity.key().digest()))
1558                .then_with(|| {
1559                    left.entity
1560                        .key()
1561                        .canonical_identity()
1562                        .cmp(right.entity.key().canonical_identity())
1563                })
1564        });
1565        candidates.dedup_by(|left, right| {
1566            left.key == right.key && left.entity.key() == right.entity.key()
1567        });
1568        let truncated = candidates.len() > limit as usize;
1569        candidates.truncate(limit as usize);
1570        Ok(RepositoryGraphPage {
1571            rows: candidates,
1572            truncated,
1573        })
1574    }
1575
1576    /// Load bounded canonical export keys previously owned by exact source paths.
1577    ///
1578    /// The result is deduplicated and sorted by canonical key identity so callers
1579    /// can union it deterministically with newly staged exports.
1580    ///
1581    /// # Errors
1582    ///
1583    /// Returns an error for invalid paths or limits, project mismatch, corrupt
1584    /// persisted keys, canonical witness collisions, or `SQLite` failure.
1585    pub fn repository_export_keys_for_paths(
1586        &self,
1587        project: ProjectInstanceId,
1588        paths: &[String],
1589        limit: u32,
1590    ) -> DbResult<RepositoryGraphPage<CanonicalResolutionKey>> {
1591        validated_limit_plus_one(
1592            limit,
1593            GraphLimits::MAX_ROWS,
1594            "resolution export keys must be nonzero and within the product ceiling",
1595        )?;
1596        if self.repository_graph_generation()?.is_none()
1597            || !verify_project_identity(&self.connection, project)?
1598        {
1599            return Ok(empty_page());
1600        }
1601        let paths = normalized_file_paths(paths)?;
1602        let keys = resolution_keys_for_owner_paths(
1603            &self.connection,
1604            project,
1605            &paths,
1606            ResolutionOwner::EntityExports,
1607            Some(limit),
1608        )?;
1609        Ok(page_from_ordered_set(keys, limit))
1610    }
1611
1612    /// Find the bounded distinct source paths that depend on canonical keys.
1613    ///
1614    /// `truncated` is set from aggregate `LIMIT + 1` handling. Callers must
1615    /// escalate to a full refresh before opening a publication transaction when
1616    /// it is true.
1617    ///
1618    /// # Errors
1619    ///
1620    /// Returns an error for invalid limits, mixed projects, canonical witness
1621    /// collisions, invalid persisted paths, or `SQLite` failure. No mutation is
1622    /// performed.
1623    pub fn repository_affected_source_paths(
1624        &self,
1625        project: ProjectInstanceId,
1626        keys: &[CanonicalResolutionKey],
1627        limit: u32,
1628    ) -> DbResult<RepositoryGraphPage<RepositoryFilePath>> {
1629        validated_limit_plus_one(
1630            limit,
1631            GraphLimits::MAX_ROWS,
1632            "affected source paths must be nonzero and within the product ceiling",
1633        )?;
1634        if self.repository_graph_generation()?.is_none()
1635            || !verify_project_identity(&self.connection, project)?
1636        {
1637            return Ok(empty_page());
1638        }
1639        let keys = normalized_resolution_keys(project, keys)?;
1640        validate_persisted_resolution_keys(&self.connection, &keys)?;
1641        let paths = affected_source_paths(&self.connection, &keys, limit)?;
1642        Ok(page_from_ordered_set(paths, limit))
1643    }
1644
1645    /// Account the persisted closure owned by exact affected source paths.
1646    ///
1647    /// Resolution witnesses are conservatively counted once per retained export
1648    /// or dependency binding. Callers must require a full refresh when
1649    /// `truncated` is true; in that case `rows` is the `limit + 1` lower bound
1650    /// and `retained_bytes` covers only that bounded prefix.
1651    ///
1652    /// # Errors
1653    ///
1654    /// Returns an error for invalid paths or limits, corrupt persisted rows,
1655    /// unavailable owned indexes, or any `SQLite` preparation, iteration, or
1656    /// conversion failure. An unavailable graph returns an empty footprint;
1657    /// a differently bound graph returns the typed project-mismatch error.
1658    pub fn repository_affected_source_footprint(
1659        &self,
1660        project: ProjectInstanceId,
1661        paths: &[String],
1662        limit: u32,
1663    ) -> DbResult<RepositoryAffectedSourceFootprint> {
1664        let limit_plus_one = validated_limit_plus_one(
1665            limit,
1666            GraphLimits::MAX_ROWS,
1667            "affected source footprint rows must be nonzero and within the product ceiling",
1668        )?;
1669        if self.repository_graph_generation()?.is_none()
1670            || !verify_project_identity(&self.connection, project)?
1671        {
1672            return Ok(empty_affected_source_footprint());
1673        }
1674        let paths = normalized_file_paths(paths)?;
1675        affected_source_footprint(&self.connection, project, &paths, limit_plus_one)
1676    }
1677
1678    /// Load a bounded page of logical relations through one indexed query shape.
1679    ///
1680    /// # Errors
1681    ///
1682    /// Returns an error for invalid limits, mismatched project identities,
1683    /// `SQLite` failure, or any corrupt entity/relation row in the complete page.
1684    pub fn repository_graph_relations(
1685        &self,
1686        query: RepositoryGraphRelationQuery,
1687        limit: u32,
1688    ) -> DbResult<RepositoryGraphPage<LogicalRelation>> {
1689        let page = self.repository_graph_relation_rows(query, limit, None)?;
1690        Ok(RepositoryGraphPage {
1691            rows: page.rows.into_iter().map(|row| row.relation).collect(),
1692            truncated: page.truncated,
1693        })
1694    }
1695
1696    /// Load a bounded relation page with unique endpoint hydration.
1697    ///
1698    /// # Errors
1699    ///
1700    /// Returns an error for invalid limits, mismatched project identities,
1701    /// cancellation, `SQLite` failure, or any corrupt entity/relation row in
1702    /// the complete page.
1703    pub fn repository_graph_relation_rows(
1704        &self,
1705        query: RepositoryGraphRelationQuery,
1706        limit: u32,
1707        control: Option<&IndexWorkControl>,
1708    ) -> DbResult<RepositoryGraphPage<RepositoryGraphRelationRow>> {
1709        let limit_plus_one = validated_limit_plus_one(
1710            limit,
1711            GraphLimits::MAX_ROWS,
1712            "graph rows must be nonzero and within the product ceiling",
1713        )?;
1714        let Some(generation) = self.repository_graph_generation()? else {
1715            return Ok(empty_page());
1716        };
1717        let (project, raw) = match query {
1718            RepositoryGraphRelationQuery::Outbound { source } => {
1719                let project = source.project();
1720                if !verify_project_identity(&self.connection, project)? {
1721                    return Ok(empty_page());
1722                }
1723                let raw = with_sqlite_read_progress(
1724                    &self.connection,
1725                    control,
1726                    IndexWorkStage::RepositoryTraversal,
1727                    || {
1728                        self.collect_relation_rows_by_key(
1729                            "source_entity_key",
1730                            &source.digest_bytes()?,
1731                            limit_plus_one,
1732                        )
1733                    },
1734                )?;
1735                (project, raw)
1736            }
1737            RepositoryGraphRelationQuery::Inbound { target } => {
1738                let project = target.project();
1739                if !verify_project_identity(&self.connection, project)? {
1740                    return Ok(empty_page());
1741                }
1742                let raw = with_sqlite_read_progress(
1743                    &self.connection,
1744                    control,
1745                    IndexWorkStage::RepositoryTraversal,
1746                    || {
1747                        self.collect_relation_rows_by_key(
1748                            "target_entity_key",
1749                            &target.digest_bytes()?,
1750                            limit_plus_one,
1751                        )
1752                    },
1753                )?;
1754                (project, raw)
1755            }
1756            RepositoryGraphRelationQuery::Family { relation } => {
1757                let project = load_project_identity(&self.connection)?
1758                    .ok_or(DbError::GraphPublicationUnavailable)?;
1759                let (scope, kind) = relation_parts(relation);
1760                let raw = with_sqlite_read_progress(
1761                    &self.connection,
1762                    control,
1763                    IndexWorkStage::RepositoryTraversal,
1764                    || {
1765                        #[cfg(feature = "sqlite-progress-test-observer")]
1766                        crate::sqlite_progress_test_observer::notify(
1767                            crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryEntered,
1768                        );
1769                        let result = (|| {
1770                            let mut statement = self.connection.prepare_cached(
1771                                "SELECT relation_key, project_instance_id, canonical_identity,
1772                                    source_entity_key, relation_scope, relation_kind,
1773                                    resolution_status, target_entity_key, reference_text,
1774                                    candidate_count, confidence, completeness
1775                               FROM graph_relations
1776                              WHERE project_instance_id = ?1
1777                                AND relation_scope = ?2 AND relation_kind = ?3
1778                              ORDER BY relation_key
1779                              LIMIT ?4",
1780                            )?;
1781                            collect_relation_rows(statement.query(params![
1782                                &project.as_bytes()[..],
1783                                scope,
1784                                kind,
1785                                limit_plus_one
1786                            ])?)
1787                        })();
1788                        #[cfg(feature = "sqlite-progress-test-observer")]
1789                        crate::sqlite_progress_test_observer::notify(
1790                            crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryExited,
1791                        );
1792                        result
1793                    },
1794                )?;
1795                (project, raw)
1796            }
1797        };
1798        let entities = load_relation_entities(self, &raw, project, generation, control)?;
1799        page_from_raw(raw, limit, |row| {
1800            relation_detail_from_row(&entities, row, project, generation)
1801        })
1802    }
1803
1804    /// Hydrate an ordered unique set of normalized relations from compact stable keys.
1805    ///
1806    /// # Errors
1807    ///
1808    /// Returns an error for duplicate or oversized input, a stale project or
1809    /// generation, cancellation, a missing relation or endpoint, `SQLite`
1810    /// failure, or any invalid persisted key or canonical identity. No partial
1811    /// set is returned.
1812    pub fn repository_graph_relation_rows_by_digest(
1813        &self,
1814        project: ProjectInstanceId,
1815        generation: IndexGeneration,
1816        digests: &[[u8; 32]],
1817        budget: RepositoryGraphReadBudget,
1818        control: Option<&IndexWorkControl>,
1819    ) -> DbResult<RepositoryGraphReadBatch<RepositoryGraphRelationRow>> {
1820        validate_graph_hydration_request(digests)?;
1821        self.require_repository_graph_snapshot(project, generation)?;
1822        let mut meter = RepositoryGraphReadMeter::new(budget, digests.len())?;
1823        if digests.is_empty() {
1824            return Ok(RepositoryGraphReadBatch {
1825                rows: Vec::new(),
1826                work: meter.finish(0)?,
1827            });
1828        }
1829        let sql = graph_relation_hydration_sql(digests.len());
1830        let mut bindings = digests
1831            .iter()
1832            .map(|digest| Value::Blob(digest.to_vec()))
1833            .collect::<Vec<_>>();
1834        bindings.push(Value::Blob(project.as_bytes().to_vec()));
1835        let raw = with_sqlite_read_progress(
1836            &self.connection,
1837            control,
1838            IndexWorkStage::RepositoryTraversal,
1839            || {
1840                let mut statement = self.connection.prepare(&sql)?;
1841                collect_relation_rows_metered(
1842                    statement.query(params_from_iter(bindings.iter()))?,
1843                    &mut meter,
1844                )
1845            },
1846        )?;
1847        let mut relations = HashMap::with_capacity(raw.len());
1848        for row in raw {
1849            let digest = fixed_bytes::<32>("graph_relations.relation_key", row.key.clone())?;
1850            if relations.insert(digest, row).is_some() {
1851                return Err(DbError::GraphRowShape {
1852                    table: "graph_relations",
1853                    reason: "batched relation hydration returned a duplicate key",
1854                });
1855            }
1856        }
1857        let mut ordered = Vec::with_capacity(digests.len());
1858        for digest in digests {
1859            ordered.push(relations.remove(digest).ok_or(DbError::GraphRowShape {
1860                table: "graph_relations",
1861                reason: "requested graph relation is missing",
1862            })?);
1863        }
1864        let references = ordered.iter().collect::<Vec<_>>();
1865        let entities = load_relation_entity_references_metered(
1866            self,
1867            &references,
1868            project,
1869            generation,
1870            control,
1871            Some(&mut meter),
1872        )?;
1873        let rows = ordered
1874            .into_iter()
1875            .map(|row| relation_detail_from_row(&entities, row, project, generation))
1876            .collect::<DbResult<Vec<_>>>()?;
1877        let work = meter.finish(rows.len())?;
1878        Ok(RepositoryGraphReadBatch { rows, work })
1879    }
1880
1881    /// Load one bounded direction-specific adjacency page for a unique frontier.
1882    ///
1883    /// The complete frontier is bound through one statement whose indexed
1884    /// per-frontier branches each cap their candidate rows before the stable
1885    /// compound order. Endpoint entities are hydrated in bounded set-oriented
1886    /// batches. The opaque continuation is valid only with the same project,
1887    /// generation, frontier, and direction inside the same request snapshot.
1888    ///
1889    /// # Errors
1890    ///
1891    /// Returns an error for invalid or mixed-project frontiers, invalid limits
1892    /// or continuation state, cancellation, `SQLite` failures, or any corrupt
1893    /// relation or endpoint row. No partial page is returned.
1894    pub fn repository_graph_adjacency_page(
1895        &self,
1896        frontier: &[GraphEntityKey],
1897        direction: RepositoryGraphDirection,
1898        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
1899        limit: u32,
1900        control: Option<&IndexWorkControl>,
1901    ) -> DbResult<RepositoryGraphAdjacencyPage> {
1902        Ok(self
1903            .repository_graph_adjacency_page_bounded(
1904                frontier,
1905                direction,
1906                continuation,
1907                limit,
1908                maximum_repository_graph_read_budget()?,
1909                control,
1910            )?
1911            .page)
1912    }
1913
1914    /// Load one bounded adjacency page and report exact database work.
1915    ///
1916    /// # Errors
1917    ///
1918    /// Returns the same fail-closed errors as
1919    /// [`Self::repository_graph_adjacency_page`] and rejects any page whose
1920    /// returned, decoded, endpoint, or path hydration crosses `budget`.
1921    pub fn repository_graph_adjacency_page_bounded(
1922        &self,
1923        frontier: &[GraphEntityKey],
1924        direction: RepositoryGraphDirection,
1925        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
1926        limit: u32,
1927        budget: RepositoryGraphReadBudget,
1928        control: Option<&IndexWorkControl>,
1929    ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
1930        self.repository_graph_adjacency_page_filtered_bounded(
1931            frontier,
1932            direction,
1933            None,
1934            continuation,
1935            limit,
1936            budget,
1937            control,
1938        )
1939    }
1940
1941    /// Load one bounded direction-specific adjacency page for an optional exact family.
1942    ///
1943    /// # Errors
1944    ///
1945    /// Returns the same fail-closed errors as
1946    /// [`Self::repository_graph_adjacency_page`] and binds the optional family
1947    /// to continuation state.
1948    pub fn repository_graph_adjacency_page_filtered(
1949        &self,
1950        frontier: &[GraphEntityKey],
1951        direction: RepositoryGraphDirection,
1952        relation: Option<GraphRelationKind>,
1953        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
1954        limit: u32,
1955        control: Option<&IndexWorkControl>,
1956    ) -> DbResult<RepositoryGraphAdjacencyPage> {
1957        Ok(self
1958            .repository_graph_adjacency_page_filtered_bounded(
1959                frontier,
1960                direction,
1961                relation,
1962                continuation,
1963                limit,
1964                maximum_repository_graph_read_budget()?,
1965                control,
1966            )?
1967            .page)
1968    }
1969
1970    /// Load one family-filtered adjacency page and report exact database work.
1971    ///
1972    /// # Errors
1973    ///
1974    /// Returns the same fail-closed errors as
1975    /// [`Self::repository_graph_adjacency_page_filtered`] and rejects any page
1976    /// whose complete query work crosses `budget`.
1977    pub fn repository_graph_adjacency_page_filtered_bounded(
1978        &self,
1979        frontier: &[GraphEntityKey],
1980        direction: RepositoryGraphDirection,
1981        relation: Option<GraphRelationKind>,
1982        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
1983        limit: u32,
1984        budget: RepositoryGraphReadBudget,
1985        control: Option<&IndexWorkControl>,
1986    ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
1987        let limit_plus_one = validated_limit_plus_one(
1988            limit,
1989            GraphLimits::MAX_ROWS,
1990            "graph adjacency rows must be nonzero and within the product ceiling",
1991        )?;
1992        if limit > budget.returned_rows() {
1993            return Err(GraphContractError::InvalidLimits {
1994                reason: "graph adjacency page limit exceeds the return budget",
1995            }
1996            .into());
1997        }
1998        if frontier.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
1999            return Err(GraphContractError::InvalidLimits {
2000                reason: "graph adjacency frontier exceeds the product ceiling",
2001            }
2002            .into());
2003        }
2004        if frontier.is_empty() {
2005            if continuation.is_some() {
2006                return Err(GraphContractError::InvalidLimits {
2007                    reason: "graph adjacency continuation requires a nonempty frontier",
2008                }
2009                .into());
2010            }
2011            let meter = RepositoryGraphReadMeter::new(budget, 0)?;
2012            return Ok(RepositoryGraphAdjacencyReadPage {
2013                page: empty_adjacency_page(),
2014                work: meter.finish(0)?,
2015            });
2016        }
2017        let mut meter = RepositoryGraphReadMeter::new(budget, frontier.len())?;
2018        let limit_plus_one_usize = usize::try_from(limit_plus_one).map_err(|_source| {
2019            GraphContractError::InvalidLimits {
2020                reason: "graph adjacency page limit overflowed",
2021            }
2022        })?;
2023        let project = frontier[0].project();
2024        let mut unique = BTreeSet::new();
2025        let mut frontier_digests = Vec::with_capacity(frontier.len());
2026        for key in frontier {
2027            require_project(project, key.project())?;
2028            let digest = key.digest_bytes()?;
2029            if !unique.insert(digest) {
2030                return Err(GraphContractError::InvalidLimits {
2031                    reason: "graph adjacency frontier must contain unique entities",
2032                }
2033                .into());
2034            }
2035            frontier_digests.push(digest);
2036        }
2037
2038        let Some(generation) = self.repository_graph_generation()? else {
2039            if continuation.is_some() {
2040                return Err(GraphContractError::InvalidLimits {
2041                    reason: "graph adjacency continuation has no active generation",
2042                }
2043                .into());
2044            }
2045            return Ok(RepositoryGraphAdjacencyReadPage {
2046                page: empty_adjacency_page(),
2047                work: meter.finish(0)?,
2048            });
2049        };
2050        if !verify_project_identity(&self.connection, project)? {
2051            if continuation.is_some() {
2052                return Err(GraphContractError::InvalidLimits {
2053                    reason: "graph adjacency continuation does not match the bound project",
2054                }
2055                .into());
2056            }
2057            return Ok(RepositoryGraphAdjacencyReadPage {
2058                page: empty_adjacency_page(),
2059                work: meter.finish(0)?,
2060            });
2061        }
2062
2063        if let Some(continuation) = continuation
2064            && (continuation.project != project
2065                || continuation.generation != generation
2066                || continuation.direction != direction
2067                || continuation.relation != relation
2068                || continuation.frontier != frontier_digests
2069                || continuation.frontier_index as usize >= frontier.len())
2070        {
2071            return Err(GraphContractError::InvalidLimits {
2072                reason: "graph adjacency continuation does not match the request",
2073            }
2074            .into());
2075        }
2076
2077        let continuation_index = continuation.map_or(0, |value| value.frontier_index as usize);
2078        let active_frontier = frontier.len() - continuation_index;
2079        let work_rows = active_frontier.checked_mul(limit_plus_one_usize).ok_or(
2080            GraphContractError::InvalidLimits {
2081                reason: "graph adjacency intermediate row ceiling overflowed",
2082            },
2083        )?;
2084        if work_rows > MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS {
2085            return Err(GraphContractError::InvalidLimits {
2086                reason: "graph adjacency frontier and page exceed the intermediate row ceiling",
2087            }
2088            .into());
2089        }
2090
2091        let bindings_per_frontier = if relation.is_some() { 4 } else { 2 };
2092        let mut bindings = Vec::with_capacity(
2093            active_frontier * bindings_per_frontier + continuation.map_or(2, |_| 5),
2094        );
2095        bindings.push(Value::Blob(project.as_bytes().to_vec()));
2096        for (index, digest) in frontier_digests.iter().enumerate().skip(continuation_index) {
2097            bindings.push(Value::Blob(digest.to_vec()));
2098            if let Some(relation) = relation {
2099                let (scope, kind) = relation_parts(relation);
2100                bindings.push(Value::Text(scope.to_string()));
2101                bindings.push(Value::Text(kind.to_string()));
2102            }
2103            if index == continuation_index
2104                && let Some(continuation) = continuation
2105            {
2106                bindings.push(Value::Text(continuation.relation_scope.clone()));
2107                bindings.push(Value::Text(continuation.relation_kind.clone()));
2108                bindings.push(Value::Blob(continuation.relation_key.to_vec()));
2109            }
2110            bindings.push(Value::Integer(limit_plus_one));
2111        }
2112        bindings.push(Value::Integer(limit_plus_one));
2113
2114        let sql = adjacency_relation_sql(
2115            frontier.len(),
2116            direction,
2117            continuation.map(|value| value.frontier_index as usize),
2118            relation.is_some(),
2119        );
2120        let raw = with_sqlite_read_progress(
2121            &self.connection,
2122            control,
2123            IndexWorkStage::RepositoryTraversal,
2124            || {
2125                let mut statement = self.connection.prepare(&sql)?;
2126                collect_adjacency_relation_rows_metered(
2127                    statement.query(params_from_iter(bindings.iter()))?,
2128                    &mut meter,
2129                )
2130            },
2131        )?;
2132        let truncated = raw.len() > limit as usize;
2133        let next = if truncated {
2134            let last = raw.get(limit as usize - 1).ok_or(DbError::GraphRowShape {
2135                table: "graph_relations",
2136                reason: "adjacency truncation row is missing",
2137            })?;
2138            Some(RepositoryGraphAdjacencyContinuation {
2139                project,
2140                generation,
2141                direction,
2142                relation,
2143                frontier: frontier_digests,
2144                frontier_index: last.frontier_index,
2145                relation_scope: last.relation.relation_scope.clone(),
2146                relation_kind: last.relation.relation_kind.clone(),
2147                relation_key: fixed_bytes::<32>(
2148                    "graph_relations.relation_key",
2149                    last.relation.key.clone(),
2150                )?,
2151            })
2152        } else {
2153            None
2154        };
2155        let relation_rows = raw.iter().map(|row| &row.relation).collect::<Vec<_>>();
2156        let entities = load_relation_entity_references_metered(
2157            self,
2158            &relation_rows,
2159            project,
2160            generation,
2161            control,
2162            Some(&mut meter),
2163        )?;
2164        let mut rows = Vec::with_capacity(raw.len());
2165        for row in raw {
2166            if let Some(control) = control {
2167                control.check(IndexWorkStage::RepositoryTraversal)?;
2168            }
2169            let frontier_key = frontier
2170                .get(row.frontier_index as usize)
2171                .ok_or(DbError::GraphRowShape {
2172                    table: "graph_relations",
2173                    reason: "adjacency row has an invalid frontier position",
2174                })?
2175                .clone();
2176            rows.push(RepositoryGraphAdjacencyRow {
2177                frontier_index: row.frontier_index,
2178                frontier: frontier_key,
2179                direction,
2180                detail: relation_detail_from_row(&entities, row.relation, project, generation)?,
2181            });
2182        }
2183        if truncated {
2184            rows.pop();
2185        }
2186        let work = meter.finish(rows.len())?;
2187        Ok(RepositoryGraphAdjacencyReadPage {
2188            page: RepositoryGraphAdjacencyPage {
2189                rows,
2190                truncated,
2191                continuation: next,
2192            },
2193            work,
2194        })
2195    }
2196
2197    /// Load bounded exact source occurrences for one logical relation.
2198    ///
2199    /// # Errors
2200    ///
2201    /// Returns an error for invalid limits, project mismatch, unavailable
2202    /// publication state, `SQLite` failure, or any invalid span or key.
2203    pub fn repository_graph_occurrences(
2204        &self,
2205        relation: &LogicalRelation,
2206        limit: u32,
2207    ) -> DbResult<RepositoryGraphPage<RelationOccurrence>> {
2208        let limit_plus_one = validated_limit_plus_one(
2209            limit,
2210            GraphLimits::MAX_OCCURRENCES,
2211            "graph occurrences must be nonzero and within the product ceiling",
2212        )?;
2213        let Some(generation) = self.repository_graph_generation()? else {
2214            return Ok(empty_page());
2215        };
2216        if relation.generation() != generation {
2217            return Err(
2218                projectatlas_core::graph::GraphContractError::GenerationMismatch {
2219                    context: "relation occurrence query",
2220                }
2221                .into(),
2222            );
2223        }
2224        if !verify_project_identity(&self.connection, relation.key().project())? {
2225            return Ok(empty_page());
2226        }
2227        let raw = {
2228            let mut statement = self.connection.prepare_cached(
2229                "SELECT relation_key, file_path, start_line, start_column,
2230                        end_line, end_column
2231                   FROM graph_relation_occurrences
2232                  WHERE relation_key = ?1
2233                  ORDER BY file_path, start_line, start_column, end_line, end_column
2234                  LIMIT ?2",
2235            )?;
2236            let mut rows =
2237                statement.query(params![&relation.key().digest_bytes()?[..], limit_plus_one])?;
2238            let mut collected = Vec::new();
2239            while let Some(row) = rows.next()? {
2240                collected.push(occurrence_row(row)?);
2241            }
2242            collected
2243        };
2244        page_from_raw(raw, limit, |row| {
2245            occurrence_from_row(row, relation, generation)
2246        })
2247    }
2248
2249    /// Load per-relation occurrence pages through one bounded set-oriented statement.
2250    ///
2251    /// Result pages retain input order. Every generated branch uses the
2252    /// relation-leading unique index and admits only `limit + 1` rows before
2253    /// the final stable merge.
2254    ///
2255    /// # Errors
2256    ///
2257    /// Returns an error for mixed projects or generations, oversized input or
2258    /// intermediate work, cancellation, unavailable publication state,
2259    /// `SQLite` failure, or any invalid span or key.
2260    pub fn repository_graph_occurrence_pages(
2261        &self,
2262        relations: &[LogicalRelation],
2263        limit: u32,
2264        control: Option<&IndexWorkControl>,
2265    ) -> DbResult<Vec<RepositoryGraphPage<RelationOccurrence>>> {
2266        Ok(self
2267            .repository_graph_occurrence_pages_bounded(
2268                relations,
2269                limit,
2270                maximum_repository_graph_read_budget()?,
2271                control,
2272            )?
2273            .pages)
2274    }
2275
2276    /// Load ordered per-relation occurrence pages under one exact read envelope.
2277    ///
2278    /// # Errors
2279    ///
2280    /// Returns the same fail-closed errors as
2281    /// [`Self::repository_graph_occurrence_pages`] and rejects any aggregate
2282    /// returned-row, decoded-byte, or occurrence-path hydration overrun. Raw
2283    /// `limit + 1` sentinels are included in decoded and path work.
2284    pub fn repository_graph_occurrence_pages_bounded(
2285        &self,
2286        relations: &[LogicalRelation],
2287        limit: u32,
2288        budget: RepositoryGraphReadBudget,
2289        control: Option<&IndexWorkControl>,
2290    ) -> DbResult<RepositoryGraphReadPages<RelationOccurrence>> {
2291        let limit_plus_one = validated_limit_plus_one(
2292            limit,
2293            GraphLimits::MAX_OCCURRENCES,
2294            "graph occurrences must be nonzero and within the product ceiling",
2295        )?;
2296        if relations.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
2297            return Err(GraphContractError::InvalidLimits {
2298                reason: "graph occurrence batch exceeds the product ceiling",
2299            }
2300            .into());
2301        }
2302        let mut meter = RepositoryGraphReadMeter::new(budget, relations.len())?;
2303        if relations.is_empty() {
2304            return Ok(RepositoryGraphReadPages {
2305                pages: Vec::new(),
2306                work: meter.finish(0)?,
2307            });
2308        }
2309        let work_rows = relations.len().checked_mul(limit_plus_one as usize).ok_or(
2310            GraphContractError::InvalidLimits {
2311                reason: "graph occurrence batch work overflowed",
2312            },
2313        )?;
2314        if work_rows > MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS {
2315            return Err(GraphContractError::InvalidLimits {
2316                reason: "graph occurrence batch exceeds the intermediate row ceiling",
2317            }
2318            .into());
2319        }
2320        let Some(generation) = self.repository_graph_generation()? else {
2321            return Ok(RepositoryGraphReadPages {
2322                pages: (0..relations.len()).map(|_| empty_page()).collect(),
2323                work: meter.finish(0)?,
2324            });
2325        };
2326        let project = relations[0].key().project();
2327        if !verify_project_identity(&self.connection, project)? {
2328            return Ok(RepositoryGraphReadPages {
2329                pages: (0..relations.len()).map(|_| empty_page()).collect(),
2330                work: meter.finish(0)?,
2331            });
2332        }
2333        let mut bindings = Vec::with_capacity(relations.len() * 2);
2334        for relation in relations {
2335            require_project(project, relation.key().project())?;
2336            if relation.generation() != generation {
2337                return Err(GraphContractError::GenerationMismatch {
2338                    context: "relation occurrence batch query",
2339                }
2340                .into());
2341            }
2342            bindings.push(Value::Blob(relation.key().digest_bytes()?.to_vec()));
2343            bindings.push(Value::Integer(limit_plus_one));
2344        }
2345        let sql = occurrence_pages_sql(relations.len());
2346        let raw = with_sqlite_read_progress(
2347            &self.connection,
2348            control,
2349            IndexWorkStage::RepositoryTraversal,
2350            || {
2351                let mut statement = self.connection.prepare(&sql)?;
2352                let mut queried = statement.query(params_from_iter(bindings.iter()))?;
2353                let mut grouped = (0..relations.len())
2354                    .map(|_| Vec::new())
2355                    .collect::<Vec<Vec<OccurrenceRow>>>();
2356                while let Some(row) = queried.next()? {
2357                    let index = row.get::<_, usize>(0)?;
2358                    let group = grouped.get_mut(index).ok_or(DbError::GraphRowShape {
2359                        table: "graph_relation_occurrences",
2360                        reason: "occurrence batch returned an invalid relation position",
2361                    })?;
2362                    let occurrence = occurrence_row_at(row, 1)?;
2363                    meter.record_decoded_bytes(
2364                        occurrence_row_decoded_bytes(&occurrence)?
2365                            .checked_add(8)
2366                            .ok_or(GraphContractError::InvalidLimits {
2367                                reason: "graph occurrence batch decoded row size overflowed",
2368                            })?,
2369                    )?;
2370                    group.push(occurrence);
2371                }
2372                Ok(grouped)
2373            },
2374        )?;
2375        let pages = raw
2376            .into_iter()
2377            .zip(relations)
2378            .map(|(rows, relation)| {
2379                page_from_raw(rows, limit, |row| {
2380                    let occurrence = occurrence_from_row(row, relation, generation)?;
2381                    meter.record_hydrated_path(occurrence.file().as_str())?;
2382                    Ok(occurrence)
2383                })
2384            })
2385            .collect::<DbResult<Vec<_>>>()?;
2386        let returned_rows = pages.iter().try_fold(0_usize, |count, page| {
2387            count
2388                .checked_add(page.rows.len())
2389                .ok_or(GraphContractError::InvalidLimits {
2390                    reason: "graph occurrence returned-row accounting overflowed",
2391                })
2392        })?;
2393        let work = meter.finish(returned_rows)?;
2394        Ok(RepositoryGraphReadPages { pages, work })
2395    }
2396
2397    /// Load bounded coverage rows for one exact project or path scope.
2398    ///
2399    /// # Errors
2400    ///
2401    /// Returns an error for invalid limits, unavailable publication state,
2402    /// project mismatch, `SQLite` failure, or any inconsistent coverage row.
2403    pub fn repository_graph_coverage(
2404        &self,
2405        project: ProjectInstanceId,
2406        scope: &CoverageScope,
2407        limit: u32,
2408    ) -> DbResult<RepositoryGraphPage<CoverageRecord>> {
2409        let limit_plus_one = validated_limit_plus_one(
2410            limit,
2411            GraphLimits::MAX_ROWS,
2412            "graph rows must be nonzero and within the product ceiling",
2413        )?;
2414        let Some(generation) = self.repository_graph_generation()? else {
2415            return Ok(empty_page());
2416        };
2417        if !verify_project_identity(&self.connection, project)? {
2418            return Ok(empty_page());
2419        }
2420        let (scope_kind, scope_path) = coverage_scope_parts(scope);
2421        let raw = {
2422            let mut statement = self.connection.prepare_cached(
2423                "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
2424                        relation_kind, state, total, covered, omitted, reason, reached_limit,
2425                        NULL, NULL
2426                   FROM graph_coverage
2427                  WHERE project_instance_id = ?1
2428                    AND scope_kind = ?2 AND scope_path IS ?3
2429                  ORDER BY relation_scope, relation_kind, state, id
2430                  LIMIT ?4",
2431            )?;
2432            let mut rows = statement.query(params![
2433                &project.as_bytes()[..],
2434                scope_kind,
2435                scope_path,
2436                limit_plus_one
2437            ])?;
2438            let mut collected = Vec::new();
2439            while let Some(row) = rows.next()? {
2440                collected.push(coverage_row(row)?);
2441            }
2442            collected
2443        };
2444        page_from_raw(raw, limit, |row| {
2445            coverage_from_row(row, project, generation)
2446        })
2447    }
2448
2449    /// Load current coverage for a bounded unique set of exact repository paths.
2450    ///
2451    /// The complete path set is bound to one prepared statement so service
2452    /// traversal never performs one coverage query per returned node.
2453    ///
2454    /// # Errors
2455    ///
2456    /// Returns an error for duplicate or oversized path sets, cancellation,
2457    /// unavailable publication state, project mismatch, `SQLite` failure, or
2458    /// invalid persisted coverage.
2459    pub fn repository_graph_path_coverage(
2460        &self,
2461        project: ProjectInstanceId,
2462        paths: &[RepositoryNodePath],
2463        control: Option<&IndexWorkControl>,
2464    ) -> DbResult<RepositoryGraphPage<CoverageRecord>> {
2465        validate_path_coverage_request(paths)?;
2466        if paths.is_empty() {
2467            return Ok(empty_page());
2468        }
2469        let Some(generation) = self.repository_graph_generation()? else {
2470            return Ok(empty_page());
2471        };
2472        if !verify_project_identity(&self.connection, project)? {
2473            return Ok(empty_page());
2474        }
2475        Ok(self
2476            .repository_graph_path_coverage_bounded(
2477                project,
2478                generation,
2479                paths,
2480                maximum_repository_graph_read_budget()?,
2481                control,
2482            )?
2483            .page)
2484    }
2485
2486    /// Load current exact-path coverage under one stable graph read envelope.
2487    ///
2488    /// # Errors
2489    ///
2490    /// Returns the same fail-closed errors as
2491    /// [`Self::repository_graph_path_coverage`], rejects a stale project or
2492    /// generation and any returned-row, decoded-byte, or coverage-path
2493    /// hydration overrun, and never returns a partial batch. The raw truncation
2494    /// sentinel is included in decoded and path work.
2495    pub fn repository_graph_path_coverage_bounded(
2496        &self,
2497        project: ProjectInstanceId,
2498        generation: IndexGeneration,
2499        paths: &[RepositoryNodePath],
2500        budget: RepositoryGraphReadBudget,
2501        control: Option<&IndexWorkControl>,
2502    ) -> DbResult<RepositoryGraphReadPage<CoverageRecord>> {
2503        validate_path_coverage_request(paths)?;
2504        self.require_repository_graph_snapshot(project, generation)?;
2505        let mut meter = RepositoryGraphReadMeter::new(budget, paths.len())?;
2506        if paths.is_empty() {
2507            return Ok(RepositoryGraphReadPage {
2508                page: empty_page(),
2509                work: meter.finish(0)?,
2510            });
2511        }
2512
2513        let sql = path_coverage_sql(paths.len());
2514        let mut bindings = Vec::with_capacity(paths.len() + 3);
2515        bindings.push(Value::Blob(project.as_bytes().to_vec()));
2516        bindings.push(Value::Text("path".to_string()));
2517        bindings.extend(
2518            paths
2519                .iter()
2520                .map(|path| Value::Text(path.as_str().to_string())),
2521        );
2522        bindings.push(Value::Integer(i64::from(GraphLimits::MAX_ROWS) + 1));
2523        let raw = with_sqlite_read_progress(
2524            &self.connection,
2525            control,
2526            IndexWorkStage::RepositoryTraversal,
2527            || {
2528                let mut statement = self.connection.prepare(&sql)?;
2529                let mut rows = statement.query(params_from_iter(bindings.iter()))?;
2530                let mut collected = Vec::new();
2531                while let Some(row) = rows.next()? {
2532                    let coverage = coverage_row(row)?;
2533                    meter.record_decoded_bytes(coverage_row_decoded_bytes(&coverage)?)?;
2534                    collected.push(coverage);
2535                }
2536                Ok(collected)
2537            },
2538        )?;
2539        let page = page_from_raw(raw, GraphLimits::MAX_ROWS, |row| {
2540            let coverage = coverage_from_row(row, project, generation)?;
2541            if let CoverageScope::Path { path } = coverage.scope() {
2542                meter.record_hydrated_path(path.as_str())?;
2543            }
2544            Ok(coverage)
2545        })?;
2546        let work = meter.finish(page.rows.len())?;
2547        Ok(RepositoryGraphReadPage { page, work })
2548    }
2549
2550    /// Discover one bounded page of current coverage with optional typed filters.
2551    ///
2552    /// # Errors
2553    ///
2554    /// Returns an error for invalid bounds, unavailable publication state,
2555    /// project mismatch, `SQLite` failure, or invalid persisted coverage or
2556    /// parser provenance.
2557    pub fn repository_coverage_page(
2558        &self,
2559        project: ProjectInstanceId,
2560        query: &RepositoryCoverageQuery,
2561    ) -> DbResult<RepositoryGraphPage<RepositoryCoverageRow>> {
2562        let limit_plus_one = validated_limit_plus_one(
2563            query.limit,
2564            GraphLimits::MAX_ROWS,
2565            "coverage rows must be nonzero and within the product ceiling",
2566        )?;
2567        if query.start_index >= GraphLimits::MAX_ROWS {
2568            return Err(GraphContractError::InvalidLimits {
2569                reason: "coverage start index is at or above the product ceiling",
2570            }
2571            .into());
2572        }
2573        let Some(generation) = self.repository_graph_generation()? else {
2574            return Ok(empty_page());
2575        };
2576        if !verify_project_identity(&self.connection, project)? {
2577            return Ok(empty_page());
2578        }
2579
2580        let provenance_driven = query.parser.is_some() || query.provider.is_some();
2581        let path_prefix = query.path_prefix.as_deref().filter(|prefix| *prefix != ".");
2582        let mut sql = String::from(
2583            "SELECT coverage.project_instance_id, coverage.scope_kind,
2584                    coverage.scope_path, coverage.relation_scope,
2585                    coverage.relation_kind, coverage.state, coverage.total,
2586                    coverage.covered, coverage.omitted, coverage.reason,
2587                    coverage.reached_limit, metadata.source_parser,
2588                    metadata.fact_parser
2589               FROM ",
2590        );
2591        if provenance_driven {
2592            sql.push_str(
2593                "source_parse_metadata AS metadata
2594                 CROSS JOIN graph_coverage AS coverage
2595                   ON coverage.scope_kind = 'path'
2596                  AND coverage.scope_path = metadata.path",
2597            );
2598        } else {
2599            sql.push_str(
2600                "graph_coverage AS coverage
2601                 LEFT JOIN source_parse_metadata AS metadata
2602                   ON metadata.path = coverage.scope_path",
2603            );
2604        }
2605        sql.push_str(" WHERE coverage.project_instance_id = ?");
2606        let mut values = vec![Value::Blob(project.as_bytes().to_vec())];
2607
2608        if let Some(prefix) = path_prefix {
2609            sql.push_str(
2610                " AND coverage.scope_kind = 'path'
2611                  AND coverage.scope_path >= ? AND coverage.scope_path < ?
2612                  AND (coverage.scope_path = ? OR coverage.scope_path >= ?)",
2613            );
2614            values.push(Value::Text(prefix.to_string()));
2615            values.push(Value::Text(format!("{prefix}0")));
2616            values.push(Value::Text(prefix.to_string()));
2617            values.push(Value::Text(format!("{prefix}/")));
2618        }
2619        if let Some(parser) = query.parser {
2620            sql.push_str(" AND metadata.source_parser = ?");
2621            values.push(Value::Text(parser.to_string()));
2622        }
2623        if let Some(provider) = query.provider {
2624            sql.push_str(" AND metadata.fact_parser = ?");
2625            values.push(Value::Text(provider.to_string()));
2626        }
2627        if let Some(relation) = query.relation {
2628            let (scope, kind) = relation_parts(relation);
2629            sql.push_str(" AND coverage.relation_scope = ? AND coverage.relation_kind = ?");
2630            values.push(Value::Text(scope.to_string()));
2631            values.push(Value::Text(kind.to_string()));
2632        }
2633        if let Some(state) = query.state {
2634            sql.push_str(" AND coverage.state = ?");
2635            values.push(Value::Text(coverage_state_name(state).to_string()));
2636        }
2637        if let Some(reason) = query.reason.as_deref() {
2638            sql.push_str(" AND coverage.reason = ?");
2639            values.push(Value::Text(reason.to_string()));
2640        }
2641
2642        if provenance_driven {
2643            sql.push_str(" ORDER BY metadata.path, coverage.id");
2644        } else if path_prefix.is_some() {
2645            sql.push_str(
2646                " ORDER BY coverage.scope_path, coverage.relation_scope,
2647                          coverage.relation_kind, coverage.state, coverage.id",
2648            );
2649        } else if query.relation.is_some() {
2650            sql.push_str(
2651                " ORDER BY coverage.relation_scope, coverage.relation_kind,
2652                          coverage.state, coverage.id",
2653            );
2654        } else if query.state.is_some() {
2655            sql.push_str(" ORDER BY coverage.state, coverage.scope_path, coverage.id");
2656        } else if query.reason.is_some() {
2657            sql.push_str(" ORDER BY coverage.reason, coverage.scope_path, coverage.id");
2658        } else {
2659            sql.push_str(
2660                " ORDER BY coverage.scope_kind, coverage.scope_path,
2661                          coverage.relation_scope, coverage.relation_kind,
2662                          coverage.state, coverage.id",
2663            );
2664        }
2665        sql.push_str(" LIMIT ? OFFSET ?");
2666        values.push(Value::Integer(limit_plus_one));
2667        values.push(Value::Integer(i64::from(query.start_index)));
2668
2669        let raw = {
2670            let mut statement = self.connection.prepare_cached(&sql)?;
2671            let mut rows = statement.query(params_from_iter(values.iter()))?;
2672            let mut collected = Vec::new();
2673            while let Some(row) = rows.next()? {
2674                collected.push(coverage_row(row)?);
2675            }
2676            collected
2677        };
2678        page_from_raw(raw, query.limit, |row| {
2679            coverage_discovery_from_row(row, project, generation)
2680        })
2681    }
2682
2683    /// Return the complete generation used to reconstruct normalized graph rows.
2684    ///
2685    /// # Errors
2686    ///
2687    /// Returns an error when publication metadata is incomplete or disagrees
2688    /// with the project identity's active graph generation.
2689    pub fn repository_graph_generation(&self) -> DbResult<Option<IndexGeneration>> {
2690        let Some(publication) = self.index_publication()? else {
2691            return Ok(None);
2692        };
2693        if publication.state != IndexPublicationState::Complete
2694            || publication.generation == IndexGeneration::ZERO
2695        {
2696            return Err(DbError::GraphPublicationUnavailable);
2697        }
2698        let Some(graph_generation) = load_graph_generation(&self.connection)? else {
2699            return Ok(None);
2700        };
2701        if graph_generation == IndexGeneration::ZERO || graph_generation != publication.generation {
2702            return Err(DbError::GraphRowShape {
2703                table: "project_identity",
2704                reason: "typed graph generation does not match complete publication",
2705            });
2706        }
2707        Ok(Some(graph_generation))
2708    }
2709
2710    /// Require one exact published graph snapshot for cursor-owned hydration.
2711    pub(crate) fn require_repository_graph_snapshot(
2712        &self,
2713        project: ProjectInstanceId,
2714        generation: IndexGeneration,
2715    ) -> DbResult<()> {
2716        require_bound_project_identity(&self.connection, project)?;
2717        let current = self
2718            .repository_graph_generation()?
2719            .ok_or(DbError::GraphPublicationUnavailable)?;
2720        if current != generation {
2721            return Err(GraphContractError::InvalidLimits {
2722                reason: "graph hydration generation does not match the current publication",
2723            }
2724            .into());
2725        }
2726        Ok(())
2727    }
2728
2729    /// Collect one indexed relation page by source or target key.
2730    fn collect_relation_rows_by_key(
2731        &self,
2732        key_column: &'static str,
2733        key: &[u8; 32],
2734        limit_plus_one: i64,
2735    ) -> DbResult<Vec<RelationRow>> {
2736        let sql = match key_column {
2737            "source_entity_key" => {
2738                "SELECT relation_key, project_instance_id, canonical_identity,
2739                        source_entity_key, relation_scope, relation_kind,
2740                        resolution_status, target_entity_key, reference_text,
2741                        candidate_count, confidence, completeness
2742                   FROM graph_relations
2743                  WHERE source_entity_key = ?1
2744                  ORDER BY relation_scope, relation_kind, relation_key
2745                  LIMIT ?2"
2746            }
2747            "target_entity_key" => {
2748                "SELECT relation_key, project_instance_id, canonical_identity,
2749                        source_entity_key, relation_scope, relation_kind,
2750                        resolution_status, target_entity_key, reference_text,
2751                        candidate_count, confidence, completeness
2752                   FROM graph_relations
2753                  WHERE target_entity_key = ?1
2754                  ORDER BY relation_scope, relation_kind, relation_key
2755                  LIMIT ?2"
2756            }
2757            _ => {
2758                return Err(DbError::GraphRowShape {
2759                    table: "graph_relations",
2760                    reason: "unsupported internal relation lookup",
2761                });
2762            }
2763        };
2764        let mut statement = self.connection.prepare_cached(sql)?;
2765        collect_relation_rows(statement.query(params![&key[..], limit_plus_one])?)
2766    }
2767}
2768
2769/// Decode the complete normalized graph from one private `SQLite` backup.
2770pub(crate) fn capture_derived_graph(
2771    connection: &Connection,
2772    project: ProjectInstanceId,
2773    generation: IndexGeneration,
2774    budget: &mut SnapshotBudget,
2775) -> DbResult<CapturedGraph> {
2776    let mut entities = Vec::new();
2777    let mut entities_by_key = HashMap::new();
2778    {
2779        let mut statement = connection.prepare(
2780            "SELECT entity_key, project_instance_id, canonical_identity, entity_kind,
2781                    repository_path, package_manager, package_name, manifest_path,
2782                    symbol_name, symbol_kind, symbol_parent, symbol_signature,
2783                    external_system, external_identity
2784               FROM graph_entities
2785              WHERE project_instance_id = ?1
2786              ORDER BY entity_key",
2787        )?;
2788        let mut rows = statement.query(params![&project.as_bytes()[..]])?;
2789        while let Some(row) = rows.next()? {
2790            let raw = entity_row(row)?;
2791            budget.admit(entity_row_decoded_bytes(&raw)?)?;
2792            let entity = entity_from_row(raw, project, generation)?;
2793            let key = entity.key().digest_bytes()?;
2794            if entities_by_key.insert(key, entity.clone()).is_some() {
2795                return Err(DbError::DerivedSnapshotInvalid {
2796                    reason: "private capture contains a duplicate entity",
2797                });
2798            }
2799            entities.push(entity);
2800        }
2801    }
2802
2803    let mut relations = Vec::new();
2804    let mut relations_by_key = HashMap::new();
2805    {
2806        let mut statement = connection.prepare(
2807            "SELECT relation_key, project_instance_id, canonical_identity,
2808                    source_entity_key, relation_scope, relation_kind,
2809                    resolution_status, target_entity_key, reference_text,
2810                    candidate_count, confidence, completeness
2811               FROM graph_relations
2812              WHERE project_instance_id = ?1
2813              ORDER BY relation_key",
2814        )?;
2815        let mut rows = statement.query(params![&project.as_bytes()[..]])?;
2816        while let Some(row) = rows.next()? {
2817            let raw = relation_row(row)?;
2818            budget.admit(relation_row_decoded_bytes(&raw)?)?;
2819            let relation = relation_from_row(&entities_by_key, raw, project, generation)?;
2820            let key = relation.key().digest_bytes()?;
2821            if relations_by_key.insert(key, relation.clone()).is_some() {
2822                return Err(DbError::DerivedSnapshotInvalid {
2823                    reason: "private capture contains a duplicate relation",
2824                });
2825            }
2826            relations.push(relation);
2827        }
2828    }
2829
2830    let mut occurrences = Vec::new();
2831    {
2832        let mut statement = connection.prepare(
2833            "SELECT relation_key, file_path, start_line, start_column, end_line, end_column
2834               FROM graph_relation_occurrences
2835              ORDER BY relation_key, file_path, start_line, start_column, end_line, end_column",
2836        )?;
2837        let mut rows = statement.query([])?;
2838        while let Some(row) = rows.next()? {
2839            let raw = occurrence_row(row)?;
2840            budget.admit(occurrence_row_decoded_bytes(&raw)?)?;
2841            let key = fixed_bytes::<32>(
2842                "graph_relation_occurrences.relation_key",
2843                raw.relation.clone(),
2844            )?;
2845            let relation = relations_by_key
2846                .get(&key)
2847                .ok_or(DbError::DerivedSnapshotInvalid {
2848                    reason: "private capture occurrence owner is absent",
2849                })?;
2850            occurrences.push(occurrence_from_row(raw, relation, generation)?);
2851        }
2852    }
2853
2854    let mut coverage = Vec::new();
2855    {
2856        let mut statement = connection.prepare(
2857            "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
2858                    relation_kind, state, total, covered, omitted, reason,
2859                    reached_limit, NULL, NULL
2860               FROM graph_coverage
2861              WHERE project_instance_id = ?1
2862              ORDER BY scope_kind, scope_path, relation_scope, relation_kind, state, id",
2863        )?;
2864        let mut rows = statement.query(params![&project.as_bytes()[..]])?;
2865        while let Some(row) = rows.next()? {
2866            let raw = coverage_row(row)?;
2867            budget.admit(coverage_row_decoded_bytes(&raw)?)?;
2868            coverage.push(coverage_from_row(raw, project, generation)?);
2869        }
2870    }
2871
2872    let mut entity_exports = Vec::new();
2873    {
2874        let mut statement = connection.prepare(
2875            "SELECT key.project_instance_id, key.resolution_domain, key.key_digest,
2876                    key.canonical_identity, export.entity_key
2877               FROM graph_entity_exports AS export
2878               JOIN graph_resolution_keys AS key
2879                 ON key.project_instance_id = export.project_instance_id
2880                AND key.resolution_domain = export.resolution_domain
2881                AND key.key_digest = export.key_digest
2882              WHERE export.project_instance_id = ?1
2883              ORDER BY export.entity_key, key.resolution_domain, key.canonical_identity",
2884        )?;
2885        let mut rows = statement.query(params![&project.as_bytes()[..]])?;
2886        while let Some(row) = rows.next()? {
2887            let key = resolution_key_from_row(row, project)?;
2888            let owner =
2889                fixed_bytes::<32>("graph_entity_exports.entity_key", row.get::<_, Vec<u8>>(4)?)?;
2890            budget.admit(decoded_payload_bytes(
2891                [
2892                    key.canonical_identity().len(),
2893                    key.digest_bytes().len(),
2894                    owner.len(),
2895                ],
2896                16,
2897            )?)?;
2898            entity_exports.push((owner, key));
2899        }
2900    }
2901
2902    let mut relation_dependencies = Vec::new();
2903    {
2904        let mut statement = connection.prepare(
2905            "SELECT key.project_instance_id, key.resolution_domain, key.key_digest,
2906                    key.canonical_identity, dependency.relation_key
2907               FROM graph_relation_dependencies AS dependency
2908               JOIN graph_resolution_keys AS key
2909                 ON key.project_instance_id = dependency.project_instance_id
2910                AND key.resolution_domain = dependency.resolution_domain
2911                AND key.key_digest = dependency.key_digest
2912              WHERE dependency.project_instance_id = ?1
2913              ORDER BY dependency.relation_key, key.resolution_domain, key.canonical_identity",
2914        )?;
2915        let mut rows = statement.query(params![&project.as_bytes()[..]])?;
2916        while let Some(row) = rows.next()? {
2917            let key = resolution_key_from_row(row, project)?;
2918            let owner = fixed_bytes::<32>(
2919                "graph_relation_dependencies.relation_key",
2920                row.get::<_, Vec<u8>>(4)?,
2921            )?;
2922            budget.admit(decoded_payload_bytes(
2923                [
2924                    key.canonical_identity().len(),
2925                    key.digest_bytes().len(),
2926                    owner.len(),
2927                ],
2928                16,
2929            )?)?;
2930            relation_dependencies.push((owner, key));
2931        }
2932    }
2933
2934    Ok(CapturedGraph {
2935        entities,
2936        relations,
2937        occurrences,
2938        coverage,
2939        entity_exports,
2940        relation_dependencies,
2941    })
2942}
2943
2944impl AtlasStore {
2945    /// Return whether an existing file is an exact disposable stage for this project.
2946    ///
2947    /// This intentionally validates only staging ownership metadata because disposable
2948    /// stores omit normal read indexes and are not ordinary `ProjectAtlas` databases.
2949    ///
2950    /// # Errors
2951    ///
2952    /// Returns an error when the file cannot be opened read-only or its ownership
2953    /// metadata cannot be read.
2954    pub fn repository_graph_staging_belongs_to(
2955        path: &Path,
2956        root: &Path,
2957        project: ProjectInstanceId,
2958    ) -> DbResult<bool> {
2959        let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
2960        let project_root = connection
2961            .query_row(
2962                "SELECT value FROM metadata WHERE key = ?1",
2963                [PROJECT_ROOT_KEY],
2964                |row| row.get::<_, String>(0),
2965            )
2966            .optional()?;
2967        let marker = connection
2968            .query_row(
2969                "SELECT value FROM metadata WHERE key = ?1",
2970                [GRAPH_STAGING_MARKER_KEY],
2971                |row| row.get::<_, String>(0),
2972            )
2973            .optional()?;
2974        Ok(
2975            project_root.as_deref() == Some(normalize_native_path_display(root).as_str())
2976                && load_project_identity(&connection)? == Some(project)
2977                && marker.as_deref() == Some(GRAPH_STAGING_MARKER_VALUE),
2978        )
2979    }
2980
2981    /// Create a new disposable graph staging store with the selected project identity.
2982    ///
2983    /// # Errors
2984    ///
2985    /// Returns an error when the path already exists, schema initialization or
2986    /// root binding fails, or the selected identity cannot be stored.
2987    pub fn create_repository_graph_staging(
2988        path: &Path,
2989        root: &Path,
2990        project: ProjectInstanceId,
2991    ) -> DbResult<Self> {
2992        if path.exists() {
2993            return Err(DbError::GraphRowShape {
2994                table: "repository graph staging database",
2995                reason: "staging path already exists",
2996            });
2997        }
2998        let mut store = Self::open_for_project(path, root)?;
2999        set_project_identity(&store.connection, project)?;
3000        store.connection.execute(
3001            "INSERT INTO metadata(key, value) VALUES (?1, ?2)
3002             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
3003            params![GRAPH_STAGING_MARKER_KEY, GRAPH_STAGING_MARKER_VALUE],
3004        )?;
3005        drop_graph_rebuildable_indexes(&store.connection)?;
3006        store.checkpoint_repository_graph_staging()?;
3007        store.validated_project_instance_id = Some(project);
3008        Ok(store)
3009    }
3010
3011    /// Checkpoint and truncate a completed disposable graph staging WAL.
3012    ///
3013    /// # Errors
3014    ///
3015    /// Returns an error when another connection keeps the staging WAL busy or
3016    /// `SQLite` cannot complete the checkpoint.
3017    pub fn checkpoint_repository_graph_staging(&self) -> DbResult<()> {
3018        let (busy, _log_frames, _checkpointed_frames) =
3019            self.connection
3020                .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
3021                    Ok((
3022                        row.get::<_, i64>(0)?,
3023                        row.get::<_, i64>(1)?,
3024                        row.get::<_, i64>(2)?,
3025                    ))
3026                })?;
3027        if busy != 0 {
3028            return Err(DbError::GraphRowShape {
3029                table: "repository graph staging database",
3030                reason: "WAL checkpoint remained busy",
3031            });
3032        }
3033        Ok(())
3034    }
3035
3036    /// Begin one disposable typed repository-graph staging transaction.
3037    ///
3038    /// # Errors
3039    ///
3040    /// Returns an error when the store belongs to another project, the generation
3041    /// is zero, or `SQLite` cannot begin or clear the staging transaction.
3042    pub fn begin_repository_graph_staging(
3043        &mut self,
3044        project: ProjectInstanceId,
3045        generation: IndexGeneration,
3046    ) -> DbResult<RepositoryGraphStagingGuard<'_>> {
3047        if generation == IndexGeneration::ZERO {
3048            return Err(GraphContractError::InvalidGeneration.into());
3049        }
3050        let transaction = self.connection.transaction()?;
3051        require_bound_project_identity(&transaction, project)?;
3052        transaction.execute("DELETE FROM graph_resolution_keys", [])?;
3053        transaction.execute("DELETE FROM graph_coverage", [])?;
3054        transaction.execute("DELETE FROM graph_relations", [])?;
3055        transaction.execute("DELETE FROM graph_entities", [])?;
3056        Ok(RepositoryGraphStagingGuard {
3057            transaction,
3058            project,
3059            generation,
3060        })
3061    }
3062}
3063
3064impl RepositoryGraphStagingGuard<'_> {
3065    /// Append borrowed entity rows without cloning the staged domain objects.
3066    ///
3067    /// # Errors
3068    ///
3069    /// Returns an error for foreign project or generation rows, stable-key
3070    /// collisions, or `SQLite` failures.
3071    pub fn append_entity_refs(&mut self, entities: &[&GraphEntity]) -> DbResult<()> {
3072        if entities.iter().any(|entity| {
3073            entity.key().project() != self.project || entity.generation() != self.generation
3074        }) {
3075            return Err(GraphContractError::GenerationMismatch {
3076                context: "repository graph staging entities",
3077            }
3078            .into());
3079        }
3080        insert_entities(&self.transaction, self.project, entities.iter().copied())
3081    }
3082
3083    /// Append one fully validated graph and resolution-key batch.
3084    ///
3085    /// # Errors
3086    ///
3087    /// Returns an error for foreign project or generation rows, invalid owner
3088    /// bindings, stable-key collisions, or `SQLite` failures.
3089    #[allow(clippy::too_many_arguments)]
3090    pub fn append_batch(
3091        &mut self,
3092        entities: &[GraphEntity],
3093        relations: &[LogicalRelation],
3094        occurrences: &[RelationOccurrence],
3095        coverage: &[CoverageRecord],
3096        entity_exports: &[EntityResolutionKey],
3097        relation_dependencies: &[RelationDependencyKey],
3098    ) -> DbResult<()> {
3099        validate_graph_batch(
3100            self.project,
3101            self.generation,
3102            entities,
3103            relations,
3104            occurrences,
3105            coverage,
3106        )?;
3107        validate_resolution_key_batch(self.project, entity_exports, relation_dependencies)?;
3108        insert_graph_batch(
3109            &self.transaction,
3110            self.project,
3111            entities,
3112            relations,
3113            occurrences,
3114            coverage,
3115        )?;
3116        insert_resolution_key_batch(
3117            &self.transaction,
3118            self.project,
3119            entity_exports,
3120            relation_dependencies,
3121        )
3122    }
3123
3124    /// Commit the staged graph and bind it to the requested generation.
3125    ///
3126    /// # Errors
3127    ///
3128    /// Returns an error when `SQLite` cannot persist the generation or commit.
3129    pub fn complete(self) -> DbResult<()> {
3130        set_graph_generation(&self.transaction, self.generation)?;
3131        self.transaction.commit()?;
3132        Ok(())
3133    }
3134}
3135
3136/// Normalized graph columns copied from one typed disposable staging database.
3137const GRAPH_STAGE_COPY_TABLES: &[(&str, &str)] = &[
3138    (
3139        "graph_entities",
3140        "entity_key, project_instance_id, canonical_identity, entity_kind, repository_path, \
3141         package_manager, package_name, manifest_path, symbol_name, symbol_kind, symbol_parent, \
3142         symbol_signature, external_system, external_identity",
3143    ),
3144    (
3145        "graph_relations",
3146        "relation_key, project_instance_id, canonical_identity, source_entity_key, \
3147         relation_scope, relation_kind, resolution_status, target_entity_key, reference_text, \
3148         candidate_count, confidence, completeness",
3149    ),
3150    (
3151        "graph_relation_occurrences",
3152        "relation_key, file_path, start_line, start_column, end_line, end_column",
3153    ),
3154    (
3155        "graph_coverage",
3156        "project_instance_id, scope_kind, scope_path, relation_scope, relation_kind, state, \
3157         total, covered, omitted, reason, reached_limit",
3158    ),
3159    (
3160        "graph_resolution_keys",
3161        "project_instance_id, resolution_domain, key_digest, canonical_identity",
3162    ),
3163    (
3164        "graph_entity_exports",
3165        "project_instance_id, entity_key, owner_path, resolution_domain, key_digest",
3166    ),
3167    (
3168        "graph_relation_dependencies",
3169        "project_instance_id, relation_key, owner_path, resolution_domain, key_digest",
3170    ),
3171];
3172
3173/// Non-unique graph read indexes rebuilt once after a full bulk replacement.
3174const GRAPH_REBUILDABLE_INDEX_NAMES: &[&str] = &[
3175    "idx_graph_entities_path",
3176    "idx_graph_entities_package",
3177    "idx_graph_entities_manifest_path",
3178    "idx_graph_entities_symbol",
3179    "idx_graph_entities_external",
3180    "idx_graph_relations_source_kind",
3181    "idx_graph_relations_target_kind",
3182    "idx_graph_relations_kind_order",
3183    "idx_graph_relations_kind_resolution",
3184    "idx_graph_occurrences_file_span",
3185    "idx_graph_coverage_scope_state",
3186    "idx_graph_coverage_scope_order",
3187    "idx_graph_coverage_path",
3188    "idx_graph_coverage_relation_state",
3189    "idx_graph_coverage_discovery_state",
3190    "idx_graph_coverage_discovery_reason",
3191    "idx_graph_entity_exports_key",
3192    "idx_graph_entity_exports_owner",
3193    "idx_graph_relation_dependencies_key",
3194    "idx_graph_relation_dependencies_owner",
3195];
3196
3197/// Drop only the known derived read indexes around one bulk graph load.
3198fn drop_graph_rebuildable_indexes(connection: &Connection) -> DbResult<()> {
3199    for name in GRAPH_REBUILDABLE_INDEX_NAMES {
3200        connection.execute(&format!("DROP INDEX IF EXISTS {name}"), [])?;
3201    }
3202    Ok(())
3203}
3204
3205/// Capture exact live index definitions before a transactional bulk graph load.
3206fn graph_rebuildable_index_sql(connection: &Connection) -> DbResult<Vec<String>> {
3207    GRAPH_REBUILDABLE_INDEX_NAMES
3208        .iter()
3209        .map(|name| {
3210            connection
3211                .query_row(
3212                    "SELECT sql FROM sqlite_schema WHERE type = 'index' AND name = ?1",
3213                    [name],
3214                    |row| row.get::<_, String>(0),
3215                )
3216                .optional()?
3217                .ok_or(DbError::GraphRowShape {
3218                    table: "repository graph indexes",
3219                    reason: "required rebuildable index is missing",
3220                })
3221        })
3222        .collect()
3223}
3224/// Maximum rows copied through one bounded multi-row `SQLite` insert.
3225const GRAPH_STAGE_COPY_ROWS: usize = 512;
3226
3227/// Insert one bounded group of decoded staging values.
3228fn insert_graph_stage_values(
3229    target: &Connection,
3230    table: &'static str,
3231    columns: &'static str,
3232    column_count: usize,
3233    values: &[Value],
3234) -> DbResult<()> {
3235    let rows = values.len() / column_count;
3236    let row = format!(
3237        "({})",
3238        std::iter::repeat_n("?", column_count)
3239            .collect::<Vec<_>>()
3240            .join(",")
3241    );
3242    let placeholders = std::iter::repeat_n(row, rows).collect::<Vec<_>>().join(",");
3243    let insert_sql = format!("INSERT INTO {table}({columns}) VALUES{placeholders}");
3244    target.execute(&insert_sql, params_from_iter(values.iter()))?;
3245    Ok(())
3246}
3247
3248/// Copy one normalized table through bounded decoded multi-row inserts.
3249fn copy_graph_stage_table(
3250    source: &Connection,
3251    target: &Connection,
3252    table: &'static str,
3253    columns: &'static str,
3254    control: Option<&IndexWorkControl>,
3255) -> DbResult<()> {
3256    let column_count = columns.split(',').count();
3257    let select_sql = format!("SELECT {columns} FROM {table}");
3258    let mut select = source.prepare(&select_sql)?;
3259    let mut rows = select.query([])?;
3260    let mut copied = 0_usize;
3261    let mut values = Vec::with_capacity(GRAPH_STAGE_COPY_ROWS.saturating_mul(column_count));
3262    while let Some(row) = rows.next()? {
3263        if copied.is_multiple_of(256)
3264            && let Some(control) = control
3265        {
3266            control.check(IndexWorkStage::Publication)?;
3267        }
3268        for column in 0..column_count {
3269            values.push(row.get::<_, Value>(column)?);
3270        }
3271        copied = copied.saturating_add(1);
3272        if copied.is_multiple_of(GRAPH_STAGE_COPY_ROWS) {
3273            insert_graph_stage_values(target, table, columns, column_count, &values)?;
3274            values.clear();
3275        }
3276    }
3277    if !values.is_empty() {
3278        insert_graph_stage_values(target, table, columns, column_count, &values)?;
3279    }
3280    Ok(())
3281}
3282
3283impl IndexPublicationGuard<'_> {
3284    /// Replace the complete graph from one typed disposable staging database.
3285    ///
3286    /// # Errors
3287    ///
3288    /// Returns an error when staging integrity, project identity, generation,
3289    /// normalized constraints, cancellation, or `SQLite` copying fails.
3290    pub fn replace_repository_graph_from_staging(
3291        &mut self,
3292        project: ProjectInstanceId,
3293        staging: &AtlasStore,
3294        control: Option<&IndexWorkControl>,
3295    ) -> DbResult<()> {
3296        let generation = self.pending_graph_generation()?;
3297        let staged_project = load_project_identity(&staging.connection)?;
3298        if staged_project != Some(project) {
3299            return Err(DbError::GraphProjectIdentityMismatch {
3300                expected: project.to_string(),
3301                found: staged_project
3302                    .map_or_else(|| "missing".to_string(), |value| value.to_string()),
3303            });
3304        }
3305        if load_graph_generation(&staging.connection)? != Some(generation) {
3306            return Err(GraphContractError::GenerationMismatch {
3307                context: "staged repository graph",
3308            }
3309            .into());
3310        }
3311        let quick_check = staging
3312            .connection
3313            .query_row("PRAGMA quick_check(1)", [], |row| row.get::<_, String>(0))?;
3314        if quick_check != "ok" {
3315            return Err(DbError::GraphRowShape {
3316                table: "repository graph staging database",
3317                reason: "quick check failed",
3318            });
3319        }
3320        let mut foreign_keys = staging.connection.prepare("PRAGMA foreign_key_check")?;
3321        if foreign_keys.query([])?.next()?.is_some() {
3322            return Err(DbError::GraphRowShape {
3323                table: "repository graph staging database",
3324                reason: "foreign key check failed",
3325            });
3326        }
3327
3328        let savepoint = self.store.connection.savepoint()?;
3329        require_bound_project_identity(&savepoint, project)?;
3330        let rebuildable_indexes = graph_rebuildable_index_sql(&savepoint)?;
3331        drop_graph_rebuildable_indexes(&savepoint)?;
3332        savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
3333        savepoint.execute("DELETE FROM graph_coverage", [])?;
3334        savepoint.execute("DELETE FROM graph_relations", [])?;
3335        savepoint.execute("DELETE FROM graph_entities", [])?;
3336        for (table, columns) in GRAPH_STAGE_COPY_TABLES {
3337            copy_graph_stage_table(&staging.connection, &savepoint, table, columns, control)?;
3338        }
3339        set_graph_generation(&savepoint, generation)?;
3340        for index_sql in rebuildable_indexes {
3341            savepoint.execute_batch(&index_sql)?;
3342        }
3343        savepoint.commit()?;
3344        Ok(())
3345    }
3346
3347    /// Replace the complete normalized repository graph inside this publication.
3348    ///
3349    /// # Errors
3350    ///
3351    /// Returns an error when records do not belong to the pending generation or
3352    /// selected project, a stable-key collision is detected, or `SQLite` fails.
3353    pub fn replace_repository_graph(
3354        &mut self,
3355        project: ProjectInstanceId,
3356        entities: &[GraphEntity],
3357        relations: &[LogicalRelation],
3358        occurrences: &[RelationOccurrence],
3359        coverage: &[CoverageRecord],
3360    ) -> DbResult<()> {
3361        self.replace_repository_graph_with_resolution_keys(
3362            project,
3363            entities,
3364            relations,
3365            occurrences,
3366            coverage,
3367            &[],
3368            &[],
3369        )
3370    }
3371
3372    /// Replace the complete graph and canonical resolution-key projection atomically.
3373    ///
3374    /// # Errors
3375    ///
3376    /// Returns an error when graph or key records do not belong to the pending
3377    /// generation and selected project, a stable-key collision is detected, an
3378    /// owner has no exact source path, or `SQLite` fails.
3379    #[allow(clippy::too_many_arguments)]
3380    pub fn replace_repository_graph_with_resolution_keys(
3381        &mut self,
3382        project: ProjectInstanceId,
3383        entities: &[GraphEntity],
3384        relations: &[LogicalRelation],
3385        occurrences: &[RelationOccurrence],
3386        coverage: &[CoverageRecord],
3387        entity_exports: &[EntityResolutionKey],
3388        relation_dependencies: &[RelationDependencyKey],
3389    ) -> DbResult<()> {
3390        let generation = self.pending_graph_generation()?;
3391        validate_graph_batch(
3392            project,
3393            generation,
3394            entities,
3395            relations,
3396            occurrences,
3397            coverage,
3398        )?;
3399        validate_resolution_key_batch(project, entity_exports, relation_dependencies)?;
3400        let savepoint = self.store.connection.savepoint()?;
3401        require_bound_project_identity(&savepoint, project)?;
3402        savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
3403        savepoint.execute("DELETE FROM graph_coverage", [])?;
3404        savepoint.execute("DELETE FROM graph_relations", [])?;
3405        savepoint.execute("DELETE FROM graph_entities", [])?;
3406        insert_graph_batch(
3407            &savepoint,
3408            project,
3409            entities,
3410            relations,
3411            occurrences,
3412            coverage,
3413        )?;
3414        insert_resolution_key_batch(&savepoint, project, entity_exports, relation_dependencies)?;
3415        set_graph_generation(&savepoint, generation)?;
3416        savepoint.commit()?;
3417        Ok(())
3418    }
3419
3420    /// Replace the normalized graph closure owned by affected repository paths.
3421    ///
3422    /// Unchanged rows stay physically untouched and are reconstructed at the next
3423    /// complete publication generation. The caller supplies the complete new
3424    /// closure for the affected paths.
3425    ///
3426    /// # Errors
3427    ///
3428    /// Returns an error when records do not belong to the pending generation or
3429    /// selected project, a stable-key collision is detected, or `SQLite` fails.
3430    pub fn replace_repository_graph_for_paths(
3431        &mut self,
3432        project: ProjectInstanceId,
3433        affected_paths: &[String],
3434        entities: &[GraphEntity],
3435        relations: &[LogicalRelation],
3436        occurrences: &[RelationOccurrence],
3437        coverage: &[CoverageRecord],
3438    ) -> DbResult<()> {
3439        self.replace_repository_graph_for_paths_with_resolution_keys(
3440            project,
3441            affected_paths,
3442            entities,
3443            relations,
3444            occurrences,
3445            coverage,
3446            &[],
3447            &[],
3448        )
3449    }
3450
3451    /// Replace one affected graph closure and its canonical resolution keys atomically.
3452    ///
3453    /// # Errors
3454    ///
3455    /// Returns an error when graph or key records do not belong to the pending
3456    /// generation and selected project, a stable-key collision is detected, an
3457    /// owner has no exact source path, or `SQLite` fails.
3458    #[allow(clippy::too_many_arguments)]
3459    pub fn replace_repository_graph_for_paths_with_resolution_keys(
3460        &mut self,
3461        project: ProjectInstanceId,
3462        affected_paths: &[String],
3463        entities: &[GraphEntity],
3464        relations: &[LogicalRelation],
3465        occurrences: &[RelationOccurrence],
3466        coverage: &[CoverageRecord],
3467        entity_exports: &[EntityResolutionKey],
3468        relation_dependencies: &[RelationDependencyKey],
3469    ) -> DbResult<()> {
3470        let generation = self.pending_graph_generation()?;
3471        validate_graph_batch(
3472            project,
3473            generation,
3474            entities,
3475            relations,
3476            occurrences,
3477            coverage,
3478        )?;
3479        validate_resolution_key_batch(project, entity_exports, relation_dependencies)?;
3480        let affected_paths = affected_paths
3481            .iter()
3482            .map(|path| RepositoryNodePath::new(Path::new(path)))
3483            .collect::<Result<Vec<_>, _>>()?;
3484        let savepoint = self.store.connection.savepoint()?;
3485        require_bound_project_identity(&savepoint, project)?;
3486        if affected_paths.iter().any(|path| path.as_str() == ".") {
3487            savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
3488            savepoint.execute("DELETE FROM graph_coverage", [])?;
3489            savepoint.execute("DELETE FROM graph_relations", [])?;
3490            savepoint.execute("DELETE FROM graph_entities", [])?;
3491            insert_graph_batch(
3492                &savepoint,
3493                project,
3494                entities,
3495                relations,
3496                occurrences,
3497                coverage,
3498            )?;
3499            insert_resolution_key_batch(
3500                &savepoint,
3501                project,
3502                entity_exports,
3503                relation_dependencies,
3504            )?;
3505            set_graph_generation(&savepoint, generation)?;
3506            savepoint.commit()?;
3507            return Ok(());
3508        }
3509        let touched_keys = resolution_keys_for_owner_paths(
3510            &savepoint,
3511            project,
3512            &affected_paths,
3513            ResolutionOwner::Both,
3514            None,
3515        )?;
3516        let mut orphan_candidates = affected_external_candidates(&savepoint, &affected_paths)?;
3517        invalidate_repository_graph_paths(&savepoint, &affected_paths, &mut orphan_candidates)?;
3518        insert_graph_batch(
3519            &savepoint,
3520            project,
3521            entities,
3522            relations,
3523            occurrences,
3524            coverage,
3525        )?;
3526        insert_resolution_key_batch(&savepoint, project, entity_exports, relation_dependencies)?;
3527        for entity in entities {
3528            if matches!(entity.selector(), EntitySelector::External { .. }) {
3529                orphan_candidates.insert(entity.key().digest_bytes()?);
3530            }
3531        }
3532        remove_orphan_external_candidates(&savepoint, &orphan_candidates)?;
3533        remove_touched_orphan_resolution_keys(&savepoint, &touched_keys)?;
3534        set_graph_generation(&savepoint, generation)?;
3535        savepoint.commit()?;
3536        Ok(())
3537    }
3538
3539    /// Return the generation that will become complete if this guard commits.
3540    fn pending_graph_generation(&self) -> DbResult<IndexGeneration> {
3541        self.previous_generation
3542            .checked_next()
3543            .ok_or(DbError::PublicationGenerationOverflow)
3544    }
3545}
3546
3547/// Conservative key count that stays below `SQLite`'s legacy bind ceiling.
3548const RESOLUTION_KEYS_PER_QUERY: usize = 200;
3549/// Conservative path count that stays below `SQLite`'s legacy bind ceiling.
3550const RESOLUTION_PATHS_PER_QUERY: usize = 400;
3551/// Conservative affected-entity count for one external-candidate adjacency batch.
3552const EXTERNAL_CANDIDATE_KEYS_PER_QUERY: usize = 400;
3553
3554/// Closed owner projections that retain canonical resolution keys.
3555#[derive(Clone, Copy)]
3556enum ResolutionOwner {
3557    /// Entity-export bindings only.
3558    EntityExports,
3559    /// Both binding families, used for touched-key garbage collection.
3560    Both,
3561}
3562
3563impl ResolutionOwner {
3564    /// Return the owner tables and their path-first access indexes.
3565    fn tables(self) -> &'static [(&'static str, &'static str)] {
3566        match self {
3567            Self::EntityExports => &[("graph_entity_exports", "idx_graph_entity_exports_owner")],
3568            Self::Both => &[
3569                ("graph_entity_exports", "idx_graph_entity_exports_owner"),
3570                (
3571                    "graph_relation_dependencies",
3572                    "idx_graph_relation_dependencies_owner",
3573                ),
3574            ],
3575        }
3576    }
3577}
3578
3579/// Validate and deduplicate exact source paths before querying owner bindings.
3580fn normalized_file_paths(paths: &[String]) -> DbResult<Vec<RepositoryNodePath>> {
3581    let mut normalized = BTreeSet::new();
3582    for path in paths {
3583        let file = RepositoryFilePath::new(Path::new(path))?;
3584        normalized.insert(RepositoryNodePath::new(Path::new(file.as_str()))?);
3585    }
3586    Ok(normalized.into_iter().collect())
3587}
3588
3589/// Validate project ownership and deduplicate canonical keys deterministically.
3590fn normalized_resolution_keys(
3591    project: ProjectInstanceId,
3592    keys: &[CanonicalResolutionKey],
3593) -> DbResult<Vec<CanonicalResolutionKey>> {
3594    let mut normalized = BTreeSet::new();
3595    for key in keys {
3596        if key.project() != project {
3597            return Err(DbError::GraphProjectIdentityMismatch {
3598                expected: project.to_string(),
3599                found: key.project().to_string(),
3600            });
3601        }
3602        normalized.insert(key.clone());
3603    }
3604    Ok(normalized.into_iter().collect())
3605}
3606
3607/// Convert a sorted unique set into one `LIMIT + 1` page.
3608fn page_from_ordered_set<T: Ord>(rows: BTreeSet<T>, limit: u32) -> RepositoryGraphPage<T> {
3609    let truncated = rows.len() > limit as usize;
3610    RepositoryGraphPage {
3611        rows: rows.into_iter().take(limit as usize).collect(),
3612        truncated,
3613    }
3614}
3615
3616/// Load and validate canonical keys retained by path-owned graph projections.
3617fn resolution_keys_for_owner_paths(
3618    connection: &Connection,
3619    project: ProjectInstanceId,
3620    paths: &[RepositoryNodePath],
3621    owners: ResolutionOwner,
3622    limit: Option<u32>,
3623) -> DbResult<BTreeSet<CanonicalResolutionKey>> {
3624    let mut keys = BTreeSet::new();
3625    for (table, index) in owners.tables() {
3626        for chunk in paths.chunks(RESOLUTION_PATHS_PER_QUERY) {
3627            if chunk.is_empty() {
3628                continue;
3629            }
3630            let placeholders = vec!["?"; chunk.len()].join(",");
3631            let limit_clause = limit.map_or("", |_| " LIMIT ?");
3632            let sql = format!(
3633                "SELECT DISTINCT resolution.project_instance_id,
3634                        resolution.resolution_domain, resolution.key_digest,
3635                        resolution.canonical_identity
3636                   FROM {table} AS owner INDEXED BY {index}
3637                   JOIN graph_resolution_keys AS resolution
3638                     ON resolution.project_instance_id = owner.project_instance_id
3639                    AND resolution.resolution_domain = owner.resolution_domain
3640                    AND resolution.key_digest = owner.key_digest
3641                  WHERE owner.project_instance_id = ?
3642                    AND owner.owner_path IN ({placeholders})
3643                  ORDER BY resolution.resolution_domain, resolution.key_digest{limit_clause}"
3644            );
3645            let mut values = Vec::with_capacity(chunk.len() + 2);
3646            values.push(Value::Blob(project.as_bytes().to_vec()));
3647            values.extend(
3648                chunk
3649                    .iter()
3650                    .map(|path| Value::Text(path.as_str().to_string())),
3651            );
3652            if let Some(limit) = limit {
3653                values.push(Value::Integer(i64::from(limit) + 1));
3654            }
3655            let mut statement = connection.prepare(&sql)?;
3656            let mut rows = statement.query(params_from_iter(values.iter()))?;
3657            while let Some(row) = rows.next()? {
3658                keys.insert(resolution_key_from_row(row, project)?);
3659            }
3660            if limit.is_some_and(|limit| keys.len() > limit as usize) {
3661                return Ok(keys);
3662            }
3663        }
3664    }
3665    Ok(keys)
3666}
3667
3668/// Reconstruct one canonical key row without accepting malformed persisted data.
3669fn resolution_key_from_row(
3670    row: &Row<'_>,
3671    expected_project: ProjectInstanceId,
3672) -> DbResult<CanonicalResolutionKey> {
3673    let project = project_from_blob(
3674        "graph_resolution_keys.project_instance_id",
3675        row.get::<_, Vec<u8>>(0)?,
3676    )?;
3677    require_project(expected_project, project)?;
3678    let domain_text = row.get::<_, String>(1)?;
3679    let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
3680    let digest = fixed_bytes::<32>(
3681        "graph_resolution_keys.key_digest",
3682        row.get::<_, Vec<u8>>(2)?,
3683    )?;
3684    Ok(CanonicalResolutionKey::from_persisted(
3685        project,
3686        domain,
3687        digest,
3688        row.get(3)?,
3689    )?)
3690}
3691
3692/// Validate one requested key against any retained canonical witness.
3693fn validate_persisted_resolution_key(
3694    connection: &Connection,
3695    key: &CanonicalResolutionKey,
3696) -> DbResult<bool> {
3697    let stored = connection
3698        .query_row(
3699            "SELECT canonical_identity
3700               FROM graph_resolution_keys
3701              WHERE project_instance_id = ?1
3702                AND resolution_domain = ?2
3703                AND key_digest = ?3",
3704            params![
3705                &key.project().as_bytes()[..],
3706                key.domain().as_str(),
3707                &key.digest_bytes()[..],
3708            ],
3709            |row| row.get::<_, String>(0),
3710        )
3711        .optional()?;
3712    let Some(stored) = stored else {
3713        return Ok(false);
3714    };
3715    if stored != key.canonical_identity() {
3716        return Err(DbError::ResolutionKeyCollision {
3717            domain: key.domain().as_str(),
3718            digest: key.digest_bytes(),
3719        });
3720    }
3721    CanonicalResolutionKey::from_persisted(
3722        key.project(),
3723        key.domain(),
3724        key.digest_bytes(),
3725        stored,
3726    )?;
3727    Ok(true)
3728}
3729
3730/// Validate all retained witnesses for a key batch without per-key queries.
3731fn validate_persisted_resolution_keys(
3732    connection: &Connection,
3733    keys: &[CanonicalResolutionKey],
3734) -> DbResult<()> {
3735    for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
3736        if chunk.is_empty() {
3737            continue;
3738        }
3739        let values_clause = anonymous_values_clause(chunk.len(), 4);
3740        let sql = format!(
3741            "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
3742                  AS (VALUES {values_clause})
3743             SELECT requested.resolution_domain, requested.key_digest,
3744                    requested.canonical_identity, stored.canonical_identity
3745               FROM requested
3746               JOIN graph_resolution_keys AS stored
3747                 ON stored.project_instance_id = requested.project_instance_id
3748                AND stored.resolution_domain = requested.resolution_domain
3749                AND stored.key_digest = requested.key_digest
3750              ORDER BY requested.resolution_domain, requested.key_digest"
3751        );
3752        let values = resolution_key_values(chunk, true);
3753        let mut statement = connection.prepare(&sql)?;
3754        let mut rows = statement.query(params_from_iter(values.iter()))?;
3755        while let Some(row) = rows.next()? {
3756            let domain_text = row.get::<_, String>(0)?;
3757            let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
3758            let digest = fixed_bytes::<32>(
3759                "graph_resolution_keys.key_digest",
3760                row.get::<_, Vec<u8>>(1)?,
3761            )?;
3762            let requested = row.get::<_, String>(2)?;
3763            let stored = row.get::<_, String>(3)?;
3764            if requested != stored {
3765                return Err(DbError::ResolutionKeyCollision {
3766                    domain: domain.as_str(),
3767                    digest,
3768                });
3769            }
3770        }
3771    }
3772    Ok(())
3773}
3774
3775/// Return distinct ordered dependency-owning paths for a bounded key set.
3776fn affected_source_paths(
3777    connection: &Connection,
3778    keys: &[CanonicalResolutionKey],
3779    limit: u32,
3780) -> DbResult<BTreeSet<RepositoryFilePath>> {
3781    let mut paths = BTreeSet::new();
3782    for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
3783        if chunk.is_empty() {
3784            continue;
3785        }
3786        let values_clause = anonymous_values_clause(chunk.len(), 4);
3787        let sql = format!(
3788            "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
3789                  AS (VALUES {values_clause})
3790             SELECT DISTINCT dependency.owner_path
3791               FROM requested
3792               JOIN graph_resolution_keys AS stored
3793                 ON stored.project_instance_id = requested.project_instance_id
3794                AND stored.resolution_domain = requested.resolution_domain
3795                AND stored.key_digest = requested.key_digest
3796                AND stored.canonical_identity = requested.canonical_identity
3797               JOIN graph_relation_dependencies AS dependency
3798                    INDEXED BY idx_graph_relation_dependencies_key
3799                 ON dependency.project_instance_id = stored.project_instance_id
3800                AND dependency.resolution_domain = stored.resolution_domain
3801                AND dependency.key_digest = stored.key_digest
3802              ORDER BY dependency.owner_path
3803              LIMIT ?"
3804        );
3805        let mut values = resolution_key_values(chunk, true);
3806        values.push(Value::Integer(i64::from(limit) + 1));
3807        let mut statement = connection.prepare(&sql)?;
3808        let mut rows = statement.query(params_from_iter(values.iter()))?;
3809        while let Some(row) = rows.next()? {
3810            paths.insert(RepositoryFilePath::new(Path::new(
3811                &row.get::<_, String>(0)?,
3812            ))?);
3813        }
3814        if paths.len() > limit as usize {
3815            return Ok(paths);
3816        }
3817    }
3818    Ok(paths)
3819}
3820
3821/// Account one exact-path closure through bounded index-owned branches.
3822fn affected_source_footprint(
3823    connection: &Connection,
3824    project: ProjectInstanceId,
3825    paths: &[RepositoryNodePath],
3826    limit_plus_one: i64,
3827) -> DbResult<RepositoryAffectedSourceFootprint> {
3828    let maximum_rows = u64::try_from(limit_plus_one).map_err(|source| DbError::InvalidCount {
3829        field: "affected_source_footprint.limit_plus_one",
3830        value: limit_plus_one,
3831        source,
3832    })?;
3833    let mut footprint = empty_affected_source_footprint();
3834    for chunk in paths.chunks(RESOLUTION_PATHS_PER_QUERY) {
3835        if chunk.is_empty() {
3836            continue;
3837        }
3838        let remaining = maximum_rows.saturating_sub(footprint.rows);
3839        if remaining == 0 {
3840            footprint.truncated = true;
3841            break;
3842        }
3843        let sql = affected_source_footprint_sql(chunk.len());
3844        let mut values = Vec::with_capacity(chunk.len() + 2);
3845        values.push(Value::Blob(project.as_bytes().to_vec()));
3846        values.extend(
3847            chunk
3848                .iter()
3849                .map(|path| Value::Text(path.as_str().to_string())),
3850        );
3851        values.push(Value::Integer(i64::try_from(remaining).map_err(
3852            |source| DbError::InvalidCount {
3853                field: "affected_source_footprint.remaining_rows",
3854                value: i64::MAX,
3855                source,
3856            },
3857        )?));
3858        let mut statement = connection.prepare(&sql)?;
3859        let mut rows = statement.query(params_from_iter(values.iter()))?;
3860        while let Some(row) = rows.next()? {
3861            let bytes = nonnegative_u64(
3862                "affected_source_footprint.retained_bytes",
3863                row.get::<_, i64>(0)?,
3864            )?;
3865            footprint.rows = footprint.rows.saturating_add(1);
3866            footprint.retained_bytes = footprint.retained_bytes.saturating_add(bytes);
3867        }
3868        if footprint.rows >= maximum_rows {
3869            footprint.truncated = true;
3870            break;
3871        }
3872    }
3873    Ok(footprint)
3874}
3875
3876/// Build one bounded union of exact-path footprint branches.
3877fn affected_source_footprint_sql(path_count: usize) -> String {
3878    let requested = vec!["(?)"; path_count].join(",");
3879    format!(
3880        "WITH selected_project(project_instance_id) AS (VALUES (?)),
3881              requested(path) AS (VALUES {requested})
3882         SELECT retained_bytes
3883           FROM (
3884             SELECT length(CAST(metadata.path AS BLOB))
3885                    + coalesce(length(CAST(metadata.language AS BLOB)), 0)
3886                    + length(CAST(metadata.source_parser AS BLOB))
3887                    + length(CAST(metadata.fact_parser AS BLOB)) + 16
3888                    + length(CAST(metadata.updated_at AS BLOB)) AS retained_bytes
3889               FROM requested
3890               JOIN source_parse_metadata AS metadata
3891                    INDEXED BY sqlite_autoindex_source_parse_metadata_1
3892                 ON metadata.path = requested.path
3893             UNION ALL
3894             SELECT 32 + length(CAST(symbol.path AS BLOB))
3895                    + coalesce(length(CAST(symbol.language AS BLOB)), 0)
3896                    + length(CAST(symbol.name AS BLOB))
3897                    + length(CAST(symbol.kind AS BLOB))
3898                    + length(CAST(symbol.signature AS BLOB))
3899                    + coalesce(length(CAST(symbol.documentation AS BLOB)), 0)
3900                    + coalesce(length(CAST(symbol.parent AS BLOB)), 0)
3901                    + length(CAST(symbol.parser AS BLOB))
3902                    + coalesce(length(CAST(symbol.detail AS BLOB)), 0)
3903                    + length(CAST(symbol.created_at AS BLOB))
3904                    + length(CAST(symbol.updated_at AS BLOB))
3905               FROM requested
3906               JOIN symbols AS symbol INDEXED BY idx_symbols_path
3907                 ON symbol.path = requested.path
3908             UNION ALL
3909             SELECT 16 + length(CAST(symbol_relation.path AS BLOB))
3910                    + length(CAST(symbol_relation.source_name AS BLOB))
3911                    + length(CAST(symbol_relation.target_name AS BLOB))
3912                    + length(CAST(symbol_relation.kind AS BLOB))
3913                    + length(CAST(symbol_relation.context AS BLOB))
3914                    + length(CAST(symbol_relation.parser AS BLOB))
3915                    + length(CAST(symbol_relation.created_at AS BLOB))
3916               FROM requested
3917               JOIN symbol_relations AS symbol_relation INDEXED BY idx_symbol_relations_path
3918                 ON symbol_relation.path = requested.path
3919             UNION ALL
3920             SELECT 48 + length(CAST(entity.canonical_identity AS BLOB))
3921                    + length(CAST(entity.entity_kind AS BLOB))
3922                    + coalesce(length(CAST(entity.repository_path AS BLOB)), 0)
3923                    + coalesce(length(CAST(entity.package_manager AS BLOB)), 0)
3924                    + coalesce(length(CAST(entity.package_name AS BLOB)), 0)
3925                    + coalesce(length(CAST(entity.manifest_path AS BLOB)), 0)
3926                    + coalesce(length(CAST(entity.symbol_name AS BLOB)), 0)
3927                    + coalesce(length(CAST(entity.symbol_kind AS BLOB)), 0)
3928                    + coalesce(length(CAST(entity.symbol_parent AS BLOB)), 0)
3929                    + coalesce(length(CAST(entity.symbol_signature AS BLOB)), 0)
3930                    + coalesce(length(CAST(entity.external_system AS BLOB)), 0)
3931                    + coalesce(length(CAST(entity.external_identity AS BLOB)), 0)
3932               FROM requested
3933               JOIN graph_entities AS entity INDEXED BY idx_graph_entities_path
3934                 ON entity.repository_path = requested.path
3935               JOIN selected_project
3936                 ON selected_project.project_instance_id = entity.project_instance_id
3937             UNION ALL
3938             SELECT 48 + length(CAST(entity.canonical_identity AS BLOB))
3939                    + length(CAST(entity.entity_kind AS BLOB))
3940                    + coalesce(length(CAST(entity.repository_path AS BLOB)), 0)
3941                    + coalesce(length(CAST(entity.package_manager AS BLOB)), 0)
3942                    + coalesce(length(CAST(entity.package_name AS BLOB)), 0)
3943                    + coalesce(length(CAST(entity.manifest_path AS BLOB)), 0)
3944                    + coalesce(length(CAST(entity.symbol_name AS BLOB)), 0)
3945                    + coalesce(length(CAST(entity.symbol_kind AS BLOB)), 0)
3946                    + coalesce(length(CAST(entity.symbol_parent AS BLOB)), 0)
3947                    + coalesce(length(CAST(entity.symbol_signature AS BLOB)), 0)
3948                    + coalesce(length(CAST(entity.external_system AS BLOB)), 0)
3949                    + coalesce(length(CAST(entity.external_identity AS BLOB)), 0)
3950               FROM requested
3951               JOIN graph_entities AS entity INDEXED BY idx_graph_entities_manifest_path
3952                 ON entity.manifest_path = requested.path
3953               JOIN selected_project
3954                 ON selected_project.project_instance_id = entity.project_instance_id
3955             UNION ALL
3956             SELECT 80 + length(CAST(relation.canonical_identity AS BLOB))
3957                    + length(CAST(relation.relation_scope AS BLOB))
3958                    + length(CAST(relation.relation_kind AS BLOB))
3959                    + length(CAST(relation.resolution_status AS BLOB))
3960                    + coalesce(length(relation.target_entity_key), 0)
3961                    + coalesce(length(CAST(relation.reference_text AS BLOB)), 0)
3962                    + CASE WHEN relation.candidate_count IS NULL THEN 0 ELSE 8 END
3963                    + length(CAST(relation.confidence AS BLOB))
3964                    + length(CAST(relation.completeness AS BLOB))
3965               FROM requested
3966               JOIN graph_entities AS entity INDEXED BY idx_graph_entities_path
3967                 ON entity.repository_path = requested.path
3968               JOIN selected_project
3969                 ON selected_project.project_instance_id = entity.project_instance_id
3970               JOIN graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
3971                 ON relation.source_entity_key = entity.entity_key
3972                AND relation.project_instance_id = selected_project.project_instance_id
3973             UNION ALL
3974             SELECT 80 + length(CAST(relation.canonical_identity AS BLOB))
3975                    + length(CAST(relation.relation_scope AS BLOB))
3976                    + length(CAST(relation.relation_kind AS BLOB))
3977                    + length(CAST(relation.resolution_status AS BLOB))
3978                    + coalesce(length(relation.target_entity_key), 0)
3979                    + coalesce(length(CAST(relation.reference_text AS BLOB)), 0)
3980                    + CASE WHEN relation.candidate_count IS NULL THEN 0 ELSE 8 END
3981                    + length(CAST(relation.confidence AS BLOB))
3982                    + length(CAST(relation.completeness AS BLOB))
3983               FROM requested
3984               JOIN graph_entities AS entity INDEXED BY idx_graph_entities_manifest_path
3985                 ON entity.manifest_path = requested.path
3986               JOIN selected_project
3987                 ON selected_project.project_instance_id = entity.project_instance_id
3988               JOIN graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
3989                 ON relation.source_entity_key = entity.entity_key
3990                AND relation.project_instance_id = selected_project.project_instance_id
3991             UNION ALL
3992             SELECT 72 + length(CAST(occurrence.file_path AS BLOB))
3993               FROM requested
3994               JOIN graph_relation_occurrences AS occurrence
3995                    INDEXED BY idx_graph_occurrences_file_span
3996                 ON occurrence.file_path = requested.path
3997             UNION ALL
3998             SELECT 48 + length(CAST(coverage.scope_kind AS BLOB))
3999                    + coalesce(length(CAST(coverage.scope_path AS BLOB)), 0)
4000                    + coalesce(length(CAST(coverage.relation_scope AS BLOB)), 0)
4001                    + coalesce(length(CAST(coverage.relation_kind AS BLOB)), 0)
4002                    + length(CAST(coverage.state AS BLOB))
4003                    + coalesce(length(CAST(coverage.reason AS BLOB)), 0)
4004                    + coalesce(length(CAST(coverage.reached_limit AS BLOB)), 0)
4005               FROM requested
4006               JOIN graph_coverage AS coverage INDEXED BY idx_graph_coverage_path
4007                 ON coverage.scope_path = requested.path
4008               JOIN selected_project
4009                 ON selected_project.project_instance_id = coverage.project_instance_id
4010             UNION ALL
4011             SELECT 80 + length(CAST(export.owner_path AS BLOB))
4012                    + length(CAST(export.resolution_domain AS BLOB))
4013               FROM requested
4014               JOIN graph_entity_exports AS export INDEXED BY idx_graph_entity_exports_owner
4015                 ON export.owner_path = requested.path
4016               JOIN selected_project
4017                 ON selected_project.project_instance_id = export.project_instance_id
4018             UNION ALL
4019             SELECT length(witness.project_instance_id)
4020                    + length(CAST(witness.resolution_domain AS BLOB))
4021                    + length(witness.key_digest)
4022                    + length(CAST(witness.canonical_identity AS BLOB))
4023               FROM requested
4024               JOIN graph_entity_exports AS export INDEXED BY idx_graph_entity_exports_owner
4025                 ON export.owner_path = requested.path
4026               JOIN selected_project
4027                 ON selected_project.project_instance_id = export.project_instance_id
4028               LEFT JOIN graph_resolution_keys AS witness
4029                 ON witness.project_instance_id = export.project_instance_id
4030                AND witness.resolution_domain = export.resolution_domain
4031                AND witness.key_digest = export.key_digest
4032             UNION ALL
4033             SELECT 80 + length(CAST(dependency.owner_path AS BLOB))
4034                    + length(CAST(dependency.resolution_domain AS BLOB))
4035               FROM requested
4036               JOIN graph_relation_dependencies AS dependency
4037                    INDEXED BY idx_graph_relation_dependencies_owner
4038                 ON dependency.owner_path = requested.path
4039               JOIN selected_project
4040                 ON selected_project.project_instance_id = dependency.project_instance_id
4041             UNION ALL
4042             SELECT length(witness.project_instance_id)
4043                    + length(CAST(witness.resolution_domain AS BLOB))
4044                    + length(witness.key_digest)
4045                    + length(CAST(witness.canonical_identity AS BLOB))
4046               FROM requested
4047               JOIN graph_relation_dependencies AS dependency
4048                    INDEXED BY idx_graph_relation_dependencies_owner
4049                 ON dependency.owner_path = requested.path
4050               JOIN selected_project
4051                 ON selected_project.project_instance_id = dependency.project_instance_id
4052               LEFT JOIN graph_resolution_keys AS witness
4053                 ON witness.project_instance_id = dependency.project_instance_id
4054                AND witness.resolution_domain = dependency.resolution_domain
4055                AND witness.key_digest = dependency.key_digest
4056           )
4057          LIMIT ?"
4058    )
4059}
4060
4061/// Build an anonymous `VALUES` clause with fixed columns per row.
4062fn anonymous_values_clause(rows: usize, columns: usize) -> String {
4063    let row = format!("({})", vec!["?"; columns].join(","));
4064    vec![row; rows].join(",")
4065}
4066
4067/// Bind one canonical-key batch in the same order as its `VALUES` rows.
4068fn resolution_key_values(keys: &[CanonicalResolutionKey], witness: bool) -> Vec<Value> {
4069    let columns = if witness { 4 } else { 3 };
4070    let mut values = Vec::with_capacity(keys.len() * columns);
4071    for key in keys {
4072        values.push(Value::Blob(key.project().as_bytes().to_vec()));
4073        values.push(Value::Text(key.domain().as_str().to_string()));
4074        values.push(Value::Blob(key.digest_bytes().to_vec()));
4075        if witness {
4076            values.push(Value::Text(key.canonical_identity().to_string()));
4077        }
4078    }
4079    values
4080}
4081
4082/// Validate graph-owner bindings before acquiring the savepoint.
4083fn validate_resolution_key_batch(
4084    project: ProjectInstanceId,
4085    entity_exports: &[EntityResolutionKey],
4086    relation_dependencies: &[RelationDependencyKey],
4087) -> DbResult<()> {
4088    let foreign = entity_exports.iter().find_map(|binding| {
4089        (binding.entity().project() != project || binding.key().project() != project)
4090            .then(|| binding.key().project())
4091    });
4092    let foreign = foreign.or_else(|| {
4093        relation_dependencies.iter().find_map(|binding| {
4094            (binding.relation().project() != project || binding.key().project() != project)
4095                .then(|| binding.key().project())
4096        })
4097    });
4098    if let Some(found) = foreign {
4099        return Err(DbError::GraphProjectIdentityMismatch {
4100            expected: project.to_string(),
4101            found: found.to_string(),
4102        });
4103    }
4104    Ok(())
4105}
4106
4107/// Insert a validated resolution-key projection through prepared owner statements.
4108fn insert_resolution_key_batch(
4109    connection: &Connection,
4110    project: ProjectInstanceId,
4111    entity_exports: &[EntityResolutionKey],
4112    relation_dependencies: &[RelationDependencyKey],
4113) -> DbResult<()> {
4114    let mut insert_key = connection.prepare_cached(
4115        "INSERT INTO graph_resolution_keys(
4116             project_instance_id, resolution_domain, key_digest, canonical_identity
4117         ) VALUES(?1, ?2, ?3, ?4)
4118         ON CONFLICT(project_instance_id, resolution_domain, key_digest)
4119         DO UPDATE SET canonical_identity = excluded.canonical_identity
4120         WHERE graph_resolution_keys.canonical_identity = excluded.canonical_identity",
4121    )?;
4122    let mut insert_export = connection.prepare_cached(
4123        "INSERT INTO graph_entity_exports(
4124             project_instance_id, entity_key, owner_path, resolution_domain, key_digest
4125         )
4126         SELECT entity.project_instance_id, entity.entity_key,
4127                CASE entity.entity_kind
4128                    WHEN 'file' THEN entity.repository_path
4129                    WHEN 'symbol' THEN entity.repository_path
4130                    WHEN 'package' THEN entity.manifest_path
4131                END,
4132                ?3, ?4
4133           FROM graph_entities AS entity
4134          WHERE entity.project_instance_id = ?1 AND entity.entity_key = ?2
4135            AND entity.entity_kind IN ('file', 'symbol', 'package')
4136         ON CONFLICT(project_instance_id, entity_key, resolution_domain, key_digest)
4137         DO UPDATE SET owner_path = excluded.owner_path",
4138    )?;
4139    let mut insert_dependency = connection.prepare_cached(
4140        "INSERT INTO graph_relation_dependencies(
4141             project_instance_id, relation_key, owner_path, resolution_domain, key_digest
4142         )
4143         SELECT relation.project_instance_id, relation.relation_key,
4144                CASE source.entity_kind
4145                    WHEN 'file' THEN source.repository_path
4146                    WHEN 'symbol' THEN source.repository_path
4147                    WHEN 'package' THEN source.manifest_path
4148                END,
4149                ?3, ?4
4150           FROM graph_relations AS relation
4151           JOIN graph_entities AS source
4152             ON source.project_instance_id = relation.project_instance_id
4153            AND source.entity_key = relation.source_entity_key
4154          WHERE relation.project_instance_id = ?1 AND relation.relation_key = ?2
4155            AND source.entity_kind IN ('file', 'symbol', 'package')
4156         ON CONFLICT(project_instance_id, relation_key, resolution_domain, key_digest)
4157         DO UPDATE SET owner_path = excluded.owner_path",
4158    )?;
4159
4160    for binding in entity_exports {
4161        insert_resolution_key(&mut insert_key, binding.key())?;
4162        let key = binding.key();
4163        let inserted = insert_export.execute(params![
4164            &project.as_bytes()[..],
4165            &binding.entity().digest_bytes()?[..],
4166            key.domain().as_str(),
4167            &key.digest_bytes()[..],
4168        ])?;
4169        if inserted != 1 {
4170            return Err(DbError::GraphRowShape {
4171                table: "graph_entity_exports",
4172                reason: "export keys require a local file, symbol, or package owner",
4173            });
4174        }
4175    }
4176    for binding in relation_dependencies {
4177        insert_resolution_key(&mut insert_key, binding.key())?;
4178        let key = binding.key();
4179        let inserted = insert_dependency.execute(params![
4180            &project.as_bytes()[..],
4181            &binding.relation().digest_bytes()?[..],
4182            key.domain().as_str(),
4183            &key.digest_bytes()[..],
4184        ])?;
4185        if inserted != 1 {
4186            return Err(DbError::GraphRowShape {
4187                table: "graph_relation_dependencies",
4188                reason: "dependency keys require a local file, symbol, or package source",
4189            });
4190        }
4191    }
4192    Ok(())
4193}
4194
4195/// Insert one key witness, rejecting equal digests with different material.
4196fn insert_resolution_key(
4197    statement: &mut rusqlite::CachedStatement<'_>,
4198    key: &CanonicalResolutionKey,
4199) -> DbResult<()> {
4200    let inserted = statement.execute(params![
4201        &key.project().as_bytes()[..],
4202        key.domain().as_str(),
4203        &key.digest_bytes()[..],
4204        key.canonical_identity(),
4205    ])?;
4206    if inserted == 0 {
4207        return Err(DbError::ResolutionKeyCollision {
4208            domain: key.domain().as_str(),
4209            digest: key.digest_bytes(),
4210        });
4211    }
4212    Ok(())
4213}
4214
4215/// Delete only touched witness rows left without either owner family.
4216fn remove_touched_orphan_resolution_keys(
4217    connection: &Connection,
4218    keys: &BTreeSet<CanonicalResolutionKey>,
4219) -> DbResult<()> {
4220    let keys = keys.iter().cloned().collect::<Vec<_>>();
4221    for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
4222        if chunk.is_empty() {
4223            continue;
4224        }
4225        let values_clause = anonymous_values_clause(chunk.len(), 3);
4226        let sql = format!(
4227            "WITH touched(project_instance_id, resolution_domain, key_digest)
4228                  AS (VALUES {values_clause})
4229             DELETE FROM graph_resolution_keys
4230              WHERE rowid IN (
4231                    SELECT stored.rowid
4232                      FROM graph_resolution_keys AS stored
4233                      JOIN touched
4234                        ON touched.project_instance_id = stored.project_instance_id
4235                       AND touched.resolution_domain = stored.resolution_domain
4236                       AND touched.key_digest = stored.key_digest
4237                     WHERE NOT EXISTS (
4238                               SELECT 1 FROM graph_entity_exports AS export
4239                                WHERE export.project_instance_id = stored.project_instance_id
4240                                  AND export.resolution_domain = stored.resolution_domain
4241                                  AND export.key_digest = stored.key_digest
4242                           )
4243                       AND NOT EXISTS (
4244                               SELECT 1 FROM graph_relation_dependencies AS dependency
4245                                WHERE dependency.project_instance_id = stored.project_instance_id
4246                                  AND dependency.resolution_domain = stored.resolution_domain
4247                                  AND dependency.key_digest = stored.key_digest
4248                           )
4249              )"
4250        );
4251        let values = resolution_key_values(chunk, false);
4252        connection.execute(&sql, params_from_iter(values.iter()))?;
4253    }
4254    Ok(())
4255}
4256
4257/// Collect external entities whose relation to an affected local entity may vanish.
4258fn affected_external_candidates(
4259    connection: &Connection,
4260    affected_paths: &[RepositoryNodePath],
4261) -> DbResult<HashSet<[u8; 32]>> {
4262    let mut by_repository_path = connection.prepare_cached(
4263        "SELECT entity_key FROM graph_entities
4264          WHERE repository_path = ?1
4265             OR (repository_path >= ?2 AND repository_path < ?3)",
4266    )?;
4267    let mut by_manifest_path = connection.prepare_cached(
4268        "SELECT entity_key FROM graph_entities
4269          WHERE manifest_path = ?1 OR (manifest_path >= ?2 AND manifest_path < ?3)",
4270    )?;
4271    let mut local_keys = HashSet::new();
4272    for path in affected_paths {
4273        let path = path.as_str();
4274        let (descendant_start, descendant_end) = repository_descendant_bounds(path);
4275        for statement in [&mut by_repository_path, &mut by_manifest_path] {
4276            let mut rows = statement.query(params![path, descendant_start, descendant_end])?;
4277            while let Some(row) = rows.next()? {
4278                local_keys.insert(fixed_bytes::<32>(
4279                    "graph_entities.entity_key",
4280                    row.get::<_, Vec<u8>>(0)?,
4281                )?);
4282            }
4283        }
4284    }
4285
4286    let mut candidates = HashSet::new();
4287    let local_keys = local_keys.into_iter().collect::<Vec<_>>();
4288    for chunk in local_keys.chunks(EXTERNAL_CANDIDATE_KEYS_PER_QUERY) {
4289        let sql = external_candidate_batch_sql(chunk.len());
4290        let mut statement = connection.prepare_cached(&sql)?;
4291        let mut rows = statement.query(params_from_iter(chunk.iter().map(|key| &key[..])))?;
4292        while let Some(row) = rows.next()? {
4293            candidates.insert(fixed_bytes::<32>(
4294                "graph_entities.entity_key",
4295                row.get::<_, Vec<u8>>(0)?,
4296            )?);
4297        }
4298    }
4299    Ok(candidates)
4300}
4301
4302/// Build one two-direction indexed adjacency query for a bounded affected-key batch.
4303fn external_candidate_batch_sql(key_count: usize) -> String {
4304    let values = anonymous_values_clause(key_count, 1);
4305    format!(
4306        "WITH affected(entity_key) AS (VALUES {values})
4307         SELECT relation.target_entity_key
4308           FROM affected
4309           CROSS JOIN graph_relations AS relation
4310                      INDEXED BY idx_graph_relations_source_kind
4311           JOIN graph_entities AS external
4312             ON external.entity_key = relation.target_entity_key
4313          WHERE relation.source_entity_key = affected.entity_key
4314            AND external.entity_kind = 'external'
4315         UNION ALL
4316         SELECT relation.source_entity_key
4317           FROM affected
4318           CROSS JOIN graph_relations AS relation
4319                      INDEXED BY idx_graph_relations_target_kind
4320           JOIN graph_entities AS external
4321             ON external.entity_key = relation.source_entity_key
4322          WHERE relation.target_entity_key = affected.entity_key
4323            AND external.entity_kind = 'external'"
4324    )
4325}
4326
4327/// Delete one affected local closure through statements prepared once per batch.
4328fn invalidate_repository_graph_paths(
4329    connection: &Connection,
4330    affected_paths: &[RepositoryNodePath],
4331    orphan_candidates: &mut HashSet<[u8; 32]>,
4332) -> DbResult<()> {
4333    let mut affected_relations = HashSet::new();
4334    let mut relation_occurrences = connection.prepare_cached(
4335        "SELECT relation_key FROM graph_relation_occurrences
4336          WHERE file_path = ?1 OR (file_path >= ?2 AND file_path < ?3)",
4337    )?;
4338    let mut occurrences = connection.prepare_cached(
4339        "DELETE FROM graph_relation_occurrences
4340          WHERE file_path = ?1 OR (file_path >= ?2 AND file_path < ?3)",
4341    )?;
4342    let mut coverage = connection.prepare_cached(
4343        "DELETE FROM graph_coverage
4344          INDEXED BY idx_graph_coverage_path
4345          WHERE scope_kind = 'path'
4346            AND (scope_path = ?1 OR (scope_path >= ?2 AND scope_path < ?3))",
4347    )?;
4348    let mut entities_by_path = connection.prepare_cached(
4349        "DELETE FROM graph_entities
4350          WHERE repository_path = ?1
4351             OR (repository_path >= ?2 AND repository_path < ?3)",
4352    )?;
4353    let mut entities_by_manifest = connection.prepare_cached(
4354        "DELETE FROM graph_entities
4355          WHERE manifest_path = ?1 OR (manifest_path >= ?2 AND manifest_path < ?3)",
4356    )?;
4357    for path in affected_paths {
4358        let path = path.as_str();
4359        let (descendant_start, descendant_end) = repository_descendant_bounds(path);
4360        let mut rows =
4361            relation_occurrences.query(params![path, descendant_start, descendant_end])?;
4362        while let Some(row) = rows.next()? {
4363            affected_relations.insert(fixed_bytes::<32>(
4364                "graph_relation_occurrences.relation_key",
4365                row.get::<_, Vec<u8>>(0)?,
4366            )?);
4367        }
4368        occurrences.execute(params![path, descendant_start, descendant_end])?;
4369        coverage.execute(params![path, descendant_start, descendant_end])?;
4370        entities_by_path.execute(params![path, descendant_start, descendant_end])?;
4371        entities_by_manifest.execute(params![path, descendant_start, descendant_end])?;
4372    }
4373    collect_external_relation_endpoints(connection, &affected_relations, orphan_candidates)?;
4374    let mut relation = connection.prepare_cached(
4375        "DELETE FROM graph_relations
4376          WHERE relation_key = ?1
4377            AND NOT EXISTS (
4378                SELECT 1 FROM graph_relation_occurrences
4379                 WHERE relation_key = ?1
4380            )",
4381    )?;
4382    for relation_key in affected_relations {
4383        relation.execute([&relation_key[..]])?;
4384    }
4385    Ok(())
4386}
4387
4388/// Retain external endpoints whose occurrence-backed relation may be removed.
4389fn collect_external_relation_endpoints(
4390    connection: &Connection,
4391    relation_keys: &HashSet<[u8; 32]>,
4392    candidates: &mut HashSet<[u8; 32]>,
4393) -> DbResult<()> {
4394    let mut endpoints = connection.prepare_cached(
4395        "SELECT source_entity_key, target_entity_key
4396           FROM graph_relations
4397          WHERE relation_key = ?1",
4398    )?;
4399    let mut is_external = connection.prepare_cached(
4400        "SELECT EXISTS(
4401            SELECT 1 FROM graph_entities
4402             WHERE entity_key = ?1 AND entity_kind = 'external'
4403        )",
4404    )?;
4405    for relation_key in relation_keys {
4406        let endpoints = endpoints
4407            .query_row([&relation_key[..]], |row| {
4408                Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, Option<Vec<u8>>>(1)?))
4409            })
4410            .optional()?;
4411        let Some((source, target)) = endpoints else {
4412            continue;
4413        };
4414        for endpoint in [Some(source), target].into_iter().flatten() {
4415            let endpoint = fixed_bytes::<32>("graph_relations endpoint", endpoint)?;
4416            if is_external.query_row([&endpoint[..]], |row| row.get::<_, bool>(0))? {
4417                candidates.insert(endpoint);
4418            }
4419        }
4420    }
4421    Ok(())
4422}
4423
4424/// Remove only candidate external entities that no surviving relation references.
4425fn remove_orphan_external_candidates(
4426    connection: &Connection,
4427    candidates: &HashSet<[u8; 32]>,
4428) -> DbResult<()> {
4429    let mut statement = connection.prepare_cached(
4430        "DELETE FROM graph_entities
4431          WHERE entity_key = ?1 AND entity_kind = 'external'
4432            AND NOT EXISTS (
4433                SELECT 1 FROM graph_relations INDEXED BY idx_graph_relations_source_kind
4434                 WHERE source_entity_key = ?1
4435            )
4436            AND NOT EXISTS (
4437                SELECT 1 FROM graph_relations INDEXED BY idx_graph_relations_target_kind
4438                 WHERE target_entity_key = ?1
4439            )",
4440    )?;
4441    for candidate in candidates {
4442        statement.execute([&candidate[..]])?;
4443    }
4444    Ok(())
4445}
4446
4447/// Return case-preserving indexed bounds for every slash-delimited descendant.
4448fn repository_descendant_bounds(path: &str) -> (String, String) {
4449    (format!("{path}/"), format!("{path}0"))
4450}
4451
4452/// Validate ownership and generation before any graph mutation occurs.
4453fn validate_graph_batch(
4454    project: ProjectInstanceId,
4455    generation: IndexGeneration,
4456    entities: &[GraphEntity],
4457    relations: &[LogicalRelation],
4458    occurrences: &[RelationOccurrence],
4459    coverage: &[CoverageRecord],
4460) -> DbResult<()> {
4461    if entities
4462        .iter()
4463        .any(|entity| entity.key().project() != project)
4464        || relations
4465            .iter()
4466            .any(|relation| relation.key().project() != project)
4467        || occurrences
4468            .iter()
4469            .any(|occurrence| occurrence.relation().project() != project)
4470    {
4471        return Err(DbError::GraphProjectIdentityMismatch {
4472            expected: project.to_string(),
4473            found: "record from another project".to_string(),
4474        });
4475    }
4476    if entities
4477        .iter()
4478        .any(|entity| entity.generation() != generation)
4479        || relations
4480            .iter()
4481            .any(|relation| relation.generation() != generation)
4482        || occurrences
4483            .iter()
4484            .any(|occurrence| occurrence.generation() != generation)
4485        || coverage
4486            .iter()
4487            .any(|record| record.generation() != generation)
4488    {
4489        return Err(
4490            projectatlas_core::graph::GraphContractError::GenerationMismatch {
4491                context: "repository graph publication batch",
4492            }
4493            .into(),
4494        );
4495    }
4496    Ok(())
4497}
4498
4499/// Insert one validated graph batch through cached normalized statements.
4500fn insert_graph_batch(
4501    connection: &Connection,
4502    project: ProjectInstanceId,
4503    entities: &[GraphEntity],
4504    relations: &[LogicalRelation],
4505    occurrences: &[RelationOccurrence],
4506    coverage: &[CoverageRecord],
4507) -> DbResult<()> {
4508    insert_entities(connection, project, entities)?;
4509    insert_relations(connection, project, relations)?;
4510    insert_occurrences(connection, occurrences)?;
4511    insert_coverage(connection, project, coverage)
4512}
4513
4514/// Insert typed entities while refusing compact-key collisions.
4515fn insert_entities<'entity>(
4516    connection: &Connection,
4517    project: ProjectInstanceId,
4518    entities: impl IntoIterator<Item = &'entity GraphEntity>,
4519) -> DbResult<()> {
4520    let mut insert = connection.prepare_cached(
4521        "INSERT INTO graph_entities(
4522            entity_key, project_instance_id, canonical_identity, entity_kind,
4523            repository_path, package_manager, package_name, manifest_path,
4524            symbol_name, symbol_kind, symbol_parent, symbol_signature,
4525            external_system, external_identity
4526         ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
4527         ON CONFLICT(entity_key) DO NOTHING",
4528    )?;
4529    let mut existing = connection.prepare_cached(
4530        "SELECT project_instance_id, canonical_identity
4531           FROM graph_entities WHERE entity_key = ?1",
4532    )?;
4533    for entity in entities {
4534        let columns = entity_columns(entity.selector());
4535        let key = entity.key().digest_bytes()?;
4536        insert.execute(params![
4537            &key[..],
4538            &project.as_bytes()[..],
4539            entity.key().canonical_identity(),
4540            columns.kind,
4541            columns.repository_path,
4542            columns.package_manager,
4543            columns.package_name,
4544            columns.manifest_path,
4545            columns.symbol_name,
4546            columns.symbol_kind,
4547            columns.symbol_parent,
4548            columns.symbol_signature,
4549            columns.external_system,
4550            columns.external_identity,
4551        ])?;
4552        let (stored_project, stored_canonical): (Vec<u8>, String) =
4553            existing.query_row([&key[..]], |row| Ok((row.get(0)?, row.get(1)?)))?;
4554        if fixed_bytes::<16>("graph_entities.project_instance_id", stored_project)?
4555            != project.as_bytes()
4556            || stored_canonical != entity.key().canonical_identity()
4557        {
4558            return Err(
4559                projectatlas_core::graph::GraphContractError::StableKeyCollision {
4560                    digest: entity.key().digest().to_string(),
4561                }
4562                .into(),
4563            );
4564        }
4565    }
4566    Ok(())
4567}
4568
4569/// Insert typed logical relations while allowing trust metadata to refresh.
4570fn insert_relations(
4571    connection: &Connection,
4572    project: ProjectInstanceId,
4573    relations: &[LogicalRelation],
4574) -> DbResult<()> {
4575    let mut statement = connection.prepare_cached(
4576        "INSERT INTO graph_relations(
4577            relation_key, project_instance_id, canonical_identity, source_entity_key,
4578            relation_scope, relation_kind, resolution_status, target_entity_key,
4579            reference_text, candidate_count, confidence, completeness
4580         ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
4581         ON CONFLICT(relation_key) DO UPDATE SET
4582            confidence = excluded.confidence,
4583            completeness = excluded.completeness
4584         WHERE graph_relations.project_instance_id = excluded.project_instance_id
4585           AND graph_relations.canonical_identity = excluded.canonical_identity
4586           AND graph_relations.source_entity_key = excluded.source_entity_key
4587           AND graph_relations.relation_scope = excluded.relation_scope
4588           AND graph_relations.relation_kind = excluded.relation_kind
4589           AND graph_relations.resolution_status = excluded.resolution_status
4590           AND graph_relations.target_entity_key IS excluded.target_entity_key
4591           AND graph_relations.reference_text IS excluded.reference_text
4592           AND graph_relations.candidate_count IS excluded.candidate_count",
4593    )?;
4594    for relation in relations {
4595        let (scope, kind) = relation_parts(relation.kind());
4596        let resolution = resolution_columns(relation.resolution())?;
4597        let key = relation.key().digest_bytes()?;
4598        let source = relation.source().digest_bytes()?;
4599        let changed = statement.execute(params![
4600            &key[..],
4601            &project.as_bytes()[..],
4602            relation.key().canonical_identity(),
4603            &source[..],
4604            scope,
4605            kind,
4606            resolution.status,
4607            resolution.target.as_ref().map(|target| &target[..]),
4608            resolution.reference,
4609            resolution.candidate_count,
4610            confidence_name(relation.confidence()),
4611            completeness_name(relation.completeness()),
4612        ])?;
4613        if changed == 0 {
4614            return Err(
4615                projectatlas_core::graph::GraphContractError::StableKeyCollision {
4616                    digest: relation.key().digest().to_string(),
4617                }
4618                .into(),
4619            );
4620        }
4621    }
4622    Ok(())
4623}
4624
4625/// Insert every exact source occurrence without duplicating logical evidence.
4626fn insert_occurrences(connection: &Connection, occurrences: &[RelationOccurrence]) -> DbResult<()> {
4627    let mut statement = connection.prepare_cached(
4628        "INSERT INTO graph_relation_occurrences(
4629            relation_key, file_path, start_line, start_column, end_line, end_column
4630         ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)
4631         ON CONFLICT(relation_key, file_path, start_line, start_column, end_line, end_column)
4632         DO NOTHING",
4633    )?;
4634    for occurrence in occurrences {
4635        let key = occurrence.relation().digest_bytes()?;
4636        let span = occurrence.span();
4637        statement.execute(params![
4638            &key[..],
4639            occurrence.file().as_str(),
4640            i64::from(span.start_line()),
4641            i64::from(span.start_column()),
4642            i64::from(span.end_line()),
4643            i64::from(span.end_column()),
4644        ])?;
4645    }
4646    Ok(())
4647}
4648
4649/// Replace coverage rows by their normalized identity.
4650fn insert_coverage(
4651    connection: &Connection,
4652    project: ProjectInstanceId,
4653    coverage: &[CoverageRecord],
4654) -> DbResult<()> {
4655    let mut remove = connection.prepare_cached(
4656        "DELETE FROM graph_coverage
4657          WHERE project_instance_id = ?1 AND scope_kind = ?2 AND scope_path IS ?3
4658            AND relation_scope IS ?4 AND relation_kind IS ?5",
4659    )?;
4660    let mut insert = connection.prepare_cached(
4661        "INSERT INTO graph_coverage(
4662            project_instance_id, scope_kind, scope_path, relation_scope, relation_kind,
4663            state, total, covered, omitted, reason, reached_limit
4664         ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
4665    )?;
4666    for record in coverage {
4667        let (scope_kind, scope_path) = coverage_scope_parts(record.scope());
4668        let (relation_scope, relation_kind) = record
4669            .relation()
4670            .map(relation_parts)
4671            .map_or((None, None), |(scope, kind)| (Some(scope), Some(kind)));
4672        let values = params![
4673            &project.as_bytes()[..],
4674            scope_kind,
4675            scope_path,
4676            relation_scope,
4677            relation_kind,
4678        ];
4679        remove.execute(values)?;
4680        insert.execute(params![
4681            &project.as_bytes()[..],
4682            scope_kind,
4683            scope_path,
4684            relation_scope,
4685            relation_kind,
4686            coverage_state_name(record.state()),
4687            sqlite_count("graph_coverage.total", record.total())?,
4688            sqlite_count("graph_coverage.covered", record.covered())?,
4689            sqlite_count("graph_coverage.omitted", record.omitted())?,
4690            record.reason().map(GraphIdentityText::as_str),
4691            record.reached_limit().map(limit_kind_name),
4692        ])?;
4693    }
4694    Ok(())
4695}
4696
4697/// Borrowed normalized selector columns for one entity insert.
4698struct EntityColumns<'selector> {
4699    /// Normalized selector variant.
4700    kind: &'static str,
4701    /// Folder, file, or symbol repository path.
4702    repository_path: Option<&'selector str>,
4703    /// Package ecosystem.
4704    package_manager: Option<&'selector str>,
4705    /// Package name.
4706    package_name: Option<&'selector str>,
4707    /// Package manifest path.
4708    manifest_path: Option<&'selector str>,
4709    /// Declaration name.
4710    symbol_name: Option<&'selector str>,
4711    /// Declaration kind.
4712    symbol_kind: Option<&'static str>,
4713    /// Optional containing declaration.
4714    symbol_parent: Option<&'selector str>,
4715    /// Stable declaration signature.
4716    symbol_signature: Option<&'selector str>,
4717    /// External namespace.
4718    external_system: Option<&'selector str>,
4719    /// External identity.
4720    external_identity: Option<&'selector str>,
4721}
4722
4723/// Map one typed selector to its normalized database columns.
4724fn entity_columns(selector: &EntitySelector) -> EntityColumns<'_> {
4725    match selector {
4726        EntitySelector::Project => EntityColumns {
4727            kind: "project",
4728            repository_path: None,
4729            package_manager: None,
4730            package_name: None,
4731            manifest_path: None,
4732            symbol_name: None,
4733            symbol_kind: None,
4734            symbol_parent: None,
4735            symbol_signature: None,
4736            external_system: None,
4737            external_identity: None,
4738        },
4739        EntitySelector::Folder { path } => EntityColumns {
4740            kind: "folder",
4741            repository_path: Some(path.as_str()),
4742            package_manager: None,
4743            package_name: None,
4744            manifest_path: None,
4745            symbol_name: None,
4746            symbol_kind: None,
4747            symbol_parent: None,
4748            symbol_signature: None,
4749            external_system: None,
4750            external_identity: None,
4751        },
4752        EntitySelector::File { path } => EntityColumns {
4753            kind: "file",
4754            repository_path: Some(path.as_str()),
4755            package_manager: None,
4756            package_name: None,
4757            manifest_path: None,
4758            symbol_name: None,
4759            symbol_kind: None,
4760            symbol_parent: None,
4761            symbol_signature: None,
4762            external_system: None,
4763            external_identity: None,
4764        },
4765        EntitySelector::Package { package } => EntityColumns {
4766            kind: "package",
4767            repository_path: None,
4768            package_manager: Some(package.manager.as_str()),
4769            package_name: Some(package.name.as_str()),
4770            manifest_path: Some(package.manifest.as_str()),
4771            symbol_name: None,
4772            symbol_kind: None,
4773            symbol_parent: None,
4774            symbol_signature: None,
4775            external_system: None,
4776            external_identity: None,
4777        },
4778        EntitySelector::Symbol { symbol } => EntityColumns {
4779            kind: "symbol",
4780            repository_path: Some(symbol.file.as_str()),
4781            package_manager: None,
4782            package_name: None,
4783            manifest_path: None,
4784            symbol_name: Some(symbol.name.as_str()),
4785            symbol_kind: Some(symbol_kind_name(symbol.kind)),
4786            symbol_parent: symbol.parent.as_ref().map(GraphIdentityText::as_str),
4787            symbol_signature: Some(symbol.signature.as_str()),
4788            external_system: None,
4789            external_identity: None,
4790        },
4791        EntitySelector::External { external } => EntityColumns {
4792            kind: "external",
4793            repository_path: None,
4794            package_manager: None,
4795            package_name: None,
4796            manifest_path: None,
4797            symbol_name: None,
4798            symbol_kind: None,
4799            symbol_parent: None,
4800            symbol_signature: None,
4801            external_system: Some(external.system.as_str()),
4802            external_identity: Some(external.identity.as_str()),
4803        },
4804    }
4805}
4806
4807/// Borrowed normalized resolution columns for one relation insert.
4808struct ResolutionColumns<'resolution> {
4809    /// Normalized resolution state.
4810    status: &'static str,
4811    /// Optional resolved or external target.
4812    target: Option<[u8; 32]>,
4813    /// Optional unresolved reference text.
4814    reference: Option<&'resolution str>,
4815    /// Optional ambiguous candidate count.
4816    candidate_count: Option<i64>,
4817}
4818
4819/// Map typed resolution state to normalized database columns.
4820fn resolution_columns(resolution: &RelationResolution) -> DbResult<ResolutionColumns<'_>> {
4821    match resolution {
4822        RelationResolution::Resolved { target, .. } => Ok(ResolutionColumns {
4823            status: "resolved",
4824            target: Some(target.digest_bytes()?),
4825            reference: None,
4826            candidate_count: None,
4827        }),
4828        RelationResolution::Ambiguous {
4829            reference,
4830            candidates,
4831        } => Ok(ResolutionColumns {
4832            status: "ambiguous",
4833            target: None,
4834            reference: Some(reference.as_str()),
4835            candidate_count: Some(i64::from(candidates.get())),
4836        }),
4837        RelationResolution::Unresolved { reference } => Ok(ResolutionColumns {
4838            status: "unresolved",
4839            target: None,
4840            reference: Some(reference.as_str()),
4841            candidate_count: None,
4842        }),
4843        RelationResolution::External { target, .. } => Ok(ResolutionColumns {
4844            status: "external",
4845            target: Some(target.digest_bytes()?),
4846            reference: None,
4847            candidate_count: None,
4848        }),
4849    }
4850}
4851
4852/// Reconstruct one typed entity and validate persisted key witnesses.
4853fn entity_from_row(
4854    row: EntityRow,
4855    expected_project: ProjectInstanceId,
4856    generation: IndexGeneration,
4857) -> DbResult<GraphEntity> {
4858    let project = project_from_blob("graph_entities.project_instance_id", row.project.clone())?;
4859    require_project(expected_project, project)?;
4860    validate_entity_row_shape(&row)?;
4861    let selector = match row.kind.as_str() {
4862        "project" => EntitySelector::Project,
4863        "folder" => EntitySelector::Folder {
4864            path: RepositoryNodePath::new(Path::new(required_text(
4865                "graph_entities",
4866                "folder path is missing",
4867                row.repository_path.as_deref(),
4868            )?))?,
4869        },
4870        "file" => EntitySelector::File {
4871            path: RepositoryFilePath::new(Path::new(required_text(
4872                "graph_entities",
4873                "file path is missing",
4874                row.repository_path.as_deref(),
4875            )?))?,
4876        },
4877        "package" => EntitySelector::Package {
4878            package: PackageSelector {
4879                manager: GraphIdentityText::new(required_text(
4880                    "graph_entities",
4881                    "package manager is missing",
4882                    row.package_manager.as_deref(),
4883                )?)?,
4884                name: GraphIdentityText::new(required_text(
4885                    "graph_entities",
4886                    "package name is missing",
4887                    row.package_name.as_deref(),
4888                )?)?,
4889                manifest: RepositoryFilePath::new(Path::new(required_text(
4890                    "graph_entities",
4891                    "package manifest is missing",
4892                    row.manifest_path.as_deref(),
4893                )?))?,
4894            },
4895        },
4896        "symbol" => EntitySelector::Symbol {
4897            symbol: SymbolSelector {
4898                file: RepositoryFilePath::new(Path::new(required_text(
4899                    "graph_entities",
4900                    "symbol file is missing",
4901                    row.repository_path.as_deref(),
4902                )?))?,
4903                name: GraphIdentityText::new(required_text(
4904                    "graph_entities",
4905                    "symbol name is missing",
4906                    row.symbol_name.as_deref(),
4907                )?)?,
4908                kind: parse_symbol_kind(required_text(
4909                    "graph_entities",
4910                    "symbol kind is missing",
4911                    row.symbol_kind.as_deref(),
4912                )?)?,
4913                parent: row.symbol_parent.map(GraphIdentityText::new).transpose()?,
4914                signature: GraphIdentityText::new(required_text(
4915                    "graph_entities",
4916                    "symbol signature is missing",
4917                    row.symbol_signature.as_deref(),
4918                )?)?,
4919            },
4920        },
4921        "external" => EntitySelector::External {
4922            external: ExternalSelector {
4923                system: GraphIdentityText::new(required_text(
4924                    "graph_entities",
4925                    "external system is missing",
4926                    row.external_system.as_deref(),
4927                )?)?,
4928                identity: GraphIdentityText::new(required_text(
4929                    "graph_entities",
4930                    "external identity is missing",
4931                    row.external_identity.as_deref(),
4932                )?)?,
4933            },
4934        },
4935        value => {
4936            return Err(DbError::InvalidEnum {
4937                field: "graph_entities.entity_kind",
4938                value: value.to_string(),
4939            });
4940        }
4941    };
4942    let entity = GraphEntity::new(project, selector, generation)?;
4943    validate_entity_key(&entity, row.key, &row.canonical)?;
4944    Ok(entity)
4945}
4946
4947/// Validate selector-column shape independently of physical schema checks.
4948fn validate_entity_row_shape(row: &EntityRow) -> DbResult<()> {
4949    let repository = row.repository_path.is_some();
4950    let package = (
4951        row.package_manager.is_some(),
4952        row.package_name.is_some(),
4953        row.manifest_path.is_some(),
4954    );
4955    let symbol = (
4956        row.symbol_name.is_some(),
4957        row.symbol_kind.is_some(),
4958        row.symbol_parent.is_some(),
4959        row.symbol_signature.is_some(),
4960    );
4961    let external = (
4962        row.external_system.is_some(),
4963        row.external_identity.is_some(),
4964    );
4965    let valid = match row.kind.as_str() {
4966        "project" => {
4967            !repository
4968                && package == (false, false, false)
4969                && symbol == (false, false, false, false)
4970                && external == (false, false)
4971        }
4972        "folder" | "file" => {
4973            repository
4974                && package == (false, false, false)
4975                && symbol == (false, false, false, false)
4976                && external == (false, false)
4977        }
4978        "package" => {
4979            !repository
4980                && package == (true, true, true)
4981                && symbol == (false, false, false, false)
4982                && external == (false, false)
4983        }
4984        "symbol" => {
4985            repository
4986                && package == (false, false, false)
4987                && symbol.0
4988                && symbol.1
4989                && symbol.3
4990                && external == (false, false)
4991        }
4992        "external" => {
4993            !repository
4994                && package == (false, false, false)
4995                && symbol == (false, false, false, false)
4996                && external == (true, true)
4997        }
4998        _ => true,
4999    };
5000    if !valid {
5001        return Err(DbError::GraphRowShape {
5002            table: "graph_entities",
5003            reason: "selector columns contradict entity kind",
5004        });
5005    }
5006    Ok(())
5007}
5008
5009/// Build one direction-owned batched adjacency statement.
5010fn adjacency_relation_sql(
5011    frontier_count: usize,
5012    direction: RepositoryGraphDirection,
5013    continuation_index: Option<usize>,
5014    relation_filter: bool,
5015) -> String {
5016    let (key_column, index_name) = match direction {
5017        RepositoryGraphDirection::Outbound => {
5018            ("source_entity_key", "idx_graph_relations_source_kind")
5019        }
5020        RepositoryGraphDirection::Inbound => {
5021            ("target_entity_key", "idx_graph_relations_target_kind")
5022        }
5023    };
5024    let request = "request(project_instance_id) AS (VALUES (?))";
5025    let relation_filter = if relation_filter {
5026        "AND relation.relation_scope = ? AND relation.relation_kind = ?"
5027    } else {
5028        ""
5029    };
5030    let branches = (continuation_index.unwrap_or(0)..frontier_count)
5031        .map(|frontier_index| {
5032            let continuation = if continuation_index == Some(frontier_index) {
5033                "AND (relation.relation_scope, relation.relation_kind,
5034                      relation.relation_key) > (?, ?, ?)"
5035                    .to_string()
5036            } else {
5037                String::new()
5038            };
5039            format!(
5040                "SELECT * FROM (
5041                     SELECT {frontier_index} AS frontier_index,
5042                            relation.relation_key, relation.project_instance_id,
5043                            relation.canonical_identity, relation.source_entity_key,
5044                            relation.relation_scope, relation.relation_kind,
5045                            relation.resolution_status, relation.target_entity_key,
5046                            relation.reference_text, relation.candidate_count,
5047                            relation.confidence, relation.completeness
5048                       FROM graph_relations AS relation INDEXED BY {index_name}
5049                       CROSS JOIN request
5050                      WHERE relation.{key_column} = ?
5051                        AND relation.project_instance_id = request.project_instance_id
5052                        {relation_filter}
5053                        {continuation}
5054                      ORDER BY relation.relation_scope, relation.relation_kind,
5055                               relation.relation_key
5056                      LIMIT ?
5057                 )"
5058            )
5059        })
5060        .collect::<Vec<_>>()
5061        .join(" UNION ALL ");
5062    format!(
5063        "WITH {request}
5064         {branches}
5065         ORDER BY frontier_index, relation_scope, relation_kind,
5066                  relation_key
5067         LIMIT ?"
5068    )
5069}
5070
5071/// Build one anonymous fixed-column `VALUES` clause.
5072fn graph_values_clause(rows: usize, columns: usize) -> String {
5073    let row = format!("({})", vec!["?"; columns].join(", "));
5074    vec![row; rows].join(", ")
5075}
5076
5077/// Build one indexed stable-key entity hydration statement.
5078fn graph_entity_hydration_sql(entity_count: usize) -> String {
5079    format!(
5080        "WITH requested(entity_key) AS (VALUES {})
5081         SELECT entity.entity_key, entity.project_instance_id,
5082                entity.canonical_identity, entity.entity_kind,
5083                entity.repository_path, entity.package_manager,
5084                entity.package_name, entity.manifest_path,
5085                entity.symbol_name, entity.symbol_kind,
5086                entity.symbol_parent, entity.symbol_signature,
5087                entity.external_system, entity.external_identity
5088           FROM requested
5089           JOIN graph_entities AS entity
5090             ON entity.entity_key = requested.entity_key
5091          WHERE entity.project_instance_id = ?",
5092        graph_values_clause(entity_count, 1),
5093    )
5094}
5095
5096/// Build one project-scoped stable-key relation hydration statement.
5097fn graph_relation_hydration_sql(relation_count: usize) -> String {
5098    format!(
5099        "WITH requested(relation_key) AS (VALUES {})
5100         SELECT relation.relation_key, relation.project_instance_id,
5101                relation.canonical_identity, relation.source_entity_key,
5102                relation.relation_scope, relation.relation_kind,
5103                relation.resolution_status, relation.target_entity_key,
5104                relation.reference_text, relation.candidate_count,
5105                relation.confidence, relation.completeness
5106           FROM requested
5107           JOIN graph_relations AS relation INDEXED BY idx_graph_relations_project_key
5108             ON relation.relation_key = requested.relation_key
5109            AND relation.project_instance_id = ?",
5110        graph_values_clause(relation_count, 1),
5111    )
5112}
5113
5114/// Build direction-independent per-relation occurrence branches.
5115fn occurrence_pages_sql(relation_count: usize) -> String {
5116    let branches = (0..relation_count)
5117        .map(|index| {
5118            format!(
5119                "SELECT {index} AS relation_index, relation_key, file_path,
5120                        start_line, start_column, end_line, end_column
5121                   FROM (
5122                        SELECT relation_key, file_path, start_line, start_column,
5123                               end_line, end_column
5124                          FROM graph_relation_occurrences
5125                         WHERE relation_key = ?
5126                         ORDER BY file_path, start_line, start_column,
5127                                  end_line, end_column
5128                         LIMIT ?
5129                   ) AS occurrence_page_{index}"
5130            )
5131        })
5132        .collect::<Vec<_>>()
5133        .join(" UNION ALL ");
5134    format!(
5135        "{branches}
5136         ORDER BY relation_index, file_path, start_line, start_column,
5137                  end_line, end_column"
5138    )
5139}
5140
5141/// Build the set-oriented exact-path coverage hydration statement.
5142fn path_coverage_sql(path_count: usize) -> String {
5143    let path_bindings = vec!["?"; path_count].join(", ");
5144    format!(
5145        "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
5146                relation_kind, state, total, covered, omitted, reason, reached_limit,
5147                NULL, NULL
5148           FROM graph_coverage
5149          WHERE project_instance_id = ?
5150            AND scope_kind = ?
5151            AND scope_path IN ({path_bindings})
5152          ORDER BY scope_path, relation_scope, relation_kind, state, id
5153          LIMIT ?"
5154    )
5155}
5156
5157/// Load every unique relation endpoint through bounded set-oriented joins.
5158fn load_relation_entities(
5159    store: &AtlasStore,
5160    rows: &[RelationRow],
5161    project: ProjectInstanceId,
5162    generation: IndexGeneration,
5163    control: Option<&IndexWorkControl>,
5164) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
5165    let references = rows.iter().collect::<Vec<_>>();
5166    load_relation_entity_references(store, &references, project, generation, control)
5167}
5168
5169/// Load relation endpoint references shared by ordinary and adjacency pages.
5170fn load_relation_entity_references(
5171    store: &AtlasStore,
5172    rows: &[&RelationRow],
5173    project: ProjectInstanceId,
5174    generation: IndexGeneration,
5175    control: Option<&IndexWorkControl>,
5176) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
5177    load_relation_entity_references_metered(store, rows, project, generation, control, None)
5178}
5179
5180/// Load relation endpoint references with optional exact batch accounting.
5181fn load_relation_entity_references_metered(
5182    store: &AtlasStore,
5183    rows: &[&RelationRow],
5184    project: ProjectInstanceId,
5185    generation: IndexGeneration,
5186    control: Option<&IndexWorkControl>,
5187    meter: Option<&mut RepositoryGraphReadMeter>,
5188) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
5189    let mut digests = BTreeSet::new();
5190    for row in rows {
5191        let row_project =
5192            project_from_blob("graph_relations.project_instance_id", row.project.clone())?;
5193        require_project(project, row_project)?;
5194        digests.insert(fixed_bytes::<32>(
5195            "graph_relations.source_entity_key",
5196            row.source.clone(),
5197        )?);
5198        if let Some(target) = &row.target {
5199            digests.insert(fixed_bytes::<32>(
5200                "graph_relations.target_entity_key",
5201                target.clone(),
5202            )?);
5203        }
5204    }
5205    load_graph_entities_by_digest_metered(
5206        store,
5207        &digests.into_iter().collect::<Vec<_>>(),
5208        project,
5209        generation,
5210        control,
5211        meter,
5212    )
5213}
5214
5215/// Hydrate one unique stable-key set with optional exact batch accounting.
5216fn load_graph_entities_by_digest_metered(
5217    store: &AtlasStore,
5218    digests: &[[u8; 32]],
5219    project: ProjectInstanceId,
5220    generation: IndexGeneration,
5221    control: Option<&IndexWorkControl>,
5222    mut meter: Option<&mut RepositoryGraphReadMeter>,
5223) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
5224    let mut entities = HashMap::with_capacity(digests.len());
5225    for chunk in digests.chunks(GRAPH_ENTITY_HYDRATION_CHUNK) {
5226        if let Some(control) = control {
5227            control.check(IndexWorkStage::RepositoryTraversal)?;
5228        }
5229        let sql = graph_entity_hydration_sql(chunk.len());
5230        let mut bindings = chunk
5231            .iter()
5232            .map(|digest| Value::Blob(digest.to_vec()))
5233            .collect::<Vec<_>>();
5234        bindings.push(Value::Blob(project.as_bytes().to_vec()));
5235        let raw = with_sqlite_read_progress(
5236            &store.connection,
5237            control,
5238            IndexWorkStage::RepositoryTraversal,
5239            || {
5240                let mut statement = store.connection.prepare(&sql)?;
5241                let rows = statement.query(params_from_iter(bindings.iter()))?;
5242                if let Some(meter) = meter.as_deref_mut() {
5243                    collect_entity_rows_metered(rows, meter)
5244                } else {
5245                    collect_entity_rows(rows)
5246                }
5247            },
5248        )?;
5249        for row in raw {
5250            let entity = entity_from_row(row, project, generation)?;
5251            let digest = entity.key().digest_bytes()?;
5252            if entities.contains_key(&digest) {
5253                return Err(DbError::GraphRowShape {
5254                    table: "graph_entities",
5255                    reason: "batched entity hydration returned a duplicate key",
5256                });
5257            }
5258            if let Some(meter) = meter.as_deref_mut() {
5259                meter.record_entity(&entity)?;
5260            }
5261            entities.insert(digest, entity);
5262        }
5263    }
5264    Ok(entities)
5265}
5266
5267/// Construct the compatibility envelope used by legacy bounded read wrappers.
5268fn maximum_repository_graph_read_budget() -> DbResult<RepositoryGraphReadBudget> {
5269    Ok(RepositoryGraphReadBudget::new(
5270        RepositoryGraphReadBudget::MAX_REQUESTED_ROWS,
5271        RepositoryGraphReadBudget::MAX_RETURNED_ROWS,
5272        RepositoryGraphReadBudget::MAX_DECODED_BYTES,
5273        RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES,
5274        RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
5275    )?)
5276}
5277
5278/// Validate one bounded unique exact-path coverage request.
5279fn validate_path_coverage_request(paths: &[RepositoryNodePath]) -> DbResult<()> {
5280    if paths.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
5281        return Err(GraphContractError::InvalidLimits {
5282            reason: "graph coverage path set exceeds the product ceiling",
5283        }
5284        .into());
5285    }
5286    if paths
5287        .iter()
5288        .map(RepositoryNodePath::as_str)
5289        .collect::<BTreeSet<_>>()
5290        .len()
5291        != paths.len()
5292    {
5293        return Err(GraphContractError::InvalidLimits {
5294            reason: "graph coverage paths must be unique",
5295        }
5296        .into());
5297    }
5298    Ok(())
5299}
5300
5301/// Validate the bounded unique stable-key set shared by cursor hydration calls.
5302fn validate_graph_hydration_request(digests: &[[u8; 32]]) -> DbResult<()> {
5303    if digests.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
5304        return Err(GraphContractError::InvalidLimits {
5305            reason: "graph hydration key set exceeds the product ceiling",
5306        }
5307        .into());
5308    }
5309    if digests.iter().copied().collect::<HashSet<_>>().len() != digests.len() {
5310        return Err(GraphContractError::InvalidLimits {
5311            reason: "graph hydration keys must be unique",
5312        }
5313        .into());
5314    }
5315    Ok(())
5316}
5317
5318/// Return the exact repository path that can own authored purpose for an entity.
5319fn graph_entity_purpose_owner(entity: &GraphEntity) -> Option<&str> {
5320    match entity.selector() {
5321        EntitySelector::Project => Some("."),
5322        EntitySelector::Folder { path } => Some(path.as_str()),
5323        EntitySelector::File { path } => Some(path.as_str()),
5324        EntitySelector::Package { package } => Some(package.manifest.as_str()),
5325        EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
5326        EntitySelector::External { .. } => None,
5327    }
5328}
5329
5330/// Reconstruct one relation and retain its already-hydrated endpoint entities.
5331fn relation_detail_from_row(
5332    entities: &HashMap<[u8; 32], GraphEntity>,
5333    row: RelationRow,
5334    expected_project: ProjectInstanceId,
5335    generation: IndexGeneration,
5336) -> DbResult<RepositoryGraphRelationRow> {
5337    let source_key = fixed_bytes::<32>("graph_relations.source_entity_key", row.source.clone())?;
5338    let target_key = row
5339        .target
5340        .as_ref()
5341        .map(|target| fixed_bytes::<32>("graph_relations.target_entity_key", target.clone()))
5342        .transpose()?;
5343    let relation = relation_from_row(entities, row, expected_project, generation)?;
5344    let source = entities
5345        .get(&source_key)
5346        .cloned()
5347        .ok_or(DbError::GraphRowShape {
5348            table: "graph_relations",
5349            reason: "source entity is missing",
5350        })?;
5351    let target = target_key
5352        .map(|key| {
5353            entities.get(&key).cloned().ok_or(DbError::GraphRowShape {
5354                table: "graph_relations",
5355                reason: "retained target entity is missing",
5356            })
5357        })
5358        .transpose()?;
5359    Ok(RepositoryGraphRelationRow {
5360        relation,
5361        source,
5362        target,
5363    })
5364}
5365
5366/// Reconstruct one typed logical relation through existing domain constructors.
5367fn relation_from_row(
5368    entities: &HashMap<[u8; 32], GraphEntity>,
5369    row: RelationRow,
5370    expected_project: ProjectInstanceId,
5371    generation: IndexGeneration,
5372) -> DbResult<LogicalRelation> {
5373    let project = project_from_blob("graph_relations.project_instance_id", row.project.clone())?;
5374    require_project(expected_project, project)?;
5375    let source_key = fixed_bytes::<32>("graph_relations.source_entity_key", row.source.clone())?;
5376    let source = entities
5377        .get(&source_key)
5378        .cloned()
5379        .ok_or(DbError::GraphRowShape {
5380            table: "graph_relations",
5381            reason: "source entity is missing",
5382        })?;
5383    let kind = parse_relation_kind(&row.relation_scope, &row.relation_kind)?;
5384    let resolution = match row.resolution_status.as_str() {
5385        "resolved" => {
5386            require_relation_resolution_shape(&row, true, false, false)?;
5387            let target_key = fixed_bytes::<32>(
5388                "graph_relations.target_entity_key",
5389                row.target.clone().ok_or(DbError::GraphRowShape {
5390                    table: "graph_relations",
5391                    reason: "resolved target is missing",
5392                })?,
5393            )?;
5394            let target = entities
5395                .get(&target_key)
5396                .cloned()
5397                .ok_or(DbError::GraphRowShape {
5398                    table: "graph_relations",
5399                    reason: "resolved target entity is missing",
5400                })?;
5401            RelationResolution::resolved(&target)?
5402        }
5403        "external" => {
5404            require_relation_resolution_shape(&row, true, false, false)?;
5405            let target_key = fixed_bytes::<32>(
5406                "graph_relations.target_entity_key",
5407                row.target.clone().ok_or(DbError::GraphRowShape {
5408                    table: "graph_relations",
5409                    reason: "external target is missing",
5410                })?,
5411            )?;
5412            let target = entities
5413                .get(&target_key)
5414                .cloned()
5415                .ok_or(DbError::GraphRowShape {
5416                    table: "graph_relations",
5417                    reason: "external target entity is missing",
5418                })?;
5419            RelationResolution::external(&target)?
5420        }
5421        "ambiguous" => {
5422            require_relation_resolution_shape(&row, false, true, true)?;
5423            let candidates = positive_u32(
5424                "graph_relations.candidate_count",
5425                row.candidate_count.ok_or(DbError::GraphRowShape {
5426                    table: "graph_relations",
5427                    reason: "ambiguous candidate count is missing",
5428                })?,
5429            )?;
5430            RelationResolution::Ambiguous {
5431                reference: GraphIdentityText::new(row.reference.clone().ok_or(
5432                    DbError::GraphRowShape {
5433                        table: "graph_relations",
5434                        reason: "ambiguous reference is missing",
5435                    },
5436                )?)?,
5437                candidates,
5438            }
5439        }
5440        "unresolved" => {
5441            require_relation_resolution_shape(&row, false, true, false)?;
5442            RelationResolution::Unresolved {
5443                reference: GraphIdentityText::new(row.reference.clone().ok_or(
5444                    DbError::GraphRowShape {
5445                        table: "graph_relations",
5446                        reason: "unresolved reference is missing",
5447                    },
5448                )?)?,
5449            }
5450        }
5451        value => {
5452            return Err(DbError::InvalidEnum {
5453                field: "graph_relations.resolution_status",
5454                value: value.to_string(),
5455            });
5456        }
5457    };
5458    let relation = LogicalRelation::new(
5459        &source,
5460        kind,
5461        resolution,
5462        parse_confidence(&row.confidence)?,
5463        parse_completeness(&row.completeness)?,
5464        generation,
5465    )?;
5466    validate_relation_key(&relation, row.key, &row.canonical)?;
5467    Ok(relation)
5468}
5469
5470/// Reject contradictory normalized resolution columns.
5471fn require_relation_resolution_shape(
5472    row: &RelationRow,
5473    target_required: bool,
5474    reference_required: bool,
5475    candidates_required: bool,
5476) -> DbResult<()> {
5477    let valid = row.target.is_some() == target_required
5478        && row.reference.is_some() == reference_required
5479        && row.candidate_count.is_some() == candidates_required;
5480    if !valid {
5481        return Err(DbError::GraphRowShape {
5482            table: "graph_relations",
5483            reason: "resolution columns contradict status",
5484        });
5485    }
5486    Ok(())
5487}
5488
5489/// Reconstruct one exact relation occurrence.
5490fn occurrence_from_row(
5491    row: OccurrenceRow,
5492    relation: &LogicalRelation,
5493    generation: IndexGeneration,
5494) -> DbResult<RelationOccurrence> {
5495    let stored_key = fixed_bytes::<32>("graph_relation_occurrences.relation_key", row.relation)?;
5496    if stored_key != relation.key().digest_bytes()? {
5497        return Err(DbError::GraphRowShape {
5498            table: "graph_relation_occurrences",
5499            reason: "occurrence relation key does not match query",
5500        });
5501    }
5502    RelationOccurrence::new(
5503        relation,
5504        RepositoryFilePath::new(Path::new(&row.file_path))?,
5505        SourceSpan::new(
5506            positive_u32_value("graph_relation_occurrences.start_line", row.start_line)?,
5507            nonnegative_u32("graph_relation_occurrences.start_column", row.start_column)?,
5508            positive_u32_value("graph_relation_occurrences.end_line", row.end_line)?,
5509            nonnegative_u32("graph_relation_occurrences.end_column", row.end_column)?,
5510        )?,
5511        generation,
5512    )
5513    .map_err(Into::into)
5514}
5515
5516/// Reconstruct one graph coverage record and verify project ownership.
5517fn coverage_from_row(
5518    row: CoverageRow,
5519    expected_project: ProjectInstanceId,
5520    generation: IndexGeneration,
5521) -> DbResult<CoverageRecord> {
5522    let project = project_from_blob("graph_coverage.project_instance_id", row.project)?;
5523    require_project(expected_project, project)?;
5524    let scope = match (row.scope_kind.as_str(), row.scope_path) {
5525        ("project", None) => CoverageScope::Project,
5526        ("path", Some(path)) => CoverageScope::Path {
5527            path: RepositoryNodePath::new(Path::new(&path))?,
5528        },
5529        ("project" | "path", _) => {
5530            return Err(DbError::GraphRowShape {
5531                table: "graph_coverage",
5532                reason: "scope columns contradict scope kind",
5533            });
5534        }
5535        (value, _) => {
5536            return Err(DbError::InvalidEnum {
5537                field: "graph_coverage.scope_kind",
5538                value: value.to_string(),
5539            });
5540        }
5541    };
5542    let relation = match (row.relation_scope, row.relation_kind) {
5543        (None, None) => None,
5544        (Some(scope), Some(kind)) => Some(parse_relation_kind(&scope, &kind)?),
5545        _ => {
5546            return Err(DbError::GraphRowShape {
5547                table: "graph_coverage",
5548                reason: "relation scope and kind must both be present or absent",
5549            });
5550        }
5551    };
5552    let persisted_total = nonnegative_u64("graph_coverage.total", row.total)?;
5553    let record = CoverageRecord::new(
5554        scope,
5555        relation,
5556        parse_coverage_state(&row.state)?,
5557        nonnegative_u64("graph_coverage.covered", row.covered)?,
5558        nonnegative_u64("graph_coverage.omitted", row.omitted)?,
5559        generation,
5560        row.reason.map(GraphIdentityText::new).transpose()?,
5561        row.reached_limit
5562            .as_deref()
5563            .map(parse_limit_kind)
5564            .transpose()?,
5565    )?;
5566    if record.total() != persisted_total {
5567        return Err(DbError::GraphRowShape {
5568            table: "graph_coverage",
5569            reason: "total does not equal covered plus omitted",
5570        });
5571    }
5572    Ok(record)
5573}
5574
5575/// Reconstruct discovered coverage together with strict parser provenance.
5576fn coverage_discovery_from_row(
5577    row: CoverageRow,
5578    expected_project: ProjectInstanceId,
5579    generation: IndexGeneration,
5580) -> DbResult<RepositoryCoverageRow> {
5581    let parser = row
5582        .parser
5583        .as_deref()
5584        .map(|value| parse_parser_kind("source_parse_metadata.source_parser", value))
5585        .transpose()?;
5586    let provider = row
5587        .provider
5588        .as_deref()
5589        .map(|value| parse_parser_kind("source_parse_metadata.fact_parser", value))
5590        .transpose()?;
5591    let coverage = coverage_from_row(row, expected_project, generation)?;
5592    Ok(RepositoryCoverageRow {
5593        coverage,
5594        parser,
5595        provider,
5596    })
5597}
5598
5599/// Fail with both project identities when normalized ownership differs.
5600fn require_project(expected: ProjectInstanceId, found: ProjectInstanceId) -> DbResult<()> {
5601    if expected != found {
5602        return Err(DbError::GraphProjectIdentityMismatch {
5603            expected: expected.to_string(),
5604            found: found.to_string(),
5605        });
5606    }
5607    Ok(())
5608}
5609
5610/// Validate one stored entity key and canonical collision witness.
5611fn validate_entity_key(
5612    entity: &GraphEntity,
5613    stored_key: Vec<u8>,
5614    stored_canonical: &str,
5615) -> DbResult<()> {
5616    let stored_key = fixed_bytes::<32>("graph_entities.entity_key", stored_key)?;
5617    if stored_key != entity.key().digest_bytes()? {
5618        return Err(projectatlas_core::graph::GraphContractError::InvalidStableKeyDigest.into());
5619    }
5620    if stored_canonical != entity.key().canonical_identity() {
5621        return Err(
5622            projectatlas_core::graph::GraphContractError::StableKeyCollision {
5623                digest: entity.key().digest().to_string(),
5624            }
5625            .into(),
5626        );
5627    }
5628    Ok(())
5629}
5630
5631/// Validate one stored relation key and canonical collision witness.
5632fn validate_relation_key(
5633    relation: &LogicalRelation,
5634    stored_key: Vec<u8>,
5635    stored_canonical: &str,
5636) -> DbResult<()> {
5637    let stored_key = fixed_bytes::<32>("graph_relations.relation_key", stored_key)?;
5638    if stored_key != relation.key().digest_bytes()? {
5639        return Err(projectatlas_core::graph::GraphContractError::InvalidStableKeyDigest.into());
5640    }
5641    if stored_canonical != relation.key().canonical_identity() {
5642        return Err(
5643            projectatlas_core::graph::GraphContractError::StableKeyCollision {
5644                digest: relation.key().digest().to_string(),
5645            }
5646            .into(),
5647        );
5648    }
5649    Ok(())
5650}
5651
5652/// Collect every raw entity row, including the truncation sentinel row.
5653fn collect_entity_rows(mut rows: rusqlite::Rows<'_>) -> DbResult<Vec<EntityRow>> {
5654    let mut collected = Vec::new();
5655    while let Some(row) = rows.next()? {
5656        collected.push(entity_row(row)?);
5657    }
5658    Ok(collected)
5659}
5660
5661/// Collect raw entity rows while enforcing decoded payload bytes.
5662fn collect_entity_rows_metered(
5663    mut rows: rusqlite::Rows<'_>,
5664    meter: &mut RepositoryGraphReadMeter,
5665) -> DbResult<Vec<EntityRow>> {
5666    let mut collected = Vec::new();
5667    while let Some(row) = rows.next()? {
5668        let raw = entity_row(row)?;
5669        meter.record_decoded_bytes(entity_row_decoded_bytes(&raw)?)?;
5670        collected.push(raw);
5671    }
5672    Ok(collected)
5673}
5674
5675/// Read one raw entity row without interpreting enum or selector values.
5676fn entity_row(row: &Row<'_>) -> rusqlite::Result<EntityRow> {
5677    Ok(EntityRow {
5678        key: row.get(0)?,
5679        project: row.get(1)?,
5680        canonical: row.get(2)?,
5681        kind: row.get(3)?,
5682        repository_path: row.get(4)?,
5683        package_manager: row.get(5)?,
5684        package_name: row.get(6)?,
5685        manifest_path: row.get(7)?,
5686        symbol_name: row.get(8)?,
5687        symbol_kind: row.get(9)?,
5688        symbol_parent: row.get(10)?,
5689        symbol_signature: row.get(11)?,
5690        external_system: row.get(12)?,
5691        external_identity: row.get(13)?,
5692    })
5693}
5694
5695/// Count exact dynamic payload bytes decoded for one normalized entity row.
5696fn entity_row_decoded_bytes(row: &EntityRow) -> DbResult<u64> {
5697    decoded_payload_bytes(
5698        [
5699            row.key.len(),
5700            row.project.len(),
5701            row.canonical.len(),
5702            row.kind.len(),
5703            row.repository_path.as_ref().map_or(0, String::len),
5704            row.package_manager.as_ref().map_or(0, String::len),
5705            row.package_name.as_ref().map_or(0, String::len),
5706            row.manifest_path.as_ref().map_or(0, String::len),
5707            row.symbol_name.as_ref().map_or(0, String::len),
5708            row.symbol_kind.as_ref().map_or(0, String::len),
5709            row.symbol_parent.as_ref().map_or(0, String::len),
5710            row.symbol_signature.as_ref().map_or(0, String::len),
5711            row.external_system.as_ref().map_or(0, String::len),
5712            row.external_identity.as_ref().map_or(0, String::len),
5713        ],
5714        0,
5715    )
5716}
5717
5718/// Collect every raw relation row, including the truncation sentinel row.
5719fn collect_relation_rows(mut rows: rusqlite::Rows<'_>) -> DbResult<Vec<RelationRow>> {
5720    let mut collected = Vec::new();
5721    while let Some(row) = rows.next()? {
5722        collected.push(relation_row(row)?);
5723    }
5724    Ok(collected)
5725}
5726
5727/// Collect raw relation rows while enforcing decoded payload bytes.
5728fn collect_relation_rows_metered(
5729    mut rows: rusqlite::Rows<'_>,
5730    meter: &mut RepositoryGraphReadMeter,
5731) -> DbResult<Vec<RelationRow>> {
5732    let mut collected = Vec::new();
5733    while let Some(row) = rows.next()? {
5734        let raw = relation_row(row)?;
5735        meter.record_decoded_bytes(relation_row_decoded_bytes(&raw)?)?;
5736        collected.push(raw);
5737    }
5738    Ok(collected)
5739}
5740
5741/// Collect raw adjacency rows and meter the truncation sentinel before return.
5742fn collect_adjacency_relation_rows_metered(
5743    mut rows: rusqlite::Rows<'_>,
5744    meter: &mut RepositoryGraphReadMeter,
5745) -> DbResult<Vec<AdjacencyRelationRow>> {
5746    let mut collected = Vec::new();
5747    while let Some(row) = rows.next()? {
5748        let raw = AdjacencyRelationRow {
5749            frontier_index: row.get(0)?,
5750            relation: relation_row_at(row, 1)?,
5751        };
5752        meter.record_decoded_bytes(
5753            relation_row_decoded_bytes(&raw.relation)?
5754                .checked_add(8)
5755                .ok_or(GraphContractError::InvalidLimits {
5756                    reason: "graph adjacency decoded row size overflowed",
5757                })?,
5758        )?;
5759        collected.push(raw);
5760    }
5761    Ok(collected)
5762}
5763
5764/// Read one raw relation row without interpreting enum or resolution values.
5765fn relation_row(row: &Row<'_>) -> rusqlite::Result<RelationRow> {
5766    relation_row_at(row, 0)
5767}
5768
5769/// Read one raw relation row beginning at the selected column offset.
5770fn relation_row_at(row: &Row<'_>, offset: usize) -> rusqlite::Result<RelationRow> {
5771    Ok(RelationRow {
5772        key: row.get(offset)?,
5773        project: row.get(offset + 1)?,
5774        canonical: row.get(offset + 2)?,
5775        source: row.get(offset + 3)?,
5776        relation_scope: row.get(offset + 4)?,
5777        relation_kind: row.get(offset + 5)?,
5778        resolution_status: row.get(offset + 6)?,
5779        target: row.get(offset + 7)?,
5780        reference: row.get(offset + 8)?,
5781        candidate_count: row.get(offset + 9)?,
5782        confidence: row.get(offset + 10)?,
5783        completeness: row.get(offset + 11)?,
5784    })
5785}
5786
5787/// Count exact dynamic and fixed payload bytes decoded for one relation row.
5788fn relation_row_decoded_bytes(row: &RelationRow) -> DbResult<u64> {
5789    decoded_payload_bytes(
5790        [
5791            row.key.len(),
5792            row.project.len(),
5793            row.canonical.len(),
5794            row.source.len(),
5795            row.relation_scope.len(),
5796            row.relation_kind.len(),
5797            row.resolution_status.len(),
5798            row.target.as_ref().map_or(0, Vec::len),
5799            row.reference.as_ref().map_or(0, String::len),
5800            row.confidence.len(),
5801            row.completeness.len(),
5802        ],
5803        8,
5804    )
5805}
5806
5807/// Sum decoded variable-width values plus fixed scalar widths without overflow.
5808fn decoded_payload_bytes(
5809    lengths: impl IntoIterator<Item = usize>,
5810    fixed_bytes: u64,
5811) -> DbResult<u64> {
5812    let mut decoded = fixed_bytes;
5813    for length in lengths {
5814        let length =
5815            u64::try_from(length).map_err(|_source| GraphContractError::InvalidLimits {
5816                reason: "graph read decoded field length overflowed",
5817            })?;
5818        decoded = decoded
5819            .checked_add(length)
5820            .ok_or(GraphContractError::InvalidLimits {
5821                reason: "graph read decoded row size overflowed",
5822            })?;
5823    }
5824    Ok(decoded)
5825}
5826
5827/// Read one raw relation occurrence row.
5828fn occurrence_row(row: &Row<'_>) -> rusqlite::Result<OccurrenceRow> {
5829    occurrence_row_at(row, 0)
5830}
5831
5832/// Read one raw relation occurrence row at a stable column offset.
5833fn occurrence_row_at(row: &Row<'_>, offset: usize) -> rusqlite::Result<OccurrenceRow> {
5834    Ok(OccurrenceRow {
5835        relation: row.get(offset)?,
5836        file_path: row.get(offset + 1)?,
5837        start_line: row.get(offset + 2)?,
5838        start_column: row.get(offset + 3)?,
5839        end_line: row.get(offset + 4)?,
5840        end_column: row.get(offset + 5)?,
5841    })
5842}
5843
5844/// Count exact occurrence payload bytes, including four fixed span scalars.
5845fn occurrence_row_decoded_bytes(row: &OccurrenceRow) -> DbResult<u64> {
5846    decoded_payload_bytes([row.relation.len(), row.file_path.len()], 32)
5847}
5848
5849/// Read one raw graph coverage row.
5850fn coverage_row(row: &Row<'_>) -> rusqlite::Result<CoverageRow> {
5851    Ok(CoverageRow {
5852        project: row.get(0)?,
5853        scope_kind: row.get(1)?,
5854        scope_path: row.get(2)?,
5855        relation_scope: row.get(3)?,
5856        relation_kind: row.get(4)?,
5857        state: row.get(5)?,
5858        total: row.get(6)?,
5859        covered: row.get(7)?,
5860        omitted: row.get(8)?,
5861        reason: row.get(9)?,
5862        reached_limit: row.get(10)?,
5863        parser: row.get(11)?,
5864        provider: row.get(12)?,
5865    })
5866}
5867
5868/// Count exact coverage payload bytes, including three fixed count scalars.
5869fn coverage_row_decoded_bytes(row: &CoverageRow) -> DbResult<u64> {
5870    decoded_payload_bytes(
5871        [
5872            row.project.len(),
5873            row.scope_kind.len(),
5874            row.scope_path.as_ref().map_or(0, String::len),
5875            row.relation_scope.as_ref().map_or(0, String::len),
5876            row.relation_kind.as_ref().map_or(0, String::len),
5877            row.state.len(),
5878            row.reason.as_ref().map_or(0, String::len),
5879            row.reached_limit.as_ref().map_or(0, String::len),
5880            row.parser.as_ref().map_or(0, String::len),
5881            row.provider.as_ref().map_or(0, String::len),
5882        ],
5883        24,
5884    )
5885}
5886
5887/// Convert a fully collected raw page and validate the sentinel before truncating.
5888fn page_from_raw<Raw, Domain>(
5889    raw: Vec<Raw>,
5890    limit: u32,
5891    mut convert: impl FnMut(Raw) -> DbResult<Domain>,
5892) -> DbResult<RepositoryGraphPage<Domain>> {
5893    let mut rows = raw
5894        .into_iter()
5895        .map(&mut convert)
5896        .collect::<DbResult<Vec<_>>>()?;
5897    let truncated = rows.len() > limit as usize;
5898    if truncated {
5899        rows.pop();
5900    }
5901    Ok(RepositoryGraphPage { rows, truncated })
5902}
5903
5904/// Return an empty graph page when no project graph has been initialized.
5905fn empty_page<T>() -> RepositoryGraphPage<T> {
5906    RepositoryGraphPage {
5907        rows: Vec::new(),
5908        truncated: false,
5909    }
5910}
5911
5912/// Return an empty adjacency page when no selected graph/frontier is available.
5913fn empty_adjacency_page() -> RepositoryGraphAdjacencyPage {
5914    RepositoryGraphAdjacencyPage {
5915        rows: Vec::new(),
5916        truncated: false,
5917        continuation: None,
5918    }
5919}
5920
5921/// Return an empty footprint when no complete selected graph is available.
5922const fn empty_affected_source_footprint() -> RepositoryAffectedSourceFootprint {
5923    RepositoryAffectedSourceFootprint {
5924        rows: 0,
5925        retained_bytes: 0,
5926        truncated: false,
5927    }
5928}
5929
5930/// Validate and convert a requested page size into `LIMIT + 1`.
5931fn validated_limit_plus_one(limit: u32, ceiling: u32, reason: &'static str) -> DbResult<i64> {
5932    if limit == 0 || limit > ceiling {
5933        return Err(projectatlas_core::graph::GraphContractError::InvalidLimits { reason }.into());
5934    }
5935    Ok(i64::from(limit) + 1)
5936}
5937
5938/// Convert a fixed-width normalized BLOB without truncation.
5939fn fixed_bytes<const WIDTH: usize>(field: &'static str, bytes: Vec<u8>) -> DbResult<[u8; WIDTH]> {
5940    let found = bytes.len();
5941    bytes
5942        .try_into()
5943        .map_err(|_bytes| DbError::InvalidBlobLength {
5944            field,
5945            expected: WIDTH,
5946            found,
5947        })
5948}
5949
5950/// Reconstruct a project identity from its normalized binary column.
5951fn project_from_blob(field: &'static str, bytes: Vec<u8>) -> DbResult<ProjectInstanceId> {
5952    ProjectInstanceId::from_bytes(fixed_bytes::<16>(field, bytes)?).map_err(Into::into)
5953}
5954
5955/// Return a required text column or a stable row-shape failure.
5956fn required_text<'value>(
5957    table: &'static str,
5958    reason: &'static str,
5959    value: Option<&'value str>,
5960) -> DbResult<&'value str> {
5961    value.ok_or(DbError::GraphRowShape { table, reason })
5962}
5963
5964/// Convert a nonnegative `SQLite` count to `u64`.
5965fn nonnegative_u64(field: &'static str, value: i64) -> DbResult<u64> {
5966    u64::try_from(value).map_err(|source| DbError::InvalidCount {
5967        field,
5968        value,
5969        source,
5970    })
5971}
5972
5973/// Convert a positive `SQLite` count to `NonZeroU32`.
5974fn positive_u32(field: &'static str, value: i64) -> DbResult<NonZeroU32> {
5975    let value = positive_u32_value(field, value)?;
5976    NonZeroU32::new(value).ok_or(DbError::GraphRowShape {
5977        table: "graph_relations",
5978        reason: "candidate count must be positive",
5979    })
5980}
5981
5982/// Convert a positive `SQLite` integer to `u32`.
5983fn positive_u32_value(field: &'static str, value: i64) -> DbResult<u32> {
5984    let converted = u32::try_from(value).map_err(|source| DbError::InvalidCount {
5985        field,
5986        value,
5987        source,
5988    })?;
5989    if converted == 0 {
5990        return Err(DbError::GraphRowShape {
5991            table: "repository_graph",
5992            reason: "positive integer column contains zero",
5993        });
5994    }
5995    Ok(converted)
5996}
5997
5998/// Convert a nonnegative `SQLite` integer to `u32`.
5999fn nonnegative_u32(field: &'static str, value: i64) -> DbResult<u32> {
6000    u32::try_from(value).map_err(|source| DbError::InvalidCount {
6001        field,
6002        value,
6003        source,
6004    })
6005}
6006
6007/// Convert a domain count to one lossless `SQLite` integer.
6008fn sqlite_count(field: &'static str, value: u64) -> DbResult<i64> {
6009    i64::try_from(value).map_err(|_source| DbError::GraphCountOverflow { field, value })
6010}
6011
6012/// Split one typed relation family into its normalized scope and spelling.
6013const fn relation_parts(relation: GraphRelationKind) -> (&'static str, &'static str) {
6014    match relation {
6015        GraphRelationKind::Legacy(RelationKind::Contains) => ("legacy", "contains"),
6016        GraphRelationKind::Legacy(RelationKind::Imports) => ("legacy", "imports"),
6017        GraphRelationKind::Legacy(RelationKind::Calls) => ("legacy", "calls"),
6018        GraphRelationKind::Legacy(RelationKind::DependsOn) => ("legacy", "depends-on"),
6019        GraphRelationKind::Extended(ExtendedRelationKind::References) => ("extended", "references"),
6020        GraphRelationKind::Extended(ExtendedRelationKind::Tests) => ("extended", "tests"),
6021        GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo) => ("extended", "routes-to"),
6022        GraphRelationKind::Extended(ExtendedRelationKind::Configures) => ("extended", "configures"),
6023        GraphRelationKind::Extended(ExtendedRelationKind::Deploys) => ("extended", "deploys"),
6024        GraphRelationKind::Extended(ExtendedRelationKind::Reads) => ("extended", "reads"),
6025        GraphRelationKind::Extended(ExtendedRelationKind::Writes) => ("extended", "writes"),
6026    }
6027}
6028
6029/// Parse one normalized relation family without accepting unknown values.
6030fn parse_relation_kind(scope: &str, kind: &str) -> DbResult<GraphRelationKind> {
6031    match (scope, kind) {
6032        ("legacy", "contains") => Ok(GraphRelationKind::Legacy(RelationKind::Contains)),
6033        ("legacy", "imports") => Ok(GraphRelationKind::Legacy(RelationKind::Imports)),
6034        ("legacy", "calls") => Ok(GraphRelationKind::Legacy(RelationKind::Calls)),
6035        ("legacy", "depends-on") => Ok(GraphRelationKind::Legacy(RelationKind::DependsOn)),
6036        ("extended", "references") => Ok(GraphRelationKind::Extended(
6037            ExtendedRelationKind::References,
6038        )),
6039        ("extended", "tests") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Tests)),
6040        ("extended", "routes-to") => {
6041            Ok(GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo))
6042        }
6043        ("extended", "configures") => Ok(GraphRelationKind::Extended(
6044            ExtendedRelationKind::Configures,
6045        )),
6046        ("extended", "deploys") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Deploys)),
6047        ("extended", "reads") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Reads)),
6048        ("extended", "writes") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Writes)),
6049        _ => Err(DbError::InvalidEnum {
6050            field: "graph_relations.relation_kind",
6051            value: format!("{scope}:{kind}"),
6052        }),
6053    }
6054}
6055
6056/// Return the normalized symbol-kind spelling.
6057const fn symbol_kind_name(kind: SymbolKind) -> &'static str {
6058    match kind {
6059        SymbolKind::Function => "function",
6060        SymbolKind::Method => "method",
6061        SymbolKind::Class => "class",
6062        SymbolKind::Struct => "struct",
6063        SymbolKind::Enum => "enum",
6064        SymbolKind::Trait => "trait",
6065        SymbolKind::Interface => "interface",
6066        SymbolKind::Module => "module",
6067        SymbolKind::Type => "type",
6068        SymbolKind::Value => "value",
6069        SymbolKind::Import => "import",
6070        SymbolKind::Package => "package",
6071        SymbolKind::Workspace => "workspace",
6072        SymbolKind::Dependency => "dependency",
6073        SymbolKind::Unknown => "unknown",
6074    }
6075}
6076
6077/// Parse one normalized symbol-kind spelling.
6078fn parse_symbol_kind(value: &str) -> DbResult<SymbolKind> {
6079    match value {
6080        "function" => Ok(SymbolKind::Function),
6081        "method" => Ok(SymbolKind::Method),
6082        "class" => Ok(SymbolKind::Class),
6083        "struct" => Ok(SymbolKind::Struct),
6084        "enum" => Ok(SymbolKind::Enum),
6085        "trait" => Ok(SymbolKind::Trait),
6086        "interface" => Ok(SymbolKind::Interface),
6087        "module" => Ok(SymbolKind::Module),
6088        "type" => Ok(SymbolKind::Type),
6089        "value" => Ok(SymbolKind::Value),
6090        "import" => Ok(SymbolKind::Import),
6091        "package" => Ok(SymbolKind::Package),
6092        "workspace" => Ok(SymbolKind::Workspace),
6093        "dependency" => Ok(SymbolKind::Dependency),
6094        "unknown" => Ok(SymbolKind::Unknown),
6095        _ => Err(DbError::InvalidEnum {
6096            field: "graph_entities.symbol_kind",
6097            value: value.to_string(),
6098        }),
6099    }
6100}
6101
6102/// Return the normalized confidence spelling.
6103const fn confidence_name(confidence: ConfidenceClass) -> &'static str {
6104    match confidence {
6105        ConfidenceClass::Exact => "exact",
6106        ConfidenceClass::High => "high",
6107        ConfidenceClass::Medium => "medium",
6108        ConfidenceClass::Low => "low",
6109    }
6110}
6111
6112/// Parse one normalized confidence spelling.
6113fn parse_confidence(value: &str) -> DbResult<ConfidenceClass> {
6114    match value {
6115        "exact" => Ok(ConfidenceClass::Exact),
6116        "high" => Ok(ConfidenceClass::High),
6117        "medium" => Ok(ConfidenceClass::Medium),
6118        "low" => Ok(ConfidenceClass::Low),
6119        _ => Err(DbError::InvalidEnum {
6120            field: "graph_relations.confidence",
6121            value: value.to_string(),
6122        }),
6123    }
6124}
6125
6126/// Return the normalized completeness spelling.
6127const fn completeness_name(completeness: Completeness) -> &'static str {
6128    match completeness {
6129        Completeness::Complete => "complete",
6130        Completeness::Partial => "partial",
6131    }
6132}
6133
6134/// Parse one normalized completeness spelling.
6135fn parse_completeness(value: &str) -> DbResult<Completeness> {
6136    match value {
6137        "complete" => Ok(Completeness::Complete),
6138        "partial" => Ok(Completeness::Partial),
6139        _ => Err(DbError::InvalidEnum {
6140            field: "graph_relations.completeness",
6141            value: value.to_string(),
6142        }),
6143    }
6144}
6145
6146/// Return normalized coverage scope columns.
6147fn coverage_scope_parts(scope: &CoverageScope) -> (&'static str, Option<&str>) {
6148    match scope {
6149        CoverageScope::Project => ("project", None),
6150        CoverageScope::Path { path } => ("path", Some(path.as_str())),
6151    }
6152}
6153
6154/// Return the normalized coverage lifecycle spelling.
6155const fn coverage_state_name(state: CoverageState) -> &'static str {
6156    match state {
6157        CoverageState::Complete => "complete",
6158        CoverageState::Partial => "partial",
6159        CoverageState::Failed => "failed",
6160        CoverageState::Ignored => "ignored",
6161        CoverageState::Oversized => "oversized",
6162        CoverageState::Quarantined => "quarantined",
6163        CoverageState::Stale => "stale",
6164    }
6165}
6166
6167/// Parse one normalized coverage lifecycle spelling.
6168pub(crate) fn parse_coverage_state(value: &str) -> DbResult<CoverageState> {
6169    match value {
6170        "complete" => Ok(CoverageState::Complete),
6171        "partial" => Ok(CoverageState::Partial),
6172        "failed" => Ok(CoverageState::Failed),
6173        "ignored" => Ok(CoverageState::Ignored),
6174        "oversized" => Ok(CoverageState::Oversized),
6175        "quarantined" => Ok(CoverageState::Quarantined),
6176        "stale" => Ok(CoverageState::Stale),
6177        _ => Err(DbError::InvalidEnum {
6178            field: "graph_coverage.state",
6179            value: value.to_string(),
6180        }),
6181    }
6182}
6183
6184/// Parse one normalized parser provenance spelling without fallback coercion.
6185fn parse_parser_kind(field: &'static str, value: &str) -> DbResult<ParserKind> {
6186    match value {
6187        "tree-sitter" => Ok(ParserKind::TreeSitter),
6188        "manifest" => Ok(ParserKind::Manifest),
6189        "structural" => Ok(ParserKind::Structural),
6190        "fallback" => Ok(ParserKind::Fallback),
6191        _ => Err(DbError::InvalidEnum {
6192            field,
6193            value: value.to_string(),
6194        }),
6195    }
6196}
6197
6198/// Return the normalized reached-limit spelling.
6199const fn limit_kind_name(limit: GraphLimitKind) -> &'static str {
6200    match limit {
6201        GraphLimitKind::Rows => "rows",
6202        GraphLimitKind::Nodes => "nodes",
6203        GraphLimitKind::Edges => "edges",
6204        GraphLimitKind::Occurrences => "occurrences",
6205        GraphLimitKind::Visited => "visited",
6206        GraphLimitKind::IntermediateBytes => "intermediate_bytes",
6207        GraphLimitKind::Deadline => "deadline",
6208        GraphLimitKind::Depth => "depth",
6209        GraphLimitKind::OutputBytes => "output_bytes",
6210    }
6211}
6212
6213/// Parse one normalized reached-limit spelling.
6214pub(crate) fn parse_limit_kind(value: &str) -> DbResult<GraphLimitKind> {
6215    match value {
6216        "rows" => Ok(GraphLimitKind::Rows),
6217        "nodes" => Ok(GraphLimitKind::Nodes),
6218        "edges" => Ok(GraphLimitKind::Edges),
6219        "occurrences" => Ok(GraphLimitKind::Occurrences),
6220        "visited" => Ok(GraphLimitKind::Visited),
6221        "intermediate_bytes" => Ok(GraphLimitKind::IntermediateBytes),
6222        "deadline" => Ok(GraphLimitKind::Deadline),
6223        "depth" => Ok(GraphLimitKind::Depth),
6224        "output_bytes" => Ok(GraphLimitKind::OutputBytes),
6225        _ => Err(DbError::InvalidEnum {
6226            field: "graph_coverage.reached_limit",
6227            value: value.to_string(),
6228        }),
6229    }
6230}
6231
6232#[cfg(test)]
6233mod tests {
6234    use super::*;
6235    use crate::IndexedFileText;
6236    use projectatlas_core::symbols::{CodeSymbol, ParserKind, SymbolGraph, SymbolRelation};
6237    use projectatlas_core::{Node, NodeKind};
6238    use std::cell::RefCell;
6239    use std::error::Error;
6240    use std::fmt::Debug;
6241    use std::fs;
6242    use std::io;
6243    use std::time::{Duration, Instant};
6244
6245    thread_local! {
6246        /// Statements executed by the connection currently under test.
6247        static TRACED_STATEMENTS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
6248    }
6249
6250    /// Record one statement executed synchronously by the traced test connection.
6251    fn record_traced_statement(sql: &str) {
6252        TRACED_STATEMENTS.with(|statements| statements.borrow_mut().push(sql.to_string()));
6253    }
6254
6255    /// Execute one database operation while retaining its connection-local SQL trace.
6256    fn trace_statements<T>(
6257        store: &mut AtlasStore,
6258        operation: impl FnOnce(&AtlasStore) -> DbResult<T>,
6259    ) -> Result<(T, Vec<String>), Box<dyn Error>> {
6260        TRACED_STATEMENTS.with(|statements| statements.borrow_mut().clear());
6261        store.connection.trace(Some(record_traced_statement));
6262        let result = operation(store);
6263        store.connection.trace(None);
6264        let statements =
6265            TRACED_STATEMENTS.with(|statements| std::mem::take(&mut *statements.borrow_mut()));
6266        Ok((result?, statements))
6267    }
6268
6269    /// Closed production statement families allowed during detailed relation reads.
6270    #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
6271    enum DetailedRelationTraceStatement {
6272        /// Bound project singleton verification.
6273        ProjectIdentity,
6274        /// Complete publication metadata lookup.
6275        PublicationMetadata,
6276        /// Active normalized-graph generation lookup.
6277        ActiveGraphGeneration,
6278        /// Direction-owned indexed adjacency query.
6279        AdjacencyRelations,
6280        /// Stable-key relation hydration.
6281        RelationsByDigest,
6282        /// Stable-key endpoint entity hydration.
6283        RelationEntities,
6284        /// Per-relation occurrence hydration.
6285        RelationOccurrences,
6286        /// Purpose-owning node hydration.
6287        PurposeOwners,
6288    }
6289
6290    /// Classify one exact production statement emitted by a detailed relation read.
6291    fn classify_detailed_relation_statement(
6292        statement: &str,
6293    ) -> Option<DetailedRelationTraceStatement> {
6294        if statement
6295            .contains("SELECT project_instance_id FROM project_identity WHERE singleton = 1")
6296        {
6297            Some(DetailedRelationTraceStatement::ProjectIdentity)
6298        } else if statement.contains("SELECT state.value, fingerprint.value, generation.value")
6299            && statement.contains("FROM metadata AS state")
6300        {
6301            Some(DetailedRelationTraceStatement::PublicationMetadata)
6302        } else if statement
6303            .contains("SELECT active_generation FROM project_identity WHERE singleton = 1")
6304        {
6305            Some(DetailedRelationTraceStatement::ActiveGraphGeneration)
6306        } else if statement.contains("FROM graph_relations AS relation INDEXED BY")
6307            && statement.contains("idx_graph_relations_source_kind")
6308        {
6309            Some(DetailedRelationTraceStatement::AdjacencyRelations)
6310        } else if statement
6311            .contains("JOIN graph_relations AS relation INDEXED BY idx_graph_relations_project_key")
6312        {
6313            Some(DetailedRelationTraceStatement::RelationsByDigest)
6314        } else if statement.contains("JOIN graph_entities AS entity") {
6315            Some(DetailedRelationTraceStatement::RelationEntities)
6316        } else if statement.contains("FROM graph_relation_occurrences") {
6317            Some(DetailedRelationTraceStatement::RelationOccurrences)
6318        } else if statement.contains("FROM nodes n")
6319            && statement.contains("JOIN purposes p")
6320            && statement.contains("LEFT JOIN summaries s")
6321        {
6322            Some(DetailedRelationTraceStatement::PurposeOwners)
6323        } else {
6324            None
6325        }
6326    }
6327
6328    /// Require the complete traced statement multiset for one production read.
6329    fn require_traced_statement_multiset(
6330        statements: &[String],
6331        expected: &[(DetailedRelationTraceStatement, usize)],
6332        context: &str,
6333    ) -> Result<(), Box<dyn Error>> {
6334        let mut actual = BTreeMap::new();
6335        for statement in statements {
6336            let family = classify_detailed_relation_statement(statement).ok_or_else(|| {
6337                io::Error::other(format!(
6338                    "{context} executed an unclassified production statement: {statement}"
6339                ))
6340            })?;
6341            *actual.entry(family).or_insert(0) += 1;
6342        }
6343        let expected = expected.iter().copied().collect::<BTreeMap<_, _>>();
6344        require_eq(
6345            &actual,
6346            &expected,
6347            &format!("{context} complete statement multiset: {statements:?}"),
6348        )
6349    }
6350
6351    /// Traverse one relation family with the same bounded adjacency primitive used by the service.
6352    fn collect_bounded_outbound_calls(
6353        store: &AtlasStore,
6354        anchor: &GraphEntityKey,
6355    ) -> Result<(Vec<[u8; 32]>, usize, usize), Box<dyn Error>> {
6356        let mut visited = BTreeMap::new();
6357        visited.insert(anchor.digest_bytes()?, anchor.clone());
6358        let mut frontier = vec![anchor.clone()];
6359        let mut inspected_edges = 0_usize;
6360        let mut peak_frontier = frontier.len();
6361        let calls = GraphRelationKind::Legacy(RelationKind::Calls);
6362
6363        while !frontier.is_empty() {
6364            let mut next = BTreeMap::new();
6365            for chunk in frontier.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
6366                let work_per_row = MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS / chunk.len();
6367                let page_limit = work_per_row
6368                    .saturating_sub(1)
6369                    .min(GraphLimits::MAX_ROWS as usize)
6370                    .max(1) as u32;
6371                let mut continuation = None;
6372                loop {
6373                    let page = store.repository_graph_adjacency_page_filtered(
6374                        chunk,
6375                        RepositoryGraphDirection::Outbound,
6376                        Some(calls),
6377                        continuation.as_ref(),
6378                        page_limit,
6379                        None,
6380                    )?;
6381                    inspected_edges = inspected_edges
6382                        .checked_add(page.rows.len())
6383                        .ok_or_else(|| io::Error::other("measured edge count overflowed"))?;
6384                    for row in page.rows {
6385                        if let Some(target) = row.detail.target {
6386                            let digest = target.key().digest_bytes()?;
6387                            if !visited.contains_key(&digest) {
6388                                next.entry(digest).or_insert_with(|| target.key().clone());
6389                            }
6390                        }
6391                    }
6392                    if !page.truncated {
6393                        break;
6394                    }
6395                    continuation = Some(page.continuation.ok_or_else(|| {
6396                        io::Error::other("truncated adjacency page omitted its continuation")
6397                    })?);
6398                }
6399            }
6400            frontier = next.into_values().collect();
6401            peak_frontier = peak_frontier.max(frontier.len());
6402            for key in &frontier {
6403                visited.insert(key.digest_bytes()?, key.clone());
6404            }
6405        }
6406
6407        Ok((
6408            visited.into_keys().collect(),
6409            inspected_edges,
6410            peak_frontier,
6411        ))
6412    }
6413
6414    /// Coherent typed graph fixture used by storage, corruption, and publication tests.
6415    struct GraphFixture {
6416        /// Owning project identity.
6417        project: ProjectInstanceId,
6418        /// Every entity selector variant.
6419        entities: Vec<GraphEntity>,
6420        /// Every relation resolution state.
6421        relations: Vec<LogicalRelation>,
6422        /// Two occurrences for one logical relation.
6423        occurrences: Vec<RelationOccurrence>,
6424        /// Every coverage lifecycle state.
6425        coverage: Vec<CoverageRecord>,
6426    }
6427
6428    /// Maximum production relation/occurrence fixture used by SQL trace tests.
6429    struct DetailedRelationTraceFixture {
6430        /// Owning project identity.
6431        project: ProjectInstanceId,
6432        /// Common source whose unique targets cross entity hydration chunks.
6433        source: GraphEntity,
6434        /// Maximum accepted relation batch.
6435        relations: Vec<LogicalRelation>,
6436    }
6437
6438    /// Canonical keys used by export, ambiguity, and unresolved dependency tests.
6439    struct ResolutionFixture {
6440        /// Published typed graph.
6441        graph: GraphFixture,
6442        /// Key exported by the fixture symbol and consumed by a resolved relation.
6443        resolved: CanonicalResolutionKey,
6444        /// Key consumed by an ambiguous relation.
6445        ambiguous: CanonicalResolutionKey,
6446        /// Key consumed by an unresolved relation.
6447        unresolved: CanonicalResolutionKey,
6448    }
6449
6450    /// Build one complete typed graph for the selected generation.
6451    fn graph_fixture(
6452        project: ProjectInstanceId,
6453        generation: IndexGeneration,
6454    ) -> Result<GraphFixture, Box<dyn Error>> {
6455        let project_entity = GraphEntity::new(project, EntitySelector::Project, generation)?;
6456        let folder = GraphEntity::new(
6457            project,
6458            EntitySelector::Folder {
6459                path: RepositoryNodePath::new(Path::new("src"))?,
6460            },
6461            generation,
6462        )?;
6463        let file = GraphEntity::new(
6464            project,
6465            EntitySelector::File {
6466                path: RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
6467            },
6468            generation,
6469        )?;
6470        let package = GraphEntity::new(
6471            project,
6472            EntitySelector::Package {
6473                package: PackageSelector {
6474                    manager: GraphIdentityText::new("cargo")?,
6475                    name: GraphIdentityText::new("ProjectAtlas")?,
6476                    manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
6477                },
6478            },
6479            generation,
6480        )?;
6481        let symbol = GraphEntity::new(
6482            project,
6483            EntitySelector::Symbol {
6484                symbol: SymbolSelector {
6485                    file: RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
6486                    name: GraphIdentityText::new("verifyToken")?,
6487                    kind: SymbolKind::Function,
6488                    parent: Some(GraphIdentityText::new("Auth")?),
6489                    signature: GraphIdentityText::new("verifyToken(&str)")?,
6490                },
6491            },
6492            generation,
6493        )?;
6494        let external = GraphEntity::new(
6495            project,
6496            EntitySelector::External {
6497                external: ExternalSelector {
6498                    system: GraphIdentityText::new("crates.io")?,
6499                    identity: GraphIdentityText::new("serde@1")?,
6500                },
6501            },
6502            generation,
6503        )?;
6504
6505        let resolved = LogicalRelation::new(
6506            &file,
6507            GraphRelationKind::Legacy(RelationKind::Calls),
6508            RelationResolution::resolved(&symbol)?,
6509            ConfidenceClass::Exact,
6510            Completeness::Complete,
6511            generation,
6512        )?;
6513        let ambiguous = LogicalRelation::new(
6514            &file,
6515            GraphRelationKind::Extended(ExtendedRelationKind::References),
6516            RelationResolution::Ambiguous {
6517                reference: GraphIdentityText::new("Session")?,
6518                candidates: NonZeroU32::new(2)
6519                    .ok_or_else(|| io::Error::other("fixture candidate count is zero"))?,
6520            },
6521            ConfidenceClass::Medium,
6522            Completeness::Partial,
6523            generation,
6524        )?;
6525        let unresolved = LogicalRelation::new(
6526            &file,
6527            GraphRelationKind::Extended(ExtendedRelationKind::Configures),
6528            RelationResolution::Unresolved {
6529                reference: GraphIdentityText::new("AUTH_KEY")?,
6530            },
6531            ConfidenceClass::Low,
6532            Completeness::Partial,
6533            generation,
6534        )?;
6535        let external_relation = LogicalRelation::new(
6536            &file,
6537            GraphRelationKind::Legacy(RelationKind::DependsOn),
6538            RelationResolution::external(&external)?,
6539            ConfidenceClass::High,
6540            Completeness::Complete,
6541            generation,
6542        )?;
6543        let occurrences = vec![
6544            RelationOccurrence::new(
6545                &resolved,
6546                RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
6547                SourceSpan::new(10, 4, 10, 18)?,
6548                generation,
6549            )?,
6550            RelationOccurrence::new(
6551                &resolved,
6552                RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
6553                SourceSpan::new(22, 2, 22, 16)?,
6554                generation,
6555            )?,
6556        ];
6557        let coverage = vec![
6558            CoverageRecord::new(
6559                CoverageScope::Project,
6560                None,
6561                CoverageState::Complete,
6562                4,
6563                0,
6564                generation,
6565                None,
6566                None,
6567            )?,
6568            CoverageRecord::new(
6569                CoverageScope::Path {
6570                    path: RepositoryNodePath::new(Path::new("src"))?,
6571                },
6572                None,
6573                CoverageState::Partial,
6574                3,
6575                1,
6576                generation,
6577                Some(GraphIdentityText::new("one parser region omitted")?),
6578                Some(GraphLimitKind::Rows),
6579            )?,
6580            incomplete_coverage(
6581                GraphRelationKind::Legacy(RelationKind::Calls),
6582                CoverageState::Failed,
6583                "parser failed",
6584                generation,
6585            )?,
6586            incomplete_coverage(
6587                GraphRelationKind::Legacy(RelationKind::Imports),
6588                CoverageState::Ignored,
6589                "ignored by policy",
6590                generation,
6591            )?,
6592            incomplete_coverage(
6593                GraphRelationKind::Legacy(RelationKind::Contains),
6594                CoverageState::Oversized,
6595                "file exceeded limit",
6596                generation,
6597            )?,
6598            incomplete_coverage(
6599                GraphRelationKind::Legacy(RelationKind::DependsOn),
6600                CoverageState::Quarantined,
6601                "provider quarantined",
6602                generation,
6603            )?,
6604            incomplete_coverage(
6605                GraphRelationKind::Extended(ExtendedRelationKind::References),
6606                CoverageState::Stale,
6607                "source changed",
6608                generation,
6609            )?,
6610        ];
6611        Ok(GraphFixture {
6612            project,
6613            entities: vec![project_entity, folder, file, package, symbol, external],
6614            relations: vec![resolved, ambiguous, unresolved, external_relation],
6615            occurrences,
6616            coverage,
6617        })
6618    }
6619
6620    /// Construct one project-qualified declaration resolution key.
6621    fn declaration_key(
6622        project: ProjectInstanceId,
6623        identity: &str,
6624        relation: GraphRelationKind,
6625    ) -> Result<CanonicalResolutionKey, Box<dyn Error>> {
6626        Ok(CanonicalResolutionKey::new(
6627            project,
6628            ResolutionKeyDomain::Declaration,
6629            &GraphIdentityText::new("tree-sitter")?,
6630            &GraphIdentityText::new("rust")?,
6631            None,
6632            Some(&GraphIdentityText::new("crate")?),
6633            Some(relation),
6634            &GraphIdentityText::new(identity)?,
6635        ))
6636    }
6637
6638    /// Publish one graph with duplicate-safe export and all dependency states.
6639    fn publish_resolution_fixture(
6640        store: &mut AtlasStore,
6641        fingerprint: &str,
6642    ) -> Result<ResolutionFixture, Box<dyn Error>> {
6643        let project = store
6644            .project_instance_id()?
6645            .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
6646        let graph = graph_fixture(project, IndexGeneration::new(1))?;
6647        let resolved = declaration_key(
6648            project,
6649            "verifyToken",
6650            GraphRelationKind::Legacy(RelationKind::Calls),
6651        )?;
6652        let ambiguous = declaration_key(
6653            project,
6654            "Session",
6655            GraphRelationKind::Extended(ExtendedRelationKind::References),
6656        )?;
6657        let unresolved = declaration_key(
6658            project,
6659            "AUTH_KEY",
6660            GraphRelationKind::Extended(ExtendedRelationKind::Configures),
6661        )?;
6662        let export = EntityResolutionKey::new(graph.entities[4].key().clone(), resolved.clone())?;
6663        let exports = vec![export.clone(), export];
6664        let dependencies = vec![
6665            RelationDependencyKey::new(graph.relations[0].key().clone(), resolved.clone())?,
6666            RelationDependencyKey::new(graph.relations[1].key().clone(), ambiguous.clone())?,
6667            RelationDependencyKey::new(graph.relations[2].key().clone(), unresolved.clone())?,
6668        ];
6669        let mut publication = store.begin_index_publication(fingerprint)?;
6670        publication.begin_scan_replacement()?;
6671        publication.upsert_scan_node_batch(&[
6672            graph_node(".", NodeKind::Folder, None),
6673            graph_node("src", NodeKind::Folder, Some(".")),
6674            graph_node("src/Äuth.rs", NodeKind::File, Some("src")),
6675            graph_node("Cargo.toml", NodeKind::File, Some(".")),
6676        ])?;
6677        publication.finish_scan_replacement()?;
6678        publication.replace_repository_graph_with_resolution_keys(
6679            graph.project,
6680            &graph.entities,
6681            &graph.relations,
6682            &graph.occurrences,
6683            &graph.coverage,
6684            &exports,
6685            &dependencies,
6686        )?;
6687        publication.complete()?;
6688        Ok(ResolutionFixture {
6689            graph,
6690            resolved,
6691            ambiguous,
6692            unresolved,
6693        })
6694    }
6695
6696    /// Replace one source-owned parser graph with a requested symbol degree.
6697    fn replace_fixture_symbol_rows(
6698        store: &mut AtlasStore,
6699        path: &str,
6700        symbol_count: usize,
6701    ) -> Result<(), Box<dyn Error>> {
6702        let symbols = (0..symbol_count)
6703            .map(|index| CodeSymbol {
6704                path: path.to_string(),
6705                language: Some("rust".to_string()),
6706                name: format!("symbol_{index}"),
6707                kind: SymbolKind::Function,
6708                signature: format!("fn symbol_{index}()"),
6709                exported: index == 0,
6710                documentation: None,
6711                line_start: index + 1,
6712                line_end: index + 1,
6713                parent: None,
6714                parser: ParserKind::TreeSitter,
6715                detail: Some("function_item".to_string()),
6716            })
6717            .collect();
6718        store.replace_symbol_graph(&SymbolGraph {
6719            path: path.to_string(),
6720            language: Some("rust".to_string()),
6721            parser: ParserKind::TreeSitter,
6722            symbols,
6723            relations: vec![SymbolRelation {
6724                path: path.to_string(),
6725                source_name: "symbol_0".to_string(),
6726                target_name: "dependency".to_string(),
6727                kind: RelationKind::Calls,
6728                line: 1,
6729                context: "dependency()".to_string(),
6730                parser: ParserKind::TreeSitter,
6731            }],
6732        })?;
6733        Ok(())
6734    }
6735
6736    /// Construct one non-complete project-wide coverage row.
6737    fn incomplete_coverage(
6738        relation: GraphRelationKind,
6739        state: CoverageState,
6740        reason: &str,
6741        generation: IndexGeneration,
6742    ) -> Result<CoverageRecord, Box<dyn Error>> {
6743        Ok(CoverageRecord::new(
6744            CoverageScope::Project,
6745            Some(relation),
6746            state,
6747            0,
6748            1,
6749            generation,
6750            Some(GraphIdentityText::new(reason)?),
6751            None,
6752        )?)
6753    }
6754
6755    /// Construct one local-source node for graph publication fixtures.
6756    fn graph_node(path: &str, kind: NodeKind, parent_path: Option<&str>) -> Node {
6757        Node {
6758            path: path.to_string(),
6759            kind,
6760            parent_path: parent_path.map(str::to_string),
6761            extension: None,
6762            language: None,
6763            size_bytes: None,
6764            mtime_ns: None,
6765            content_hash: None,
6766        }
6767    }
6768
6769    /// Persisted all-family fixture for folder/file navigation enrichment.
6770    struct NavigationFixture {
6771        /// Selected project identity.
6772        project: ProjectInstanceId,
6773        /// Exact source path used by file-level assertions.
6774        api_path: String,
6775        /// Exact manifest path used by package-ownership assertions.
6776        manifest_path: String,
6777    }
6778
6779    /// Publish every navigation family plus skewed inbound call context.
6780    fn publish_navigation_fixture(
6781        store: &mut AtlasStore,
6782        fingerprint: &str,
6783    ) -> Result<NavigationFixture, Box<dyn Error>> {
6784        let project = store
6785            .project_instance_id()?
6786            .ok_or_else(|| io::Error::other("navigation fixture identity is missing"))?;
6787        let generation = IndexGeneration::new(1);
6788        let api_path = "src/auth/api.rs".to_string();
6789        let manifest_path = "Cargo.toml".to_string();
6790        let mut nodes = vec![
6791            graph_node(".", NodeKind::Folder, None),
6792            graph_node("src", NodeKind::Folder, Some(".")),
6793            graph_node("src/auth", NodeKind::Folder, Some("src")),
6794            graph_node(&api_path, NodeKind::File, Some("src/auth")),
6795            graph_node("src/auth/caller.rs", NodeKind::File, Some("src/auth")),
6796            graph_node("src/other.rs", NodeKind::File, Some("src")),
6797            graph_node("src/authz.rs", NodeKind::File, Some("src")),
6798            graph_node("tests", NodeKind::Folder, Some(".")),
6799            graph_node("tests/api_test.rs", NodeKind::File, Some("tests")),
6800            graph_node("clients", NodeKind::Folder, Some(".")),
6801            graph_node(&manifest_path, NodeKind::File, Some(".")),
6802        ];
6803        let api = GraphEntity::new(
6804            project,
6805            EntitySelector::File {
6806                path: RepositoryFilePath::new(Path::new(&api_path))?,
6807            },
6808            generation,
6809        )?;
6810        let internal_caller = GraphEntity::new(
6811            project,
6812            EntitySelector::File {
6813                path: RepositoryFilePath::new(Path::new("src/auth/caller.rs"))?,
6814            },
6815            generation,
6816        )?;
6817        let other = GraphEntity::new(
6818            project,
6819            EntitySelector::File {
6820                path: RepositoryFilePath::new(Path::new("src/other.rs"))?,
6821            },
6822            generation,
6823        )?;
6824        let sibling = GraphEntity::new(
6825            project,
6826            EntitySelector::File {
6827                path: RepositoryFilePath::new(Path::new("src/authz.rs"))?,
6828            },
6829            generation,
6830        )?;
6831        let test = GraphEntity::new(
6832            project,
6833            EntitySelector::File {
6834                path: RepositoryFilePath::new(Path::new("tests/api_test.rs"))?,
6835            },
6836            generation,
6837        )?;
6838        let package = GraphEntity::new(
6839            project,
6840            EntitySelector::Package {
6841                package: PackageSelector {
6842                    manager: GraphIdentityText::new("cargo")?,
6843                    name: GraphIdentityText::new("projectatlas-navigation")?,
6844                    manifest: RepositoryFilePath::new(Path::new(&manifest_path))?,
6845                },
6846            },
6847            generation,
6848        )?;
6849        let mut entities = vec![
6850            api.clone(),
6851            internal_caller.clone(),
6852            other.clone(),
6853            sibling.clone(),
6854            test.clone(),
6855            package.clone(),
6856        ];
6857        let mut relations = vec![
6858            LogicalRelation::new(
6859                &api,
6860                GraphRelationKind::Legacy(RelationKind::DependsOn),
6861                RelationResolution::resolved(&package)?,
6862                ConfidenceClass::Exact,
6863                Completeness::Complete,
6864                generation,
6865            )?,
6866            LogicalRelation::new(
6867                &api,
6868                GraphRelationKind::Legacy(RelationKind::Imports),
6869                RelationResolution::resolved(&other)?,
6870                ConfidenceClass::Exact,
6871                Completeness::Complete,
6872                generation,
6873            )?,
6874            LogicalRelation::new(
6875                &api,
6876                GraphRelationKind::Legacy(RelationKind::Calls),
6877                RelationResolution::resolved(&other)?,
6878                ConfidenceClass::Exact,
6879                Completeness::Complete,
6880                generation,
6881            )?,
6882            LogicalRelation::new(
6883                &api,
6884                GraphRelationKind::Extended(ExtendedRelationKind::References),
6885                RelationResolution::Unresolved {
6886                    reference: GraphIdentityText::new("SessionStore")?,
6887                },
6888                ConfidenceClass::High,
6889                Completeness::Partial,
6890                generation,
6891            )?,
6892            LogicalRelation::new(
6893                &test,
6894                GraphRelationKind::Extended(ExtendedRelationKind::Tests),
6895                RelationResolution::resolved(&api)?,
6896                ConfidenceClass::Exact,
6897                Completeness::Complete,
6898                generation,
6899            )?,
6900            LogicalRelation::new(
6901                &api,
6902                GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo),
6903                RelationResolution::resolved(&other)?,
6904                ConfidenceClass::High,
6905                Completeness::Complete,
6906                generation,
6907            )?,
6908            LogicalRelation::new(
6909                &api,
6910                GraphRelationKind::Extended(ExtendedRelationKind::Configures),
6911                RelationResolution::Unresolved {
6912                    reference: GraphIdentityText::new("AUTH_MODE")?,
6913                },
6914                ConfidenceClass::High,
6915                Completeness::Partial,
6916                generation,
6917            )?,
6918            LogicalRelation::new(
6919                &internal_caller,
6920                GraphRelationKind::Legacy(RelationKind::Calls),
6921                RelationResolution::resolved(&api)?,
6922                ConfidenceClass::Exact,
6923                Completeness::Complete,
6924                generation,
6925            )?,
6926            LogicalRelation::new(
6927                &sibling,
6928                GraphRelationKind::Legacy(RelationKind::Imports),
6929                RelationResolution::resolved(&other)?,
6930                ConfidenceClass::Exact,
6931                Completeness::Complete,
6932                generation,
6933            )?,
6934        ];
6935        for index in 0..4 {
6936            let path = format!("clients/caller-{index}.rs");
6937            nodes.push(graph_node(&path, NodeKind::File, Some("clients")));
6938            let caller = GraphEntity::new(
6939                project,
6940                EntitySelector::File {
6941                    path: RepositoryFilePath::new(Path::new(&path))?,
6942                },
6943                generation,
6944            )?;
6945            relations.push(LogicalRelation::new(
6946                &caller,
6947                GraphRelationKind::Legacy(RelationKind::Calls),
6948                RelationResolution::resolved(&api)?,
6949                ConfidenceClass::Exact,
6950                Completeness::Complete,
6951                generation,
6952            )?);
6953            entities.push(caller);
6954        }
6955
6956        let mut publication = store.begin_index_publication(fingerprint)?;
6957        publication.begin_scan_replacement()?;
6958        publication.upsert_scan_node_batch(&nodes)?;
6959        publication.finish_scan_replacement()?;
6960        publication.replace_repository_graph(project, &entities, &relations, &[], &[])?;
6961        publication.complete()?;
6962        Ok(NavigationFixture {
6963            project,
6964            api_path,
6965            manifest_path,
6966        })
6967    }
6968
6969    /// Return a test failure without relying on panic-only assertions.
6970    fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
6971        if condition {
6972            Ok(())
6973        } else {
6974            Err(io::Error::other(message.to_string()).into())
6975        }
6976    }
6977
6978    /// Compare values while preserving useful failure context in fallible tests.
6979    fn require_eq<T: Debug + PartialEq>(
6980        actual: &T,
6981        expected: &T,
6982        context: &str,
6983    ) -> Result<(), Box<dyn Error>> {
6984        require(
6985            actual == expected,
6986            &format!("{context}: expected {expected:?}, found {actual:?}"),
6987        )
6988    }
6989
6990    /// Require a database operation to fail and return its typed error.
6991    fn require_db_error<T>(result: DbResult<T>, message: &str) -> Result<DbError, Box<dyn Error>> {
6992        let Err(error) = result else {
6993            return Err(io::Error::other(message.to_string()).into());
6994        };
6995        Ok(error)
6996    }
6997
6998    /// Prove cursor hydration seeks through stable-key indexes.
6999    fn assert_cursor_hydration_indexes(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
7000        let project = store
7001            .project_instance_id()?
7002            .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
7003        let cases = [
7004            (
7005                "entity cursor hydration",
7006                graph_entity_hydration_sql(2),
7007                vec![
7008                    Value::Blob(vec![0; 32]),
7009                    Value::Blob(vec![1; 32]),
7010                    Value::Blob(project.as_bytes().to_vec()),
7011                ],
7012                "project_instance_id=? AND entity_key=?",
7013                "SCAN entity",
7014                false,
7015            ),
7016            (
7017                "relation cursor hydration",
7018                graph_relation_hydration_sql(2),
7019                vec![
7020                    Value::Blob(vec![0; 32]),
7021                    Value::Blob(vec![1; 32]),
7022                    Value::Blob(project.as_bytes().to_vec()),
7023                ],
7024                "idx_graph_relations_project_key",
7025                "SCAN relation",
7026                false,
7027            ),
7028            (
7029                "batched occurrence hydration",
7030                occurrence_pages_sql(2),
7031                vec![
7032                    Value::Blob(vec![0; 32]),
7033                    Value::Integer(2),
7034                    Value::Blob(vec![1; 32]),
7035                    Value::Integer(2),
7036                ],
7037                "sqlite_autoindex_graph_relation_occurrences_1",
7038                "SCAN graph_relation_occurrences",
7039                true,
7040            ),
7041            (
7042                "batched path coverage hydration",
7043                path_coverage_sql(2),
7044                vec![
7045                    Value::Blob(project.as_bytes().to_vec()),
7046                    Value::Text("path".to_string()),
7047                    Value::Text("Cargo.toml".to_string()),
7048                    Value::Text("src/Äuth.rs".to_string()),
7049                    Value::Integer(i64::from(GraphLimits::MAX_ROWS) + 1),
7050                ],
7051                "idx_graph_coverage_scope_order",
7052                "SCAN graph_coverage",
7053                false,
7054            ),
7055        ];
7056        for (context, sql, bindings, required_plan, forbidden_scan, allow_bounded_sort) in cases {
7057            let mut statement = store
7058                .connection
7059                .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
7060            let details = statement
7061                .query_map(params_from_iter(bindings.iter()), |row| {
7062                    row.get::<_, String>(3)
7063                })?
7064                .collect::<Result<Vec<_>, _>>()?;
7065            require(
7066                details.iter().any(|detail| detail.contains(required_plan))
7067                    && details.iter().all(|detail| {
7068                        !detail.contains(forbidden_scan)
7069                            && (allow_bounded_sort || !detail.contains("USE TEMP B-TREE"))
7070                    }),
7071                &format!(
7072                    "{context} did not use {required_plan} without a scan or sort: {details:?}"
7073                ),
7074            )?;
7075        }
7076        Ok(())
7077    }
7078
7079    /// Prove each normal graph query shape enters through its owning index.
7080    fn assert_query_indexes(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
7081        let cases: &[(&str, &str, &[&str])] = &[
7082            (
7083                "entity path lookup",
7084                "EXPLAIN QUERY PLAN
7085                 SELECT entity_key FROM graph_entities
7086                  WHERE project_instance_id = zeroblob(16)
7087                    AND repository_path = 'src/Äuth.rs'
7088                  ORDER BY entity_kind, entity_key
7089                  LIMIT 11",
7090                &["idx_graph_entities_path"],
7091            ),
7092            (
7093                "outbound relation lookup",
7094                "EXPLAIN QUERY PLAN
7095                 SELECT relation_key FROM graph_relations
7096                  WHERE source_entity_key = zeroblob(32)
7097                  ORDER BY relation_scope, relation_kind, relation_key
7098                  LIMIT 11",
7099                &["idx_graph_relations_source_kind"],
7100            ),
7101            (
7102                "inbound relation lookup",
7103                "EXPLAIN QUERY PLAN
7104                 SELECT relation_key FROM graph_relations
7105                  WHERE target_entity_key = zeroblob(32)
7106                  ORDER BY relation_scope, relation_kind, relation_key
7107                  LIMIT 11",
7108                &["idx_graph_relations_target_kind"],
7109            ),
7110            (
7111                "relation family lookup",
7112                "EXPLAIN QUERY PLAN
7113                 SELECT relation_key FROM graph_relations
7114                  WHERE project_instance_id = zeroblob(16)
7115                    AND relation_scope = 'legacy'
7116                    AND relation_kind = 'calls'
7117                  ORDER BY relation_key
7118                  LIMIT 11",
7119                &["idx_graph_relations_kind_order"],
7120            ),
7121            (
7122                "relation occurrence lookup",
7123                "EXPLAIN QUERY PLAN
7124                 SELECT file_path FROM graph_relation_occurrences
7125                  WHERE relation_key = zeroblob(32)
7126                  ORDER BY file_path, start_line, start_column, end_line, end_column
7127                  LIMIT 11",
7128                &["sqlite_autoindex_graph_relation_occurrences_1"],
7129            ),
7130            (
7131                "occurrence path invalidation",
7132                "EXPLAIN QUERY PLAN
7133                 SELECT relation_key FROM graph_relation_occurrences
7134                  WHERE file_path = 'src'
7135                     OR (file_path >= 'src/' AND file_path < 'src0')",
7136                &["idx_graph_occurrences_file_span"],
7137            ),
7138            (
7139                "coverage path invalidation",
7140                "EXPLAIN QUERY PLAN
7141                 SELECT id FROM graph_coverage
7142                        INDEXED BY idx_graph_coverage_path
7143                  WHERE scope_kind = 'path'
7144                    AND (scope_path = 'src'
7145                     OR (scope_path >= 'src/' AND scope_path < 'src0'))",
7146                &["idx_graph_coverage_path"],
7147            ),
7148            (
7149                "entity repository-path invalidation",
7150                "EXPLAIN QUERY PLAN
7151                 SELECT entity_key FROM graph_entities
7152                  WHERE repository_path = 'src'
7153                     OR (repository_path >= 'src/' AND repository_path < 'src0')",
7154                &["idx_graph_entities_path"],
7155            ),
7156            (
7157                "entity manifest-path invalidation",
7158                "EXPLAIN QUERY PLAN
7159                 SELECT entity_key FROM graph_entities
7160                  WHERE manifest_path = 'src'
7161                     OR (manifest_path >= 'src/' AND manifest_path < 'src0')",
7162                &["idx_graph_entities_manifest_path"],
7163            ),
7164            (
7165                "outbound external cleanup candidate",
7166                "EXPLAIN QUERY PLAN
7167                 SELECT relation.target_entity_key
7168                   FROM graph_relations AS relation
7169                        INDEXED BY idx_graph_relations_source_kind
7170                   JOIN graph_entities AS external
7171                     ON external.entity_key = relation.target_entity_key
7172                  WHERE relation.source_entity_key = zeroblob(32)
7173                    AND external.entity_kind = 'external'",
7174                &["idx_graph_relations_source_kind"],
7175            ),
7176            (
7177                "inbound external cleanup candidate",
7178                "EXPLAIN QUERY PLAN
7179                 SELECT relation.source_entity_key
7180                   FROM graph_relations AS relation
7181                        INDEXED BY idx_graph_relations_target_kind
7182                   JOIN graph_entities AS external
7183                     ON external.entity_key = relation.source_entity_key
7184                  WHERE relation.target_entity_key = zeroblob(32)
7185                    AND external.entity_kind = 'external'",
7186                &["idx_graph_relations_target_kind"],
7187            ),
7188            (
7189                "candidate-bounded external cleanup",
7190                "EXPLAIN QUERY PLAN
7191                 DELETE FROM graph_entities
7192                  WHERE entity_key = zeroblob(32) AND entity_kind = 'external'
7193                    AND NOT EXISTS (
7194                        SELECT 1 FROM graph_relations
7195                               INDEXED BY idx_graph_relations_source_kind
7196                         WHERE source_entity_key = zeroblob(32)
7197                    )
7198                    AND NOT EXISTS (
7199                        SELECT 1 FROM graph_relations
7200                               INDEXED BY idx_graph_relations_target_kind
7201                         WHERE target_entity_key = zeroblob(32)
7202                    )",
7203                &[
7204                    "sqlite_autoindex_graph_entities_1",
7205                    "idx_graph_relations_source_kind",
7206                    "idx_graph_relations_target_kind",
7207                ],
7208            ),
7209            (
7210                "coverage scope lookup",
7211                "EXPLAIN QUERY PLAN
7212                 SELECT id FROM graph_coverage
7213                  WHERE project_instance_id = zeroblob(16)
7214                    AND scope_kind = 'path'
7215                    AND scope_path IS 'src'
7216                  ORDER BY relation_scope, relation_kind, state, id
7217                  LIMIT 11",
7218                &["idx_graph_coverage_scope_order"],
7219            ),
7220            (
7221                "resolution witness lookup",
7222                "EXPLAIN QUERY PLAN
7223                 SELECT canonical_identity FROM graph_resolution_keys
7224                  WHERE project_instance_id = zeroblob(16)
7225                    AND resolution_domain = 'declaration'
7226                    AND key_digest = zeroblob(32)",
7227                &["sqlite_autoindex_graph_resolution_keys_1"],
7228            ),
7229            (
7230                "resolution export lookup",
7231                "EXPLAIN QUERY PLAN
7232                 SELECT entity_key FROM graph_entity_exports
7233                  WHERE project_instance_id = zeroblob(16)
7234                    AND resolution_domain = 'declaration'
7235                    AND key_digest = zeroblob(32)
7236                  ORDER BY entity_key",
7237                &["idx_graph_entity_exports_key"],
7238            ),
7239            (
7240                "resolution export owner lookup",
7241                "EXPLAIN QUERY PLAN
7242                 SELECT resolution_domain, key_digest, entity_key
7243                   FROM graph_entity_exports
7244                  WHERE project_instance_id = zeroblob(16)
7245                    AND owner_path = 'src/Äuth.rs'
7246                  ORDER BY resolution_domain, key_digest, entity_key",
7247                &["idx_graph_entity_exports_owner"],
7248            ),
7249            (
7250                "resolution dependency lookup",
7251                "EXPLAIN QUERY PLAN
7252                 SELECT owner_path, relation_key FROM graph_relation_dependencies
7253                  WHERE project_instance_id = zeroblob(16)
7254                    AND resolution_domain = 'declaration'
7255                    AND key_digest = zeroblob(32)
7256                  ORDER BY owner_path, relation_key",
7257                &["idx_graph_relation_dependencies_key"],
7258            ),
7259            (
7260                "resolution dependency owner lookup",
7261                "EXPLAIN QUERY PLAN
7262                 SELECT resolution_domain, key_digest, relation_key
7263                   FROM graph_relation_dependencies
7264                  WHERE project_instance_id = zeroblob(16)
7265                    AND owner_path = 'src/Äuth.rs'
7266                  ORDER BY resolution_domain, key_digest, relation_key",
7267                &["idx_graph_relation_dependencies_owner"],
7268            ),
7269            (
7270                "relation composite integrity lookup",
7271                "EXPLAIN QUERY PLAN
7272                 SELECT relation_key FROM graph_relations
7273                  WHERE project_instance_id = zeroblob(16)
7274                    AND relation_key = zeroblob(32)",
7275                &["idx_graph_relations_project_key"],
7276            ),
7277        ];
7278
7279        for (context, sql, required_indexes) in cases {
7280            let mut statement = store.connection.prepare(sql)?;
7281            let details = statement
7282                .query_map([], |row| row.get::<_, String>(3))?
7283                .collect::<Result<Vec<_>, _>>()?;
7284            require(
7285                required_indexes
7286                    .iter()
7287                    .all(|index| details.iter().any(|detail| detail.contains(index))),
7288                &format!("{context} did not use {required_indexes:?}; query plan was {details:?}"),
7289            )?;
7290            require(
7291                details.iter().all(|detail| {
7292                    !detail.contains("SCAN graph_") && !detail.contains("USE TEMP B-TREE")
7293                }),
7294                &format!("{context} was not bounded by index order: {details:?}"),
7295            )?;
7296        }
7297
7298        for key_count in [2, EXTERNAL_CANDIDATE_KEYS_PER_QUERY] {
7299            let sql = format!(
7300                "EXPLAIN QUERY PLAN {}",
7301                external_candidate_batch_sql(key_count)
7302            );
7303            let bindings = (0..key_count)
7304                .map(|_| Value::Blob(vec![0_u8; 32]))
7305                .collect::<Vec<_>>();
7306            let mut statement = store.connection.prepare(&sql)?;
7307            let details = statement
7308                .query_map(params_from_iter(bindings.iter()), |row| {
7309                    row.get::<_, String>(3)
7310                })?
7311                .collect::<Result<Vec<_>, _>>()?;
7312            require(
7313                [
7314                    "SEARCH relation USING INDEX idx_graph_relations_source_kind (source_entity_key=?)",
7315                    "SEARCH relation USING INDEX idx_graph_relations_target_kind (target_entity_key=?)",
7316                ]
7317                .iter()
7318                .all(|seek| details.iter().any(|detail| detail.contains(seek)))
7319                    && details.iter().all(|detail| {
7320                        !detail.contains("SCAN relation")
7321                            && !detail.contains("SCAN graph_relations")
7322                            && !detail.contains("USE TEMP B-TREE")
7323                    }),
7324                &format!(
7325                    "{key_count}-key external cleanup batch was not driven by indexed point seeks: {details:?}"
7326                ),
7327            )?;
7328        }
7329        Ok(())
7330    }
7331
7332    /// Require one coverage-discovery query shape to seek through its owning indexes.
7333    fn assert_coverage_discovery_plan(
7334        connection: &Connection,
7335        sql: &str,
7336        values: &[Value],
7337        required_indexes: &[&str],
7338        allow_bounded_partial_sort: bool,
7339        context: &str,
7340    ) -> Result<(), Box<dyn Error>> {
7341        let mut statement = connection.prepare(sql)?;
7342        let details = statement
7343            .query_map(params_from_iter(values.iter()), |row| {
7344                row.get::<_, String>(3)
7345            })?
7346            .collect::<Result<Vec<_>, _>>()?;
7347        require(
7348            required_indexes
7349                .iter()
7350                .all(|index| details.iter().any(|detail| detail.contains(index))),
7351            &format!("{context} did not use {required_indexes:?}; query plan was {details:?}"),
7352        )?;
7353        require(
7354            details.iter().all(|detail| {
7355                let uses_temporary_sort = detail.contains("USE TEMP B-TREE");
7356                let bounded_partial_sort = detail.contains("USE TEMP B-TREE FOR LAST");
7357                !detail.contains("SCAN coverage")
7358                    && (!uses_temporary_sort
7359                        || (allow_bounded_partial_sort && bounded_partial_sort))
7360            }),
7361            &format!("{context} used an unbounded scan or sort: {details:?}"),
7362        )?;
7363        Ok(())
7364    }
7365
7366    /// Publish the maximum relation batch with two occurrences per unique target.
7367    fn publish_detailed_relation_trace_fixture(
7368        store: &mut AtlasStore,
7369        fingerprint: &str,
7370    ) -> Result<DetailedRelationTraceFixture, Box<dyn Error>> {
7371        let project = store
7372            .project_instance_id()?
7373            .ok_or_else(|| io::Error::other("bound trace fixture identity is missing"))?;
7374        let generation = IndexGeneration::new(1);
7375        let source_path = RepositoryFilePath::new(Path::new("src/trace-source.rs"))?;
7376        let source = GraphEntity::new(
7377            project,
7378            EntitySelector::File {
7379                path: source_path.clone(),
7380            },
7381            generation,
7382        )?;
7383        let mut entities = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER + 1);
7384        let mut relations = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER);
7385        let mut occurrences = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER * 2);
7386        entities.push(source.clone());
7387        for index in 0..MAX_REPOSITORY_GRAPH_FRONTIER {
7388            let name = format!("trace_target_{index:03}");
7389            let target = GraphEntity::new(
7390                project,
7391                EntitySelector::Symbol {
7392                    symbol: SymbolSelector {
7393                        file: source_path.clone(),
7394                        name: GraphIdentityText::new(&name)?,
7395                        kind: SymbolKind::Function,
7396                        parent: None,
7397                        signature: GraphIdentityText::new(format!("{name}()"))?,
7398                    },
7399                },
7400                generation,
7401            )?;
7402            let relation = LogicalRelation::new(
7403                &source,
7404                GraphRelationKind::Legacy(RelationKind::Calls),
7405                RelationResolution::resolved(&target)?,
7406                ConfidenceClass::Exact,
7407                Completeness::Complete,
7408                generation,
7409            )?;
7410            let first_line = u32::try_from(index * 2 + 1)?;
7411            let second_line = first_line + 1;
7412            occurrences.push(RelationOccurrence::new(
7413                &relation,
7414                source_path.clone(),
7415                SourceSpan::new(first_line, 1, first_line, 2)?,
7416                generation,
7417            )?);
7418            occurrences.push(RelationOccurrence::new(
7419                &relation,
7420                source_path.clone(),
7421                SourceSpan::new(second_line, 1, second_line, 2)?,
7422                generation,
7423            )?);
7424            entities.push(target);
7425            relations.push(relation);
7426        }
7427
7428        let mut publication = store.begin_index_publication(fingerprint)?;
7429        publication.begin_scan_replacement()?;
7430        publication.upsert_scan_node_batch(&[
7431            graph_node(".", NodeKind::Folder, None),
7432            graph_node("src", NodeKind::Folder, Some(".")),
7433            graph_node("src/trace-source.rs", NodeKind::File, Some("src")),
7434        ])?;
7435        publication.finish_scan_replacement()?;
7436        publication.replace_repository_graph(project, &entities, &relations, &occurrences, &[])?;
7437        publication.complete()?;
7438        Ok(DetailedRelationTraceFixture {
7439            project,
7440            source,
7441            relations,
7442        })
7443    }
7444
7445    /// Publish one complete fixture and its lexical source text.
7446    fn publish_fixture(
7447        store: &mut AtlasStore,
7448        fingerprint: &str,
7449    ) -> Result<GraphFixture, Box<dyn Error>> {
7450        let project = store
7451            .project_instance_id()?
7452            .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
7453        let mut fixture = graph_fixture(project, IndexGeneration::new(1))?;
7454        fixture.coverage.push(CoverageRecord::new(
7455            CoverageScope::Path {
7456                path: RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
7457            },
7458            None,
7459            CoverageState::Complete,
7460            1,
7461            0,
7462            IndexGeneration::new(1),
7463            None,
7464            None,
7465        )?);
7466        let mut occurrences = fixture.occurrences.clone();
7467        occurrences.push(fixture.occurrences[0].clone());
7468        let mut publication = store.begin_index_publication(fingerprint)?;
7469        publication.begin_scan_replacement()?;
7470        publication.upsert_scan_node_batch(&[
7471            graph_node(".", NodeKind::Folder, None),
7472            graph_node("src", NodeKind::Folder, Some(".")),
7473            graph_node("src/Äuth.rs", NodeKind::File, Some("src")),
7474            graph_node("Cargo.toml", NodeKind::File, Some(".")),
7475        ])?;
7476        publication.finish_scan_replacement()?;
7477        publication.replace_symbol_graph(&SymbolGraph {
7478            path: "src/Äuth.rs".to_string(),
7479            language: Some("rust".to_string()),
7480            parser: ParserKind::TreeSitter,
7481            symbols: Vec::new(),
7482            relations: vec![SymbolRelation {
7483                path: "src/Äuth.rs".to_string(),
7484                source_name: "verifyToken".to_string(),
7485                target_name: "legacyTarget".to_string(),
7486                kind: RelationKind::Calls,
7487                line: 10,
7488                context: "legacyTarget()".to_string(),
7489                parser: ParserKind::TreeSitter,
7490            }],
7491        })?;
7492        publication.replace_file_texts_for_paths(
7493            &["src/Äuth.rs".to_string()],
7494            &[IndexedFileText {
7495                path: "src/Äuth.rs".to_string(),
7496                content_hash: Some("hash-old".to_string()),
7497                byte_count: 16,
7498                line_count: 1,
7499                content: "fn verifyToken()".to_string(),
7500            }],
7501        )?;
7502        publication.replace_repository_graph(
7503            fixture.project,
7504            &fixture.entities,
7505            &fixture.relations,
7506            &occurrences,
7507            &fixture.coverage,
7508        )?;
7509        publication.complete()?;
7510        Ok(fixture)
7511    }
7512
7513    #[test]
7514    fn repository_graph_staging_checkpoints_ownership_before_graph_writes()
7515    -> Result<(), Box<dyn Error>> {
7516        let temp = tempfile::tempdir()?;
7517        let project_root = temp.path().join("staging-ownership");
7518        let stage = project_root.join(".projectatlas").join("graph-stage-test");
7519        fs::create_dir_all(&stage)?;
7520        let database = stage.join("projectatlas.db");
7521        let project = ProjectInstanceId::from_bytes([7; 16])?;
7522        let _store =
7523            AtlasStore::create_repository_graph_staging(&database, &project_root, project)?;
7524
7525        let main_database_only = temp.path().join("staging-main-only.db");
7526        fs::copy(&database, &main_database_only)?;
7527        require(
7528            AtlasStore::repository_graph_staging_belongs_to(
7529                &main_database_only,
7530                &project_root,
7531                project,
7532            )?,
7533            "staging ownership depended on WAL sidecars before graph writes",
7534        )
7535    }
7536
7537    #[test]
7538    fn resolution_keys_round_trip_reopen_and_preserve_all_dependency_states()
7539    -> Result<(), Box<dyn Error>> {
7540        let temp = tempfile::tempdir()?;
7541        let project_root = temp.path().join("resolution-round-trip");
7542        let atlas_dir = project_root.join(".projectatlas");
7543        fs::create_dir_all(&atlas_dir)?;
7544        let db_path = atlas_dir.join("projectatlas.db");
7545        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
7546        let fixture = publish_resolution_fixture(&mut writer, "resolution-round-trip")?;
7547        assert_query_indexes(&writer)?;
7548
7549        let counts = writer.connection.query_row(
7550            "SELECT
7551                 (SELECT COUNT(*) FROM graph_resolution_keys),
7552                 (SELECT COUNT(*) FROM graph_entity_exports),
7553                 (SELECT COUNT(*) FROM graph_relation_dependencies)",
7554            [],
7555            |row| {
7556                Ok((
7557                    row.get::<_, i64>(0)?,
7558                    row.get::<_, i64>(1)?,
7559                    row.get::<_, i64>(2)?,
7560                ))
7561            },
7562        )?;
7563        require_eq(&counts, &(3, 1, 3), "deduplicated resolution-key rows")?;
7564        let states = writer
7565            .connection
7566            .prepare(
7567                "SELECT relation.resolution_status, COUNT(*)
7568                   FROM graph_relation_dependencies AS dependency
7569                   JOIN graph_relations AS relation
7570                     ON relation.project_instance_id = dependency.project_instance_id
7571                    AND relation.relation_key = dependency.relation_key
7572                  GROUP BY relation.resolution_status
7573                  ORDER BY relation.resolution_status",
7574            )?
7575            .query_map([], |row| {
7576                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
7577            })?
7578            .collect::<Result<Vec<_>, _>>()?;
7579        require_eq(
7580            &states,
7581            &vec![
7582                ("ambiguous".to_string(), 1),
7583                ("resolved".to_string(), 1),
7584                ("unresolved".to_string(), 1),
7585            ],
7586            "dependency resolution states",
7587        )?;
7588        drop(writer);
7589
7590        let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
7591        let exports = reader.repository_export_keys_for_paths(
7592            fixture.graph.project,
7593            &["src/Äuth.rs".to_string()],
7594            10,
7595        )?;
7596        require_eq(&exports.truncated, &false, "export key truncation")?;
7597        require_eq(
7598            &exports.rows,
7599            &vec![fixture.resolved.clone()],
7600            "export key round trip",
7601        )?;
7602        let candidates = reader.repository_resolution_candidates(&fixture.resolved, 10)?;
7603        require_eq(&candidates.truncated, &false, "candidate truncation")?;
7604        require_eq(
7605            &candidates.rows,
7606            &vec![fixture.graph.entities[4].clone()],
7607            "candidate export round trip",
7608        )?;
7609        let batch_candidates = reader.repository_resolution_candidates_for_keys(
7610            fixture.graph.project,
7611            &[
7612                fixture.unresolved.clone(),
7613                fixture.resolved.clone(),
7614                fixture.ambiguous.clone(),
7615                fixture.resolved.clone(),
7616            ],
7617            10,
7618        )?;
7619        require_eq(
7620            &batch_candidates.truncated,
7621            &false,
7622            "batch candidate truncation",
7623        )?;
7624        require_eq(
7625            &batch_candidates.rows.len(),
7626            &1,
7627            "batch candidate deduplication",
7628        )?;
7629        require_eq(
7630            batch_candidates.rows[0].key(),
7631            &fixture.resolved,
7632            "batch candidate selecting key",
7633        )?;
7634        require_eq(
7635            batch_candidates.rows[0].entity(),
7636            &fixture.graph.entities[4],
7637            "batch candidate entity",
7638        )?;
7639        let affected = reader.repository_affected_source_paths(
7640            fixture.graph.project,
7641            &[
7642                fixture.resolved.clone(),
7643                fixture.resolved,
7644                fixture.ambiguous,
7645                fixture.unresolved,
7646            ],
7647            10,
7648        )?;
7649        require_eq(&affected.truncated, &false, "affected path truncation")?;
7650        require_eq(
7651            &affected.rows,
7652            &vec![RepositoryFilePath::new(Path::new("src/Äuth.rs"))?],
7653            "resolved ambiguous and unresolved dependency owners",
7654        )?;
7655        reader.finish_index_read_snapshot()?;
7656        Ok(())
7657    }
7658
7659    #[test]
7660    fn high_degree_dependency_closure_is_unique_and_reports_overflow_before_mutation()
7661    -> Result<(), Box<dyn Error>> {
7662        let temp = tempfile::tempdir()?;
7663        let project_root = temp.path().join("high-degree-resolution");
7664        fs::create_dir_all(&project_root)?;
7665        let db_path = project_root.join("projectatlas.db");
7666        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
7667        let project = store
7668            .project_instance_id()?
7669            .ok_or_else(|| io::Error::other("bound identity is missing"))?;
7670        let generation = IndexGeneration::new(1);
7671        let dependency_key = declaration_key(
7672            project,
7673            "sharedTarget",
7674            GraphRelationKind::Extended(ExtendedRelationKind::References),
7675        )?;
7676        let mut nodes = vec![
7677            graph_node(".", NodeKind::Folder, None),
7678            graph_node("src", NodeKind::Folder, Some(".")),
7679        ];
7680        let mut entities = Vec::new();
7681        let mut relations = Vec::new();
7682        let mut dependencies = Vec::new();
7683        for index in 0..5 {
7684            let path = format!("src/caller-{index}.rs");
7685            nodes.push(graph_node(&path, NodeKind::File, Some("src")));
7686            let entity = GraphEntity::new(
7687                project,
7688                EntitySelector::File {
7689                    path: RepositoryFilePath::new(Path::new(&path))?,
7690                },
7691                generation,
7692            )?;
7693            let relation = LogicalRelation::new(
7694                &entity,
7695                GraphRelationKind::Extended(ExtendedRelationKind::References),
7696                RelationResolution::Unresolved {
7697                    reference: GraphIdentityText::new("sharedTarget")?,
7698                },
7699                ConfidenceClass::High,
7700                Completeness::Complete,
7701                generation,
7702            )?;
7703            dependencies.push(RelationDependencyKey::new(
7704                relation.key().clone(),
7705                dependency_key.clone(),
7706            )?);
7707            entities.push(entity);
7708            relations.push(relation);
7709        }
7710        dependencies.push(dependencies[0].clone());
7711        let mut publication = store.begin_index_publication("high-degree-resolution")?;
7712        publication.begin_scan_replacement()?;
7713        publication.upsert_scan_node_batch(&nodes)?;
7714        publication.finish_scan_replacement()?;
7715        publication.replace_repository_graph_with_resolution_keys(
7716            project,
7717            &entities,
7718            &relations,
7719            &[],
7720            &[],
7721            &[],
7722            &dependencies,
7723        )?;
7724        publication.complete()?;
7725
7726        let before = store.index_publication()?;
7727        let bounded = store.repository_affected_source_paths(
7728            project,
7729            std::slice::from_ref(&dependency_key),
7730            2,
7731        )?;
7732        require_eq(&bounded.rows.len(), &2, "bounded affected path count")?;
7733        require_eq(&bounded.truncated, &true, "bounded affected path overflow")?;
7734        require_eq(
7735            &store.index_publication()?,
7736            &before,
7737            "overflow lookup mutated publication state",
7738        )?;
7739        let complete = store.repository_affected_source_paths(project, &[dependency_key], 10)?;
7740        require_eq(&complete.rows.len(), &5, "complete affected path count")?;
7741        require_eq(
7742            &complete.truncated,
7743            &false,
7744            "complete affected path overflow",
7745        )?;
7746        let unique = complete.rows.iter().collect::<BTreeSet<_>>();
7747        require_eq(&unique.len(), &5, "affected paths are unique")?;
7748        Ok(())
7749    }
7750
7751    #[test]
7752    fn affected_source_footprint_accounts_exact_owned_rows_and_uses_path_indexes()
7753    -> Result<(), Box<dyn Error>> {
7754        let temp = tempfile::tempdir()?;
7755        let project_root = temp.path().join("affected-source-footprint");
7756        fs::create_dir_all(&project_root)?;
7757        let db_path = project_root.join("projectatlas.db");
7758        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
7759        let fixture = publish_resolution_fixture(&mut store, "affected-source-footprint")?;
7760        replace_fixture_symbol_rows(&mut store, "src/Äuth.rs", 1)?;
7761        store.connection.execute(
7762            "INSERT INTO graph_coverage(
7763                 project_instance_id, scope_kind, scope_path, relation_scope,
7764                 relation_kind, state, total, covered, omitted, reason, reached_limit
7765             ) VALUES(?1, 'path', 'src/Äuth.rs', NULL, NULL,
7766                      'complete', 1, 1, 0, NULL, NULL)",
7767            [&fixture.graph.project.as_bytes()[..]],
7768        )?;
7769
7770        let mut paths = (0..=RESOLUTION_PATHS_PER_QUERY)
7771            .map(|index| format!("missing/{index:04}.rs"))
7772            .collect::<Vec<_>>();
7773        paths.extend(["src/Äuth.rs".to_string(), "src/Äuth.rs".to_string()]);
7774        let footprint =
7775            store.repository_affected_source_footprint(fixture.graph.project, &paths, 100)?;
7776        require_eq(&footprint.rows, &20, "exact affected persisted rows")?;
7777        require(
7778            footprint.retained_bytes > footprint.rows,
7779            "affected footprint omitted decoded bytes",
7780        )?;
7781        require_eq(&footprint.truncated, &false, "exact footprint truncation")?;
7782
7783        let sql = format!("EXPLAIN QUERY PLAN {}", affected_source_footprint_sql(1));
7784        let values = [
7785            Value::Blob(fixture.graph.project.as_bytes().to_vec()),
7786            Value::Text("src/Äuth.rs".to_string()),
7787            Value::Integer(101),
7788        ];
7789        let mut statement = store.connection.prepare(&sql)?;
7790        let details = statement
7791            .query_map(params_from_iter(values.iter()), |row| {
7792                row.get::<_, String>(3)
7793            })?
7794            .collect::<Result<Vec<_>, _>>()?;
7795        for index in [
7796            "sqlite_autoindex_source_parse_metadata_1",
7797            "idx_symbols_path",
7798            "idx_symbol_relations_path",
7799            "idx_graph_entities_path",
7800            "idx_graph_entities_manifest_path",
7801            "idx_graph_relations_source_kind",
7802            "idx_graph_occurrences_file_span",
7803            "idx_graph_coverage_path",
7804            "idx_graph_entity_exports_owner",
7805            "idx_graph_relation_dependencies_owner",
7806            "sqlite_autoindex_graph_resolution_keys_1",
7807        ] {
7808            require(
7809                details.iter().any(|detail| detail.contains(index)),
7810                &format!("affected footprint missed {index}; plan was {details:?}"),
7811            )?;
7812        }
7813        require(
7814            details.iter().all(|detail| !detail.contains("SCAN graph_")),
7815            &format!("affected footprint scanned graph storage: {details:?}"),
7816        )?;
7817        Ok(())
7818    }
7819
7820    #[test]
7821    fn affected_source_footprint_reports_high_degree_overflow_before_mutation()
7822    -> Result<(), Box<dyn Error>> {
7823        let temp = tempfile::tempdir()?;
7824        let project_root = temp.path().join("affected-source-degree");
7825        fs::create_dir_all(&project_root)?;
7826        let db_path = project_root.join("projectatlas.db");
7827        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
7828        let fixture = publish_resolution_fixture(&mut store, "affected-source-degree")?;
7829        replace_fixture_symbol_rows(&mut store, "src/Äuth.rs", 25)?;
7830        let before = store.index_publication()?;
7831
7832        let bounded = store.repository_affected_source_footprint(
7833            fixture.graph.project,
7834            &["src/Äuth.rs".to_string()],
7835            5,
7836        )?;
7837        require_eq(&bounded.rows, &6, "footprint limit plus one sentinel")?;
7838        require_eq(&bounded.truncated, &true, "high-degree footprint overflow")?;
7839        require(
7840            bounded.retained_bytes > 0,
7841            "bounded footprint lost retained bytes",
7842        )?;
7843        require_eq(
7844            &store.index_publication()?,
7845            &before,
7846            "footprint overflow mutated publication",
7847        )?;
7848        Ok(())
7849    }
7850
7851    #[test]
7852    fn affected_source_footprint_validates_inputs_identity_and_graph_availability()
7853    -> Result<(), Box<dyn Error>> {
7854        let temp = tempfile::tempdir()?;
7855        let selected_root = temp.path().join("selected");
7856        let other_root = temp.path().join("other");
7857        fs::create_dir_all(&selected_root)?;
7858        fs::create_dir_all(&other_root)?;
7859        let mut selected =
7860            AtlasStore::open_for_project(&selected_root.join("projectatlas.db"), &selected_root)?;
7861        let fixture = publish_resolution_fixture(&mut selected, "footprint-validation")?;
7862        let other = AtlasStore::open_for_project(&other_root.join("projectatlas.db"), &other_root)?;
7863        let other_project = other
7864            .project_instance_id()?
7865            .ok_or_else(|| io::Error::other("other bound identity is missing"))?;
7866
7867        let invalid_limit = require_db_error(
7868            selected.repository_affected_source_footprint(
7869                fixture.graph.project,
7870                &["src/Äuth.rs".to_string()],
7871                0,
7872            ),
7873            "zero footprint limit was accepted",
7874        )?;
7875        require(
7876            matches!(invalid_limit, DbError::GraphContract(_)),
7877            &format!("invalid footprint limit returned {invalid_limit}"),
7878        )?;
7879        let invalid_path = require_db_error(
7880            selected.repository_affected_source_footprint(
7881                fixture.graph.project,
7882                &["../outside.rs".to_string()],
7883                10,
7884            ),
7885            "escaping footprint path was accepted",
7886        )?;
7887        require(
7888            matches!(invalid_path, DbError::GraphContract(_)),
7889            &format!("invalid footprint path returned {invalid_path}"),
7890        )?;
7891        let mismatched = require_db_error(
7892            selected.repository_affected_source_footprint(
7893                other_project,
7894                &["src/Äuth.rs".to_string()],
7895                10,
7896            ),
7897            "mismatched project footprint was accepted",
7898        )?;
7899        require(
7900            matches!(mismatched, DbError::GraphProjectIdentityMismatch { .. }),
7901            &format!("mismatched footprint returned {mismatched}"),
7902        )?;
7903        require_eq(
7904            &other.repository_affected_source_footprint(
7905                other_project,
7906                &["src/Äuth.rs".to_string()],
7907                10,
7908            )?,
7909            &empty_affected_source_footprint(),
7910            "unpublished graph footprint",
7911        )?;
7912        Ok(())
7913    }
7914
7915    #[test]
7916    fn affected_source_footprint_rejects_missing_resolution_witnesses() -> Result<(), Box<dyn Error>>
7917    {
7918        let temp = tempfile::tempdir()?;
7919        let project_root = temp.path().join("affected-source-corruption");
7920        fs::create_dir_all(&project_root)?;
7921        let db_path = project_root.join("projectatlas.db");
7922        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
7923        let fixture = publish_resolution_fixture(&mut store, "affected-source-corruption")?;
7924        store
7925            .connection
7926            .execute_batch("PRAGMA foreign_keys = OFF")?;
7927        store.connection.execute(
7928            "DELETE FROM graph_resolution_keys
7929              WHERE project_instance_id = ?1
7930                AND resolution_domain = ?2
7931                AND key_digest = ?3",
7932            params![
7933                &fixture.graph.project.as_bytes()[..],
7934                fixture.resolved.domain().as_str(),
7935                &fixture.resolved.digest_bytes()[..],
7936            ],
7937        )?;
7938        let error = require_db_error(
7939            store.repository_affected_source_footprint(
7940                fixture.graph.project,
7941                &["src/Äuth.rs".to_string()],
7942                100,
7943            ),
7944            "missing resolution witness returned a partial footprint",
7945        )?;
7946        require(
7947            matches!(error, DbError::Sqlite(_)),
7948            &format!("missing witness returned the wrong error: {error}"),
7949        )?;
7950        Ok(())
7951    }
7952
7953    #[test]
7954    fn resolution_key_failures_roll_back_and_owner_foreign_keys_cascade()
7955    -> Result<(), Box<dyn Error>> {
7956        let temp = tempfile::tempdir()?;
7957        let project_root = temp.path().join("resolution-failures");
7958        fs::create_dir_all(&project_root)?;
7959        let db_path = project_root.join("projectatlas.db");
7960        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
7961        let fixture = publish_resolution_fixture(&mut store, "resolution-failures")?;
7962        let before_publication = store.index_publication()?;
7963        let before_counts = store.connection.query_row(
7964            "SELECT
7965                 (SELECT COUNT(*) FROM graph_resolution_keys),
7966                 (SELECT COUNT(*) FROM graph_entity_exports),
7967                 (SELECT COUNT(*) FROM graph_relation_dependencies)",
7968            [],
7969            |row| {
7970                Ok((
7971                    row.get::<_, i64>(0)?,
7972                    row.get::<_, i64>(1)?,
7973                    row.get::<_, i64>(2)?,
7974                ))
7975            },
7976        )?;
7977
7978        let replacement = graph_fixture(fixture.graph.project, IndexGeneration::new(2))?;
7979        let invalid_export = EntityResolutionKey::new(
7980            replacement.entities[0].key().clone(),
7981            fixture.resolved.clone(),
7982        )?;
7983        {
7984            let mut publication = store.begin_index_publication("resolution-failures")?;
7985            let error = require_db_error(
7986                publication.replace_repository_graph_with_resolution_keys(
7987                    replacement.project,
7988                    &replacement.entities,
7989                    &replacement.relations,
7990                    &replacement.occurrences,
7991                    &replacement.coverage,
7992                    &[invalid_export],
7993                    &[],
7994                ),
7995                "source-less export owner unexpectedly published",
7996            )?;
7997            require(
7998                matches!(error, DbError::GraphRowShape { .. }),
7999                &format!("invalid export owner returned the wrong error: {error}"),
8000            )?;
8001        }
8002        require_eq(
8003            &store.index_publication()?,
8004            &before_publication,
8005            "failed key publication generation",
8006        )?;
8007        let after_counts = store.connection.query_row(
8008            "SELECT
8009                 (SELECT COUNT(*) FROM graph_resolution_keys),
8010                 (SELECT COUNT(*) FROM graph_entity_exports),
8011                 (SELECT COUNT(*) FROM graph_relation_dependencies)",
8012            [],
8013            |row| {
8014                Ok((
8015                    row.get::<_, i64>(0)?,
8016                    row.get::<_, i64>(1)?,
8017                    row.get::<_, i64>(2)?,
8018                ))
8019            },
8020        )?;
8021        require_eq(
8022            &after_counts,
8023            &before_counts,
8024            "failed key publication durable rows",
8025        )?;
8026        require_eq(
8027            &store
8028                .repository_resolution_candidates(&fixture.resolved, 10)?
8029                .rows,
8030            &vec![fixture.graph.entities[4].clone()],
8031            "failed key publication previous candidates",
8032        )?;
8033
8034        store.connection.execute(
8035            "UPDATE graph_resolution_keys
8036                SET canonical_identity = 'conflicting collision witness'
8037              WHERE project_instance_id = ?1
8038                AND resolution_domain = ?2
8039                AND key_digest = ?3",
8040            params![
8041                &fixture.graph.project.as_bytes()[..],
8042                fixture.resolved.domain().as_str(),
8043                &fixture.resolved.digest_bytes()[..],
8044            ],
8045        )?;
8046        let collision = require_db_error(
8047            store.repository_resolution_candidates(&fixture.resolved, 10),
8048            "conflicting resolution witness was accepted",
8049        )?;
8050        require(
8051            matches!(collision, DbError::ResolutionKeyCollision { .. }),
8052            &format!("conflicting witness returned the wrong error: {collision}"),
8053        )?;
8054        let corrupt_page = require_db_error(
8055            store.repository_export_keys_for_paths(
8056                fixture.graph.project,
8057                &["src/Äuth.rs".to_string()],
8058                10,
8059            ),
8060            "corrupt witness returned a partial key page",
8061        )?;
8062        require(
8063            matches!(corrupt_page, DbError::GraphContract(_)),
8064            &format!("corrupt witness returned the wrong page error: {corrupt_page}"),
8065        )?;
8066        store.connection.execute(
8067            "UPDATE graph_resolution_keys
8068                SET canonical_identity = ?1
8069              WHERE project_instance_id = ?2
8070                AND resolution_domain = ?3
8071                AND key_digest = ?4",
8072            params![
8073                fixture.resolved.canonical_identity(),
8074                &fixture.graph.project.as_bytes()[..],
8075                fixture.resolved.domain().as_str(),
8076                &fixture.resolved.digest_bytes()[..],
8077            ],
8078        )?;
8079
8080        let relation_digest = fixture.graph.relations[2].key().digest_bytes()?;
8081        store.connection.execute(
8082            "DELETE FROM graph_relations
8083              WHERE project_instance_id = ?1 AND relation_key = ?2",
8084            params![&fixture.graph.project.as_bytes()[..], &relation_digest[..]],
8085        )?;
8086        let remaining_dependencies = store.connection.query_row(
8087            "SELECT COUNT(*) FROM graph_relation_dependencies",
8088            [],
8089            |row| row.get::<_, i64>(0),
8090        )?;
8091        require_eq(
8092            &remaining_dependencies,
8093            &(before_counts.2 - 1),
8094            "relation-owner dependency cascade",
8095        )?;
8096        let entity_digest = fixture.graph.entities[4].key().digest_bytes()?;
8097        store.connection.execute(
8098            "DELETE FROM graph_entities
8099              WHERE project_instance_id = ?1 AND entity_key = ?2",
8100            params![&fixture.graph.project.as_bytes()[..], &entity_digest[..]],
8101        )?;
8102        let remaining_exports =
8103            store
8104                .connection
8105                .query_row("SELECT COUNT(*) FROM graph_entity_exports", [], |row| {
8106                    row.get::<_, i64>(0)
8107                })?;
8108        require_eq(&remaining_exports, &0, "entity-owner export cascade")?;
8109        Ok(())
8110    }
8111
8112    #[test]
8113    fn affected_replacement_swaps_resolution_keys_and_collects_only_touched_orphans()
8114    -> Result<(), Box<dyn Error>> {
8115        let temp = tempfile::tempdir()?;
8116        let project_root = temp.path().join("resolution-replacement");
8117        fs::create_dir_all(&project_root)?;
8118        let db_path = project_root.join("projectatlas.db");
8119        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
8120        let first = publish_resolution_fixture(&mut store, "resolution-replacement")?;
8121        let replacement = graph_fixture(first.graph.project, IndexGeneration::new(2))?;
8122        let renamed = declaration_key(
8123            first.graph.project,
8124            "verifySession",
8125            GraphRelationKind::Legacy(RelationKind::Calls),
8126        )?;
8127        let exports = vec![EntityResolutionKey::new(
8128            replacement.entities[4].key().clone(),
8129            renamed.clone(),
8130        )?];
8131        let dependencies = vec![
8132            RelationDependencyKey::new(replacement.relations[0].key().clone(), renamed.clone())?,
8133            RelationDependencyKey::new(
8134                replacement.relations[1].key().clone(),
8135                first.ambiguous.clone(),
8136            )?,
8137            RelationDependencyKey::new(
8138                replacement.relations[2].key().clone(),
8139                first.unresolved.clone(),
8140            )?,
8141        ];
8142        let mut publication = store.begin_index_publication("resolution-replacement")?;
8143        publication.replace_repository_graph_for_paths_with_resolution_keys(
8144            replacement.project,
8145            &["src/Äuth.rs".to_string()],
8146            &replacement.entities,
8147            &replacement.relations,
8148            &replacement.occurrences,
8149            &replacement.coverage,
8150            &exports,
8151            &dependencies,
8152        )?;
8153        publication.complete()?;
8154
8155        require_eq(
8156            &store
8157                .repository_resolution_candidates(&first.resolved, 10)?
8158                .rows
8159                .len(),
8160            &0,
8161            "removed export candidates",
8162        )?;
8163        require_eq(
8164            &store
8165                .repository_affected_source_paths(
8166                    first.graph.project,
8167                    std::slice::from_ref(&first.resolved),
8168                    10,
8169                )?
8170                .rows
8171                .len(),
8172            &0,
8173            "removed dependency owners",
8174        )?;
8175        require_eq(
8176            &store.repository_resolution_candidates(&renamed, 10)?.rows,
8177            &vec![replacement.entities[4].clone()],
8178            "renamed export candidates",
8179        )?;
8180        let exports = store.repository_export_keys_for_paths(
8181            first.graph.project,
8182            &["src/Äuth.rs".to_string()],
8183            10,
8184        )?;
8185        require_eq(&exports.rows, &vec![renamed], "replacement export keys")?;
8186        let registry_rows = store.connection.query_row(
8187            "SELECT COUNT(*) FROM graph_resolution_keys",
8188            [],
8189            |row| row.get::<_, i64>(0),
8190        )?;
8191        require_eq(&registry_rows, &3, "touched orphan witness cleanup")?;
8192        require_eq(
8193            &store
8194                .index_publication()?
8195                .ok_or_else(|| io::Error::other("replacement publication is missing"))?
8196                .generation,
8197            &IndexGeneration::new(2),
8198            "replacement generation",
8199        )?;
8200        Ok(())
8201    }
8202
8203    #[test]
8204    fn affected_graph_replacement_preserves_only_the_unaffected_closure()
8205    -> Result<(), Box<dyn Error>> {
8206        let temp = tempfile::tempdir()?;
8207        let project_root = temp.path().join("affected-closure");
8208        let atlas_dir = project_root.join(".projectatlas");
8209        fs::create_dir_all(&atlas_dir)?;
8210        let db_path = atlas_dir.join("projectatlas.db");
8211        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
8212
8213        let project = store
8214            .project_instance_id()?
8215            .ok_or_else(|| io::Error::other("bound affected-closure identity is missing"))?;
8216        let generation_one = IndexGeneration::new(1);
8217        let project_entity = GraphEntity::new(project, EntitySelector::Project, generation_one)?;
8218        let affected_folder = GraphEntity::new(
8219            project,
8220            EntitySelector::Folder {
8221                path: RepositoryNodePath::new(Path::new("src/a"))?,
8222            },
8223            generation_one,
8224        )?;
8225        let affected_file = GraphEntity::new(
8226            project,
8227            EntitySelector::File {
8228                path: RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
8229            },
8230            generation_one,
8231        )?;
8232        let case_distinct_file = GraphEntity::new(
8233            project,
8234            EntitySelector::File {
8235                path: RepositoryFilePath::new(Path::new("src/A/keep.rs"))?,
8236            },
8237            generation_one,
8238        )?;
8239        let package = GraphEntity::new(
8240            project,
8241            EntitySelector::Package {
8242                package: PackageSelector {
8243                    manager: GraphIdentityText::new("cargo")?,
8244                    name: GraphIdentityText::new("api")?,
8245                    manifest: RepositoryFilePath::new(Path::new("packages/api/Cargo.toml"))?,
8246                },
8247            },
8248            generation_one,
8249        )?;
8250        let orphan_external = GraphEntity::new(
8251            project,
8252            EntitySelector::External {
8253                external: ExternalSelector {
8254                    system: GraphIdentityText::new("crates.io")?,
8255                    identity: GraphIdentityText::new("orphan@1")?,
8256                },
8257            },
8258            generation_one,
8259        )?;
8260        let retained_external = GraphEntity::new(
8261            project,
8262            EntitySelector::External {
8263                external: ExternalSelector {
8264                    system: GraphIdentityText::new("crates.io")?,
8265                    identity: GraphIdentityText::new("retained@1")?,
8266                },
8267            },
8268            generation_one,
8269        )?;
8270        let occurrence_owned_external = GraphEntity::new(
8271            project,
8272            EntitySelector::External {
8273                external: ExternalSelector {
8274                    system: GraphIdentityText::new("crates.io")?,
8275                    identity: GraphIdentityText::new("occurrence-owned@1")?,
8276                },
8277            },
8278            generation_one,
8279        )?;
8280        let affected_relation = LogicalRelation::new(
8281            &affected_file,
8282            GraphRelationKind::Legacy(RelationKind::DependsOn),
8283            RelationResolution::external(&orphan_external)?,
8284            ConfidenceClass::Exact,
8285            Completeness::Complete,
8286            generation_one,
8287        )?;
8288        let package_relation = LogicalRelation::new(
8289            &package,
8290            GraphRelationKind::Legacy(RelationKind::DependsOn),
8291            RelationResolution::external(&orphan_external)?,
8292            ConfidenceClass::Exact,
8293            Completeness::Complete,
8294            generation_one,
8295        )?;
8296        let retained_relation = LogicalRelation::new(
8297            &case_distinct_file,
8298            GraphRelationKind::Legacy(RelationKind::DependsOn),
8299            RelationResolution::external(&retained_external)?,
8300            ConfidenceClass::Exact,
8301            Completeness::Complete,
8302            generation_one,
8303        )?;
8304        let project_external_relation = LogicalRelation::new(
8305            &project_entity,
8306            GraphRelationKind::Legacy(RelationKind::DependsOn),
8307            RelationResolution::external(&retained_external)?,
8308            ConfidenceClass::Exact,
8309            Completeness::Complete,
8310            generation_one,
8311        )?;
8312        let occurrence_backed_project_relation = LogicalRelation::new(
8313            &project_entity,
8314            GraphRelationKind::Legacy(RelationKind::DependsOn),
8315            RelationResolution::external(&occurrence_owned_external)?,
8316            ConfidenceClass::Exact,
8317            Completeness::Complete,
8318            generation_one,
8319        )?;
8320        let affected_occurrence = RelationOccurrence::new(
8321            &affected_relation,
8322            RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
8323            SourceSpan::new(3, 0, 3, 12)?,
8324            generation_one,
8325        )?;
8326        let retained_occurrence = RelationOccurrence::new(
8327            &retained_relation,
8328            RepositoryFilePath::new(Path::new("src/A/keep.rs"))?,
8329            SourceSpan::new(5, 0, 5, 14)?,
8330            generation_one,
8331        )?;
8332        let retained_relation_affected_occurrence = RelationOccurrence::new(
8333            &retained_relation,
8334            RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
8335            SourceSpan::new(6, 0, 6, 14)?,
8336            generation_one,
8337        )?;
8338        let project_relation_occurrence = RelationOccurrence::new(
8339            &occurrence_backed_project_relation,
8340            RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
8341            SourceSpan::new(9, 0, 9, 16)?,
8342            generation_one,
8343        )?;
8344        let initial_coverage = vec![
8345            CoverageRecord::new(
8346                CoverageScope::Project,
8347                None,
8348                CoverageState::Complete,
8349                4,
8350                0,
8351                generation_one,
8352                None,
8353                None,
8354            )?,
8355            CoverageRecord::new(
8356                CoverageScope::Path {
8357                    path: RepositoryNodePath::new(Path::new("src/a"))?,
8358                },
8359                None,
8360                CoverageState::Partial,
8361                1,
8362                1,
8363                generation_one,
8364                Some(GraphIdentityText::new("affected coverage")?),
8365                Some(GraphLimitKind::Rows),
8366            )?,
8367            CoverageRecord::new(
8368                CoverageScope::Path {
8369                    path: RepositoryNodePath::new(Path::new("src/A"))?,
8370                },
8371                None,
8372                CoverageState::Complete,
8373                1,
8374                0,
8375                generation_one,
8376                None,
8377                None,
8378            )?,
8379        ];
8380        {
8381            let mut publication = store.begin_index_publication("affected-closure")?;
8382            publication.begin_scan_replacement()?;
8383            publication.upsert_scan_node_batch(&[
8384                graph_node(".", NodeKind::Folder, None),
8385                graph_node("src", NodeKind::Folder, Some(".")),
8386                graph_node("src/a", NodeKind::Folder, Some("src")),
8387                graph_node("src/a/local.rs", NodeKind::File, Some("src/a")),
8388                graph_node("src/a/new.rs", NodeKind::File, Some("src/a")),
8389                graph_node("src/A", NodeKind::Folder, Some("src")),
8390                graph_node("src/A/keep.rs", NodeKind::File, Some("src/A")),
8391                graph_node("packages", NodeKind::Folder, Some(".")),
8392                graph_node("packages/api", NodeKind::Folder, Some("packages")),
8393                graph_node(
8394                    "packages/api/Cargo.toml",
8395                    NodeKind::File,
8396                    Some("packages/api"),
8397                ),
8398                graph_node("README.md", NodeKind::File, Some(".")),
8399            ])?;
8400            publication.finish_scan_replacement()?;
8401            publication.replace_repository_graph(
8402                project,
8403                &[
8404                    project_entity.clone(),
8405                    affected_folder,
8406                    affected_file.clone(),
8407                    case_distinct_file.clone(),
8408                    package.clone(),
8409                    orphan_external.clone(),
8410                    retained_external.clone(),
8411                    occurrence_owned_external.clone(),
8412                ],
8413                &[
8414                    affected_relation,
8415                    package_relation,
8416                    retained_relation,
8417                    project_external_relation,
8418                    occurrence_backed_project_relation,
8419                ],
8420                &[
8421                    affected_occurrence,
8422                    retained_occurrence,
8423                    retained_relation_affected_occurrence,
8424                    project_relation_occurrence,
8425                ],
8426                &initial_coverage,
8427            )?;
8428            publication.complete()?;
8429        }
8430
8431        let affected_paths = [
8432            RepositoryNodePath::new(Path::new("src/a"))?,
8433            RepositoryNodePath::new(Path::new("packages/api/Cargo.toml"))?,
8434        ];
8435        let (candidates, statements) = trace_statements(&mut store, |store| {
8436            affected_external_candidates(&store.connection, &affected_paths)
8437        })?;
8438        require(
8439            candidates.contains(&orphan_external.key().digest_bytes()?),
8440            "batched external cleanup omitted an affected candidate",
8441        )?;
8442        require_eq(
8443            &statements
8444                .iter()
8445                .filter(|statement| {
8446                    statement.contains("WITH affected(entity_key)")
8447                        && statement.contains("idx_graph_relations_source_kind")
8448                        && statement.contains("idx_graph_relations_target_kind")
8449                })
8450                .count(),
8451            &1,
8452            "bounded external candidate adjacency batches",
8453        )?;
8454
8455        let generation_two = IndexGeneration::new(2);
8456        let replacement_folder = GraphEntity::new(
8457            project,
8458            EntitySelector::Folder {
8459                path: RepositoryNodePath::new(Path::new("src/a"))?,
8460            },
8461            generation_two,
8462        )?;
8463        let replacement_file = GraphEntity::new(
8464            project,
8465            EntitySelector::File {
8466                path: RepositoryFilePath::new(Path::new("src/a/new.rs"))?,
8467            },
8468            generation_two,
8469        )?;
8470        let retained_external_for_relation = GraphEntity::new(
8471            project,
8472            retained_external.selector().clone(),
8473            generation_two,
8474        )?;
8475        let replacement_relation = LogicalRelation::new(
8476            &replacement_file,
8477            GraphRelationKind::Legacy(RelationKind::DependsOn),
8478            RelationResolution::external(&retained_external_for_relation)?,
8479            ConfidenceClass::Exact,
8480            Completeness::Complete,
8481            generation_two,
8482        )?;
8483        let replacement_occurrence = RelationOccurrence::new(
8484            &replacement_relation,
8485            RepositoryFilePath::new(Path::new("src/a/new.rs"))?,
8486            SourceSpan::new(7, 0, 7, 10)?,
8487            generation_two,
8488        )?;
8489        let replacement_coverage = CoverageRecord::new(
8490            CoverageScope::Path {
8491                path: RepositoryNodePath::new(Path::new("src/a"))?,
8492            },
8493            None,
8494            CoverageState::Complete,
8495            1,
8496            0,
8497            generation_two,
8498            None,
8499            None,
8500        )?;
8501        {
8502            let mut publication = store.begin_index_publication("affected-closure")?;
8503            publication.replace_repository_graph_for_paths(
8504                project,
8505                &["src/a".to_string(), "packages/api/Cargo.toml".to_string()],
8506                &[replacement_folder, replacement_file.clone()],
8507                &[replacement_relation],
8508                &[replacement_occurrence],
8509                &[replacement_coverage],
8510            )?;
8511            publication.complete()?;
8512        }
8513
8514        drop(store);
8515        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
8516
8517        require_eq(
8518            &store.repository_graph_entity(affected_file.key())?,
8519            &None,
8520            "affected descendant removal",
8521        )?;
8522        require_eq(
8523            &store.repository_graph_entity(package.key())?,
8524            &None,
8525            "manifest-owned package removal",
8526        )?;
8527        require_eq(
8528            &store.repository_graph_entity(orphan_external.key())?,
8529            &None,
8530            "candidate-bounded orphan external cleanup",
8531        )?;
8532        require_eq(
8533            &store.repository_graph_entity(occurrence_owned_external.key())?,
8534            &None,
8535            "final affected occurrence relation and external cleanup",
8536        )?;
8537        let preserved_case = store
8538            .repository_graph_entity(case_distinct_file.key())?
8539            .ok_or_else(|| io::Error::other("case-distinct sibling was removed"))?;
8540        require_eq(
8541            &preserved_case.generation(),
8542            &generation_two,
8543            "case-distinct sibling generation injection",
8544        )?;
8545        let preserved_external = store
8546            .repository_graph_entity(retained_external.key())?
8547            .ok_or_else(|| io::Error::other("referenced external entity was removed"))?;
8548        require_eq(
8549            &preserved_external.generation(),
8550            &generation_two,
8551            "unaffected external generation injection",
8552        )?;
8553        let retained_relations = store.repository_graph_relations(
8554            RepositoryGraphRelationQuery::Outbound {
8555                source: case_distinct_file.key().clone(),
8556            },
8557            10,
8558        )?;
8559        require_eq(
8560            &retained_relations.rows.len(),
8561            &1,
8562            "relation with one unaffected occurrence",
8563        )?;
8564        require_eq(
8565            &store
8566                .repository_graph_occurrences(&retained_relations.rows[0], 10)?
8567                .rows
8568                .len(),
8569            &1,
8570            "only the affected occurrence was removed",
8571        )?;
8572        let replacement_relations = store.repository_graph_relations(
8573            RepositoryGraphRelationQuery::Outbound {
8574                source: replacement_file.key().clone(),
8575            },
8576            10,
8577        )?;
8578        require_eq(
8579            &replacement_relations.rows.len(),
8580            &1,
8581            "replacement relation count",
8582        )?;
8583        require_eq(
8584            &store
8585                .repository_graph_occurrences(&replacement_relations.rows[0], 10)?
8586                .rows
8587                .len(),
8588            &1,
8589            "replacement source occurrence",
8590        )?;
8591        let affected_coverage = store.repository_graph_coverage(
8592            project,
8593            &CoverageScope::Path {
8594                path: RepositoryNodePath::new(Path::new("src/a"))?,
8595            },
8596            10,
8597        )?;
8598        require(
8599            affected_coverage.rows.len() == 1
8600                && affected_coverage.rows[0].state() == CoverageState::Complete,
8601            "affected path coverage was not replaced",
8602        )?;
8603        let case_coverage = store.repository_graph_coverage(
8604            project,
8605            &CoverageScope::Path {
8606                path: RepositoryNodePath::new(Path::new("src/A"))?,
8607            },
8608            10,
8609        )?;
8610        require_eq(
8611            &case_coverage.rows.len(),
8612            &1,
8613            "case-distinct coverage preservation",
8614        )?;
8615        require_eq(
8616            &store
8617                .repository_graph_coverage(project, &CoverageScope::Project, 10)?
8618                .rows
8619                .len(),
8620            &1,
8621            "unaffected project coverage preservation",
8622        )?;
8623        require_eq(
8624            &store
8625                .repository_graph_relations(
8626                    RepositoryGraphRelationQuery::Outbound {
8627                        source: project_entity.key().clone(),
8628                    },
8629                    10,
8630                )?
8631                .rows
8632                .len(),
8633            &1,
8634            "project-to-external relation preservation",
8635        )?;
8636
8637        store.finish_index_read_snapshot()?;
8638        drop(store);
8639        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
8640        let generation_three = IndexGeneration::new(3);
8641        let root_project = GraphEntity::new(project, EntitySelector::Project, generation_three)?;
8642        let readme = GraphEntity::new(
8643            project,
8644            EntitySelector::File {
8645                path: RepositoryFilePath::new(Path::new("README.md"))?,
8646            },
8647            generation_three,
8648        )?;
8649        let root_coverage = CoverageRecord::new(
8650            CoverageScope::Project,
8651            None,
8652            CoverageState::Complete,
8653            1,
8654            0,
8655            generation_three,
8656            None,
8657            None,
8658        )?;
8659        {
8660            let mut publication = store.begin_index_publication("affected-closure")?;
8661            publication.replace_repository_graph_for_paths(
8662                project,
8663                &[".".to_string()],
8664                &[root_project.clone(), readme],
8665                &[],
8666                &[],
8667                &[root_coverage],
8668            )?;
8669            publication.complete()?;
8670        }
8671        drop(store);
8672        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
8673        require_eq(
8674            &store.repository_graph_entity(case_distinct_file.key())?,
8675            &None,
8676            "root replacement stale local removal",
8677        )?;
8678        require_eq(
8679            &store.repository_graph_entity(retained_external.key())?,
8680            &None,
8681            "root replacement external removal",
8682        )?;
8683        require_eq(
8684            &store
8685                .repository_graph_relations(
8686                    RepositoryGraphRelationQuery::Outbound {
8687                        source: root_project.key().clone(),
8688                    },
8689                    10,
8690                )?
8691                .rows
8692                .len(),
8693            &0,
8694            "root replacement project relation removal",
8695        )?;
8696        require_eq(
8697            &store
8698                .repository_graph_coverage(project, &CoverageScope::Project, 10)?
8699                .rows
8700                .len(),
8701            &1,
8702            "root replacement project coverage",
8703        )?;
8704        require_eq(
8705            &store
8706                .repository_graph_coverage(
8707                    project,
8708                    &CoverageScope::Path {
8709                        path: RepositoryNodePath::new(Path::new("src/A"))?,
8710                    },
8711                    10,
8712                )?
8713                .rows
8714                .len(),
8715            &0,
8716            "root replacement path coverage removal",
8717        )?;
8718
8719        store.finish_index_read_snapshot()?;
8720        drop(store);
8721        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
8722        {
8723            let mut projection = store.begin_index_projection_refresh("affected-closure")?;
8724            projection.replace_file_texts_for_paths(
8725                &["README.md".to_string()],
8726                &[IndexedFileText {
8727                    path: "README.md".to_string(),
8728                    content_hash: Some("readme-hash".to_string()),
8729                    byte_count: 7,
8730                    line_count: 1,
8731                    content: "# Atlas".to_string(),
8732                }],
8733            )?;
8734            projection.complete()?;
8735        }
8736        drop(store);
8737        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
8738        let error = require_db_error(
8739            store.repository_graph_entity(root_project.key()),
8740            "non-graph publication blessed stale graph rows",
8741        )?;
8742        require(
8743            matches!(
8744                error,
8745                DbError::GraphRowShape {
8746                    table: "project_identity",
8747                    ..
8748                }
8749            ),
8750            &format!("unexpected stale graph generation error: {error}"),
8751        )?;
8752        store.finish_index_read_snapshot()?;
8753        Ok(())
8754    }
8755
8756    /// Assert one reader sees a complete internally consistent graph projection.
8757    fn require_graph_projection(
8758        store: &AtlasStore,
8759        fixture: &GraphFixture,
8760        generation: IndexGeneration,
8761        lexical_content: &str,
8762    ) -> Result<(), Box<dyn Error>> {
8763        let publication = store
8764            .index_publication()?
8765            .ok_or_else(|| io::Error::other("graph publication metadata missing"))?;
8766        require_eq(
8767            &publication.state,
8768            &IndexPublicationState::Complete,
8769            "graph publication state",
8770        )?;
8771        require_eq(
8772            &publication.generation,
8773            &generation,
8774            "graph publication generation",
8775        )?;
8776        let source = fixture
8777            .entities
8778            .iter()
8779            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
8780            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
8781        let entity = store
8782            .repository_graph_entity(source.key())?
8783            .ok_or_else(|| io::Error::other("source graph entity missing"))?;
8784        require_eq(&entity.generation(), &generation, "graph entity generation")?;
8785        let relations = store.repository_graph_relations(
8786            RepositoryGraphRelationQuery::Outbound {
8787                source: source.key().clone(),
8788            },
8789            10,
8790        )?;
8791        require_eq(&relations.rows.len(), &4, "graph relation count")?;
8792        require(
8793            relations
8794                .rows
8795                .iter()
8796                .all(|relation| relation.generation() == generation),
8797            "graph relation generation mismatch",
8798        )?;
8799        let calls = relations
8800            .rows
8801            .iter()
8802            .find(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls))
8803            .ok_or_else(|| io::Error::other("call relation missing"))?;
8804        let occurrences = store.repository_graph_occurrences(calls, 10)?;
8805        require_eq(&occurrences.rows.len(), &2, "graph occurrence count")?;
8806        require(
8807            occurrences
8808                .rows
8809                .iter()
8810                .all(|occurrence| occurrence.generation() == generation),
8811            "graph occurrence generation mismatch",
8812        )?;
8813        let occurrence_pages = store.repository_graph_occurrence_pages(&relations.rows, 1, None)?;
8814        require_eq(
8815            &occurrence_pages.len(),
8816            &relations.rows.len(),
8817            "batched occurrence page count",
8818        )?;
8819        for (relation, page) in relations.rows.iter().zip(&occurrence_pages) {
8820            let is_calls = relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls);
8821            require_eq(
8822                &page.rows.len(),
8823                &usize::from(is_calls),
8824                "batched occurrence rows",
8825            )?;
8826            require_eq(&page.truncated, &is_calls, "batched occurrence truncation")?;
8827        }
8828        let coverage =
8829            store.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10)?;
8830        require_eq(&coverage.rows.len(), &6, "graph coverage count")?;
8831        require(
8832            coverage
8833                .rows
8834                .iter()
8835                .all(|record| record.generation() == generation),
8836            "graph coverage generation mismatch",
8837        )?;
8838        let lexical = store
8839            .load_file_text("src/Äuth.rs")?
8840            .ok_or_else(|| io::Error::other("lexical source row missing"))?;
8841        require_eq(
8842            &lexical.content.as_str(),
8843            &lexical_content,
8844            "lexical source generation",
8845        )?;
8846        require_eq(
8847            &store.symbol_relation_count()?,
8848            &1,
8849            "legacy symbol relation compatibility",
8850        )?;
8851        Ok(())
8852    }
8853
8854    #[test]
8855    fn typed_graph_round_trips_through_bounded_indexed_queries() -> Result<(), Box<dyn Error>> {
8856        let temp = tempfile::tempdir()?;
8857        let project_root = temp.path().join("typed-graph");
8858        let atlas_dir = project_root.join(".projectatlas");
8859        fs::create_dir_all(&atlas_dir)?;
8860        let db_path = atlas_dir.join("projectatlas.db");
8861        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
8862        let fixture = publish_fixture(&mut writer, "typed-graph")?;
8863        drop(writer);
8864        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
8865
8866        for expected in &fixture.entities {
8867            require_eq(
8868                &store.repository_graph_entity(expected.key())?,
8869                &Some(expected.clone()),
8870                "stable entity lookup",
8871            )?;
8872        }
8873
8874        let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
8875        let truncated =
8876            store.repository_graph_entities_by_path(fixture.project, &source_path, 1)?;
8877        require(
8878            truncated.truncated && truncated.rows.len() == 1,
8879            "entity LIMIT + 1",
8880        )?;
8881        let path_rows =
8882            store.repository_graph_entities_by_path(fixture.project, &source_path, 10)?;
8883        require(
8884            !path_rows.truncated && path_rows.rows.len() == 2,
8885            "Unicode/case path lookup",
8886        )?;
8887        for (result, context) in [
8888            (
8889                store.repository_graph_entities_by_path(fixture.project, &source_path, 0),
8890                "zero entity page limit",
8891            ),
8892            (
8893                store.repository_graph_entities_by_path(
8894                    fixture.project,
8895                    &source_path,
8896                    GraphLimits::MAX_ROWS + 1,
8897                ),
8898                "over-ceiling entity page limit",
8899            ),
8900        ] {
8901            let error = require_db_error(result, context)?;
8902            require(
8903                matches!(error, DbError::GraphContract(_)),
8904                &format!("unexpected {context} error: {error}"),
8905            )?;
8906        }
8907
8908        let source = fixture
8909            .entities
8910            .iter()
8911            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
8912            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
8913        let outbound = store.repository_graph_relations(
8914            RepositoryGraphRelationQuery::Outbound {
8915                source: source.key().clone(),
8916            },
8917            10,
8918        )?;
8919        require_eq(&outbound.rows.len(), &4, "all resolution states")?;
8920        require(
8921            outbound.rows.iter().any(|relation| {
8922                matches!(relation.resolution(), RelationResolution::Resolved { .. })
8923            }) && outbound.rows.iter().any(|relation| {
8924                matches!(relation.resolution(), RelationResolution::Ambiguous { .. })
8925            }) && outbound.rows.iter().any(|relation| {
8926                matches!(relation.resolution(), RelationResolution::Unresolved { .. })
8927            }) && outbound.rows.iter().any(|relation| {
8928                matches!(relation.resolution(), RelationResolution::External { .. })
8929            }),
8930            "resolution variants did not round-trip",
8931        )?;
8932        let outbound_truncated = store.repository_graph_relations(
8933            RepositoryGraphRelationQuery::Outbound {
8934                source: source.key().clone(),
8935            },
8936            3,
8937        )?;
8938        require(
8939            outbound_truncated.truncated && outbound_truncated.rows.len() == 3,
8940            "relation LIMIT + 1",
8941        )?;
8942        for (limit, context) in [
8943            (0, "zero relation page limit"),
8944            (
8945                GraphLimits::MAX_ROWS + 1,
8946                "over-ceiling relation page limit",
8947            ),
8948        ] {
8949            let error = require_db_error(
8950                store.repository_graph_relations(
8951                    RepositoryGraphRelationQuery::Outbound {
8952                        source: source.key().clone(),
8953                    },
8954                    limit,
8955                ),
8956                context,
8957            )?;
8958            require(
8959                matches!(error, DbError::GraphContract(_)),
8960                &format!("unexpected {context} error: {error}"),
8961            )?;
8962        }
8963
8964        let symbol = fixture
8965            .entities
8966            .iter()
8967            .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
8968            .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
8969        let inbound = store.repository_graph_relations(
8970            RepositoryGraphRelationQuery::Inbound {
8971                target: symbol.key().clone(),
8972            },
8973            10,
8974        )?;
8975        require_eq(&inbound.rows.len(), &1, "inbound relation lookup")?;
8976        let calls = store.repository_graph_relations(
8977            RepositoryGraphRelationQuery::Family {
8978                relation: GraphRelationKind::Legacy(RelationKind::Calls),
8979            },
8980            10,
8981        )?;
8982        require_eq(&calls.rows.len(), &1, "relation-family lookup")?;
8983        let occurrence_page = store.repository_graph_occurrences(&calls.rows[0], 1)?;
8984        require(
8985            occurrence_page.truncated && occurrence_page.rows.len() == 1,
8986            "occurrence LIMIT + 1",
8987        )?;
8988        let all_occurrences = store.repository_graph_occurrences(&calls.rows[0], 10)?;
8989        require_eq(
8990            &all_occurrences.rows.len(),
8991            &2,
8992            "logical relation occurrence retention",
8993        )?;
8994        let error = require_db_error(
8995            store.repository_graph_occurrences(&calls.rows[0], 0),
8996            "zero occurrence page limit was accepted",
8997        )?;
8998        require(
8999            matches!(error, DbError::GraphContract(_)),
9000            &format!("unexpected zero occurrence-limit error: {error}"),
9001        )?;
9002        let error = require_db_error(
9003            store.repository_graph_occurrences(&calls.rows[0], GraphLimits::MAX_OCCURRENCES + 1),
9004            "over-ceiling occurrence page was accepted",
9005        )?;
9006        require(
9007            matches!(error, DbError::GraphContract(_)),
9008            &format!("unexpected occurrence-limit error: {error}"),
9009        )?;
9010
9011        let project_coverage =
9012            store.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10)?;
9013        let path_coverage = store.repository_graph_coverage(
9014            fixture.project,
9015            &CoverageScope::Path {
9016                path: RepositoryNodePath::new(Path::new("src"))?,
9017            },
9018            10,
9019        )?;
9020        require_eq(&project_coverage.rows.len(), &6, "project coverage states")?;
9021        require_eq(&path_coverage.rows.len(), &1, "path coverage state")?;
9022        require(
9023            path_coverage.rows[0].state() == CoverageState::Partial,
9024            "partial coverage did not round-trip",
9025        )?;
9026        for (limit, context) in [
9027            (0, "zero coverage page limit"),
9028            (
9029                GraphLimits::MAX_ROWS + 1,
9030                "over-ceiling coverage page limit",
9031            ),
9032        ] {
9033            let error = require_db_error(
9034                store.repository_graph_coverage(fixture.project, &CoverageScope::Project, limit),
9035                context,
9036            )?;
9037            require(
9038                matches!(error, DbError::GraphContract(_)),
9039                &format!("unexpected {context} error: {error}"),
9040            )?;
9041        }
9042
9043        let source_next = GraphEntity::new(
9044            fixture.project,
9045            source.selector().clone(),
9046            IndexGeneration::new(2),
9047        )?;
9048        let symbol_next = GraphEntity::new(
9049            fixture.project,
9050            symbol.selector().clone(),
9051            IndexGeneration::new(2),
9052        )?;
9053        let next_generation_call = LogicalRelation::new(
9054            &source_next,
9055            GraphRelationKind::Legacy(RelationKind::Calls),
9056            RelationResolution::resolved(&symbol_next)?,
9057            ConfidenceClass::Exact,
9058            Completeness::Complete,
9059            IndexGeneration::new(2),
9060        )?;
9061        let error = require_db_error(
9062            store.repository_graph_occurrences(&next_generation_call, 10),
9063            "generation-mismatched occurrence request was accepted",
9064        )?;
9065        require(
9066            matches!(error, DbError::GraphContract(_)),
9067            &format!("unexpected occurrence generation error: {error}"),
9068        )?;
9069
9070        let lexical = store
9071            .load_file_text("src/Äuth.rs")?
9072            .ok_or_else(|| io::Error::other("lexical source row missing"))?;
9073        require_eq(
9074            &lexical.content,
9075            &"fn verifyToken()".to_string(),
9076            "lexical owner",
9077        )?;
9078        require_eq(
9079            &store.symbol_relation_count()?,
9080            &1,
9081            "legacy relation projection changed",
9082        )?;
9083
9084        store.finish_index_read_snapshot()?;
9085        drop(store);
9086        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
9087        let mut publication = writer.begin_index_publication("typed-graph")?;
9088        publication.replace_repository_graph_for_paths(
9089            fixture.project,
9090            &["src/unrelated.rs".to_string()],
9091            &[],
9092            &[],
9093            &[],
9094            &[],
9095        )?;
9096        publication.complete()?;
9097        drop(writer);
9098        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
9099        let reused = store
9100            .repository_graph_entity(source.key())?
9101            .ok_or_else(|| io::Error::other("unchanged entity disappeared"))?;
9102        require_eq(
9103            &reused.generation(),
9104            &IndexGeneration::new(2),
9105            "unchanged graph row generation injection",
9106        )?;
9107        for expected in &fixture.entities {
9108            let reused = store
9109                .repository_graph_entity(expected.key())?
9110                .ok_or_else(|| io::Error::other("unchanged graph entity disappeared"))?;
9111            require_eq(
9112                &reused.generation(),
9113                &IndexGeneration::new(2),
9114                "incremental graph row reuse",
9115            )?;
9116        }
9117        assert_query_indexes(&store)?;
9118        store.finish_index_read_snapshot()?;
9119        Ok(())
9120    }
9121
9122    #[test]
9123    fn batched_adjacency_uses_direction_owned_indexes_and_stable_keysets()
9124    -> Result<(), Box<dyn Error>> {
9125        let temp = tempfile::tempdir()?;
9126        let project_root = temp.path().join("batched-adjacency");
9127        let atlas_dir = project_root.join(".projectatlas");
9128        fs::create_dir_all(&atlas_dir)?;
9129        let db_path = atlas_dir.join("projectatlas.db");
9130        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
9131        let fixture = publish_fixture(&mut writer, "batched-adjacency")?;
9132        drop(writer);
9133        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
9134
9135        let source = fixture
9136            .entities
9137            .iter()
9138            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
9139            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
9140        let symbol = fixture
9141            .entities
9142            .iter()
9143            .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
9144            .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
9145        let external = fixture
9146            .entities
9147            .iter()
9148            .find(|entity| matches!(entity.selector(), EntitySelector::External { .. }))
9149            .ok_or_else(|| io::Error::other("external fixture missing"))?;
9150        let outbound_frontier = vec![source.key().clone()];
9151
9152        let first = store.repository_graph_adjacency_page(
9153            &outbound_frontier,
9154            RepositoryGraphDirection::Outbound,
9155            None,
9156            2,
9157            None,
9158        )?;
9159        require(
9160            first.truncated && first.rows.len() == 2 && first.continuation.is_some(),
9161            "outbound adjacency did not retain its LIMIT + 1 keyset",
9162        )?;
9163        require(
9164            first.rows.iter().all(|row| {
9165                row.frontier_index == 0
9166                    && row.frontier == source.key().clone()
9167                    && row.direction == RepositoryGraphDirection::Outbound
9168            }),
9169            "outbound adjacency lost its selecting frontier",
9170        )?;
9171        let continuation = first
9172            .continuation
9173            .clone()
9174            .ok_or_else(|| io::Error::other("outbound continuation missing"))?;
9175        let second = store.repository_graph_adjacency_page(
9176            &outbound_frontier,
9177            RepositoryGraphDirection::Outbound,
9178            Some(&continuation),
9179            10,
9180            None,
9181        )?;
9182        require(
9183            !second.truncated && second.rows.len() == 2 && second.continuation.is_none(),
9184            "outbound continuation did not finish the stable relation order",
9185        )?;
9186        let ordinary = store.repository_graph_relations(
9187            RepositoryGraphRelationQuery::Outbound {
9188                source: source.key().clone(),
9189            },
9190            10,
9191        )?;
9192        let combined = first
9193            .rows
9194            .into_iter()
9195            .chain(second.rows)
9196            .map(|row| row.detail.relation)
9197            .collect::<Vec<_>>();
9198        require_eq(
9199            &combined,
9200            &ordinary.rows,
9201            "adjacency keyset order versus ordinary relation order",
9202        )?;
9203        let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9204        let filtered = store.repository_graph_adjacency_page_filtered(
9205            &outbound_frontier,
9206            RepositoryGraphDirection::Outbound,
9207            Some(calls),
9208            None,
9209            10,
9210            None,
9211        )?;
9212        let expected_calls = ordinary
9213            .rows
9214            .iter()
9215            .filter(|relation| relation.kind() == calls)
9216            .cloned()
9217            .collect::<Vec<_>>();
9218        require_eq(
9219            &filtered
9220                .rows
9221                .iter()
9222                .map(|row| row.detail.relation.clone())
9223                .collect::<Vec<_>>(),
9224            &expected_calls,
9225            "filtered adjacency versus ordinary family selection",
9226        )?;
9227        require(
9228            !filtered.truncated && filtered.continuation.is_none(),
9229            "filtered adjacency unexpectedly truncated its complete family",
9230        )?;
9231
9232        let inbound_frontier = vec![symbol.key().clone(), external.key().clone()];
9233        let inbound = store.repository_graph_adjacency_page(
9234            &inbound_frontier,
9235            RepositoryGraphDirection::Inbound,
9236            None,
9237            10,
9238            None,
9239        )?;
9240        require(
9241            !inbound.truncated
9242                && inbound.rows.len() == 2
9243                && inbound.rows[0].frontier_index == 0
9244                && inbound.rows[0].frontier == symbol.key().clone()
9245                && inbound.rows[1].frontier_index == 1
9246                && inbound.rows[1].frontier == external.key().clone()
9247                && inbound
9248                    .rows
9249                    .iter()
9250                    .all(|row| row.direction == RepositoryGraphDirection::Inbound),
9251            "inbound adjacency did not preserve bounded frontier order",
9252        )?;
9253
9254        for (direction, expected_index) in [
9255            (
9256                RepositoryGraphDirection::Outbound,
9257                "idx_graph_relations_source_kind",
9258            ),
9259            (
9260                RepositoryGraphDirection::Inbound,
9261                "idx_graph_relations_target_kind",
9262            ),
9263        ] {
9264            for continuation_index in [None, Some(0)] {
9265                let sql = format!(
9266                    "EXPLAIN QUERY PLAN {}",
9267                    adjacency_relation_sql(2, direction, continuation_index, false)
9268                );
9269                let mut statement = store.connection.prepare(&sql)?;
9270                let mut bindings = vec![Value::Blob(fixture.project.as_bytes().to_vec())];
9271                bindings.push(Value::Blob(source.key().digest_bytes()?.to_vec()));
9272                if continuation_index.is_some() {
9273                    bindings.extend([
9274                        Value::Text(String::new()),
9275                        Value::Text(String::new()),
9276                        Value::Blob(vec![0; 32]),
9277                    ]);
9278                }
9279                bindings.extend([
9280                    Value::Integer(11),
9281                    Value::Blob(symbol.key().digest_bytes()?.to_vec()),
9282                    Value::Integer(11),
9283                    Value::Integer(11),
9284                ]);
9285                let details = statement
9286                    .query_map(params_from_iter(bindings.iter()), |row| {
9287                        row.get::<_, String>(3)
9288                    })?
9289                    .collect::<Result<Vec<_>, _>>()?;
9290                require(
9291                    details.iter().any(|detail| detail.contains(expected_index))
9292                        && details
9293                            .iter()
9294                            .all(|detail| !detail.contains("SCAN relation")),
9295                    &format!(
9296                        "{direction:?} adjacency plan (continuation_index={continuation_index:?}) did not own its index: {details:?}"
9297                    ),
9298                )?;
9299            }
9300
9301            let (scope, kind) = relation_parts(calls);
9302            let filtered_sql = format!(
9303                "EXPLAIN QUERY PLAN {}",
9304                adjacency_relation_sql(2, direction, None, true)
9305            );
9306            let mut filtered_statement = store.connection.prepare(&filtered_sql)?;
9307            let filtered_bindings = [
9308                Value::Blob(fixture.project.as_bytes().to_vec()),
9309                Value::Blob(source.key().digest_bytes()?.to_vec()),
9310                Value::Text(scope.to_string()),
9311                Value::Text(kind.to_string()),
9312                Value::Integer(11),
9313                Value::Blob(symbol.key().digest_bytes()?.to_vec()),
9314                Value::Text(scope.to_string()),
9315                Value::Text(kind.to_string()),
9316                Value::Integer(11),
9317                Value::Integer(11),
9318            ];
9319            let filtered_details = filtered_statement
9320                .query_map(params_from_iter(filtered_bindings.iter()), |row| {
9321                    row.get::<_, String>(3)
9322                })?
9323                .collect::<Result<Vec<_>, _>>()?;
9324            require(
9325                filtered_details
9326                    .iter()
9327                    .any(|detail| detail.contains(expected_index))
9328                    && filtered_details
9329                        .iter()
9330                        .all(|detail| !detail.contains("SCAN relation")),
9331                &format!(
9332                    "filtered {direction:?} adjacency plan did not own its index: {filtered_details:?}"
9333                ),
9334            )?;
9335        }
9336
9337        let duplicate_error = require_db_error(
9338            store.repository_graph_adjacency_page(
9339                &[source.key().clone(), source.key().clone()],
9340                RepositoryGraphDirection::Outbound,
9341                None,
9342                1,
9343                None,
9344            ),
9345            "duplicate adjacency frontier was accepted",
9346        )?;
9347        require(
9348            matches!(duplicate_error, DbError::GraphContract(_)),
9349            &format!("unexpected duplicate-frontier error: {duplicate_error}"),
9350        )?;
9351        let oversized = vec![source.key().clone(); MAX_REPOSITORY_GRAPH_FRONTIER + 1];
9352        let oversized_error = require_db_error(
9353            store.repository_graph_adjacency_page(
9354                &oversized,
9355                RepositoryGraphDirection::Outbound,
9356                None,
9357                1,
9358                None,
9359            ),
9360            "oversized adjacency frontier was accepted",
9361        )?;
9362        require(
9363            matches!(oversized_error, DbError::GraphContract(_)),
9364            &format!("unexpected oversized-frontier error: {oversized_error}"),
9365        )?;
9366        let foreign = GraphEntity::new(
9367            ProjectInstanceId::from_bytes([0x7f; 16])?,
9368            EntitySelector::Project,
9369            IndexGeneration::new(1),
9370        )?;
9371        let project_error = require_db_error(
9372            store.repository_graph_adjacency_page(
9373                &[source.key().clone(), foreign.key().clone()],
9374                RepositoryGraphDirection::Outbound,
9375                None,
9376                1,
9377                None,
9378            ),
9379            "mixed-project adjacency frontier was accepted",
9380        )?;
9381        require(
9382            matches!(project_error, DbError::GraphProjectIdentityMismatch { .. }),
9383            &format!("unexpected mixed-project error: {project_error}"),
9384        )?;
9385        let direction_error = require_db_error(
9386            store.repository_graph_adjacency_page(
9387                &outbound_frontier,
9388                RepositoryGraphDirection::Inbound,
9389                Some(&continuation),
9390                1,
9391                None,
9392            ),
9393            "cross-direction adjacency continuation was accepted",
9394        )?;
9395        require(
9396            matches!(direction_error, DbError::GraphContract(_)),
9397            &format!("unexpected continuation-direction error: {direction_error}"),
9398        )?;
9399        let mut filtered_continuation = continuation.clone();
9400        filtered_continuation.relation = Some(calls);
9401        let family_error = require_db_error(
9402            store.repository_graph_adjacency_page_filtered(
9403                &outbound_frontier,
9404                RepositoryGraphDirection::Outbound,
9405                Some(GraphRelationKind::Extended(
9406                    ExtendedRelationKind::Configures,
9407                )),
9408                Some(&filtered_continuation),
9409                1,
9410                None,
9411            ),
9412            "cross-family adjacency continuation was accepted",
9413        )?;
9414        require(
9415            matches!(family_error, DbError::GraphContract(_)),
9416            &format!("unexpected continuation-family error: {family_error}"),
9417        )?;
9418        let frontier_error = require_db_error(
9419            store.repository_graph_adjacency_page(
9420                &[external.key().clone()],
9421                RepositoryGraphDirection::Outbound,
9422                Some(&continuation),
9423                1,
9424                None,
9425            ),
9426            "cross-frontier adjacency continuation was accepted",
9427        )?;
9428        require(
9429            matches!(frontier_error, DbError::GraphContract(_)),
9430            &format!("unexpected continuation-frontier error: {frontier_error}"),
9431        )?;
9432
9433        let inbound_first = store.repository_graph_adjacency_page(
9434            &inbound_frontier,
9435            RepositoryGraphDirection::Inbound,
9436            None,
9437            1,
9438            None,
9439        )?;
9440        let inbound_continuation = inbound_first
9441            .continuation
9442            .ok_or_else(|| io::Error::other("inbound continuation missing"))?;
9443        let reordered_frontier = vec![external.key().clone(), symbol.key().clone()];
9444        let reordered_error = require_db_error(
9445            store.repository_graph_adjacency_page(
9446                &reordered_frontier,
9447                RepositoryGraphDirection::Inbound,
9448                Some(&inbound_continuation),
9449                1,
9450                None,
9451            ),
9452            "reordered adjacency continuation frontier was accepted",
9453        )?;
9454        require(
9455            matches!(reordered_error, DbError::GraphContract(_)),
9456            &format!("unexpected reordered-frontier error: {reordered_error}"),
9457        )?;
9458        let empty_frontier_error = require_db_error(
9459            store.repository_graph_adjacency_page(
9460                &[],
9461                RepositoryGraphDirection::Inbound,
9462                Some(&inbound_continuation),
9463                1,
9464                None,
9465            ),
9466            "adjacency continuation without a frontier was accepted",
9467        )?;
9468        require(
9469            matches!(empty_frontier_error, DbError::GraphContract(_)),
9470            &format!("unexpected empty-frontier error: {empty_frontier_error}"),
9471        )?;
9472
9473        let mut foreign_project_continuation = continuation.clone();
9474        foreign_project_continuation.project = foreign.key().project();
9475        let continuation_project_error = require_db_error(
9476            store.repository_graph_adjacency_page(
9477                &outbound_frontier,
9478                RepositoryGraphDirection::Outbound,
9479                Some(&foreign_project_continuation),
9480                1,
9481                None,
9482            ),
9483            "cross-project adjacency continuation was accepted",
9484        )?;
9485        require(
9486            matches!(continuation_project_error, DbError::GraphContract(_)),
9487            &format!("unexpected continuation-project error: {continuation_project_error}"),
9488        )?;
9489        let mut stale_generation_continuation = continuation.clone();
9490        stale_generation_continuation.generation = stale_generation_continuation
9491            .generation
9492            .checked_next()
9493            .ok_or_else(|| io::Error::other("fixture generation overflowed"))?;
9494        let continuation_generation_error = require_db_error(
9495            store.repository_graph_adjacency_page(
9496                &outbound_frontier,
9497                RepositoryGraphDirection::Outbound,
9498                Some(&stale_generation_continuation),
9499                1,
9500                None,
9501            ),
9502            "cross-generation adjacency continuation was accepted",
9503        )?;
9504        require(
9505            matches!(continuation_generation_error, DbError::GraphContract(_)),
9506            &format!("unexpected continuation-generation error: {continuation_generation_error}"),
9507        )?;
9508
9509        let mut maximum_frontier = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER);
9510        for index in 0..MAX_REPOSITORY_GRAPH_FRONTIER {
9511            let entity = GraphEntity::new(
9512                fixture.project,
9513                EntitySelector::External {
9514                    external: ExternalSelector {
9515                        system: GraphIdentityText::new("work-envelope")?,
9516                        identity: GraphIdentityText::new(format!("candidate-{index}"))?,
9517                    },
9518                },
9519                continuation.generation,
9520            )?;
9521            maximum_frontier.push(entity.key().clone());
9522        }
9523        let bounded_work = store.repository_graph_adjacency_page(
9524            &maximum_frontier,
9525            RepositoryGraphDirection::Outbound,
9526            None,
9527            38,
9528            None,
9529        )?;
9530        require(
9531            bounded_work.rows.is_empty() && !bounded_work.truncated,
9532            "maximum adjacency frontier rejected work below the intermediate ceiling",
9533        )?;
9534        let excessive_work_error = require_db_error(
9535            store.repository_graph_adjacency_page(
9536                &maximum_frontier,
9537                RepositoryGraphDirection::Outbound,
9538                None,
9539                39,
9540                None,
9541            ),
9542            "adjacency intermediate work above the ceiling was accepted",
9543        )?;
9544        require(
9545            matches!(excessive_work_error, DbError::GraphContract(_)),
9546            &format!("unexpected intermediate-work error: {excessive_work_error}"),
9547        )?;
9548
9549        let successful_cancellation = projectatlas_core::IndexCancellation::new();
9550        let successful_control = IndexWorkControl::new(successful_cancellation.clone(), None);
9551        store.repository_graph_adjacency_page(
9552            &outbound_frontier,
9553            RepositoryGraphDirection::Outbound,
9554            None,
9555            10,
9556            Some(&successful_control),
9557        )?;
9558        successful_cancellation.cancel();
9559        require_eq(
9560            &store
9561                .connection
9562                .query_row("SELECT 1", [], |row| row.get::<_, i64>(0))?,
9563            &1,
9564            "cleared adjacency progress handler",
9565        )?;
9566
9567        let cancellation = projectatlas_core::IndexCancellation::new();
9568        cancellation.cancel();
9569        let control = IndexWorkControl::new(cancellation, None);
9570        let cancelled = store.repository_graph_adjacency_page(
9571            &outbound_frontier,
9572            RepositoryGraphDirection::Outbound,
9573            None,
9574            10,
9575            Some(&control),
9576        );
9577        require(
9578            matches!(
9579                cancelled,
9580                Err(DbError::IndexWork(
9581                    projectatlas_core::IndexWorkFailure::Cancelled {
9582                        stage: IndexWorkStage::RepositoryTraversal
9583                    }
9584                ))
9585            ),
9586            "adjacency cancellation was not typed",
9587        )?;
9588        store.finish_index_read_snapshot()?;
9589        Ok(())
9590    }
9591
9592    #[test]
9593    fn detailed_relation_storage_statements_are_indexed_and_batch_bounded()
9594    -> Result<(), Box<dyn Error>> {
9595        use DetailedRelationTraceStatement::{
9596            ActiveGraphGeneration, AdjacencyRelations, ProjectIdentity, PublicationMetadata,
9597            PurposeOwners, RelationEntities, RelationOccurrences, RelationsByDigest,
9598        };
9599
9600        let temp = tempfile::tempdir()?;
9601        let project_root = temp.path().join("detailed-relation-storage");
9602        let atlas_dir = project_root.join(".projectatlas");
9603        fs::create_dir_all(&atlas_dir)?;
9604        let db_path = atlas_dir.join("projectatlas.db");
9605        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
9606        let fixture =
9607            publish_detailed_relation_trace_fixture(&mut writer, "detailed-relation-storage")?;
9608        drop(writer);
9609        let mut store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
9610        let generation = store
9611            .repository_graph_generation()?
9612            .ok_or_else(|| io::Error::other("graph generation missing"))?;
9613        let budget = maximum_repository_graph_read_budget()?;
9614
9615        assert_cursor_hydration_indexes(&store)?;
9616
9617        let one_frontier = vec![fixture.source.key().clone()];
9618        let many_frontier = std::iter::once(fixture.source.key().clone())
9619            .chain(
9620                fixture
9621                    .relations
9622                    .iter()
9623                    .filter_map(|relation| relation.resolution().resolved_target().cloned())
9624                    .take(3),
9625            )
9626            .collect::<Vec<_>>();
9627        require_eq(
9628            &many_frontier.len(),
9629            &4,
9630            "multi-frontier trace fixture cardinality",
9631        )?;
9632        for (context, frontier) in [
9633            ("one-frontier adjacency", one_frontier),
9634            ("many-frontier adjacency", many_frontier),
9635        ] {
9636            let (page, statements) = trace_statements(&mut store, |store| {
9637                store.repository_graph_adjacency_page_bounded(
9638                    &frontier,
9639                    RepositoryGraphDirection::Outbound,
9640                    None,
9641                    4,
9642                    budget,
9643                    None,
9644                )
9645            })?;
9646            require(
9647                page.page.rows.len() == 4 && page.page.truncated,
9648                &format!("{context} did not exercise its bounded sentinel"),
9649            )?;
9650            require_traced_statement_multiset(
9651                &statements,
9652                &[
9653                    (ProjectIdentity, 1),
9654                    (PublicationMetadata, 1),
9655                    (ActiveGraphGeneration, 1),
9656                    (AdjacencyRelations, 1),
9657                    (RelationEntities, 1),
9658                ],
9659                context,
9660            )?;
9661        }
9662
9663        let relation_digests = fixture
9664            .relations
9665            .iter()
9666            .map(|relation| relation.key().digest_bytes())
9667            .collect::<Result<Vec<_>, _>>()?;
9668        for (context, digests, endpoint_chunks) in [
9669            ("one-relation hydration", &relation_digests[..1], 1),
9670            (
9671                "128 unique endpoint hydration",
9672                &relation_digests[..GRAPH_ENTITY_HYDRATION_CHUNK - 1],
9673                1,
9674            ),
9675            (
9676                "129 unique endpoint hydration",
9677                &relation_digests[..GRAPH_ENTITY_HYDRATION_CHUNK],
9678                2,
9679            ),
9680            ("maximum relation hydration", relation_digests.as_slice(), 3),
9681        ] {
9682            let (relations, statements) = trace_statements(&mut store, |store| {
9683                store.repository_graph_relation_rows_by_digest(
9684                    fixture.project,
9685                    generation,
9686                    digests,
9687                    budget,
9688                    None,
9689                )
9690            })?;
9691            require_eq(
9692                &relations.rows.len(),
9693                &digests.len(),
9694                &format!("{context} returned relation count"),
9695            )?;
9696            require_traced_statement_multiset(
9697                &statements,
9698                &[
9699                    (ProjectIdentity, 1),
9700                    (PublicationMetadata, 1),
9701                    (ActiveGraphGeneration, 1),
9702                    (RelationsByDigest, 1),
9703                    (RelationEntities, endpoint_chunks),
9704                ],
9705                context,
9706            )?;
9707        }
9708
9709        for (context, relations) in [
9710            ("one-relation occurrences", &fixture.relations[..1]),
9711            (
9712                "maximum relation occurrence batch",
9713                fixture.relations.as_slice(),
9714            ),
9715        ] {
9716            let (pages, statements) = trace_statements(&mut store, |store| {
9717                store.repository_graph_occurrence_pages_bounded(relations, 1, budget, None)
9718            })?;
9719            require(
9720                pages.pages.len() == relations.len()
9721                    && pages
9722                        .pages
9723                        .iter()
9724                        .all(|page| page.rows.len() == 1 && page.truncated),
9725                &format!("{context} did not retain one row plus its occurrence sentinel"),
9726            )?;
9727            require_traced_statement_multiset(
9728                &statements,
9729                &[
9730                    (ProjectIdentity, 1),
9731                    (PublicationMetadata, 1),
9732                    (ActiveGraphGeneration, 1),
9733                    (RelationOccurrences, 1),
9734                ],
9735                context,
9736            )?;
9737        }
9738        let mut oversized_occurrence_batch = fixture.relations.clone();
9739        oversized_occurrence_batch.push(fixture.relations[0].clone());
9740        let oversized_occurrences = require_db_error(
9741            store.repository_graph_occurrence_pages_bounded(
9742                &oversized_occurrence_batch,
9743                1,
9744                budget,
9745                None,
9746            ),
9747            "oversized relation occurrence batch was accepted",
9748        )?;
9749        require(
9750            matches!(oversized_occurrences, DbError::GraphContract(_)),
9751            &format!("unexpected oversized occurrence error: {oversized_occurrences}"),
9752        )?;
9753
9754        let one_purpose_path = vec![".".to_string()];
9755        let one_purpose_chunk = std::iter::once(".".to_string())
9756            .chain(
9757                (1..crate::MAX_PURPOSE_CURATION_BATCH_ROWS)
9758                    .map(|index| format!("missing/purpose-{index}.rs")),
9759            )
9760            .collect::<Vec<_>>();
9761        let two_purpose_chunks = one_purpose_chunk
9762            .iter()
9763            .cloned()
9764            .chain(std::iter::once("missing/purpose-overflow.rs".to_string()))
9765            .collect::<Vec<_>>();
9766        for (context, paths, expected) in [
9767            ("one purpose owner", one_purpose_path.as_slice(), 1),
9768            (
9769                "one full purpose-owner chunk",
9770                one_purpose_chunk.as_slice(),
9771                1,
9772            ),
9773            ("two purpose-owner chunks", two_purpose_chunks.as_slice(), 2),
9774        ] {
9775            let (_purposes, statements) = trace_statements(&mut store, |store| {
9776                store.load_purpose_owner_nodes_by_paths_controlled(
9777                    fixture.project,
9778                    generation,
9779                    paths,
9780                    budget,
9781                    None,
9782                )
9783            })?;
9784            require_traced_statement_multiset(
9785                &statements,
9786                &[
9787                    (ProjectIdentity, 1),
9788                    (PublicationMetadata, 1),
9789                    (ActiveGraphGeneration, 1),
9790                    (PurposeOwners, expected),
9791                ],
9792                context,
9793            )?;
9794        }
9795
9796        store.finish_index_read_snapshot()?;
9797        Ok(())
9798    }
9799
9800    #[test]
9801    fn cursor_hydration_is_ordered_bounded_and_fail_closed() -> Result<(), Box<dyn Error>> {
9802        let temp = tempfile::tempdir()?;
9803        let project_root = temp.path().join("cursor-hydration");
9804        let atlas_dir = project_root.join(".projectatlas");
9805        fs::create_dir_all(&atlas_dir)?;
9806        let db_path = atlas_dir.join("projectatlas.db");
9807        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
9808        let fixture = publish_fixture(&mut writer, "cursor-hydration")?;
9809        writer.connection.execute(
9810            "INSERT INTO graph_relation_occurrences(
9811                 relation_key, file_path, start_line, start_column, end_line, end_column
9812             ) VALUES(?1, 'Cargo.toml', 1, 0, 1, 1)",
9813            params![&fixture.relations[0].key().digest_bytes()?[..]],
9814        )?;
9815        writer.connection.execute(
9816            "INSERT INTO graph_coverage(
9817                 project_instance_id, scope_kind, scope_path, relation_scope,
9818                 relation_kind, state, total, covered, omitted, reason, reached_limit
9819             ) VALUES(?1, 'path', 'Cargo.toml', NULL, NULL,
9820                      'complete', 1, 1, 0, NULL, NULL)",
9821            params![&fixture.project.as_bytes()[..]],
9822        )?;
9823        drop(writer);
9824        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
9825        let generation = store
9826            .repository_graph_generation()?
9827            .ok_or_else(|| io::Error::other("graph generation missing"))?;
9828        let full_budget = RepositoryGraphReadBudget::new(
9829            MAX_REPOSITORY_GRAPH_FRONTIER as u32,
9830            MAX_REPOSITORY_GRAPH_FRONTIER as u32,
9831            RepositoryGraphReadBudget::MAX_DECODED_BYTES,
9832            RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES,
9833            RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
9834        )?;
9835
9836        let expected_entities = fixture
9837            .entities
9838            .iter()
9839            .rev()
9840            .take(3)
9841            .cloned()
9842            .collect::<Vec<_>>();
9843        let entity_digests = expected_entities
9844            .iter()
9845            .map(|entity| entity.key().digest_bytes())
9846            .collect::<Result<Vec<_>, _>>()?;
9847        let entity_batch = store.repository_graph_entities_by_digest(
9848            fixture.project,
9849            generation,
9850            &entity_digests,
9851            full_budget,
9852            None,
9853        )?;
9854        require_eq(
9855            &entity_batch.rows,
9856            &expected_entities,
9857            "ordered entity cursor hydration",
9858        )?;
9859        require_eq(
9860            &entity_batch.work,
9861            &RepositoryGraphReadWork {
9862                requested_rows: 3,
9863                returned_rows: 3,
9864                decoded_bytes: entity_batch.work.decoded_bytes,
9865                hydrated_entities: 3,
9866                hydrated_paths: 2,
9867            },
9868            "exact entity cursor work",
9869        )?;
9870        require(
9871            entity_batch.work.decoded_bytes > 0,
9872            "entity cursor decoded no SQLite payload bytes",
9873        )?;
9874
9875        let file_entity = fixture
9876            .entities
9877            .iter()
9878            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
9879            .ok_or_else(|| io::Error::other("file anchor fixture missing"))?;
9880        let file_anchor = store.repository_graph_entity_bounded(
9881            file_entity.key(),
9882            generation,
9883            full_budget,
9884            None,
9885        )?;
9886        require_eq(
9887            &file_anchor.rows,
9888            &vec![file_entity.clone()],
9889            "exact file anchor hydration",
9890        )?;
9891        require_eq(
9892            &file_anchor.work,
9893            &RepositoryGraphReadWork {
9894                requested_rows: 1,
9895                returned_rows: 1,
9896                decoded_bytes: file_anchor.work.decoded_bytes,
9897                hydrated_entities: 1,
9898                hydrated_paths: 1,
9899            },
9900            "exact file anchor work",
9901        )?;
9902        let exact_file_budget =
9903            RepositoryGraphReadBudget::new(1, 1, file_anchor.work.decoded_bytes, 1, 1)?;
9904        require_eq(
9905            &store.repository_graph_entity_bounded(
9906                file_entity.key(),
9907                generation,
9908                exact_file_budget,
9909                None,
9910            )?,
9911            &file_anchor,
9912            "exact file anchor envelope",
9913        )?;
9914        require_eq(
9915            &store.repository_graph_entity(file_entity.key())?,
9916            &Some(file_entity.clone()),
9917            "legacy file anchor wrapper compatibility",
9918        )?;
9919        let file_decode_overrun = require_db_error(
9920            store.repository_graph_entity_bounded(
9921                file_entity.key(),
9922                generation,
9923                RepositoryGraphReadBudget::new(1, 1, file_anchor.work.decoded_bytes - 1, 1, 1)?,
9924                None,
9925            ),
9926            "file anchor decoded-byte overrun was accepted",
9927        )?;
9928        require(
9929            matches!(file_decode_overrun, DbError::GraphContract(_)),
9930            &format!("unexpected file anchor envelope error: {file_decode_overrun}"),
9931        )?;
9932        let missing_file_selector = EntitySelector::File {
9933            path: RepositoryFilePath::new(Path::new("missing-anchor.rs"))?,
9934        };
9935        let missing_file_key = GraphEntityKey::new(fixture.project, &missing_file_selector);
9936        let missing_file = store.repository_graph_entity_bounded(
9937            &missing_file_key,
9938            generation,
9939            full_budget,
9940            None,
9941        )?;
9942        require(
9943            missing_file.rows.is_empty()
9944                && missing_file.work
9945                    == (RepositoryGraphReadWork {
9946                        requested_rows: 1,
9947                        returned_rows: 0,
9948                        decoded_bytes: 0,
9949                        hydrated_entities: 0,
9950                        hydrated_paths: 0,
9951                    }),
9952            "missing file anchor did not return exact empty work",
9953        )?;
9954
9955        let anchor_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
9956        let path_anchors = store.repository_graph_entities_by_path_bounded(
9957            fixture.project,
9958            generation,
9959            &anchor_path,
9960            1,
9961            full_budget,
9962            None,
9963        )?;
9964        require(
9965            path_anchors.page.rows.len() == 1
9966                && path_anchors.page.truncated
9967                && matches!(
9968                    path_anchors.page.rows[0].selector(),
9969                    EntitySelector::File { .. }
9970                )
9971                && path_anchors.work.requested_rows == 1
9972                && path_anchors.work.returned_rows == 1
9973                && path_anchors.work.decoded_bytes > 0
9974                && path_anchors.work.hydrated_entities == 2
9975                && path_anchors.work.hydrated_paths == 1,
9976            "path anchor page lost stable order or sentinel work",
9977        )?;
9978        let exact_path_anchor_budget =
9979            RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes, 2, 1)?;
9980        require_eq(
9981            &store.repository_graph_entities_by_path_bounded(
9982                fixture.project,
9983                generation,
9984                &anchor_path,
9985                1,
9986                exact_path_anchor_budget,
9987                None,
9988            )?,
9989            &path_anchors,
9990            "exact path anchor envelope",
9991        )?;
9992        require_eq(
9993            &store.repository_graph_entities_by_path(fixture.project, &anchor_path, 1)?,
9994            &path_anchors.page,
9995            "legacy path anchor wrapper compatibility",
9996        )?;
9997        for (budget, limit, context) in [
9998            (
9999                RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes - 1, 2, 1)?,
10000                1,
10001                "path anchor decoded-byte overrun was accepted",
10002            ),
10003            (
10004                RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes, 1, 1)?,
10005                1,
10006                "path anchor sentinel entity overrun was accepted",
10007            ),
10008            (
10009                exact_path_anchor_budget,
10010                2,
10011                "path anchor returned-row overrun was accepted",
10012            ),
10013        ] {
10014            let error = require_db_error(
10015                store.repository_graph_entities_by_path_bounded(
10016                    fixture.project,
10017                    generation,
10018                    &anchor_path,
10019                    limit,
10020                    budget,
10021                    None,
10022                ),
10023                context,
10024            )?;
10025            require(
10026                matches!(error, DbError::GraphContract(_)),
10027                &format!("unexpected path anchor envelope error: {error}"),
10028            )?;
10029        }
10030        let anchor_cancellation = projectatlas_core::IndexCancellation::new();
10031        anchor_cancellation.cancel();
10032        let anchor_control = IndexWorkControl::new(anchor_cancellation, None);
10033        let cancelled_anchor = store.repository_graph_entities_by_path_bounded(
10034            fixture.project,
10035            generation,
10036            &anchor_path,
10037            1,
10038            full_budget,
10039            Some(&anchor_control),
10040        );
10041        require(
10042            matches!(
10043                cancelled_anchor,
10044                Err(DbError::IndexWork(
10045                    projectatlas_core::IndexWorkFailure::Cancelled {
10046                        stage: IndexWorkStage::RepositoryTraversal
10047                    }
10048                ))
10049            ),
10050            "path anchor cancellation was not typed",
10051        )?;
10052        let expired_anchor = IndexWorkControl::with_deadline(
10053            projectatlas_core::IndexCancellation::new(),
10054            Instant::now(),
10055        );
10056        let anchor_deadline = store.repository_graph_entity_bounded(
10057            file_entity.key(),
10058            generation,
10059            full_budget,
10060            Some(&expired_anchor),
10061        );
10062        require(
10063            matches!(
10064                anchor_deadline,
10065                Err(DbError::IndexWork(
10066                    projectatlas_core::IndexWorkFailure::DeadlineExceeded {
10067                        stage: IndexWorkStage::RepositoryTraversal
10068                    }
10069                ))
10070            ),
10071            "file anchor deadline was not typed",
10072        )?;
10073
10074        let expected_relations = fixture.relations.iter().rev().cloned().collect::<Vec<_>>();
10075        let relation_digests = expected_relations
10076            .iter()
10077            .map(|relation| relation.key().digest_bytes())
10078            .collect::<Result<Vec<_>, _>>()?;
10079        let relation_rows = store.repository_graph_relation_rows_by_digest(
10080            fixture.project,
10081            generation,
10082            &relation_digests,
10083            full_budget,
10084            None,
10085        )?;
10086        require_eq(
10087            &relation_rows
10088                .rows
10089                .iter()
10090                .map(|row| row.relation.clone())
10091                .collect::<Vec<_>>(),
10092            &expected_relations,
10093            "ordered relation cursor hydration",
10094        )?;
10095        require(
10096            relation_rows.rows.iter().all(|row| {
10097                row.source.key().project() == fixture.project
10098                    && row
10099                        .target
10100                        .as_ref()
10101                        .is_none_or(|target| target.key().project() == fixture.project)
10102            }),
10103            "relation cursor hydration returned an unvalidated endpoint",
10104        )?;
10105        require_eq(
10106            &relation_rows.work,
10107            &RepositoryGraphReadWork {
10108                requested_rows: 4,
10109                returned_rows: 4,
10110                decoded_bytes: relation_rows.work.decoded_bytes,
10111                hydrated_entities: 3,
10112                hydrated_paths: 1,
10113            },
10114            "exact relation cursor work",
10115        )?;
10116        require(
10117            relation_rows.work.decoded_bytes > entity_batch.work.decoded_bytes,
10118            "relation cursor did not meter relation and endpoint payload bytes",
10119        )?;
10120
10121        let exact_entity_budget =
10122            RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes, 3, 2)?;
10123        require_eq(
10124            &store
10125                .repository_graph_entities_by_digest(
10126                    fixture.project,
10127                    generation,
10128                    &entity_digests,
10129                    exact_entity_budget,
10130                    None,
10131                )?
10132                .work,
10133            &entity_batch.work,
10134            "exact entity envelope",
10135        )?;
10136        let exact_relation_budget =
10137            RepositoryGraphReadBudget::new(4, 4, relation_rows.work.decoded_bytes, 3, 1)?;
10138        require_eq(
10139            &store
10140                .repository_graph_relation_rows_by_digest(
10141                    fixture.project,
10142                    generation,
10143                    &relation_digests,
10144                    exact_relation_budget,
10145                    None,
10146                )?
10147                .work,
10148            &relation_rows.work,
10149            "exact relation envelope",
10150        )?;
10151
10152        for (budget, context) in [
10153            (
10154                RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes - 1, 3, 2)?,
10155                "decoded-byte envelope overrun was accepted",
10156            ),
10157            (
10158                RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes, 3, 1)?,
10159                "purpose-path envelope overrun was accepted",
10160            ),
10161        ] {
10162            let error = require_db_error(
10163                store.repository_graph_entities_by_digest(
10164                    fixture.project,
10165                    generation,
10166                    &entity_digests,
10167                    budget,
10168                    None,
10169                ),
10170                context,
10171            )?;
10172            require(
10173                matches!(error, DbError::GraphContract(_)),
10174                &format!("unexpected entity envelope error: {error}"),
10175            )?;
10176        }
10177        let endpoint_budget =
10178            RepositoryGraphReadBudget::new(4, 4, relation_rows.work.decoded_bytes, 2, 1)?;
10179        let endpoint_error = require_db_error(
10180            store.repository_graph_relation_rows_by_digest(
10181                fixture.project,
10182                generation,
10183                &relation_digests,
10184                endpoint_budget,
10185                None,
10186            ),
10187            "endpoint entity envelope overrun was accepted",
10188        )?;
10189        require(
10190            matches!(endpoint_error, DbError::GraphContract(_)),
10191            &format!("unexpected endpoint envelope error: {endpoint_error}"),
10192        )?;
10193
10194        for (budget, context) in [
10195            (
10196                RepositoryGraphReadBudget::new(
10197                    2,
10198                    3,
10199                    RepositoryGraphReadBudget::MAX_DECODED_BYTES,
10200                    3,
10201                    2,
10202                )?,
10203                "requested-row envelope overrun was accepted",
10204            ),
10205            (
10206                RepositoryGraphReadBudget::new(
10207                    3,
10208                    2,
10209                    RepositoryGraphReadBudget::MAX_DECODED_BYTES,
10210                    3,
10211                    2,
10212                )?,
10213                "returned-row envelope overrun was accepted",
10214            ),
10215        ] {
10216            let error = require_db_error(
10217                store.repository_graph_entities_by_digest(
10218                    fixture.project,
10219                    generation,
10220                    &entity_digests,
10221                    budget,
10222                    None,
10223                ),
10224                context,
10225            )?;
10226            require(
10227                matches!(error, DbError::GraphContract(_)),
10228                &format!("unexpected row-envelope error: {error}"),
10229            )?;
10230        }
10231
10232        for invalid in [
10233            RepositoryGraphReadBudget::new(0, 1, 1, 1, 1),
10234            RepositoryGraphReadBudget::new(
10235                RepositoryGraphReadBudget::MAX_REQUESTED_ROWS + 1,
10236                1,
10237                1,
10238                1,
10239                1,
10240            ),
10241            RepositoryGraphReadBudget::new(
10242                1,
10243                RepositoryGraphReadBudget::MAX_RETURNED_ROWS + 1,
10244                1,
10245                1,
10246                1,
10247            ),
10248            RepositoryGraphReadBudget::new(
10249                1,
10250                1,
10251                RepositoryGraphReadBudget::MAX_DECODED_BYTES + 1,
10252                1,
10253                1,
10254            ),
10255            RepositoryGraphReadBudget::new(
10256                1,
10257                1,
10258                1,
10259                RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES + 1,
10260                1,
10261            ),
10262            RepositoryGraphReadBudget::new(
10263                1,
10264                1,
10265                1,
10266                1,
10267                RepositoryGraphReadBudget::MAX_HYDRATED_PATHS + 1,
10268            ),
10269        ] {
10270            require(
10271                matches!(invalid, Err(GraphContractError::InvalidLimits { .. })),
10272                "invalid graph read budget was accepted",
10273            )?;
10274        }
10275
10276        let empty_entities = store.repository_graph_entities_by_digest(
10277            fixture.project,
10278            generation,
10279            &[],
10280            full_budget,
10281            None,
10282        )?;
10283        let empty_relations = store.repository_graph_relation_rows_by_digest(
10284            fixture.project,
10285            generation,
10286            &[],
10287            full_budget,
10288            None,
10289        )?;
10290        require(
10291            empty_entities.rows.is_empty()
10292                && empty_relations.rows.is_empty()
10293                && empty_entities.work
10294                    == (RepositoryGraphReadWork {
10295                        requested_rows: 0,
10296                        returned_rows: 0,
10297                        decoded_bytes: 0,
10298                        hydrated_entities: 0,
10299                        hydrated_paths: 0,
10300                    })
10301                && empty_relations.work == empty_entities.work,
10302            "empty cursor hydration was not stable",
10303        )?;
10304
10305        let purpose_paths = vec![
10306            "Cargo.toml".to_string(),
10307            ".".to_string(),
10308            "src/Äuth.rs".to_string(),
10309            "src".to_string(),
10310        ];
10311        let purpose_batch = store.load_purpose_owner_nodes_by_paths_controlled(
10312            fixture.project,
10313            generation,
10314            &purpose_paths,
10315            full_budget,
10316            None,
10317        )?;
10318        require_eq(
10319            &purpose_batch
10320                .rows
10321                .iter()
10322                .map(|node| node.node.path.clone())
10323                .collect::<Vec<_>>(),
10324            &purpose_paths,
10325            "ordered purpose-owner hydration",
10326        )?;
10327        require_eq(
10328            &purpose_batch.work,
10329            &RepositoryGraphReadWork {
10330                requested_rows: 4,
10331                returned_rows: 4,
10332                decoded_bytes: purpose_batch.work.decoded_bytes,
10333                hydrated_entities: 0,
10334                hydrated_paths: 4,
10335            },
10336            "exact purpose-owner work",
10337        )?;
10338        require(
10339            purpose_batch.work.decoded_bytes > 0,
10340            "purpose-owner hydration decoded no SQLite payload bytes",
10341        )?;
10342        let exact_purpose_budget =
10343            RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes, 1, 4)?;
10344        require_eq(
10345            &store
10346                .load_purpose_owner_nodes_by_paths_controlled(
10347                    fixture.project,
10348                    generation,
10349                    &purpose_paths,
10350                    exact_purpose_budget,
10351                    None,
10352                )?
10353                .work,
10354            &purpose_batch.work,
10355            "exact purpose-owner envelope",
10356        )?;
10357        for (budget, context) in [
10358            (
10359                RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes - 1, 1, 4)?,
10360                "purpose-owner decoded-byte overrun was accepted",
10361            ),
10362            (
10363                RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes, 1, 3)?,
10364                "purpose-owner path overrun was accepted",
10365            ),
10366        ] {
10367            let error = require_db_error(
10368                store.load_purpose_owner_nodes_by_paths_controlled(
10369                    fixture.project,
10370                    generation,
10371                    &purpose_paths,
10372                    budget,
10373                    None,
10374                ),
10375                context,
10376            )?;
10377            require(
10378                matches!(error, DbError::GraphContract(_)),
10379                &format!("unexpected purpose-owner envelope error: {error}"),
10380            )?;
10381        }
10382        let mut missing_purpose_paths = purpose_paths.clone();
10383        missing_purpose_paths.push("missing/purpose-owner.rs".to_string());
10384        let missing_purpose = store.load_purpose_owner_nodes_by_paths_controlled(
10385            fixture.project,
10386            generation,
10387            &missing_purpose_paths,
10388            full_budget,
10389            None,
10390        )?;
10391        require_eq(
10392            &missing_purpose
10393                .rows
10394                .iter()
10395                .map(|node| node.node.path.clone())
10396                .collect::<Vec<_>>(),
10397            &purpose_paths,
10398            "absent purpose-owner candidate ordering",
10399        )?;
10400        require(
10401            missing_purpose.work.requested_rows == 5
10402                && missing_purpose.work.returned_rows == 4
10403                && missing_purpose.work.hydrated_paths == 4,
10404            "absent purpose-owner candidate work was not exact",
10405        )?;
10406        let duplicate_purpose = require_db_error(
10407            store.load_purpose_owner_nodes_by_paths_controlled(
10408                fixture.project,
10409                generation,
10410                &[purpose_paths[0].clone(), purpose_paths[0].clone()],
10411                full_budget,
10412                None,
10413            ),
10414            "duplicate purpose-owner paths were accepted",
10415        )?;
10416        require(
10417            matches!(duplicate_purpose, DbError::GraphContract(_)),
10418            &format!("unexpected duplicate purpose-owner error: {duplicate_purpose}"),
10419        )?;
10420        let purpose_cancellation = projectatlas_core::IndexCancellation::new();
10421        purpose_cancellation.cancel();
10422        let purpose_control = IndexWorkControl::new(purpose_cancellation, None);
10423        let cancelled_purpose = store.load_purpose_owner_nodes_by_paths_controlled(
10424            fixture.project,
10425            generation,
10426            &purpose_paths,
10427            full_budget,
10428            Some(&purpose_control),
10429        );
10430        require(
10431            matches!(
10432                cancelled_purpose,
10433                Err(DbError::IndexWork(
10434                    projectatlas_core::IndexWorkFailure::Cancelled {
10435                        stage: IndexWorkStage::RepositoryTraversal
10436                    }
10437                ))
10438            ),
10439            "purpose-owner cancellation was not typed",
10440        )?;
10441
10442        let occurrence_batch = store.repository_graph_occurrence_pages_bounded(
10443            &fixture.relations,
10444            1,
10445            full_budget,
10446            None,
10447        )?;
10448        require(
10449            occurrence_batch.pages.len() == fixture.relations.len()
10450                && occurrence_batch.pages[0].rows.len() == 1
10451                && occurrence_batch.pages[0].truncated
10452                && occurrence_batch.pages[1..]
10453                    .iter()
10454                    .all(|page| page.rows.is_empty() && !page.truncated),
10455            "batched occurrence pages lost owner order or truncation state",
10456        )?;
10457        require_eq(
10458            &occurrence_batch.work,
10459            &RepositoryGraphReadWork {
10460                requested_rows: 4,
10461                returned_rows: 1,
10462                decoded_bytes: occurrence_batch.work.decoded_bytes,
10463                hydrated_entities: 0,
10464                hydrated_paths: 2,
10465            },
10466            "exact occurrence batch work including sentinel path",
10467        )?;
10468        let exact_occurrence_budget =
10469            RepositoryGraphReadBudget::new(4, 1, occurrence_batch.work.decoded_bytes, 1, 2)?;
10470        require_eq(
10471            &store.repository_graph_occurrence_pages_bounded(
10472                &fixture.relations,
10473                1,
10474                exact_occurrence_budget,
10475                None,
10476            )?,
10477            &occurrence_batch,
10478            "exact occurrence envelope",
10479        )?;
10480        require_eq(
10481            &store.repository_graph_occurrence_pages(&fixture.relations, 1, None)?,
10482            &occurrence_batch.pages,
10483            "legacy occurrence wrapper compatibility",
10484        )?;
10485        for (budget, limit, context) in [
10486            (
10487                RepositoryGraphReadBudget::new(
10488                    4,
10489                    1,
10490                    occurrence_batch.work.decoded_bytes - 1,
10491                    1,
10492                    2,
10493                )?,
10494                1,
10495                "occurrence decoded-byte overrun was accepted",
10496            ),
10497            (
10498                RepositoryGraphReadBudget::new(4, 1, occurrence_batch.work.decoded_bytes, 1, 1)?,
10499                1,
10500                "occurrence sentinel path overrun was accepted",
10501            ),
10502            (
10503                RepositoryGraphReadBudget::new(
10504                    4,
10505                    1,
10506                    RepositoryGraphReadBudget::MAX_DECODED_BYTES,
10507                    1,
10508                    RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
10509                )?,
10510                3,
10511                "occurrence returned-row overrun was accepted",
10512            ),
10513        ] {
10514            let error = require_db_error(
10515                store.repository_graph_occurrence_pages_bounded(
10516                    &fixture.relations,
10517                    limit,
10518                    budget,
10519                    None,
10520                ),
10521                context,
10522            )?;
10523            require(
10524                matches!(error, DbError::GraphContract(_)),
10525                &format!("unexpected occurrence envelope error: {error}"),
10526            )?;
10527        }
10528        let occurrence_cancellation = projectatlas_core::IndexCancellation::new();
10529        occurrence_cancellation.cancel();
10530        let occurrence_control = IndexWorkControl::new(occurrence_cancellation, None);
10531        let cancelled_occurrences = store.repository_graph_occurrence_pages_bounded(
10532            &fixture.relations,
10533            1,
10534            full_budget,
10535            Some(&occurrence_control),
10536        );
10537        require(
10538            matches!(
10539                cancelled_occurrences,
10540                Err(DbError::IndexWork(
10541                    projectatlas_core::IndexWorkFailure::Cancelled {
10542                        stage: IndexWorkStage::RepositoryTraversal
10543                    }
10544                ))
10545            ),
10546            "occurrence batch cancellation was not typed",
10547        )?;
10548        let expired_occurrences = IndexWorkControl::with_deadline(
10549            projectatlas_core::IndexCancellation::new(),
10550            Instant::now(),
10551        );
10552        let occurrence_deadline = store.repository_graph_occurrence_pages_bounded(
10553            &fixture.relations,
10554            1,
10555            full_budget,
10556            Some(&expired_occurrences),
10557        );
10558        require(
10559            matches!(
10560                occurrence_deadline,
10561                Err(DbError::IndexWork(
10562                    projectatlas_core::IndexWorkFailure::DeadlineExceeded {
10563                        stage: IndexWorkStage::RepositoryTraversal
10564                    }
10565                ))
10566            ),
10567            "occurrence batch deadline was not typed",
10568        )?;
10569
10570        let coverage_paths = vec![
10571            RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
10572            RepositoryNodePath::new(Path::new("Cargo.toml"))?,
10573        ];
10574        let coverage_batch = store.repository_graph_path_coverage_bounded(
10575            fixture.project,
10576            generation,
10577            &coverage_paths,
10578            full_budget,
10579            None,
10580        )?;
10581        require_eq(
10582            &coverage_batch
10583                .page
10584                .rows
10585                .iter()
10586                .map(|coverage| match coverage.scope() {
10587                    CoverageScope::Path { path } => path.as_str().to_string(),
10588                    CoverageScope::Project => "project".to_string(),
10589                })
10590                .collect::<Vec<_>>(),
10591            &vec!["Cargo.toml".to_string(), "src/Äuth.rs".to_string()],
10592            "stable path coverage order",
10593        )?;
10594        require(
10595            !coverage_batch.page.truncated
10596                && coverage_batch.work
10597                    == (RepositoryGraphReadWork {
10598                        requested_rows: 2,
10599                        returned_rows: 2,
10600                        decoded_bytes: coverage_batch.work.decoded_bytes,
10601                        hydrated_entities: 0,
10602                        hydrated_paths: 2,
10603                    })
10604                && coverage_batch.work.decoded_bytes > 0,
10605            "exact coverage batch work was incomplete",
10606        )?;
10607        let exact_coverage_budget =
10608            RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes, 1, 2)?;
10609        require_eq(
10610            &store.repository_graph_path_coverage_bounded(
10611                fixture.project,
10612                generation,
10613                &coverage_paths,
10614                exact_coverage_budget,
10615                None,
10616            )?,
10617            &coverage_batch,
10618            "exact coverage envelope",
10619        )?;
10620        require_eq(
10621            &store.repository_graph_path_coverage(fixture.project, &coverage_paths, None)?,
10622            &coverage_batch.page,
10623            "legacy coverage wrapper compatibility",
10624        )?;
10625        for (budget, context) in [
10626            (
10627                RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes - 1, 1, 2)?,
10628                "coverage decoded-byte overrun was accepted",
10629            ),
10630            (
10631                RepositoryGraphReadBudget::new(2, 1, coverage_batch.work.decoded_bytes, 1, 2)?,
10632                "coverage returned-row overrun was accepted",
10633            ),
10634            (
10635                RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes, 1, 1)?,
10636                "coverage hydrated-path overrun was accepted",
10637            ),
10638        ] {
10639            let error = require_db_error(
10640                store.repository_graph_path_coverage_bounded(
10641                    fixture.project,
10642                    generation,
10643                    &coverage_paths,
10644                    budget,
10645                    None,
10646                ),
10647                context,
10648            )?;
10649            require(
10650                matches!(error, DbError::GraphContract(_)),
10651                &format!("unexpected coverage envelope error: {error}"),
10652            )?;
10653        }
10654        let coverage_cancellation = projectatlas_core::IndexCancellation::new();
10655        coverage_cancellation.cancel();
10656        let coverage_control = IndexWorkControl::new(coverage_cancellation, None);
10657        let cancelled_coverage = store.repository_graph_path_coverage_bounded(
10658            fixture.project,
10659            generation,
10660            &coverage_paths,
10661            full_budget,
10662            Some(&coverage_control),
10663        );
10664        require(
10665            matches!(
10666                cancelled_coverage,
10667                Err(DbError::IndexWork(
10668                    projectatlas_core::IndexWorkFailure::Cancelled {
10669                        stage: IndexWorkStage::RepositoryTraversal
10670                    }
10671                ))
10672            ),
10673            "coverage batch cancellation was not typed",
10674        )?;
10675        let expired_coverage = IndexWorkControl::with_deadline(
10676            projectatlas_core::IndexCancellation::new(),
10677            Instant::now(),
10678        );
10679        let coverage_deadline = store.repository_graph_path_coverage_bounded(
10680            fixture.project,
10681            generation,
10682            &coverage_paths,
10683            full_budget,
10684            Some(&expired_coverage),
10685        );
10686        require(
10687            matches!(
10688                coverage_deadline,
10689                Err(DbError::IndexWork(
10690                    projectatlas_core::IndexWorkFailure::DeadlineExceeded {
10691                        stage: IndexWorkStage::RepositoryTraversal
10692                    }
10693                ))
10694            ),
10695            "coverage batch deadline was not typed",
10696        )?;
10697
10698        let missing = [0xff; 32];
10699        let missing_entity = require_db_error(
10700            store.repository_graph_entities_by_digest(
10701                fixture.project,
10702                generation,
10703                &[entity_digests[0], missing],
10704                full_budget,
10705                None,
10706            ),
10707            "missing entity cursor key returned a partial set",
10708        )?;
10709        require(
10710            matches!(
10711                missing_entity,
10712                DbError::GraphRowShape {
10713                    table: "graph_entities",
10714                    ..
10715                }
10716            ),
10717            &format!("unexpected missing-entity error: {missing_entity}"),
10718        )?;
10719        let missing_relation = require_db_error(
10720            store.repository_graph_relation_rows_by_digest(
10721                fixture.project,
10722                generation,
10723                &[relation_digests[0], missing],
10724                full_budget,
10725                None,
10726            ),
10727            "missing relation cursor key returned a partial set",
10728        )?;
10729        require(
10730            matches!(
10731                missing_relation,
10732                DbError::GraphRowShape {
10733                    table: "graph_relations",
10734                    ..
10735                }
10736            ),
10737            &format!("unexpected missing-relation error: {missing_relation}"),
10738        )?;
10739
10740        let duplicate = require_db_error(
10741            store.repository_graph_entities_by_digest(
10742                fixture.project,
10743                generation,
10744                &[entity_digests[0], entity_digests[0]],
10745                full_budget,
10746                None,
10747            ),
10748            "duplicate entity cursor keys were accepted",
10749        )?;
10750        require(
10751            matches!(duplicate, DbError::GraphContract(_)),
10752            &format!("unexpected duplicate hydration error: {duplicate}"),
10753        )?;
10754        let oversized = vec![[0; 32]; MAX_REPOSITORY_GRAPH_FRONTIER + 1];
10755        let oversized = require_db_error(
10756            store.repository_graph_relation_rows_by_digest(
10757                fixture.project,
10758                generation,
10759                &oversized,
10760                full_budget,
10761                None,
10762            ),
10763            "oversized relation cursor key set was accepted",
10764        )?;
10765        require(
10766            matches!(oversized, DbError::GraphContract(_)),
10767            &format!("unexpected oversized hydration error: {oversized}"),
10768        )?;
10769
10770        let foreign_project = ProjectInstanceId::from_bytes([0x7f; 16])?;
10771        let foreign = require_db_error(
10772            store.repository_graph_entities_by_digest(
10773                foreign_project,
10774                generation,
10775                &entity_digests,
10776                full_budget,
10777                None,
10778            ),
10779            "cross-project entity cursor hydration was accepted",
10780        )?;
10781        require(
10782            matches!(foreign, DbError::GraphProjectIdentityMismatch { .. }),
10783            &format!("unexpected cross-project hydration error: {foreign}"),
10784        )?;
10785        let stale_generation = generation
10786            .checked_next()
10787            .ok_or_else(|| io::Error::other("fixture generation overflowed"))?;
10788        let stale = require_db_error(
10789            store.repository_graph_relation_rows_by_digest(
10790                fixture.project,
10791                stale_generation,
10792                &relation_digests,
10793                full_budget,
10794                None,
10795            ),
10796            "stale-generation relation cursor hydration was accepted",
10797        )?;
10798        require(
10799            matches!(stale, DbError::GraphContract(_)),
10800            &format!("unexpected stale-generation hydration error: {stale}"),
10801        )?;
10802        for entities in [true, false] {
10803            let cancellation = projectatlas_core::IndexCancellation::new();
10804            cancellation.cancel();
10805            let control = IndexWorkControl::new(cancellation, None);
10806            let cancelled = if entities {
10807                store
10808                    .repository_graph_entities_by_digest(
10809                        fixture.project,
10810                        generation,
10811                        &entity_digests,
10812                        full_budget,
10813                        Some(&control),
10814                    )
10815                    .map(|_| ())
10816            } else {
10817                store
10818                    .repository_graph_relation_rows_by_digest(
10819                        fixture.project,
10820                        generation,
10821                        &relation_digests,
10822                        full_budget,
10823                        Some(&control),
10824                    )
10825                    .map(|_| ())
10826            };
10827            require(
10828                matches!(
10829                    cancelled,
10830                    Err(DbError::IndexWork(
10831                        projectatlas_core::IndexWorkFailure::Cancelled {
10832                            stage: IndexWorkStage::RepositoryTraversal
10833                        }
10834                    ))
10835                ),
10836                "cursor hydration cancellation was not typed",
10837            )?;
10838        }
10839        let expired = IndexWorkControl::with_deadline(
10840            projectatlas_core::IndexCancellation::new(),
10841            Instant::now(),
10842        );
10843        let deadline = store.repository_graph_entities_by_digest(
10844            fixture.project,
10845            generation,
10846            &entity_digests,
10847            full_budget,
10848            Some(&expired),
10849        );
10850        require(
10851            matches!(
10852                deadline,
10853                Err(DbError::IndexWork(
10854                    projectatlas_core::IndexWorkFailure::DeadlineExceeded {
10855                        stage: IndexWorkStage::RepositoryTraversal
10856                    }
10857                ))
10858            ),
10859            "cursor hydration deadline was not typed",
10860        )?;
10861
10862        let source = fixture
10863            .entities
10864            .iter()
10865            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
10866            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
10867        let adjacency = store.repository_graph_adjacency_page_bounded(
10868            &[source.key().clone()],
10869            RepositoryGraphDirection::Outbound,
10870            None,
10871            1,
10872            full_budget,
10873            None,
10874        )?;
10875        require(
10876            adjacency.page.rows.len() == 1
10877                && adjacency.page.truncated
10878                && adjacency.work.requested_rows == 1
10879                && adjacency.work.returned_rows == 1
10880                && adjacency.work.decoded_bytes > 0
10881                && adjacency.work.hydrated_entities > 0
10882                && adjacency.work.hydrated_paths > 0,
10883            "bounded adjacency page omitted exact raw or endpoint work",
10884        )?;
10885        let exact_adjacency_budget = RepositoryGraphReadBudget::new(
10886            1,
10887            1,
10888            adjacency.work.decoded_bytes,
10889            adjacency.work.hydrated_entities,
10890            adjacency.work.hydrated_paths,
10891        )?;
10892        require_eq(
10893            &store.repository_graph_adjacency_page_bounded(
10894                &[source.key().clone()],
10895                RepositoryGraphDirection::Outbound,
10896                None,
10897                1,
10898                exact_adjacency_budget,
10899                None,
10900            )?,
10901            &adjacency,
10902            "exact adjacency envelope",
10903        )?;
10904        let adjacency_overrun = require_db_error(
10905            store.repository_graph_adjacency_page_bounded(
10906                &[source.key().clone()],
10907                RepositoryGraphDirection::Outbound,
10908                None,
10909                1,
10910                RepositoryGraphReadBudget::new(
10911                    1,
10912                    1,
10913                    adjacency.work.decoded_bytes - 1,
10914                    adjacency.work.hydrated_entities,
10915                    adjacency.work.hydrated_paths,
10916                )?,
10917                None,
10918            ),
10919            "adjacency decoded-byte overrun was accepted",
10920        )?;
10921        require(
10922            matches!(adjacency_overrun, DbError::GraphContract(_)),
10923            &format!("unexpected adjacency envelope error: {adjacency_overrun}"),
10924        )?;
10925        let adjacency_return_limit = require_db_error(
10926            store.repository_graph_adjacency_page_bounded(
10927                &[source.key().clone()],
10928                RepositoryGraphDirection::Outbound,
10929                None,
10930                2,
10931                exact_adjacency_budget,
10932                None,
10933            ),
10934            "adjacency page limit exceeded the return budget",
10935        )?;
10936        require(
10937            matches!(adjacency_return_limit, DbError::GraphContract(_)),
10938            &format!("unexpected adjacency return-budget error: {adjacency_return_limit}"),
10939        )?;
10940        let continuation = adjacency
10941            .page
10942            .continuation
10943            .ok_or_else(|| io::Error::other("adjacency continuation missing"))?;
10944        let encoded =
10945            serde_json::to_vec(&(RepositoryGraphDirection::Outbound, continuation.clone()))?;
10946        let decoded: (
10947            RepositoryGraphDirection,
10948            RepositoryGraphAdjacencyContinuation,
10949        ) = serde_json::from_slice(&encoded)?;
10950        require_eq(
10951            &decoded,
10952            &(RepositoryGraphDirection::Outbound, continuation),
10953            "opaque relation cursor serde round trip",
10954        )?;
10955
10956        assert_cursor_hydration_indexes(&store)?;
10957        store.finish_index_read_snapshot()?;
10958        Ok(())
10959    }
10960
10961    #[test]
10962    fn continued_adjacency_seeks_past_high_degree_prefixes() -> Result<(), Box<dyn Error>> {
10963        let temp = tempfile::tempdir()?;
10964        let project_root = temp.path().join("continued-adjacency-work");
10965        let atlas_dir = project_root.join(".projectatlas");
10966        fs::create_dir_all(&atlas_dir)?;
10967        let db_path = atlas_dir.join("projectatlas.db");
10968        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
10969        let fixture = publish_fixture(&mut writer, "continued-adjacency-work")?;
10970        let source = fixture
10971            .entities
10972            .iter()
10973            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
10974            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
10975        let external = fixture
10976            .entities
10977            .iter()
10978            .find(|entity| matches!(entity.selector(), EntitySelector::External { .. }))
10979            .ok_or_else(|| io::Error::other("external fixture missing"))?;
10980        writer.connection.execute(
10981            "WITH RECURSIVE sequence(value) AS (
10982                 VALUES(1)
10983                 UNION ALL
10984                 SELECT value + 1 FROM sequence WHERE value < 100000
10985             )
10986             INSERT INTO graph_relations(
10987                 relation_key, project_instance_id, canonical_identity,
10988                 source_entity_key, relation_scope, relation_kind,
10989                 resolution_status, target_entity_key, reference_text,
10990                 candidate_count, confidence, completeness
10991             )
10992             SELECT CAST(printf('%032d', 1000000 + value) AS BLOB),
10993                    ?1, printf('perf-%06d', value), ?2,
10994                    'legacy', 'calls', 'resolved', ?3, NULL, NULL,
10995                    'exact', 'complete'
10996               FROM sequence",
10997            params![
10998                fixture.project.as_bytes().as_slice(),
10999                source.key().digest_bytes()?.as_slice(),
11000                external.key().digest_bytes()?.as_slice(),
11001            ],
11002        )?;
11003        drop(writer);
11004
11005        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11006        let generation = store
11007            .repository_graph_generation()?
11008            .ok_or_else(|| io::Error::other("graph generation missing"))?;
11009        let source_digest = source.key().digest_bytes()?;
11010        let external_digest = external.key().digest_bytes()?;
11011        let skipped_frontier = vec![source.key().clone(), external.key().clone()];
11012        let skipped_continuation = RepositoryGraphAdjacencyContinuation {
11013            project: fixture.project,
11014            generation,
11015            direction: RepositoryGraphDirection::Outbound,
11016            relation: None,
11017            frontier: vec![source_digest, external_digest],
11018            frontier_index: 1,
11019            relation_scope: String::new(),
11020            relation_kind: String::new(),
11021            relation_key: [0; 32],
11022        };
11023        let skipped_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
11024        let skipped_counter = std::sync::Arc::clone(&skipped_steps);
11025        store.connection.progress_handler(
11026            1_000,
11027            Some(move || {
11028                skipped_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
11029                false
11030            }),
11031        );
11032        let skipped_result = store.repository_graph_adjacency_page(
11033            &skipped_frontier,
11034            RepositoryGraphDirection::Outbound,
11035            Some(&skipped_continuation),
11036            2,
11037            None,
11038        );
11039        store.connection.progress_handler(0, None::<fn() -> bool>);
11040        skipped_result?;
11041        require(
11042            skipped_steps.load(std::sync::atomic::Ordering::Relaxed) < 100_000,
11043            "continued adjacency scanned a completed high-degree frontier branch",
11044        )?;
11045
11046        let last_key: [u8; 32] = format!("{:032}", 1_100_000)
11047            .into_bytes()
11048            .try_into()
11049            .map_err(|_source| io::Error::other("high-degree cursor key width changed"))?;
11050        let deep_continuation = RepositoryGraphAdjacencyContinuation {
11051            project: fixture.project,
11052            generation,
11053            direction: RepositoryGraphDirection::Outbound,
11054            relation: None,
11055            frontier: vec![source_digest],
11056            frontier_index: 0,
11057            relation_scope: "legacy".to_string(),
11058            relation_kind: "calls".to_string(),
11059            relation_key: last_key,
11060        };
11061        let deep_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
11062        let deep_counter = std::sync::Arc::clone(&deep_steps);
11063        store.connection.progress_handler(
11064            1_000,
11065            Some(move || {
11066                deep_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
11067                false
11068            }),
11069        );
11070        let deep_result = store.repository_graph_adjacency_page(
11071            &[source.key().clone()],
11072            RepositoryGraphDirection::Outbound,
11073            Some(&deep_continuation),
11074            2,
11075            None,
11076        );
11077        store.connection.progress_handler(0, None::<fn() -> bool>);
11078        deep_result?;
11079        require(
11080            deep_steps.load(std::sync::atomic::Ordering::Relaxed) < 100_000,
11081            "continued adjacency rescanned a high-degree keyset prefix",
11082        )?;
11083        store.finish_index_read_snapshot()?;
11084        Ok(())
11085    }
11086
11087    #[test]
11088    fn bounded_rust_frontier_matches_indexed_recursive_cte_on_cycles_and_high_degree()
11089    -> Result<(), Box<dyn Error>> {
11090        const HIGH_DEGREE: usize = 4_096;
11091        const RECURSIVE_CTE: &str = "WITH RECURSIVE walk(entity_key) AS (
11092                VALUES(?2)
11093                UNION
11094                SELECT relation.target_entity_key
11095                  FROM graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
11096                  JOIN walk ON relation.source_entity_key = walk.entity_key
11097                 WHERE relation.project_instance_id = ?1
11098                   AND relation.relation_scope = 'legacy'
11099                   AND relation.relation_kind = 'calls'
11100                   AND relation.target_entity_key IS NOT NULL
11101            )
11102            SELECT entity_key FROM walk ORDER BY entity_key";
11103
11104        let temp = tempfile::tempdir()?;
11105        let project_root = temp.path().join("frontier-cte-comparison");
11106        let atlas_dir = project_root.join(".projectatlas");
11107        fs::create_dir_all(&atlas_dir)?;
11108        let db_path = atlas_dir.join("projectatlas.db");
11109        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
11110        let fixture = publish_fixture(&mut writer, "frontier-cte-comparison")?;
11111        let source = fixture
11112            .entities
11113            .iter()
11114            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
11115            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
11116        let generation = source.generation();
11117        let mut high_degree_entities = Vec::with_capacity(HIGH_DEGREE);
11118        let mut cyclic_relations = Vec::with_capacity(HIGH_DEGREE * 2);
11119        for index in 0..HIGH_DEGREE {
11120            let target = GraphEntity::new(
11121                fixture.project,
11122                EntitySelector::External {
11123                    external: ExternalSelector {
11124                        system: GraphIdentityText::new("frontier-measurement")?,
11125                        identity: GraphIdentityText::new(format!("node-{index:05}"))?,
11126                    },
11127                },
11128                generation,
11129            )?;
11130            cyclic_relations.push(LogicalRelation::new(
11131                source,
11132                GraphRelationKind::Legacy(RelationKind::Calls),
11133                RelationResolution::external(&target)?,
11134                ConfidenceClass::Exact,
11135                Completeness::Complete,
11136                generation,
11137            )?);
11138            cyclic_relations.push(LogicalRelation::new(
11139                &target,
11140                GraphRelationKind::Legacy(RelationKind::Calls),
11141                RelationResolution::resolved(source)?,
11142                ConfidenceClass::Exact,
11143                Completeness::Complete,
11144                generation,
11145            )?);
11146            high_degree_entities.push(target);
11147        }
11148        let transaction = writer.connection.transaction()?;
11149        insert_entities(&transaction, fixture.project, &high_degree_entities)?;
11150        insert_relations(&transaction, fixture.project, &cyclic_relations)?;
11151        transaction.commit()?;
11152        drop(writer);
11153
11154        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11155        let project = fixture.project.as_bytes();
11156        let source_key = source.key().digest_bytes()?;
11157        let plan_sql = format!("EXPLAIN QUERY PLAN {RECURSIVE_CTE}");
11158        let cte_plan = store
11159            .connection
11160            .prepare(&plan_sql)?
11161            .query_map(params![&project[..], &source_key[..]], |row| {
11162                row.get::<_, String>(3)
11163            })?
11164            .collect::<Result<Vec<_>, _>>()?;
11165        require(
11166            cte_plan
11167                .iter()
11168                .any(|detail| detail.contains("idx_graph_relations_source_kind"))
11169                && cte_plan
11170                    .iter()
11171                    .all(|detail| !detail.contains("SCAN relation")),
11172            &format!("recursive CTE did not retain the source-owned index: {cte_plan:?}"),
11173        )?;
11174
11175        let cte_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
11176        let cte_counter = std::sync::Arc::clone(&cte_steps);
11177        store.connection.progress_handler(
11178            1_000,
11179            Some(move || {
11180                cte_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
11181                false
11182            }),
11183        );
11184        let cte_started = Instant::now();
11185        let cte_result = store
11186            .connection
11187            .prepare(RECURSIVE_CTE)?
11188            .query_map(params![&project[..], &source_key[..]], |row| {
11189                row.get::<_, Vec<u8>>(0)
11190            })?
11191            .map(|row| fixed_bytes::<32>("recursive_cte.entity_key", row?))
11192            .collect::<DbResult<Vec<_>>>();
11193        let cte_elapsed = cte_started.elapsed();
11194        store.connection.progress_handler(0, None::<fn() -> bool>);
11195        let cte_nodes = cte_result?;
11196
11197        let rust_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
11198        let rust_counter = std::sync::Arc::clone(&rust_steps);
11199        store.connection.progress_handler(
11200            1_000,
11201            Some(move || {
11202                rust_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
11203                false
11204            }),
11205        );
11206        let rust_started = Instant::now();
11207        let rust_result = collect_bounded_outbound_calls(&store, source.key());
11208        let rust_elapsed = rust_started.elapsed();
11209        store.connection.progress_handler(0, None::<fn() -> bool>);
11210        let (rust_nodes, inspected_edges, peak_frontier) = rust_result?;
11211        let (repeated_nodes, repeated_edges, repeated_peak) =
11212            collect_bounded_outbound_calls(&store, source.key())?;
11213
11214        require_eq(
11215            &rust_nodes,
11216            &cte_nodes,
11217            "Rust frontier versus recursive CTE topology",
11218        )?;
11219        require_eq(
11220            &repeated_nodes,
11221            &rust_nodes,
11222            "deterministic Rust frontier order",
11223        )?;
11224        require_eq(
11225            &repeated_edges,
11226            &inspected_edges,
11227            "deterministic inspected edges",
11228        )?;
11229        require_eq(
11230            &repeated_peak,
11231            &peak_frontier,
11232            "deterministic peak frontier",
11233        )?;
11234        require_eq(
11235            &rust_nodes.len(),
11236            &(HIGH_DEGREE + 2),
11237            "cycle-safe high-degree topology",
11238        )?;
11239        require_eq(
11240            &inspected_edges,
11241            &(HIGH_DEGREE * 2 + 1),
11242            "bounded frontier inspected every call edge once",
11243        )?;
11244        require(
11245            cte_steps.load(std::sync::atomic::Ordering::Relaxed) > 0
11246                && rust_steps.load(std::sync::atomic::Ordering::Relaxed) > 0
11247                && cte_elapsed > Duration::ZERO
11248                && rust_elapsed > Duration::ZERO,
11249            "frontier comparison did not record VM work and elapsed time",
11250        )?;
11251        let retained_key_bytes = rust_nodes
11252            .len()
11253            .checked_add(peak_frontier)
11254            .and_then(|keys| keys.checked_mul(std::mem::size_of::<[u8; 32]>()))
11255            .ok_or_else(|| io::Error::other("retained key measurement overflowed"))?;
11256        let output_key_bytes = rust_nodes
11257            .len()
11258            .checked_mul(std::mem::size_of::<[u8; 32]>())
11259            .ok_or_else(|| io::Error::other("output key measurement overflowed"))?;
11260        require(
11261            retained_key_bytes <= GraphLimits::MAX_OUTPUT_BYTES as usize
11262                && output_key_bytes <= GraphLimits::MAX_OUTPUT_BYTES as usize,
11263            "bounded Rust frontier exceeded the shared compact byte ceiling",
11264        )?;
11265
11266        store.connection.progress_handler(1, Some(|| true));
11267        let cancelled_cte = (|| -> Result<Vec<Vec<u8>>, rusqlite::Error> {
11268            let mut statement = store.connection.prepare(RECURSIVE_CTE)?;
11269            statement
11270                .query_map(params![&project[..], &source_key[..]], |row| {
11271                    row.get::<_, Vec<u8>>(0)
11272                })?
11273                .collect()
11274        })();
11275        store.connection.progress_handler(0, None::<fn() -> bool>);
11276        require(
11277            matches!(
11278                cancelled_cte,
11279                Err(rusqlite::Error::SqliteFailure(ref failure, _))
11280                    if failure.code == rusqlite::ErrorCode::OperationInterrupted
11281            ),
11282            "recursive CTE did not stop through the SQLite progress handler",
11283        )?;
11284        let cancellation = projectatlas_core::IndexCancellation::new();
11285        cancellation.cancel();
11286        let control = IndexWorkControl::new(cancellation, None);
11287        let cancelled_rust = store.repository_graph_adjacency_page_filtered(
11288            &[source.key().clone()],
11289            RepositoryGraphDirection::Outbound,
11290            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
11291            None,
11292            1,
11293            Some(&control),
11294        );
11295        require(
11296            matches!(
11297                cancelled_rust,
11298                Err(DbError::IndexWork(
11299                    projectatlas_core::IndexWorkFailure::Cancelled {
11300                        stage: IndexWorkStage::RepositoryTraversal
11301                    }
11302                ))
11303            ),
11304            "bounded Rust frontier did not retain typed cancellation",
11305        )?;
11306        store.finish_index_read_snapshot()?;
11307        Ok(())
11308    }
11309
11310    #[test]
11311    fn coverage_discovery_filters_provenance_and_fails_closed_after_reopen()
11312    -> Result<(), Box<dyn Error>> {
11313        let temp = tempfile::tempdir()?;
11314        let project_root = temp.path().join("coverage-discovery");
11315        fs::create_dir_all(&project_root)?;
11316        let db_path = project_root.join("projectatlas.db");
11317        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
11318        let fixture = publish_fixture(&mut writer, "coverage-discovery")?;
11319        let sibling_path = "src/Äuth.rs.backup";
11320        let sibling_coverage = CoverageRecord::new(
11321            CoverageScope::Path {
11322                path: RepositoryNodePath::new(Path::new(sibling_path))?,
11323            },
11324            None,
11325            CoverageState::Complete,
11326            1,
11327            0,
11328            IndexGeneration::new(2),
11329            None,
11330            None,
11331        )?;
11332        let mut publication = writer.begin_index_publication("coverage-discovery-sibling")?;
11333        publication.replace_repository_graph_for_paths(
11334            fixture.project,
11335            &[sibling_path.to_string()],
11336            &[],
11337            &[],
11338            &[],
11339            &[sibling_coverage],
11340        )?;
11341        publication.complete()?;
11342        drop(writer);
11343
11344        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11345        let project_value = Value::Blob(fixture.project.as_bytes().to_vec());
11346        assert_coverage_discovery_plan(
11347            &store.connection,
11348            "EXPLAIN QUERY PLAN
11349             SELECT coverage.id FROM graph_coverage AS coverage
11350              WHERE coverage.project_instance_id = ?
11351                AND coverage.scope_kind = 'path'
11352                AND coverage.scope_path >= ? AND coverage.scope_path < ?
11353                AND (coverage.scope_path = ? OR coverage.scope_path >= ?)
11354              ORDER BY coverage.scope_path, coverage.relation_scope, coverage.relation_kind,
11355                       coverage.state, coverage.id LIMIT 11",
11356            &[
11357                project_value.clone(),
11358                Value::Text("src/Äuth.rs".to_string()),
11359                Value::Text("src/Äuth.rs0".to_string()),
11360                Value::Text("src/Äuth.rs".to_string()),
11361                Value::Text("src/Äuth.rs/".to_string()),
11362            ],
11363            &["idx_graph_coverage_scope_order"],
11364            false,
11365            "coverage path filter",
11366        )?;
11367        assert_coverage_discovery_plan(
11368            &store.connection,
11369            "EXPLAIN QUERY PLAN
11370             SELECT coverage.id FROM graph_coverage AS coverage
11371              WHERE coverage.project_instance_id = ? AND coverage.state = ?
11372              ORDER BY coverage.state, coverage.scope_path, coverage.id LIMIT 11",
11373            &[project_value.clone(), Value::Text("failed".to_string())],
11374            &["idx_graph_coverage_discovery_state"],
11375            false,
11376            "coverage state filter",
11377        )?;
11378        assert_coverage_discovery_plan(
11379            &store.connection,
11380            "EXPLAIN QUERY PLAN
11381             SELECT coverage.id FROM graph_coverage AS coverage
11382              WHERE coverage.project_instance_id = ? AND coverage.reason = ?
11383              ORDER BY coverage.reason, coverage.scope_path, coverage.id LIMIT 11",
11384            &[
11385                project_value.clone(),
11386                Value::Text("parser failed".to_string()),
11387            ],
11388            &["idx_graph_coverage_discovery_reason"],
11389            false,
11390            "coverage reason filter",
11391        )?;
11392        for (column, index, context) in [
11393            (
11394                "source_parser",
11395                "idx_source_parse_metadata_source_parser_path",
11396                "coverage parser filter",
11397            ),
11398            (
11399                "fact_parser",
11400                "idx_source_parse_metadata_fact_parser_path",
11401                "coverage provider filter",
11402            ),
11403        ] {
11404            assert_coverage_discovery_plan(
11405                &store.connection,
11406                &format!(
11407                    "EXPLAIN QUERY PLAN
11408                     SELECT coverage.id
11409                       FROM source_parse_metadata AS metadata
11410                       CROSS JOIN graph_coverage AS coverage
11411                         ON coverage.scope_kind = 'path'
11412                        AND coverage.scope_path = metadata.path
11413                      WHERE coverage.project_instance_id = ?
11414                        AND metadata.{column} = ?
11415                      ORDER BY metadata.path, coverage.id LIMIT 11"
11416                ),
11417                &[
11418                    project_value.clone(),
11419                    Value::Text("tree-sitter".to_string()),
11420                ],
11421                &[index, "idx_graph_coverage_scope_order"],
11422                true,
11423                context,
11424            )?;
11425        }
11426        let all = store.repository_coverage_page(
11427            fixture.project,
11428            &RepositoryCoverageQuery {
11429                start_index: 0,
11430                limit: 2,
11431                path_prefix: None,
11432                parser: None,
11433                provider: None,
11434                relation: None,
11435                state: None,
11436                reason: None,
11437            },
11438        )?;
11439        require(
11440            all.truncated && all.rows.len() == 2,
11441            "coverage discovery did not use LIMIT + 1",
11442        )?;
11443        let all_states = store.repository_coverage_page(
11444            fixture.project,
11445            &RepositoryCoverageQuery {
11446                start_index: 0,
11447                limit: 10,
11448                path_prefix: None,
11449                parser: None,
11450                provider: None,
11451                relation: None,
11452                state: None,
11453                reason: None,
11454            },
11455        )?;
11456        for state in [
11457            CoverageState::Complete,
11458            CoverageState::Partial,
11459            CoverageState::Failed,
11460            CoverageState::Ignored,
11461            CoverageState::Oversized,
11462            CoverageState::Quarantined,
11463            CoverageState::Stale,
11464        ] {
11465            require(
11466                all_states
11467                    .rows
11468                    .iter()
11469                    .any(|row| row.coverage.state() == state),
11470                &format!("coverage discovery omitted {state:?}"),
11471            )?;
11472        }
11473
11474        let exact_path = store.repository_coverage_page(
11475            fixture.project,
11476            &RepositoryCoverageQuery {
11477                start_index: 0,
11478                limit: 1,
11479                path_prefix: Some("src/Äuth.rs".to_string()),
11480                parser: None,
11481                provider: None,
11482                relation: None,
11483                state: None,
11484                reason: None,
11485            },
11486        )?;
11487        require(
11488            !exact_path.truncated && exact_path.rows.len() == 1,
11489            "exact coverage path admitted a lexical sibling",
11490        )?;
11491        require_eq(
11492            exact_path.rows[0].coverage.scope(),
11493            &CoverageScope::Path {
11494                path: RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
11495            },
11496            "exact coverage path scope",
11497        )?;
11498
11499        let parsed = store.repository_coverage_page(
11500            fixture.project,
11501            &RepositoryCoverageQuery {
11502                start_index: 0,
11503                limit: 10,
11504                path_prefix: Some("src/Äuth.rs".to_string()),
11505                parser: Some(ParserKind::TreeSitter),
11506                provider: Some(ParserKind::TreeSitter),
11507                relation: None,
11508                state: Some(CoverageState::Complete),
11509                reason: None,
11510            },
11511        )?;
11512        require_eq(&parsed.rows.len(), &1, "parser/provider filtered coverage")?;
11513        require_eq(
11514            &parsed.rows[0].parser,
11515            &Some(ParserKind::TreeSitter),
11516            "source parser provenance",
11517        )?;
11518        require_eq(
11519            &parsed.rows[0].provider,
11520            &Some(ParserKind::TreeSitter),
11521            "fact provider provenance",
11522        )?;
11523
11524        let failed_calls = store.repository_coverage_page(
11525            fixture.project,
11526            &RepositoryCoverageQuery {
11527                start_index: 0,
11528                limit: 10,
11529                path_prefix: None,
11530                parser: None,
11531                provider: None,
11532                relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
11533                state: Some(CoverageState::Failed),
11534                reason: Some("parser failed".to_string()),
11535            },
11536        )?;
11537        require_eq(&failed_calls.rows.len(), &1, "combined coverage filters")?;
11538        require_eq(
11539            &failed_calls.rows[0].coverage.state(),
11540            &CoverageState::Failed,
11541            "failed coverage state",
11542        )?;
11543
11544        let absent = store.repository_coverage_page(
11545            fixture.project,
11546            &RepositoryCoverageQuery {
11547                start_index: 0,
11548                limit: 10,
11549                path_prefix: None,
11550                parser: None,
11551                provider: Some(ParserKind::Manifest),
11552                relation: None,
11553                state: None,
11554                reason: None,
11555            },
11556        )?;
11557        require(
11558            absent.rows.is_empty(),
11559            "provider filter returned a false match",
11560        )?;
11561        store.finish_index_read_snapshot()?;
11562        drop(store);
11563
11564        let writer = AtlasStore::open_for_project(&db_path, &project_root)?;
11565        writer.connection.execute(
11566            "UPDATE source_parse_metadata SET source_parser = 'corrupt-parser'
11567              WHERE path = 'src/Äuth.rs'",
11568            [],
11569        )?;
11570        drop(writer);
11571        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11572        let error = require_db_error(
11573            store.repository_coverage_page(
11574                fixture.project,
11575                &RepositoryCoverageQuery {
11576                    start_index: 0,
11577                    limit: 10,
11578                    path_prefix: Some("src/Äuth.rs".to_string()),
11579                    parser: None,
11580                    provider: None,
11581                    relation: None,
11582                    state: None,
11583                    reason: None,
11584                },
11585            ),
11586            "corrupt parser provenance was accepted",
11587        )?;
11588        require(
11589            matches!(error, DbError::InvalidEnum { .. }),
11590            &format!("unexpected parser corruption error: {error}"),
11591        )?;
11592        Ok(())
11593    }
11594
11595    #[test]
11596    fn navigation_connections_cover_families_prefixes_truncation_and_reopen()
11597    -> Result<(), Box<dyn Error>> {
11598        let temp = tempfile::tempdir()?;
11599        let root = temp.path().join("navigation-connections");
11600        fs::create_dir_all(&root)?;
11601        let db_path = root.join("projectatlas.db");
11602        let mut store = AtlasStore::open_for_project(&db_path, &root)?;
11603        let fixture = publish_navigation_fixture(&mut store, "navigation-connections")?;
11604        let owners = vec![
11605            RepositoryNavigationNode {
11606                path: fixture.api_path.clone(),
11607                kind: NodeKind::File,
11608            },
11609            RepositoryNavigationNode {
11610                path: "src/auth".to_string(),
11611                kind: NodeKind::Folder,
11612            },
11613            RepositoryNavigationNode {
11614                path: fixture.manifest_path.clone(),
11615                kind: NodeKind::File,
11616            },
11617            RepositoryNavigationNode {
11618                path: ".".to_string(),
11619                kind: NodeKind::Folder,
11620            },
11621        ];
11622        let pages = store.repository_navigation_connections(&owners, 2, 20)?;
11623        require_eq(&pages.len(), &owners.len(), "navigation owner count")?;
11624        let api = &pages[0];
11625        let families = api
11626            .counts
11627            .iter()
11628            .map(|count| count.kind)
11629            .collect::<Vec<_>>();
11630        require_eq(
11631            &families,
11632            &NAVIGATION_CONNECTION_FAMILIES
11633                .iter()
11634                .map(|&(kind, _, _)| kind)
11635                .collect::<Vec<_>>(),
11636            "all navigation families",
11637        )?;
11638        let calls = api
11639            .counts
11640            .iter()
11641            .find(|count| count.kind == RankedConnectionKind::Call)
11642            .ok_or_else(|| io::Error::other("call navigation count is missing"))?;
11643        require_eq(&calls.count, &2, "bounded high-degree call count")?;
11644        require_eq(&calls.truncated, &true, "high-degree call truncation")?;
11645        require_eq(&api.truncated, &true, "file aggregate truncation")?;
11646        require(
11647            api.connections.iter().any(|connection| {
11648                connection.kind == RankedConnectionKind::Test
11649                    && connection.direction == RankedConnectionDirection::Inbound
11650            }),
11651            "inbound test connection was not projected",
11652        )?;
11653
11654        let folder = &pages[1];
11655        let folder_imports = folder
11656            .counts
11657            .iter()
11658            .find(|count| count.kind == RankedConnectionKind::Import)
11659            .ok_or_else(|| io::Error::other("folder import count is missing"))?;
11660        require_eq(
11661            &folder_imports.count,
11662            &1,
11663            "folder prefix import count excluding sibling authz.rs",
11664        )?;
11665        let manifest = &pages[2];
11666        require(
11667            manifest
11668                .counts
11669                .iter()
11670                .any(|count| count.kind == RankedConnectionKind::Package && count.count == 1),
11671            "manifest-owned package context is missing",
11672        )?;
11673        require(
11674            pages[3]
11675                .counts
11676                .iter()
11677                .any(|count| count.kind == RankedConnectionKind::Call),
11678            "root aggregate omitted bounded call context",
11679        )?;
11680
11681        let globally_sampled = store.repository_navigation_connections(&owners[..1], 10, 3)?;
11682        require_eq(
11683            &globally_sampled[0].connections.len(),
11684            &3,
11685            "global connection sample limit",
11686        )?;
11687        require_eq(
11688            &globally_sampled[0].counts.len(),
11689            &NAVIGATION_CONNECTION_FAMILIES.len(),
11690            "global sample retained all family counts",
11691        )?;
11692        require(
11693            globally_sampled[0]
11694                .counts
11695                .iter()
11696                .all(|count| !count.truncated),
11697            "global sample incorrectly reported family overflow",
11698        )?;
11699        require_eq(
11700            &globally_sampled[0].truncated,
11701            &true,
11702            "global sample truncation",
11703        )?;
11704
11705        drop(store);
11706        let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
11707        require_eq(
11708            &reader.project_instance_id()?,
11709            &Some(fixture.project),
11710            "reopened navigation identity",
11711        )?;
11712        let reopened = reader.repository_navigation_connections(&owners[..1], 2, 20)?;
11713        require_eq(
11714            &reopened[0].counts,
11715            &api.counts,
11716            "reopened navigation counts",
11717        )?;
11718        Ok(())
11719    }
11720
11721    #[test]
11722    fn navigation_connections_use_owned_indexes_and_fail_all_or_error_on_corruption()
11723    -> Result<(), Box<dyn Error>> {
11724        let temp = tempfile::tempdir()?;
11725        let root = temp.path().join("navigation-plan-corruption");
11726        fs::create_dir_all(&root)?;
11727        let db_path = root.join("projectatlas.db");
11728        let mut store = AtlasStore::open_for_project(&db_path, &root)?;
11729        let fixture = publish_navigation_fixture(&mut store, "navigation-plan-corruption")?;
11730        let owner = RepositoryNavigationNode {
11731            path: fixture.api_path,
11732            kind: NodeKind::File,
11733        };
11734        for (direction, expected_index) in [
11735            (
11736                RankedConnectionDirection::Outbound,
11737                "idx_graph_relations_source_kind",
11738            ),
11739            (
11740                RankedConnectionDirection::Inbound,
11741                "idx_graph_relations_target_kind",
11742            ),
11743        ] {
11744            let mut values = Vec::new();
11745            let sql = navigation_connection_branch(
11746                0,
11747                &owner,
11748                RankedConnectionKind::Call,
11749                "legacy",
11750                "calls",
11751                direction,
11752                3,
11753                &mut values,
11754            );
11755            let mut statement = store
11756                .connection
11757                .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
11758            let details = statement
11759                .query_map(params_from_iter(values.iter()), |row| {
11760                    row.get::<_, String>(3)
11761                })?
11762                .collect::<Result<Vec<_>, _>>()?;
11763            for expected in [
11764                expected_index,
11765                "idx_graph_entities_path",
11766                "idx_graph_entities_manifest_path",
11767            ] {
11768                require(
11769                    details.iter().any(|detail| detail.contains(expected)),
11770                    &format!("navigation plan missed {expected}: {details:?}"),
11771                )?;
11772            }
11773            require(
11774                details.iter().all(|detail| !detail.contains("SCAN graph_")),
11775                &format!("navigation plan scanned graph storage: {details:?}"),
11776            )?;
11777        }
11778        let folder_owner = RepositoryNavigationNode {
11779            path: "src/auth".to_string(),
11780            kind: NodeKind::Folder,
11781        };
11782        for (direction, expected_index) in [
11783            (
11784                RankedConnectionDirection::Outbound,
11785                "idx_graph_relations_source_kind",
11786            ),
11787            (
11788                RankedConnectionDirection::Inbound,
11789                "idx_graph_relations_target_kind",
11790            ),
11791        ] {
11792            let mut values = Vec::new();
11793            let sql = navigation_connection_branch(
11794                0,
11795                &folder_owner,
11796                RankedConnectionKind::Call,
11797                "legacy",
11798                "calls",
11799                direction,
11800                3,
11801                &mut values,
11802            );
11803            let details = store
11804                .connection
11805                .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?
11806                .query_map(params_from_iter(values.iter()), |row| {
11807                    row.get::<_, String>(3)
11808                })?
11809                .collect::<Result<Vec<_>, _>>()?;
11810            for expected in [
11811                expected_index,
11812                "idx_graph_entities_path",
11813                "idx_graph_entities_manifest_path",
11814            ] {
11815                require(
11816                    details.iter().any(|detail| detail.contains(expected)),
11817                    &format!("folder navigation plan missed {expected}: {details:?}"),
11818                )?;
11819            }
11820            require(
11821                details.iter().all(|detail| !detail.contains("SCAN graph_")),
11822                &format!("folder navigation plan scanned graph storage: {details:?}"),
11823            )?;
11824        }
11825        let root_owner = RepositoryNavigationNode {
11826            path: ".".to_string(),
11827            kind: NodeKind::Folder,
11828        };
11829        let mut root_values = Vec::new();
11830        let root_sql = navigation_connection_branch(
11831            0,
11832            &root_owner,
11833            RankedConnectionKind::Call,
11834            "legacy",
11835            "calls",
11836            RankedConnectionDirection::Outbound,
11837            2,
11838            &mut root_values,
11839        );
11840        let root_details = store
11841            .connection
11842            .prepare(&format!("EXPLAIN QUERY PLAN {root_sql}"))?
11843            .query_map(params_from_iter(root_values.iter()), |row| {
11844                row.get::<_, String>(3)
11845            })?
11846            .collect::<Result<Vec<_>, _>>()?;
11847        require(
11848            root_details
11849                .iter()
11850                .any(|detail| detail.contains("idx_graph_relations_kind_order")),
11851            &format!("root navigation plan missed family index: {root_details:?}"),
11852        )?;
11853        require(
11854            root_details
11855                .iter()
11856                .all(|detail| !detail.contains("SCAN graph_")),
11857            &format!("root navigation plan scanned graph storage: {root_details:?}"),
11858        )?;
11859
11860        store
11861            .connection
11862            .execute_batch("PRAGMA ignore_check_constraints = ON")?;
11863        store.connection.execute(
11864            "UPDATE graph_relations
11865                SET resolution_status = 'unresolved', reference_text = 'broken-route'
11866              WHERE relation_scope = 'extended' AND relation_kind = 'routes-to'",
11867            [],
11868        )?;
11869        let error = require_db_error(
11870            store.repository_navigation_connections(&[owner], 4, 20),
11871            "corrupt navigation relation returned a partial page",
11872        )?;
11873        require(
11874            matches!(error, DbError::GraphRowShape { .. }),
11875            &format!("corrupt navigation relation returned {error}"),
11876        )?;
11877        Ok(())
11878    }
11879
11880    #[test]
11881    fn graph_queries_fail_closed_on_corrupt_normalized_rows() -> Result<(), Box<dyn Error>> {
11882        let temp = tempfile::tempdir()?;
11883        let project_root = temp.path().join("graph-corruption");
11884        let atlas_dir = project_root.join(".projectatlas");
11885        fs::create_dir_all(&atlas_dir)?;
11886        let db_path = atlas_dir.join("projectatlas.db");
11887        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
11888        let fixture = publish_fixture(&mut store, "graph-corruption")?;
11889        drop(store);
11890        let store = AtlasStore::open_for_project(&db_path, &project_root)?;
11891        let source = fixture
11892            .entities
11893            .iter()
11894            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
11895            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
11896        let folder = fixture
11897            .entities
11898            .iter()
11899            .find(|entity| matches!(entity.selector(), EntitySelector::Folder { .. }))
11900            .ok_or_else(|| io::Error::other("folder fixture missing"))?;
11901        let symbol = fixture
11902            .entities
11903            .iter()
11904            .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
11905            .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
11906        let ambiguous = fixture
11907            .relations
11908            .iter()
11909            .find(|relation| matches!(relation.resolution(), RelationResolution::Ambiguous { .. }))
11910            .ok_or_else(|| io::Error::other("ambiguous relation fixture missing"))?;
11911        let source_digest = source.key().digest_bytes()?;
11912        let folder_digest = folder.key().digest_bytes()?;
11913        let symbol_digest = symbol.key().digest_bytes()?;
11914        let ambiguous_digest = ambiguous.key().digest_bytes()?;
11915        let source_canonical = store.connection.query_row(
11916            "SELECT canonical_identity FROM graph_entities WHERE entity_key = ?1",
11917            [&source_digest[..]],
11918            |row| row.get::<_, String>(0),
11919        )?;
11920        let symbol_canonical = store.connection.query_row(
11921            "SELECT canonical_identity FROM graph_entities WHERE entity_key = ?1",
11922            [&symbol_digest[..]],
11923            |row| row.get::<_, String>(0),
11924        )?;
11925        store
11926            .connection
11927            .execute_batch("PRAGMA ignore_check_constraints = ON")?;
11928
11929        store.connection.execute(
11930            "UPDATE graph_entities SET entity_kind = 'corrupt' WHERE entity_key = ?1",
11931            [&source_digest[..]],
11932        )?;
11933        {
11934            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11935            let error = require_db_error(
11936                reader.repository_graph_entity(source.key()),
11937                "malformed graph enum was accepted",
11938            )?;
11939            require(
11940                matches!(error, DbError::InvalidEnum { .. }),
11941                &format!("unexpected malformed-enum error: {error}"),
11942            )?;
11943            reader.finish_index_read_snapshot()?;
11944        }
11945        store.connection.execute(
11946            "UPDATE graph_entities SET entity_kind = 'file' WHERE entity_key = ?1",
11947            [&source_digest[..]],
11948        )?;
11949
11950        store.connection.execute(
11951            "UPDATE graph_relations SET candidate_count = 0 WHERE relation_key = ?1",
11952            [&ambiguous_digest[..]],
11953        )?;
11954        {
11955            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11956            let error = require_db_error(
11957                reader.repository_graph_relations(
11958                    RepositoryGraphRelationQuery::Outbound {
11959                        source: source.key().clone(),
11960                    },
11961                    10,
11962                ),
11963                "zero ambiguity count was accepted",
11964            )?;
11965            require(
11966                matches!(error, DbError::GraphRowShape { .. }),
11967                &format!("unexpected candidate-count error: {error}"),
11968            )?;
11969            let adjacency_error = require_db_error(
11970                reader.repository_graph_adjacency_page(
11971                    &[source.key().clone()],
11972                    RepositoryGraphDirection::Outbound,
11973                    None,
11974                    10,
11975                    None,
11976                ),
11977                "corrupt adjacency relation returned a partial page",
11978            )?;
11979            require(
11980                matches!(adjacency_error, DbError::GraphRowShape { .. }),
11981                &format!("unexpected adjacency row-shape error: {adjacency_error}"),
11982            )?;
11983            reader.finish_index_read_snapshot()?;
11984        }
11985        store.connection.execute(
11986            "UPDATE graph_relations SET candidate_count = 2 WHERE relation_key = ?1",
11987            [&ambiguous_digest[..]],
11988        )?;
11989
11990        store.connection.execute(
11991            "UPDATE graph_relations SET resolution_status = 'resolved'
11992              WHERE relation_key = ?1",
11993            [&ambiguous_digest[..]],
11994        )?;
11995        {
11996            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11997            let error = require_db_error(
11998                reader.repository_graph_relations(
11999                    RepositoryGraphRelationQuery::Outbound {
12000                        source: source.key().clone(),
12001                    },
12002                    10,
12003                ),
12004                "contradictory resolution columns were accepted",
12005            )?;
12006            require(
12007                matches!(error, DbError::GraphRowShape { .. }),
12008                &format!("unexpected resolution-shape error: {error}"),
12009            )?;
12010            reader.finish_index_read_snapshot()?;
12011        }
12012        store.connection.execute(
12013            "UPDATE graph_relations SET resolution_status = 'ambiguous'
12014              WHERE relation_key = ?1",
12015            [&ambiguous_digest[..]],
12016        )?;
12017
12018        store.connection.execute(
12019            "UPDATE graph_coverage SET total = 999
12020              WHERE scope_kind = 'project' AND relation_scope IS NULL",
12021            [],
12022        )?;
12023        {
12024            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12025            let error = require_db_error(
12026                reader.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10),
12027                "contradictory coverage total was accepted",
12028            )?;
12029            require(
12030                matches!(error, DbError::GraphRowShape { .. }),
12031                &format!("unexpected coverage-total error: {error}"),
12032            )?;
12033            reader.finish_index_read_snapshot()?;
12034        }
12035        store.connection.execute(
12036            "UPDATE graph_coverage SET total = covered + omitted
12037              WHERE scope_kind = 'project' AND relation_scope IS NULL",
12038            [],
12039        )?;
12040
12041        store.connection.execute(
12042            "UPDATE project_identity SET active_generation = 99 WHERE singleton = 1",
12043            [],
12044        )?;
12045        {
12046            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12047            let error = require_db_error(
12048                reader.repository_graph_entity(source.key()),
12049                "mismatched typed graph generation was accepted",
12050            )?;
12051            require(
12052                matches!(error, DbError::GraphRowShape { .. }),
12053                &format!("unexpected typed-generation error: {error}"),
12054            )?;
12055            reader.finish_index_read_snapshot()?;
12056        }
12057        store.connection.execute(
12058            "UPDATE project_identity SET active_generation = 1 WHERE singleton = 1",
12059            [],
12060        )?;
12061
12062        store.connection.execute(
12063            "UPDATE graph_entities SET canonical_identity = 'different-collision-witness'
12064              WHERE entity_key = ?1",
12065            [&source_digest[..]],
12066        )?;
12067        {
12068            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12069            let error = require_db_error(
12070                reader.repository_graph_entity(source.key()),
12071                "canonical collision witness was accepted",
12072            )?;
12073            require(
12074                matches!(error, DbError::GraphContract(_)),
12075                &format!("unexpected collision-witness error: {error}"),
12076            )?;
12077            reader.finish_index_read_snapshot()?;
12078        }
12079        store.connection.execute(
12080            "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
12081            params![source_canonical, &source_digest[..]],
12082        )?;
12083
12084        store.connection.execute(
12085            "UPDATE graph_entities SET entity_key = zeroblob(32) WHERE entity_key = ?1",
12086            [&folder_digest[..]],
12087        )?;
12088        let folder_path = RepositoryNodePath::new(Path::new("src"))?;
12089        {
12090            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12091            let error = require_db_error(
12092                reader.repository_graph_entities_by_path(fixture.project, &folder_path, 10),
12093                "invalid stable digest was accepted",
12094            )?;
12095            require(
12096                matches!(error, DbError::GraphContract(_)),
12097                &format!("unexpected stable-digest error: {error}"),
12098            )?;
12099            reader.finish_index_read_snapshot()?;
12100        }
12101        store.connection.execute(
12102            "UPDATE graph_entities SET entity_key = ?1 WHERE entity_key = zeroblob(32)",
12103            [&folder_digest[..]],
12104        )?;
12105
12106        store.connection.execute(
12107            "UPDATE graph_entities SET entity_key = X'01' WHERE entity_key = ?1",
12108            [&folder_digest[..]],
12109        )?;
12110        {
12111            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12112            let error = require_db_error(
12113                reader.repository_graph_entities_by_path(fixture.project, &folder_path, 10),
12114                "short graph key blob was accepted",
12115            )?;
12116            require(
12117                matches!(
12118                    error,
12119                    DbError::InvalidBlobLength {
12120                        field: "graph_entities.entity_key",
12121                        expected: 32,
12122                        found: 1
12123                    }
12124                ),
12125                &format!("unexpected graph-key length error: {error}"),
12126            )?;
12127            reader.finish_index_read_snapshot()?;
12128        }
12129        store.connection.execute(
12130            "UPDATE graph_entities SET entity_key = ?1 WHERE entity_key = X'01'",
12131            [&folder_digest[..]],
12132        )?;
12133
12134        store.connection.execute(
12135            "UPDATE graph_entities SET canonical_identity = X'00' WHERE entity_key = ?1",
12136            [&symbol_digest[..]],
12137        )?;
12138        let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
12139        {
12140            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12141            let error = require_db_error(
12142                reader.repository_graph_entities_by_path(fixture.project, &source_path, 10),
12143                "later row conversion failure returned a successful partial page",
12144            )?;
12145            require(
12146                matches!(error, DbError::Sqlite(_)),
12147                &format!("unexpected later-row conversion error: {error}"),
12148            )?;
12149            reader.finish_index_read_snapshot()?;
12150        }
12151        store.connection.execute(
12152            "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
12153            params![symbol_canonical, &symbol_digest[..]],
12154        )?;
12155        store
12156            .connection
12157            .execute_batch("PRAGMA ignore_check_constraints = OFF")?;
12158        Ok(())
12159    }
12160
12161    #[test]
12162    fn graph_publication_failure_rolls_back_text_graph_and_generation_for_readers()
12163    -> Result<(), Box<dyn Error>> {
12164        let temp = tempfile::tempdir()?;
12165        let project_root = temp.path().join("graph-publication");
12166        let atlas_dir = project_root.join(".projectatlas");
12167        fs::create_dir_all(&atlas_dir)?;
12168        let db_path = atlas_dir.join("projectatlas.db");
12169        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
12170        let fixture_v1 = publish_fixture(&mut writer, "graph-publication")?;
12171        let old_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12172        require_graph_projection(
12173            &old_reader,
12174            &fixture_v1,
12175            IndexGeneration::new(1),
12176            "fn verifyToken()",
12177        )?;
12178
12179        let missing_entity = GraphEntity::new(
12180            fixture_v1.project,
12181            EntitySelector::File {
12182                path: RepositoryFilePath::new(Path::new("src/missing.rs"))?,
12183            },
12184            IndexGeneration::new(2),
12185        )?;
12186        {
12187            let mut publication = writer.begin_index_publication("graph-publication")?;
12188            publication.replace_file_texts_for_paths(
12189                &["src/Äuth.rs".to_string()],
12190                &[IndexedFileText {
12191                    path: "src/Äuth.rs".to_string(),
12192                    content_hash: Some("hash-new".to_string()),
12193                    byte_count: "fn verifyTokenUpdated()".len(),
12194                    line_count: 1,
12195                    content: "fn verifyTokenUpdated()".to_string(),
12196                }],
12197            )?;
12198            let error = require_db_error(
12199                publication.replace_repository_graph_for_paths(
12200                    fixture_v1.project,
12201                    &["src/Äuth.rs".to_string(), "src/missing.rs".to_string()],
12202                    &[missing_entity],
12203                    &[],
12204                    &[],
12205                    &[],
12206                ),
12207                "missing-node graph publication unexpectedly succeeded",
12208            )?;
12209            require(
12210                matches!(error, DbError::Sqlite(_)),
12211                &format!("unexpected late graph publication error: {error}"),
12212            )?;
12213        }
12214
12215        require_graph_projection(
12216            &writer,
12217            &fixture_v1,
12218            IndexGeneration::new(1),
12219            "fn verifyToken()",
12220        )?;
12221        let rolled_back_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12222        require_graph_projection(
12223            &rolled_back_reader,
12224            &fixture_v1,
12225            IndexGeneration::new(1),
12226            "fn verifyToken()",
12227        )?;
12228        require_graph_projection(
12229            &old_reader,
12230            &fixture_v1,
12231            IndexGeneration::new(1),
12232            "fn verifyToken()",
12233        )?;
12234        rolled_back_reader.finish_index_read_snapshot()?;
12235
12236        let project = writer
12237            .project_instance_id()?
12238            .ok_or_else(|| io::Error::other("bound writer identity is missing"))?;
12239        let fixture_v2 = graph_fixture(project, IndexGeneration::new(2))?;
12240        {
12241            let mut publication = writer.begin_index_publication("graph-publication")?;
12242            publication.replace_file_texts_for_paths(
12243                &["src/Äuth.rs".to_string()],
12244                &[IndexedFileText {
12245                    path: "src/Äuth.rs".to_string(),
12246                    content_hash: Some("hash-new".to_string()),
12247                    byte_count: "fn verifyTokenUpdated()".len(),
12248                    line_count: 1,
12249                    content: "fn verifyTokenUpdated()".to_string(),
12250                }],
12251            )?;
12252            publication.replace_repository_graph(
12253                fixture_v2.project,
12254                &fixture_v2.entities,
12255                &fixture_v2.relations,
12256                &fixture_v2.occurrences,
12257                &fixture_v2.coverage,
12258            )?;
12259            publication.complete()?;
12260        }
12261
12262        require_graph_projection(
12263            &old_reader,
12264            &fixture_v1,
12265            IndexGeneration::new(1),
12266            "fn verifyToken()",
12267        )?;
12268        let new_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12269        require_graph_projection(
12270            &new_reader,
12271            &fixture_v2,
12272            IndexGeneration::new(2),
12273            "fn verifyTokenUpdated()",
12274        )?;
12275        new_reader.finish_index_read_snapshot()?;
12276        old_reader.finish_index_read_snapshot()?;
12277        Ok(())
12278    }
12279}