Skip to main content

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    numbered_placeholders, with_sqlite_read_progress,
6};
7use crate::content_classification::parse_classification;
8use crate::derived_snapshot::{CapturedGraph, SnapshotBudget};
9use crate::project_identity::{
10    load_graph_generation, load_project_identity, load_project_root_identity,
11    prove_existing_root_equivalence, require_bound_project_identity, set_graph_generation,
12    set_project_identity, verify_project_identity,
13};
14use crate::schema;
15use projectatlas_core::graph::{
16    CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
17    CoverageState, DocumentTargetUnresolvedReason, EntityResolutionKey, EntitySelector,
18    ExtendedRelationKind, ExternalSelector, GraphContractError, GraphEntity, GraphEntityKey,
19    GraphIdentityField, GraphIdentityRejection, GraphIdentityRejectionReason, GraphIdentityText,
20    GraphLimitKind, GraphLimits, GraphRelationKind, LogicalRelation, LogicalRelationKey,
21    PackageSelector, ProjectInstanceId, RelationDependencyKey, RelationOccurrence,
22    RelationResolution, RepositoryFilePath, RepositoryNodePath, ResolutionKeyDomain, SourceSpan,
23    SymbolSelector,
24};
25use projectatlas_core::language::{ContentClassification, ContentSelection};
26use projectatlas_core::symbols::{ParserKind, RelationKind, SymbolKind};
27use projectatlas_core::{
28    IndexGeneration, IndexWorkControl, IndexWorkStage, NodeKind, RankedConnection,
29    RankedConnectionCount, RankedConnectionDirection, RankedConnectionKind, RankedConnectionTarget,
30};
31use rusqlite::types::Value;
32use rusqlite::{
33    Connection, OpenFlags, OptionalExtension, Row, Transaction, params, params_from_iter,
34};
35use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
36use std::num::{NonZeroU32, NonZeroU64};
37use std::path::Path;
38
39/// Disposable typed repository-graph writer used before main index publication.
40pub struct RepositoryGraphStagingGuard<'store> {
41    /// Transaction owning the staged graph rows.
42    transaction: Transaction<'store>,
43    /// Project identity accepted by every staged row.
44    project: ProjectInstanceId,
45    /// Generation that the main publication must consume.
46    generation: IndexGeneration,
47}
48
49/// Metadata key proving a database was created only as a disposable graph stage.
50const GRAPH_STAGING_MARKER_KEY: &str = "repository_graph_staging";
51/// Current disposable graph-staging marker value.
52const GRAPH_STAGING_MARKER_VALUE: &str = "v1";
53/// Persisted local-resolution status admitted by resolved graph previews.
54const RESOLUTION_STATUS_RESOLVED: &str = "resolved";
55
56/// One bounded page of typed normalized graph rows.
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub struct RepositoryGraphPage<T> {
59    /// Fully validated rows in deterministic storage order.
60    pub rows: Vec<T>,
61    /// Whether at least one additional validated row exists.
62    pub truncated: bool,
63}
64
65/// Validated resource envelope for one bounded repository-graph database read.
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub struct RepositoryGraphReadBudget {
68    /// Maximum input keys, frontier selectors, or purpose-owner paths.
69    requested_rows: NonZeroU32,
70    /// Maximum fully reconstructed rows returned to the caller.
71    returned_rows: NonZeroU32,
72    /// Maximum raw `SQLite` payload bytes decoded by the complete batch.
73    decoded_bytes: NonZeroU64,
74    /// Maximum unique entities reconstructed by the complete batch.
75    hydrated_entities: NonZeroU32,
76    /// Maximum unique purpose-owning repository paths retained from entities.
77    hydrated_paths: NonZeroU32,
78}
79
80impl RepositoryGraphReadBudget {
81    /// Absolute compact-key or purpose-path request ceiling for one batch.
82    pub const MAX_REQUESTED_ROWS: u32 = GraphLimits::MAX_ROWS;
83    /// Absolute reconstructed-row ceiling for one batch.
84    pub const MAX_RETURNED_ROWS: u32 = GraphLimits::MAX_ROWS;
85    /// Absolute decoded payload ceiling for one hydration batch.
86    pub const MAX_DECODED_BYTES: u64 = 32 * 1_024 * 1_024;
87    /// Absolute unique-entity ceiling including one adjacency sentinel.
88    pub const MAX_HYDRATED_ENTITIES: u32 = 2 * (GraphLimits::MAX_ROWS + 1);
89    /// Absolute unique purpose-owner path ceiling for one hydration batch.
90    pub const MAX_HYDRATED_PATHS: u32 = 2 * (GraphLimits::MAX_ROWS + 1);
91
92    /// Construct one bounded repository-graph read envelope.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error when a limit is zero or above its absolute batch
97    /// ceiling.
98    pub fn new(
99        requested_rows: u32,
100        returned_rows: u32,
101        decoded_bytes: u64,
102        hydrated_entities: u32,
103        hydrated_paths: u32,
104    ) -> Result<Self, GraphContractError> {
105        if requested_rows == 0 || requested_rows > Self::MAX_REQUESTED_ROWS {
106            return Err(GraphContractError::InvalidLimits {
107                reason: "graph read requested-row budget is zero or above the batch ceiling",
108            });
109        }
110        if returned_rows == 0 || returned_rows > Self::MAX_RETURNED_ROWS {
111            return Err(GraphContractError::InvalidLimits {
112                reason: "graph read returned-row budget is zero or above the batch ceiling",
113            });
114        }
115        if decoded_bytes == 0 || decoded_bytes > Self::MAX_DECODED_BYTES {
116            return Err(GraphContractError::InvalidLimits {
117                reason: "graph read decoded-byte budget is zero or above the batch ceiling",
118            });
119        }
120        if hydrated_entities == 0 || hydrated_entities > Self::MAX_HYDRATED_ENTITIES {
121            return Err(GraphContractError::InvalidLimits {
122                reason: "graph read entity budget is zero or above the batch ceiling",
123            });
124        }
125        if hydrated_paths == 0 || hydrated_paths > Self::MAX_HYDRATED_PATHS {
126            return Err(GraphContractError::InvalidLimits {
127                reason: "graph read path budget is zero or above the batch ceiling",
128            });
129        }
130        Ok(Self {
131            requested_rows: NonZeroU32::new(requested_rows).ok_or(
132                GraphContractError::InvalidLimits {
133                    reason: "graph read requested-row budget must be nonzero",
134                },
135            )?,
136            returned_rows: NonZeroU32::new(returned_rows).ok_or(
137                GraphContractError::InvalidLimits {
138                    reason: "graph read returned-row budget must be nonzero",
139                },
140            )?,
141            decoded_bytes: NonZeroU64::new(decoded_bytes).ok_or(
142                GraphContractError::InvalidLimits {
143                    reason: "graph read decoded-byte budget must be nonzero",
144                },
145            )?,
146            hydrated_entities: NonZeroU32::new(hydrated_entities).ok_or(
147                GraphContractError::InvalidLimits {
148                    reason: "graph read entity budget must be nonzero",
149                },
150            )?,
151            hydrated_paths: NonZeroU32::new(hydrated_paths).ok_or(
152                GraphContractError::InvalidLimits {
153                    reason: "graph read path budget must be nonzero",
154                },
155            )?,
156        })
157    }
158
159    /// Maximum input keys, frontier selectors, or purpose-owner paths.
160    #[must_use]
161    pub const fn requested_rows(self) -> u32 {
162        self.requested_rows.get()
163    }
164
165    /// Maximum fully reconstructed rows.
166    #[must_use]
167    pub const fn returned_rows(self) -> u32 {
168        self.returned_rows.get()
169    }
170
171    /// Maximum decoded raw payload bytes.
172    #[must_use]
173    pub const fn decoded_bytes(self) -> u64 {
174        self.decoded_bytes.get()
175    }
176
177    /// Maximum unique hydrated entities.
178    #[must_use]
179    pub const fn hydrated_entities(self) -> u32 {
180        self.hydrated_entities.get()
181    }
182
183    /// Maximum unique purpose-owner paths.
184    #[must_use]
185    pub const fn hydrated_paths(self) -> u32 {
186        self.hydrated_paths.get()
187    }
188}
189
190/// Exact work observed while hydrating one stable-key graph batch.
191#[derive(Clone, Copy, Debug, Eq, PartialEq)]
192pub struct RepositoryGraphReadWork {
193    /// Input keys, frontier selectors, or purpose-owner paths supplied.
194    pub requested_rows: u32,
195    /// Fully reconstructed rows returned to the caller.
196    pub returned_rows: u32,
197    /// Raw `SQLite` BLOB, TEXT, and fixed scalar bytes decoded.
198    pub decoded_bytes: u64,
199    /// Unique entities reconstructed from normalized rows.
200    pub hydrated_entities: u32,
201    /// Unique purpose-owning repository paths retained from those entities.
202    pub hydrated_paths: u32,
203}
204
205/// Fully reconstructed graph rows plus their exact bounded read work.
206#[derive(Clone, Debug, Eq, PartialEq)]
207pub struct RepositoryGraphReadBatch<T> {
208    /// Fully validated rows in caller key order.
209    pub rows: Vec<T>,
210    /// Exact resource use for the complete successful batch.
211    pub work: RepositoryGraphReadWork,
212}
213
214/// One stable paged graph result plus exact database work for the page attempt.
215#[derive(Clone, Debug, Eq, PartialEq)]
216pub struct RepositoryGraphReadPage<T> {
217    /// Stable bounded page, including its truncation sentinel result.
218    pub page: RepositoryGraphPage<T>,
219    /// Exact work for all decoded rows, including a removed sentinel.
220    pub work: RepositoryGraphReadWork,
221}
222
223/// Ordered per-owner graph pages plus exact aggregate database work.
224#[derive(Clone, Debug, Eq, PartialEq)]
225pub struct RepositoryGraphReadPages<T> {
226    /// Pages in caller owner order.
227    pub pages: Vec<RepositoryGraphPage<T>>,
228    /// Exact aggregate work across every page and truncation sentinel.
229    pub work: RepositoryGraphReadWork,
230}
231
232/// Bounded filters for opt-in project-wide coverage discovery.
233#[derive(Clone, Debug, Eq, PartialEq)]
234pub struct RepositoryCoverageQuery {
235    /// Zero-based result offset after filters are applied.
236    pub start_index: u32,
237    /// Maximum rows returned before the overflow sentinel.
238    pub limit: u32,
239    /// Optional normalized repository path prefix.
240    pub path_prefix: Option<String>,
241    /// Optional source parser pass.
242    pub parser: Option<ParserKind>,
243    /// Optional derived-fact provider pass.
244    pub provider: Option<ParserKind>,
245    /// Optional relation family.
246    pub relation: Option<GraphRelationKind>,
247    /// Optional coverage lifecycle state.
248    pub state: Option<CoverageState>,
249    /// Optional exact persisted reason.
250    pub reason: Option<String>,
251}
252
253/// One discovered coverage row with parse and fact-provider provenance.
254#[derive(Clone, Debug, Eq, PartialEq)]
255pub struct RepositoryCoverageRow {
256    /// Validated normalized graph coverage record.
257    pub coverage: CoverageRecord,
258    /// Source parser pass for path-scoped coverage.
259    pub parser: Option<ParserKind>,
260    /// Fact provider pass for path-scoped coverage.
261    pub provider: Option<ParserKind>,
262}
263
264/// One folder or file whose current graph context should enrich navigation.
265#[derive(Clone, Debug, Eq, PartialEq)]
266pub struct RepositoryNavigationNode {
267    /// Exact repository-relative path.
268    pub path: String,
269    /// Folder or file ownership semantics used by the set query.
270    pub kind: NodeKind,
271}
272
273/// Bounded current graph evidence for one folder or file navigation row.
274#[derive(Clone, Debug, Eq, PartialEq)]
275pub struct RepositoryNavigationConnections {
276    /// Exact repository-relative owner path.
277    pub path: String,
278    /// Sparse stable-order family counts.
279    pub counts: Vec<RankedConnectionCount>,
280    /// Bounded stable-order connection sample.
281    pub connections: Vec<RankedConnection>,
282    /// Whether the bounded sample omitted any validated relation through family or global overflow.
283    pub truncated: bool,
284}
285
286/// Maximum owners admitted to one generated set-oriented navigation statement.
287const NAVIGATION_CONNECTION_OWNER_CHUNK: usize = 8;
288
289/// Stable family order and normalized persisted selectors for navigation context.
290const NAVIGATION_CONNECTION_FAMILIES: &[(RankedConnectionKind, &str, &str)] = &[
291    (RankedConnectionKind::Package, "legacy", "depends-on"),
292    (RankedConnectionKind::Import, "legacy", "imports"),
293    (RankedConnectionKind::Call, "legacy", "calls"),
294    (RankedConnectionKind::Reference, "extended", "references"),
295    (RankedConnectionKind::Test, "extended", "tests"),
296    (RankedConnectionKind::Route, "extended", "routes-to"),
297    (RankedConnectionKind::Config, "extended", "configures"),
298];
299
300/// Conservative persisted footprint owned by exact affected source paths.
301#[derive(Clone, Copy, Debug, Eq, PartialEq)]
302pub struct RepositoryAffectedSourceFootprint {
303    /// Existing persisted rows, including a conservative resolution-witness allowance.
304    pub rows: u64,
305    /// UTF-8, BLOB, and fixed-width scalar bytes represented by those rows.
306    pub retained_bytes: u64,
307    /// Whether `rows` reached the caller's `LIMIT + 1` overflow sentinel.
308    pub truncated: bool,
309}
310
311/// One persisted export candidate paired with the canonical key that selected it.
312#[derive(Clone, Debug, Eq, PartialEq)]
313pub struct RepositoryResolutionCandidate {
314    /// Exact canonical key exported by the entity.
315    key: CanonicalResolutionKey,
316    /// Typed entity that currently exports the key.
317    entity: GraphEntity,
318}
319
320impl RepositoryResolutionCandidate {
321    /// Borrow the canonical key that selected this candidate.
322    #[must_use]
323    pub const fn key(&self) -> &CanonicalResolutionKey {
324        &self.key
325    }
326
327    /// Borrow the typed export candidate.
328    #[must_use]
329    pub const fn entity(&self) -> &GraphEntity {
330        &self.entity
331    }
332}
333
334/// Closed relation lookup shapes owned by normalized graph storage.
335#[derive(Clone, Debug, Eq, PartialEq)]
336pub enum RepositoryGraphRelationQuery {
337    /// Relations whose source is one exact stable entity.
338    Outbound {
339        /// Exact project-qualified source key.
340        source: GraphEntityKey,
341    },
342    /// Relations whose resolved or external target is one exact stable entity.
343    Inbound {
344        /// Exact project-qualified target key.
345        target: GraphEntityKey,
346    },
347    /// Relations in one typed legacy or extended family.
348    Family {
349        /// Exact relation family.
350        relation: GraphRelationKind,
351    },
352}
353
354/// Direction of one batched normalized-graph adjacency read.
355#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
356pub enum RepositoryGraphDirection {
357    /// Relations whose source is in the selected frontier.
358    Outbound,
359    /// Relations whose retained target is in the selected frontier.
360    Inbound,
361}
362
363/// One normalized relation with the endpoint entities already hydrated.
364#[derive(Clone, Debug, Eq, PartialEq)]
365pub struct RepositoryGraphRelationRow {
366    /// Fully reconstructed normalized relation.
367    pub relation: LogicalRelation,
368    /// Exact source entity named by the relation.
369    pub source: GraphEntity,
370    /// Retained resolved or external target, when the relation has one.
371    pub target: Option<GraphEntity>,
372    /// Closed reason retained only for an unresolved `documents` relation.
373    pub document_unresolved_reason: Option<DocumentTargetUnresolvedReason>,
374}
375
376/// One normalized relation with its file-bearing source classification.
377#[derive(Clone, Debug, Eq, PartialEq)]
378pub struct RepositoryGraphClassifiedRelationRow {
379    /// Fully hydrated normalized relation row.
380    pub detail: RepositoryGraphRelationRow,
381    /// Classification of a file, symbol owner, or package manifest source.
382    pub source_classification: Option<ContentClassification>,
383}
384
385/// Opaque keyset used only to continue one bounded adjacency request.
386#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
387pub struct RepositoryGraphAdjacencyContinuation {
388    /// Project whose read snapshot produced this keyset.
389    project: ProjectInstanceId,
390    /// Complete graph generation whose read snapshot produced this keyset.
391    generation: IndexGeneration,
392    /// Direction whose stable order produced this keyset.
393    direction: RepositoryGraphDirection,
394    /// Optional exact relation family whose stable order produced this keyset.
395    relation: Option<GraphRelationKind>,
396    /// Whether the request admitted only exact local resolutions.
397    #[serde(default, skip_serializing_if = "is_default")]
398    resolved_only: bool,
399    /// Whether unfiltered adjacency admitted the classified document family.
400    #[serde(default, skip_serializing_if = "is_default")]
401    include_documents: bool,
402    /// Ordered frontier identity whose result order produced this keyset.
403    frontier: Vec<[u8; 32]>,
404    /// Zero-based frontier position of the last returned relation.
405    frontier_index: u32,
406    /// Persisted relation family scope of the last returned relation.
407    relation_scope: String,
408    /// Persisted relation family value of the last returned relation.
409    relation_kind: String,
410    /// Stable compact key of the last returned relation.
411    relation_key: [u8; 32],
412}
413
414/// One logical relation paired with the frontier entity that selected it.
415#[derive(Clone, Debug, Eq, PartialEq)]
416pub struct RepositoryGraphAdjacencyRow {
417    /// Zero-based position of the selecting entity in the request frontier.
418    pub frontier_index: u32,
419    /// Exact project-qualified entity that selected this relation.
420    pub frontier: GraphEntityKey,
421    /// Direction relative to the selecting frontier entity.
422    pub direction: RepositoryGraphDirection,
423    /// Normalized relation and its already-hydrated endpoints.
424    pub detail: RepositoryGraphRelationRow,
425}
426
427/// One bounded direction-specific adjacency page.
428#[derive(Clone, Debug, Eq, PartialEq)]
429pub struct RepositoryGraphAdjacencyPage {
430    /// Fully validated rows in deterministic frontier and relation order.
431    pub rows: Vec<RepositoryGraphAdjacencyRow>,
432    /// Whether at least one additional validated relation exists.
433    pub truncated: bool,
434    /// Opaque continuation for the same frontier and direction when truncated.
435    pub continuation: Option<RepositoryGraphAdjacencyContinuation>,
436}
437
438/// One bounded adjacency page plus exact database work for the page attempt.
439#[derive(Clone, Debug, Eq, PartialEq)]
440pub struct RepositoryGraphAdjacencyReadPage {
441    /// Stable direction-specific page and continuation state.
442    pub page: RepositoryGraphAdjacencyPage,
443    /// Exact raw-row and endpoint hydration work for the complete page.
444    pub work: RepositoryGraphReadWork,
445}
446
447/// Maximum unique entities admitted to one batched adjacency statement.
448pub const MAX_REPOSITORY_GRAPH_FRONTIER: usize = 256;
449
450/// Maximum relation rows admitted across all per-frontier query branches.
451const MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS: usize = GraphLimits::MAX_ROWS as usize + 1;
452
453/// Maximum stable entity keys hydrated through one prepared `VALUES` join.
454const GRAPH_ENTITY_HYDRATION_CHUNK: usize = 128;
455
456/// Raw normalized entity row collected before domain reconstruction.
457struct EntityRow {
458    /// Compact stable entity key.
459    key: Vec<u8>,
460    /// Owning project identity.
461    project: Vec<u8>,
462    /// Canonical collision witness.
463    canonical: String,
464    /// Normalized selector variant.
465    kind: String,
466    /// Folder, file, or symbol repository path.
467    repository_path: Option<String>,
468    /// Package ecosystem.
469    package_manager: Option<String>,
470    /// Manifest package name.
471    package_name: Option<String>,
472    /// Owning package manifest.
473    manifest_path: Option<String>,
474    /// Declaration name.
475    symbol_name: Option<String>,
476    /// Declaration kind.
477    symbol_kind: Option<String>,
478    /// Optional containing declaration.
479    symbol_parent: Option<String>,
480    /// Stable declaration signature.
481    symbol_signature: Option<String>,
482    /// External namespace.
483    external_system: Option<String>,
484    /// Identity inside the external namespace.
485    external_identity: Option<String>,
486}
487
488/// Raw normalized relation row collected before domain reconstruction.
489struct RelationRow {
490    /// Compact stable relation key.
491    key: Vec<u8>,
492    /// Owning project identity.
493    project: Vec<u8>,
494    /// Canonical collision witness.
495    canonical: String,
496    /// Stable source entity key.
497    source: Vec<u8>,
498    /// Legacy or extended family scope.
499    relation_scope: String,
500    /// Family spelling within the scope.
501    relation_kind: String,
502    /// Resolution lifecycle state.
503    resolution_status: String,
504    /// Optional resolved or external target key.
505    target: Option<Vec<u8>>,
506    /// Optional unresolved reference text.
507    reference: Option<String>,
508    /// Optional ambiguous candidate count.
509    candidate_count: Option<i64>,
510    /// Optional closed reason for an unresolved document target.
511    document_unresolved_reason: Option<String>,
512    /// Coarse trust class.
513    confidence: String,
514    /// Producer completeness.
515    completeness: String,
516}
517
518/// Raw relation-family row with its source classification projected in SQL.
519struct ClassifiedRelationRow {
520    /// Normalized relation columns.
521    relation: RelationRow,
522    /// Persisted source classification for file-bearing entities.
523    source_classification: Option<String>,
524}
525
526/// One raw relation paired with its selecting frontier position.
527struct AdjacencyRelationRow {
528    /// Zero-based position inside the request frontier.
529    frontier_index: u32,
530    /// Raw normalized relation selected by the indexed adjacency branch.
531    relation: RelationRow,
532}
533
534/// Raw normalized relation occurrence row.
535struct OccurrenceRow {
536    /// Stable logical relation key.
537    relation: Vec<u8>,
538    /// Exact repository-local source file.
539    file_path: String,
540    /// First one-based source line.
541    start_line: i64,
542    /// First zero-based source column.
543    start_column: i64,
544    /// Last one-based source line.
545    end_line: i64,
546    /// Exclusive zero-based end column.
547    end_column: i64,
548}
549
550/// Raw normalized graph coverage row.
551struct CoverageRow {
552    /// Owning project identity.
553    project: Vec<u8>,
554    /// Project or path scope discriminator.
555    scope_kind: String,
556    /// Optional repository path scope.
557    scope_path: Option<String>,
558    /// Optional legacy or extended relation scope.
559    relation_scope: Option<String>,
560    /// Optional relation family spelling.
561    relation_kind: Option<String>,
562    /// Coverage lifecycle state.
563    state: String,
564    /// Persisted total items in scope.
565    total: i64,
566    /// Successfully covered items.
567    covered: i64,
568    /// Omitted or untrusted items.
569    omitted: i64,
570    /// Optional actionable explanation.
571    reason: Option<String>,
572    /// Optional reached product limit.
573    reached_limit: Option<String>,
574    /// Optional source parser pass joined from file metadata.
575    parser: Option<String>,
576    /// Optional derived-fact provider pass joined from file metadata.
577    provider: Option<String>,
578}
579
580/// Mutable accounting retained only for one bounded hydration call.
581pub(crate) struct RepositoryGraphReadMeter {
582    /// Validated caller envelope.
583    budget: RepositoryGraphReadBudget,
584    /// Input selectors admitted before any query runs.
585    requested_rows: u32,
586    /// Raw payload bytes decoded so far.
587    decoded_bytes: u64,
588    /// Unique entities reconstructed so far.
589    hydrated_entities: u32,
590    /// Unique purpose-owning paths retained from hydrated entities.
591    hydrated_paths: HashSet<String>,
592}
593
594impl RepositoryGraphReadMeter {
595    /// Admit one request before any `SQLite` work begins.
596    pub(crate) fn new(budget: RepositoryGraphReadBudget, requested_rows: usize) -> DbResult<Self> {
597        let requested_rows =
598            u32::try_from(requested_rows).map_err(|_source| GraphContractError::InvalidLimits {
599                reason: "graph read requested-row count overflowed",
600            })?;
601        if requested_rows > budget.requested_rows() {
602            return Err(GraphContractError::InvalidLimits {
603                reason: "graph read requested rows exceed the batch budget",
604            }
605            .into());
606        }
607        Ok(Self {
608            budget,
609            requested_rows,
610            decoded_bytes: 0,
611            hydrated_entities: 0,
612            hydrated_paths: HashSet::new(),
613        })
614    }
615
616    /// Charge one decoded raw row before it leaves the row iterator.
617    pub(crate) fn record_decoded_bytes(&mut self, bytes: u64) -> DbResult<()> {
618        let decoded_bytes =
619            self.decoded_bytes
620                .checked_add(bytes)
621                .ok_or(GraphContractError::InvalidLimits {
622                    reason: "graph read decoded-byte accounting overflowed",
623                })?;
624        if decoded_bytes > self.budget.decoded_bytes() {
625            return Err(GraphContractError::InvalidLimits {
626                reason: "graph read decoded bytes exceed the batch budget",
627            }
628            .into());
629        }
630        self.decoded_bytes = decoded_bytes;
631        Ok(())
632    }
633
634    /// Charge one unique reconstructed entity and its exact purpose owner path.
635    fn record_entity(&mut self, entity: &GraphEntity) -> DbResult<()> {
636        let hydrated_entities =
637            self.hydrated_entities
638                .checked_add(1)
639                .ok_or(GraphContractError::InvalidLimits {
640                    reason: "graph read entity accounting overflowed",
641                })?;
642        if hydrated_entities > self.budget.hydrated_entities() {
643            return Err(GraphContractError::InvalidLimits {
644                reason: "graph read hydrated entities exceed the batch budget",
645            }
646            .into());
647        }
648        if let Some(path) = graph_entity_purpose_owner(entity) {
649            self.record_hydrated_path(path)?;
650        }
651        self.hydrated_entities = hydrated_entities;
652        Ok(())
653    }
654
655    /// Charge one unique repository path hydrated from authoritative node state.
656    pub(crate) fn record_hydrated_path(&mut self, path: &str) -> DbResult<()> {
657        if self.hydrated_paths.contains(path) {
658            return Ok(());
659        }
660        let hydrated_paths = u32::try_from(self.hydrated_paths.len()).map_err(|_source| {
661            GraphContractError::InvalidLimits {
662                reason: "graph read path accounting overflowed",
663            }
664        })?;
665        if hydrated_paths >= self.budget.hydrated_paths() {
666            return Err(GraphContractError::InvalidLimits {
667                reason: "graph read hydrated paths exceed the batch budget",
668            }
669            .into());
670        }
671        self.hydrated_paths.insert(path.to_string());
672        Ok(())
673    }
674
675    /// Finish exact work only after every requested row was reconstructed.
676    pub(crate) fn finish(self, returned_rows: usize) -> DbResult<RepositoryGraphReadWork> {
677        let returned_rows =
678            u32::try_from(returned_rows).map_err(|_source| GraphContractError::InvalidLimits {
679                reason: "graph read returned-row count overflowed",
680            })?;
681        if returned_rows > self.budget.returned_rows() {
682            return Err(GraphContractError::InvalidLimits {
683                reason: "graph read returned rows exceed the batch budget",
684            }
685            .into());
686        }
687        let hydrated_paths = u32::try_from(self.hydrated_paths.len()).map_err(|_source| {
688            GraphContractError::InvalidLimits {
689                reason: "graph read path accounting overflowed",
690            }
691        })?;
692        Ok(RepositoryGraphReadWork {
693            requested_rows: self.requested_rows,
694            returned_rows,
695            decoded_bytes: self.decoded_bytes,
696            hydrated_entities: self.hydrated_entities,
697            hydrated_paths,
698        })
699    }
700}
701
702/// One bounded flattened relation row used only for navigation enrichment.
703struct NavigationConnectionRow {
704    /// Zero-based owner position inside the current statement chunk.
705    owner_index: usize,
706    /// Closed connection family selected by the query branch.
707    kind: RankedConnectionKind,
708    /// Direction relative to the owner.
709    direction: RankedConnectionDirection,
710    /// Stable relation key used for deterministic ordering and deduplication.
711    relation_key: Vec<u8>,
712    /// Persisted resolution lifecycle state.
713    resolution_status: String,
714    /// Persisted unresolved or ambiguous identity.
715    reference: Option<String>,
716    /// Related normalized entity kind, absent only for unresolved output.
717    entity_kind: Option<String>,
718    /// Related repository path.
719    repository_path: Option<String>,
720    /// Related package ecosystem.
721    package_manager: Option<String>,
722    /// Related package name.
723    package_name: Option<String>,
724    /// Related package manifest.
725    manifest_path: Option<String>,
726    /// Related declaration name.
727    symbol_name: Option<String>,
728    /// Related external namespace.
729    external_system: Option<String>,
730    /// Related external identity.
731    external_identity: Option<String>,
732}
733
734/// Return one empty navigation page while retaining the requested owner path.
735fn empty_navigation_connections(path: &str) -> RepositoryNavigationConnections {
736    RepositoryNavigationConnections {
737        path: path.to_string(),
738        counts: Vec::new(),
739        connections: Vec::new(),
740        truncated: false,
741    }
742}
743
744/// Load one bounded chunk with a single compound set-oriented statement.
745fn collect_navigation_connection_rows(
746    connection: &Connection,
747    owners: &[RepositoryNavigationNode],
748    family_limit_plus_one: i64,
749) -> DbResult<Vec<NavigationConnectionRow>> {
750    let mut branches = Vec::with_capacity(owners.len() * NAVIGATION_CONNECTION_FAMILIES.len() * 2);
751    let mut values = Vec::new();
752    for (owner_index, owner) in owners.iter().enumerate() {
753        for &(kind, scope, relation) in NAVIGATION_CONNECTION_FAMILIES {
754            branches.push(navigation_connection_branch(
755                owner_index,
756                owner,
757                kind,
758                scope,
759                relation,
760                RankedConnectionDirection::Outbound,
761                family_limit_plus_one,
762                &mut values,
763            ));
764            if owner.kind != NodeKind::Folder || owner.path != "." {
765                branches.push(navigation_connection_branch(
766                    owner_index,
767                    owner,
768                    kind,
769                    scope,
770                    relation,
771                    RankedConnectionDirection::Inbound,
772                    family_limit_plus_one,
773                    &mut values,
774                ));
775            }
776        }
777    }
778    let sql = branches.join(" UNION ALL ");
779    let mut statement = connection.prepare(&sql)?;
780    let mut rows = statement.query(params_from_iter(values))?;
781    let mut collected = Vec::new();
782    while let Some(row) = rows.next()? {
783        collected.push(navigation_connection_row(row)?);
784    }
785    Ok(collected)
786}
787
788/// Build one indexed outbound or inbound query branch for one owner and family.
789fn navigation_connection_branch(
790    owner_index: usize,
791    owner: &RepositoryNavigationNode,
792    kind: RankedConnectionKind,
793    scope: &'static str,
794    relation: &'static str,
795    direction: RankedConnectionDirection,
796    family_limit_plus_one: i64,
797    values: &mut Vec<Value>,
798) -> String {
799    values.push(Value::Integer(owner_index as i64));
800    values.push(Value::Text(
801        navigation_connection_kind_name(kind).to_string(),
802    ));
803    if owner.kind == NodeKind::Folder
804        && owner.path == "."
805        && direction == RankedConnectionDirection::Outbound
806    {
807        values.push(Value::Text(scope.to_string()));
808        values.push(Value::Text(relation.to_string()));
809        values.push(Value::Integer(family_limit_plus_one));
810        return "SELECT * FROM (
811                    SELECT ? AS owner_index, ? AS expected_kind, 'outbound' AS direction,
812                           r.relation_key, r.relation_scope, r.relation_kind,
813                           r.resolution_status, r.reference_text,
814                           related.entity_kind, related.repository_path,
815                           related.package_manager, related.package_name, related.manifest_path,
816                           related.symbol_name, related.external_system, related.external_identity
817                      FROM graph_relations r INDEXED BY idx_graph_relations_kind_order
818                      LEFT JOIN graph_entities related
819                        ON related.entity_key = r.target_entity_key
820                     WHERE r.relation_scope = ? AND r.relation_kind = ?
821                     ORDER BY r.relation_key
822                     LIMIT ?
823                )"
824        .to_string();
825    }
826    let (relation_key, related_key, index, direction_name) = match direction {
827        RankedConnectionDirection::Outbound => (
828            "source_entity_key",
829            "target_entity_key",
830            "idx_graph_relations_source_kind",
831            "outbound",
832        ),
833        RankedConnectionDirection::Inbound => (
834            "target_entity_key",
835            "source_entity_key",
836            "idx_graph_relations_target_kind",
837            "inbound",
838        ),
839    };
840    let owned = navigation_owned_entity_sql(owner, values);
841    let exclude_internal = if direction == RankedConnectionDirection::Inbound {
842        let owned_sources = navigation_owned_entity_sql(owner, values);
843        format!(" AND r.source_entity_key NOT IN ({owned_sources})")
844    } else {
845        String::new()
846    };
847    values.push(Value::Text(scope.to_string()));
848    values.push(Value::Text(relation.to_string()));
849    values.push(Value::Integer(family_limit_plus_one));
850    format!(
851        "SELECT * FROM (
852             SELECT ? AS owner_index, ? AS expected_kind, '{direction_name}' AS direction,
853                    r.relation_key, r.relation_scope, r.relation_kind,
854                    r.resolution_status, r.reference_text,
855                    related.entity_kind, related.repository_path,
856                    related.package_manager, related.package_name, related.manifest_path,
857                    related.symbol_name, related.external_system, related.external_identity
858               FROM graph_relations r INDEXED BY {index}
859               LEFT JOIN graph_entities related ON related.entity_key = r.{related_key}
860              WHERE r.{relation_key} IN ({owned})
861                {exclude_internal}
862                AND r.relation_scope = ? AND r.relation_kind = ?
863              ORDER BY r.relation_key
864              LIMIT ?
865         )"
866    )
867}
868
869/// Build the indexed entity-key ownership set for one navigation owner.
870fn navigation_owned_entity_sql(
871    owner: &RepositoryNavigationNode,
872    values: &mut Vec<Value>,
873) -> String {
874    match owner.kind {
875        NodeKind::File => {
876            values.push(Value::Text(owner.path.clone()));
877            values.push(Value::Text(owner.path.clone()));
878            "SELECT entity_key
879               FROM graph_entities INDEXED BY idx_graph_entities_path
880              WHERE repository_path = ?
881             UNION
882             SELECT entity_key
883               FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
884              WHERE manifest_path = ?"
885                .to_string()
886        }
887        NodeKind::Folder if owner.path == "." => "SELECT entity_key
888               FROM graph_entities INDEXED BY idx_graph_entities_path
889              WHERE repository_path IS NOT NULL
890             UNION
891             SELECT entity_key
892               FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
893              WHERE manifest_path IS NOT NULL"
894            .to_string(),
895        NodeKind::Folder => {
896            let lower = format!("{}/", owner.path);
897            let upper = format!("{}0", owner.path);
898            values.push(Value::Text(owner.path.clone()));
899            values.push(Value::Text(lower.clone()));
900            values.push(Value::Text(upper.clone()));
901            values.push(Value::Text(owner.path.clone()));
902            values.push(Value::Text(lower));
903            values.push(Value::Text(upper));
904            "SELECT entity_key
905               FROM graph_entities INDEXED BY idx_graph_entities_path
906              WHERE repository_path = ?
907             UNION
908             SELECT entity_key
909               FROM graph_entities INDEXED BY idx_graph_entities_path
910              WHERE repository_path >= ? AND repository_path < ?
911             UNION
912             SELECT entity_key
913               FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
914              WHERE manifest_path = ?
915             UNION
916             SELECT entity_key
917               FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
918              WHERE manifest_path >= ? AND manifest_path < ?"
919                .to_string()
920        }
921    }
922}
923
924/// Decode one flattened navigation row without accepting partial corruption.
925fn navigation_connection_row(row: &Row<'_>) -> DbResult<NavigationConnectionRow> {
926    let owner_index = count_to_usize("navigation_connection.owner_index", row.get(0)?)?;
927    let expected_kind: String = row.get(1)?;
928    let direction = match row.get::<_, String>(2)?.as_str() {
929        "outbound" => RankedConnectionDirection::Outbound,
930        "inbound" => RankedConnectionDirection::Inbound,
931        _ => {
932            return Err(DbError::GraphRowShape {
933                table: "graph_relations",
934                reason: "navigation relation direction is invalid",
935            });
936        }
937    };
938    let relation_key: Vec<u8> = row.get(3)?;
939    if relation_key.len() != 32 {
940        return Err(DbError::InvalidBlobLength {
941            field: "graph_relations.relation_key",
942            expected: 32,
943            found: relation_key.len(),
944        });
945    }
946    let relation_scope: String = row.get(4)?;
947    let relation_kind: String = row.get(5)?;
948    let kind = navigation_connection_kind(&relation_scope, &relation_kind)?;
949    if navigation_connection_kind_name(kind) != expected_kind {
950        return Err(DbError::GraphRowShape {
951            table: "graph_relations",
952            reason: "navigation relation family does not match its query branch",
953        });
954    }
955    let resolution_status: String = row.get(6)?;
956    if !matches!(
957        resolution_status.as_str(),
958        "resolved" | "ambiguous" | "unresolved" | "external"
959    ) {
960        return Err(DbError::InvalidEnum {
961            field: "graph_relations.resolution_status",
962            value: resolution_status,
963        });
964    }
965    Ok(NavigationConnectionRow {
966        owner_index,
967        kind,
968        direction,
969        relation_key,
970        resolution_status,
971        reference: row.get(7)?,
972        entity_kind: row.get(8)?,
973        repository_path: row.get(9)?,
974        package_manager: row.get(10)?,
975        package_name: row.get(11)?,
976        manifest_path: row.get(12)?,
977        symbol_name: row.get(13)?,
978        external_system: row.get(14)?,
979        external_identity: row.get(15)?,
980    })
981}
982
983/// Compose deterministic per-owner pages from fully decoded rows.
984fn navigation_connection_pages(
985    owners: &[RepositoryNavigationNode],
986    mut rows: Vec<NavigationConnectionRow>,
987    family_limit: usize,
988    sample_limit: usize,
989) -> DbResult<Vec<RepositoryNavigationConnections>> {
990    rows.sort_by(|left, right| {
991        left.owner_index
992            .cmp(&right.owner_index)
993            .then_with(|| left.kind.cmp(&right.kind))
994            .then_with(|| {
995                navigation_direction_order(left.direction)
996                    .cmp(&navigation_direction_order(right.direction))
997            })
998            .then_with(|| left.relation_key.cmp(&right.relation_key))
999    });
1000    let mut grouped =
1001        BTreeMap::<(usize, RankedConnectionKind), Vec<NavigationConnectionRow>>::new();
1002    for row in rows {
1003        if row.owner_index >= owners.len() {
1004            return Err(DbError::GraphRowShape {
1005                table: "graph_relations",
1006                reason: "navigation relation owner index is outside its request chunk",
1007            });
1008        }
1009        grouped
1010            .entry((row.owner_index, row.kind))
1011            .or_default()
1012            .push(row);
1013    }
1014
1015    owners
1016        .iter()
1017        .enumerate()
1018        .map(|(owner_index, owner)| {
1019            let mut page = empty_navigation_connections(&owner.path);
1020            for &(kind, _, _) in NAVIGATION_CONNECTION_FAMILIES {
1021                let Some(rows) = grouped.remove(&(owner_index, kind)) else {
1022                    continue;
1023                };
1024                let mut seen = HashSet::new();
1025                let unique = rows
1026                    .into_iter()
1027                    .filter(|row| seen.insert(row.relation_key.clone()))
1028                    .collect::<Vec<_>>();
1029                let truncated = unique.len() > family_limit;
1030                let count = unique.len().min(family_limit);
1031                page.counts.push(RankedConnectionCount {
1032                    kind,
1033                    count,
1034                    truncated,
1035                });
1036                for (family_index, row) in unique.into_iter().enumerate() {
1037                    let target = navigation_connection_target(&row)?;
1038                    if family_index < family_limit && page.connections.len() < sample_limit {
1039                        page.connections.push(RankedConnection {
1040                            kind,
1041                            direction: row.direction,
1042                            target,
1043                        });
1044                    } else if family_index < family_limit {
1045                        page.truncated = true;
1046                    }
1047                }
1048                page.truncated |= truncated;
1049            }
1050            Ok(page)
1051        })
1052        .collect()
1053}
1054
1055/// Convert a persisted endpoint or unresolved reference into its compact target.
1056fn navigation_connection_target(row: &NavigationConnectionRow) -> DbResult<RankedConnectionTarget> {
1057    if row.direction == RankedConnectionDirection::Outbound
1058        && matches!(row.resolution_status.as_str(), "ambiguous" | "unresolved")
1059    {
1060        let reference = row.reference.clone().ok_or(DbError::GraphRowShape {
1061            table: "graph_relations",
1062            reason: "unresolved navigation relation is missing its reference",
1063        })?;
1064        if row.entity_kind.is_some() {
1065            return Err(DbError::GraphRowShape {
1066                table: "graph_relations",
1067                reason: "unresolved navigation relation retained a target entity",
1068            });
1069        }
1070        return Ok(RankedConnectionTarget::Unresolved { reference });
1071    }
1072    if !matches!(row.resolution_status.as_str(), "resolved" | "external") {
1073        return Err(DbError::GraphRowShape {
1074            table: "graph_relations",
1075            reason: "inbound navigation relation has no resolved target",
1076        });
1077    }
1078    match row.entity_kind.as_deref() {
1079        Some("project") => Ok(RankedConnectionTarget::Local {
1080            path: ".".to_string(),
1081            symbol: None,
1082        }),
1083        Some("folder" | "file") => Ok(RankedConnectionTarget::Local {
1084            path: required_navigation_target(
1085                row.repository_path.as_deref(),
1086                "local navigation target is missing its repository path",
1087            )?,
1088            symbol: None,
1089        }),
1090        Some("symbol") => Ok(RankedConnectionTarget::Local {
1091            path: required_navigation_target(
1092                row.repository_path.as_deref(),
1093                "symbol navigation target is missing its repository path",
1094            )?,
1095            symbol: Some(required_navigation_target(
1096                row.symbol_name.as_deref(),
1097                "symbol navigation target is missing its declaration name",
1098            )?),
1099        }),
1100        Some("package") => Ok(RankedConnectionTarget::Package {
1101            manager: required_navigation_target(
1102                row.package_manager.as_deref(),
1103                "package navigation target is missing its manager",
1104            )?,
1105            name: required_navigation_target(
1106                row.package_name.as_deref(),
1107                "package navigation target is missing its name",
1108            )?,
1109            manifest: required_navigation_target(
1110                row.manifest_path.as_deref(),
1111                "package navigation target is missing its manifest",
1112            )?,
1113        }),
1114        Some("external") => Ok(RankedConnectionTarget::External {
1115            system: required_navigation_target(
1116                row.external_system.as_deref(),
1117                "external navigation target is missing its system",
1118            )?,
1119            identity: required_navigation_target(
1120                row.external_identity.as_deref(),
1121                "external navigation target is missing its identity",
1122            )?,
1123        }),
1124        Some(_) => Err(DbError::GraphRowShape {
1125            table: "graph_entities",
1126            reason: "navigation endpoint has an unsupported entity kind",
1127        }),
1128        None => Err(DbError::GraphRowShape {
1129            table: "graph_relations",
1130            reason: "resolved navigation relation target is missing",
1131        }),
1132    }
1133}
1134
1135/// Clone one required nonempty target field.
1136fn required_navigation_target(value: Option<&str>, reason: &'static str) -> DbResult<String> {
1137    value
1138        .filter(|value| !value.is_empty())
1139        .map(str::to_string)
1140        .ok_or(DbError::GraphRowShape {
1141            table: "graph_entities",
1142            reason,
1143        })
1144}
1145
1146/// Map persisted relation family fields to the navigation family inventory.
1147fn navigation_connection_kind(scope: &str, relation: &str) -> DbResult<RankedConnectionKind> {
1148    NAVIGATION_CONNECTION_FAMILIES
1149        .iter()
1150        .find_map(|&(kind, expected_scope, expected_relation)| {
1151            (scope == expected_scope && relation == expected_relation).then_some(kind)
1152        })
1153        .ok_or(DbError::GraphRowShape {
1154            table: "graph_relations",
1155            reason: "relation family is not available to navigation enrichment",
1156        })
1157}
1158
1159/// Return the stable compact name for one navigation family.
1160const fn navigation_connection_kind_name(kind: RankedConnectionKind) -> &'static str {
1161    match kind {
1162        RankedConnectionKind::Package => "package",
1163        RankedConnectionKind::Import => "import",
1164        RankedConnectionKind::Call => "call",
1165        RankedConnectionKind::Reference => "reference",
1166        RankedConnectionKind::Test => "test",
1167        RankedConnectionKind::Route => "route",
1168        RankedConnectionKind::Config => "config",
1169    }
1170}
1171
1172/// Return stable outbound-before-inbound sample order.
1173const fn navigation_direction_order(direction: RankedConnectionDirection) -> u8 {
1174    match direction {
1175        RankedConnectionDirection::Outbound => 0,
1176        RankedConnectionDirection::Inbound => 1,
1177    }
1178}
1179
1180impl AtlasStore {
1181    /// Load bounded current graph context for folder and file navigation rows.
1182    ///
1183    /// Owners are processed through a fixed-size set-oriented statement per
1184    /// chunk. Each family uses separate indexed outbound and inbound branches,
1185    /// exact file plus manifest ownership, or bounded folder-prefix ownership.
1186    /// No partial result is returned if any statement or row fails.
1187    ///
1188    /// # Errors
1189    ///
1190    /// Returns an error for invalid paths or limits, unavailable publication
1191    /// state, `SQLite` failures, or any corrupt relation or endpoint row.
1192    pub fn repository_navigation_connections(
1193        &self,
1194        owners: &[RepositoryNavigationNode],
1195        family_limit: u32,
1196        sample_limit: usize,
1197    ) -> DbResult<Vec<RepositoryNavigationConnections>> {
1198        let family_limit_plus_one = validated_limit_plus_one(
1199            family_limit,
1200            GraphLimits::MAX_ROWS,
1201            "navigation connection rows must be nonzero and within the product ceiling",
1202        )?;
1203        if sample_limit == 0 || sample_limit > GraphLimits::MAX_ROWS as usize {
1204            return Err(GraphContractError::InvalidLimits {
1205                reason: "navigation connection sample must be nonzero and within the product ceiling",
1206            }
1207            .into());
1208        }
1209        for owner in owners {
1210            match owner.kind {
1211                NodeKind::Folder => {
1212                    RepositoryNodePath::new(Path::new(&owner.path))?;
1213                }
1214                NodeKind::File => {
1215                    RepositoryFilePath::new(Path::new(&owner.path))?;
1216                }
1217            }
1218        }
1219        if owners.is_empty() {
1220            return Ok(Vec::new());
1221        }
1222        if self.repository_graph_generation()?.is_none() {
1223            return Ok(owners
1224                .iter()
1225                .map(|owner| empty_navigation_connections(&owner.path))
1226                .collect());
1227        }
1228        let project = load_project_identity(&self.connection)?
1229            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
1230        require_bound_project_identity(&self.connection, project)?;
1231
1232        let mut result = Vec::with_capacity(owners.len());
1233        for chunk in owners.chunks(NAVIGATION_CONNECTION_OWNER_CHUNK) {
1234            let rows =
1235                collect_navigation_connection_rows(&self.connection, chunk, family_limit_plus_one)?;
1236            result.extend(navigation_connection_pages(
1237                chunk,
1238                rows,
1239                family_limit as usize,
1240                sample_limit,
1241            )?);
1242        }
1243        Ok(result)
1244    }
1245
1246    /// Load one typed graph entity by its compact stable key.
1247    ///
1248    /// # Errors
1249    ///
1250    /// Returns an error when publication state, project identity, row shape,
1251    /// canonical identity, or persisted key material is invalid.
1252    pub fn repository_graph_entity(&self, key: &GraphEntityKey) -> DbResult<Option<GraphEntity>> {
1253        let Some(generation) = self.repository_graph_generation()? else {
1254            return Ok(None);
1255        };
1256        if !verify_project_identity(&self.connection, key.project())? {
1257            return Ok(None);
1258        }
1259        Ok(self
1260            .repository_graph_entity_bounded(
1261                key,
1262                generation,
1263                maximum_repository_graph_read_budget()?,
1264                None,
1265            )?
1266            .rows
1267            .into_iter()
1268            .next())
1269    }
1270
1271    /// Load one optional exact entity under a stable graph read envelope.
1272    ///
1273    /// # Errors
1274    ///
1275    /// Returns an error for a stale project or generation, cancellation,
1276    /// `SQLite` failure, corrupt entity state, or any decoded-byte, entity, or
1277    /// path hydration overrun. A missing exact key returns an empty successful
1278    /// batch with exact zero returned work.
1279    pub fn repository_graph_entity_bounded(
1280        &self,
1281        key: &GraphEntityKey,
1282        generation: IndexGeneration,
1283        budget: RepositoryGraphReadBudget,
1284        control: Option<&IndexWorkControl>,
1285    ) -> DbResult<RepositoryGraphReadBatch<GraphEntity>> {
1286        self.require_repository_graph_snapshot(key.project(), generation)?;
1287        let digest = key.digest_bytes()?;
1288        let mut meter = RepositoryGraphReadMeter::new(budget, 1)?;
1289        let mut entities = load_graph_entities_by_digest_metered(
1290            self,
1291            &[digest],
1292            key.project(),
1293            generation,
1294            control,
1295            Some(&mut meter),
1296        )?;
1297        let rows = entities.remove(&digest).into_iter().collect::<Vec<_>>();
1298        let work = meter.finish(rows.len())?;
1299        Ok(RepositoryGraphReadBatch { rows, work })
1300    }
1301
1302    /// Hydrate an ordered unique set of graph entities from compact stable keys.
1303    ///
1304    /// # Errors
1305    ///
1306    /// Returns an error for duplicate or oversized input, a stale project or
1307    /// generation, cancellation, a missing entity, `SQLite` failure, or any
1308    /// invalid persisted key or canonical identity. No partial set is returned.
1309    pub fn repository_graph_entities_by_digest(
1310        &self,
1311        project: ProjectInstanceId,
1312        generation: IndexGeneration,
1313        digests: &[[u8; 32]],
1314        budget: RepositoryGraphReadBudget,
1315        control: Option<&IndexWorkControl>,
1316    ) -> DbResult<RepositoryGraphReadBatch<GraphEntity>> {
1317        validate_graph_hydration_request(digests)?;
1318        self.require_repository_graph_snapshot(project, generation)?;
1319        let mut meter = RepositoryGraphReadMeter::new(budget, digests.len())?;
1320        let mut entities = load_graph_entities_by_digest_metered(
1321            self,
1322            digests,
1323            project,
1324            generation,
1325            control,
1326            Some(&mut meter),
1327        )?;
1328        let mut ordered = Vec::with_capacity(digests.len());
1329        for digest in digests {
1330            ordered.push(entities.remove(digest).ok_or(DbError::GraphRowShape {
1331                table: "graph_entities",
1332                reason: "requested graph entity is missing",
1333            })?);
1334        }
1335        let work = meter.finish(ordered.len())?;
1336        Ok(RepositoryGraphReadBatch {
1337            rows: ordered,
1338            work,
1339        })
1340    }
1341
1342    /// Load a bounded page of entities that use one exact repository path.
1343    ///
1344    /// # Errors
1345    ///
1346    /// Returns an error for invalid limits, unavailable publication state,
1347    /// project mismatch, `SQLite` failure, or any corrupt row in the page.
1348    pub fn repository_graph_entities_by_path(
1349        &self,
1350        project: ProjectInstanceId,
1351        path: &RepositoryNodePath,
1352        limit: u32,
1353    ) -> DbResult<RepositoryGraphPage<GraphEntity>> {
1354        validated_limit_plus_one(
1355            limit,
1356            GraphLimits::MAX_ROWS,
1357            "graph rows must be nonzero and within the product ceiling",
1358        )?;
1359        let Some(generation) = self.repository_graph_generation()? else {
1360            return Ok(empty_page());
1361        };
1362        if !verify_project_identity(&self.connection, project)? {
1363            return Ok(empty_page());
1364        }
1365        Ok(self
1366            .repository_graph_entities_by_path_bounded(
1367                project,
1368                generation,
1369                path,
1370                limit,
1371                maximum_repository_graph_read_budget()?,
1372                None,
1373            )?
1374            .page)
1375    }
1376
1377    /// Load one exact-path entity page under a stable graph read envelope.
1378    ///
1379    /// # Errors
1380    ///
1381    /// Returns the same fail-closed errors as
1382    /// [`Self::repository_graph_entities_by_path`], rejects a stale project or
1383    /// generation and any returned-row, decoded-byte, entity, or path hydration
1384    /// overrun, and meters the raw truncation sentinel.
1385    pub fn repository_graph_entities_by_path_bounded(
1386        &self,
1387        project: ProjectInstanceId,
1388        generation: IndexGeneration,
1389        path: &RepositoryNodePath,
1390        limit: u32,
1391        budget: RepositoryGraphReadBudget,
1392        control: Option<&IndexWorkControl>,
1393    ) -> DbResult<RepositoryGraphReadPage<GraphEntity>> {
1394        let limit_plus_one = validated_limit_plus_one(
1395            limit,
1396            GraphLimits::MAX_ROWS,
1397            "graph rows must be nonzero and within the product ceiling",
1398        )?;
1399        self.require_repository_graph_snapshot(project, generation)?;
1400        let mut meter = RepositoryGraphReadMeter::new(budget, 1)?;
1401        let raw = with_sqlite_read_progress(
1402            &self.connection,
1403            control,
1404            IndexWorkStage::RepositoryTraversal,
1405            || {
1406                let mut statement = self.connection.prepare_cached(
1407                    "SELECT entity_key, project_instance_id, canonical_identity, entity_kind,
1408                            repository_path, package_manager, package_name, manifest_path,
1409                            symbol_name, symbol_kind, symbol_parent, symbol_signature,
1410                            external_system, external_identity
1411                       FROM graph_entities
1412                      WHERE project_instance_id = ?1 AND repository_path = ?2
1413                      ORDER BY entity_kind, entity_key
1414                      LIMIT ?3",
1415                )?;
1416                collect_entity_rows_metered(
1417                    statement.query(params![
1418                        &project.as_bytes()[..],
1419                        path.as_str(),
1420                        limit_plus_one
1421                    ])?,
1422                    &mut meter,
1423                )
1424            },
1425        )?;
1426        let page = page_from_raw(raw, limit, |row| {
1427            let entity = entity_from_row(row, project, generation)?;
1428            meter.record_entity(&entity)?;
1429            Ok(entity)
1430        })?;
1431        let work = meter.finish(page.rows.len())?;
1432        Ok(RepositoryGraphReadPage { page, work })
1433    }
1434
1435    /// Load a bounded stable-order page of local entrypoint candidate entities.
1436    ///
1437    /// This read is intentionally page-shaped and has no persistence side
1438    /// effects. Callers use the truncation sentinel to refuse conclusions
1439    /// when the complete entity scope does not fit its declared bound.
1440    ///
1441    /// # Errors
1442    ///
1443    /// Returns a database error when the requested snapshot is unavailable,
1444    /// the limit or budget is invalid, the read is cancelled, or SQLite
1445    /// cannot execute the bounded query.
1446    pub fn repository_graph_entrypoint_candidates_page_bounded(
1447        &self,
1448        project: ProjectInstanceId,
1449        generation: IndexGeneration,
1450        limit: u32,
1451        selection: ContentSelection,
1452        budget: RepositoryGraphReadBudget,
1453        control: Option<&IndexWorkControl>,
1454    ) -> DbResult<RepositoryGraphReadPage<GraphEntity>> {
1455        let limit_plus_one = validated_limit_plus_one(
1456            limit,
1457            GraphLimits::MAX_ROWS,
1458            "graph entity rows must be nonzero and within the product ceiling",
1459        )?;
1460        self.require_repository_graph_snapshot(project, generation)?;
1461        let mut meter = RepositoryGraphReadMeter::new(budget, 1)?;
1462        let selection_filter = match selection {
1463            ContentSelection::UnspecifiedLegacy => "",
1464            ContentSelection::Source => {
1465                " AND EXISTS (SELECT 1 FROM file_content_classifications AS classification
1466                               WHERE classification.path = graph_entities.repository_path
1467                                 AND classification.classification = 'source')"
1468            }
1469            ContentSelection::Documentation => {
1470                " AND EXISTS (SELECT 1 FROM file_content_classifications AS classification
1471                               WHERE classification.path = graph_entities.repository_path
1472                                 AND classification.classification = 'documentation')"
1473            }
1474            ContentSelection::Both => {
1475                " AND EXISTS (SELECT 1 FROM file_content_classifications AS classification
1476                               WHERE classification.path = graph_entities.repository_path
1477                                 AND classification.classification IN ('source', 'documentation'))"
1478            }
1479        };
1480        let sql = format!(
1481            "SELECT entity_key, project_instance_id, canonical_identity, entity_kind,
1482                    repository_path, package_manager, package_name, manifest_path,
1483                    symbol_name, symbol_kind, symbol_parent, symbol_signature,
1484                    external_system, external_identity
1485               FROM graph_entities
1486              WHERE project_instance_id = ?1
1487                AND entity_kind IN ('file', 'symbol'){selection_filter}
1488              ORDER BY entity_key
1489              LIMIT ?2"
1490        );
1491        let raw = with_sqlite_read_progress(
1492            &self.connection,
1493            control,
1494            IndexWorkStage::RepositoryTraversal,
1495            || {
1496                let mut statement = self.connection.prepare_cached(&sql)?;
1497                collect_entity_rows_metered(
1498                    statement.query(params![&project.as_bytes()[..], limit_plus_one])?,
1499                    &mut meter,
1500                )
1501            },
1502        )?;
1503        let page = page_from_raw(raw, limit, |row| {
1504            let entity = entity_from_row(row, project, generation)?;
1505            meter.record_entity(&entity)?;
1506            Ok(entity)
1507        })?;
1508        let work = meter.finish(page.rows.len())?;
1509        Ok(RepositoryGraphReadPage { page, work })
1510    }
1511
1512    /// Load bounded graph entities that export one exact canonical resolution key.
1513    ///
1514    /// # Errors
1515    ///
1516    /// Returns an error for invalid limits, a project mismatch, a conflicting
1517    /// canonical witness, corrupt graph rows, or `SQLite` failure.
1518    pub fn repository_resolution_candidates(
1519        &self,
1520        key: &CanonicalResolutionKey,
1521        limit: u32,
1522    ) -> DbResult<RepositoryGraphPage<GraphEntity>> {
1523        let limit_plus_one = validated_limit_plus_one(
1524            limit,
1525            GraphLimits::MAX_ROWS,
1526            "resolution candidates must be nonzero and within the product ceiling",
1527        )?;
1528        let Some(generation) = self.repository_graph_generation()? else {
1529            return Ok(empty_page());
1530        };
1531        if !verify_project_identity(&self.connection, key.project())? {
1532            return Ok(empty_page());
1533        }
1534        if !validate_persisted_resolution_key(&self.connection, key)? {
1535            return Ok(empty_page());
1536        }
1537        let raw = {
1538            let mut statement = self.connection.prepare_cached(
1539                "SELECT entity.entity_key, entity.project_instance_id,
1540                        entity.canonical_identity, entity.entity_kind,
1541                        entity.repository_path, entity.package_manager,
1542                        entity.package_name, entity.manifest_path,
1543                        entity.symbol_name, entity.symbol_kind,
1544                        entity.symbol_parent, entity.symbol_signature,
1545                        entity.external_system, entity.external_identity
1546                   FROM graph_entity_exports AS export
1547                        INDEXED BY idx_graph_entity_exports_key
1548                   JOIN graph_entities AS entity
1549                     ON entity.project_instance_id = export.project_instance_id
1550                    AND entity.entity_key = export.entity_key
1551                  WHERE export.project_instance_id = ?1
1552                    AND export.resolution_domain = ?2
1553                    AND export.key_digest = ?3
1554                  ORDER BY export.entity_key
1555                  LIMIT ?4",
1556            )?;
1557            collect_entity_rows(statement.query(params![
1558                &key.project().as_bytes()[..],
1559                key.domain().as_str(),
1560                &key.digest_bytes()[..],
1561                limit_plus_one,
1562            ])?)?
1563        };
1564        page_from_raw(raw, limit, |row| {
1565            entity_from_row(row, key.project(), generation)
1566        })
1567    }
1568
1569    /// Load bounded export candidates for a canonical-key batch in one set-oriented pass.
1570    ///
1571    /// Results retain the selecting key so callers can resolve several relation
1572    /// occurrences without issuing one query per dependency key. Duplicate input
1573    /// keys and duplicate candidate bindings are returned once in stable order.
1574    ///
1575    /// # Errors
1576    ///
1577    /// Returns an error for invalid limits, mixed projects, a conflicting
1578    /// canonical witness, corrupt graph rows, or `SQLite` failure. A terminal row
1579    /// conversion failure rejects the complete operation rather than returning a
1580    /// partial candidate set.
1581    pub fn repository_resolution_candidates_for_keys(
1582        &self,
1583        project: ProjectInstanceId,
1584        keys: &[CanonicalResolutionKey],
1585        limit: u32,
1586    ) -> DbResult<RepositoryGraphPage<RepositoryResolutionCandidate>> {
1587        validated_limit_plus_one(
1588            limit,
1589            GraphLimits::MAX_ROWS,
1590            "resolution candidates must be nonzero and within the product ceiling",
1591        )?;
1592        let Some(generation) = self.repository_graph_generation()? else {
1593            return Ok(empty_page());
1594        };
1595        if !verify_project_identity(&self.connection, project)? {
1596            return Ok(empty_page());
1597        }
1598        let keys = normalized_resolution_keys(project, keys)?;
1599        validate_persisted_resolution_keys(&self.connection, &keys)?;
1600        let mut candidates = Vec::new();
1601        for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
1602            if chunk.is_empty() {
1603                continue;
1604            }
1605            let values_clause = anonymous_values_clause(chunk.len(), 4);
1606            let sql = format!(
1607                "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
1608                      AS (VALUES {values_clause})
1609                 SELECT entity.entity_key, entity.project_instance_id,
1610                        entity.canonical_identity, entity.entity_kind,
1611                        entity.repository_path, entity.package_manager,
1612                        entity.package_name, entity.manifest_path,
1613                        entity.symbol_name, entity.symbol_kind,
1614                        entity.symbol_parent, entity.symbol_signature,
1615                        entity.external_system, entity.external_identity,
1616                        stored.project_instance_id, stored.resolution_domain,
1617                        stored.key_digest, stored.canonical_identity
1618                   FROM requested
1619                   JOIN graph_resolution_keys AS stored
1620                     ON stored.project_instance_id = requested.project_instance_id
1621                    AND stored.resolution_domain = requested.resolution_domain
1622                    AND stored.key_digest = requested.key_digest
1623                    AND stored.canonical_identity = requested.canonical_identity
1624                   JOIN graph_entity_exports AS export
1625                        INDEXED BY idx_graph_entity_exports_key
1626                     ON export.project_instance_id = stored.project_instance_id
1627                    AND export.resolution_domain = stored.resolution_domain
1628                    AND export.key_digest = stored.key_digest
1629                   JOIN graph_entities AS entity
1630                     ON entity.project_instance_id = export.project_instance_id
1631                    AND entity.entity_key = export.entity_key
1632                  ORDER BY stored.resolution_domain, stored.key_digest, export.entity_key
1633                  LIMIT ?"
1634            );
1635            let mut values = resolution_key_values(chunk, true);
1636            values.push(Value::Integer(i64::from(limit) + 1));
1637            let mut statement = self.connection.prepare(&sql)?;
1638            let mut rows = statement.query(params_from_iter(values.iter()))?;
1639            while let Some(row) = rows.next()? {
1640                let entity = entity_from_row(entity_row(row)?, project, generation)?;
1641                let key_project = project_from_blob(
1642                    "graph_resolution_keys.project_instance_id",
1643                    row.get::<_, Vec<u8>>(14)?,
1644                )?;
1645                require_project(project, key_project)?;
1646                let domain_text = row.get::<_, String>(15)?;
1647                let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
1648                let digest = fixed_bytes::<32>(
1649                    "graph_resolution_keys.key_digest",
1650                    row.get::<_, Vec<u8>>(16)?,
1651                )?;
1652                let key = CanonicalResolutionKey::from_persisted(
1653                    key_project,
1654                    domain,
1655                    digest,
1656                    row.get(17)?,
1657                )?;
1658                candidates.push(RepositoryResolutionCandidate { key, entity });
1659            }
1660            if candidates.len() > limit as usize {
1661                break;
1662            }
1663        }
1664        candidates.sort_by(|left, right| {
1665            left.key
1666                .cmp(&right.key)
1667                .then_with(|| left.entity.key().digest().cmp(right.entity.key().digest()))
1668                .then_with(|| {
1669                    left.entity
1670                        .key()
1671                        .canonical_identity()
1672                        .cmp(right.entity.key().canonical_identity())
1673                })
1674        });
1675        candidates.dedup_by(|left, right| {
1676            left.key == right.key && left.entity.key() == right.entity.key()
1677        });
1678        let truncated = candidates.len() > limit as usize;
1679        candidates.truncate(limit as usize);
1680        Ok(RepositoryGraphPage {
1681            rows: candidates,
1682            truncated,
1683        })
1684    }
1685
1686    /// Load bounded canonical export keys previously owned by exact source paths.
1687    ///
1688    /// The result is deduplicated and sorted by canonical key identity so callers
1689    /// can union it deterministically with newly staged exports.
1690    ///
1691    /// # Errors
1692    ///
1693    /// Returns an error for invalid paths or limits, project mismatch, corrupt
1694    /// persisted keys, canonical witness collisions, or `SQLite` failure.
1695    pub fn repository_export_keys_for_paths(
1696        &self,
1697        project: ProjectInstanceId,
1698        paths: &[String],
1699        limit: u32,
1700    ) -> DbResult<RepositoryGraphPage<CanonicalResolutionKey>> {
1701        validated_limit_plus_one(
1702            limit,
1703            GraphLimits::MAX_ROWS,
1704            "resolution export keys must be nonzero and within the product ceiling",
1705        )?;
1706        if self.repository_graph_generation()?.is_none()
1707            || !verify_project_identity(&self.connection, project)?
1708        {
1709            return Ok(empty_page());
1710        }
1711        let paths = normalized_file_paths(paths)?;
1712        let keys = resolution_keys_for_owner_paths(
1713            &self.connection,
1714            project,
1715            &paths,
1716            ResolutionOwner::EntityExports,
1717            Some(limit),
1718        )?;
1719        Ok(page_from_ordered_set(keys, limit))
1720    }
1721
1722    /// Find the bounded distinct source paths that depend on canonical keys.
1723    ///
1724    /// `truncated` is set from aggregate `LIMIT + 1` handling. Callers must
1725    /// escalate to a full refresh before opening a publication transaction when
1726    /// it is true.
1727    ///
1728    /// # Errors
1729    ///
1730    /// Returns an error for invalid limits, mixed projects, canonical witness
1731    /// collisions, invalid persisted paths, or `SQLite` failure. No mutation is
1732    /// performed.
1733    pub fn repository_affected_source_paths(
1734        &self,
1735        project: ProjectInstanceId,
1736        keys: &[CanonicalResolutionKey],
1737        limit: u32,
1738    ) -> DbResult<RepositoryGraphPage<RepositoryFilePath>> {
1739        validated_limit_plus_one(
1740            limit,
1741            GraphLimits::MAX_ROWS,
1742            "affected source paths must be nonzero and within the product ceiling",
1743        )?;
1744        if self.repository_graph_generation()?.is_none()
1745            || !verify_project_identity(&self.connection, project)?
1746        {
1747            return Ok(empty_page());
1748        }
1749        let keys = normalized_resolution_keys(project, keys)?;
1750        validate_persisted_resolution_keys(&self.connection, &keys)?;
1751        let paths = affected_source_paths(&self.connection, &keys, limit)?;
1752        Ok(page_from_ordered_set(paths, limit))
1753    }
1754
1755    /// Account the persisted closure owned by exact affected source paths.
1756    ///
1757    /// Resolution witnesses are conservatively counted once per retained export
1758    /// or dependency binding. Callers must require a full refresh when
1759    /// `truncated` is true; in that case `rows` is the `limit + 1` lower bound
1760    /// and `retained_bytes` covers only that bounded prefix.
1761    ///
1762    /// # Errors
1763    ///
1764    /// Returns an error for invalid paths or limits, corrupt persisted rows,
1765    /// unavailable owned indexes, or any `SQLite` preparation, iteration, or
1766    /// conversion failure. An unavailable graph returns an empty footprint;
1767    /// a differently bound graph returns the typed project-mismatch error.
1768    pub fn repository_affected_source_footprint(
1769        &self,
1770        project: ProjectInstanceId,
1771        paths: &[String],
1772        limit: u32,
1773    ) -> DbResult<RepositoryAffectedSourceFootprint> {
1774        let limit_plus_one = validated_limit_plus_one(
1775            limit,
1776            GraphLimits::MAX_ROWS,
1777            "affected source footprint rows must be nonzero and within the product ceiling",
1778        )?;
1779        if self.repository_graph_generation()?.is_none()
1780            || !verify_project_identity(&self.connection, project)?
1781        {
1782            return Ok(empty_affected_source_footprint());
1783        }
1784        let paths = normalized_file_paths(paths)?;
1785        affected_source_footprint(&self.connection, project, &paths, limit_plus_one)
1786    }
1787
1788    /// Load a bounded page of logical relations through one indexed query shape.
1789    ///
1790    /// # Errors
1791    ///
1792    /// Returns an error for invalid limits, mismatched project identities,
1793    /// `SQLite` failure, or any corrupt entity/relation row in the complete page.
1794    pub fn repository_graph_relations(
1795        &self,
1796        query: RepositoryGraphRelationQuery,
1797        limit: u32,
1798    ) -> DbResult<RepositoryGraphPage<LogicalRelation>> {
1799        let page = self.repository_graph_relation_rows(query, limit, None)?;
1800        Ok(RepositoryGraphPage {
1801            rows: page.rows.into_iter().map(|row| row.relation).collect(),
1802            truncated: page.truncated,
1803        })
1804    }
1805
1806    /// Load the highest-degree resolved endpoints for one relation family.
1807    ///
1808    /// Ranking considers every exact local resolution from the current
1809    /// publication while hydrating only the requested bounded endpoint page.
1810    /// Self-relations are excluded because they cannot expand a preview.
1811    ///
1812    /// # Errors
1813    ///
1814    /// Returns an error for invalid limits, cancellation, `SQLite` failure, or
1815    /// any corrupt entity row in the complete page.
1816    pub fn repository_graph_resolved_relation_hubs(
1817        &self,
1818        relation: GraphRelationKind,
1819        limit: u32,
1820        control: Option<&IndexWorkControl>,
1821    ) -> DbResult<RepositoryGraphPage<GraphEntity>> {
1822        if let Some(control) = control {
1823            control.check(IndexWorkStage::RepositoryTraversal)?;
1824        }
1825        let limit_plus_one = validated_limit_plus_one(
1826            limit,
1827            GraphLimits::MAX_ROWS,
1828            "resolved graph hub rows must be nonzero and within the product ceiling",
1829        )?;
1830        let Some(generation) = self.repository_graph_generation()? else {
1831            return Ok(empty_page());
1832        };
1833        let project =
1834            load_project_identity(&self.connection)?.ok_or(DbError::GraphPublicationUnavailable)?;
1835        let (scope, kind) = relation_parts(relation);
1836        let raw_keys = with_sqlite_read_progress(
1837            &self.connection,
1838            control,
1839            IndexWorkStage::RepositoryTraversal,
1840            || {
1841                let mut statement = self
1842                    .connection
1843                    .prepare_cached(resolved_relation_hub_keys_sql())?;
1844                let mut rows = statement.query(params![
1845                    &project.as_bytes()[..],
1846                    scope,
1847                    kind,
1848                    RESOLUTION_STATUS_RESOLVED,
1849                    limit_plus_one,
1850                ])?;
1851                let mut keys = Vec::new();
1852                while let Some(row) = rows.next()? {
1853                    keys.push(row.get::<_, Vec<u8>>(0)?);
1854                }
1855                Ok(keys)
1856            },
1857        )?;
1858        let keys = raw_keys
1859            .into_iter()
1860            .map(|key| fixed_bytes::<32>("graph_entities.entity_key", key))
1861            .collect::<DbResult<Vec<_>>>()?;
1862        let mut entities =
1863            load_graph_entities_by_digest_metered(self, &keys, project, generation, control, None)?;
1864        let ordered = keys
1865            .into_iter()
1866            .map(|key| {
1867                entities.remove(&key).ok_or(DbError::GraphRowShape {
1868                    table: "graph_relations",
1869                    reason: "ranked resolved endpoint entity is missing",
1870                })
1871            })
1872            .collect::<DbResult<Vec<_>>>()?;
1873        page_from_raw(ordered, limit, Ok)
1874    }
1875
1876    /// Load a bounded relation page with unique endpoint hydration.
1877    ///
1878    /// # Errors
1879    ///
1880    /// Returns an error for invalid limits, mismatched project identities,
1881    /// cancellation, `SQLite` failure, or any corrupt entity/relation row in
1882    /// the complete page.
1883    pub fn repository_graph_relation_rows(
1884        &self,
1885        query: RepositoryGraphRelationQuery,
1886        limit: u32,
1887        control: Option<&IndexWorkControl>,
1888    ) -> DbResult<RepositoryGraphPage<RepositoryGraphRelationRow>> {
1889        let limit_plus_one = validated_limit_plus_one(
1890            limit,
1891            GraphLimits::MAX_ROWS,
1892            "graph rows must be nonzero and within the product ceiling",
1893        )?;
1894        let Some(generation) = self.repository_graph_generation()? else {
1895            return Ok(empty_page());
1896        };
1897        let (project, raw) = match query {
1898            RepositoryGraphRelationQuery::Outbound { source } => {
1899                let project = source.project();
1900                if !verify_project_identity(&self.connection, project)? {
1901                    return Ok(empty_page());
1902                }
1903                let raw = with_sqlite_read_progress(
1904                    &self.connection,
1905                    control,
1906                    IndexWorkStage::RepositoryTraversal,
1907                    || {
1908                        self.collect_relation_rows_by_key(
1909                            "source_entity_key",
1910                            &source.digest_bytes()?,
1911                            limit_plus_one,
1912                        )
1913                    },
1914                )?;
1915                (project, raw)
1916            }
1917            RepositoryGraphRelationQuery::Inbound { target } => {
1918                let project = target.project();
1919                if !verify_project_identity(&self.connection, project)? {
1920                    return Ok(empty_page());
1921                }
1922                let raw = with_sqlite_read_progress(
1923                    &self.connection,
1924                    control,
1925                    IndexWorkStage::RepositoryTraversal,
1926                    || {
1927                        self.collect_relation_rows_by_key(
1928                            "target_entity_key",
1929                            &target.digest_bytes()?,
1930                            limit_plus_one,
1931                        )
1932                    },
1933                )?;
1934                (project, raw)
1935            }
1936            RepositoryGraphRelationQuery::Family { relation } => {
1937                let project = load_project_identity(&self.connection)?
1938                    .ok_or(DbError::GraphPublicationUnavailable)?;
1939                let (scope, kind) = relation_parts(relation);
1940                let raw = with_sqlite_read_progress(
1941                    &self.connection,
1942                    control,
1943                    IndexWorkStage::RepositoryTraversal,
1944                    || {
1945                        #[cfg(feature = "sqlite-progress-test-observer")]
1946                        crate::sqlite_progress_test_observer::notify(
1947                            crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryEntered,
1948                        );
1949                        let result = (|| {
1950                            let mut statement = self.connection.prepare_cached(
1951                                "SELECT relation_key, project_instance_id, canonical_identity,
1952                                    source_entity_key, relation_scope, relation_kind,
1953                                    resolution_status, target_entity_key, reference_text,
1954                                    candidate_count, document_unresolved_reason,
1955                                    confidence, completeness
1956                               FROM graph_relations
1957                              WHERE project_instance_id = ?1
1958                                AND relation_scope = ?2 AND relation_kind = ?3
1959                              ORDER BY relation_key
1960                              LIMIT ?4",
1961                            )?;
1962                            collect_relation_rows(statement.query(params![
1963                                &project.as_bytes()[..],
1964                                scope,
1965                                kind,
1966                                limit_plus_one
1967                            ])?)
1968                        })();
1969                        #[cfg(feature = "sqlite-progress-test-observer")]
1970                        crate::sqlite_progress_test_observer::notify(
1971                            crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryExited,
1972                        );
1973                        result
1974                    },
1975                )?;
1976                (project, raw)
1977            }
1978        };
1979        let entities = load_relation_entities(self, &raw, project, generation, control)?;
1980        page_from_raw(raw, limit, |row| {
1981            relation_detail_from_row(&entities, row, project, generation)
1982        })
1983    }
1984
1985    /// Load one relation-family page with pre-limit source classification selection.
1986    ///
1987    /// Omitted selection preserves the existing family candidate universe and
1988    /// relation-key order. Explicit selection filters file-bearing source
1989    /// entities inside the indexed relation query before `LIMIT`; the returned
1990    /// source classification is projected by the same statement.
1991    ///
1992    /// # Errors
1993    ///
1994    /// Returns an error for invalid limits, cancellation, `SQLite` failures,
1995    /// missing or corrupt source classifications, or corrupt graph rows. No
1996    /// partial page is returned.
1997    pub fn repository_graph_classified_relation_family_rows(
1998        &self,
1999        relation: GraphRelationKind,
2000        selection: ContentSelection,
2001        limit: u32,
2002        control: Option<&IndexWorkControl>,
2003    ) -> DbResult<RepositoryGraphPage<RepositoryGraphClassifiedRelationRow>> {
2004        let limit_plus_one = validated_limit_plus_one(
2005            limit,
2006            GraphLimits::MAX_ROWS,
2007            "graph rows must be nonzero and within the product ceiling",
2008        )?;
2009        let Some(generation) = self.repository_graph_generation()? else {
2010            return Ok(empty_page());
2011        };
2012        let project =
2013            load_project_identity(&self.connection)?.ok_or(DbError::GraphPublicationUnavailable)?;
2014        let (scope, kind) = relation_parts(relation);
2015        let sql = classified_relation_family_sql(selection);
2016        let mut bindings = vec![
2017            Value::Blob(project.as_bytes().to_vec()),
2018            Value::Text(scope.to_string()),
2019            Value::Text(kind.to_string()),
2020        ];
2021        match selection {
2022            ContentSelection::UnspecifiedLegacy => {}
2023            ContentSelection::Source => bindings.push(Value::Text(
2024                ContentClassification::Source.as_str().to_string(),
2025            )),
2026            ContentSelection::Documentation => bindings.push(Value::Text(
2027                ContentClassification::Documentation.as_str().to_string(),
2028            )),
2029            ContentSelection::Both => bindings.extend([
2030                Value::Text(ContentClassification::Source.as_str().to_string()),
2031                Value::Text(ContentClassification::Documentation.as_str().to_string()),
2032            ]),
2033        }
2034        bindings.push(Value::Integer(limit_plus_one));
2035        let raw = with_sqlite_read_progress(
2036            &self.connection,
2037            control,
2038            IndexWorkStage::RepositoryTraversal,
2039            || {
2040                #[cfg(feature = "sqlite-progress-test-observer")]
2041                crate::sqlite_progress_test_observer::notify(
2042                    crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryEntered,
2043                );
2044                let result = (|| {
2045                    let mut statement = self.connection.prepare_cached(&sql)?;
2046                    collect_classified_relation_rows(
2047                        statement.query(params_from_iter(bindings.iter()))?,
2048                    )
2049                })();
2050                #[cfg(feature = "sqlite-progress-test-observer")]
2051                crate::sqlite_progress_test_observer::notify(
2052                    crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryExited,
2053                );
2054                result
2055            },
2056        )?;
2057        let relation_rows = raw.iter().map(|row| &row.relation).collect::<Vec<_>>();
2058        let entities =
2059            load_relation_entity_references(self, &relation_rows, project, generation, control)?;
2060        page_from_raw(raw, limit, |row| {
2061            classified_relation_detail_from_row(&entities, row, project, generation)
2062        })
2063    }
2064
2065    /// Hydrate an ordered unique set of normalized relations from compact stable keys.
2066    ///
2067    /// # Errors
2068    ///
2069    /// Returns an error for duplicate or oversized input, a stale project or
2070    /// generation, cancellation, a missing relation or endpoint, `SQLite`
2071    /// failure, or any invalid persisted key or canonical identity. No partial
2072    /// set is returned.
2073    pub fn repository_graph_relation_rows_by_digest(
2074        &self,
2075        project: ProjectInstanceId,
2076        generation: IndexGeneration,
2077        digests: &[[u8; 32]],
2078        budget: RepositoryGraphReadBudget,
2079        control: Option<&IndexWorkControl>,
2080    ) -> DbResult<RepositoryGraphReadBatch<RepositoryGraphRelationRow>> {
2081        validate_graph_hydration_request(digests)?;
2082        self.require_repository_graph_snapshot(project, generation)?;
2083        let mut meter = RepositoryGraphReadMeter::new(budget, digests.len())?;
2084        if digests.is_empty() {
2085            return Ok(RepositoryGraphReadBatch {
2086                rows: Vec::new(),
2087                work: meter.finish(0)?,
2088            });
2089        }
2090        let sql = graph_relation_hydration_sql(digests.len());
2091        let mut bindings = digests
2092            .iter()
2093            .map(|digest| Value::Blob(digest.to_vec()))
2094            .collect::<Vec<_>>();
2095        bindings.push(Value::Blob(project.as_bytes().to_vec()));
2096        let raw = with_sqlite_read_progress(
2097            &self.connection,
2098            control,
2099            IndexWorkStage::RepositoryTraversal,
2100            || {
2101                let mut statement = self.connection.prepare(&sql)?;
2102                collect_relation_rows_metered(
2103                    statement.query(params_from_iter(bindings.iter()))?,
2104                    &mut meter,
2105                )
2106            },
2107        )?;
2108        let mut relations = HashMap::with_capacity(raw.len());
2109        for row in raw {
2110            let digest = fixed_bytes::<32>("graph_relations.relation_key", row.key.clone())?;
2111            if relations.insert(digest, row).is_some() {
2112                return Err(DbError::GraphRowShape {
2113                    table: "graph_relations",
2114                    reason: "batched relation hydration returned a duplicate key",
2115                });
2116            }
2117        }
2118        let mut ordered = Vec::with_capacity(digests.len());
2119        for digest in digests {
2120            ordered.push(relations.remove(digest).ok_or(DbError::GraphRowShape {
2121                table: "graph_relations",
2122                reason: "requested graph relation is missing",
2123            })?);
2124        }
2125        let references = ordered.iter().collect::<Vec<_>>();
2126        let entities = load_relation_entity_references_metered(
2127            self,
2128            &references,
2129            project,
2130            generation,
2131            control,
2132            Some(&mut meter),
2133        )?;
2134        let rows = ordered
2135            .into_iter()
2136            .map(|row| relation_detail_from_row(&entities, row, project, generation))
2137            .collect::<DbResult<Vec<_>>>()?;
2138        let work = meter.finish(rows.len())?;
2139        Ok(RepositoryGraphReadBatch { rows, work })
2140    }
2141
2142    /// Load one bounded legacy direction-specific adjacency page for a unique frontier.
2143    ///
2144    /// The complete frontier is bound through one statement whose indexed
2145    /// per-frontier branches each cap their candidate rows before the stable
2146    /// compound order. Endpoint entities are hydrated in bounded set-oriented
2147    /// batches. The opaque continuation is valid only with the same project,
2148    /// generation, frontier, and direction inside the same request snapshot.
2149    ///
2150    /// # Errors
2151    ///
2152    /// Returns an error for invalid or mixed-project frontiers, invalid limits
2153    /// or continuation state, cancellation, `SQLite` failures, or any corrupt
2154    /// relation or endpoint row. No partial page is returned.
2155    pub fn repository_graph_adjacency_page(
2156        &self,
2157        frontier: &[GraphEntityKey],
2158        direction: RepositoryGraphDirection,
2159        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2160        limit: u32,
2161        control: Option<&IndexWorkControl>,
2162    ) -> DbResult<RepositoryGraphAdjacencyPage> {
2163        Ok(self
2164            .repository_graph_adjacency_page_bounded(
2165                frontier,
2166                direction,
2167                continuation,
2168                limit,
2169                maximum_repository_graph_read_budget()?,
2170                control,
2171            )?
2172            .page)
2173    }
2174
2175    /// Load one bounded adjacency page and report exact database work.
2176    ///
2177    /// # Errors
2178    ///
2179    /// Returns the same fail-closed errors as
2180    /// [`Self::repository_graph_adjacency_page`] and rejects any page whose
2181    /// returned, decoded, endpoint, or path hydration crosses `budget`.
2182    pub fn repository_graph_adjacency_page_bounded(
2183        &self,
2184        frontier: &[GraphEntityKey],
2185        direction: RepositoryGraphDirection,
2186        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2187        limit: u32,
2188        budget: RepositoryGraphReadBudget,
2189        control: Option<&IndexWorkControl>,
2190    ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
2191        self.repository_graph_adjacency_page_filtered_bounded(
2192            frontier,
2193            direction,
2194            None,
2195            continuation,
2196            limit,
2197            budget,
2198            control,
2199        )
2200    }
2201
2202    /// Load one bounded direction-specific adjacency page for an optional exact family.
2203    ///
2204    /// An unfiltered request preserves the legacy family set and excludes
2205    /// `documents`; an exact `documents` request remains selectable.
2206    ///
2207    /// # Errors
2208    ///
2209    /// Returns the same fail-closed errors as
2210    /// [`Self::repository_graph_adjacency_page`] and binds the optional family
2211    /// to continuation state.
2212    pub fn repository_graph_adjacency_page_filtered(
2213        &self,
2214        frontier: &[GraphEntityKey],
2215        direction: RepositoryGraphDirection,
2216        relation: Option<GraphRelationKind>,
2217        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2218        limit: u32,
2219        control: Option<&IndexWorkControl>,
2220    ) -> DbResult<RepositoryGraphAdjacencyPage> {
2221        Ok(self
2222            .repository_graph_adjacency_page_filtered_bounded(
2223                frontier,
2224                direction,
2225                relation,
2226                continuation,
2227                limit,
2228                maximum_repository_graph_read_budget()?,
2229                control,
2230            )?
2231            .page)
2232    }
2233
2234    /// Load one bounded exact-family adjacency page containing only local resolutions.
2235    ///
2236    /// # Errors
2237    ///
2238    /// Returns the same fail-closed errors as
2239    /// [`Self::repository_graph_adjacency_page_filtered`] and binds the local
2240    /// resolution filter to continuation state.
2241    pub fn repository_graph_resolved_adjacency_page(
2242        &self,
2243        frontier: &[GraphEntityKey],
2244        direction: RepositoryGraphDirection,
2245        relation: GraphRelationKind,
2246        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2247        limit: u32,
2248        control: Option<&IndexWorkControl>,
2249    ) -> DbResult<RepositoryGraphAdjacencyPage> {
2250        Ok(self
2251            .repository_graph_adjacency_page_filtered_by_resolution_bounded(
2252                frontier,
2253                direction,
2254                Some(relation),
2255                true,
2256                false,
2257                continuation,
2258                limit,
2259                maximum_repository_graph_read_budget()?,
2260                control,
2261            )?
2262            .page)
2263    }
2264
2265    /// Load one legacy family-filtered adjacency page and report exact database work.
2266    ///
2267    /// An unfiltered request excludes `documents` before limits are applied.
2268    ///
2269    /// # Errors
2270    ///
2271    /// Returns the same fail-closed errors as
2272    /// [`Self::repository_graph_adjacency_page_filtered`] and rejects any page
2273    /// whose complete query work crosses `budget`.
2274    pub fn repository_graph_adjacency_page_filtered_bounded(
2275        &self,
2276        frontier: &[GraphEntityKey],
2277        direction: RepositoryGraphDirection,
2278        relation: Option<GraphRelationKind>,
2279        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2280        limit: u32,
2281        budget: RepositoryGraphReadBudget,
2282        control: Option<&IndexWorkControl>,
2283    ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
2284        self.repository_graph_adjacency_page_filtered_bounded_with_documents(
2285            frontier,
2286            direction,
2287            relation,
2288            false,
2289            continuation,
2290            limit,
2291            budget,
2292            control,
2293        )
2294    }
2295
2296    /// Load one family-filtered adjacency page with explicit document visibility.
2297    ///
2298    /// `include_documents` affects only an unfiltered request. An exact
2299    /// `documents` family always remains selectable. The effective choice is
2300    /// applied before branch limits and bound into continuation identity.
2301    ///
2302    /// # Errors
2303    ///
2304    /// Returns the same fail-closed errors and bounded-work accounting as
2305    /// [`Self::repository_graph_adjacency_page_filtered_bounded`].
2306    #[allow(clippy::too_many_arguments)]
2307    pub fn repository_graph_adjacency_page_filtered_bounded_with_documents(
2308        &self,
2309        frontier: &[GraphEntityKey],
2310        direction: RepositoryGraphDirection,
2311        relation: Option<GraphRelationKind>,
2312        include_documents: bool,
2313        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2314        limit: u32,
2315        budget: RepositoryGraphReadBudget,
2316        control: Option<&IndexWorkControl>,
2317    ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
2318        self.repository_graph_adjacency_page_filtered_by_resolution_bounded(
2319            frontier,
2320            direction,
2321            relation,
2322            false,
2323            include_documents,
2324            continuation,
2325            limit,
2326            budget,
2327            control,
2328        )
2329    }
2330
2331    /// Check one exact relation family without admitting or decoding a row.
2332    ///
2333    /// This is the terminal probe used when a bounded traversal has exhausted
2334    /// its edge allowance. It distinguishes an empty adjacency from a pending
2335    /// edge without granting the caller another ordinary adjacency row.
2336    ///
2337    /// # Errors
2338    ///
2339    /// Returns an error for an invalid entity key, cancellation, or a SQLite
2340    /// failure.
2341    pub fn repository_graph_adjacency_is_empty(
2342        &self,
2343        key: &GraphEntityKey,
2344        direction: RepositoryGraphDirection,
2345        relation: GraphRelationKind,
2346        control: Option<&IndexWorkControl>,
2347    ) -> DbResult<bool> {
2348        let project = key.project();
2349        if !verify_project_identity(&self.connection, project)?
2350            || self.repository_graph_generation()?.is_none()
2351        {
2352            return Ok(true);
2353        }
2354        let digest = key.digest_bytes()?;
2355        let (key_column, index_name) = match direction {
2356            RepositoryGraphDirection::Outbound => {
2357                ("source_entity_key", "idx_graph_relations_source_kind")
2358            }
2359            RepositoryGraphDirection::Inbound => {
2360                ("target_entity_key", "idx_graph_relations_target_kind")
2361            }
2362        };
2363        let (scope, kind) = relation_parts(relation);
2364        let sql = format!(
2365            "SELECT EXISTS(
2366                 SELECT 1
2367                   FROM graph_relations AS relation INDEXED BY {index_name}
2368                  WHERE relation.project_instance_id = ?1
2369                    AND relation.{key_column} = ?2
2370                    AND relation.relation_scope = ?3
2371                    AND relation.relation_kind = ?4
2372             )"
2373        );
2374        let bindings = [
2375            Value::Blob(project.as_bytes().to_vec()),
2376            Value::Blob(digest.to_vec()),
2377            Value::Text(scope.to_string()),
2378            Value::Text(kind.to_string()),
2379        ];
2380        with_sqlite_read_progress(
2381            &self.connection,
2382            control,
2383            IndexWorkStage::RepositoryTraversal,
2384            || {
2385                self.connection
2386                    .query_row(&sql, params_from_iter(bindings.iter()), |row| {
2387                        row.get::<_, bool>(0)
2388                    })
2389                    .map(|has_row| !has_row)
2390                    .map_err(Into::into)
2391            },
2392        )
2393    }
2394
2395    /// Check one exact relation family after applying confidence and endpoint
2396    /// content admission without decoding or admitting a row.
2397    ///
2398    /// This is the bounded terminal probe used by callers that must distinguish
2399    /// an empty admitted frontier from a pending relation without consuming an
2400    /// ordinary traversal edge.
2401    ///
2402    /// # Errors
2403    ///
2404    /// Returns an error for an invalid entity key, cancellation, or a SQLite
2405    /// failure.
2406    pub fn repository_graph_adjacency_is_empty_filtered(
2407        &self,
2408        key: &GraphEntityKey,
2409        direction: RepositoryGraphDirection,
2410        relation: GraphRelationKind,
2411        minimum_confidence: ConfidenceClass,
2412        selection: ContentSelection,
2413        control: Option<&IndexWorkControl>,
2414    ) -> DbResult<bool> {
2415        let project = key.project();
2416        if !verify_project_identity(&self.connection, project)?
2417            || self.repository_graph_generation()?.is_none()
2418        {
2419            return Ok(true);
2420        }
2421        let digest = key.digest_bytes()?;
2422        let (key_column, index_name, endpoint_column) = match direction {
2423            RepositoryGraphDirection::Outbound => (
2424                "source_entity_key",
2425                "idx_graph_relations_source_kind",
2426                "target_entity_key",
2427            ),
2428            RepositoryGraphDirection::Inbound => (
2429                "target_entity_key",
2430                "idx_graph_relations_target_kind",
2431                "source_entity_key",
2432            ),
2433        };
2434        let (scope, kind) = relation_parts(relation);
2435        let confidence_filter = match minimum_confidence {
2436            ConfidenceClass::Exact => "AND relation.confidence = 'exact'",
2437            ConfidenceClass::High => "AND relation.confidence IN ('exact', 'high')",
2438            ConfidenceClass::Medium => "AND relation.confidence IN ('exact', 'high', 'medium')",
2439            ConfidenceClass::Low => "",
2440        };
2441        let endpoint_joins = if selection == ContentSelection::UnspecifiedLegacy {
2442            String::new()
2443        } else {
2444            format!(
2445                "LEFT JOIN graph_entities AS endpoint
2446                   ON endpoint.project_instance_id = relation.project_instance_id
2447                  AND endpoint.entity_key = relation.{endpoint_column}
2448                 LEFT JOIN file_content_classifications AS endpoint_classification
2449                   ON endpoint_classification.path = CASE endpoint.entity_kind
2450                        WHEN 'file' THEN endpoint.repository_path
2451                        WHEN 'symbol' THEN endpoint.repository_path
2452                        WHEN 'package' THEN endpoint.manifest_path
2453                    END"
2454            )
2455        };
2456        let selection_filter = match selection {
2457            ContentSelection::UnspecifiedLegacy => "",
2458            ContentSelection::Source => {
2459                "AND (relation.resolution_status NOT IN ('resolved', 'external')
2460                      OR relation.resolution_status = 'external'
2461                      OR (relation.relation_scope = 'extended'
2462                          AND relation.relation_kind = 'documents')
2463                      OR (relation.resolution_status = 'resolved'
2464                          AND endpoint_classification.classification = 'source'))"
2465            }
2466            ContentSelection::Documentation => {
2467                "AND (relation.resolution_status NOT IN ('resolved', 'external')
2468                      OR relation.resolution_status = 'external'
2469                      OR (relation.relation_scope = 'extended'
2470                          AND relation.relation_kind = 'documents')
2471                      OR (relation.resolution_status = 'resolved'
2472                          AND endpoint_classification.classification = 'documentation'))"
2473            }
2474            ContentSelection::Both => {
2475                "AND (relation.resolution_status NOT IN ('resolved', 'external')
2476                      OR relation.resolution_status = 'external'
2477                      OR (relation.relation_scope = 'extended'
2478                          AND relation.relation_kind = 'documents')
2479                      OR (relation.resolution_status = 'resolved'
2480                          AND endpoint_classification.classification IN ('source', 'documentation')))"
2481            }
2482        };
2483        let sql = format!(
2484            "SELECT EXISTS(
2485                 SELECT 1
2486                   FROM graph_relations AS relation INDEXED BY {index_name}
2487                    {endpoint_joins}
2488                  WHERE relation.project_instance_id = ?1
2489                    AND relation.{key_column} = ?2
2490                    AND relation.relation_scope = ?3
2491                    AND relation.relation_kind = ?4
2492                    {confidence_filter}
2493                    {selection_filter}
2494             )"
2495        );
2496        let bindings = [
2497            Value::Blob(project.as_bytes().to_vec()),
2498            Value::Blob(digest.to_vec()),
2499            Value::Text(scope.to_string()),
2500            Value::Text(kind.to_string()),
2501        ];
2502        with_sqlite_read_progress(
2503            &self.connection,
2504            control,
2505            IndexWorkStage::RepositoryTraversal,
2506            || {
2507                self.connection
2508                    .query_row(&sql, params_from_iter(bindings.iter()), |row| {
2509                        row.get::<_, bool>(0)
2510                    })
2511                    .map(|has_row| !has_row)
2512                    .map_err(Into::into)
2513            },
2514        )
2515    }
2516
2517    /// Check whether a prior adjacency page has any admitted rows remaining.
2518    ///
2519    /// The continuation is validated against its original project, generation,
2520    /// direction, family, and frontier. No relation row is decoded or charged
2521    /// to the caller's ordinary page budget.
2522    ///
2523    /// # Errors
2524    ///
2525    /// Returns an error for a stale or invalid continuation, cancellation, or a
2526    /// SQLite failure.
2527    pub fn repository_graph_adjacency_continuation_has_filtered_rows(
2528        &self,
2529        continuation: &RepositoryGraphAdjacencyContinuation,
2530        minimum_confidence: ConfidenceClass,
2531        selection: ContentSelection,
2532        control: Option<&IndexWorkControl>,
2533    ) -> DbResult<bool> {
2534        let project = continuation.project;
2535        self.require_repository_graph_snapshot(project, continuation.generation)?;
2536        if continuation.frontier.is_empty()
2537            || continuation.frontier_index as usize >= continuation.frontier.len()
2538        {
2539            return Err(GraphContractError::InvalidLimits {
2540                reason: "graph adjacency continuation has an invalid frontier",
2541            }
2542            .into());
2543        }
2544        let (key_column, index_name, endpoint_column) = match continuation.direction {
2545            RepositoryGraphDirection::Outbound => (
2546                "source_entity_key",
2547                "idx_graph_relations_source_kind",
2548                "target_entity_key",
2549            ),
2550            RepositoryGraphDirection::Inbound => (
2551                "target_entity_key",
2552                "idx_graph_relations_target_kind",
2553                "source_entity_key",
2554            ),
2555        };
2556        let confidence_filter = match minimum_confidence {
2557            ConfidenceClass::Exact => "AND relation.confidence = 'exact'",
2558            ConfidenceClass::High => "AND relation.confidence IN ('exact', 'high')",
2559            ConfidenceClass::Medium => "AND relation.confidence IN ('exact', 'high', 'medium')",
2560            ConfidenceClass::Low => "",
2561        };
2562        let endpoint_joins = if selection == ContentSelection::UnspecifiedLegacy {
2563            String::new()
2564        } else {
2565            format!(
2566                "LEFT JOIN graph_entities AS endpoint
2567                   ON endpoint.project_instance_id = relation.project_instance_id
2568                  AND endpoint.entity_key = relation.{endpoint_column}
2569                 LEFT JOIN file_content_classifications AS endpoint_classification
2570                   ON endpoint_classification.path = CASE endpoint.entity_kind
2571                        WHEN 'file' THEN endpoint.repository_path
2572                        WHEN 'symbol' THEN endpoint.repository_path
2573                        WHEN 'package' THEN endpoint.manifest_path
2574                    END"
2575            )
2576        };
2577        let selection_filter = match selection {
2578            ContentSelection::UnspecifiedLegacy => "",
2579            ContentSelection::Source => {
2580                "AND (relation.resolution_status NOT IN ('resolved', 'external')
2581                      OR relation.resolution_status = 'external'
2582                      OR (relation.relation_scope = 'extended'
2583                          AND relation.relation_kind = 'documents')
2584                      OR (relation.resolution_status = 'resolved'
2585                          AND endpoint_classification.classification = 'source'))"
2586            }
2587            ContentSelection::Documentation => {
2588                "AND (relation.resolution_status NOT IN ('resolved', 'external')
2589                      OR relation.resolution_status = 'external'
2590                      OR (relation.relation_scope = 'extended'
2591                          AND relation.relation_kind = 'documents')
2592                      OR (relation.resolution_status = 'resolved'
2593                          AND endpoint_classification.classification = 'documentation'))"
2594            }
2595            ContentSelection::Both => {
2596                "AND (relation.resolution_status NOT IN ('resolved', 'external')
2597                      OR relation.resolution_status = 'external'
2598                      OR (relation.relation_scope = 'extended'
2599                          AND relation.relation_kind = 'documents')
2600                      OR (relation.resolution_status = 'resolved'
2601                          AND endpoint_classification.classification IN ('source', 'documentation')))"
2602            }
2603        };
2604        let relation_filter = continuation.relation.map(|relation| {
2605            let (scope, kind) = relation_parts(relation);
2606            (scope, kind)
2607        });
2608        let relation_filter_sql = if relation_filter.is_some() {
2609            "AND relation.relation_scope = ? AND relation.relation_kind = ?"
2610        } else {
2611            ""
2612        };
2613        let resolution_filter = if continuation.resolved_only {
2614            "AND relation.resolution_status = 'resolved'
2615             AND relation.target_entity_key IS NOT NULL
2616             AND relation.source_entity_key <> relation.target_entity_key"
2617        } else {
2618            ""
2619        };
2620        let document_filter = if continuation.relation.is_some() || continuation.include_documents {
2621            ""
2622        } else {
2623            "AND NOT (
2624                 relation.relation_scope = 'extended'
2625                 AND relation.relation_kind = 'documents'
2626             )"
2627        };
2628        let mut bindings = Vec::new();
2629        let branches = (continuation.frontier_index as usize..continuation.frontier.len())
2630            .map(|frontier_index| {
2631                let is_continuation_frontier =
2632                    frontier_index == continuation.frontier_index as usize;
2633                let keyset = if is_continuation_frontier {
2634                    "AND (relation.relation_scope, relation.relation_kind,
2635                          relation.relation_key) > (?, ?, ?)"
2636                } else {
2637                    ""
2638                };
2639                bindings.push(Value::Blob(project.as_bytes().to_vec()));
2640                bindings.push(Value::Blob(continuation.frontier[frontier_index].to_vec()));
2641                if let Some((scope, kind)) = relation_filter {
2642                    bindings.push(Value::Text(scope.to_string()));
2643                    bindings.push(Value::Text(kind.to_string()));
2644                }
2645                if is_continuation_frontier {
2646                    bindings.push(Value::Text(continuation.relation_scope.clone()));
2647                    bindings.push(Value::Text(continuation.relation_kind.clone()));
2648                    bindings.push(Value::Blob(continuation.relation_key.to_vec()));
2649                }
2650                format!(
2651                    "SELECT 1
2652                       FROM graph_relations AS relation INDEXED BY {index_name}
2653                        {endpoint_joins}
2654                      WHERE relation.project_instance_id = ?
2655                        AND relation.{key_column} = ?
2656                        {relation_filter_sql}
2657                        {resolution_filter}
2658                        {document_filter}
2659                        {confidence_filter}
2660                        {selection_filter}
2661                        {keyset}"
2662                )
2663            })
2664            .collect::<Vec<_>>()
2665            .join(" UNION ALL ");
2666        let sql = format!("SELECT EXISTS(SELECT 1 FROM ({branches}) AS pending LIMIT 1)");
2667        with_sqlite_read_progress(
2668            &self.connection,
2669            control,
2670            IndexWorkStage::RepositoryTraversal,
2671            || {
2672                self.connection
2673                    .query_row(&sql, params_from_iter(bindings.iter()), |row| {
2674                        row.get::<_, bool>(0)
2675                    })
2676                    .map_err(Into::into)
2677            },
2678        )
2679    }
2680
2681    /// Load one optionally family- and local-resolution-filtered adjacency page.
2682    fn repository_graph_adjacency_page_filtered_by_resolution_bounded(
2683        &self,
2684        frontier: &[GraphEntityKey],
2685        direction: RepositoryGraphDirection,
2686        relation: Option<GraphRelationKind>,
2687        resolved_only: bool,
2688        include_documents: bool,
2689        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2690        limit: u32,
2691        budget: RepositoryGraphReadBudget,
2692        control: Option<&IndexWorkControl>,
2693    ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
2694        let include_documents = include_documents
2695            || relation == Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents));
2696        let limit_plus_one = validated_limit_plus_one(
2697            limit,
2698            GraphLimits::MAX_ROWS,
2699            "graph adjacency rows must be nonzero and within the product ceiling",
2700        )?;
2701        if limit > budget.returned_rows() {
2702            return Err(GraphContractError::InvalidLimits {
2703                reason: "graph adjacency page limit exceeds the return budget",
2704            }
2705            .into());
2706        }
2707        if frontier.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
2708            return Err(GraphContractError::InvalidLimits {
2709                reason: "graph adjacency frontier exceeds the product ceiling",
2710            }
2711            .into());
2712        }
2713        if frontier.is_empty() {
2714            if continuation.is_some() {
2715                return Err(GraphContractError::InvalidLimits {
2716                    reason: "graph adjacency continuation requires a nonempty frontier",
2717                }
2718                .into());
2719            }
2720            let meter = RepositoryGraphReadMeter::new(budget, 0)?;
2721            return Ok(RepositoryGraphAdjacencyReadPage {
2722                page: empty_adjacency_page(),
2723                work: meter.finish(0)?,
2724            });
2725        }
2726        let mut meter = RepositoryGraphReadMeter::new(budget, frontier.len())?;
2727        let limit_plus_one_usize = usize::try_from(limit_plus_one).map_err(|_source| {
2728            GraphContractError::InvalidLimits {
2729                reason: "graph adjacency page limit overflowed",
2730            }
2731        })?;
2732        let project = frontier[0].project();
2733        let mut unique = BTreeSet::new();
2734        let mut frontier_digests = Vec::with_capacity(frontier.len());
2735        for key in frontier {
2736            require_project(project, key.project())?;
2737            let digest = key.digest_bytes()?;
2738            if !unique.insert(digest) {
2739                return Err(GraphContractError::InvalidLimits {
2740                    reason: "graph adjacency frontier must contain unique entities",
2741                }
2742                .into());
2743            }
2744            frontier_digests.push(digest);
2745        }
2746
2747        let Some(generation) = self.repository_graph_generation()? else {
2748            if continuation.is_some() {
2749                return Err(GraphContractError::InvalidLimits {
2750                    reason: "graph adjacency continuation has no active generation",
2751                }
2752                .into());
2753            }
2754            return Ok(RepositoryGraphAdjacencyReadPage {
2755                page: empty_adjacency_page(),
2756                work: meter.finish(0)?,
2757            });
2758        };
2759        if !verify_project_identity(&self.connection, project)? {
2760            if continuation.is_some() {
2761                return Err(GraphContractError::InvalidLimits {
2762                    reason: "graph adjacency continuation does not match the bound project",
2763                }
2764                .into());
2765            }
2766            return Ok(RepositoryGraphAdjacencyReadPage {
2767                page: empty_adjacency_page(),
2768                work: meter.finish(0)?,
2769            });
2770        }
2771
2772        if let Some(continuation) = continuation
2773            && (continuation.project != project
2774                || continuation.generation != generation
2775                || continuation.direction != direction
2776                || continuation.relation != relation
2777                || continuation.resolved_only != resolved_only
2778                || continuation.include_documents != include_documents
2779                || continuation.frontier != frontier_digests
2780                || continuation.frontier_index as usize >= frontier.len())
2781        {
2782            return Err(GraphContractError::InvalidLimits {
2783                reason: "graph adjacency continuation does not match the request",
2784            }
2785            .into());
2786        }
2787
2788        let continuation_index = continuation.map_or(0, |value| value.frontier_index as usize);
2789        let active_frontier = frontier.len() - continuation_index;
2790        let work_rows = active_frontier.checked_mul(limit_plus_one_usize).ok_or(
2791            GraphContractError::InvalidLimits {
2792                reason: "graph adjacency intermediate row ceiling overflowed",
2793            },
2794        )?;
2795        if work_rows > MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS {
2796            return Err(GraphContractError::InvalidLimits {
2797                reason: "graph adjacency frontier and page exceed the intermediate row ceiling",
2798            }
2799            .into());
2800        }
2801        if resolved_only && relation.is_none() {
2802            return Err(GraphContractError::InvalidLimits {
2803                reason: "resolved graph adjacency requires an exact relation family",
2804            }
2805            .into());
2806        }
2807
2808        let bindings_per_frontier = if relation.is_some() { 4 } else { 2 };
2809        let mut bindings = Vec::with_capacity(
2810            active_frontier * bindings_per_frontier + continuation.map_or(2, |_| 5),
2811        );
2812        bindings.push(Value::Blob(project.as_bytes().to_vec()));
2813        if resolved_only {
2814            bindings.push(Value::Text(RESOLUTION_STATUS_RESOLVED.to_string()));
2815        }
2816        for (index, digest) in frontier_digests.iter().enumerate().skip(continuation_index) {
2817            bindings.push(Value::Blob(digest.to_vec()));
2818            if let Some(relation) = relation {
2819                let (scope, kind) = relation_parts(relation);
2820                bindings.push(Value::Text(scope.to_string()));
2821                bindings.push(Value::Text(kind.to_string()));
2822            }
2823            if index == continuation_index
2824                && let Some(continuation) = continuation
2825            {
2826                bindings.push(Value::Text(continuation.relation_scope.clone()));
2827                bindings.push(Value::Text(continuation.relation_kind.clone()));
2828                bindings.push(Value::Blob(continuation.relation_key.to_vec()));
2829            }
2830            bindings.push(Value::Integer(limit_plus_one));
2831        }
2832        bindings.push(Value::Integer(limit_plus_one));
2833
2834        let sql = adjacency_relation_sql(
2835            frontier.len(),
2836            direction,
2837            continuation.map(|value| value.frontier_index as usize),
2838            relation.is_some(),
2839            resolved_only,
2840            include_documents,
2841        );
2842        let raw = with_sqlite_read_progress(
2843            &self.connection,
2844            control,
2845            IndexWorkStage::RepositoryTraversal,
2846            || {
2847                let mut statement = self.connection.prepare(&sql)?;
2848                collect_adjacency_relation_rows_metered(
2849                    statement.query(params_from_iter(bindings.iter()))?,
2850                    &mut meter,
2851                )
2852            },
2853        )?;
2854        let truncated = raw.len() > limit as usize;
2855        let next = if truncated {
2856            let last = raw.get(limit as usize - 1).ok_or(DbError::GraphRowShape {
2857                table: "graph_relations",
2858                reason: "adjacency truncation row is missing",
2859            })?;
2860            Some(RepositoryGraphAdjacencyContinuation {
2861                project,
2862                generation,
2863                direction,
2864                relation,
2865                resolved_only,
2866                include_documents,
2867                frontier: frontier_digests,
2868                frontier_index: last.frontier_index,
2869                relation_scope: last.relation.relation_scope.clone(),
2870                relation_kind: last.relation.relation_kind.clone(),
2871                relation_key: fixed_bytes::<32>(
2872                    "graph_relations.relation_key",
2873                    last.relation.key.clone(),
2874                )?,
2875            })
2876        } else {
2877            None
2878        };
2879        let relation_rows = raw.iter().map(|row| &row.relation).collect::<Vec<_>>();
2880        let entities = load_relation_entity_references_metered(
2881            self,
2882            &relation_rows,
2883            project,
2884            generation,
2885            control,
2886            Some(&mut meter),
2887        )?;
2888        let mut rows = Vec::with_capacity(raw.len());
2889        for row in raw {
2890            if let Some(control) = control {
2891                control.check(IndexWorkStage::RepositoryTraversal)?;
2892            }
2893            let frontier_key = frontier
2894                .get(row.frontier_index as usize)
2895                .ok_or(DbError::GraphRowShape {
2896                    table: "graph_relations",
2897                    reason: "adjacency row has an invalid frontier position",
2898                })?
2899                .clone();
2900            rows.push(RepositoryGraphAdjacencyRow {
2901                frontier_index: row.frontier_index,
2902                frontier: frontier_key,
2903                direction,
2904                detail: relation_detail_from_row(&entities, row.relation, project, generation)?,
2905            });
2906        }
2907        if truncated {
2908            rows.pop();
2909        }
2910        let work = meter.finish(rows.len())?;
2911        Ok(RepositoryGraphAdjacencyReadPage {
2912            page: RepositoryGraphAdjacencyPage {
2913                rows,
2914                truncated,
2915                continuation: next,
2916            },
2917            work,
2918        })
2919    }
2920
2921    /// Load bounded exact source occurrences for one logical relation.
2922    ///
2923    /// # Errors
2924    ///
2925    /// Returns an error for invalid limits, project mismatch, unavailable
2926    /// publication state, `SQLite` failure, or any invalid span or key.
2927    pub fn repository_graph_occurrences(
2928        &self,
2929        relation: &LogicalRelation,
2930        limit: u32,
2931    ) -> DbResult<RepositoryGraphPage<RelationOccurrence>> {
2932        let limit_plus_one = validated_limit_plus_one(
2933            limit,
2934            GraphLimits::MAX_OCCURRENCES,
2935            "graph occurrences must be nonzero and within the product ceiling",
2936        )?;
2937        let Some(generation) = self.repository_graph_generation()? else {
2938            return Ok(empty_page());
2939        };
2940        if relation.generation() != generation {
2941            return Err(
2942                projectatlas_core::graph::GraphContractError::GenerationMismatch {
2943                    context: "relation occurrence query",
2944                }
2945                .into(),
2946            );
2947        }
2948        if !verify_project_identity(&self.connection, relation.key().project())? {
2949            return Ok(empty_page());
2950        }
2951        let raw = {
2952            let mut statement = self.connection.prepare_cached(
2953                "SELECT relation_key, file_path, start_line, start_column,
2954                        end_line, end_column
2955                   FROM graph_relation_occurrences
2956                  WHERE relation_key = ?1
2957                  ORDER BY file_path, start_line, start_column, end_line, end_column
2958                  LIMIT ?2",
2959            )?;
2960            let mut rows =
2961                statement.query(params![&relation.key().digest_bytes()?[..], limit_plus_one])?;
2962            let mut collected = Vec::new();
2963            while let Some(row) = rows.next()? {
2964                collected.push(occurrence_row(row)?);
2965            }
2966            collected
2967        };
2968        page_from_raw(raw, limit, |row| {
2969            occurrence_from_row(row, relation, generation)
2970        })
2971    }
2972
2973    /// Load per-relation occurrence pages through one bounded set-oriented statement.
2974    ///
2975    /// Result pages retain input order. Every generated branch uses the
2976    /// relation-leading unique index and admits only `limit + 1` rows before
2977    /// the final stable merge.
2978    ///
2979    /// # Errors
2980    ///
2981    /// Returns an error for mixed projects or generations, oversized input or
2982    /// intermediate work, cancellation, unavailable publication state,
2983    /// `SQLite` failure, or any invalid span or key.
2984    pub fn repository_graph_occurrence_pages(
2985        &self,
2986        relations: &[LogicalRelation],
2987        limit: u32,
2988        control: Option<&IndexWorkControl>,
2989    ) -> DbResult<Vec<RepositoryGraphPage<RelationOccurrence>>> {
2990        Ok(self
2991            .repository_graph_occurrence_pages_bounded(
2992                relations,
2993                limit,
2994                maximum_repository_graph_read_budget()?,
2995                control,
2996            )?
2997            .pages)
2998    }
2999
3000    /// Load ordered per-relation occurrence pages under one exact read envelope.
3001    ///
3002    /// # Errors
3003    ///
3004    /// Returns the same fail-closed errors as
3005    /// [`Self::repository_graph_occurrence_pages`] and rejects any aggregate
3006    /// returned-row, decoded-byte, or occurrence-path hydration overrun. Raw
3007    /// `limit + 1` sentinels are included in decoded and path work.
3008    pub fn repository_graph_occurrence_pages_bounded(
3009        &self,
3010        relations: &[LogicalRelation],
3011        limit: u32,
3012        budget: RepositoryGraphReadBudget,
3013        control: Option<&IndexWorkControl>,
3014    ) -> DbResult<RepositoryGraphReadPages<RelationOccurrence>> {
3015        let limit_plus_one = validated_limit_plus_one(
3016            limit,
3017            GraphLimits::MAX_OCCURRENCES,
3018            "graph occurrences must be nonzero and within the product ceiling",
3019        )?;
3020        if relations.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
3021            return Err(GraphContractError::InvalidLimits {
3022                reason: "graph occurrence batch exceeds the product ceiling",
3023            }
3024            .into());
3025        }
3026        let mut meter = RepositoryGraphReadMeter::new(budget, relations.len())?;
3027        if relations.is_empty() {
3028            return Ok(RepositoryGraphReadPages {
3029                pages: Vec::new(),
3030                work: meter.finish(0)?,
3031            });
3032        }
3033        let work_rows = relations.len().checked_mul(limit_plus_one as usize).ok_or(
3034            GraphContractError::InvalidLimits {
3035                reason: "graph occurrence batch work overflowed",
3036            },
3037        )?;
3038        if work_rows > MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS {
3039            return Err(GraphContractError::InvalidLimits {
3040                reason: "graph occurrence batch exceeds the intermediate row ceiling",
3041            }
3042            .into());
3043        }
3044        let Some(generation) = self.repository_graph_generation()? else {
3045            return Ok(RepositoryGraphReadPages {
3046                pages: (0..relations.len()).map(|_| empty_page()).collect(),
3047                work: meter.finish(0)?,
3048            });
3049        };
3050        let project = relations[0].key().project();
3051        if !verify_project_identity(&self.connection, project)? {
3052            return Ok(RepositoryGraphReadPages {
3053                pages: (0..relations.len()).map(|_| empty_page()).collect(),
3054                work: meter.finish(0)?,
3055            });
3056        }
3057        let mut bindings = Vec::with_capacity(relations.len() * 2);
3058        for relation in relations {
3059            require_project(project, relation.key().project())?;
3060            if relation.generation() != generation {
3061                return Err(GraphContractError::GenerationMismatch {
3062                    context: "relation occurrence batch query",
3063                }
3064                .into());
3065            }
3066            bindings.push(Value::Blob(relation.key().digest_bytes()?.to_vec()));
3067            bindings.push(Value::Integer(limit_plus_one));
3068        }
3069        let sql = occurrence_pages_sql(relations.len());
3070        let raw = with_sqlite_read_progress(
3071            &self.connection,
3072            control,
3073            IndexWorkStage::RepositoryTraversal,
3074            || {
3075                let mut statement = self.connection.prepare(&sql)?;
3076                let mut queried = statement.query(params_from_iter(bindings.iter()))?;
3077                let mut grouped = (0..relations.len())
3078                    .map(|_| Vec::new())
3079                    .collect::<Vec<Vec<OccurrenceRow>>>();
3080                while let Some(row) = queried.next()? {
3081                    let index_value = row.get::<_, i64>(0)?;
3082                    let index =
3083                        usize::try_from(index_value).map_err(|source| DbError::InvalidCount {
3084                            field: "graph_relation_occurrences.relation_index",
3085                            value: index_value,
3086                            source,
3087                        })?;
3088                    let group = grouped.get_mut(index).ok_or(DbError::GraphRowShape {
3089                        table: "graph_relation_occurrences",
3090                        reason: "occurrence batch returned an invalid relation position",
3091                    })?;
3092                    let occurrence = occurrence_row_at(row, 1)?;
3093                    meter.record_decoded_bytes(
3094                        occurrence_row_decoded_bytes(&occurrence)?
3095                            .checked_add(8)
3096                            .ok_or(GraphContractError::InvalidLimits {
3097                                reason: "graph occurrence batch decoded row size overflowed",
3098                            })?,
3099                    )?;
3100                    group.push(occurrence);
3101                }
3102                Ok(grouped)
3103            },
3104        )?;
3105        let pages = raw
3106            .into_iter()
3107            .zip(relations)
3108            .map(|(rows, relation)| {
3109                page_from_raw(rows, limit, |row| {
3110                    let occurrence = occurrence_from_row(row, relation, generation)?;
3111                    meter.record_hydrated_path(occurrence.file().as_str())?;
3112                    Ok(occurrence)
3113                })
3114            })
3115            .collect::<DbResult<Vec<_>>>()?;
3116        let returned_rows = pages.iter().try_fold(0_usize, |count, page| {
3117            count
3118                .checked_add(page.rows.len())
3119                .ok_or(GraphContractError::InvalidLimits {
3120                    reason: "graph occurrence returned-row accounting overflowed",
3121                })
3122        })?;
3123        let work = meter.finish(returned_rows)?;
3124        Ok(RepositoryGraphReadPages { pages, work })
3125    }
3126
3127    /// Load bounded coverage rows for one exact project or path scope.
3128    ///
3129    /// # Errors
3130    ///
3131    /// Returns an error for invalid limits, unavailable publication state,
3132    /// project mismatch, `SQLite` failure, or any inconsistent coverage row.
3133    pub fn repository_graph_coverage(
3134        &self,
3135        project: ProjectInstanceId,
3136        scope: &CoverageScope,
3137        limit: u32,
3138    ) -> DbResult<RepositoryGraphPage<CoverageRecord>> {
3139        let limit_plus_one = validated_limit_plus_one(
3140            limit,
3141            GraphLimits::MAX_ROWS,
3142            "graph rows must be nonzero and within the product ceiling",
3143        )?;
3144        let Some(generation) = self.repository_graph_generation()? else {
3145            return Ok(empty_page());
3146        };
3147        if !verify_project_identity(&self.connection, project)? {
3148            return Ok(empty_page());
3149        }
3150        let (scope_kind, scope_path) = coverage_scope_parts(scope);
3151        let raw = {
3152            let mut statement = self.connection.prepare_cached(
3153                "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
3154                        relation_kind, state, total, covered, omitted, reason, reached_limit,
3155                        NULL, NULL
3156                   FROM graph_coverage
3157                  WHERE project_instance_id = ?1
3158                    AND scope_kind = ?2 AND scope_path IS ?3
3159                  ORDER BY relation_scope, relation_kind, state, id
3160                  LIMIT ?4",
3161            )?;
3162            let mut rows = statement.query(params![
3163                &project.as_bytes()[..],
3164                scope_kind,
3165                scope_path,
3166                limit_plus_one
3167            ])?;
3168            let mut collected = Vec::new();
3169            while let Some(row) = rows.next()? {
3170                collected.push(coverage_row(row)?);
3171            }
3172            collected
3173        };
3174        page_from_raw(raw, limit, |row| {
3175            coverage_from_row(row, project, generation)
3176        })
3177    }
3178
3179    /// Load current coverage for a bounded unique set of exact repository paths.
3180    ///
3181    /// The complete path set is bound to one prepared statement so service
3182    /// traversal never performs one coverage query per returned node.
3183    ///
3184    /// # Errors
3185    ///
3186    /// Returns an error for duplicate or oversized path sets, cancellation,
3187    /// unavailable publication state, project mismatch, `SQLite` failure, or
3188    /// invalid persisted coverage.
3189    pub fn repository_graph_path_coverage(
3190        &self,
3191        project: ProjectInstanceId,
3192        paths: &[RepositoryNodePath],
3193        control: Option<&IndexWorkControl>,
3194    ) -> DbResult<RepositoryGraphPage<CoverageRecord>> {
3195        validate_path_coverage_request(paths)?;
3196        if paths.is_empty() {
3197            return Ok(empty_page());
3198        }
3199        let Some(generation) = self.repository_graph_generation()? else {
3200            return Ok(empty_page());
3201        };
3202        if !verify_project_identity(&self.connection, project)? {
3203            return Ok(empty_page());
3204        }
3205        Ok(self
3206            .repository_graph_path_coverage_bounded(
3207                project,
3208                generation,
3209                paths,
3210                maximum_repository_graph_read_budget()?,
3211                control,
3212            )?
3213            .page)
3214    }
3215
3216    /// Load current exact-path coverage under one stable graph read envelope.
3217    ///
3218    /// # Errors
3219    ///
3220    /// Returns the same fail-closed errors as
3221    /// [`Self::repository_graph_path_coverage`], rejects a stale project or
3222    /// generation and any returned-row, decoded-byte, or coverage-path
3223    /// hydration overrun, and never returns a partial batch. The raw truncation
3224    /// sentinel is included in decoded and path work.
3225    pub fn repository_graph_path_coverage_bounded(
3226        &self,
3227        project: ProjectInstanceId,
3228        generation: IndexGeneration,
3229        paths: &[RepositoryNodePath],
3230        budget: RepositoryGraphReadBudget,
3231        control: Option<&IndexWorkControl>,
3232    ) -> DbResult<RepositoryGraphReadPage<CoverageRecord>> {
3233        validate_path_coverage_request(paths)?;
3234        self.require_repository_graph_snapshot(project, generation)?;
3235        let mut meter = RepositoryGraphReadMeter::new(budget, paths.len())?;
3236        if paths.is_empty() {
3237            return Ok(RepositoryGraphReadPage {
3238                page: empty_page(),
3239                work: meter.finish(0)?,
3240            });
3241        }
3242
3243        let sql = path_coverage_sql(paths.len());
3244        let mut bindings = Vec::with_capacity(paths.len() + 3);
3245        bindings.push(Value::Blob(project.as_bytes().to_vec()));
3246        bindings.push(Value::Text("path".to_string()));
3247        bindings.extend(
3248            paths
3249                .iter()
3250                .map(|path| Value::Text(path.as_str().to_string())),
3251        );
3252        bindings.push(Value::Integer(i64::from(GraphLimits::MAX_ROWS) + 1));
3253        let raw = with_sqlite_read_progress(
3254            &self.connection,
3255            control,
3256            IndexWorkStage::RepositoryTraversal,
3257            || {
3258                let mut statement = self.connection.prepare(&sql)?;
3259                let mut rows = statement.query(params_from_iter(bindings.iter()))?;
3260                let mut collected = Vec::new();
3261                while let Some(row) = rows.next()? {
3262                    let coverage = coverage_row(row)?;
3263                    meter.record_decoded_bytes(coverage_row_decoded_bytes(&coverage)?)?;
3264                    collected.push(coverage);
3265                }
3266                Ok(collected)
3267            },
3268        )?;
3269        let page = page_from_raw(raw, GraphLimits::MAX_ROWS, |row| {
3270            let coverage = coverage_from_row(row, project, generation)?;
3271            if let CoverageScope::Path { path } = coverage.scope() {
3272                meter.record_hydrated_path(path.as_str())?;
3273            }
3274            Ok(coverage)
3275        })?;
3276        let work = meter.finish(page.rows.len())?;
3277        Ok(RepositoryGraphReadPage { page, work })
3278    }
3279
3280    /// Discover one bounded page of current coverage with optional typed filters.
3281    ///
3282    /// # Errors
3283    ///
3284    /// Returns an error for invalid bounds, unavailable publication state,
3285    /// project mismatch, `SQLite` failure, or invalid persisted coverage or
3286    /// parser provenance.
3287    pub fn repository_coverage_page(
3288        &self,
3289        project: ProjectInstanceId,
3290        query: &RepositoryCoverageQuery,
3291    ) -> DbResult<RepositoryGraphPage<RepositoryCoverageRow>> {
3292        self.repository_coverage_page_controlled(project, query, None)
3293    }
3294
3295    /// Discover coverage through the shared cancellation and deadline boundary.
3296    ///
3297    /// # Errors
3298    ///
3299    /// Returns the same errors as [`Self::repository_coverage_page`] plus typed
3300    /// cancellation or deadline failure while `SQLite` is scanning candidates.
3301    pub fn repository_coverage_page_controlled(
3302        &self,
3303        project: ProjectInstanceId,
3304        query: &RepositoryCoverageQuery,
3305        control: Option<&IndexWorkControl>,
3306    ) -> DbResult<RepositoryGraphPage<RepositoryCoverageRow>> {
3307        let limit_plus_one = validated_limit_plus_one(
3308            query.limit,
3309            GraphLimits::MAX_ROWS,
3310            "coverage rows must be nonzero and within the product ceiling",
3311        )?;
3312        if query.start_index >= GraphLimits::MAX_ROWS {
3313            return Err(GraphContractError::InvalidLimits {
3314                reason: "coverage start index is at or above the product ceiling",
3315            }
3316            .into());
3317        }
3318        let Some(generation) = self.repository_graph_generation()? else {
3319            return Ok(empty_page());
3320        };
3321        if !verify_project_identity(&self.connection, project)? {
3322            return Ok(empty_page());
3323        }
3324
3325        let provenance_driven = query.parser.is_some() || query.provider.is_some();
3326        let path_prefix = query.path_prefix.as_deref().filter(|prefix| *prefix != ".");
3327        let mut sql = String::from(
3328            "SELECT coverage.project_instance_id, coverage.scope_kind,
3329                    coverage.scope_path, coverage.relation_scope,
3330                    coverage.relation_kind, coverage.state, coverage.total,
3331                    coverage.covered, coverage.omitted, coverage.reason,
3332                    coverage.reached_limit, metadata.source_parser,
3333                    metadata.fact_parser
3334               FROM ",
3335        );
3336        if provenance_driven {
3337            sql.push_str(
3338                "source_parse_metadata AS metadata
3339                 CROSS JOIN graph_coverage AS coverage
3340                   ON coverage.scope_kind = 'path'
3341                  AND coverage.scope_path = metadata.path",
3342            );
3343        } else {
3344            sql.push_str(
3345                "graph_coverage AS coverage
3346                 LEFT JOIN source_parse_metadata AS metadata
3347                   ON metadata.path = coverage.scope_path",
3348            );
3349        }
3350        sql.push_str(" WHERE coverage.project_instance_id = ?");
3351        let mut values = vec![Value::Blob(project.as_bytes().to_vec())];
3352
3353        if let Some(prefix) = path_prefix {
3354            sql.push_str(
3355                " AND coverage.scope_kind = 'path'
3356                  AND coverage.scope_path >= ? AND coverage.scope_path < ?
3357                  AND (coverage.scope_path = ? OR coverage.scope_path >= ?)",
3358            );
3359            values.push(Value::Text(prefix.to_string()));
3360            values.push(Value::Text(format!("{prefix}0")));
3361            values.push(Value::Text(prefix.to_string()));
3362            values.push(Value::Text(format!("{prefix}/")));
3363        }
3364        if let Some(parser) = query.parser {
3365            sql.push_str(" AND metadata.source_parser = ?");
3366            values.push(Value::Text(parser.to_string()));
3367        }
3368        if let Some(provider) = query.provider {
3369            sql.push_str(" AND metadata.fact_parser = ?");
3370            values.push(Value::Text(provider.to_string()));
3371        }
3372        if let Some(relation) = query.relation {
3373            let (scope, kind) = relation_parts(relation);
3374            sql.push_str(" AND coverage.relation_scope = ? AND coverage.relation_kind = ?");
3375            values.push(Value::Text(scope.to_string()));
3376            values.push(Value::Text(kind.to_string()));
3377        }
3378        if let Some(state) = query.state {
3379            match state {
3380                CoverageState::NoCandidates => {
3381                    sql.push_str(
3382                        " AND coverage.state = 'complete' AND coverage.total = 0 AND coverage.relation_scope = 'extended' AND coverage.relation_kind = 'documents'",
3383                    );
3384                }
3385                CoverageState::Complete => {
3386                    sql.push_str(
3387                        " AND coverage.state = 'complete' AND NOT (coverage.total = 0 AND coverage.relation_scope IS 'extended' AND coverage.relation_kind IS 'documents')",
3388                    );
3389                }
3390                _ => {
3391                    sql.push_str(" AND coverage.state = ?");
3392                    values.push(Value::Text(coverage_state_name(state).to_string()));
3393                }
3394            }
3395        }
3396        if let Some(reason) = query.reason.as_deref() {
3397            sql.push_str(" AND coverage.reason = ?");
3398            values.push(Value::Text(reason.to_string()));
3399        }
3400
3401        if matches!(query.state, Some(CoverageState::NoCandidates)) {
3402            sql.push_str(
3403                " ORDER BY coverage.relation_scope, coverage.relation_kind,
3404                          coverage.state, coverage.id",
3405            );
3406        } else if provenance_driven {
3407            sql.push_str(" ORDER BY metadata.path, coverage.id");
3408        } else if path_prefix.is_some() {
3409            sql.push_str(
3410                " ORDER BY coverage.scope_path, coverage.relation_scope,
3411                          coverage.relation_kind, coverage.state, coverage.id",
3412            );
3413        } else if query.relation.is_some() {
3414            sql.push_str(
3415                " ORDER BY coverage.relation_scope, coverage.relation_kind,
3416                          coverage.state, coverage.id",
3417            );
3418        } else if query.state.is_some() {
3419            sql.push_str(" ORDER BY coverage.state, coverage.scope_path, coverage.id");
3420        } else if query.reason.is_some() {
3421            sql.push_str(" ORDER BY coverage.reason, coverage.scope_path, coverage.id");
3422        } else {
3423            sql.push_str(
3424                " ORDER BY coverage.scope_kind, coverage.scope_path,
3425                          coverage.relation_scope, coverage.relation_kind,
3426                          coverage.state, coverage.id",
3427            );
3428        }
3429        sql.push_str(" LIMIT ? OFFSET ?");
3430        values.push(Value::Integer(limit_plus_one));
3431        values.push(Value::Integer(i64::from(query.start_index)));
3432
3433        let raw = with_sqlite_read_progress(
3434            &self.connection,
3435            control,
3436            IndexWorkStage::RepositoryTraversal,
3437            || {
3438                let mut statement = self.connection.prepare_cached(&sql)?;
3439                let mut rows = statement.query(params_from_iter(values.iter()))?;
3440                let mut collected = Vec::new();
3441                while let Some(row) = rows.next()? {
3442                    collected.push(coverage_row(row)?);
3443                }
3444                Ok(collected)
3445            },
3446        )?;
3447        page_from_raw(raw, query.limit, |row| {
3448            coverage_discovery_from_row(row, project, generation)
3449        })
3450    }
3451
3452    /// Load bounded typed identity rejections for the current graph generation.
3453    ///
3454    /// # Errors
3455    ///
3456    /// Returns an error for invalid bounds or paths, unavailable publication
3457    /// state, project mismatch, cancellation, `SQLite` failure, or corrupt
3458    /// persisted rejection data.
3459    pub fn repository_graph_identity_rejections(
3460        &self,
3461        project: ProjectInstanceId,
3462        paths: &[RepositoryNodePath],
3463        limit: u32,
3464        control: Option<&IndexWorkControl>,
3465    ) -> DbResult<Vec<GraphIdentityRejection>> {
3466        if limit == 0 || limit > GraphLimits::MAX_ROWS {
3467            return Err(GraphContractError::InvalidLimits {
3468                reason: "identity rejection limit must be nonzero and within the product ceiling",
3469            }
3470            .into());
3471        }
3472        let Some(generation) = self.repository_graph_generation()? else {
3473            return Ok(Vec::new());
3474        };
3475        if !verify_project_identity(&self.connection, project)? {
3476            return Ok(Vec::new());
3477        }
3478        if paths.is_empty() {
3479            return Ok(Vec::new());
3480        }
3481        if paths.len() > GraphLimits::MAX_ROWS as usize {
3482            return Err(GraphContractError::InvalidLimits {
3483                reason: "identity rejection path set exceeds the product ceiling",
3484            }
3485            .into());
3486        }
3487        let placeholders = numbered_placeholders(4, paths.len());
3488        let sql = format!(
3489            "SELECT file_path, start_line, start_column, end_line, end_column,
3490             parser, field, reason, fact_index
3491               FROM graph_identity_rejections
3492              WHERE project_instance_id = ?1 AND generation = ?2
3493                AND file_path IN ({placeholders})
3494               ORDER BY file_path, start_line, start_column, end_line, end_column,
3495                        parser, field, reason, fact_index, id
3496              LIMIT ?3"
3497        );
3498        let mut values = Vec::with_capacity(paths.len() + 3);
3499        values.push(Value::Blob(project.as_bytes().to_vec()));
3500        values.push(Value::Integer(i64::try_from(generation.get()).map_err(
3501            |_error| GraphContractError::InvalidLimits {
3502                reason: "identity rejection generation exceeded SQLite integer range",
3503            },
3504        )?));
3505        values.push(Value::Integer(i64::from(limit)));
3506        values.extend(
3507            paths
3508                .iter()
3509                .map(|path| Value::Text(path.as_str().to_string())),
3510        );
3511        with_sqlite_read_progress(
3512            &self.connection,
3513            control,
3514            IndexWorkStage::RepositoryTraversal,
3515            || {
3516                let mut statement = self.connection.prepare_cached(&sql)?;
3517                let mut rows = statement.query(params_from_iter(values.iter()))?;
3518                let mut rejections = Vec::new();
3519                while let Some(row) = rows.next()? {
3520                    rejections.push(graph_identity_rejection_from_row(row)?);
3521                }
3522                Ok(rejections)
3523            },
3524        )
3525    }
3526
3527    /// Return the complete generation used to reconstruct normalized graph rows.
3528    ///
3529    /// # Errors
3530    ///
3531    /// Returns an error when publication metadata is incomplete or disagrees
3532    /// with the project identity's active graph generation.
3533    pub fn repository_graph_generation(&self) -> DbResult<Option<IndexGeneration>> {
3534        let Some(publication) = self.index_publication()? else {
3535            return Ok(None);
3536        };
3537        if publication.state != IndexPublicationState::Complete
3538            || publication.generation == IndexGeneration::ZERO
3539        {
3540            return Err(DbError::GraphPublicationUnavailable);
3541        }
3542        let Some(graph_generation) = load_graph_generation(&self.connection)? else {
3543            return Ok(None);
3544        };
3545        if graph_generation == IndexGeneration::ZERO || graph_generation != publication.generation {
3546            return Err(DbError::GraphRowShape {
3547                table: "project_identity",
3548                reason: "typed graph generation does not match complete publication",
3549            });
3550        }
3551        Ok(Some(graph_generation))
3552    }
3553
3554    /// Require one exact published graph snapshot for cursor-owned hydration.
3555    pub(crate) fn require_repository_graph_snapshot(
3556        &self,
3557        project: ProjectInstanceId,
3558        generation: IndexGeneration,
3559    ) -> DbResult<()> {
3560        require_bound_project_identity(&self.connection, project)?;
3561        let current = self
3562            .repository_graph_generation()?
3563            .ok_or(DbError::GraphPublicationUnavailable)?;
3564        if current != generation {
3565            return Err(GraphContractError::InvalidLimits {
3566                reason: "graph hydration generation does not match the current publication",
3567            }
3568            .into());
3569        }
3570        Ok(())
3571    }
3572
3573    /// Collect one indexed relation page by source or target key.
3574    fn collect_relation_rows_by_key(
3575        &self,
3576        key_column: &'static str,
3577        key: &[u8; 32],
3578        limit_plus_one: i64,
3579    ) -> DbResult<Vec<RelationRow>> {
3580        let sql = match key_column {
3581            "source_entity_key" => {
3582                "SELECT relation_key, project_instance_id, canonical_identity,
3583                        source_entity_key, relation_scope, relation_kind,
3584                        resolution_status, target_entity_key, reference_text,
3585                        candidate_count, document_unresolved_reason, confidence, completeness
3586                   FROM graph_relations
3587                  WHERE source_entity_key = ?1
3588                  ORDER BY relation_scope, relation_kind, relation_key
3589                  LIMIT ?2"
3590            }
3591            "target_entity_key" => {
3592                "SELECT relation_key, project_instance_id, canonical_identity,
3593                        source_entity_key, relation_scope, relation_kind,
3594                        resolution_status, target_entity_key, reference_text,
3595                        candidate_count, document_unresolved_reason, confidence, completeness
3596                   FROM graph_relations
3597                  WHERE target_entity_key = ?1
3598                  ORDER BY relation_scope, relation_kind, relation_key
3599                  LIMIT ?2"
3600            }
3601            _ => {
3602                return Err(DbError::GraphRowShape {
3603                    table: "graph_relations",
3604                    reason: "unsupported internal relation lookup",
3605                });
3606            }
3607        };
3608        let mut statement = self.connection.prepare_cached(sql)?;
3609        collect_relation_rows(statement.query(params![&key[..], limit_plus_one])?)
3610    }
3611}
3612
3613/// Decode the complete normalized graph from one private `SQLite` backup.
3614pub(crate) fn capture_derived_graph(
3615    connection: &Connection,
3616    project: ProjectInstanceId,
3617    generation: IndexGeneration,
3618    budget: &mut SnapshotBudget,
3619) -> DbResult<CapturedGraph> {
3620    let mut entities = Vec::new();
3621    let mut entities_by_key = HashMap::new();
3622    {
3623        let mut statement = connection.prepare(
3624            "SELECT entity_key, project_instance_id, canonical_identity, entity_kind,
3625                    repository_path, package_manager, package_name, manifest_path,
3626                    symbol_name, symbol_kind, symbol_parent, symbol_signature,
3627                    external_system, external_identity
3628               FROM graph_entities
3629              WHERE project_instance_id = ?1
3630              ORDER BY entity_key",
3631        )?;
3632        let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3633        while let Some(row) = rows.next()? {
3634            let raw = entity_row(row)?;
3635            budget.admit(entity_row_decoded_bytes(&raw)?)?;
3636            let entity = entity_from_row(raw, project, generation)?;
3637            let key = entity.key().digest_bytes()?;
3638            if entities_by_key.insert(key, entity.clone()).is_some() {
3639                return Err(DbError::DerivedSnapshotInvalid {
3640                    reason: "private capture contains a duplicate entity",
3641                });
3642            }
3643            entities.push(entity);
3644        }
3645    }
3646
3647    let mut relations = Vec::new();
3648    let mut relations_by_key = HashMap::new();
3649    let mut document_unresolved_reasons = Vec::new();
3650    {
3651        let mut statement = connection.prepare(
3652            "SELECT relation_key, project_instance_id, canonical_identity,
3653                    source_entity_key, relation_scope, relation_kind,
3654                    resolution_status, target_entity_key, reference_text,
3655                    candidate_count, document_unresolved_reason, confidence, completeness
3656               FROM graph_relations
3657              WHERE project_instance_id = ?1
3658              ORDER BY relation_key",
3659        )?;
3660        let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3661        while let Some(row) = rows.next()? {
3662            let raw = relation_row(row)?;
3663            budget.admit(relation_row_decoded_bytes(&raw)?)?;
3664            let document_unresolved_reason = document_unresolved_reason_from_row(&raw)?;
3665            let relation = relation_from_row(&entities_by_key, raw, project, generation)?;
3666            let key = relation.key().digest_bytes()?;
3667            if relations_by_key.insert(key, relation.clone()).is_some() {
3668                return Err(DbError::DerivedSnapshotInvalid {
3669                    reason: "private capture contains a duplicate relation",
3670                });
3671            }
3672            if let Some(reason) = document_unresolved_reason {
3673                document_unresolved_reasons.push((key, reason));
3674            }
3675            relations.push(relation);
3676        }
3677    }
3678
3679    let mut occurrences = Vec::new();
3680    {
3681        let mut statement = connection.prepare(
3682            "SELECT relation_key, file_path, start_line, start_column, end_line, end_column
3683               FROM graph_relation_occurrences
3684              ORDER BY relation_key, file_path, start_line, start_column, end_line, end_column",
3685        )?;
3686        let mut rows = statement.query([])?;
3687        while let Some(row) = rows.next()? {
3688            let raw = occurrence_row(row)?;
3689            budget.admit(occurrence_row_decoded_bytes(&raw)?)?;
3690            let key = fixed_bytes::<32>(
3691                "graph_relation_occurrences.relation_key",
3692                raw.relation.clone(),
3693            )?;
3694            let relation = relations_by_key
3695                .get(&key)
3696                .ok_or(DbError::DerivedSnapshotInvalid {
3697                    reason: "private capture occurrence owner is absent",
3698                })?;
3699            occurrences.push(occurrence_from_row(raw, relation, generation)?);
3700        }
3701    }
3702
3703    let mut coverage = Vec::new();
3704    {
3705        let mut statement = connection.prepare(
3706            "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
3707                    relation_kind, state, total, covered, omitted, reason,
3708                    reached_limit, NULL, NULL
3709               FROM graph_coverage
3710              WHERE project_instance_id = ?1
3711              ORDER BY scope_kind, scope_path, relation_scope, relation_kind, state, id",
3712        )?;
3713        let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3714        while let Some(row) = rows.next()? {
3715            let raw = coverage_row(row)?;
3716            budget.admit(coverage_row_decoded_bytes(&raw)?)?;
3717            coverage.push(coverage_from_row(raw, project, generation)?);
3718        }
3719    }
3720
3721    let mut entity_exports = Vec::new();
3722    {
3723        let mut statement = connection.prepare(
3724            "SELECT key.project_instance_id, key.resolution_domain, key.key_digest,
3725                    key.canonical_identity, export.entity_key
3726               FROM graph_entity_exports AS export
3727               JOIN graph_resolution_keys AS key
3728                 ON key.project_instance_id = export.project_instance_id
3729                AND key.resolution_domain = export.resolution_domain
3730                AND key.key_digest = export.key_digest
3731              WHERE export.project_instance_id = ?1
3732              ORDER BY export.entity_key, key.resolution_domain, key.canonical_identity",
3733        )?;
3734        let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3735        while let Some(row) = rows.next()? {
3736            let key = resolution_key_from_row(row, project)?;
3737            let owner =
3738                fixed_bytes::<32>("graph_entity_exports.entity_key", row.get::<_, Vec<u8>>(4)?)?;
3739            budget.admit(decoded_payload_bytes(
3740                [
3741                    key.canonical_identity().len(),
3742                    key.digest_bytes().len(),
3743                    owner.len(),
3744                ],
3745                16,
3746            )?)?;
3747            entity_exports.push((owner, key));
3748        }
3749    }
3750
3751    let mut relation_dependencies = Vec::new();
3752    {
3753        let mut statement = connection.prepare(
3754            "SELECT key.project_instance_id, key.resolution_domain, key.key_digest,
3755                    key.canonical_identity, dependency.relation_key
3756               FROM graph_relation_dependencies AS dependency
3757               JOIN graph_resolution_keys AS key
3758                 ON key.project_instance_id = dependency.project_instance_id
3759                AND key.resolution_domain = dependency.resolution_domain
3760                AND key.key_digest = dependency.key_digest
3761              WHERE dependency.project_instance_id = ?1
3762              ORDER BY dependency.relation_key, key.resolution_domain, key.canonical_identity",
3763        )?;
3764        let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3765        while let Some(row) = rows.next()? {
3766            let key = resolution_key_from_row(row, project)?;
3767            let owner = fixed_bytes::<32>(
3768                "graph_relation_dependencies.relation_key",
3769                row.get::<_, Vec<u8>>(4)?,
3770            )?;
3771            budget.admit(decoded_payload_bytes(
3772                [
3773                    key.canonical_identity().len(),
3774                    key.digest_bytes().len(),
3775                    owner.len(),
3776                ],
3777                16,
3778            )?)?;
3779            relation_dependencies.push((owner, key));
3780        }
3781    }
3782
3783    Ok(CapturedGraph {
3784        file_classifications: Vec::new(),
3785        entities,
3786        relations,
3787        document_unresolved_reasons,
3788        occurrences,
3789        coverage,
3790        entity_exports,
3791        relation_dependencies,
3792    })
3793}
3794
3795impl AtlasStore {
3796    /// Return whether an existing file is an exact disposable stage for this project.
3797    ///
3798    /// This intentionally validates only staging ownership metadata because disposable
3799    /// stores omit normal read indexes and are not ordinary `ProjectAtlas` databases.
3800    ///
3801    /// # Errors
3802    ///
3803    /// Returns an error when the file cannot be opened read-only or its ownership
3804    /// metadata cannot be read.
3805    pub fn repository_graph_staging_belongs_to(
3806        path: &Path,
3807        root: &Path,
3808        project: ProjectInstanceId,
3809    ) -> DbResult<bool> {
3810        let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
3811        let marker = connection
3812            .query_row(
3813                "SELECT value FROM metadata WHERE key = ?1",
3814                [GRAPH_STAGING_MARKER_KEY],
3815                |row| row.get::<_, String>(0),
3816            )
3817            .optional()?;
3818        let schema_version = connection
3819            .query_row(
3820                "SELECT value FROM metadata WHERE key = ?1",
3821                [schema::SCHEMA_VERSION_KEY],
3822                |row| row.get::<_, String>(0),
3823            )
3824            .optional()?
3825            .and_then(|value| value.parse::<i64>().ok());
3826        let Some(schema_version) = schema_version else {
3827            return Ok(false);
3828        };
3829        let expected_root = projectatlas_core::CanonicalProjectRoot::from_path(root)?;
3830        let roots_match = match schema_version {
3831            schema::SCHEMA_VERSION => {
3832                load_project_root_identity(&connection)?.is_some_and(|stored_root| {
3833                    prove_existing_root_equivalence(expected_root.as_path(), stored_root.as_path())
3834                        .is_ok()
3835                })
3836            }
3837            // Predecessor stages intentionally lack native identity. The
3838            // staging marker and project identity checked below are their
3839            // staging-specific ownership proof; general predecessor admission
3840            // remains strict about legacy root authority.
3841            schema::CANONICAL_ROOT_PREDECESSOR_SCHEMA_VERSION => true,
3842            _ => false,
3843        };
3844        Ok(roots_match
3845            && load_project_identity(&connection)? == Some(project)
3846            && marker.as_deref() == Some(GRAPH_STAGING_MARKER_VALUE))
3847    }
3848
3849    /// Create a new disposable graph staging store with the selected project identity.
3850    ///
3851    /// # Errors
3852    ///
3853    /// Returns an error when the path already exists, schema initialization or
3854    /// root binding fails, or the selected identity cannot be stored.
3855    pub fn create_repository_graph_staging(
3856        path: &Path,
3857        root: &Path,
3858        project: ProjectInstanceId,
3859    ) -> DbResult<Self> {
3860        if path.exists() {
3861            return Err(DbError::GraphRowShape {
3862                table: "repository graph staging database",
3863                reason: "staging path already exists",
3864            });
3865        }
3866        let mut store = Self::open_for_project(path, root)?;
3867        set_project_identity(&store.connection, project)?;
3868        store.connection.execute(
3869            "INSERT INTO metadata(key, value) VALUES (?1, ?2)
3870             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
3871            params![GRAPH_STAGING_MARKER_KEY, GRAPH_STAGING_MARKER_VALUE],
3872        )?;
3873        drop_graph_rebuildable_indexes(&store.connection)?;
3874        store.checkpoint_repository_graph_staging()?;
3875        store.validated_project_instance_id = Some(project);
3876        Ok(store)
3877    }
3878
3879    /// Checkpoint and truncate a completed disposable graph staging WAL.
3880    ///
3881    /// # Errors
3882    ///
3883    /// Returns an error when another connection keeps the staging WAL busy or
3884    /// `SQLite` cannot complete the checkpoint.
3885    pub fn checkpoint_repository_graph_staging(&self) -> DbResult<()> {
3886        let (busy, _log_frames, _checkpointed_frames) =
3887            self.connection
3888                .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
3889                    Ok((
3890                        row.get::<_, i64>(0)?,
3891                        row.get::<_, i64>(1)?,
3892                        row.get::<_, i64>(2)?,
3893                    ))
3894                })?;
3895        if busy != 0 {
3896            return Err(DbError::GraphRowShape {
3897                table: "repository graph staging database",
3898                reason: "WAL checkpoint remained busy",
3899            });
3900        }
3901        Ok(())
3902    }
3903
3904    /// Begin one disposable typed repository-graph staging transaction.
3905    ///
3906    /// # Errors
3907    ///
3908    /// Returns an error when the store belongs to another project, the generation
3909    /// is zero, or `SQLite` cannot begin or clear the staging transaction.
3910    pub fn begin_repository_graph_staging(
3911        &mut self,
3912        project: ProjectInstanceId,
3913        generation: IndexGeneration,
3914    ) -> DbResult<RepositoryGraphStagingGuard<'_>> {
3915        if generation == IndexGeneration::ZERO {
3916            return Err(GraphContractError::InvalidGeneration.into());
3917        }
3918        let transaction = self.connection.transaction()?;
3919        require_bound_project_identity(&transaction, project)?;
3920        transaction.execute("DELETE FROM graph_resolution_keys", [])?;
3921        transaction.execute("DELETE FROM graph_coverage", [])?;
3922        transaction.execute("DELETE FROM graph_identity_rejections", [])?;
3923        transaction.execute("DELETE FROM graph_relations", [])?;
3924        transaction.execute("DELETE FROM graph_entities", [])?;
3925        Ok(RepositoryGraphStagingGuard {
3926            transaction,
3927            project,
3928            generation,
3929        })
3930    }
3931}
3932
3933impl RepositoryGraphStagingGuard<'_> {
3934    /// Append borrowed entity rows without cloning the staged domain objects.
3935    ///
3936    /// # Errors
3937    ///
3938    /// Returns an error for foreign project or generation rows, stable-key
3939    /// collisions, or `SQLite` failures.
3940    pub fn append_entity_refs(&mut self, entities: &[&GraphEntity]) -> DbResult<()> {
3941        if entities.iter().any(|entity| {
3942            entity.key().project() != self.project || entity.generation() != self.generation
3943        }) {
3944            return Err(GraphContractError::GenerationMismatch {
3945                context: "repository graph staging entities",
3946            }
3947            .into());
3948        }
3949        insert_entities(&self.transaction, self.project, entities.iter().copied())
3950    }
3951
3952    /// Append one fully validated graph and resolution-key batch.
3953    ///
3954    /// # Errors
3955    ///
3956    /// Returns an error for foreign project or generation rows, invalid owner
3957    /// bindings, stable-key collisions, or `SQLite` failures.
3958    #[allow(clippy::too_many_arguments)]
3959    pub fn append_batch(
3960        &mut self,
3961        entities: &[GraphEntity],
3962        relations: &[LogicalRelation],
3963        occurrences: &[RelationOccurrence],
3964        coverage: &[CoverageRecord],
3965        entity_exports: &[EntityResolutionKey],
3966        relation_dependencies: &[RelationDependencyKey],
3967    ) -> DbResult<()> {
3968        validate_graph_batch(
3969            self.project,
3970            self.generation,
3971            entities,
3972            relations,
3973            occurrences,
3974            coverage,
3975        )?;
3976        validate_resolution_key_batch(self.project, entity_exports, relation_dependencies)?;
3977        insert_graph_batch(
3978            &self.transaction,
3979            self.project,
3980            entities,
3981            relations,
3982            occurrences,
3983            coverage,
3984        )?;
3985        insert_resolution_key_batch(
3986            &self.transaction,
3987            self.project,
3988            entity_exports,
3989            relation_dependencies,
3990        )
3991    }
3992
3993    /// Attach closed reasons to staged unresolved `documents` relations.
3994    ///
3995    /// # Errors
3996    ///
3997    /// Returns an error without partial mutation for duplicate keys,
3998    /// incompatible staged relations, or `SQLite` failures.
3999    pub fn set_document_unresolved_reasons(
4000        &mut self,
4001        reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4002    ) -> DbResult<()> {
4003        self.set_document_unresolved_reasons_with_control(reasons, None)
4004    }
4005
4006    /// Attach staged document reasons while observing publication cancellation.
4007    ///
4008    /// # Errors
4009    ///
4010    /// Returns an error without partial mutation when cancellation, duplicate
4011    /// keys, incompatible staged relations, or `SQLite` failures occur.
4012    pub fn set_document_unresolved_reasons_controlled(
4013        &mut self,
4014        reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4015        control: &IndexWorkControl,
4016    ) -> DbResult<()> {
4017        self.set_document_unresolved_reasons_with_control(reasons, Some(control))
4018    }
4019
4020    /// Apply staged reasons through one savepoint with optional cancellation checks.
4021    fn set_document_unresolved_reasons_with_control(
4022        &mut self,
4023        reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4024        control: Option<&IndexWorkControl>,
4025    ) -> DbResult<()> {
4026        let savepoint = self.transaction.savepoint()?;
4027        write_document_unresolved_reasons(&savepoint, reasons, control)?;
4028        savepoint.commit()?;
4029        Ok(())
4030    }
4031
4032    /// Commit the staged graph and bind it to the requested generation.
4033    ///
4034    /// # Errors
4035    ///
4036    /// Returns an error when `SQLite` cannot persist the generation or commit.
4037    pub fn complete(self) -> DbResult<()> {
4038        validate_complete_document_unresolved_reasons(&self.transaction)?;
4039        set_graph_generation(&self.transaction, self.generation)?;
4040        self.transaction.commit()?;
4041        Ok(())
4042    }
4043}
4044
4045/// Normalized graph columns copied from one typed disposable staging database.
4046const GRAPH_STAGE_COPY_TABLES: &[(&str, &str)] = &[
4047    (
4048        "graph_entities",
4049        "entity_key, project_instance_id, canonical_identity, entity_kind, repository_path, \
4050         package_manager, package_name, manifest_path, symbol_name, symbol_kind, symbol_parent, \
4051         symbol_signature, external_system, external_identity",
4052    ),
4053    (
4054        "graph_relations",
4055        "relation_key, project_instance_id, canonical_identity, source_entity_key, \
4056         relation_scope, relation_kind, resolution_status, target_entity_key, reference_text, \
4057         candidate_count, document_unresolved_reason, confidence, completeness",
4058    ),
4059    (
4060        "graph_relation_occurrences",
4061        "relation_key, file_path, start_line, start_column, end_line, end_column",
4062    ),
4063    (
4064        "graph_coverage",
4065        "project_instance_id, scope_kind, scope_path, relation_scope, relation_kind, state, \
4066         total, covered, omitted, reason, reached_limit",
4067    ),
4068    (
4069        "graph_resolution_keys",
4070        "project_instance_id, resolution_domain, key_digest, canonical_identity",
4071    ),
4072    (
4073        "graph_entity_exports",
4074        "project_instance_id, entity_key, owner_path, resolution_domain, key_digest",
4075    ),
4076    (
4077        "graph_relation_dependencies",
4078        "project_instance_id, relation_key, owner_path, resolution_domain, key_digest",
4079    ),
4080];
4081
4082/// Non-unique graph read indexes rebuilt once after a full bulk replacement.
4083const GRAPH_REBUILDABLE_INDEX_NAMES: &[&str] = &[
4084    "idx_graph_entities_path",
4085    "idx_graph_entities_package",
4086    "idx_graph_entities_manifest_path",
4087    "idx_graph_entities_symbol",
4088    "idx_graph_entities_external",
4089    "idx_graph_relations_source_kind",
4090    "idx_graph_relations_target_kind",
4091    "idx_graph_relations_kind_order",
4092    "idx_graph_relations_kind_resolution",
4093    "idx_graph_occurrences_file_span",
4094    "idx_graph_coverage_scope_state",
4095    "idx_graph_coverage_scope_order",
4096    "idx_graph_coverage_path",
4097    "idx_graph_coverage_relation_state",
4098    "idx_graph_coverage_discovery_state",
4099    "idx_graph_coverage_discovery_reason",
4100    "idx_graph_entity_exports_key",
4101    "idx_graph_entity_exports_owner",
4102    "idx_graph_relation_dependencies_key",
4103    "idx_graph_relation_dependencies_owner",
4104];
4105
4106/// Drop only the known derived read indexes around one bulk graph load.
4107fn drop_graph_rebuildable_indexes(connection: &Connection) -> DbResult<()> {
4108    for name in GRAPH_REBUILDABLE_INDEX_NAMES {
4109        connection.execute(&format!("DROP INDEX IF EXISTS {name}"), [])?;
4110    }
4111    Ok(())
4112}
4113
4114/// Capture exact live index definitions before a transactional bulk graph load.
4115fn graph_rebuildable_index_sql(connection: &Connection) -> DbResult<Vec<String>> {
4116    GRAPH_REBUILDABLE_INDEX_NAMES
4117        .iter()
4118        .map(|name| {
4119            connection
4120                .query_row(
4121                    "SELECT sql FROM sqlite_schema WHERE type = 'index' AND name = ?1",
4122                    [name],
4123                    |row| row.get::<_, String>(0),
4124                )
4125                .optional()?
4126                .ok_or(DbError::GraphRowShape {
4127                    table: "repository graph indexes",
4128                    reason: "required rebuildable index is missing",
4129                })
4130        })
4131        .collect()
4132}
4133/// Maximum rows copied through one bounded multi-row `SQLite` insert.
4134const GRAPH_STAGE_COPY_ROWS: usize = 512;
4135
4136/// Insert one bounded group of decoded staging values.
4137fn insert_graph_stage_values(
4138    target: &Connection,
4139    table: &'static str,
4140    columns: &'static str,
4141    column_count: usize,
4142    values: &[Value],
4143) -> DbResult<()> {
4144    let rows = values.len() / column_count;
4145    let row = format!(
4146        "({})",
4147        std::iter::repeat_n("?", column_count)
4148            .collect::<Vec<_>>()
4149            .join(",")
4150    );
4151    let placeholders = std::iter::repeat_n(row, rows).collect::<Vec<_>>().join(",");
4152    let insert_sql = format!("INSERT INTO {table}({columns}) VALUES{placeholders}");
4153    target.execute(&insert_sql, params_from_iter(values.iter()))?;
4154    Ok(())
4155}
4156
4157/// Copy one normalized table through bounded decoded multi-row inserts.
4158fn copy_graph_stage_table(
4159    source: &Connection,
4160    target: &Connection,
4161    table: &'static str,
4162    columns: &'static str,
4163    control: Option<&IndexWorkControl>,
4164) -> DbResult<()> {
4165    let column_count = columns.split(',').count();
4166    let select_sql = format!("SELECT {columns} FROM {table}");
4167    let mut select = source.prepare(&select_sql)?;
4168    let mut rows = select.query([])?;
4169    let mut copied = 0_usize;
4170    let mut values = Vec::with_capacity(GRAPH_STAGE_COPY_ROWS.saturating_mul(column_count));
4171    while let Some(row) = rows.next()? {
4172        if copied.is_multiple_of(256)
4173            && let Some(control) = control
4174        {
4175            control.check(IndexWorkStage::Publication)?;
4176        }
4177        for column in 0..column_count {
4178            values.push(row.get::<_, Value>(column)?);
4179        }
4180        copied = copied.saturating_add(1);
4181        if copied.is_multiple_of(GRAPH_STAGE_COPY_ROWS) {
4182            insert_graph_stage_values(target, table, columns, column_count, &values)?;
4183            values.clear();
4184        }
4185    }
4186    if !values.is_empty() {
4187        insert_graph_stage_values(target, table, columns, column_count, &values)?;
4188    }
4189    Ok(())
4190}
4191
4192impl IndexPublicationGuard<'_> {
4193    /// Replace the complete graph from one typed disposable staging database.
4194    ///
4195    /// # Errors
4196    ///
4197    /// Returns an error when staging integrity, project identity, generation,
4198    /// normalized constraints, cancellation, or `SQLite` copying fails.
4199    pub fn replace_repository_graph_from_staging(
4200        &mut self,
4201        project: ProjectInstanceId,
4202        staging: &AtlasStore,
4203        control: Option<&IndexWorkControl>,
4204    ) -> DbResult<()> {
4205        let generation = self.pending_graph_generation()?;
4206        let staged_project = load_project_identity(&staging.connection)?;
4207        if staged_project != Some(project) {
4208            return Err(DbError::GraphProjectIdentityMismatch {
4209                expected: project.to_string(),
4210                found: staged_project
4211                    .map_or_else(|| "missing".to_string(), |value| value.to_string()),
4212            });
4213        }
4214        if load_graph_generation(&staging.connection)? != Some(generation) {
4215            return Err(GraphContractError::GenerationMismatch {
4216                context: "staged repository graph",
4217            }
4218            .into());
4219        }
4220        let quick_check = staging
4221            .connection
4222            .query_row("PRAGMA quick_check(1)", [], |row| row.get::<_, String>(0))?;
4223        if quick_check != "ok" {
4224            return Err(DbError::GraphRowShape {
4225                table: "repository graph staging database",
4226                reason: "quick check failed",
4227            });
4228        }
4229        let mut foreign_keys = staging.connection.prepare("PRAGMA foreign_key_check")?;
4230        if foreign_keys.query([])?.next()?.is_some() {
4231            return Err(DbError::GraphRowShape {
4232                table: "repository graph staging database",
4233                reason: "foreign key check failed",
4234            });
4235        }
4236        validate_complete_document_unresolved_reasons(&staging.connection)?;
4237
4238        let savepoint = self.store.connection.savepoint()?;
4239        require_bound_project_identity(&savepoint, project)?;
4240        prepare_graph_identity_rejections(&savepoint, project, generation, None)?;
4241        let rebuildable_indexes = graph_rebuildable_index_sql(&savepoint)?;
4242        drop_graph_rebuildable_indexes(&savepoint)?;
4243        savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
4244        savepoint.execute("DELETE FROM graph_coverage", [])?;
4245        savepoint.execute("DELETE FROM graph_relations", [])?;
4246        savepoint.execute("DELETE FROM graph_entities", [])?;
4247        for (table, columns) in GRAPH_STAGE_COPY_TABLES {
4248            copy_graph_stage_table(&staging.connection, &savepoint, table, columns, control)?;
4249        }
4250        set_graph_generation(&savepoint, generation)?;
4251        for index_sql in rebuildable_indexes {
4252            savepoint.execute_batch(&index_sql)?;
4253        }
4254        savepoint.commit()?;
4255        Ok(())
4256    }
4257
4258    /// Replace the complete normalized repository graph inside this publication.
4259    ///
4260    /// # Errors
4261    ///
4262    /// Returns an error when records do not belong to the pending generation or
4263    /// selected project, a stable-key collision is detected, or `SQLite` fails.
4264    pub fn replace_repository_graph(
4265        &mut self,
4266        project: ProjectInstanceId,
4267        entities: &[GraphEntity],
4268        relations: &[LogicalRelation],
4269        occurrences: &[RelationOccurrence],
4270        coverage: &[CoverageRecord],
4271    ) -> DbResult<()> {
4272        self.replace_repository_graph_with_resolution_keys(
4273            project,
4274            entities,
4275            relations,
4276            occurrences,
4277            coverage,
4278            &[],
4279            &[],
4280        )
4281    }
4282
4283    /// Replace the complete graph and canonical resolution-key projection atomically.
4284    ///
4285    /// # Errors
4286    ///
4287    /// Returns an error when graph or key records do not belong to the pending
4288    /// generation and selected project, a stable-key collision is detected, an
4289    /// owner has no exact source path, or `SQLite` fails.
4290    #[allow(clippy::too_many_arguments)]
4291    pub fn replace_repository_graph_with_resolution_keys(
4292        &mut self,
4293        project: ProjectInstanceId,
4294        entities: &[GraphEntity],
4295        relations: &[LogicalRelation],
4296        occurrences: &[RelationOccurrence],
4297        coverage: &[CoverageRecord],
4298        entity_exports: &[EntityResolutionKey],
4299        relation_dependencies: &[RelationDependencyKey],
4300    ) -> DbResult<()> {
4301        let generation = self.pending_graph_generation()?;
4302        validate_graph_batch(
4303            project,
4304            generation,
4305            entities,
4306            relations,
4307            occurrences,
4308            coverage,
4309        )?;
4310        validate_resolution_key_batch(project, entity_exports, relation_dependencies)?;
4311        let savepoint = self.store.connection.savepoint()?;
4312        require_bound_project_identity(&savepoint, project)?;
4313        prepare_graph_identity_rejections(&savepoint, project, generation, None)?;
4314        savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
4315        savepoint.execute("DELETE FROM graph_coverage", [])?;
4316        savepoint.execute("DELETE FROM graph_relations", [])?;
4317        savepoint.execute("DELETE FROM graph_entities", [])?;
4318        insert_graph_batch(
4319            &savepoint,
4320            project,
4321            entities,
4322            relations,
4323            occurrences,
4324            coverage,
4325        )?;
4326        insert_resolution_key_batch(&savepoint, project, entity_exports, relation_dependencies)?;
4327        set_graph_generation(&savepoint, generation)?;
4328        savepoint.commit()?;
4329        Ok(())
4330    }
4331
4332    /// Replace the normalized graph closure owned by affected repository paths.
4333    ///
4334    /// Unchanged rows stay physically untouched and are reconstructed at the next
4335    /// complete publication generation. The caller supplies the complete new
4336    /// closure for the affected paths.
4337    ///
4338    /// # Errors
4339    ///
4340    /// Returns an error when records do not belong to the pending generation or
4341    /// selected project, a stable-key collision is detected, or `SQLite` fails.
4342    pub fn replace_repository_graph_for_paths(
4343        &mut self,
4344        project: ProjectInstanceId,
4345        affected_paths: &[String],
4346        entities: &[GraphEntity],
4347        relations: &[LogicalRelation],
4348        occurrences: &[RelationOccurrence],
4349        coverage: &[CoverageRecord],
4350    ) -> DbResult<()> {
4351        self.replace_repository_graph_for_paths_with_resolution_keys(
4352            project,
4353            affected_paths,
4354            entities,
4355            relations,
4356            occurrences,
4357            coverage,
4358            &[],
4359            &[],
4360        )
4361    }
4362
4363    /// Replace one affected graph closure and its canonical resolution keys atomically.
4364    ///
4365    /// # Errors
4366    ///
4367    /// Returns an error when graph or key records do not belong to the pending
4368    /// generation and selected project, a stable-key collision is detected, an
4369    /// owner has no exact source path, or `SQLite` fails.
4370    #[allow(clippy::too_many_arguments)]
4371    pub fn replace_repository_graph_for_paths_with_resolution_keys(
4372        &mut self,
4373        project: ProjectInstanceId,
4374        affected_paths: &[String],
4375        entities: &[GraphEntity],
4376        relations: &[LogicalRelation],
4377        occurrences: &[RelationOccurrence],
4378        coverage: &[CoverageRecord],
4379        entity_exports: &[EntityResolutionKey],
4380        relation_dependencies: &[RelationDependencyKey],
4381    ) -> DbResult<()> {
4382        let generation = self.pending_graph_generation()?;
4383        validate_graph_batch(
4384            project,
4385            generation,
4386            entities,
4387            relations,
4388            occurrences,
4389            coverage,
4390        )?;
4391        validate_resolution_key_batch(project, entity_exports, relation_dependencies)?;
4392        let affected_paths = affected_paths
4393            .iter()
4394            .map(|path| RepositoryNodePath::new(Path::new(path)))
4395            .collect::<Result<Vec<_>, _>>()?;
4396        let savepoint = self.store.connection.savepoint()?;
4397        require_bound_project_identity(&savepoint, project)?;
4398        if affected_paths.iter().any(|path| path.as_str() == ".") {
4399            prepare_graph_identity_rejections(&savepoint, project, generation, None)?;
4400            savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
4401            savepoint.execute("DELETE FROM graph_coverage", [])?;
4402            savepoint.execute("DELETE FROM graph_relations", [])?;
4403            savepoint.execute("DELETE FROM graph_entities", [])?;
4404            insert_graph_batch(
4405                &savepoint,
4406                project,
4407                entities,
4408                relations,
4409                occurrences,
4410                coverage,
4411            )?;
4412            insert_resolution_key_batch(
4413                &savepoint,
4414                project,
4415                entity_exports,
4416                relation_dependencies,
4417            )?;
4418            set_graph_generation(&savepoint, generation)?;
4419            savepoint.commit()?;
4420            return Ok(());
4421        }
4422        prepare_graph_identity_rejections(&savepoint, project, generation, Some(&affected_paths))?;
4423        let touched_keys = resolution_keys_for_owner_paths(
4424            &savepoint,
4425            project,
4426            &affected_paths,
4427            ResolutionOwner::Both,
4428            None,
4429        )?;
4430        let mut orphan_candidates = affected_external_candidates(&savepoint, &affected_paths)?;
4431        invalidate_repository_graph_paths(&savepoint, &affected_paths, &mut orphan_candidates)?;
4432        insert_graph_batch(
4433            &savepoint,
4434            project,
4435            entities,
4436            relations,
4437            occurrences,
4438            coverage,
4439        )?;
4440        insert_resolution_key_batch(&savepoint, project, entity_exports, relation_dependencies)?;
4441        for entity in entities {
4442            if matches!(entity.selector(), EntitySelector::External { .. }) {
4443                orphan_candidates.insert(entity.key().digest_bytes()?);
4444            }
4445        }
4446        remove_orphan_external_candidates(&savepoint, &orphan_candidates)?;
4447        remove_touched_orphan_resolution_keys(&savepoint, &touched_keys)?;
4448        set_graph_generation(&savepoint, generation)?;
4449        savepoint.commit()?;
4450        Ok(())
4451    }
4452
4453    /// Attach closed reasons to unresolved `documents` relations in this publication.
4454    ///
4455    /// Call this after the owning graph replacement and before completing the
4456    /// parent publication. The complete input is validated before prepared
4457    /// chunks at or below the graph row ceiling are applied, and every key must
4458    /// select exactly one unresolved `documents` relation.
4459    ///
4460    /// # Errors
4461    ///
4462    /// Returns an error before mutation for duplicate keys, or if a key does
4463    /// not identify one compatible relation in the selected project.
4464    pub fn set_document_unresolved_reasons(
4465        &mut self,
4466        reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4467    ) -> DbResult<()> {
4468        self.set_document_unresolved_reasons_with_control(reasons, None)
4469    }
4470
4471    /// Attach document reasons while observing publication cancellation.
4472    ///
4473    /// # Errors
4474    ///
4475    /// Returns an error without partial mutation when cancellation, duplicate
4476    /// keys, incompatible relations, or `SQLite` failures occur.
4477    pub fn set_document_unresolved_reasons_controlled(
4478        &mut self,
4479        reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4480        control: &IndexWorkControl,
4481    ) -> DbResult<()> {
4482        self.set_document_unresolved_reasons_with_control(reasons, Some(control))
4483    }
4484
4485    /// Apply publication reasons through one savepoint with optional cancellation checks.
4486    fn set_document_unresolved_reasons_with_control(
4487        &mut self,
4488        reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4489        control: Option<&IndexWorkControl>,
4490    ) -> DbResult<()> {
4491        let savepoint = self.store.validated_savepoint()?;
4492        write_document_unresolved_reasons(&savepoint, reasons, control)?;
4493        savepoint.commit()?;
4494        Ok(())
4495    }
4496
4497    /// Replace bounded parser identity rejection rows in this publication.
4498    ///
4499    /// Append the typed details prepared by the graph replacement in this
4500    /// publication. The replacement method clears or rebinds the existing rows
4501    /// before changing graph nodes, so these inserts share its parent transaction;
4502    /// the complete resulting generation must remain within the same row ceiling.
4503    ///
4504    /// # Errors
4505    ///
4506    /// Returns an error for a foreign project/path, an oversized complete
4507    /// generation, invalid span data, or `SQLite` failure.
4508    pub fn replace_graph_identity_rejections(
4509        &mut self,
4510        project: ProjectInstanceId,
4511        rejections: &[GraphIdentityRejection],
4512    ) -> DbResult<()> {
4513        if rejections.len() > GraphLimits::MAX_ROWS as usize {
4514            return Err(GraphContractError::InvalidLimits {
4515                reason: "graph identity rejection batch exceeds the graph row ceiling",
4516            }
4517            .into());
4518        }
4519        let generation = self.pending_graph_generation()?;
4520        let savepoint = self.store.validated_savepoint()?;
4521        require_bound_project_identity(&savepoint, project)?;
4522        insert_graph_identity_rejections(&savepoint, project, generation, rejections)?;
4523        let generation_value =
4524            sqlite_count("graph_identity_rejections.generation", generation.get())?;
4525        let persisted_count = savepoint.query_row(
4526            "SELECT COUNT(*)
4527               FROM graph_identity_rejections
4528              WHERE project_instance_id = ?1 AND generation = ?2",
4529            params![&project.as_bytes()[..], generation_value],
4530            |row| row.get::<_, i64>(0),
4531        )?;
4532        if persisted_count > i64::from(GraphLimits::MAX_ROWS) {
4533            return Err(GraphContractError::InvalidLimits {
4534                reason: "complete graph identity rejection generation exceeds the graph row ceiling",
4535            }
4536            .into());
4537        }
4538        savepoint.commit()?;
4539        Ok(())
4540    }
4541
4542    /// Return the generation that will become complete if this guard commits.
4543    fn pending_graph_generation(&self) -> DbResult<IndexGeneration> {
4544        self.previous_generation
4545            .checked_next()
4546            .ok_or(DbError::PublicationGenerationOverflow)
4547    }
4548}
4549
4550/// Validate and atomically update one complete document-reason publication.
4551fn write_document_unresolved_reasons(
4552    connection: &Connection,
4553    reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4554    control: Option<&IndexWorkControl>,
4555) -> DbResult<()> {
4556    if let Some(control) = control {
4557        control.check(IndexWorkStage::Publication)?;
4558    }
4559    validate_document_unresolved_reasons(connection, reasons, control)?;
4560    for chunk in reasons.chunks(GraphLimits::MAX_ROWS as usize) {
4561        if let Some(control) = control {
4562            control.check(IndexWorkStage::Publication)?;
4563        }
4564        write_document_unresolved_reason_chunk(connection, chunk, control)?;
4565    }
4566    Ok(())
4567}
4568
4569/// Validate every document-reason key before any chunk can mutate the graph.
4570fn validate_document_unresolved_reasons(
4571    connection: &Connection,
4572    reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4573    control: Option<&IndexWorkControl>,
4574) -> DbResult<()> {
4575    let mut keys = HashSet::with_capacity(reasons.len());
4576    for (index, (key, _)) in reasons.iter().enumerate() {
4577        if index.is_multiple_of(256)
4578            && let Some(control) = control
4579        {
4580            control.check(IndexWorkStage::Publication)?;
4581        }
4582        if !keys.insert(key.digest_bytes()?) {
4583            return Err(GraphContractError::InvalidLimits {
4584                reason: "document unresolved reason batch repeats a relation key",
4585            }
4586            .into());
4587        }
4588    }
4589    let mut validate = connection.prepare_cached(
4590        "SELECT EXISTS(
4591                 SELECT 1
4592                   FROM graph_relations
4593                        INDEXED BY idx_graph_relations_project_key
4594                  WHERE relation_key = ?1
4595                    AND project_instance_id = ?2
4596                    AND relation_scope = 'extended'
4597                    AND relation_kind = 'documents'
4598                    AND resolution_status = 'unresolved'
4599             )",
4600    )?;
4601    for (index, (key, _)) in reasons.iter().enumerate() {
4602        if index.is_multiple_of(256)
4603            && let Some(control) = control
4604        {
4605            control.check(IndexWorkStage::Publication)?;
4606        }
4607        let digest = key.digest_bytes()?;
4608        let compatible = validate
4609            .query_row(params![&digest[..], &key.project().as_bytes()[..]], |row| {
4610                row.get::<_, bool>(0)
4611            })?;
4612        if !compatible {
4613            return Err(DbError::GraphRowShape {
4614                table: "graph_relations",
4615                reason: "document unresolved reason target is missing or incompatible",
4616            });
4617        }
4618    }
4619    drop(validate);
4620    Ok(())
4621}
4622
4623/// Apply one prepared document-reason chunk inside the caller-owned savepoint.
4624fn write_document_unresolved_reason_chunk(
4625    connection: &Connection,
4626    reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4627    control: Option<&IndexWorkControl>,
4628) -> DbResult<()> {
4629    let mut statement = connection.prepare_cached(
4630        "UPDATE graph_relations
4631                SET document_unresolved_reason = ?2
4632              WHERE relation_key = ?1
4633                AND project_instance_id = ?3
4634                AND relation_scope = 'extended'
4635                AND relation_kind = 'documents'
4636                AND resolution_status = 'unresolved'",
4637    )?;
4638    for (index, (key, reason)) in reasons.iter().enumerate() {
4639        if index.is_multiple_of(256)
4640            && let Some(control) = control
4641        {
4642            control.check(IndexWorkStage::Publication)?;
4643        }
4644        let digest = key.digest_bytes()?;
4645        let changed = statement.execute(params![
4646            &digest[..],
4647            reason.as_str(),
4648            &key.project().as_bytes()[..],
4649        ])?;
4650        debug_assert_eq!(changed, 1);
4651    }
4652    if let Some(control) = control {
4653        control.check(IndexWorkStage::Publication)?;
4654    }
4655    Ok(())
4656}
4657
4658/// Require every unresolved `documents` relation, and only such a relation, to own a reason.
4659pub(crate) fn validate_complete_document_unresolved_reasons(
4660    connection: &Connection,
4661) -> DbResult<()> {
4662    let invalid = connection
4663        .query_row(
4664            "SELECT relation_key
4665               FROM graph_relations
4666              WHERE (
4667                    relation_scope = 'extended'
4668                AND relation_kind = 'documents'
4669                AND resolution_status = 'unresolved'
4670                AND document_unresolved_reason IS NULL
4671              ) OR (
4672                    document_unresolved_reason IS NOT NULL
4673                AND NOT (
4674                        relation_scope = 'extended'
4675                    AND relation_kind = 'documents'
4676                    AND resolution_status = 'unresolved'
4677                )
4678              )
4679              LIMIT 1",
4680            [],
4681            |row| row.get::<_, Vec<u8>>(0),
4682        )
4683        .optional()?;
4684    if invalid.is_some() {
4685        return Err(DbError::GraphRowShape {
4686            table: "graph_relations",
4687            reason: "published document unresolved reason invariant is incomplete",
4688        });
4689    }
4690    Ok(())
4691}
4692
4693/// Conservative key count that stays below `SQLite`'s legacy bind ceiling.
4694const RESOLUTION_KEYS_PER_QUERY: usize = 200;
4695/// Conservative path count that stays below `SQLite`'s legacy bind ceiling.
4696const RESOLUTION_PATHS_PER_QUERY: usize = 400;
4697/// Conservative affected-entity count for one external-candidate adjacency batch.
4698const EXTERNAL_CANDIDATE_KEYS_PER_QUERY: usize = 400;
4699
4700/// Closed owner projections that retain canonical resolution keys.
4701#[derive(Clone, Copy)]
4702enum ResolutionOwner {
4703    /// Entity-export bindings only.
4704    EntityExports,
4705    /// Both binding families, used for touched-key garbage collection.
4706    Both,
4707}
4708
4709impl ResolutionOwner {
4710    /// Return the owner tables and their path-first access indexes.
4711    fn tables(self) -> &'static [(&'static str, &'static str)] {
4712        match self {
4713            Self::EntityExports => &[("graph_entity_exports", "idx_graph_entity_exports_owner")],
4714            Self::Both => &[
4715                ("graph_entity_exports", "idx_graph_entity_exports_owner"),
4716                (
4717                    "graph_relation_dependencies",
4718                    "idx_graph_relation_dependencies_owner",
4719                ),
4720            ],
4721        }
4722    }
4723}
4724
4725/// Validate and deduplicate exact source paths before querying owner bindings.
4726fn normalized_file_paths(paths: &[String]) -> DbResult<Vec<RepositoryNodePath>> {
4727    let mut normalized = BTreeSet::new();
4728    for path in paths {
4729        let file = RepositoryFilePath::new(Path::new(path))?;
4730        normalized.insert(RepositoryNodePath::new(Path::new(file.as_str()))?);
4731    }
4732    Ok(normalized.into_iter().collect())
4733}
4734
4735/// Validate project ownership and deduplicate canonical keys deterministically.
4736fn normalized_resolution_keys(
4737    project: ProjectInstanceId,
4738    keys: &[CanonicalResolutionKey],
4739) -> DbResult<Vec<CanonicalResolutionKey>> {
4740    let mut normalized = BTreeSet::new();
4741    for key in keys {
4742        if key.project() != project {
4743            return Err(DbError::GraphProjectIdentityMismatch {
4744                expected: project.to_string(),
4745                found: key.project().to_string(),
4746            });
4747        }
4748        normalized.insert(key.clone());
4749    }
4750    Ok(normalized.into_iter().collect())
4751}
4752
4753/// Convert a sorted unique set into one `LIMIT + 1` page.
4754fn page_from_ordered_set<T: Ord>(rows: BTreeSet<T>, limit: u32) -> RepositoryGraphPage<T> {
4755    let truncated = rows.len() > limit as usize;
4756    RepositoryGraphPage {
4757        rows: rows.into_iter().take(limit as usize).collect(),
4758        truncated,
4759    }
4760}
4761
4762/// Load and validate canonical keys retained by path-owned graph projections.
4763fn resolution_keys_for_owner_paths(
4764    connection: &Connection,
4765    project: ProjectInstanceId,
4766    paths: &[RepositoryNodePath],
4767    owners: ResolutionOwner,
4768    limit: Option<u32>,
4769) -> DbResult<BTreeSet<CanonicalResolutionKey>> {
4770    let mut keys = BTreeSet::new();
4771    for (table, index) in owners.tables() {
4772        for chunk in paths.chunks(RESOLUTION_PATHS_PER_QUERY) {
4773            if chunk.is_empty() {
4774                continue;
4775            }
4776            let placeholders = vec!["?"; chunk.len()].join(",");
4777            let limit_clause = limit.map_or("", |_| " LIMIT ?");
4778            let sql = format!(
4779                "SELECT DISTINCT resolution.project_instance_id,
4780                        resolution.resolution_domain, resolution.key_digest,
4781                        resolution.canonical_identity
4782                   FROM {table} AS owner INDEXED BY {index}
4783                   JOIN graph_resolution_keys AS resolution
4784                     ON resolution.project_instance_id = owner.project_instance_id
4785                    AND resolution.resolution_domain = owner.resolution_domain
4786                    AND resolution.key_digest = owner.key_digest
4787                  WHERE owner.project_instance_id = ?
4788                    AND owner.owner_path IN ({placeholders})
4789                  ORDER BY resolution.resolution_domain, resolution.key_digest{limit_clause}"
4790            );
4791            let mut values = Vec::with_capacity(chunk.len() + 2);
4792            values.push(Value::Blob(project.as_bytes().to_vec()));
4793            values.extend(
4794                chunk
4795                    .iter()
4796                    .map(|path| Value::Text(path.as_str().to_string())),
4797            );
4798            if let Some(limit) = limit {
4799                values.push(Value::Integer(i64::from(limit) + 1));
4800            }
4801            let mut statement = connection.prepare(&sql)?;
4802            let mut rows = statement.query(params_from_iter(values.iter()))?;
4803            while let Some(row) = rows.next()? {
4804                keys.insert(resolution_key_from_row(row, project)?);
4805            }
4806            if limit.is_some_and(|limit| keys.len() > limit as usize) {
4807                return Ok(keys);
4808            }
4809        }
4810    }
4811    Ok(keys)
4812}
4813
4814/// Reconstruct one canonical key row without accepting malformed persisted data.
4815fn resolution_key_from_row(
4816    row: &Row<'_>,
4817    expected_project: ProjectInstanceId,
4818) -> DbResult<CanonicalResolutionKey> {
4819    let project = project_from_blob(
4820        "graph_resolution_keys.project_instance_id",
4821        row.get::<_, Vec<u8>>(0)?,
4822    )?;
4823    require_project(expected_project, project)?;
4824    let domain_text = row.get::<_, String>(1)?;
4825    let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
4826    let digest = fixed_bytes::<32>(
4827        "graph_resolution_keys.key_digest",
4828        row.get::<_, Vec<u8>>(2)?,
4829    )?;
4830    Ok(CanonicalResolutionKey::from_persisted(
4831        project,
4832        domain,
4833        digest,
4834        row.get(3)?,
4835    )?)
4836}
4837
4838/// Validate one requested key against any retained canonical witness.
4839fn validate_persisted_resolution_key(
4840    connection: &Connection,
4841    key: &CanonicalResolutionKey,
4842) -> DbResult<bool> {
4843    let stored = connection
4844        .query_row(
4845            "SELECT canonical_identity
4846               FROM graph_resolution_keys
4847              WHERE project_instance_id = ?1
4848                AND resolution_domain = ?2
4849                AND key_digest = ?3",
4850            params![
4851                &key.project().as_bytes()[..],
4852                key.domain().as_str(),
4853                &key.digest_bytes()[..],
4854            ],
4855            |row| row.get::<_, String>(0),
4856        )
4857        .optional()?;
4858    let Some(stored) = stored else {
4859        return Ok(false);
4860    };
4861    if stored != key.canonical_identity() {
4862        return Err(DbError::ResolutionKeyCollision {
4863            domain: key.domain().as_str(),
4864            digest: key.digest_bytes(),
4865        });
4866    }
4867    CanonicalResolutionKey::from_persisted(
4868        key.project(),
4869        key.domain(),
4870        key.digest_bytes(),
4871        stored,
4872    )?;
4873    Ok(true)
4874}
4875
4876/// Validate all retained witnesses for a key batch without per-key queries.
4877fn validate_persisted_resolution_keys(
4878    connection: &Connection,
4879    keys: &[CanonicalResolutionKey],
4880) -> DbResult<()> {
4881    for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
4882        if chunk.is_empty() {
4883            continue;
4884        }
4885        let values_clause = anonymous_values_clause(chunk.len(), 4);
4886        let sql = format!(
4887            "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
4888                  AS (VALUES {values_clause})
4889             SELECT requested.resolution_domain, requested.key_digest,
4890                    requested.canonical_identity, stored.canonical_identity
4891               FROM requested
4892               JOIN graph_resolution_keys AS stored
4893                 ON stored.project_instance_id = requested.project_instance_id
4894                AND stored.resolution_domain = requested.resolution_domain
4895                AND stored.key_digest = requested.key_digest
4896              ORDER BY requested.resolution_domain, requested.key_digest"
4897        );
4898        let values = resolution_key_values(chunk, true);
4899        let mut statement = connection.prepare(&sql)?;
4900        let mut rows = statement.query(params_from_iter(values.iter()))?;
4901        while let Some(row) = rows.next()? {
4902            let domain_text = row.get::<_, String>(0)?;
4903            let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
4904            let digest = fixed_bytes::<32>(
4905                "graph_resolution_keys.key_digest",
4906                row.get::<_, Vec<u8>>(1)?,
4907            )?;
4908            let requested = row.get::<_, String>(2)?;
4909            let stored = row.get::<_, String>(3)?;
4910            if requested != stored {
4911                return Err(DbError::ResolutionKeyCollision {
4912                    domain: domain.as_str(),
4913                    digest,
4914                });
4915            }
4916        }
4917    }
4918    Ok(())
4919}
4920
4921/// Return distinct ordered dependency-owning paths for a bounded key set.
4922fn affected_source_paths(
4923    connection: &Connection,
4924    keys: &[CanonicalResolutionKey],
4925    limit: u32,
4926) -> DbResult<BTreeSet<RepositoryFilePath>> {
4927    let mut paths = BTreeSet::new();
4928    for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
4929        if chunk.is_empty() {
4930            continue;
4931        }
4932        let values_clause = anonymous_values_clause(chunk.len(), 4);
4933        let sql = format!(
4934            "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
4935                  AS (VALUES {values_clause})
4936             SELECT DISTINCT dependency.owner_path
4937               FROM requested
4938               JOIN graph_resolution_keys AS stored
4939                 ON stored.project_instance_id = requested.project_instance_id
4940                AND stored.resolution_domain = requested.resolution_domain
4941                AND stored.key_digest = requested.key_digest
4942                AND stored.canonical_identity = requested.canonical_identity
4943               JOIN graph_relation_dependencies AS dependency
4944                    INDEXED BY idx_graph_relation_dependencies_key
4945                 ON dependency.project_instance_id = stored.project_instance_id
4946                AND dependency.resolution_domain = stored.resolution_domain
4947                AND dependency.key_digest = stored.key_digest
4948              ORDER BY dependency.owner_path
4949              LIMIT ?"
4950        );
4951        let mut values = resolution_key_values(chunk, true);
4952        values.push(Value::Integer(i64::from(limit) + 1));
4953        let mut statement = connection.prepare(&sql)?;
4954        let mut rows = statement.query(params_from_iter(values.iter()))?;
4955        while let Some(row) = rows.next()? {
4956            paths.insert(RepositoryFilePath::new(Path::new(
4957                &row.get::<_, String>(0)?,
4958            ))?);
4959        }
4960        if paths.len() > limit as usize {
4961            return Ok(paths);
4962        }
4963    }
4964    Ok(paths)
4965}
4966
4967/// Account one exact-path closure through bounded index-owned branches.
4968fn affected_source_footprint(
4969    connection: &Connection,
4970    project: ProjectInstanceId,
4971    paths: &[RepositoryNodePath],
4972    limit_plus_one: i64,
4973) -> DbResult<RepositoryAffectedSourceFootprint> {
4974    let maximum_rows = u64::try_from(limit_plus_one).map_err(|source| DbError::InvalidCount {
4975        field: "affected_source_footprint.limit_plus_one",
4976        value: limit_plus_one,
4977        source,
4978    })?;
4979    let mut footprint = empty_affected_source_footprint();
4980    for chunk in paths.chunks(RESOLUTION_PATHS_PER_QUERY) {
4981        if chunk.is_empty() {
4982            continue;
4983        }
4984        let remaining = maximum_rows.saturating_sub(footprint.rows);
4985        if remaining == 0 {
4986            footprint.truncated = true;
4987            break;
4988        }
4989        let sql = affected_source_footprint_sql(chunk.len());
4990        let mut values = Vec::with_capacity(chunk.len() + 2);
4991        values.push(Value::Blob(project.as_bytes().to_vec()));
4992        values.extend(
4993            chunk
4994                .iter()
4995                .map(|path| Value::Text(path.as_str().to_string())),
4996        );
4997        values.push(Value::Integer(i64::try_from(remaining).map_err(
4998            |source| DbError::InvalidCount {
4999                field: "affected_source_footprint.remaining_rows",
5000                value: i64::MAX,
5001                source,
5002            },
5003        )?));
5004        let mut statement = connection.prepare(&sql)?;
5005        let mut rows = statement.query(params_from_iter(values.iter()))?;
5006        while let Some(row) = rows.next()? {
5007            let bytes = nonnegative_u64(
5008                "affected_source_footprint.retained_bytes",
5009                row.get::<_, i64>(0)?,
5010            )?;
5011            footprint.rows = footprint.rows.saturating_add(1);
5012            footprint.retained_bytes = footprint.retained_bytes.saturating_add(bytes);
5013        }
5014        if footprint.rows >= maximum_rows {
5015            footprint.truncated = true;
5016            break;
5017        }
5018    }
5019    Ok(footprint)
5020}
5021
5022/// Build one bounded union of exact-path footprint branches.
5023fn affected_source_footprint_sql(path_count: usize) -> String {
5024    let requested = vec!["(?)"; path_count].join(",");
5025    format!(
5026        "WITH selected_project(project_instance_id) AS (VALUES (?)),
5027              requested(path) AS (VALUES {requested})
5028         SELECT retained_bytes
5029           FROM (
5030             SELECT length(CAST(metadata.path AS BLOB))
5031                    + coalesce(length(CAST(metadata.language AS BLOB)), 0)
5032                    + length(CAST(metadata.source_parser AS BLOB))
5033                    + length(CAST(metadata.fact_parser AS BLOB)) + 16
5034                    + length(CAST(metadata.updated_at AS BLOB)) AS retained_bytes
5035               FROM requested
5036               JOIN source_parse_metadata AS metadata
5037                    INDEXED BY sqlite_autoindex_source_parse_metadata_1
5038                 ON metadata.path = requested.path
5039             UNION ALL
5040             SELECT 32 + length(CAST(symbol.path AS BLOB))
5041                    + coalesce(length(CAST(symbol.language AS BLOB)), 0)
5042                    + length(CAST(symbol.name AS BLOB))
5043                    + length(CAST(symbol.kind AS BLOB))
5044                    + length(CAST(symbol.signature AS BLOB))
5045                    + coalesce(length(CAST(symbol.documentation AS BLOB)), 0)
5046                    + coalesce(length(CAST(symbol.parent AS BLOB)), 0)
5047                    + length(CAST(symbol.parser AS BLOB))
5048                    + coalesce(length(CAST(symbol.detail AS BLOB)), 0)
5049                    + length(CAST(symbol.created_at AS BLOB))
5050                    + length(CAST(symbol.updated_at AS BLOB))
5051               FROM requested
5052               JOIN symbols AS symbol INDEXED BY idx_symbols_path
5053                 ON symbol.path = requested.path
5054             UNION ALL
5055             SELECT 16 + length(CAST(symbol_relation.path AS BLOB))
5056                    + length(CAST(symbol_relation.source_name AS BLOB))
5057                    + length(CAST(symbol_relation.target_name AS BLOB))
5058                    + length(CAST(symbol_relation.kind AS BLOB))
5059                    + length(CAST(symbol_relation.context AS BLOB))
5060                    + length(CAST(symbol_relation.parser AS BLOB))
5061                    + length(CAST(symbol_relation.created_at AS BLOB))
5062               FROM requested
5063               JOIN symbol_relations AS symbol_relation INDEXED BY idx_symbol_relations_path
5064                 ON symbol_relation.path = requested.path
5065             UNION ALL
5066             SELECT 48 + length(CAST(entity.canonical_identity AS BLOB))
5067                    + length(CAST(entity.entity_kind AS BLOB))
5068                    + coalesce(length(CAST(entity.repository_path AS BLOB)), 0)
5069                    + coalesce(length(CAST(entity.package_manager AS BLOB)), 0)
5070                    + coalesce(length(CAST(entity.package_name AS BLOB)), 0)
5071                    + coalesce(length(CAST(entity.manifest_path AS BLOB)), 0)
5072                    + coalesce(length(CAST(entity.symbol_name AS BLOB)), 0)
5073                    + coalesce(length(CAST(entity.symbol_kind AS BLOB)), 0)
5074                    + coalesce(length(CAST(entity.symbol_parent AS BLOB)), 0)
5075                    + coalesce(length(CAST(entity.symbol_signature AS BLOB)), 0)
5076                    + coalesce(length(CAST(entity.external_system AS BLOB)), 0)
5077                    + coalesce(length(CAST(entity.external_identity AS BLOB)), 0)
5078               FROM requested
5079               JOIN graph_entities AS entity INDEXED BY idx_graph_entities_path
5080                 ON entity.repository_path = requested.path
5081               JOIN selected_project
5082                 ON selected_project.project_instance_id = entity.project_instance_id
5083             UNION ALL
5084             SELECT 48 + length(CAST(entity.canonical_identity AS BLOB))
5085                    + length(CAST(entity.entity_kind AS BLOB))
5086                    + coalesce(length(CAST(entity.repository_path AS BLOB)), 0)
5087                    + coalesce(length(CAST(entity.package_manager AS BLOB)), 0)
5088                    + coalesce(length(CAST(entity.package_name AS BLOB)), 0)
5089                    + coalesce(length(CAST(entity.manifest_path AS BLOB)), 0)
5090                    + coalesce(length(CAST(entity.symbol_name AS BLOB)), 0)
5091                    + coalesce(length(CAST(entity.symbol_kind AS BLOB)), 0)
5092                    + coalesce(length(CAST(entity.symbol_parent AS BLOB)), 0)
5093                    + coalesce(length(CAST(entity.symbol_signature AS BLOB)), 0)
5094                    + coalesce(length(CAST(entity.external_system AS BLOB)), 0)
5095                    + coalesce(length(CAST(entity.external_identity AS BLOB)), 0)
5096               FROM requested
5097               JOIN graph_entities AS entity INDEXED BY idx_graph_entities_manifest_path
5098                 ON entity.manifest_path = requested.path
5099               JOIN selected_project
5100                 ON selected_project.project_instance_id = entity.project_instance_id
5101             UNION ALL
5102             SELECT 80 + length(CAST(relation.canonical_identity AS BLOB))
5103                    + length(CAST(relation.relation_scope AS BLOB))
5104                    + length(CAST(relation.relation_kind AS BLOB))
5105                    + length(CAST(relation.resolution_status AS BLOB))
5106                    + coalesce(length(relation.target_entity_key), 0)
5107                    + coalesce(length(CAST(relation.reference_text AS BLOB)), 0)
5108                    + CASE WHEN relation.candidate_count IS NULL THEN 0 ELSE 8 END
5109                    + length(CAST(relation.confidence AS BLOB))
5110                    + length(CAST(relation.completeness AS BLOB))
5111               FROM requested
5112               JOIN graph_entities AS entity INDEXED BY idx_graph_entities_path
5113                 ON entity.repository_path = requested.path
5114               JOIN selected_project
5115                 ON selected_project.project_instance_id = entity.project_instance_id
5116               JOIN graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
5117                 ON relation.source_entity_key = entity.entity_key
5118                AND relation.project_instance_id = selected_project.project_instance_id
5119             UNION ALL
5120             SELECT 80 + length(CAST(relation.canonical_identity AS BLOB))
5121                    + length(CAST(relation.relation_scope AS BLOB))
5122                    + length(CAST(relation.relation_kind AS BLOB))
5123                    + length(CAST(relation.resolution_status AS BLOB))
5124                    + coalesce(length(relation.target_entity_key), 0)
5125                    + coalesce(length(CAST(relation.reference_text AS BLOB)), 0)
5126                    + CASE WHEN relation.candidate_count IS NULL THEN 0 ELSE 8 END
5127                    + length(CAST(relation.confidence AS BLOB))
5128                    + length(CAST(relation.completeness AS BLOB))
5129               FROM requested
5130               JOIN graph_entities AS entity INDEXED BY idx_graph_entities_manifest_path
5131                 ON entity.manifest_path = requested.path
5132               JOIN selected_project
5133                 ON selected_project.project_instance_id = entity.project_instance_id
5134               JOIN graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
5135                 ON relation.source_entity_key = entity.entity_key
5136                AND relation.project_instance_id = selected_project.project_instance_id
5137             UNION ALL
5138             SELECT 72 + length(CAST(occurrence.file_path AS BLOB))
5139               FROM requested
5140               JOIN graph_relation_occurrences AS occurrence
5141                    INDEXED BY idx_graph_occurrences_file_span
5142                 ON occurrence.file_path = requested.path
5143             UNION ALL
5144             SELECT 48 + length(CAST(coverage.scope_kind AS BLOB))
5145                    + coalesce(length(CAST(coverage.scope_path AS BLOB)), 0)
5146                    + coalesce(length(CAST(coverage.relation_scope AS BLOB)), 0)
5147                    + coalesce(length(CAST(coverage.relation_kind AS BLOB)), 0)
5148                    + length(CAST(coverage.state AS BLOB))
5149                    + coalesce(length(CAST(coverage.reason AS BLOB)), 0)
5150                    + coalesce(length(CAST(coverage.reached_limit AS BLOB)), 0)
5151               FROM requested
5152               JOIN graph_coverage AS coverage INDEXED BY idx_graph_coverage_path
5153                 ON coverage.scope_path = requested.path
5154               JOIN selected_project
5155                 ON selected_project.project_instance_id = coverage.project_instance_id
5156             UNION ALL
5157             SELECT 80 + length(CAST(export.owner_path AS BLOB))
5158                    + length(CAST(export.resolution_domain AS BLOB))
5159               FROM requested
5160               JOIN graph_entity_exports AS export INDEXED BY idx_graph_entity_exports_owner
5161                 ON export.owner_path = requested.path
5162               JOIN selected_project
5163                 ON selected_project.project_instance_id = export.project_instance_id
5164             UNION ALL
5165             SELECT length(witness.project_instance_id)
5166                    + length(CAST(witness.resolution_domain AS BLOB))
5167                    + length(witness.key_digest)
5168                    + length(CAST(witness.canonical_identity AS BLOB))
5169               FROM requested
5170               JOIN graph_entity_exports AS export INDEXED BY idx_graph_entity_exports_owner
5171                 ON export.owner_path = requested.path
5172               JOIN selected_project
5173                 ON selected_project.project_instance_id = export.project_instance_id
5174               LEFT JOIN graph_resolution_keys AS witness
5175                 ON witness.project_instance_id = export.project_instance_id
5176                AND witness.resolution_domain = export.resolution_domain
5177                AND witness.key_digest = export.key_digest
5178             UNION ALL
5179             SELECT 80 + length(CAST(dependency.owner_path AS BLOB))
5180                    + length(CAST(dependency.resolution_domain AS BLOB))
5181               FROM requested
5182               JOIN graph_relation_dependencies AS dependency
5183                    INDEXED BY idx_graph_relation_dependencies_owner
5184                 ON dependency.owner_path = requested.path
5185               JOIN selected_project
5186                 ON selected_project.project_instance_id = dependency.project_instance_id
5187             UNION ALL
5188             SELECT length(witness.project_instance_id)
5189                    + length(CAST(witness.resolution_domain AS BLOB))
5190                    + length(witness.key_digest)
5191                    + length(CAST(witness.canonical_identity AS BLOB))
5192               FROM requested
5193               JOIN graph_relation_dependencies AS dependency
5194                    INDEXED BY idx_graph_relation_dependencies_owner
5195                 ON dependency.owner_path = requested.path
5196               JOIN selected_project
5197                 ON selected_project.project_instance_id = dependency.project_instance_id
5198               LEFT JOIN graph_resolution_keys AS witness
5199                 ON witness.project_instance_id = dependency.project_instance_id
5200                AND witness.resolution_domain = dependency.resolution_domain
5201                AND witness.key_digest = dependency.key_digest
5202           )
5203          LIMIT ?"
5204    )
5205}
5206
5207/// Build an anonymous `VALUES` clause with fixed columns per row.
5208fn anonymous_values_clause(rows: usize, columns: usize) -> String {
5209    let row = format!("({})", vec!["?"; columns].join(","));
5210    vec![row; rows].join(",")
5211}
5212
5213/// Bind one canonical-key batch in the same order as its `VALUES` rows.
5214fn resolution_key_values(keys: &[CanonicalResolutionKey], witness: bool) -> Vec<Value> {
5215    let columns = if witness { 4 } else { 3 };
5216    let mut values = Vec::with_capacity(keys.len() * columns);
5217    for key in keys {
5218        values.push(Value::Blob(key.project().as_bytes().to_vec()));
5219        values.push(Value::Text(key.domain().as_str().to_string()));
5220        values.push(Value::Blob(key.digest_bytes().to_vec()));
5221        if witness {
5222            values.push(Value::Text(key.canonical_identity().to_string()));
5223        }
5224    }
5225    values
5226}
5227
5228/// Validate graph-owner bindings before acquiring the savepoint.
5229fn validate_resolution_key_batch(
5230    project: ProjectInstanceId,
5231    entity_exports: &[EntityResolutionKey],
5232    relation_dependencies: &[RelationDependencyKey],
5233) -> DbResult<()> {
5234    let foreign = entity_exports.iter().find_map(|binding| {
5235        (binding.entity().project() != project || binding.key().project() != project)
5236            .then(|| binding.key().project())
5237    });
5238    let foreign = foreign.or_else(|| {
5239        relation_dependencies.iter().find_map(|binding| {
5240            (binding.relation().project() != project || binding.key().project() != project)
5241                .then(|| binding.key().project())
5242        })
5243    });
5244    if let Some(found) = foreign {
5245        return Err(DbError::GraphProjectIdentityMismatch {
5246            expected: project.to_string(),
5247            found: found.to_string(),
5248        });
5249    }
5250    Ok(())
5251}
5252
5253/// Insert a validated resolution-key projection through prepared owner statements.
5254fn insert_resolution_key_batch(
5255    connection: &Connection,
5256    project: ProjectInstanceId,
5257    entity_exports: &[EntityResolutionKey],
5258    relation_dependencies: &[RelationDependencyKey],
5259) -> DbResult<()> {
5260    let mut insert_key = connection.prepare_cached(
5261        "INSERT INTO graph_resolution_keys(
5262             project_instance_id, resolution_domain, key_digest, canonical_identity
5263         ) VALUES(?1, ?2, ?3, ?4)
5264         ON CONFLICT(project_instance_id, resolution_domain, key_digest)
5265         DO UPDATE SET canonical_identity = excluded.canonical_identity
5266         WHERE graph_resolution_keys.canonical_identity = excluded.canonical_identity",
5267    )?;
5268    let mut insert_export = connection.prepare_cached(
5269        "INSERT INTO graph_entity_exports(
5270             project_instance_id, entity_key, owner_path, resolution_domain, key_digest
5271         )
5272         SELECT entity.project_instance_id, entity.entity_key,
5273                CASE entity.entity_kind
5274                    WHEN 'file' THEN entity.repository_path
5275                    WHEN 'symbol' THEN entity.repository_path
5276                    WHEN 'package' THEN entity.manifest_path
5277                END,
5278                ?3, ?4
5279           FROM graph_entities AS entity
5280          WHERE entity.project_instance_id = ?1 AND entity.entity_key = ?2
5281            AND entity.entity_kind IN ('file', 'symbol', 'package')
5282         ON CONFLICT(project_instance_id, entity_key, resolution_domain, key_digest)
5283         DO UPDATE SET owner_path = excluded.owner_path",
5284    )?;
5285    let mut insert_dependency = connection.prepare_cached(
5286        "INSERT INTO graph_relation_dependencies(
5287             project_instance_id, relation_key, owner_path, resolution_domain, key_digest
5288         )
5289         SELECT relation.project_instance_id, relation.relation_key,
5290                CASE source.entity_kind
5291                    WHEN 'file' THEN source.repository_path
5292                    WHEN 'symbol' THEN source.repository_path
5293                    WHEN 'package' THEN source.manifest_path
5294                END,
5295                ?3, ?4
5296           FROM graph_relations AS relation
5297           JOIN graph_entities AS source
5298             ON source.project_instance_id = relation.project_instance_id
5299            AND source.entity_key = relation.source_entity_key
5300          WHERE relation.project_instance_id = ?1 AND relation.relation_key = ?2
5301            AND source.entity_kind IN ('file', 'symbol', 'package')
5302         ON CONFLICT(project_instance_id, relation_key, resolution_domain, key_digest)
5303         DO UPDATE SET owner_path = excluded.owner_path",
5304    )?;
5305
5306    for binding in entity_exports {
5307        insert_resolution_key(&mut insert_key, binding.key())?;
5308        let key = binding.key();
5309        let inserted = insert_export.execute(params![
5310            &project.as_bytes()[..],
5311            &binding.entity().digest_bytes()?[..],
5312            key.domain().as_str(),
5313            &key.digest_bytes()[..],
5314        ])?;
5315        if inserted != 1 {
5316            return Err(DbError::GraphRowShape {
5317                table: "graph_entity_exports",
5318                reason: "export keys require a local file, symbol, or package owner",
5319            });
5320        }
5321    }
5322    for binding in relation_dependencies {
5323        insert_resolution_key(&mut insert_key, binding.key())?;
5324        let key = binding.key();
5325        let inserted = insert_dependency.execute(params![
5326            &project.as_bytes()[..],
5327            &binding.relation().digest_bytes()?[..],
5328            key.domain().as_str(),
5329            &key.digest_bytes()[..],
5330        ])?;
5331        if inserted != 1 {
5332            return Err(DbError::GraphRowShape {
5333                table: "graph_relation_dependencies",
5334                reason: "dependency keys require a local file, symbol, or package source",
5335            });
5336        }
5337    }
5338    Ok(())
5339}
5340
5341/// Insert one key witness, rejecting equal digests with different material.
5342fn insert_resolution_key(
5343    statement: &mut rusqlite::CachedStatement<'_>,
5344    key: &CanonicalResolutionKey,
5345) -> DbResult<()> {
5346    let inserted = statement.execute(params![
5347        &key.project().as_bytes()[..],
5348        key.domain().as_str(),
5349        &key.digest_bytes()[..],
5350        key.canonical_identity(),
5351    ])?;
5352    if inserted == 0 {
5353        return Err(DbError::ResolutionKeyCollision {
5354            domain: key.domain().as_str(),
5355            digest: key.digest_bytes(),
5356        });
5357    }
5358    Ok(())
5359}
5360
5361/// Delete only touched witness rows left without either owner family.
5362fn remove_touched_orphan_resolution_keys(
5363    connection: &Connection,
5364    keys: &BTreeSet<CanonicalResolutionKey>,
5365) -> DbResult<()> {
5366    let keys = keys.iter().cloned().collect::<Vec<_>>();
5367    for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
5368        if chunk.is_empty() {
5369            continue;
5370        }
5371        let values_clause = anonymous_values_clause(chunk.len(), 3);
5372        let sql = format!(
5373            "WITH touched(project_instance_id, resolution_domain, key_digest)
5374                  AS (VALUES {values_clause})
5375             DELETE FROM graph_resolution_keys
5376              WHERE rowid IN (
5377                    SELECT stored.rowid
5378                      FROM graph_resolution_keys AS stored
5379                      JOIN touched
5380                        ON touched.project_instance_id = stored.project_instance_id
5381                       AND touched.resolution_domain = stored.resolution_domain
5382                       AND touched.key_digest = stored.key_digest
5383                     WHERE NOT EXISTS (
5384                               SELECT 1 FROM graph_entity_exports AS export
5385                                WHERE export.project_instance_id = stored.project_instance_id
5386                                  AND export.resolution_domain = stored.resolution_domain
5387                                  AND export.key_digest = stored.key_digest
5388                           )
5389                       AND NOT EXISTS (
5390                               SELECT 1 FROM graph_relation_dependencies AS dependency
5391                                WHERE dependency.project_instance_id = stored.project_instance_id
5392                                  AND dependency.resolution_domain = stored.resolution_domain
5393                                  AND dependency.key_digest = stored.key_digest
5394                           )
5395              )"
5396        );
5397        let values = resolution_key_values(chunk, false);
5398        connection.execute(&sql, params_from_iter(values.iter()))?;
5399    }
5400    Ok(())
5401}
5402
5403/// Collect external entities whose relation to an affected local entity may vanish.
5404fn affected_external_candidates(
5405    connection: &Connection,
5406    affected_paths: &[RepositoryNodePath],
5407) -> DbResult<HashSet<[u8; 32]>> {
5408    let mut by_repository_path = connection.prepare_cached(
5409        "SELECT entity_key FROM graph_entities
5410          WHERE repository_path = ?1
5411             OR (repository_path >= ?2 AND repository_path < ?3)",
5412    )?;
5413    let mut by_manifest_path = connection.prepare_cached(
5414        "SELECT entity_key FROM graph_entities
5415          WHERE manifest_path = ?1 OR (manifest_path >= ?2 AND manifest_path < ?3)",
5416    )?;
5417    let mut local_keys = HashSet::new();
5418    for path in affected_paths {
5419        let path = path.as_str();
5420        let (descendant_start, descendant_end) = repository_descendant_bounds(path);
5421        for statement in [&mut by_repository_path, &mut by_manifest_path] {
5422            let mut rows = statement.query(params![path, descendant_start, descendant_end])?;
5423            while let Some(row) = rows.next()? {
5424                local_keys.insert(fixed_bytes::<32>(
5425                    "graph_entities.entity_key",
5426                    row.get::<_, Vec<u8>>(0)?,
5427                )?);
5428            }
5429        }
5430    }
5431
5432    let mut candidates = HashSet::new();
5433    let local_keys = local_keys.into_iter().collect::<Vec<_>>();
5434    for chunk in local_keys.chunks(EXTERNAL_CANDIDATE_KEYS_PER_QUERY) {
5435        let sql = external_candidate_batch_sql(chunk.len());
5436        let mut statement = connection.prepare_cached(&sql)?;
5437        let mut rows = statement.query(params_from_iter(chunk.iter().map(|key| &key[..])))?;
5438        while let Some(row) = rows.next()? {
5439            candidates.insert(fixed_bytes::<32>(
5440                "graph_entities.entity_key",
5441                row.get::<_, Vec<u8>>(0)?,
5442            )?);
5443        }
5444    }
5445    Ok(candidates)
5446}
5447
5448/// Build one two-direction indexed adjacency query for a bounded affected-key batch.
5449fn external_candidate_batch_sql(key_count: usize) -> String {
5450    let values = anonymous_values_clause(key_count, 1);
5451    format!(
5452        "WITH affected(entity_key) AS (VALUES {values})
5453         SELECT relation.target_entity_key
5454           FROM affected
5455           CROSS JOIN graph_relations AS relation
5456                      INDEXED BY idx_graph_relations_source_kind
5457           JOIN graph_entities AS external
5458             ON external.entity_key = relation.target_entity_key
5459          WHERE relation.source_entity_key = affected.entity_key
5460            AND external.entity_kind = 'external'
5461         UNION ALL
5462         SELECT relation.source_entity_key
5463           FROM affected
5464           CROSS JOIN graph_relations AS relation
5465                      INDEXED BY idx_graph_relations_target_kind
5466           JOIN graph_entities AS external
5467             ON external.entity_key = relation.source_entity_key
5468          WHERE relation.target_entity_key = affected.entity_key
5469            AND external.entity_kind = 'external'"
5470    )
5471}
5472
5473/// Delete one affected local closure through statements prepared once per batch.
5474fn invalidate_repository_graph_paths(
5475    connection: &Connection,
5476    affected_paths: &[RepositoryNodePath],
5477    orphan_candidates: &mut HashSet<[u8; 32]>,
5478) -> DbResult<()> {
5479    let mut affected_relations = HashSet::new();
5480    let mut relation_occurrences = connection.prepare_cached(
5481        "SELECT relation_key FROM graph_relation_occurrences
5482          WHERE file_path = ?1 OR (file_path >= ?2 AND file_path < ?3)",
5483    )?;
5484    let mut occurrences = connection.prepare_cached(
5485        "DELETE FROM graph_relation_occurrences
5486          WHERE file_path = ?1 OR (file_path >= ?2 AND file_path < ?3)",
5487    )?;
5488    let mut coverage = connection.prepare_cached(
5489        "DELETE FROM graph_coverage
5490          INDEXED BY idx_graph_coverage_path
5491          WHERE scope_kind = 'path'
5492            AND (scope_path = ?1 OR (scope_path >= ?2 AND scope_path < ?3))",
5493    )?;
5494    let mut entities_by_path = connection.prepare_cached(
5495        "DELETE FROM graph_entities
5496          WHERE repository_path = ?1
5497             OR (repository_path >= ?2 AND repository_path < ?3)",
5498    )?;
5499    let mut entities_by_manifest = connection.prepare_cached(
5500        "DELETE FROM graph_entities
5501          WHERE manifest_path = ?1 OR (manifest_path >= ?2 AND manifest_path < ?3)",
5502    )?;
5503    for path in affected_paths {
5504        let path = path.as_str();
5505        let (descendant_start, descendant_end) = repository_descendant_bounds(path);
5506        let mut rows =
5507            relation_occurrences.query(params![path, descendant_start, descendant_end])?;
5508        while let Some(row) = rows.next()? {
5509            affected_relations.insert(fixed_bytes::<32>(
5510                "graph_relation_occurrences.relation_key",
5511                row.get::<_, Vec<u8>>(0)?,
5512            )?);
5513        }
5514        occurrences.execute(params![path, descendant_start, descendant_end])?;
5515        coverage.execute(params![path, descendant_start, descendant_end])?;
5516        entities_by_path.execute(params![path, descendant_start, descendant_end])?;
5517        entities_by_manifest.execute(params![path, descendant_start, descendant_end])?;
5518    }
5519    collect_external_relation_endpoints(connection, &affected_relations, orphan_candidates)?;
5520    let mut relation = connection.prepare_cached(
5521        "DELETE FROM graph_relations
5522          WHERE relation_key = ?1
5523            AND NOT EXISTS (
5524                SELECT 1 FROM graph_relation_occurrences
5525                 WHERE relation_key = ?1
5526            )",
5527    )?;
5528    for relation_key in affected_relations {
5529        relation.execute([&relation_key[..]])?;
5530    }
5531    Ok(())
5532}
5533
5534/// Retain external endpoints whose occurrence-backed relation may be removed.
5535fn collect_external_relation_endpoints(
5536    connection: &Connection,
5537    relation_keys: &HashSet<[u8; 32]>,
5538    candidates: &mut HashSet<[u8; 32]>,
5539) -> DbResult<()> {
5540    let mut endpoints = connection.prepare_cached(
5541        "SELECT source_entity_key, target_entity_key
5542           FROM graph_relations
5543          WHERE relation_key = ?1",
5544    )?;
5545    let mut is_external = connection.prepare_cached(
5546        "SELECT EXISTS(
5547            SELECT 1 FROM graph_entities
5548             WHERE entity_key = ?1 AND entity_kind = 'external'
5549        )",
5550    )?;
5551    for relation_key in relation_keys {
5552        let endpoints = endpoints
5553            .query_row([&relation_key[..]], |row| {
5554                Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, Option<Vec<u8>>>(1)?))
5555            })
5556            .optional()?;
5557        let Some((source, target)) = endpoints else {
5558            continue;
5559        };
5560        for endpoint in [Some(source), target].into_iter().flatten() {
5561            let endpoint = fixed_bytes::<32>("graph_relations endpoint", endpoint)?;
5562            if is_external.query_row([&endpoint[..]], |row| row.get::<_, bool>(0))? {
5563                candidates.insert(endpoint);
5564            }
5565        }
5566    }
5567    Ok(())
5568}
5569
5570/// Remove only candidate external entities that no surviving relation references.
5571fn remove_orphan_external_candidates(
5572    connection: &Connection,
5573    candidates: &HashSet<[u8; 32]>,
5574) -> DbResult<()> {
5575    let mut statement = connection.prepare_cached(
5576        "DELETE FROM graph_entities
5577          WHERE entity_key = ?1 AND entity_kind = 'external'
5578            AND NOT EXISTS (
5579                SELECT 1 FROM graph_relations INDEXED BY idx_graph_relations_source_kind
5580                 WHERE source_entity_key = ?1
5581            )
5582            AND NOT EXISTS (
5583                SELECT 1 FROM graph_relations INDEXED BY idx_graph_relations_target_kind
5584                 WHERE target_entity_key = ?1
5585            )",
5586    )?;
5587    for candidate in candidates {
5588        statement.execute([&candidate[..]])?;
5589    }
5590    Ok(())
5591}
5592
5593/// Return case-preserving indexed bounds for every slash-delimited descendant.
5594fn repository_descendant_bounds(path: &str) -> (String, String) {
5595    (format!("{path}/"), format!("{path}0"))
5596}
5597
5598/// Validate ownership and generation before any graph mutation occurs.
5599fn validate_graph_batch(
5600    project: ProjectInstanceId,
5601    generation: IndexGeneration,
5602    entities: &[GraphEntity],
5603    relations: &[LogicalRelation],
5604    occurrences: &[RelationOccurrence],
5605    coverage: &[CoverageRecord],
5606) -> DbResult<()> {
5607    if entities
5608        .iter()
5609        .any(|entity| entity.key().project() != project)
5610        || relations
5611            .iter()
5612            .any(|relation| relation.key().project() != project)
5613        || occurrences
5614            .iter()
5615            .any(|occurrence| occurrence.relation().project() != project)
5616    {
5617        return Err(DbError::GraphProjectIdentityMismatch {
5618            expected: project.to_string(),
5619            found: "record from another project".to_string(),
5620        });
5621    }
5622    if entities
5623        .iter()
5624        .any(|entity| entity.generation() != generation)
5625        || relations
5626            .iter()
5627            .any(|relation| relation.generation() != generation)
5628        || occurrences
5629            .iter()
5630            .any(|occurrence| occurrence.generation() != generation)
5631        || coverage
5632            .iter()
5633            .any(|record| record.generation() != generation)
5634    {
5635        return Err(
5636            projectatlas_core::graph::GraphContractError::GenerationMismatch {
5637                context: "repository graph publication batch",
5638            }
5639            .into(),
5640        );
5641    }
5642    Ok(())
5643}
5644
5645/// Insert one validated graph batch through cached normalized statements.
5646fn insert_graph_batch(
5647    connection: &Connection,
5648    project: ProjectInstanceId,
5649    entities: &[GraphEntity],
5650    relations: &[LogicalRelation],
5651    occurrences: &[RelationOccurrence],
5652    coverage: &[CoverageRecord],
5653) -> DbResult<()> {
5654    insert_entities(connection, project, entities)?;
5655    insert_relations(connection, project, relations)?;
5656    insert_occurrences(connection, occurrences)?;
5657    insert_coverage(connection, project, coverage)
5658}
5659
5660/// Insert typed entities while refusing compact-key collisions.
5661fn insert_entities<'entity>(
5662    connection: &Connection,
5663    project: ProjectInstanceId,
5664    entities: impl IntoIterator<Item = &'entity GraphEntity>,
5665) -> DbResult<()> {
5666    let mut insert = connection.prepare_cached(
5667        "INSERT INTO graph_entities(
5668            entity_key, project_instance_id, canonical_identity, entity_kind,
5669            repository_path, package_manager, package_name, manifest_path,
5670            symbol_name, symbol_kind, symbol_parent, symbol_signature,
5671            external_system, external_identity
5672         ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
5673         ON CONFLICT(entity_key) DO NOTHING",
5674    )?;
5675    let mut existing = connection.prepare_cached(
5676        "SELECT project_instance_id, canonical_identity
5677           FROM graph_entities WHERE entity_key = ?1",
5678    )?;
5679    for entity in entities {
5680        let columns = entity_columns(entity.selector());
5681        let key = entity.key().digest_bytes()?;
5682        insert.execute(params![
5683            &key[..],
5684            &project.as_bytes()[..],
5685            entity.key().canonical_identity(),
5686            columns.kind,
5687            columns.repository_path,
5688            columns.package_manager,
5689            columns.package_name,
5690            columns.manifest_path,
5691            columns.symbol_name,
5692            columns.symbol_kind,
5693            columns.symbol_parent,
5694            columns.symbol_signature,
5695            columns.external_system,
5696            columns.external_identity,
5697        ])?;
5698        let (stored_project, stored_canonical): (Vec<u8>, String) =
5699            existing.query_row([&key[..]], |row| Ok((row.get(0)?, row.get(1)?)))?;
5700        if fixed_bytes::<16>("graph_entities.project_instance_id", stored_project)?
5701            != project.as_bytes()
5702            || stored_canonical != entity.key().canonical_identity()
5703        {
5704            return Err(
5705                projectatlas_core::graph::GraphContractError::StableKeyCollision {
5706                    digest: entity.key().digest().to_string(),
5707                }
5708                .into(),
5709            );
5710        }
5711    }
5712    Ok(())
5713}
5714
5715/// Insert typed logical relations while allowing trust metadata to refresh.
5716fn insert_relations(
5717    connection: &Connection,
5718    project: ProjectInstanceId,
5719    relations: &[LogicalRelation],
5720) -> DbResult<()> {
5721    let mut statement = connection.prepare_cached(
5722        "INSERT INTO graph_relations(
5723            relation_key, project_instance_id, canonical_identity, source_entity_key,
5724            relation_scope, relation_kind, resolution_status, target_entity_key,
5725            reference_text, candidate_count, confidence, completeness
5726         ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
5727         ON CONFLICT(relation_key) DO UPDATE SET
5728            confidence = excluded.confidence,
5729            completeness = excluded.completeness
5730         WHERE graph_relations.project_instance_id = excluded.project_instance_id
5731           AND graph_relations.canonical_identity = excluded.canonical_identity
5732           AND graph_relations.source_entity_key = excluded.source_entity_key
5733           AND graph_relations.relation_scope = excluded.relation_scope
5734           AND graph_relations.relation_kind = excluded.relation_kind
5735           AND graph_relations.resolution_status = excluded.resolution_status
5736           AND graph_relations.target_entity_key IS excluded.target_entity_key
5737           AND graph_relations.reference_text IS excluded.reference_text
5738           AND graph_relations.candidate_count IS excluded.candidate_count",
5739    )?;
5740    for relation in relations {
5741        let (scope, kind) = relation_parts(relation.kind());
5742        let resolution = resolution_columns(relation.resolution())?;
5743        let key = relation.key().digest_bytes()?;
5744        let source = relation.source().digest_bytes()?;
5745        let changed = statement.execute(params![
5746            &key[..],
5747            &project.as_bytes()[..],
5748            relation.key().canonical_identity(),
5749            &source[..],
5750            scope,
5751            kind,
5752            resolution.status,
5753            resolution.target.as_ref().map(|target| &target[..]),
5754            resolution.reference,
5755            resolution.candidate_count,
5756            confidence_name(relation.confidence()),
5757            completeness_name(relation.completeness()),
5758        ])?;
5759        if changed == 0 {
5760            return Err(
5761                projectatlas_core::graph::GraphContractError::StableKeyCollision {
5762                    digest: relation.key().digest().to_string(),
5763                }
5764                .into(),
5765            );
5766        }
5767    }
5768    Ok(())
5769}
5770
5771/// Insert every exact source occurrence without duplicating logical evidence.
5772fn insert_occurrences(connection: &Connection, occurrences: &[RelationOccurrence]) -> DbResult<()> {
5773    let mut statement = connection.prepare_cached(
5774        "INSERT INTO graph_relation_occurrences(
5775            relation_key, file_path, start_line, start_column, end_line, end_column
5776         ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)
5777         ON CONFLICT(relation_key, file_path, start_line, start_column, end_line, end_column)
5778         DO NOTHING",
5779    )?;
5780    for occurrence in occurrences {
5781        let key = occurrence.relation().digest_bytes()?;
5782        let span = occurrence.span();
5783        statement.execute(params![
5784            &key[..],
5785            occurrence.file().as_str(),
5786            i64::from(span.start_line()),
5787            i64::from(span.start_column()),
5788            i64::from(span.end_line()),
5789            i64::from(span.end_column()),
5790        ])?;
5791    }
5792    Ok(())
5793}
5794
5795/// Replace coverage rows by their normalized identity.
5796fn insert_coverage(
5797    connection: &Connection,
5798    project: ProjectInstanceId,
5799    coverage: &[CoverageRecord],
5800) -> DbResult<()> {
5801    let mut remove = connection.prepare_cached(
5802        "DELETE FROM graph_coverage
5803          WHERE project_instance_id = ?1 AND scope_kind = ?2 AND scope_path IS ?3
5804            AND relation_scope IS ?4 AND relation_kind IS ?5",
5805    )?;
5806    let mut insert = connection.prepare_cached(
5807        "INSERT INTO graph_coverage(
5808            project_instance_id, scope_kind, scope_path, relation_scope, relation_kind,
5809            state, total, covered, omitted, reason, reached_limit
5810         ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
5811    )?;
5812    for record in coverage {
5813        let (scope_kind, scope_path) = coverage_scope_parts(record.scope());
5814        let (relation_scope, relation_kind) = record
5815            .relation()
5816            .map(relation_parts)
5817            .map_or((None, None), |(scope, kind)| (Some(scope), Some(kind)));
5818        let values = params![
5819            &project.as_bytes()[..],
5820            scope_kind,
5821            scope_path,
5822            relation_scope,
5823            relation_kind,
5824        ];
5825        remove.execute(values)?;
5826        insert.execute(params![
5827            &project.as_bytes()[..],
5828            scope_kind,
5829            scope_path,
5830            relation_scope,
5831            relation_kind,
5832            coverage_state_name(record.state()),
5833            sqlite_count("graph_coverage.total", record.total())?,
5834            sqlite_count("graph_coverage.covered", record.covered())?,
5835            sqlite_count("graph_coverage.omitted", record.omitted())?,
5836            record.reason().map(GraphIdentityText::as_str),
5837            record.reached_limit().map(GraphLimitKind::as_str),
5838        ])?;
5839    }
5840    Ok(())
5841}
5842
5843/// Rebind or remove identity details before replacing their owning graph paths.
5844///
5845/// The graph tables use one project-wide active generation rather than a
5846/// generation column per row. Identity details are the one bounded diagnostic
5847/// table that carries the generation explicitly, so replacement must prepare it
5848/// in the same savepoint before node deletion can cascade stale rows.
5849fn prepare_graph_identity_rejections(
5850    connection: &Connection,
5851    project: ProjectInstanceId,
5852    generation: IndexGeneration,
5853    affected_paths: Option<&[RepositoryNodePath]>,
5854) -> DbResult<()> {
5855    let generation = sqlite_count("graph_identity_rejections.generation", generation.get())?;
5856    let project_bytes = project.as_bytes();
5857    match affected_paths {
5858        None => {
5859            connection.execute(
5860                "DELETE FROM graph_identity_rejections WHERE project_instance_id = ?1",
5861                [project_bytes.as_slice()],
5862            )?;
5863        }
5864        Some(paths) => {
5865            connection.execute(
5866                "UPDATE graph_identity_rejections
5867                    SET generation = ?2
5868                  WHERE project_instance_id = ?1",
5869                params![project_bytes.as_slice(), generation],
5870            )?;
5871            let mut remove = connection.prepare_cached(
5872                "DELETE FROM graph_identity_rejections
5873                  WHERE project_instance_id = ?1
5874                    AND generation = ?2
5875                    AND (file_path = ?3 OR (file_path >= ?4 AND file_path < ?5))",
5876            )?;
5877            for path in paths {
5878                let (descendant_start, descendant_end) =
5879                    repository_descendant_bounds(path.as_str());
5880                remove.execute(params![
5881                    project_bytes.as_slice(),
5882                    generation,
5883                    path.as_str(),
5884                    descendant_start,
5885                    descendant_end,
5886                ])?;
5887            }
5888        }
5889    }
5890    Ok(())
5891}
5892
5893/// Insert bounded typed parser identity rejection rows through one statement.
5894fn insert_graph_identity_rejections(
5895    connection: &Connection,
5896    project: ProjectInstanceId,
5897    generation: IndexGeneration,
5898    rejections: &[GraphIdentityRejection],
5899) -> DbResult<()> {
5900    let mut insert = connection.prepare_cached(
5901        "INSERT INTO graph_identity_rejections(
5902            project_instance_id, generation, file_path,
5903            start_line, start_column, end_line, end_column,
5904             parser, field, reason, fact_index
5905         ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
5906         ON CONFLICT(
5907            project_instance_id, generation, file_path, start_line, start_column,
5908             end_line, end_column, parser, field, reason, fact_index
5909         ) DO NOTHING",
5910    )?;
5911    let generation = sqlite_count("graph_identity_rejections.generation", generation.get())?;
5912    for rejection in rejections {
5913        let span = rejection.span;
5914        insert.execute(params![
5915            &project.as_bytes()[..],
5916            generation,
5917            rejection.path.as_str(),
5918            i64::from(span.start_line()),
5919            i64::from(span.start_column()),
5920            i64::from(span.end_line()),
5921            i64::from(span.end_column()),
5922            rejection.parser.to_string(),
5923            rejection.field.to_string(),
5924            rejection.reason.to_string(),
5925            i64::try_from(rejection.fact_index).map_err(|_error| {
5926                GraphContractError::InvalidLimits {
5927                    reason: "graph identity rejection fact index exceeded SQLite integer range",
5928                }
5929            })?,
5930        ])?;
5931    }
5932    Ok(())
5933}
5934
5935/// Borrowed normalized selector columns for one entity insert.
5936struct EntityColumns<'selector> {
5937    /// Normalized selector variant.
5938    kind: &'static str,
5939    /// Folder, file, or symbol repository path.
5940    repository_path: Option<&'selector str>,
5941    /// Package ecosystem.
5942    package_manager: Option<&'selector str>,
5943    /// Package name.
5944    package_name: Option<&'selector str>,
5945    /// Package manifest path.
5946    manifest_path: Option<&'selector str>,
5947    /// Declaration name.
5948    symbol_name: Option<&'selector str>,
5949    /// Declaration kind.
5950    symbol_kind: Option<&'static str>,
5951    /// Optional containing declaration.
5952    symbol_parent: Option<&'selector str>,
5953    /// Stable declaration signature.
5954    symbol_signature: Option<&'selector str>,
5955    /// External namespace.
5956    external_system: Option<&'selector str>,
5957    /// External identity.
5958    external_identity: Option<&'selector str>,
5959}
5960
5961/// Map one typed selector to its normalized database columns.
5962fn entity_columns(selector: &EntitySelector) -> EntityColumns<'_> {
5963    match selector {
5964        EntitySelector::Project => EntityColumns {
5965            kind: "project",
5966            repository_path: None,
5967            package_manager: None,
5968            package_name: None,
5969            manifest_path: None,
5970            symbol_name: None,
5971            symbol_kind: None,
5972            symbol_parent: None,
5973            symbol_signature: None,
5974            external_system: None,
5975            external_identity: None,
5976        },
5977        EntitySelector::Folder { path } => EntityColumns {
5978            kind: "folder",
5979            repository_path: Some(path.as_str()),
5980            package_manager: None,
5981            package_name: None,
5982            manifest_path: None,
5983            symbol_name: None,
5984            symbol_kind: None,
5985            symbol_parent: None,
5986            symbol_signature: None,
5987            external_system: None,
5988            external_identity: None,
5989        },
5990        EntitySelector::File { path } => EntityColumns {
5991            kind: "file",
5992            repository_path: Some(path.as_str()),
5993            package_manager: None,
5994            package_name: None,
5995            manifest_path: None,
5996            symbol_name: None,
5997            symbol_kind: None,
5998            symbol_parent: None,
5999            symbol_signature: None,
6000            external_system: None,
6001            external_identity: None,
6002        },
6003        EntitySelector::Package { package } => EntityColumns {
6004            kind: "package",
6005            repository_path: None,
6006            package_manager: Some(package.manager.as_str()),
6007            package_name: Some(package.name.as_str()),
6008            manifest_path: Some(package.manifest.as_str()),
6009            symbol_name: None,
6010            symbol_kind: None,
6011            symbol_parent: None,
6012            symbol_signature: None,
6013            external_system: None,
6014            external_identity: None,
6015        },
6016        EntitySelector::Symbol { symbol } => EntityColumns {
6017            kind: "symbol",
6018            repository_path: Some(symbol.file.as_str()),
6019            package_manager: None,
6020            package_name: None,
6021            manifest_path: None,
6022            symbol_name: Some(symbol.name.as_str()),
6023            symbol_kind: Some(symbol_kind_name(symbol.kind)),
6024            symbol_parent: symbol.parent.as_ref().map(GraphIdentityText::as_str),
6025            symbol_signature: Some(symbol.signature.as_str()),
6026            external_system: None,
6027            external_identity: None,
6028        },
6029        EntitySelector::External { external } => EntityColumns {
6030            kind: "external",
6031            repository_path: None,
6032            package_manager: None,
6033            package_name: None,
6034            manifest_path: None,
6035            symbol_name: None,
6036            symbol_kind: None,
6037            symbol_parent: None,
6038            symbol_signature: None,
6039            external_system: Some(external.system.as_str()),
6040            external_identity: Some(external.identity.as_str()),
6041        },
6042    }
6043}
6044
6045/// Borrowed normalized resolution columns for one relation insert.
6046struct ResolutionColumns<'resolution> {
6047    /// Normalized resolution state.
6048    status: &'static str,
6049    /// Optional resolved or external target.
6050    target: Option<[u8; 32]>,
6051    /// Optional unresolved reference text.
6052    reference: Option<&'resolution str>,
6053    /// Optional ambiguous candidate count.
6054    candidate_count: Option<i64>,
6055}
6056
6057/// Map typed resolution state to normalized database columns.
6058fn resolution_columns(resolution: &RelationResolution) -> DbResult<ResolutionColumns<'_>> {
6059    match resolution {
6060        RelationResolution::Resolved { target, .. } => Ok(ResolutionColumns {
6061            status: "resolved",
6062            target: Some(target.digest_bytes()?),
6063            reference: None,
6064            candidate_count: None,
6065        }),
6066        RelationResolution::Ambiguous {
6067            reference,
6068            candidates,
6069        } => Ok(ResolutionColumns {
6070            status: "ambiguous",
6071            target: None,
6072            reference: Some(reference.as_str()),
6073            candidate_count: Some(i64::from(candidates.get())),
6074        }),
6075        RelationResolution::Unresolved { reference } => Ok(ResolutionColumns {
6076            status: "unresolved",
6077            target: None,
6078            reference: Some(reference.as_str()),
6079            candidate_count: None,
6080        }),
6081        RelationResolution::External { target, .. } => Ok(ResolutionColumns {
6082            status: "external",
6083            target: Some(target.digest_bytes()?),
6084            reference: None,
6085            candidate_count: None,
6086        }),
6087    }
6088}
6089
6090/// Reconstruct one typed entity and validate persisted key witnesses.
6091fn entity_from_row(
6092    row: EntityRow,
6093    expected_project: ProjectInstanceId,
6094    generation: IndexGeneration,
6095) -> DbResult<GraphEntity> {
6096    let project = project_from_blob("graph_entities.project_instance_id", row.project.clone())?;
6097    require_project(expected_project, project)?;
6098    validate_entity_row_shape(&row)?;
6099    let selector = match row.kind.as_str() {
6100        "project" => EntitySelector::Project,
6101        "folder" => EntitySelector::Folder {
6102            path: RepositoryNodePath::new(Path::new(required_text(
6103                "graph_entities",
6104                "folder path is missing",
6105                row.repository_path.as_deref(),
6106            )?))?,
6107        },
6108        "file" => EntitySelector::File {
6109            path: RepositoryFilePath::new(Path::new(required_text(
6110                "graph_entities",
6111                "file path is missing",
6112                row.repository_path.as_deref(),
6113            )?))?,
6114        },
6115        "package" => EntitySelector::Package {
6116            package: PackageSelector {
6117                manager: GraphIdentityText::new(required_text(
6118                    "graph_entities",
6119                    "package manager is missing",
6120                    row.package_manager.as_deref(),
6121                )?)?,
6122                name: GraphIdentityText::new(required_text(
6123                    "graph_entities",
6124                    "package name is missing",
6125                    row.package_name.as_deref(),
6126                )?)?,
6127                manifest: RepositoryFilePath::new(Path::new(required_text(
6128                    "graph_entities",
6129                    "package manifest is missing",
6130                    row.manifest_path.as_deref(),
6131                )?))?,
6132            },
6133        },
6134        "symbol" => EntitySelector::Symbol {
6135            symbol: SymbolSelector {
6136                file: RepositoryFilePath::new(Path::new(required_text(
6137                    "graph_entities",
6138                    "symbol file is missing",
6139                    row.repository_path.as_deref(),
6140                )?))?,
6141                name: GraphIdentityText::new(required_text(
6142                    "graph_entities",
6143                    "symbol name is missing",
6144                    row.symbol_name.as_deref(),
6145                )?)?,
6146                kind: parse_symbol_kind(required_text(
6147                    "graph_entities",
6148                    "symbol kind is missing",
6149                    row.symbol_kind.as_deref(),
6150                )?)?,
6151                parent: row.symbol_parent.map(GraphIdentityText::new).transpose()?,
6152                signature: GraphIdentityText::new(required_text(
6153                    "graph_entities",
6154                    "symbol signature is missing",
6155                    row.symbol_signature.as_deref(),
6156                )?)?,
6157            },
6158        },
6159        "external" => EntitySelector::External {
6160            external: ExternalSelector {
6161                system: GraphIdentityText::new(required_text(
6162                    "graph_entities",
6163                    "external system is missing",
6164                    row.external_system.as_deref(),
6165                )?)?,
6166                identity: GraphIdentityText::new(required_text(
6167                    "graph_entities",
6168                    "external identity is missing",
6169                    row.external_identity.as_deref(),
6170                )?)?,
6171            },
6172        },
6173        value => {
6174            return Err(DbError::InvalidEnum {
6175                field: "graph_entities.entity_kind",
6176                value: value.to_string(),
6177            });
6178        }
6179    };
6180    let entity = GraphEntity::new(project, selector, generation)?;
6181    validate_entity_key(&entity, row.key, &row.canonical)?;
6182    Ok(entity)
6183}
6184
6185/// Validate selector-column shape independently of physical schema checks.
6186fn validate_entity_row_shape(row: &EntityRow) -> DbResult<()> {
6187    let repository = row.repository_path.is_some();
6188    let package = (
6189        row.package_manager.is_some(),
6190        row.package_name.is_some(),
6191        row.manifest_path.is_some(),
6192    );
6193    let symbol = (
6194        row.symbol_name.is_some(),
6195        row.symbol_kind.is_some(),
6196        row.symbol_parent.is_some(),
6197        row.symbol_signature.is_some(),
6198    );
6199    let external = (
6200        row.external_system.is_some(),
6201        row.external_identity.is_some(),
6202    );
6203    let valid = match row.kind.as_str() {
6204        "project" => {
6205            !repository
6206                && package == (false, false, false)
6207                && symbol == (false, false, false, false)
6208                && external == (false, false)
6209        }
6210        "folder" | "file" => {
6211            repository
6212                && package == (false, false, false)
6213                && symbol == (false, false, false, false)
6214                && external == (false, false)
6215        }
6216        "package" => {
6217            !repository
6218                && package == (true, true, true)
6219                && symbol == (false, false, false, false)
6220                && external == (false, false)
6221        }
6222        "symbol" => {
6223            repository
6224                && package == (false, false, false)
6225                && symbol.0
6226                && symbol.1
6227                && symbol.3
6228                && external == (false, false)
6229        }
6230        "external" => {
6231            !repository
6232                && package == (false, false, false)
6233                && symbol == (false, false, false, false)
6234                && external == (true, true)
6235        }
6236        _ => true,
6237    };
6238    if !valid {
6239        return Err(DbError::GraphRowShape {
6240            table: "graph_entities",
6241            reason: "selector columns contradict entity kind",
6242        });
6243    }
6244    Ok(())
6245}
6246
6247/// Build the key-only endpoint-ranking query used by resolved graph previews.
6248fn resolved_relation_hub_keys_sql() -> &'static str {
6249    "WITH endpoints(entity_key) AS (
6250         SELECT relation.source_entity_key
6251           FROM graph_relations AS relation INDEXED BY idx_graph_relations_kind_resolution
6252          WHERE relation.project_instance_id = ?1
6253            AND relation.relation_scope = ?2
6254            AND relation.relation_kind = ?3
6255            AND relation.resolution_status = ?4
6256            AND relation.target_entity_key IS NOT NULL
6257            AND relation.source_entity_key <> relation.target_entity_key
6258         UNION ALL
6259         SELECT relation.target_entity_key
6260           FROM graph_relations AS relation INDEXED BY idx_graph_relations_kind_resolution
6261          WHERE relation.project_instance_id = ?1
6262            AND relation.relation_scope = ?2
6263            AND relation.relation_kind = ?3
6264            AND relation.resolution_status = ?4
6265            AND relation.target_entity_key IS NOT NULL
6266            AND relation.source_entity_key <> relation.target_entity_key
6267     ), endpoint_degrees(entity_key, degree) AS (
6268         SELECT entity_key, COUNT(*)
6269           FROM endpoints
6270          GROUP BY entity_key
6271     )
6272     SELECT entity_key
6273       FROM endpoint_degrees
6274      ORDER BY endpoint_degrees.degree DESC, endpoint_degrees.entity_key
6275      LIMIT ?5"
6276}
6277
6278/// Return whether an optionally serialized graph-request field is its default.
6279fn is_default<T: Default + PartialEq>(value: &T) -> bool {
6280    value == &T::default()
6281}
6282
6283/// Build one direction-owned batched adjacency statement.
6284fn adjacency_relation_sql(
6285    frontier_count: usize,
6286    direction: RepositoryGraphDirection,
6287    continuation_index: Option<usize>,
6288    relation_filter: bool,
6289    resolved_only: bool,
6290    include_documents: bool,
6291) -> String {
6292    let (key_column, index_name) = match direction {
6293        RepositoryGraphDirection::Outbound => {
6294            ("source_entity_key", "idx_graph_relations_source_kind")
6295        }
6296        RepositoryGraphDirection::Inbound => {
6297            ("target_entity_key", "idx_graph_relations_target_kind")
6298        }
6299    };
6300    let request = if resolved_only {
6301        "request(project_instance_id, resolution_status) AS (VALUES (?, ?))"
6302    } else {
6303        "request(project_instance_id) AS (VALUES (?))"
6304    };
6305    let document_filter = if relation_filter || include_documents {
6306        ""
6307    } else {
6308        "AND NOT (
6309             relation.relation_scope = 'extended'
6310             AND relation.relation_kind = 'documents'
6311         )"
6312    };
6313    let relation_filter = if relation_filter {
6314        "AND relation.relation_scope = ? AND relation.relation_kind = ?"
6315    } else {
6316        ""
6317    };
6318    let resolution_filter = if resolved_only {
6319        "AND relation.resolution_status = request.resolution_status
6320         AND relation.target_entity_key IS NOT NULL
6321         AND relation.source_entity_key <> relation.target_entity_key"
6322    } else {
6323        ""
6324    };
6325    let branches = (continuation_index.unwrap_or(0)..frontier_count)
6326        .map(|frontier_index| {
6327            let continuation = if continuation_index == Some(frontier_index) {
6328                "AND (relation.relation_scope, relation.relation_kind,
6329                      relation.relation_key) > (?, ?, ?)"
6330                    .to_string()
6331            } else {
6332                String::new()
6333            };
6334            format!(
6335                "SELECT * FROM (
6336                     SELECT {frontier_index} AS frontier_index,
6337                            relation.relation_key, relation.project_instance_id,
6338                            relation.canonical_identity, relation.source_entity_key,
6339                            relation.relation_scope, relation.relation_kind,
6340                            relation.resolution_status, relation.target_entity_key,
6341                            relation.reference_text, relation.candidate_count,
6342                            relation.document_unresolved_reason,
6343                            relation.confidence, relation.completeness
6344                       FROM graph_relations AS relation INDEXED BY {index_name}
6345                       CROSS JOIN request
6346                      WHERE relation.{key_column} = ?
6347                        AND relation.project_instance_id = request.project_instance_id
6348                        {relation_filter}
6349                        {resolution_filter}
6350                        {document_filter}
6351                        {continuation}
6352                      ORDER BY relation.relation_scope, relation.relation_kind,
6353                               relation.relation_key
6354                      LIMIT ?
6355                 )"
6356            )
6357        })
6358        .collect::<Vec<_>>()
6359        .join(" UNION ALL ");
6360    format!(
6361        "WITH {request}
6362         {branches}
6363         ORDER BY frontier_index, relation_scope, relation_kind,
6364                  relation_key
6365         LIMIT ?"
6366    )
6367}
6368
6369/// Build one anonymous fixed-column `VALUES` clause.
6370fn graph_values_clause(rows: usize, columns: usize) -> String {
6371    let row = format!("({})", vec!["?"; columns].join(", "));
6372    vec![row; rows].join(", ")
6373}
6374
6375/// Build one indexed stable-key entity hydration statement.
6376fn graph_entity_hydration_sql(entity_count: usize) -> String {
6377    format!(
6378        "WITH requested(entity_key) AS (VALUES {})
6379         SELECT entity.entity_key, entity.project_instance_id,
6380                entity.canonical_identity, entity.entity_kind,
6381                entity.repository_path, entity.package_manager,
6382                entity.package_name, entity.manifest_path,
6383                entity.symbol_name, entity.symbol_kind,
6384                entity.symbol_parent, entity.symbol_signature,
6385                entity.external_system, entity.external_identity
6386           FROM requested
6387           JOIN graph_entities AS entity
6388             ON entity.entity_key = requested.entity_key
6389          WHERE entity.project_instance_id = ?",
6390        graph_values_clause(entity_count, 1),
6391    )
6392}
6393
6394/// Build one project-scoped stable-key relation hydration statement.
6395fn graph_relation_hydration_sql(relation_count: usize) -> String {
6396    format!(
6397        "WITH requested(relation_key) AS (VALUES {})
6398         SELECT relation.relation_key, relation.project_instance_id,
6399                relation.canonical_identity, relation.source_entity_key,
6400                relation.relation_scope, relation.relation_kind,
6401                relation.resolution_status, relation.target_entity_key,
6402                relation.reference_text, relation.candidate_count,
6403                relation.document_unresolved_reason,
6404                relation.confidence, relation.completeness
6405           FROM requested
6406           JOIN graph_relations AS relation INDEXED BY idx_graph_relations_project_key
6407             ON relation.relation_key = requested.relation_key
6408            AND relation.project_instance_id = ?",
6409        graph_values_clause(relation_count, 1),
6410    )
6411}
6412
6413/// Build one indexed family query with source classification projected once.
6414fn classified_relation_family_sql(selection: ContentSelection) -> String {
6415    let selection_predicate = match selection {
6416        ContentSelection::UnspecifiedLegacy => "",
6417        ContentSelection::Source | ContentSelection::Documentation => {
6418            "AND source_classification.classification = ?"
6419        }
6420        ContentSelection::Both => "AND source_classification.classification IN (?, ?)",
6421    };
6422    format!(
6423        "SELECT relation.relation_key, relation.project_instance_id,
6424                relation.canonical_identity, relation.source_entity_key,
6425                relation.relation_scope, relation.relation_kind,
6426                relation.resolution_status, relation.target_entity_key,
6427                relation.reference_text, relation.candidate_count,
6428                relation.document_unresolved_reason,
6429                relation.confidence, relation.completeness,
6430                source_classification.classification
6431           FROM graph_relations AS relation INDEXED BY idx_graph_relations_kind_order
6432           JOIN graph_entities AS source
6433             ON source.project_instance_id = relation.project_instance_id
6434            AND source.entity_key = relation.source_entity_key
6435           LEFT JOIN file_content_classifications AS source_classification
6436             ON source_classification.path = CASE source.entity_kind
6437                    WHEN 'file' THEN source.repository_path
6438                    WHEN 'symbol' THEN source.repository_path
6439                    WHEN 'package' THEN source.manifest_path
6440                END
6441          WHERE relation.project_instance_id = ?
6442            AND relation.relation_scope = ?
6443            AND relation.relation_kind = ?
6444            {selection_predicate}
6445          ORDER BY relation.relation_key
6446          LIMIT ?"
6447    )
6448}
6449
6450/// Build direction-independent per-relation occurrence branches.
6451fn occurrence_pages_sql(relation_count: usize) -> String {
6452    let branches = (0..relation_count)
6453        .map(|index| {
6454            format!(
6455                "SELECT {index} AS relation_index, relation_key, file_path,
6456                        start_line, start_column, end_line, end_column
6457                   FROM (
6458                        SELECT relation_key, file_path, start_line, start_column,
6459                               end_line, end_column
6460                          FROM graph_relation_occurrences
6461                         WHERE relation_key = ?
6462                         ORDER BY file_path, start_line, start_column,
6463                                  end_line, end_column
6464                         LIMIT ?
6465                   ) AS occurrence_page_{index}"
6466            )
6467        })
6468        .collect::<Vec<_>>()
6469        .join(" UNION ALL ");
6470    format!(
6471        "{branches}
6472         ORDER BY relation_index, file_path, start_line, start_column,
6473                  end_line, end_column"
6474    )
6475}
6476
6477/// Build the set-oriented exact-path coverage hydration statement.
6478fn path_coverage_sql(path_count: usize) -> String {
6479    let path_bindings = vec!["?"; path_count].join(", ");
6480    format!(
6481        "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
6482                relation_kind, state, total, covered, omitted, reason, reached_limit,
6483                NULL, NULL
6484           FROM graph_coverage
6485          WHERE project_instance_id = ?
6486            AND scope_kind = ?
6487            AND scope_path IN ({path_bindings})
6488          ORDER BY scope_path, relation_scope, relation_kind, state, id
6489          LIMIT ?"
6490    )
6491}
6492
6493/// Load every unique relation endpoint through bounded set-oriented joins.
6494fn load_relation_entities(
6495    store: &AtlasStore,
6496    rows: &[RelationRow],
6497    project: ProjectInstanceId,
6498    generation: IndexGeneration,
6499    control: Option<&IndexWorkControl>,
6500) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
6501    let references = rows.iter().collect::<Vec<_>>();
6502    load_relation_entity_references(store, &references, project, generation, control)
6503}
6504
6505/// Load relation endpoint references shared by ordinary and adjacency pages.
6506fn load_relation_entity_references(
6507    store: &AtlasStore,
6508    rows: &[&RelationRow],
6509    project: ProjectInstanceId,
6510    generation: IndexGeneration,
6511    control: Option<&IndexWorkControl>,
6512) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
6513    load_relation_entity_references_metered(store, rows, project, generation, control, None)
6514}
6515
6516/// Load relation endpoint references with optional exact batch accounting.
6517fn load_relation_entity_references_metered(
6518    store: &AtlasStore,
6519    rows: &[&RelationRow],
6520    project: ProjectInstanceId,
6521    generation: IndexGeneration,
6522    control: Option<&IndexWorkControl>,
6523    meter: Option<&mut RepositoryGraphReadMeter>,
6524) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
6525    let mut digests = BTreeSet::new();
6526    for row in rows {
6527        let row_project =
6528            project_from_blob("graph_relations.project_instance_id", row.project.clone())?;
6529        require_project(project, row_project)?;
6530        digests.insert(fixed_bytes::<32>(
6531            "graph_relations.source_entity_key",
6532            row.source.clone(),
6533        )?);
6534        if let Some(target) = &row.target {
6535            digests.insert(fixed_bytes::<32>(
6536                "graph_relations.target_entity_key",
6537                target.clone(),
6538            )?);
6539        }
6540    }
6541    load_graph_entities_by_digest_metered(
6542        store,
6543        &digests.into_iter().collect::<Vec<_>>(),
6544        project,
6545        generation,
6546        control,
6547        meter,
6548    )
6549}
6550
6551/// Hydrate one unique stable-key set with optional exact batch accounting.
6552fn load_graph_entities_by_digest_metered(
6553    store: &AtlasStore,
6554    digests: &[[u8; 32]],
6555    project: ProjectInstanceId,
6556    generation: IndexGeneration,
6557    control: Option<&IndexWorkControl>,
6558    mut meter: Option<&mut RepositoryGraphReadMeter>,
6559) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
6560    let mut entities = HashMap::with_capacity(digests.len());
6561    for chunk in digests.chunks(GRAPH_ENTITY_HYDRATION_CHUNK) {
6562        if let Some(control) = control {
6563            control.check(IndexWorkStage::RepositoryTraversal)?;
6564        }
6565        let sql = graph_entity_hydration_sql(chunk.len());
6566        let mut bindings = chunk
6567            .iter()
6568            .map(|digest| Value::Blob(digest.to_vec()))
6569            .collect::<Vec<_>>();
6570        bindings.push(Value::Blob(project.as_bytes().to_vec()));
6571        let raw = with_sqlite_read_progress(
6572            &store.connection,
6573            control,
6574            IndexWorkStage::RepositoryTraversal,
6575            || {
6576                let mut statement = store.connection.prepare(&sql)?;
6577                let rows = statement.query(params_from_iter(bindings.iter()))?;
6578                if let Some(meter) = meter.as_deref_mut() {
6579                    collect_entity_rows_metered(rows, meter)
6580                } else {
6581                    collect_entity_rows(rows)
6582                }
6583            },
6584        )?;
6585        for row in raw {
6586            let entity = entity_from_row(row, project, generation)?;
6587            let digest = entity.key().digest_bytes()?;
6588            if entities.contains_key(&digest) {
6589                return Err(DbError::GraphRowShape {
6590                    table: "graph_entities",
6591                    reason: "batched entity hydration returned a duplicate key",
6592                });
6593            }
6594            if let Some(meter) = meter.as_deref_mut() {
6595                meter.record_entity(&entity)?;
6596            }
6597            entities.insert(digest, entity);
6598        }
6599    }
6600    Ok(entities)
6601}
6602
6603/// Construct the compatibility envelope used by legacy bounded read wrappers.
6604fn maximum_repository_graph_read_budget() -> DbResult<RepositoryGraphReadBudget> {
6605    Ok(RepositoryGraphReadBudget::new(
6606        RepositoryGraphReadBudget::MAX_REQUESTED_ROWS,
6607        RepositoryGraphReadBudget::MAX_RETURNED_ROWS,
6608        RepositoryGraphReadBudget::MAX_DECODED_BYTES,
6609        RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES,
6610        RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
6611    )?)
6612}
6613
6614/// Validate one bounded unique exact-path coverage request.
6615fn validate_path_coverage_request(paths: &[RepositoryNodePath]) -> DbResult<()> {
6616    if paths.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
6617        return Err(GraphContractError::InvalidLimits {
6618            reason: "graph coverage path set exceeds the product ceiling",
6619        }
6620        .into());
6621    }
6622    if paths
6623        .iter()
6624        .map(RepositoryNodePath::as_str)
6625        .collect::<BTreeSet<_>>()
6626        .len()
6627        != paths.len()
6628    {
6629        return Err(GraphContractError::InvalidLimits {
6630            reason: "graph coverage paths must be unique",
6631        }
6632        .into());
6633    }
6634    Ok(())
6635}
6636
6637/// Validate the bounded unique stable-key set shared by cursor hydration calls.
6638fn validate_graph_hydration_request(digests: &[[u8; 32]]) -> DbResult<()> {
6639    if digests.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
6640        return Err(GraphContractError::InvalidLimits {
6641            reason: "graph hydration key set exceeds the product ceiling",
6642        }
6643        .into());
6644    }
6645    if digests.iter().copied().collect::<HashSet<_>>().len() != digests.len() {
6646        return Err(GraphContractError::InvalidLimits {
6647            reason: "graph hydration keys must be unique",
6648        }
6649        .into());
6650    }
6651    Ok(())
6652}
6653
6654/// Return the exact repository path that can own authored purpose for an entity.
6655fn graph_entity_purpose_owner(entity: &GraphEntity) -> Option<&str> {
6656    match entity.selector() {
6657        EntitySelector::Project => Some("."),
6658        EntitySelector::Folder { path } => Some(path.as_str()),
6659        EntitySelector::File { path } => Some(path.as_str()),
6660        EntitySelector::Package { package } => Some(package.manifest.as_str()),
6661        EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
6662        EntitySelector::External { .. } => None,
6663    }
6664}
6665
6666/// Return the admitted file path that owns an entity classification.
6667fn graph_entity_classification_owner(entity: &GraphEntity) -> Option<&str> {
6668    match entity.selector() {
6669        EntitySelector::File { path } => Some(path.as_str()),
6670        EntitySelector::Package { package } => Some(package.manifest.as_str()),
6671        EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
6672        EntitySelector::Project
6673        | EntitySelector::Folder { .. }
6674        | EntitySelector::External { .. } => None,
6675    }
6676}
6677
6678/// Reconstruct one classified family row without a follow-up classification read.
6679fn classified_relation_detail_from_row(
6680    entities: &HashMap<[u8; 32], GraphEntity>,
6681    row: ClassifiedRelationRow,
6682    expected_project: ProjectInstanceId,
6683    generation: IndexGeneration,
6684) -> DbResult<RepositoryGraphClassifiedRelationRow> {
6685    let source_classification = row
6686        .source_classification
6687        .map(parse_classification)
6688        .transpose()?;
6689    let detail = relation_detail_from_row(entities, row.relation, expected_project, generation)?;
6690    match (
6691        graph_entity_classification_owner(&detail.source),
6692        source_classification,
6693    ) {
6694        (Some(path), None) => Err(DbError::FileContentClassificationMissing {
6695            path: path.to_string(),
6696        }),
6697        (None, Some(_)) => Err(DbError::GraphRowShape {
6698            table: "file_content_classifications",
6699            reason: "non-file-bearing graph source retained a classification",
6700        }),
6701        (_, source_classification) => Ok(RepositoryGraphClassifiedRelationRow {
6702            detail,
6703            source_classification,
6704        }),
6705    }
6706}
6707
6708/// Reconstruct one relation and retain its already-hydrated endpoint entities.
6709fn relation_detail_from_row(
6710    entities: &HashMap<[u8; 32], GraphEntity>,
6711    row: RelationRow,
6712    expected_project: ProjectInstanceId,
6713    generation: IndexGeneration,
6714) -> DbResult<RepositoryGraphRelationRow> {
6715    let source_key = fixed_bytes::<32>("graph_relations.source_entity_key", row.source.clone())?;
6716    let target_key = row
6717        .target
6718        .as_ref()
6719        .map(|target| fixed_bytes::<32>("graph_relations.target_entity_key", target.clone()))
6720        .transpose()?;
6721    let document_unresolved_reason = document_unresolved_reason_from_row(&row)?;
6722    let relation = relation_from_row(entities, row, expected_project, generation)?;
6723    let source = entities
6724        .get(&source_key)
6725        .cloned()
6726        .ok_or(DbError::GraphRowShape {
6727            table: "graph_relations",
6728            reason: "source entity is missing",
6729        })?;
6730    let target = target_key
6731        .map(|key| {
6732            entities.get(&key).cloned().ok_or(DbError::GraphRowShape {
6733                table: "graph_relations",
6734                reason: "retained target entity is missing",
6735            })
6736        })
6737        .transpose()?;
6738    Ok(RepositoryGraphRelationRow {
6739        relation,
6740        source,
6741        target,
6742        document_unresolved_reason,
6743    })
6744}
6745
6746/// Reconstruct one typed logical relation through existing domain constructors.
6747fn relation_from_row(
6748    entities: &HashMap<[u8; 32], GraphEntity>,
6749    row: RelationRow,
6750    expected_project: ProjectInstanceId,
6751    generation: IndexGeneration,
6752) -> DbResult<LogicalRelation> {
6753    let _document_unresolved_reason = document_unresolved_reason_from_row(&row)?;
6754    let project = project_from_blob("graph_relations.project_instance_id", row.project.clone())?;
6755    require_project(expected_project, project)?;
6756    let source_key = fixed_bytes::<32>("graph_relations.source_entity_key", row.source.clone())?;
6757    let source = entities
6758        .get(&source_key)
6759        .cloned()
6760        .ok_or(DbError::GraphRowShape {
6761            table: "graph_relations",
6762            reason: "source entity is missing",
6763        })?;
6764    let kind = parse_relation_kind(&row.relation_scope, &row.relation_kind)?;
6765    let resolution = match row.resolution_status.as_str() {
6766        "resolved" => {
6767            require_relation_resolution_shape(&row, true, false, false)?;
6768            let target_key = fixed_bytes::<32>(
6769                "graph_relations.target_entity_key",
6770                row.target.clone().ok_or(DbError::GraphRowShape {
6771                    table: "graph_relations",
6772                    reason: "resolved target is missing",
6773                })?,
6774            )?;
6775            let target = entities
6776                .get(&target_key)
6777                .cloned()
6778                .ok_or(DbError::GraphRowShape {
6779                    table: "graph_relations",
6780                    reason: "resolved target entity is missing",
6781                })?;
6782            RelationResolution::resolved(&target)?
6783        }
6784        "external" => {
6785            require_relation_resolution_shape(&row, true, false, false)?;
6786            let target_key = fixed_bytes::<32>(
6787                "graph_relations.target_entity_key",
6788                row.target.clone().ok_or(DbError::GraphRowShape {
6789                    table: "graph_relations",
6790                    reason: "external target is missing",
6791                })?,
6792            )?;
6793            let target = entities
6794                .get(&target_key)
6795                .cloned()
6796                .ok_or(DbError::GraphRowShape {
6797                    table: "graph_relations",
6798                    reason: "external target entity is missing",
6799                })?;
6800            RelationResolution::external(&target)?
6801        }
6802        "ambiguous" => {
6803            require_relation_resolution_shape(&row, false, true, true)?;
6804            let candidates = positive_u32(
6805                "graph_relations.candidate_count",
6806                row.candidate_count.ok_or(DbError::GraphRowShape {
6807                    table: "graph_relations",
6808                    reason: "ambiguous candidate count is missing",
6809                })?,
6810            )?;
6811            RelationResolution::Ambiguous {
6812                reference: GraphIdentityText::new(row.reference.clone().ok_or(
6813                    DbError::GraphRowShape {
6814                        table: "graph_relations",
6815                        reason: "ambiguous reference is missing",
6816                    },
6817                )?)?,
6818                candidates,
6819            }
6820        }
6821        "unresolved" => {
6822            require_relation_resolution_shape(&row, false, true, false)?;
6823            RelationResolution::Unresolved {
6824                reference: GraphIdentityText::new(row.reference.clone().ok_or(
6825                    DbError::GraphRowShape {
6826                        table: "graph_relations",
6827                        reason: "unresolved reference is missing",
6828                    },
6829                )?)?,
6830            }
6831        }
6832        value => {
6833            return Err(DbError::InvalidEnum {
6834                field: "graph_relations.resolution_status",
6835                value: value.to_string(),
6836            });
6837        }
6838    };
6839    let relation = LogicalRelation::new(
6840        &source,
6841        kind,
6842        resolution,
6843        parse_confidence(&row.confidence)?,
6844        parse_completeness(&row.completeness)?,
6845        generation,
6846    )?;
6847    validate_relation_key(&relation, row.key, &row.canonical)?;
6848    Ok(relation)
6849}
6850
6851/// Validate and decode the optional unresolved-document reason columns.
6852fn document_unresolved_reason_from_row(
6853    row: &RelationRow,
6854) -> DbResult<Option<DocumentTargetUnresolvedReason>> {
6855    match (
6856        row.relation_scope.as_str(),
6857        row.relation_kind.as_str(),
6858        row.resolution_status.as_str(),
6859        row.document_unresolved_reason.as_deref(),
6860    ) {
6861        ("extended", "documents", "unresolved", Some(value)) => {
6862            DocumentTargetUnresolvedReason::from_db(value)
6863                .map(Some)
6864                .ok_or_else(|| DbError::InvalidEnum {
6865                    field: "graph_relations.document_unresolved_reason",
6866                    value: value.to_string(),
6867                })
6868        }
6869        ("extended", "documents", "unresolved", None) => Err(DbError::GraphRowShape {
6870            table: "graph_relations",
6871            reason: "unresolved document relation is missing its closed reason",
6872        }),
6873        (_, _, _, None) => Ok(None),
6874        _ => Err(DbError::GraphRowShape {
6875            table: "graph_relations",
6876            reason: "document unresolved reason contradicts relation family or status",
6877        }),
6878    }
6879}
6880
6881/// Reject contradictory normalized resolution columns.
6882fn require_relation_resolution_shape(
6883    row: &RelationRow,
6884    target_required: bool,
6885    reference_required: bool,
6886    candidates_required: bool,
6887) -> DbResult<()> {
6888    let valid = row.target.is_some() == target_required
6889        && row.reference.is_some() == reference_required
6890        && row.candidate_count.is_some() == candidates_required;
6891    if !valid {
6892        return Err(DbError::GraphRowShape {
6893            table: "graph_relations",
6894            reason: "resolution columns contradict status",
6895        });
6896    }
6897    Ok(())
6898}
6899
6900/// Reconstruct one exact relation occurrence.
6901fn occurrence_from_row(
6902    row: OccurrenceRow,
6903    relation: &LogicalRelation,
6904    generation: IndexGeneration,
6905) -> DbResult<RelationOccurrence> {
6906    let stored_key = fixed_bytes::<32>("graph_relation_occurrences.relation_key", row.relation)?;
6907    if stored_key != relation.key().digest_bytes()? {
6908        return Err(DbError::GraphRowShape {
6909            table: "graph_relation_occurrences",
6910            reason: "occurrence relation key does not match query",
6911        });
6912    }
6913    RelationOccurrence::new(
6914        relation,
6915        RepositoryFilePath::new(Path::new(&row.file_path))?,
6916        SourceSpan::new(
6917            positive_u32_value("graph_relation_occurrences.start_line", row.start_line)?,
6918            nonnegative_u32("graph_relation_occurrences.start_column", row.start_column)?,
6919            positive_u32_value("graph_relation_occurrences.end_line", row.end_line)?,
6920            nonnegative_u32("graph_relation_occurrences.end_column", row.end_column)?,
6921        )?,
6922        generation,
6923    )
6924    .map_err(Into::into)
6925}
6926
6927/// Reconstruct one graph coverage record and verify project ownership.
6928fn coverage_from_row(
6929    row: CoverageRow,
6930    expected_project: ProjectInstanceId,
6931    generation: IndexGeneration,
6932) -> DbResult<CoverageRecord> {
6933    let project = project_from_blob("graph_coverage.project_instance_id", row.project)?;
6934    require_project(expected_project, project)?;
6935    let scope = match (row.scope_kind.as_str(), row.scope_path) {
6936        ("project", None) => CoverageScope::Project,
6937        ("path", Some(path)) => CoverageScope::Path {
6938            path: RepositoryNodePath::new(Path::new(&path))?,
6939        },
6940        ("project" | "path", _) => {
6941            return Err(DbError::GraphRowShape {
6942                table: "graph_coverage",
6943                reason: "scope columns contradict scope kind",
6944            });
6945        }
6946        (value, _) => {
6947            return Err(DbError::InvalidEnum {
6948                field: "graph_coverage.scope_kind",
6949                value: value.to_string(),
6950            });
6951        }
6952    };
6953    let relation = match (row.relation_scope, row.relation_kind) {
6954        (None, None) => None,
6955        (Some(scope), Some(kind)) => Some(parse_relation_kind(&scope, &kind)?),
6956        _ => {
6957            return Err(DbError::GraphRowShape {
6958                table: "graph_coverage",
6959                reason: "relation scope and kind must both be present or absent",
6960            });
6961        }
6962    };
6963    let persisted_total = nonnegative_u64("graph_coverage.total", row.total)?;
6964    let covered = nonnegative_u64("graph_coverage.covered", row.covered)?;
6965    let omitted = nonnegative_u64("graph_coverage.omitted", row.omitted)?;
6966    let persisted_state = parse_coverage_state(&row.state)?;
6967    let state = if persisted_state == CoverageState::Complete
6968        && covered == 0
6969        && omitted == 0
6970        && relation == Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents))
6971    {
6972        CoverageState::NoCandidates
6973    } else {
6974        persisted_state
6975    };
6976    let record = CoverageRecord::new(
6977        scope,
6978        relation,
6979        state,
6980        covered,
6981        omitted,
6982        generation,
6983        row.reason.map(GraphIdentityText::new).transpose()?,
6984        row.reached_limit
6985            .as_deref()
6986            .map(parse_limit_kind)
6987            .transpose()?,
6988    )?;
6989    if record.total() != persisted_total {
6990        return Err(DbError::GraphRowShape {
6991            table: "graph_coverage",
6992            reason: "total does not equal covered plus omitted",
6993        });
6994    }
6995    Ok(record)
6996}
6997
6998/// Reconstruct discovered coverage together with strict parser provenance.
6999fn coverage_discovery_from_row(
7000    row: CoverageRow,
7001    expected_project: ProjectInstanceId,
7002    generation: IndexGeneration,
7003) -> DbResult<RepositoryCoverageRow> {
7004    let parser = row
7005        .parser
7006        .as_deref()
7007        .map(|value| parse_parser_kind("source_parse_metadata.source_parser", value))
7008        .transpose()?;
7009    let provider = row
7010        .provider
7011        .as_deref()
7012        .map(|value| parse_parser_kind("source_parse_metadata.fact_parser", value))
7013        .transpose()?;
7014    let coverage = coverage_from_row(row, expected_project, generation)?;
7015    Ok(RepositoryCoverageRow {
7016        coverage,
7017        parser,
7018        provider,
7019    })
7020}
7021
7022/// Fail with both project identities when normalized ownership differs.
7023fn require_project(expected: ProjectInstanceId, found: ProjectInstanceId) -> DbResult<()> {
7024    if expected != found {
7025        return Err(DbError::GraphProjectIdentityMismatch {
7026            expected: expected.to_string(),
7027            found: found.to_string(),
7028        });
7029    }
7030    Ok(())
7031}
7032
7033/// Validate one stored entity key and canonical collision witness.
7034fn validate_entity_key(
7035    entity: &GraphEntity,
7036    stored_key: Vec<u8>,
7037    stored_canonical: &str,
7038) -> DbResult<()> {
7039    let stored_key = fixed_bytes::<32>("graph_entities.entity_key", stored_key)?;
7040    if stored_key != entity.key().digest_bytes()? {
7041        return Err(projectatlas_core::graph::GraphContractError::InvalidStableKeyDigest.into());
7042    }
7043    if stored_canonical != entity.key().canonical_identity() {
7044        return Err(
7045            projectatlas_core::graph::GraphContractError::StableKeyCollision {
7046                digest: entity.key().digest().to_string(),
7047            }
7048            .into(),
7049        );
7050    }
7051    Ok(())
7052}
7053
7054/// Validate one stored relation key and canonical collision witness.
7055fn validate_relation_key(
7056    relation: &LogicalRelation,
7057    stored_key: Vec<u8>,
7058    stored_canonical: &str,
7059) -> DbResult<()> {
7060    let stored_key = fixed_bytes::<32>("graph_relations.relation_key", stored_key)?;
7061    if stored_key != relation.key().digest_bytes()? {
7062        return Err(projectatlas_core::graph::GraphContractError::InvalidStableKeyDigest.into());
7063    }
7064    if stored_canonical != relation.key().canonical_identity() {
7065        return Err(
7066            projectatlas_core::graph::GraphContractError::StableKeyCollision {
7067                digest: relation.key().digest().to_string(),
7068            }
7069            .into(),
7070        );
7071    }
7072    Ok(())
7073}
7074
7075/// Collect every raw entity row, including the truncation sentinel row.
7076fn collect_entity_rows(mut rows: rusqlite::Rows<'_>) -> DbResult<Vec<EntityRow>> {
7077    let mut collected = Vec::new();
7078    while let Some(row) = rows.next()? {
7079        collected.push(entity_row(row)?);
7080    }
7081    Ok(collected)
7082}
7083
7084/// Collect raw entity rows while enforcing decoded payload bytes.
7085fn collect_entity_rows_metered(
7086    mut rows: rusqlite::Rows<'_>,
7087    meter: &mut RepositoryGraphReadMeter,
7088) -> DbResult<Vec<EntityRow>> {
7089    let mut collected = Vec::new();
7090    while let Some(row) = rows.next()? {
7091        let raw = entity_row(row)?;
7092        meter.record_decoded_bytes(entity_row_decoded_bytes(&raw)?)?;
7093        collected.push(raw);
7094    }
7095    Ok(collected)
7096}
7097
7098/// Read one raw entity row without interpreting enum or selector values.
7099fn entity_row(row: &Row<'_>) -> rusqlite::Result<EntityRow> {
7100    Ok(EntityRow {
7101        key: row.get(0)?,
7102        project: row.get(1)?,
7103        canonical: row.get(2)?,
7104        kind: row.get(3)?,
7105        repository_path: row.get(4)?,
7106        package_manager: row.get(5)?,
7107        package_name: row.get(6)?,
7108        manifest_path: row.get(7)?,
7109        symbol_name: row.get(8)?,
7110        symbol_kind: row.get(9)?,
7111        symbol_parent: row.get(10)?,
7112        symbol_signature: row.get(11)?,
7113        external_system: row.get(12)?,
7114        external_identity: row.get(13)?,
7115    })
7116}
7117
7118/// Count exact dynamic payload bytes decoded for one normalized entity row.
7119fn entity_row_decoded_bytes(row: &EntityRow) -> DbResult<u64> {
7120    decoded_payload_bytes(
7121        [
7122            row.key.len(),
7123            row.project.len(),
7124            row.canonical.len(),
7125            row.kind.len(),
7126            row.repository_path.as_ref().map_or(0, String::len),
7127            row.package_manager.as_ref().map_or(0, String::len),
7128            row.package_name.as_ref().map_or(0, String::len),
7129            row.manifest_path.as_ref().map_or(0, String::len),
7130            row.symbol_name.as_ref().map_or(0, String::len),
7131            row.symbol_kind.as_ref().map_or(0, String::len),
7132            row.symbol_parent.as_ref().map_or(0, String::len),
7133            row.symbol_signature.as_ref().map_or(0, String::len),
7134            row.external_system.as_ref().map_or(0, String::len),
7135            row.external_identity.as_ref().map_or(0, String::len),
7136        ],
7137        0,
7138    )
7139}
7140
7141/// Collect every raw relation row, including the truncation sentinel row.
7142fn collect_relation_rows(mut rows: rusqlite::Rows<'_>) -> DbResult<Vec<RelationRow>> {
7143    let mut collected = Vec::new();
7144    while let Some(row) = rows.next()? {
7145        collected.push(relation_row(row)?);
7146    }
7147    Ok(collected)
7148}
7149
7150/// Collect relation-family rows and their joined source classifications.
7151fn collect_classified_relation_rows(
7152    mut rows: rusqlite::Rows<'_>,
7153) -> DbResult<Vec<ClassifiedRelationRow>> {
7154    let mut collected = Vec::new();
7155    while let Some(row) = rows.next()? {
7156        collected.push(ClassifiedRelationRow {
7157            relation: relation_row_at(row, 0)?,
7158            source_classification: row.get(13)?,
7159        });
7160    }
7161    Ok(collected)
7162}
7163
7164/// Collect raw relation rows while enforcing decoded payload bytes.
7165fn collect_relation_rows_metered(
7166    mut rows: rusqlite::Rows<'_>,
7167    meter: &mut RepositoryGraphReadMeter,
7168) -> DbResult<Vec<RelationRow>> {
7169    let mut collected = Vec::new();
7170    while let Some(row) = rows.next()? {
7171        let raw = relation_row(row)?;
7172        meter.record_decoded_bytes(relation_row_decoded_bytes(&raw)?)?;
7173        collected.push(raw);
7174    }
7175    Ok(collected)
7176}
7177
7178/// Collect raw adjacency rows and meter the truncation sentinel before return.
7179fn collect_adjacency_relation_rows_metered(
7180    mut rows: rusqlite::Rows<'_>,
7181    meter: &mut RepositoryGraphReadMeter,
7182) -> DbResult<Vec<AdjacencyRelationRow>> {
7183    let mut collected = Vec::new();
7184    while let Some(row) = rows.next()? {
7185        let raw = AdjacencyRelationRow {
7186            frontier_index: row.get(0)?,
7187            relation: relation_row_at(row, 1)?,
7188        };
7189        meter.record_decoded_bytes(
7190            relation_row_decoded_bytes(&raw.relation)?
7191                .checked_add(8)
7192                .ok_or(GraphContractError::InvalidLimits {
7193                    reason: "graph adjacency decoded row size overflowed",
7194                })?,
7195        )?;
7196        collected.push(raw);
7197    }
7198    Ok(collected)
7199}
7200
7201/// Read one raw relation row without interpreting enum or resolution values.
7202fn relation_row(row: &Row<'_>) -> rusqlite::Result<RelationRow> {
7203    relation_row_at(row, 0)
7204}
7205
7206/// Read one raw relation row beginning at the selected column offset.
7207fn relation_row_at(row: &Row<'_>, offset: usize) -> rusqlite::Result<RelationRow> {
7208    Ok(RelationRow {
7209        key: row.get(offset)?,
7210        project: row.get(offset + 1)?,
7211        canonical: row.get(offset + 2)?,
7212        source: row.get(offset + 3)?,
7213        relation_scope: row.get(offset + 4)?,
7214        relation_kind: row.get(offset + 5)?,
7215        resolution_status: row.get(offset + 6)?,
7216        target: row.get(offset + 7)?,
7217        reference: row.get(offset + 8)?,
7218        candidate_count: row.get(offset + 9)?,
7219        document_unresolved_reason: row.get(offset + 10)?,
7220        confidence: row.get(offset + 11)?,
7221        completeness: row.get(offset + 12)?,
7222    })
7223}
7224
7225/// Count exact dynamic and fixed payload bytes decoded for one relation row.
7226fn relation_row_decoded_bytes(row: &RelationRow) -> DbResult<u64> {
7227    decoded_payload_bytes(
7228        [
7229            row.key.len(),
7230            row.project.len(),
7231            row.canonical.len(),
7232            row.source.len(),
7233            row.relation_scope.len(),
7234            row.relation_kind.len(),
7235            row.resolution_status.len(),
7236            row.target.as_ref().map_or(0, Vec::len),
7237            row.reference.as_ref().map_or(0, String::len),
7238            row.document_unresolved_reason
7239                .as_ref()
7240                .map_or(0, String::len),
7241            row.confidence.len(),
7242            row.completeness.len(),
7243        ],
7244        8,
7245    )
7246}
7247
7248/// Sum decoded variable-width values plus fixed scalar widths without overflow.
7249fn decoded_payload_bytes(
7250    lengths: impl IntoIterator<Item = usize>,
7251    fixed_bytes: u64,
7252) -> DbResult<u64> {
7253    let mut decoded = fixed_bytes;
7254    for length in lengths {
7255        let length =
7256            u64::try_from(length).map_err(|_source| GraphContractError::InvalidLimits {
7257                reason: "graph read decoded field length overflowed",
7258            })?;
7259        decoded = decoded
7260            .checked_add(length)
7261            .ok_or(GraphContractError::InvalidLimits {
7262                reason: "graph read decoded row size overflowed",
7263            })?;
7264    }
7265    Ok(decoded)
7266}
7267
7268/// Read one raw relation occurrence row.
7269fn occurrence_row(row: &Row<'_>) -> rusqlite::Result<OccurrenceRow> {
7270    occurrence_row_at(row, 0)
7271}
7272
7273/// Read one raw relation occurrence row at a stable column offset.
7274fn occurrence_row_at(row: &Row<'_>, offset: usize) -> rusqlite::Result<OccurrenceRow> {
7275    Ok(OccurrenceRow {
7276        relation: row.get(offset)?,
7277        file_path: row.get(offset + 1)?,
7278        start_line: row.get(offset + 2)?,
7279        start_column: row.get(offset + 3)?,
7280        end_line: row.get(offset + 4)?,
7281        end_column: row.get(offset + 5)?,
7282    })
7283}
7284
7285/// Count exact occurrence payload bytes, including four fixed span scalars.
7286fn occurrence_row_decoded_bytes(row: &OccurrenceRow) -> DbResult<u64> {
7287    decoded_payload_bytes([row.relation.len(), row.file_path.len()], 32)
7288}
7289
7290/// Read one raw graph coverage row.
7291fn coverage_row(row: &Row<'_>) -> rusqlite::Result<CoverageRow> {
7292    Ok(CoverageRow {
7293        project: row.get(0)?,
7294        scope_kind: row.get(1)?,
7295        scope_path: row.get(2)?,
7296        relation_scope: row.get(3)?,
7297        relation_kind: row.get(4)?,
7298        state: row.get(5)?,
7299        total: row.get(6)?,
7300        covered: row.get(7)?,
7301        omitted: row.get(8)?,
7302        reason: row.get(9)?,
7303        reached_limit: row.get(10)?,
7304        parser: row.get(11)?,
7305        provider: row.get(12)?,
7306    })
7307}
7308
7309/// Count exact coverage payload bytes, including three fixed count scalars.
7310fn coverage_row_decoded_bytes(row: &CoverageRow) -> DbResult<u64> {
7311    decoded_payload_bytes(
7312        [
7313            row.project.len(),
7314            row.scope_kind.len(),
7315            row.scope_path.as_ref().map_or(0, String::len),
7316            row.relation_scope.as_ref().map_or(0, String::len),
7317            row.relation_kind.as_ref().map_or(0, String::len),
7318            row.state.len(),
7319            row.reason.as_ref().map_or(0, String::len),
7320            row.reached_limit.as_ref().map_or(0, String::len),
7321            row.parser.as_ref().map_or(0, String::len),
7322            row.provider.as_ref().map_or(0, String::len),
7323        ],
7324        24,
7325    )
7326}
7327
7328/// Convert a fully collected raw page and validate the sentinel before truncating.
7329fn page_from_raw<Raw, Domain>(
7330    raw: Vec<Raw>,
7331    limit: u32,
7332    mut convert: impl FnMut(Raw) -> DbResult<Domain>,
7333) -> DbResult<RepositoryGraphPage<Domain>> {
7334    let mut rows = raw
7335        .into_iter()
7336        .map(&mut convert)
7337        .collect::<DbResult<Vec<_>>>()?;
7338    let truncated = rows.len() > limit as usize;
7339    if truncated {
7340        rows.pop();
7341    }
7342    Ok(RepositoryGraphPage { rows, truncated })
7343}
7344
7345/// Return an empty graph page when no project graph has been initialized.
7346fn empty_page<T>() -> RepositoryGraphPage<T> {
7347    RepositoryGraphPage {
7348        rows: Vec::new(),
7349        truncated: false,
7350    }
7351}
7352
7353/// Return an empty adjacency page when no selected graph/frontier is available.
7354fn empty_adjacency_page() -> RepositoryGraphAdjacencyPage {
7355    RepositoryGraphAdjacencyPage {
7356        rows: Vec::new(),
7357        truncated: false,
7358        continuation: None,
7359    }
7360}
7361
7362/// Return an empty footprint when no complete selected graph is available.
7363const fn empty_affected_source_footprint() -> RepositoryAffectedSourceFootprint {
7364    RepositoryAffectedSourceFootprint {
7365        rows: 0,
7366        retained_bytes: 0,
7367        truncated: false,
7368    }
7369}
7370
7371/// Validate and convert a requested page size into `LIMIT + 1`.
7372fn validated_limit_plus_one(limit: u32, ceiling: u32, reason: &'static str) -> DbResult<i64> {
7373    if limit == 0 || limit > ceiling {
7374        return Err(projectatlas_core::graph::GraphContractError::InvalidLimits { reason }.into());
7375    }
7376    Ok(i64::from(limit) + 1)
7377}
7378
7379/// Convert a fixed-width normalized BLOB without truncation.
7380fn fixed_bytes<const WIDTH: usize>(field: &'static str, bytes: Vec<u8>) -> DbResult<[u8; WIDTH]> {
7381    let found = bytes.len();
7382    bytes
7383        .try_into()
7384        .map_err(|_bytes| DbError::InvalidBlobLength {
7385            field,
7386            expected: WIDTH,
7387            found,
7388        })
7389}
7390
7391/// Reconstruct a project identity from its normalized binary column.
7392fn project_from_blob(field: &'static str, bytes: Vec<u8>) -> DbResult<ProjectInstanceId> {
7393    ProjectInstanceId::from_bytes(fixed_bytes::<16>(field, bytes)?).map_err(Into::into)
7394}
7395
7396/// Return a required text column or a stable row-shape failure.
7397fn required_text<'value>(
7398    table: &'static str,
7399    reason: &'static str,
7400    value: Option<&'value str>,
7401) -> DbResult<&'value str> {
7402    value.ok_or(DbError::GraphRowShape { table, reason })
7403}
7404
7405/// Convert a nonnegative `SQLite` count to `u64`.
7406fn nonnegative_u64(field: &'static str, value: i64) -> DbResult<u64> {
7407    u64::try_from(value).map_err(|source| DbError::InvalidCount {
7408        field,
7409        value,
7410        source,
7411    })
7412}
7413
7414/// Convert a positive `SQLite` count to `NonZeroU32`.
7415fn positive_u32(field: &'static str, value: i64) -> DbResult<NonZeroU32> {
7416    let value = positive_u32_value(field, value)?;
7417    NonZeroU32::new(value).ok_or(DbError::GraphRowShape {
7418        table: "graph_relations",
7419        reason: "candidate count must be positive",
7420    })
7421}
7422
7423/// Convert a positive `SQLite` integer to `u32`.
7424fn positive_u32_value(field: &'static str, value: i64) -> DbResult<u32> {
7425    let converted = u32::try_from(value).map_err(|source| DbError::InvalidCount {
7426        field,
7427        value,
7428        source,
7429    })?;
7430    if converted == 0 {
7431        return Err(DbError::GraphRowShape {
7432            table: "repository_graph",
7433            reason: "positive integer column contains zero",
7434        });
7435    }
7436    Ok(converted)
7437}
7438
7439/// Convert a nonnegative `SQLite` integer to `u32`.
7440fn nonnegative_u32(field: &'static str, value: i64) -> DbResult<u32> {
7441    u32::try_from(value).map_err(|source| DbError::InvalidCount {
7442        field,
7443        value,
7444        source,
7445    })
7446}
7447
7448/// Convert a domain count to one lossless `SQLite` integer.
7449fn sqlite_count(field: &'static str, value: u64) -> DbResult<i64> {
7450    i64::try_from(value).map_err(|_source| DbError::GraphCountOverflow { field, value })
7451}
7452
7453/// Split one typed relation family into its normalized scope and spelling.
7454const fn relation_parts(relation: GraphRelationKind) -> (&'static str, &'static str) {
7455    match relation {
7456        GraphRelationKind::Legacy(RelationKind::Contains) => ("legacy", "contains"),
7457        GraphRelationKind::Legacy(RelationKind::Imports) => ("legacy", "imports"),
7458        GraphRelationKind::Legacy(RelationKind::Calls) => ("legacy", "calls"),
7459        GraphRelationKind::Legacy(RelationKind::DependsOn) => ("legacy", "depends-on"),
7460        GraphRelationKind::Extended(ExtendedRelationKind::References) => ("extended", "references"),
7461        GraphRelationKind::Extended(ExtendedRelationKind::Tests) => ("extended", "tests"),
7462        GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo) => ("extended", "routes-to"),
7463        GraphRelationKind::Extended(ExtendedRelationKind::Configures) => ("extended", "configures"),
7464        GraphRelationKind::Extended(ExtendedRelationKind::Deploys) => ("extended", "deploys"),
7465        GraphRelationKind::Extended(ExtendedRelationKind::Reads) => ("extended", "reads"),
7466        GraphRelationKind::Extended(ExtendedRelationKind::Writes) => ("extended", "writes"),
7467        GraphRelationKind::Extended(ExtendedRelationKind::Documents) => ("extended", "documents"),
7468    }
7469}
7470
7471/// Parse one normalized relation family without accepting unknown values.
7472fn parse_relation_kind(scope: &str, kind: &str) -> DbResult<GraphRelationKind> {
7473    match (scope, kind) {
7474        ("legacy", "contains") => Ok(GraphRelationKind::Legacy(RelationKind::Contains)),
7475        ("legacy", "imports") => Ok(GraphRelationKind::Legacy(RelationKind::Imports)),
7476        ("legacy", "calls") => Ok(GraphRelationKind::Legacy(RelationKind::Calls)),
7477        ("legacy", "depends-on") => Ok(GraphRelationKind::Legacy(RelationKind::DependsOn)),
7478        ("extended", "references") => Ok(GraphRelationKind::Extended(
7479            ExtendedRelationKind::References,
7480        )),
7481        ("extended", "tests") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Tests)),
7482        ("extended", "routes-to") => {
7483            Ok(GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo))
7484        }
7485        ("extended", "configures") => Ok(GraphRelationKind::Extended(
7486            ExtendedRelationKind::Configures,
7487        )),
7488        ("extended", "deploys") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Deploys)),
7489        ("extended", "reads") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Reads)),
7490        ("extended", "writes") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Writes)),
7491        ("extended", "documents") => {
7492            Ok(GraphRelationKind::Extended(ExtendedRelationKind::Documents))
7493        }
7494        _ => Err(DbError::InvalidEnum {
7495            field: "graph_relations.relation_kind",
7496            value: format!("{scope}:{kind}"),
7497        }),
7498    }
7499}
7500
7501/// Return the normalized symbol-kind spelling.
7502const fn symbol_kind_name(kind: SymbolKind) -> &'static str {
7503    match kind {
7504        SymbolKind::Function => "function",
7505        SymbolKind::Method => "method",
7506        SymbolKind::Class => "class",
7507        SymbolKind::Struct => "struct",
7508        SymbolKind::Enum => "enum",
7509        SymbolKind::Trait => "trait",
7510        SymbolKind::Interface => "interface",
7511        SymbolKind::Module => "module",
7512        SymbolKind::Type => "type",
7513        SymbolKind::Value => "value",
7514        SymbolKind::Import => "import",
7515        SymbolKind::Package => "package",
7516        SymbolKind::Workspace => "workspace",
7517        SymbolKind::Dependency => "dependency",
7518        SymbolKind::Heading => "heading",
7519        SymbolKind::Unknown => "unknown",
7520    }
7521}
7522
7523/// Parse one normalized symbol-kind spelling.
7524fn parse_symbol_kind(value: &str) -> DbResult<SymbolKind> {
7525    match value {
7526        "function" => Ok(SymbolKind::Function),
7527        "method" => Ok(SymbolKind::Method),
7528        "class" => Ok(SymbolKind::Class),
7529        "struct" => Ok(SymbolKind::Struct),
7530        "enum" => Ok(SymbolKind::Enum),
7531        "trait" => Ok(SymbolKind::Trait),
7532        "interface" => Ok(SymbolKind::Interface),
7533        "module" => Ok(SymbolKind::Module),
7534        "type" => Ok(SymbolKind::Type),
7535        "value" => Ok(SymbolKind::Value),
7536        "import" => Ok(SymbolKind::Import),
7537        "package" => Ok(SymbolKind::Package),
7538        "workspace" => Ok(SymbolKind::Workspace),
7539        "dependency" => Ok(SymbolKind::Dependency),
7540        "heading" => Ok(SymbolKind::Heading),
7541        "unknown" => Ok(SymbolKind::Unknown),
7542        _ => Err(DbError::InvalidEnum {
7543            field: "graph_entities.symbol_kind",
7544            value: value.to_string(),
7545        }),
7546    }
7547}
7548
7549/// Return the normalized confidence spelling.
7550const fn confidence_name(confidence: ConfidenceClass) -> &'static str {
7551    match confidence {
7552        ConfidenceClass::Exact => "exact",
7553        ConfidenceClass::High => "high",
7554        ConfidenceClass::Medium => "medium",
7555        ConfidenceClass::Low => "low",
7556    }
7557}
7558
7559/// Parse one normalized confidence spelling.
7560fn parse_confidence(value: &str) -> DbResult<ConfidenceClass> {
7561    match value {
7562        "exact" => Ok(ConfidenceClass::Exact),
7563        "high" => Ok(ConfidenceClass::High),
7564        "medium" => Ok(ConfidenceClass::Medium),
7565        "low" => Ok(ConfidenceClass::Low),
7566        _ => Err(DbError::InvalidEnum {
7567            field: "graph_relations.confidence",
7568            value: value.to_string(),
7569        }),
7570    }
7571}
7572
7573/// Return the normalized completeness spelling.
7574const fn completeness_name(completeness: Completeness) -> &'static str {
7575    match completeness {
7576        Completeness::Complete => "complete",
7577        Completeness::Partial => "partial",
7578    }
7579}
7580
7581/// Parse one normalized completeness spelling.
7582fn parse_completeness(value: &str) -> DbResult<Completeness> {
7583    match value {
7584        "complete" => Ok(Completeness::Complete),
7585        "partial" => Ok(Completeness::Partial),
7586        _ => Err(DbError::InvalidEnum {
7587            field: "graph_relations.completeness",
7588            value: value.to_string(),
7589        }),
7590    }
7591}
7592
7593/// Return normalized coverage scope columns.
7594fn coverage_scope_parts(scope: &CoverageScope) -> (&'static str, Option<&str>) {
7595    match scope {
7596        CoverageScope::Project => ("project", None),
7597        CoverageScope::Path { path } => ("path", Some(path.as_str())),
7598    }
7599}
7600
7601/// Return the normalized coverage lifecycle spelling.
7602const fn coverage_state_name(state: CoverageState) -> &'static str {
7603    match state {
7604        CoverageState::Complete | CoverageState::NoCandidates => "complete",
7605        CoverageState::Partial => "partial",
7606        CoverageState::Failed => "failed",
7607        CoverageState::Ignored => "ignored",
7608        CoverageState::Oversized => "oversized",
7609        CoverageState::Quarantined => "quarantined",
7610        CoverageState::Stale => "stale",
7611    }
7612}
7613
7614/// Parse one normalized coverage lifecycle spelling.
7615pub(crate) fn parse_coverage_state(value: &str) -> DbResult<CoverageState> {
7616    match value {
7617        "complete" => Ok(CoverageState::Complete),
7618        "partial" => Ok(CoverageState::Partial),
7619        "failed" => Ok(CoverageState::Failed),
7620        "ignored" => Ok(CoverageState::Ignored),
7621        "oversized" => Ok(CoverageState::Oversized),
7622        "quarantined" => Ok(CoverageState::Quarantined),
7623        "stale" => Ok(CoverageState::Stale),
7624        _ => Err(DbError::InvalidEnum {
7625            field: "graph_coverage.state",
7626            value: value.to_string(),
7627        }),
7628    }
7629}
7630
7631/// Parse one normalized parser provenance spelling without fallback coercion.
7632fn parse_parser_kind(field: &'static str, value: &str) -> DbResult<ParserKind> {
7633    match value {
7634        "tree-sitter" => Ok(ParserKind::TreeSitter),
7635        "manifest" => Ok(ParserKind::Manifest),
7636        "structural" => Ok(ParserKind::Structural),
7637        "fallback" => Ok(ParserKind::Fallback),
7638        _ => Err(DbError::InvalidEnum {
7639            field,
7640            value: value.to_string(),
7641        }),
7642    }
7643}
7644
7645/// Decode one persisted graph identity field without coercing unknown values.
7646fn parse_graph_identity_field(value: String) -> DbResult<GraphIdentityField> {
7647    match value.as_str() {
7648        "package" => Ok(GraphIdentityField::Package),
7649        "symbol" => Ok(GraphIdentityField::Symbol),
7650        "parent" => Ok(GraphIdentityField::Parent),
7651        "signature" => Ok(GraphIdentityField::Signature),
7652        "relation.source" => Ok(GraphIdentityField::RelationSource),
7653        "relation.target" => Ok(GraphIdentityField::RelationTarget),
7654        "resolution-key" => Ok(GraphIdentityField::ResolutionKey),
7655        _ => Err(DbError::InvalidEnum {
7656            field: "graph_identity_rejections.field",
7657            value,
7658        }),
7659    }
7660}
7661
7662/// Decode one persisted graph identity rejection reason without coercion.
7663fn parse_graph_identity_rejection_reason(value: String) -> DbResult<GraphIdentityRejectionReason> {
7664    match value.as_str() {
7665        "empty" => Ok(GraphIdentityRejectionReason::Empty),
7666        "surrounding-whitespace" => Ok(GraphIdentityRejectionReason::SurroundingWhitespace),
7667        "control-characters" => Ok(GraphIdentityRejectionReason::ControlCharacters),
7668        "oversized" => Ok(GraphIdentityRejectionReason::Oversized),
7669        "reserved-namespace" => Ok(GraphIdentityRejectionReason::ReservedNamespace),
7670        "contract" => Ok(GraphIdentityRejectionReason::Contract),
7671        _ => Err(DbError::InvalidEnum {
7672            field: "graph_identity_rejections.reason",
7673            value,
7674        }),
7675    }
7676}
7677
7678/// Decode one persisted graph identity rejection row with domain validation.
7679fn graph_identity_rejection_from_row(row: &Row<'_>) -> DbResult<GraphIdentityRejection> {
7680    let file_path = row.get::<_, String>(0)?;
7681    let start_line =
7682        u32::try_from(row.get::<_, i64>(1)?).map_err(|_error| DbError::GraphRowShape {
7683            table: "graph_identity_rejections",
7684            reason: "start line does not fit the graph span domain",
7685        })?;
7686    let start_column =
7687        u32::try_from(row.get::<_, i64>(2)?).map_err(|_error| DbError::GraphRowShape {
7688            table: "graph_identity_rejections",
7689            reason: "start column does not fit the graph span domain",
7690        })?;
7691    let end_line =
7692        u32::try_from(row.get::<_, i64>(3)?).map_err(|_error| DbError::GraphRowShape {
7693            table: "graph_identity_rejections",
7694            reason: "end line does not fit the graph span domain",
7695        })?;
7696    let end_column =
7697        u32::try_from(row.get::<_, i64>(4)?).map_err(|_error| DbError::GraphRowShape {
7698            table: "graph_identity_rejections",
7699            reason: "end column does not fit the graph span domain",
7700        })?;
7701    Ok(GraphIdentityRejection {
7702        path: RepositoryNodePath::new(Path::new(&file_path))?,
7703        span: SourceSpan::new(start_line, start_column, end_line, end_column)?,
7704        parser: parse_parser_kind(
7705            "graph_identity_rejections.parser",
7706            &row.get::<_, String>(5)?,
7707        )?,
7708        field: parse_graph_identity_field(row.get(6)?)?,
7709        reason: parse_graph_identity_rejection_reason(row.get(7)?)?,
7710        fact_index: u64::try_from(row.get::<_, i64>(8)?).map_err(|_error| {
7711            DbError::GraphRowShape {
7712                table: "graph_identity_rejections",
7713                reason: "fact index does not fit the graph identity domain",
7714            }
7715        })?,
7716    })
7717}
7718
7719/// Parse one normalized reached-limit spelling.
7720pub(crate) fn parse_limit_kind(value: &str) -> DbResult<GraphLimitKind> {
7721    GraphLimitKind::from_stable_name(value).ok_or_else(|| DbError::InvalidEnum {
7722        field: "graph_coverage.reached_limit",
7723        value: value.to_string(),
7724    })
7725}
7726
7727#[cfg(test)]
7728mod tests {
7729    use super::*;
7730    use crate::IndexedFileText;
7731    use projectatlas_core::symbols::{CodeSymbol, ParserKind, SymbolGraph, SymbolRelation};
7732    use projectatlas_core::{IndexCancellation, Node, NodeKind};
7733    use std::cell::RefCell;
7734    use std::error::Error;
7735    use std::fmt::Debug;
7736    use std::fs;
7737    use std::io;
7738    use std::sync::{
7739        Arc,
7740        atomic::{AtomicUsize, Ordering},
7741    };
7742    use std::time::{Duration, Instant};
7743
7744    thread_local! {
7745        /// Statements executed by the connection currently under test.
7746        static TRACED_STATEMENTS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
7747    }
7748
7749    /// Record one statement executed synchronously by the traced test connection.
7750    fn record_traced_statement(sql: &str) {
7751        TRACED_STATEMENTS.with(|statements| statements.borrow_mut().push(sql.to_string()));
7752    }
7753
7754    /// Execute one database operation while retaining its connection-local SQL trace.
7755    fn trace_statements<T>(
7756        store: &mut AtlasStore,
7757        operation: impl FnOnce(&AtlasStore) -> DbResult<T>,
7758    ) -> Result<(T, Vec<String>), Box<dyn Error>> {
7759        TRACED_STATEMENTS.with(|statements| statements.borrow_mut().clear());
7760        store.connection.trace_v2(
7761            rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT,
7762            Some(record_traced_event),
7763        );
7764        let result = operation(store);
7765        store
7766            .connection
7767            .trace_v2(rusqlite::trace::TraceEventCodes::empty(), None);
7768        let statements =
7769            TRACED_STATEMENTS.with(|statements| std::mem::take(&mut *statements.borrow_mut()));
7770        Ok((result?, statements))
7771    }
7772
7773    /// Adapt `rusqlite` statement trace events to the SQL text collector used by tests.
7774    #[allow(clippy::needless_pass_by_value)]
7775    fn record_traced_event(event: rusqlite::trace::TraceEvent<'_>) {
7776        if let rusqlite::trace::TraceEvent::Stmt(_, sql) = event {
7777            record_traced_statement(sql);
7778        }
7779    }
7780
7781    /// Closed production statement families allowed during detailed relation reads.
7782    #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
7783    enum DetailedRelationTraceStatement {
7784        /// Bound project singleton verification.
7785        ProjectIdentity,
7786        /// Complete publication metadata lookup.
7787        PublicationMetadata,
7788        /// Active normalized-graph generation lookup.
7789        ActiveGraphGeneration,
7790        /// Direction-owned indexed adjacency query.
7791        AdjacencyRelations,
7792        /// Stable-key relation hydration.
7793        RelationsByDigest,
7794        /// Stable-key endpoint entity hydration.
7795        RelationEntities,
7796        /// Per-relation occurrence hydration.
7797        RelationOccurrences,
7798        /// Purpose-owning node hydration.
7799        PurposeOwners,
7800    }
7801
7802    /// Classify one exact production statement emitted by a detailed relation read.
7803    fn classify_detailed_relation_statement(
7804        statement: &str,
7805    ) -> Option<DetailedRelationTraceStatement> {
7806        if statement
7807            .contains("SELECT project_instance_id FROM project_identity WHERE singleton = 1")
7808        {
7809            Some(DetailedRelationTraceStatement::ProjectIdentity)
7810        } else if statement.contains("SELECT state.value, fingerprint.value, generation.value")
7811            && statement.contains("FROM metadata AS state")
7812        {
7813            Some(DetailedRelationTraceStatement::PublicationMetadata)
7814        } else if statement
7815            .contains("SELECT active_generation FROM project_identity WHERE singleton = 1")
7816        {
7817            Some(DetailedRelationTraceStatement::ActiveGraphGeneration)
7818        } else if statement.contains("FROM graph_relations AS relation INDEXED BY")
7819            && statement.contains("idx_graph_relations_source_kind")
7820        {
7821            Some(DetailedRelationTraceStatement::AdjacencyRelations)
7822        } else if statement
7823            .contains("JOIN graph_relations AS relation INDEXED BY idx_graph_relations_project_key")
7824        {
7825            Some(DetailedRelationTraceStatement::RelationsByDigest)
7826        } else if statement.contains("JOIN graph_entities AS entity") {
7827            Some(DetailedRelationTraceStatement::RelationEntities)
7828        } else if statement.contains("FROM graph_relation_occurrences") {
7829            Some(DetailedRelationTraceStatement::RelationOccurrences)
7830        } else if statement.contains("FROM nodes n")
7831            && statement.contains("JOIN purposes p")
7832            && statement.contains("LEFT JOIN summaries s")
7833        {
7834            Some(DetailedRelationTraceStatement::PurposeOwners)
7835        } else {
7836            None
7837        }
7838    }
7839
7840    /// Require the complete traced statement multiset for one production read.
7841    fn require_traced_statement_multiset(
7842        statements: &[String],
7843        expected: &[(DetailedRelationTraceStatement, usize)],
7844        context: &str,
7845    ) -> Result<(), Box<dyn Error>> {
7846        let mut actual = BTreeMap::new();
7847        for statement in statements {
7848            let family = classify_detailed_relation_statement(statement).ok_or_else(|| {
7849                io::Error::other(format!(
7850                    "{context} executed an unclassified production statement: {statement}"
7851                ))
7852            })?;
7853            *actual.entry(family).or_insert(0) += 1;
7854        }
7855        let expected = expected.iter().copied().collect::<BTreeMap<_, _>>();
7856        require_eq(
7857            &actual,
7858            &expected,
7859            &format!("{context} complete statement multiset: {statements:?}"),
7860        )
7861    }
7862
7863    /// Traverse one relation family with the same bounded adjacency primitive used by the service.
7864    fn collect_bounded_outbound_calls(
7865        store: &AtlasStore,
7866        anchor: &GraphEntityKey,
7867    ) -> Result<(Vec<[u8; 32]>, usize, usize), Box<dyn Error>> {
7868        let mut visited = BTreeMap::new();
7869        visited.insert(anchor.digest_bytes()?, anchor.clone());
7870        let mut frontier = vec![anchor.clone()];
7871        let mut inspected_edges = 0_usize;
7872        let mut peak_frontier = frontier.len();
7873        let calls = GraphRelationKind::Legacy(RelationKind::Calls);
7874
7875        while !frontier.is_empty() {
7876            let mut next = BTreeMap::new();
7877            for chunk in frontier.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
7878                let work_per_row = MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS / chunk.len();
7879                let page_limit = work_per_row
7880                    .saturating_sub(1)
7881                    .min(GraphLimits::MAX_ROWS as usize)
7882                    .max(1) as u32;
7883                let mut continuation = None;
7884                loop {
7885                    let page = store.repository_graph_adjacency_page_filtered(
7886                        chunk,
7887                        RepositoryGraphDirection::Outbound,
7888                        Some(calls),
7889                        continuation.as_ref(),
7890                        page_limit,
7891                        None,
7892                    )?;
7893                    inspected_edges = inspected_edges
7894                        .checked_add(page.rows.len())
7895                        .ok_or_else(|| io::Error::other("measured edge count overflowed"))?;
7896                    for row in page.rows {
7897                        if let Some(target) = row.detail.target {
7898                            let digest = target.key().digest_bytes()?;
7899                            if !visited.contains_key(&digest) {
7900                                next.entry(digest).or_insert_with(|| target.key().clone());
7901                            }
7902                        }
7903                    }
7904                    if !page.truncated {
7905                        break;
7906                    }
7907                    continuation = Some(page.continuation.ok_or_else(|| {
7908                        io::Error::other("truncated adjacency page omitted its continuation")
7909                    })?);
7910                }
7911            }
7912            frontier = next.into_values().collect();
7913            peak_frontier = peak_frontier.max(frontier.len());
7914            for key in &frontier {
7915                visited.insert(key.digest_bytes()?, key.clone());
7916            }
7917        }
7918
7919        Ok((
7920            visited.into_keys().collect(),
7921            inspected_edges,
7922            peak_frontier,
7923        ))
7924    }
7925
7926    /// Coherent typed graph fixture used by storage, corruption, and publication tests.
7927    struct GraphFixture {
7928        /// Owning project identity.
7929        project: ProjectInstanceId,
7930        /// Every entity selector variant.
7931        entities: Vec<GraphEntity>,
7932        /// Every relation resolution state.
7933        relations: Vec<LogicalRelation>,
7934        /// Two occurrences for one logical relation.
7935        occurrences: Vec<RelationOccurrence>,
7936        /// Every coverage lifecycle state.
7937        coverage: Vec<CoverageRecord>,
7938    }
7939
7940    /// Maximum production relation/occurrence fixture used by SQL trace tests.
7941    struct DetailedRelationTraceFixture {
7942        /// Owning project identity.
7943        project: ProjectInstanceId,
7944        /// Common source whose unique targets cross entity hydration chunks.
7945        source: GraphEntity,
7946        /// Maximum accepted relation batch.
7947        relations: Vec<LogicalRelation>,
7948    }
7949
7950    /// Canonical keys used by export, ambiguity, and unresolved dependency tests.
7951    struct ResolutionFixture {
7952        /// Published typed graph.
7953        graph: GraphFixture,
7954        /// Key exported by the fixture symbol and consumed by a resolved relation.
7955        resolved: CanonicalResolutionKey,
7956        /// Key consumed by an ambiguous relation.
7957        ambiguous: CanonicalResolutionKey,
7958        /// Key consumed by an unresolved relation.
7959        unresolved: CanonicalResolutionKey,
7960    }
7961
7962    /// Build one complete typed graph for the selected generation.
7963    fn graph_fixture(
7964        project: ProjectInstanceId,
7965        generation: IndexGeneration,
7966    ) -> Result<GraphFixture, Box<dyn Error>> {
7967        let project_entity = GraphEntity::new(project, EntitySelector::Project, generation)?;
7968        let folder = GraphEntity::new(
7969            project,
7970            EntitySelector::Folder {
7971                path: RepositoryNodePath::new(Path::new("src"))?,
7972            },
7973            generation,
7974        )?;
7975        let file = GraphEntity::new(
7976            project,
7977            EntitySelector::File {
7978                path: RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
7979            },
7980            generation,
7981        )?;
7982        let package = GraphEntity::new(
7983            project,
7984            EntitySelector::Package {
7985                package: PackageSelector {
7986                    manager: GraphIdentityText::new("cargo")?,
7987                    name: GraphIdentityText::new("ProjectAtlas")?,
7988                    manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
7989                },
7990            },
7991            generation,
7992        )?;
7993        let symbol = GraphEntity::new(
7994            project,
7995            EntitySelector::Symbol {
7996                symbol: SymbolSelector {
7997                    file: RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
7998                    name: GraphIdentityText::new("verifyToken")?,
7999                    kind: SymbolKind::Function,
8000                    parent: Some(GraphIdentityText::new("Auth")?),
8001                    signature: GraphIdentityText::new("verifyToken(&str)")?,
8002                },
8003            },
8004            generation,
8005        )?;
8006        let external = GraphEntity::new(
8007            project,
8008            EntitySelector::External {
8009                external: ExternalSelector {
8010                    system: GraphIdentityText::new("crates.io")?,
8011                    identity: GraphIdentityText::new("serde@1")?,
8012                },
8013            },
8014            generation,
8015        )?;
8016
8017        let resolved = LogicalRelation::new(
8018            &file,
8019            GraphRelationKind::Legacy(RelationKind::Calls),
8020            RelationResolution::resolved(&symbol)?,
8021            ConfidenceClass::Exact,
8022            Completeness::Complete,
8023            generation,
8024        )?;
8025        let ambiguous = LogicalRelation::new(
8026            &file,
8027            GraphRelationKind::Extended(ExtendedRelationKind::References),
8028            RelationResolution::Ambiguous {
8029                reference: GraphIdentityText::new("Session")?,
8030                candidates: NonZeroU32::new(2)
8031                    .ok_or_else(|| io::Error::other("fixture candidate count is zero"))?,
8032            },
8033            ConfidenceClass::Medium,
8034            Completeness::Partial,
8035            generation,
8036        )?;
8037        let unresolved = LogicalRelation::new(
8038            &file,
8039            GraphRelationKind::Extended(ExtendedRelationKind::Configures),
8040            RelationResolution::Unresolved {
8041                reference: GraphIdentityText::new("AUTH_KEY")?,
8042            },
8043            ConfidenceClass::Low,
8044            Completeness::Partial,
8045            generation,
8046        )?;
8047        let external_relation = LogicalRelation::new(
8048            &file,
8049            GraphRelationKind::Legacy(RelationKind::DependsOn),
8050            RelationResolution::external(&external)?,
8051            ConfidenceClass::High,
8052            Completeness::Complete,
8053            generation,
8054        )?;
8055        let occurrences = vec![
8056            RelationOccurrence::new(
8057                &resolved,
8058                RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
8059                SourceSpan::new(10, 4, 10, 18)?,
8060                generation,
8061            )?,
8062            RelationOccurrence::new(
8063                &resolved,
8064                RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
8065                SourceSpan::new(22, 2, 22, 16)?,
8066                generation,
8067            )?,
8068        ];
8069        let coverage = vec![
8070            CoverageRecord::new(
8071                CoverageScope::Project,
8072                None,
8073                CoverageState::Complete,
8074                4,
8075                0,
8076                generation,
8077                None,
8078                None,
8079            )?,
8080            CoverageRecord::new(
8081                CoverageScope::Project,
8082                Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
8083                CoverageState::NoCandidates,
8084                0,
8085                0,
8086                generation,
8087                None,
8088                None,
8089            )?,
8090            CoverageRecord::new(
8091                CoverageScope::Project,
8092                Some(GraphRelationKind::Extended(ExtendedRelationKind::Tests)),
8093                CoverageState::Complete,
8094                0,
8095                0,
8096                generation,
8097                None,
8098                None,
8099            )?,
8100            CoverageRecord::new(
8101                CoverageScope::Path {
8102                    path: RepositoryNodePath::new(Path::new("src"))?,
8103                },
8104                None,
8105                CoverageState::Partial,
8106                3,
8107                1,
8108                generation,
8109                Some(GraphIdentityText::new("one parser region omitted")?),
8110                Some(GraphLimitKind::Rows),
8111            )?,
8112            incomplete_coverage(
8113                GraphRelationKind::Legacy(RelationKind::Calls),
8114                CoverageState::Failed,
8115                "parser failed",
8116                generation,
8117            )?,
8118            incomplete_coverage(
8119                GraphRelationKind::Legacy(RelationKind::Imports),
8120                CoverageState::Ignored,
8121                "ignored by policy",
8122                generation,
8123            )?,
8124            incomplete_coverage(
8125                GraphRelationKind::Legacy(RelationKind::Contains),
8126                CoverageState::Oversized,
8127                "file exceeded limit",
8128                generation,
8129            )?,
8130            incomplete_coverage(
8131                GraphRelationKind::Legacy(RelationKind::DependsOn),
8132                CoverageState::Quarantined,
8133                "provider quarantined",
8134                generation,
8135            )?,
8136            incomplete_coverage(
8137                GraphRelationKind::Extended(ExtendedRelationKind::References),
8138                CoverageState::Stale,
8139                "source changed",
8140                generation,
8141            )?,
8142        ];
8143        Ok(GraphFixture {
8144            project,
8145            entities: vec![project_entity, folder, file, package, symbol, external],
8146            relations: vec![resolved, ambiguous, unresolved, external_relation],
8147            occurrences,
8148            coverage,
8149        })
8150    }
8151
8152    /// Construct one project-qualified declaration resolution key.
8153    fn declaration_key(
8154        project: ProjectInstanceId,
8155        identity: &str,
8156        relation: GraphRelationKind,
8157    ) -> Result<CanonicalResolutionKey, Box<dyn Error>> {
8158        Ok(CanonicalResolutionKey::new(
8159            project,
8160            ResolutionKeyDomain::Declaration,
8161            &GraphIdentityText::new("tree-sitter")?,
8162            &GraphIdentityText::new("rust")?,
8163            None,
8164            Some(&GraphIdentityText::new("crate")?),
8165            Some(relation),
8166            &GraphIdentityText::new(identity)?,
8167        ))
8168    }
8169
8170    /// Publish one graph with duplicate-safe export and all dependency states.
8171    fn publish_resolution_fixture(
8172        store: &mut AtlasStore,
8173        fingerprint: &str,
8174    ) -> Result<ResolutionFixture, Box<dyn Error>> {
8175        let project = store
8176            .project_instance_id()?
8177            .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
8178        let graph = graph_fixture(project, IndexGeneration::new(1))?;
8179        let resolved = declaration_key(
8180            project,
8181            "verifyToken",
8182            GraphRelationKind::Legacy(RelationKind::Calls),
8183        )?;
8184        let ambiguous = declaration_key(
8185            project,
8186            "Session",
8187            GraphRelationKind::Extended(ExtendedRelationKind::References),
8188        )?;
8189        let unresolved = declaration_key(
8190            project,
8191            "AUTH_KEY",
8192            GraphRelationKind::Extended(ExtendedRelationKind::Configures),
8193        )?;
8194        let export = EntityResolutionKey::new(graph.entities[4].key().clone(), resolved.clone())?;
8195        let exports = vec![export.clone(), export];
8196        let dependencies = vec![
8197            RelationDependencyKey::new(graph.relations[0].key().clone(), resolved.clone())?,
8198            RelationDependencyKey::new(graph.relations[1].key().clone(), ambiguous.clone())?,
8199            RelationDependencyKey::new(graph.relations[2].key().clone(), unresolved.clone())?,
8200        ];
8201        let mut publication = store.begin_index_publication(fingerprint)?;
8202        publication.begin_scan_replacement()?;
8203        publication.upsert_scan_node_batch(&[
8204            graph_node(".", NodeKind::Folder, None),
8205            graph_node("src", NodeKind::Folder, Some(".")),
8206            graph_node("src/Äuth.rs", NodeKind::File, Some("src")),
8207            graph_node("Cargo.toml", NodeKind::File, Some(".")),
8208        ])?;
8209        publication.finish_scan_replacement()?;
8210        publication.replace_repository_graph_with_resolution_keys(
8211            graph.project,
8212            &graph.entities,
8213            &graph.relations,
8214            &graph.occurrences,
8215            &graph.coverage,
8216            &exports,
8217            &dependencies,
8218        )?;
8219        publication.complete()?;
8220        Ok(ResolutionFixture {
8221            graph,
8222            resolved,
8223            ambiguous,
8224            unresolved,
8225        })
8226    }
8227
8228    /// Replace one source-owned parser graph with a requested symbol degree.
8229    fn replace_fixture_symbol_rows(
8230        store: &mut AtlasStore,
8231        path: &str,
8232        symbol_count: usize,
8233    ) -> Result<(), Box<dyn Error>> {
8234        let symbols = (0..symbol_count)
8235            .map(|index| CodeSymbol {
8236                path: path.to_string(),
8237                language: Some("rust".to_string()),
8238                name: format!("symbol_{index}"),
8239                kind: SymbolKind::Function,
8240                signature: format!("fn symbol_{index}()"),
8241                exported: index == 0,
8242                documentation: None,
8243                line_start: index + 1,
8244                line_end: index + 1,
8245                source_selector: None,
8246                parent: None,
8247                parser: ParserKind::TreeSitter,
8248                detail: Some("function_item".to_string()),
8249            })
8250            .collect();
8251        store.replace_symbol_graph(&SymbolGraph {
8252            path: path.to_string(),
8253            language: Some("rust".to_string()),
8254            parser: ParserKind::TreeSitter,
8255            symbols,
8256            relations: vec![SymbolRelation {
8257                path: path.to_string(),
8258                source_name: "symbol_0".to_string(),
8259                target_name: "dependency".to_string(),
8260                kind: RelationKind::Calls,
8261                line: 1,
8262                context: "dependency()".to_string(),
8263                parser: ParserKind::TreeSitter,
8264            }],
8265        })?;
8266        Ok(())
8267    }
8268
8269    /// Construct one non-complete project-wide coverage row.
8270    fn incomplete_coverage(
8271        relation: GraphRelationKind,
8272        state: CoverageState,
8273        reason: &str,
8274        generation: IndexGeneration,
8275    ) -> Result<CoverageRecord, Box<dyn Error>> {
8276        Ok(CoverageRecord::new(
8277            CoverageScope::Project,
8278            Some(relation),
8279            state,
8280            0,
8281            1,
8282            generation,
8283            Some(GraphIdentityText::new(reason)?),
8284            None,
8285        )?)
8286    }
8287
8288    /// Construct one local-source node for graph publication fixtures.
8289    fn graph_node(path: &str, kind: NodeKind, parent_path: Option<&str>) -> Node {
8290        Node {
8291            path: path.to_string(),
8292            kind,
8293            parent_path: parent_path.map(str::to_string),
8294            extension: None,
8295            language: None,
8296            size_bytes: None,
8297            mtime_ns: None,
8298            content_hash: None,
8299        }
8300    }
8301
8302    /// Persisted all-family fixture for folder/file navigation enrichment.
8303    struct NavigationFixture {
8304        /// Selected project identity.
8305        project: ProjectInstanceId,
8306        /// Exact source path used by file-level assertions.
8307        api_path: String,
8308        /// Exact manifest path used by package-ownership assertions.
8309        manifest_path: String,
8310    }
8311
8312    /// Publish every navigation family plus skewed inbound call context.
8313    fn publish_navigation_fixture(
8314        store: &mut AtlasStore,
8315        fingerprint: &str,
8316    ) -> Result<NavigationFixture, Box<dyn Error>> {
8317        let project = store
8318            .project_instance_id()?
8319            .ok_or_else(|| io::Error::other("navigation fixture identity is missing"))?;
8320        let generation = IndexGeneration::new(1);
8321        let api_path = "src/auth/api.rs".to_string();
8322        let manifest_path = "Cargo.toml".to_string();
8323        let mut nodes = vec![
8324            graph_node(".", NodeKind::Folder, None),
8325            graph_node("src", NodeKind::Folder, Some(".")),
8326            graph_node("src/auth", NodeKind::Folder, Some("src")),
8327            graph_node(&api_path, NodeKind::File, Some("src/auth")),
8328            graph_node("src/auth/caller.rs", NodeKind::File, Some("src/auth")),
8329            graph_node("src/other.rs", NodeKind::File, Some("src")),
8330            graph_node("src/authz.rs", NodeKind::File, Some("src")),
8331            graph_node("tests", NodeKind::Folder, Some(".")),
8332            graph_node("tests/api_test.rs", NodeKind::File, Some("tests")),
8333            graph_node("clients", NodeKind::Folder, Some(".")),
8334            graph_node(&manifest_path, NodeKind::File, Some(".")),
8335        ];
8336        let api = GraphEntity::new(
8337            project,
8338            EntitySelector::File {
8339                path: RepositoryFilePath::new(Path::new(&api_path))?,
8340            },
8341            generation,
8342        )?;
8343        let internal_caller = GraphEntity::new(
8344            project,
8345            EntitySelector::File {
8346                path: RepositoryFilePath::new(Path::new("src/auth/caller.rs"))?,
8347            },
8348            generation,
8349        )?;
8350        let other = GraphEntity::new(
8351            project,
8352            EntitySelector::File {
8353                path: RepositoryFilePath::new(Path::new("src/other.rs"))?,
8354            },
8355            generation,
8356        )?;
8357        let sibling = GraphEntity::new(
8358            project,
8359            EntitySelector::File {
8360                path: RepositoryFilePath::new(Path::new("src/authz.rs"))?,
8361            },
8362            generation,
8363        )?;
8364        let test = GraphEntity::new(
8365            project,
8366            EntitySelector::File {
8367                path: RepositoryFilePath::new(Path::new("tests/api_test.rs"))?,
8368            },
8369            generation,
8370        )?;
8371        let package = GraphEntity::new(
8372            project,
8373            EntitySelector::Package {
8374                package: PackageSelector {
8375                    manager: GraphIdentityText::new("cargo")?,
8376                    name: GraphIdentityText::new("projectatlas-navigation")?,
8377                    manifest: RepositoryFilePath::new(Path::new(&manifest_path))?,
8378                },
8379            },
8380            generation,
8381        )?;
8382        let mut entities = vec![
8383            api.clone(),
8384            internal_caller.clone(),
8385            other.clone(),
8386            sibling.clone(),
8387            test.clone(),
8388            package.clone(),
8389        ];
8390        let mut relations = vec![
8391            LogicalRelation::new(
8392                &api,
8393                GraphRelationKind::Legacy(RelationKind::DependsOn),
8394                RelationResolution::resolved(&package)?,
8395                ConfidenceClass::Exact,
8396                Completeness::Complete,
8397                generation,
8398            )?,
8399            LogicalRelation::new(
8400                &api,
8401                GraphRelationKind::Legacy(RelationKind::Imports),
8402                RelationResolution::resolved(&other)?,
8403                ConfidenceClass::Exact,
8404                Completeness::Complete,
8405                generation,
8406            )?,
8407            LogicalRelation::new(
8408                &api,
8409                GraphRelationKind::Legacy(RelationKind::Calls),
8410                RelationResolution::resolved(&other)?,
8411                ConfidenceClass::Exact,
8412                Completeness::Complete,
8413                generation,
8414            )?,
8415            LogicalRelation::new(
8416                &api,
8417                GraphRelationKind::Extended(ExtendedRelationKind::References),
8418                RelationResolution::Unresolved {
8419                    reference: GraphIdentityText::new("SessionStore")?,
8420                },
8421                ConfidenceClass::High,
8422                Completeness::Partial,
8423                generation,
8424            )?,
8425            LogicalRelation::new(
8426                &test,
8427                GraphRelationKind::Extended(ExtendedRelationKind::Tests),
8428                RelationResolution::resolved(&api)?,
8429                ConfidenceClass::Exact,
8430                Completeness::Complete,
8431                generation,
8432            )?,
8433            LogicalRelation::new(
8434                &api,
8435                GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo),
8436                RelationResolution::resolved(&other)?,
8437                ConfidenceClass::High,
8438                Completeness::Complete,
8439                generation,
8440            )?,
8441            LogicalRelation::new(
8442                &api,
8443                GraphRelationKind::Extended(ExtendedRelationKind::Configures),
8444                RelationResolution::Unresolved {
8445                    reference: GraphIdentityText::new("AUTH_MODE")?,
8446                },
8447                ConfidenceClass::High,
8448                Completeness::Partial,
8449                generation,
8450            )?,
8451            LogicalRelation::new(
8452                &internal_caller,
8453                GraphRelationKind::Legacy(RelationKind::Calls),
8454                RelationResolution::resolved(&api)?,
8455                ConfidenceClass::Exact,
8456                Completeness::Complete,
8457                generation,
8458            )?,
8459            LogicalRelation::new(
8460                &sibling,
8461                GraphRelationKind::Legacy(RelationKind::Imports),
8462                RelationResolution::resolved(&other)?,
8463                ConfidenceClass::Exact,
8464                Completeness::Complete,
8465                generation,
8466            )?,
8467        ];
8468        for index in 0..4 {
8469            let path = format!("clients/caller-{index}.rs");
8470            nodes.push(graph_node(&path, NodeKind::File, Some("clients")));
8471            let caller = GraphEntity::new(
8472                project,
8473                EntitySelector::File {
8474                    path: RepositoryFilePath::new(Path::new(&path))?,
8475                },
8476                generation,
8477            )?;
8478            relations.push(LogicalRelation::new(
8479                &caller,
8480                GraphRelationKind::Legacy(RelationKind::Calls),
8481                RelationResolution::resolved(&api)?,
8482                ConfidenceClass::Exact,
8483                Completeness::Complete,
8484                generation,
8485            )?);
8486            entities.push(caller);
8487        }
8488
8489        let mut publication = store.begin_index_publication(fingerprint)?;
8490        publication.begin_scan_replacement()?;
8491        publication.upsert_scan_node_batch(&nodes)?;
8492        publication.finish_scan_replacement()?;
8493        publication.replace_repository_graph(project, &entities, &relations, &[], &[])?;
8494        publication.complete()?;
8495        Ok(NavigationFixture {
8496            project,
8497            api_path,
8498            manifest_path,
8499        })
8500    }
8501
8502    /// Return a test failure without relying on panic-only assertions.
8503    fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
8504        if condition {
8505            Ok(())
8506        } else {
8507            Err(io::Error::other(message.to_string()).into())
8508        }
8509    }
8510
8511    /// Compare values while preserving useful failure context in fallible tests.
8512    fn require_eq<T: Debug + PartialEq>(
8513        actual: &T,
8514        expected: &T,
8515        context: &str,
8516    ) -> Result<(), Box<dyn Error>> {
8517        require(
8518            actual == expected,
8519            &format!("{context}: expected {expected:?}, found {actual:?}"),
8520        )
8521    }
8522
8523    /// Require a database operation to fail and return its typed error.
8524    fn require_db_error<T>(result: DbResult<T>, message: &str) -> Result<DbError, Box<dyn Error>> {
8525        let Err(error) = result else {
8526            return Err(io::Error::other(message.to_string()).into());
8527        };
8528        Ok(error)
8529    }
8530
8531    /// Prove cursor hydration seeks through stable-key indexes.
8532    fn assert_cursor_hydration_indexes(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
8533        let project = store
8534            .project_instance_id()?
8535            .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
8536        let cases = [
8537            (
8538                "entity cursor hydration",
8539                graph_entity_hydration_sql(2),
8540                vec![
8541                    Value::Blob(vec![0; 32]),
8542                    Value::Blob(vec![1; 32]),
8543                    Value::Blob(project.as_bytes().to_vec()),
8544                ],
8545                "project_instance_id=? AND entity_key=?",
8546                "SCAN entity",
8547                false,
8548            ),
8549            (
8550                "relation cursor hydration",
8551                graph_relation_hydration_sql(2),
8552                vec![
8553                    Value::Blob(vec![0; 32]),
8554                    Value::Blob(vec![1; 32]),
8555                    Value::Blob(project.as_bytes().to_vec()),
8556                ],
8557                "idx_graph_relations_project_key",
8558                "SCAN relation",
8559                false,
8560            ),
8561            (
8562                "batched occurrence hydration",
8563                occurrence_pages_sql(2),
8564                vec![
8565                    Value::Blob(vec![0; 32]),
8566                    Value::Integer(2),
8567                    Value::Blob(vec![1; 32]),
8568                    Value::Integer(2),
8569                ],
8570                "sqlite_autoindex_graph_relation_occurrences_1",
8571                "SCAN graph_relation_occurrences",
8572                true,
8573            ),
8574            (
8575                "batched path coverage hydration",
8576                path_coverage_sql(2),
8577                vec![
8578                    Value::Blob(project.as_bytes().to_vec()),
8579                    Value::Text("path".to_string()),
8580                    Value::Text("Cargo.toml".to_string()),
8581                    Value::Text("src/Äuth.rs".to_string()),
8582                    Value::Integer(i64::from(GraphLimits::MAX_ROWS) + 1),
8583                ],
8584                "idx_graph_coverage_scope_order",
8585                "SCAN graph_coverage",
8586                false,
8587            ),
8588        ];
8589        for (context, sql, bindings, required_plan, forbidden_scan, allow_bounded_sort) in cases {
8590            let mut statement = store
8591                .connection
8592                .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
8593            let details = statement
8594                .query_map(params_from_iter(bindings.iter()), |row| {
8595                    row.get::<_, String>(3)
8596                })?
8597                .collect::<Result<Vec<_>, _>>()?;
8598            require(
8599                details.iter().any(|detail| detail.contains(required_plan))
8600                    && details.iter().all(|detail| {
8601                        !detail.contains(forbidden_scan)
8602                            && (allow_bounded_sort || !detail.contains("USE TEMP B-TREE"))
8603                    }),
8604                &format!(
8605                    "{context} did not use {required_plan} without a scan or sort: {details:?}"
8606                ),
8607            )?;
8608        }
8609        Ok(())
8610    }
8611
8612    /// Prove each normal graph query shape enters through its owning index.
8613    fn assert_query_indexes(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
8614        let cases: &[(&str, &str, &[&str])] = &[
8615            (
8616                "entity path lookup",
8617                "EXPLAIN QUERY PLAN
8618                 SELECT entity_key FROM graph_entities
8619                  WHERE project_instance_id = zeroblob(16)
8620                    AND repository_path = 'src/Äuth.rs'
8621                  ORDER BY entity_kind, entity_key
8622                  LIMIT 11",
8623                &["idx_graph_entities_path"],
8624            ),
8625            (
8626                "outbound relation lookup",
8627                "EXPLAIN QUERY PLAN
8628                 SELECT relation_key FROM graph_relations
8629                  WHERE source_entity_key = zeroblob(32)
8630                  ORDER BY relation_scope, relation_kind, relation_key
8631                  LIMIT 11",
8632                &["idx_graph_relations_source_kind"],
8633            ),
8634            (
8635                "inbound relation lookup",
8636                "EXPLAIN QUERY PLAN
8637                 SELECT relation_key FROM graph_relations
8638                  WHERE target_entity_key = zeroblob(32)
8639                  ORDER BY relation_scope, relation_kind, relation_key
8640                  LIMIT 11",
8641                &["idx_graph_relations_target_kind"],
8642            ),
8643            (
8644                "inbound documents lookup",
8645                "EXPLAIN QUERY PLAN
8646                 SELECT relation_key FROM graph_relations
8647                  WHERE target_entity_key = zeroblob(32)
8648                    AND relation_scope = 'extended'
8649                    AND relation_kind = 'documents'
8650                  ORDER BY relation_scope, relation_kind, relation_key
8651                  LIMIT 11",
8652                &["idx_graph_relations_target_kind"],
8653            ),
8654            (
8655                "relation family lookup",
8656                "EXPLAIN QUERY PLAN
8657                 SELECT relation_key FROM graph_relations
8658                  WHERE project_instance_id = zeroblob(16)
8659                    AND relation_scope = 'legacy'
8660                    AND relation_kind = 'calls'
8661                  ORDER BY relation_key
8662                  LIMIT 11",
8663                &["idx_graph_relations_kind_order"],
8664            ),
8665            (
8666                "relation occurrence lookup",
8667                "EXPLAIN QUERY PLAN
8668                 SELECT file_path FROM graph_relation_occurrences
8669                  WHERE relation_key = zeroblob(32)
8670                  ORDER BY file_path, start_line, start_column, end_line, end_column
8671                  LIMIT 11",
8672                &["sqlite_autoindex_graph_relation_occurrences_1"],
8673            ),
8674            (
8675                "occurrence path invalidation",
8676                "EXPLAIN QUERY PLAN
8677                 SELECT relation_key FROM graph_relation_occurrences
8678                  WHERE file_path = 'src'
8679                     OR (file_path >= 'src/' AND file_path < 'src0')",
8680                &["idx_graph_occurrences_file_span"],
8681            ),
8682            (
8683                "coverage path invalidation",
8684                "EXPLAIN QUERY PLAN
8685                 SELECT id FROM graph_coverage
8686                        INDEXED BY idx_graph_coverage_path
8687                  WHERE scope_kind = 'path'
8688                    AND (scope_path = 'src'
8689                     OR (scope_path >= 'src/' AND scope_path < 'src0'))",
8690                &["idx_graph_coverage_path"],
8691            ),
8692            (
8693                "entity repository-path invalidation",
8694                "EXPLAIN QUERY PLAN
8695                 SELECT entity_key FROM graph_entities
8696                  WHERE repository_path = 'src'
8697                     OR (repository_path >= 'src/' AND repository_path < 'src0')",
8698                &["idx_graph_entities_path"],
8699            ),
8700            (
8701                "entity manifest-path invalidation",
8702                "EXPLAIN QUERY PLAN
8703                 SELECT entity_key FROM graph_entities
8704                  WHERE manifest_path = 'src'
8705                     OR (manifest_path >= 'src/' AND manifest_path < 'src0')",
8706                &["idx_graph_entities_manifest_path"],
8707            ),
8708            (
8709                "outbound external cleanup candidate",
8710                "EXPLAIN QUERY PLAN
8711                 SELECT relation.target_entity_key
8712                   FROM graph_relations AS relation
8713                        INDEXED BY idx_graph_relations_source_kind
8714                   JOIN graph_entities AS external
8715                     ON external.entity_key = relation.target_entity_key
8716                  WHERE relation.source_entity_key = zeroblob(32)
8717                    AND external.entity_kind = 'external'",
8718                &["idx_graph_relations_source_kind"],
8719            ),
8720            (
8721                "inbound external cleanup candidate",
8722                "EXPLAIN QUERY PLAN
8723                 SELECT relation.source_entity_key
8724                   FROM graph_relations AS relation
8725                        INDEXED BY idx_graph_relations_target_kind
8726                   JOIN graph_entities AS external
8727                     ON external.entity_key = relation.source_entity_key
8728                  WHERE relation.target_entity_key = zeroblob(32)
8729                    AND external.entity_kind = 'external'",
8730                &["idx_graph_relations_target_kind"],
8731            ),
8732            (
8733                "candidate-bounded external cleanup",
8734                "EXPLAIN QUERY PLAN
8735                 DELETE FROM graph_entities
8736                  WHERE entity_key = zeroblob(32) AND entity_kind = 'external'
8737                    AND NOT EXISTS (
8738                        SELECT 1 FROM graph_relations
8739                               INDEXED BY idx_graph_relations_source_kind
8740                         WHERE source_entity_key = zeroblob(32)
8741                    )
8742                    AND NOT EXISTS (
8743                        SELECT 1 FROM graph_relations
8744                               INDEXED BY idx_graph_relations_target_kind
8745                         WHERE target_entity_key = zeroblob(32)
8746                    )",
8747                &[
8748                    "sqlite_autoindex_graph_entities_1",
8749                    "idx_graph_relations_source_kind",
8750                    "idx_graph_relations_target_kind",
8751                ],
8752            ),
8753            (
8754                "coverage scope lookup",
8755                "EXPLAIN QUERY PLAN
8756                 SELECT id FROM graph_coverage
8757                  WHERE project_instance_id = zeroblob(16)
8758                    AND scope_kind = 'path'
8759                    AND scope_path IS 'src'
8760                  ORDER BY relation_scope, relation_kind, state, id
8761                  LIMIT 11",
8762                &["idx_graph_coverage_scope_order"],
8763            ),
8764            (
8765                "resolution witness lookup",
8766                "EXPLAIN QUERY PLAN
8767                 SELECT canonical_identity FROM graph_resolution_keys
8768                  WHERE project_instance_id = zeroblob(16)
8769                    AND resolution_domain = 'declaration'
8770                    AND key_digest = zeroblob(32)",
8771                &["sqlite_autoindex_graph_resolution_keys_1"],
8772            ),
8773            (
8774                "resolution export lookup",
8775                "EXPLAIN QUERY PLAN
8776                 SELECT entity_key FROM graph_entity_exports
8777                  WHERE project_instance_id = zeroblob(16)
8778                    AND resolution_domain = 'declaration'
8779                    AND key_digest = zeroblob(32)
8780                  ORDER BY entity_key",
8781                &["idx_graph_entity_exports_key"],
8782            ),
8783            (
8784                "resolution export owner lookup",
8785                "EXPLAIN QUERY PLAN
8786                 SELECT resolution_domain, key_digest, entity_key
8787                   FROM graph_entity_exports
8788                  WHERE project_instance_id = zeroblob(16)
8789                    AND owner_path = 'src/Äuth.rs'
8790                  ORDER BY resolution_domain, key_digest, entity_key",
8791                &["idx_graph_entity_exports_owner"],
8792            ),
8793            (
8794                "resolution dependency lookup",
8795                "EXPLAIN QUERY PLAN
8796                 SELECT owner_path, relation_key FROM graph_relation_dependencies
8797                  WHERE project_instance_id = zeroblob(16)
8798                    AND resolution_domain = 'declaration'
8799                    AND key_digest = zeroblob(32)
8800                  ORDER BY owner_path, relation_key",
8801                &["idx_graph_relation_dependencies_key"],
8802            ),
8803            (
8804                "resolution dependency owner lookup",
8805                "EXPLAIN QUERY PLAN
8806                 SELECT resolution_domain, key_digest, relation_key
8807                   FROM graph_relation_dependencies
8808                  WHERE project_instance_id = zeroblob(16)
8809                    AND owner_path = 'src/Äuth.rs'
8810                  ORDER BY resolution_domain, key_digest, relation_key",
8811                &["idx_graph_relation_dependencies_owner"],
8812            ),
8813            (
8814                "relation composite integrity lookup",
8815                "EXPLAIN QUERY PLAN
8816                 SELECT relation_key FROM graph_relations
8817                  WHERE project_instance_id = zeroblob(16)
8818                    AND relation_key = zeroblob(32)",
8819                &["idx_graph_relations_project_key"],
8820            ),
8821            (
8822                "document unresolved reason validation",
8823                "EXPLAIN QUERY PLAN
8824                 SELECT EXISTS(
8825                     SELECT 1
8826                       FROM graph_relations
8827                            INDEXED BY idx_graph_relations_project_key
8828                      WHERE relation_key = zeroblob(32)
8829                        AND project_instance_id = zeroblob(16)
8830                        AND relation_scope = 'extended'
8831                        AND relation_kind = 'documents'
8832                        AND resolution_status = 'unresolved'
8833                 )",
8834                &["idx_graph_relations_project_key"],
8835            ),
8836        ];
8837
8838        for (context, sql, required_indexes) in cases {
8839            let mut statement = store.connection.prepare(sql)?;
8840            let details = statement
8841                .query_map([], |row| row.get::<_, String>(3))?
8842                .collect::<Result<Vec<_>, _>>()?;
8843            require(
8844                required_indexes
8845                    .iter()
8846                    .all(|index| details.iter().any(|detail| detail.contains(index))),
8847                &format!("{context} did not use {required_indexes:?}; query plan was {details:?}"),
8848            )?;
8849            require(
8850                details.iter().all(|detail| {
8851                    !detail.contains("SCAN graph_") && !detail.contains("USE TEMP B-TREE")
8852                }),
8853                &format!("{context} was not bounded by index order: {details:?}"),
8854            )?;
8855        }
8856
8857        for key_count in [2, EXTERNAL_CANDIDATE_KEYS_PER_QUERY] {
8858            let sql = format!(
8859                "EXPLAIN QUERY PLAN {}",
8860                external_candidate_batch_sql(key_count)
8861            );
8862            let bindings = (0..key_count)
8863                .map(|_| Value::Blob(vec![0_u8; 32]))
8864                .collect::<Vec<_>>();
8865            let mut statement = store.connection.prepare(&sql)?;
8866            let details = statement
8867                .query_map(params_from_iter(bindings.iter()), |row| {
8868                    row.get::<_, String>(3)
8869                })?
8870                .collect::<Result<Vec<_>, _>>()?;
8871            require(
8872                [
8873                    "SEARCH relation USING INDEX idx_graph_relations_source_kind (source_entity_key=?)",
8874                    "SEARCH relation USING INDEX idx_graph_relations_target_kind (target_entity_key=?)",
8875                ]
8876                .iter()
8877                .all(|seek| details.iter().any(|detail| detail.contains(seek)))
8878                    && details.iter().all(|detail| {
8879                        !detail.contains("SCAN relation")
8880                            && !detail.contains("SCAN graph_relations")
8881                            && !detail.contains("USE TEMP B-TREE")
8882                    }),
8883                &format!(
8884                    "{key_count}-key external cleanup batch was not driven by indexed point seeks: {details:?}"
8885                ),
8886            )?;
8887        }
8888        Ok(())
8889    }
8890
8891    /// Require one coverage-discovery query shape to seek through its owning indexes.
8892    fn assert_coverage_discovery_plan(
8893        connection: &Connection,
8894        sql: &str,
8895        values: &[Value],
8896        required_indexes: &[&str],
8897        allow_bounded_partial_sort: bool,
8898        context: &str,
8899    ) -> Result<(), Box<dyn Error>> {
8900        let mut statement = connection.prepare(sql)?;
8901        let details = statement
8902            .query_map(params_from_iter(values.iter()), |row| {
8903                row.get::<_, String>(3)
8904            })?
8905            .collect::<Result<Vec<_>, _>>()?;
8906        require(
8907            required_indexes
8908                .iter()
8909                .all(|index| details.iter().any(|detail| detail.contains(index))),
8910            &format!("{context} did not use {required_indexes:?}; query plan was {details:?}"),
8911        )?;
8912        require(
8913            details.iter().all(|detail| {
8914                let uses_temporary_sort = detail.contains("USE TEMP B-TREE");
8915                let bounded_partial_sort = detail.contains("USE TEMP B-TREE FOR LAST");
8916                !detail.contains("SCAN coverage")
8917                    && (!uses_temporary_sort
8918                        || (allow_bounded_partial_sort && bounded_partial_sort))
8919            }),
8920            &format!("{context} used an unbounded scan or sort: {details:?}"),
8921        )?;
8922        Ok(())
8923    }
8924
8925    /// Publish the maximum relation batch with two occurrences per unique target.
8926    fn publish_detailed_relation_trace_fixture(
8927        store: &mut AtlasStore,
8928        fingerprint: &str,
8929    ) -> Result<DetailedRelationTraceFixture, Box<dyn Error>> {
8930        let project = store
8931            .project_instance_id()?
8932            .ok_or_else(|| io::Error::other("bound trace fixture identity is missing"))?;
8933        let generation = IndexGeneration::new(1);
8934        let source_path = RepositoryFilePath::new(Path::new("src/trace-source.rs"))?;
8935        let source = GraphEntity::new(
8936            project,
8937            EntitySelector::File {
8938                path: source_path.clone(),
8939            },
8940            generation,
8941        )?;
8942        let mut entities = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER + 1);
8943        let mut relations = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER);
8944        let mut occurrences = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER * 2);
8945        entities.push(source.clone());
8946        for index in 0..MAX_REPOSITORY_GRAPH_FRONTIER {
8947            let name = format!("trace_target_{index:03}");
8948            let target = GraphEntity::new(
8949                project,
8950                EntitySelector::Symbol {
8951                    symbol: SymbolSelector {
8952                        file: source_path.clone(),
8953                        name: GraphIdentityText::new(&name)?,
8954                        kind: SymbolKind::Function,
8955                        parent: None,
8956                        signature: GraphIdentityText::new(format!("{name}()"))?,
8957                    },
8958                },
8959                generation,
8960            )?;
8961            let relation = LogicalRelation::new(
8962                &source,
8963                GraphRelationKind::Legacy(RelationKind::Calls),
8964                RelationResolution::resolved(&target)?,
8965                ConfidenceClass::Exact,
8966                Completeness::Complete,
8967                generation,
8968            )?;
8969            let first_line = u32::try_from(index * 2 + 1)?;
8970            let second_line = first_line + 1;
8971            occurrences.push(RelationOccurrence::new(
8972                &relation,
8973                source_path.clone(),
8974                SourceSpan::new(first_line, 1, first_line, 2)?,
8975                generation,
8976            )?);
8977            occurrences.push(RelationOccurrence::new(
8978                &relation,
8979                source_path.clone(),
8980                SourceSpan::new(second_line, 1, second_line, 2)?,
8981                generation,
8982            )?);
8983            entities.push(target);
8984            relations.push(relation);
8985        }
8986
8987        let mut publication = store.begin_index_publication(fingerprint)?;
8988        publication.begin_scan_replacement()?;
8989        publication.upsert_scan_node_batch(&[
8990            graph_node(".", NodeKind::Folder, None),
8991            graph_node("src", NodeKind::Folder, Some(".")),
8992            graph_node("src/trace-source.rs", NodeKind::File, Some("src")),
8993        ])?;
8994        publication.finish_scan_replacement()?;
8995        publication.replace_repository_graph(project, &entities, &relations, &occurrences, &[])?;
8996        publication.complete()?;
8997        Ok(DetailedRelationTraceFixture {
8998            project,
8999            source,
9000            relations,
9001        })
9002    }
9003
9004    /// Publish one complete fixture and its lexical source text.
9005    fn publish_fixture(
9006        store: &mut AtlasStore,
9007        fingerprint: &str,
9008    ) -> Result<GraphFixture, Box<dyn Error>> {
9009        let project = store
9010            .project_instance_id()?
9011            .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
9012        let mut fixture = graph_fixture(project, IndexGeneration::new(1))?;
9013        fixture.coverage.push(CoverageRecord::new(
9014            CoverageScope::Path {
9015                path: RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
9016            },
9017            None,
9018            CoverageState::Complete,
9019            1,
9020            0,
9021            IndexGeneration::new(1),
9022            None,
9023            None,
9024        )?);
9025        let mut occurrences = fixture.occurrences.clone();
9026        occurrences.push(fixture.occurrences[0].clone());
9027        let mut publication = store.begin_index_publication(fingerprint)?;
9028        publication.begin_scan_replacement()?;
9029        publication.upsert_scan_node_batch(&[
9030            graph_node(".", NodeKind::Folder, None),
9031            graph_node("src", NodeKind::Folder, Some(".")),
9032            graph_node("src/Äuth.rs", NodeKind::File, Some("src")),
9033            graph_node("Cargo.toml", NodeKind::File, Some(".")),
9034        ])?;
9035        publication.finish_scan_replacement()?;
9036        publication.replace_symbol_graph(&SymbolGraph {
9037            path: "src/Äuth.rs".to_string(),
9038            language: Some("rust".to_string()),
9039            parser: ParserKind::TreeSitter,
9040            symbols: Vec::new(),
9041            relations: vec![SymbolRelation {
9042                path: "src/Äuth.rs".to_string(),
9043                source_name: "verifyToken".to_string(),
9044                target_name: "legacyTarget".to_string(),
9045                kind: RelationKind::Calls,
9046                line: 10,
9047                context: "legacyTarget()".to_string(),
9048                parser: ParserKind::TreeSitter,
9049            }],
9050        })?;
9051        publication.replace_file_texts_for_paths(
9052            &["src/Äuth.rs".to_string()],
9053            &[IndexedFileText {
9054                path: "src/Äuth.rs".to_string(),
9055                content_hash: Some("hash-old".to_string()),
9056                byte_count: 16,
9057                line_count: 1,
9058                content: "fn verifyToken()".to_string(),
9059            }],
9060        )?;
9061        publication.replace_repository_graph(
9062            fixture.project,
9063            &fixture.entities,
9064            &fixture.relations,
9065            &occurrences,
9066            &fixture.coverage,
9067        )?;
9068        publication.complete()?;
9069        Ok(fixture)
9070    }
9071
9072    #[test]
9073    fn classified_relation_family_filters_before_limit_and_preserves_legacy_page()
9074    -> Result<(), Box<dyn Error>> {
9075        let temp = tempfile::tempdir()?;
9076        let root = temp.path().join("classified-relation-family");
9077        fs::create_dir_all(&root)?;
9078        let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9079        let project = store
9080            .project_instance_id()?
9081            .ok_or_else(|| io::Error::other("classified fixture identity is missing"))?;
9082        let generation = IndexGeneration::new(1);
9083        let paths = (0..6)
9084            .map(|index| format!("src/source-{index}.rs"))
9085            .collect::<Vec<_>>();
9086        let sources = paths
9087            .iter()
9088            .map(|path| {
9089                GraphEntity::new(
9090                    project,
9091                    EntitySelector::File {
9092                        path: RepositoryFilePath::new(Path::new(path))?,
9093                    },
9094                    generation,
9095                )
9096                .map_err(Into::into)
9097            })
9098            .collect::<Result<Vec<_>, Box<dyn Error>>>()?;
9099        let external = GraphEntity::new(
9100            project,
9101            EntitySelector::External {
9102                external: ExternalSelector {
9103                    system: GraphIdentityText::new("classified-family")?,
9104                    identity: GraphIdentityText::new("shared-target")?,
9105                },
9106            },
9107            generation,
9108        )?;
9109        let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9110        let mut relations = sources
9111            .iter()
9112            .map(|source| {
9113                LogicalRelation::new(
9114                    source,
9115                    calls,
9116                    RelationResolution::external(&external)?,
9117                    ConfidenceClass::Exact,
9118                    Completeness::Complete,
9119                    generation,
9120                )
9121                .map_err(Into::into)
9122            })
9123            .collect::<Result<Vec<_>, Box<dyn Error>>>()?;
9124        relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
9125        let classifications = [
9126            ContentClassification::Documentation,
9127            ContentClassification::ConfigurationData,
9128            ContentClassification::OtherText,
9129            ContentClassification::Opaque,
9130            ContentClassification::Source,
9131            ContentClassification::Documentation,
9132        ];
9133        let rows = relations
9134            .iter()
9135            .zip(classifications)
9136            .map(|(relation, classification)| {
9137                let source = sources
9138                    .iter()
9139                    .find(|source| source.key() == relation.source())
9140                    .ok_or_else(|| io::Error::other("relation source fixture is missing"))?;
9141                let EntitySelector::File { path } = source.selector() else {
9142                    return Err(io::Error::other("relation source fixture is not a file"));
9143                };
9144                Ok(crate::FileContentClassification {
9145                    path: path.as_str().to_string(),
9146                    classification,
9147                })
9148            })
9149            .collect::<Result<Vec<_>, io::Error>>()?;
9150        let mut nodes = vec![
9151            graph_node(".", NodeKind::Folder, None),
9152            graph_node("src", NodeKind::Folder, Some(".")),
9153        ];
9154        nodes.extend(
9155            paths
9156                .iter()
9157                .map(|path| graph_node(path, NodeKind::File, Some("src"))),
9158        );
9159        let mut entities = sources;
9160        entities.push(external);
9161        let mut publication = store.begin_index_publication("classified-relation-family")?;
9162        publication.begin_scan_replacement()?;
9163        publication.upsert_scan_node_batch(&nodes)?;
9164        publication.finish_scan_replacement()?;
9165        publication.upsert_file_content_classification_batch(&rows)?;
9166        publication.replace_repository_graph(project, &entities, &relations, &[], &[])?;
9167        publication.complete()?;
9168
9169        let legacy = store.repository_graph_relation_rows(
9170            RepositoryGraphRelationQuery::Family { relation: calls },
9171            2,
9172            None,
9173        )?;
9174        let classified_legacy = store.repository_graph_classified_relation_family_rows(
9175            calls,
9176            ContentSelection::UnspecifiedLegacy,
9177            2,
9178            None,
9179        )?;
9180        require_eq(
9181            &classified_legacy
9182                .rows
9183                .iter()
9184                .map(|row| row.detail.clone())
9185                .collect::<Vec<_>>(),
9186            &legacy.rows,
9187            "omitted-selection relation rows and order",
9188        )?;
9189        require_eq(
9190            &classified_legacy.truncated,
9191            &legacy.truncated,
9192            "omitted-selection truncation",
9193        )?;
9194        require_eq(
9195            &classified_legacy
9196                .rows
9197                .iter()
9198                .map(|row| row.source_classification)
9199                .collect::<Vec<_>>(),
9200            &vec![
9201                Some(ContentClassification::Documentation),
9202                Some(ContentClassification::ConfigurationData),
9203            ],
9204            "omitted-selection additive classifications",
9205        )?;
9206
9207        let (source, source_statements) = trace_statements(&mut store, |store| {
9208            store.repository_graph_classified_relation_family_rows(
9209                calls,
9210                ContentSelection::Source,
9211                1,
9212                None,
9213            )
9214        })?;
9215        require(
9216            source.rows.len() == 1
9217                && !source.truncated
9218                && source.rows[0].detail.relation == relations[4]
9219                && source.rows[0].source_classification == Some(ContentClassification::Source),
9220            "source selection filtered only after a bounded mixed page",
9221        )?;
9222        require_eq(
9223            &source_statements
9224                .iter()
9225                .filter(|statement| statement.contains("file_content_classifications"))
9226                .count(),
9227            &1,
9228            "source classification statement count",
9229        )?;
9230        let documentation = store.repository_graph_classified_relation_family_rows(
9231            calls,
9232            ContentSelection::Documentation,
9233            1,
9234            None,
9235        )?;
9236        require(
9237            documentation.rows.len() == 1
9238                && documentation.truncated
9239                && documentation.rows[0].detail.relation == relations[0]
9240                && documentation.rows[0].source_classification
9241                    == Some(ContentClassification::Documentation),
9242            "documentation selection did not preserve pre-limit ordering",
9243        )?;
9244        let both = store.repository_graph_classified_relation_family_rows(
9245            calls,
9246            ContentSelection::Both,
9247            2,
9248            None,
9249        )?;
9250        require(
9251            both.rows.len() == 2
9252                && both.truncated
9253                && both.rows[0].detail.relation == relations[0]
9254                && both.rows[1].detail.relation == relations[4]
9255                && both.rows.iter().all(|row| {
9256                    row.source_classification.is_some_and(|classification| {
9257                        ContentSelection::Both.includes(classification)
9258                    })
9259                }),
9260            "combined selection admitted an excluded class or filtered after limit",
9261        )?;
9262
9263        for selection in [
9264            ContentSelection::UnspecifiedLegacy,
9265            ContentSelection::Source,
9266            ContentSelection::Both,
9267        ] {
9268            let mut bindings = vec![
9269                Value::Blob(project.as_bytes().to_vec()),
9270                Value::Text("legacy".to_string()),
9271                Value::Text("calls".to_string()),
9272            ];
9273            match selection {
9274                ContentSelection::UnspecifiedLegacy => {}
9275                ContentSelection::Source => bindings.push(Value::Text("source".to_string())),
9276                ContentSelection::Documentation => {
9277                    bindings.push(Value::Text("documentation".to_string()));
9278                }
9279                ContentSelection::Both => bindings.extend([
9280                    Value::Text("source".to_string()),
9281                    Value::Text("documentation".to_string()),
9282                ]),
9283            }
9284            bindings.push(Value::Integer(3));
9285            let sql = format!(
9286                "EXPLAIN QUERY PLAN {}",
9287                classified_relation_family_sql(selection)
9288            );
9289            let mut statement = store.connection.prepare(&sql)?;
9290            let details = statement
9291                .query_map(params_from_iter(bindings.iter()), |row| {
9292                    row.get::<_, String>(3)
9293                })?
9294                .collect::<Result<Vec<_>, _>>()?;
9295            require(
9296                details
9297                    .iter()
9298                    .any(|detail| detail.contains("idx_graph_relations_kind_order"))
9299                    && details
9300                        .iter()
9301                        .any(|detail| detail.contains("file_content_classifications"))
9302                    && details
9303                        .iter()
9304                        .all(|detail| !detail.contains("SCAN relation"))
9305                    && details
9306                        .iter()
9307                        .all(|detail| !detail.contains("USE TEMP B-TREE")),
9308                &format!("classified family query did not retain indexed ordering: {details:?}"),
9309            )?;
9310        }
9311
9312        let invalid = require_db_error(
9313            store.repository_graph_classified_relation_family_rows(
9314                calls,
9315                ContentSelection::Source,
9316                0,
9317                None,
9318            ),
9319            "zero classified family limit succeeded",
9320        )?;
9321        require(
9322            matches!(
9323                invalid,
9324                DbError::GraphContract(GraphContractError::InvalidLimits { .. })
9325            ),
9326            "zero classified family limit returned the wrong error",
9327        )?;
9328        Ok(())
9329    }
9330
9331    #[test]
9332    fn filtered_terminal_probe_classifies_the_directional_endpoint() -> Result<(), Box<dyn Error>> {
9333        let temp = tempfile::tempdir()?;
9334        let root = temp.path().join("filtered-terminal-probe");
9335        fs::create_dir_all(&root)?;
9336        let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9337        let project = store
9338            .project_instance_id()?
9339            .ok_or_else(|| io::Error::other("filtered probe fixture identity is missing"))?;
9340        let generation = IndexGeneration::new(1);
9341        let doc_source = GraphEntity::new(
9342            project,
9343            EntitySelector::File {
9344                path: RepositoryFilePath::new(Path::new("docs/source.md"))?,
9345            },
9346            generation,
9347        )?;
9348        let source_target = GraphEntity::new(
9349            project,
9350            EntitySelector::File {
9351                path: RepositoryFilePath::new(Path::new("src/target.rs"))?,
9352            },
9353            generation,
9354        )?;
9355        let source_source = GraphEntity::new(
9356            project,
9357            EntitySelector::File {
9358                path: RepositoryFilePath::new(Path::new("src/source.rs"))?,
9359            },
9360            generation,
9361        )?;
9362        let doc_target = GraphEntity::new(
9363            project,
9364            EntitySelector::File {
9365                path: RepositoryFilePath::new(Path::new("docs/target.md"))?,
9366            },
9367            generation,
9368        )?;
9369        let external_document_target = GraphEntity::new(
9370            project,
9371            EntitySelector::External {
9372                external: ExternalSelector {
9373                    system: GraphIdentityText::new("docs.example")?,
9374                    identity: GraphIdentityText::new("external-page")?,
9375                },
9376            },
9377            generation,
9378        )?;
9379        let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9380        let documents = GraphRelationKind::Extended(ExtendedRelationKind::Documents);
9381        let relations = vec![
9382            LogicalRelation::new(
9383                &doc_source,
9384                calls,
9385                RelationResolution::resolved(&source_target)?,
9386                ConfidenceClass::Exact,
9387                Completeness::Complete,
9388                generation,
9389            )?,
9390            LogicalRelation::new(
9391                &source_source,
9392                calls,
9393                RelationResolution::resolved(&doc_target)?,
9394                ConfidenceClass::Exact,
9395                Completeness::Complete,
9396                generation,
9397            )?,
9398            LogicalRelation::new(
9399                &doc_source,
9400                documents,
9401                RelationResolution::external(&external_document_target)?,
9402                ConfidenceClass::Exact,
9403                Completeness::Complete,
9404                generation,
9405            )?,
9406            LogicalRelation::new(
9407                &source_source,
9408                calls,
9409                RelationResolution::external(&external_document_target)?,
9410                ConfidenceClass::Exact,
9411                Completeness::Complete,
9412                generation,
9413            )?,
9414        ];
9415        let mut publication = store.begin_index_publication("filtered-terminal-probe")?;
9416        publication.begin_scan_replacement()?;
9417        publication.upsert_scan_node_batch(&[
9418            graph_node(".", NodeKind::Folder, None),
9419            graph_node("src", NodeKind::Folder, Some(".")),
9420            graph_node("docs", NodeKind::Folder, Some(".")),
9421            graph_node("src/target.rs", NodeKind::File, Some("src")),
9422            graph_node("src/source.rs", NodeKind::File, Some("src")),
9423            graph_node("docs/source.md", NodeKind::File, Some("docs")),
9424            graph_node("docs/target.md", NodeKind::File, Some("docs")),
9425        ])?;
9426        publication.finish_scan_replacement()?;
9427        publication.upsert_file_content_classification_batch(&[
9428            crate::FileContentClassification {
9429                path: "docs/source.md".to_string(),
9430                classification: ContentClassification::Documentation,
9431            },
9432            crate::FileContentClassification {
9433                path: "src/target.rs".to_string(),
9434                classification: ContentClassification::Source,
9435            },
9436            crate::FileContentClassification {
9437                path: "src/source.rs".to_string(),
9438                classification: ContentClassification::Source,
9439            },
9440            crate::FileContentClassification {
9441                path: "docs/target.md".to_string(),
9442                classification: ContentClassification::Documentation,
9443            },
9444        ])?;
9445        publication.replace_repository_graph(
9446            project,
9447            &[
9448                doc_source.clone(),
9449                source_target.clone(),
9450                source_source.clone(),
9451                doc_target.clone(),
9452                external_document_target,
9453            ],
9454            &relations,
9455            &[],
9456            &[],
9457        )?;
9458        publication.complete()?;
9459
9460        require(
9461            store.repository_graph_adjacency_is_empty_filtered(
9462                source_target.key(),
9463                RepositoryGraphDirection::Inbound,
9464                calls,
9465                ConfidenceClass::Exact,
9466                ContentSelection::Source,
9467                None,
9468            )?,
9469            "inbound documentation source was admitted under source selection",
9470        )?;
9471        require(
9472            !store.repository_graph_adjacency_is_empty_filtered(
9473                doc_target.key(),
9474                RepositoryGraphDirection::Inbound,
9475                calls,
9476                ConfidenceClass::Exact,
9477                ContentSelection::Source,
9478                None,
9479            )?,
9480            "inbound source endpoint was excluded under source selection",
9481        )?;
9482        require(
9483            !store.repository_graph_adjacency_is_empty_filtered(
9484                doc_source.key(),
9485                RepositoryGraphDirection::Outbound,
9486                calls,
9487                ConfidenceClass::Exact,
9488                ContentSelection::Source,
9489                None,
9490            )?,
9491            "outbound source-target endpoint was excluded under source selection",
9492        )?;
9493        require(
9494            !store.repository_graph_adjacency_is_empty_filtered(
9495                doc_source.key(),
9496                RepositoryGraphDirection::Outbound,
9497                documents,
9498                ConfidenceClass::Exact,
9499                ContentSelection::Source,
9500                None,
9501            )?,
9502            "external document endpoint was hidden under source selection",
9503        )?;
9504        require(
9505            !store.repository_graph_adjacency_is_empty_filtered(
9506                source_source.key(),
9507                RepositoryGraphDirection::Outbound,
9508                calls,
9509                ConfidenceClass::Exact,
9510                ContentSelection::Source,
9511                None,
9512            )?,
9513            "external non-document endpoint was hidden under source selection",
9514        )?;
9515        Ok(())
9516    }
9517
9518    #[test]
9519    fn resolved_relation_hubs_rank_the_complete_family_through_the_resolution_index()
9520    -> Result<(), Box<dyn Error>> {
9521        let temp = tempfile::tempdir()?;
9522        let root = temp.path().join("resolved-relation-hubs");
9523        fs::create_dir_all(&root)?;
9524        let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9525        let fixture = publish_fixture(&mut store, "resolved-relation-hubs")?;
9526
9527        let calls = store.repository_graph_resolved_relation_hubs(
9528            GraphRelationKind::Legacy(RelationKind::Calls),
9529            2,
9530            None,
9531        )?;
9532        let expected = BTreeSet::from([
9533            fixture.relations[0].source().digest().to_string(),
9534            fixture.relations[0]
9535                .resolution()
9536                .resolved_target()
9537                .ok_or_else(|| io::Error::other("resolved fixture target is missing"))?
9538                .digest()
9539                .to_string(),
9540        ]);
9541        let actual = calls
9542            .rows
9543            .iter()
9544            .map(|entity| entity.key().digest().to_string())
9545            .collect::<BTreeSet<_>>();
9546        require_eq(&actual, &expected, "resolved relation hubs")?;
9547        require_eq(&calls.truncated, &false, "resolved hub truncation")?;
9548
9549        let one = store.repository_graph_resolved_relation_hubs(
9550            GraphRelationKind::Legacy(RelationKind::Calls),
9551            1,
9552            None,
9553        )?;
9554        require_eq(&one.rows.len(), &1, "bounded resolved hub count")?;
9555        require_eq(&one.truncated, &true, "bounded resolved hub overflow")?;
9556        let unresolved = store.repository_graph_resolved_relation_hubs(
9557            GraphRelationKind::Extended(ExtendedRelationKind::Configures),
9558            2,
9559            None,
9560        )?;
9561        require_eq(&unresolved.rows.len(), &0, "unresolved hub exclusion")?;
9562        let external = store.repository_graph_resolved_relation_hubs(
9563            GraphRelationKind::Legacy(RelationKind::DependsOn),
9564            2,
9565            None,
9566        )?;
9567        require_eq(&external.rows.len(), &0, "external hub exclusion")?;
9568
9569        let invalid = require_db_error(
9570            store.repository_graph_resolved_relation_hubs(
9571                GraphRelationKind::Legacy(RelationKind::Calls),
9572                0,
9573                None,
9574            ),
9575            "zero resolved hub limit succeeded",
9576        )?;
9577        require(
9578            matches!(
9579                invalid,
9580                DbError::GraphContract(GraphContractError::InvalidLimits { .. })
9581            ),
9582            &format!("zero resolved hub limit returned {invalid}"),
9583        )?;
9584
9585        let mut statement = store.connection.prepare(&format!(
9586            "EXPLAIN QUERY PLAN {}",
9587            resolved_relation_hub_keys_sql()
9588        ))?;
9589        let details = statement
9590            .query_map(
9591                params![
9592                    &fixture.project.as_bytes()[..],
9593                    "legacy",
9594                    "calls",
9595                    RESOLUTION_STATUS_RESOLVED,
9596                    3_i64,
9597                ],
9598                |row| row.get::<_, String>(3),
9599            )?
9600            .collect::<Result<Vec<_>, _>>()?;
9601        require(
9602            details
9603                .iter()
9604                .filter(|detail| detail.contains("idx_graph_relations_kind_resolution"))
9605                .count()
9606                >= 2
9607                && details
9608                    .iter()
9609                    .all(|detail| !detail.contains("SCAN graph_relations"))
9610                && details
9611                    .iter()
9612                    .all(|detail| !detail.contains("graph_entities")),
9613            &format!(
9614                "resolved hub ranking did not limit indexed endpoint keys before hydration: {details:?}"
9615            ),
9616        )?;
9617        let cancellation = projectatlas_core::IndexCancellation::new();
9618        cancellation.cancel();
9619        let cancelled = IndexWorkControl::new(cancellation, None);
9620        let error = require_db_error(
9621            store.repository_graph_resolved_relation_hubs(
9622                GraphRelationKind::Legacy(RelationKind::Calls),
9623                2,
9624                Some(&cancelled),
9625            ),
9626            "cancelled resolved hub ranking succeeded",
9627        )?;
9628        require(
9629            matches!(
9630                error,
9631                DbError::IndexWork(projectatlas_core::IndexWorkFailure::Cancelled {
9632                    stage: IndexWorkStage::RepositoryTraversal
9633                })
9634            ),
9635            &format!("resolved hub cancellation was not typed: {error}"),
9636        )?;
9637        Ok(())
9638    }
9639
9640    #[test]
9641    fn resolved_preview_reads_bound_work_and_reject_corruption_when_paged()
9642    -> Result<(), Box<dyn Error>> {
9643        let temp = tempfile::tempdir()?;
9644        let root = temp.path().join("resolved-preview-bounds");
9645        fs::create_dir_all(&root)?;
9646        let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9647        let fixture =
9648            publish_detailed_relation_trace_fixture(&mut store, "resolved-preview-bounds")?;
9649        let source = fixture.source;
9650        let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9651        let (_, hub_statements) = trace_statements(&mut store, |store| {
9652            store.repository_graph_resolved_relation_hubs(calls, 1, None)
9653        })?;
9654        require(
9655            hub_statements.iter().all(|statement| {
9656                !statement.contains("ORDER BY relation.relation_key")
9657                    && !statement.contains("relation.canonical_identity")
9658            }),
9659            &format!("resolved hub lookup reconstructed omitted relation rows: {hub_statements:?}"),
9660        )?;
9661
9662        let budget = RepositoryGraphReadBudget::new(
9663            1,
9664            1,
9665            RepositoryGraphReadBudget::MAX_DECODED_BYTES,
9666            3,
9667            2,
9668        )?;
9669        let (first, adjacency_statements) = trace_statements(&mut store, |store| {
9670            store.repository_graph_adjacency_page_filtered_by_resolution_bounded(
9671                &[source.key().clone()],
9672                RepositoryGraphDirection::Outbound,
9673                Some(calls),
9674                true,
9675                false,
9676                None,
9677                1,
9678                budget,
9679                None,
9680            )
9681        })?;
9682        require(
9683            first.page.truncated
9684                && first.page.rows.len() == 1
9685                && first.page.continuation.is_some()
9686                && first.work.requested_rows == 1
9687                && first.work.returned_rows == 1
9688                && first.work.hydrated_entities <= 3
9689                && adjacency_statements.iter().all(|statement| {
9690                    !statement.contains("ORDER BY relation.relation_key")
9691                        || statement.contains("UNION ALL")
9692                }),
9693            &format!(
9694                "resolved adjacency exceeded its bounded evidence query: work={:?}, statements={adjacency_statements:?}",
9695                first.work
9696            ),
9697        )?;
9698
9699        let expired = IndexWorkControl::with_deadline(
9700            projectatlas_core::IndexCancellation::new(),
9701            Instant::now(),
9702        );
9703        for error in [
9704            require_db_error(
9705                store.repository_graph_resolved_relation_hubs(calls, 1, Some(&expired)),
9706                "expired resolved hub lookup succeeded",
9707            )?,
9708            require_db_error(
9709                store.repository_graph_resolved_adjacency_page(
9710                    &[source.key().clone()],
9711                    RepositoryGraphDirection::Outbound,
9712                    calls,
9713                    None,
9714                    1,
9715                    Some(&expired),
9716                ),
9717                "expired resolved adjacency lookup succeeded",
9718            )?,
9719        ] {
9720            require(
9721                matches!(
9722                    error,
9723                    DbError::IndexWork(projectatlas_core::IndexWorkFailure::DeadlineExceeded {
9724                        stage: IndexWorkStage::RepositoryTraversal
9725                    })
9726                ),
9727                &format!("resolved preview deadline was not typed: {error}"),
9728            )?;
9729        }
9730
9731        let continuation = first
9732            .page
9733            .continuation
9734            .ok_or_else(|| io::Error::other("resolved preview continuation is missing"))?;
9735        let mut relations = fixture.relations;
9736        relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
9737        let corrupt = relations
9738            .get(2)
9739            .ok_or_else(|| io::Error::other("late-page corruption relation is missing"))?;
9740        let corrupt_key = corrupt.key().digest_bytes()?;
9741        store
9742            .connection
9743            .execute_batch("PRAGMA ignore_check_constraints = ON")?;
9744        store.connection.execute(
9745            "UPDATE graph_relations
9746                SET canonical_identity = 'contradictory late-page relation'
9747              WHERE relation_key = ?1",
9748            [&corrupt_key[..]],
9749        )?;
9750        let healthy_first = store.repository_graph_resolved_adjacency_page(
9751            &[source.key().clone()],
9752            RepositoryGraphDirection::Outbound,
9753            calls,
9754            None,
9755            1,
9756            None,
9757        )?;
9758        require(
9759            healthy_first.rows.len() == 1 && healthy_first.continuation.is_some(),
9760            "corruption outside the bounded evidence page hid the healthy first page",
9761        )?;
9762        let error = require_db_error(
9763            store.repository_graph_resolved_adjacency_page(
9764                &[source.key().clone()],
9765                RepositoryGraphDirection::Outbound,
9766                calls,
9767                Some(&continuation),
9768                1,
9769                None,
9770            ),
9771            "late-page corrupt relation returned a partial adjacency page",
9772        )?;
9773        require(
9774            matches!(
9775                error,
9776                DbError::GraphContract(GraphContractError::StableKeyCollision { .. })
9777            ),
9778            &format!("late-page corrupt relation returned {error}"),
9779        )?;
9780        Ok(())
9781    }
9782
9783    #[test]
9784    fn resolved_preview_reads_reject_selected_relation_and_endpoint_corruption()
9785    -> Result<(), Box<dyn Error>> {
9786        let temp = tempfile::tempdir()?;
9787        let root = temp.path().join("resolved-preview-corruption");
9788        fs::create_dir_all(&root)?;
9789        let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9790        let fixture =
9791            publish_detailed_relation_trace_fixture(&mut store, "resolved-preview-corruption")?;
9792        let source = fixture.source;
9793        let mut relations = fixture.relations;
9794
9795        let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9796        let healthy_hubs = store.repository_graph_resolved_relation_hubs(calls, 1, None)?;
9797        require(
9798            healthy_hubs.truncated
9799                && healthy_hubs.rows.len() == 1
9800                && healthy_hubs.rows[0].key() == source.key(),
9801            "healthy corruption fixture did not rank its source hub first",
9802        )?;
9803        let healthy_adjacency = store.repository_graph_resolved_adjacency_page(
9804            &[source.key().clone()],
9805            RepositoryGraphDirection::Outbound,
9806            calls,
9807            None,
9808            1,
9809            None,
9810        )?;
9811        require(
9812            healthy_adjacency.truncated && healthy_adjacency.rows.len() == 1,
9813            "healthy corruption fixture did not retain its adjacency sentinel",
9814        )?;
9815
9816        relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
9817        let top = relations
9818            .first()
9819            .ok_or_else(|| io::Error::other("top corruption relation is missing"))?;
9820        let source_key = source.key().digest_bytes()?;
9821        let top_key = top.key().digest_bytes()?;
9822        let top_target_key = top
9823            .resolution()
9824            .resolved_target()
9825            .ok_or_else(|| io::Error::other("top corruption target is missing"))?;
9826        let top_target = top_target_key.digest_bytes()?;
9827        store.connection.execute_batch(
9828            "PRAGMA foreign_keys = OFF;
9829             PRAGMA ignore_check_constraints = ON;",
9830        )?;
9831        store.connection.execute(
9832            "UPDATE graph_relations
9833                SET canonical_identity = 'contradictory visible relation identity'
9834              WHERE relation_key = ?1",
9835            [&top_key[..]],
9836        )?;
9837        let visible_identity = require_db_error(
9838            store.repository_graph_resolved_adjacency_page(
9839                &[source.key().clone()],
9840                RepositoryGraphDirection::Outbound,
9841                calls,
9842                None,
9843                1,
9844                None,
9845            ),
9846            "visible canonical-identity corruption returned an adjacency page",
9847        )?;
9848        require(
9849            matches!(
9850                visible_identity,
9851                DbError::GraphContract(GraphContractError::StableKeyCollision { .. })
9852            ),
9853            &format!("visible canonical-identity corruption returned {visible_identity}"),
9854        )?;
9855        store.connection.execute(
9856            "UPDATE graph_relations SET canonical_identity = ?1 WHERE relation_key = ?2",
9857            params![top.key().canonical_identity(), &top_key[..]],
9858        )?;
9859
9860        let visible_wrong_digest = [0_u8; 32];
9861        store.connection.execute(
9862            "UPDATE graph_relations SET relation_key = ?1 WHERE relation_key = ?2",
9863            params![&visible_wrong_digest[..], &top_key[..]],
9864        )?;
9865        let visible_digest = require_db_error(
9866            store.repository_graph_resolved_adjacency_page(
9867                &[source.key().clone()],
9868                RepositoryGraphDirection::Outbound,
9869                calls,
9870                None,
9871                1,
9872                None,
9873            ),
9874            "visible well-typed wrong digest returned an adjacency page",
9875        )?;
9876        require(
9877            matches!(
9878                visible_digest,
9879                DbError::GraphContract(GraphContractError::InvalidStableKeyDigest)
9880            ),
9881            &format!("visible well-typed wrong digest returned {visible_digest}"),
9882        )?;
9883        store.connection.execute(
9884            "UPDATE graph_relations SET relation_key = ?1 WHERE canonical_identity = ?2",
9885            params![&top_key[..], top.key().canonical_identity()],
9886        )?;
9887
9888        store.connection.execute(
9889            "UPDATE graph_entities SET canonical_identity = '' WHERE entity_key = ?1",
9890            [&source_key[..]],
9891        )?;
9892        let top_hub_endpoint = require_db_error(
9893            store.repository_graph_resolved_relation_hubs(calls, 1, None),
9894            "top-ranked corrupt hub endpoint returned a partial page",
9895        )?;
9896        require(
9897            matches!(top_hub_endpoint, DbError::GraphContract(_)),
9898            &format!("top-ranked corrupt hub endpoint returned {top_hub_endpoint}"),
9899        )?;
9900        store.connection.execute(
9901            "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
9902            params![source.key().canonical_identity(), &source_key[..]],
9903        )?;
9904
9905        store.connection.execute(
9906            "UPDATE graph_entities SET canonical_identity = '' WHERE entity_key = ?1",
9907            [&top_target[..]],
9908        )?;
9909        let top_adjacency_endpoint = require_db_error(
9910            store.repository_graph_resolved_adjacency_page(
9911                &[source.key().clone()],
9912                RepositoryGraphDirection::Outbound,
9913                calls,
9914                None,
9915                1,
9916                None,
9917            ),
9918            "top-page corrupt adjacency endpoint returned a partial page",
9919        )?;
9920        require(
9921            matches!(top_adjacency_endpoint, DbError::GraphContract(_)),
9922            &format!("top-page corrupt adjacency endpoint returned {top_adjacency_endpoint}"),
9923        )?;
9924        store.connection.execute(
9925            "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
9926            params![top_target_key.canonical_identity(), &top_target[..]],
9927        )?;
9928
9929        Ok(())
9930    }
9931
9932    #[test]
9933    fn resolved_adjacency_filters_self_edges_before_limits_and_binds_its_cursor()
9934    -> Result<(), Box<dyn Error>> {
9935        let temp = tempfile::tempdir()?;
9936        let root = temp.path().join("resolved-adjacency-self-edge");
9937        fs::create_dir_all(&root)?;
9938        let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9939        let fixture = publish_fixture(&mut store, "resolved-adjacency-self-edge")?;
9940        let generation = IndexGeneration::new(1);
9941        let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9942        let mut selected = None;
9943        for index in 0..1_000 {
9944            let source_name = format!("self_edge_source_{index}");
9945            let target_name = format!("self_edge_target_{index}");
9946            let symbol = |name: &str| -> Result<GraphEntity, Box<dyn Error>> {
9947                Ok(GraphEntity::new(
9948                    fixture.project,
9949                    EntitySelector::Symbol {
9950                        symbol: SymbolSelector {
9951                            file: RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
9952                            name: GraphIdentityText::new(name)?,
9953                            kind: SymbolKind::Function,
9954                            parent: None,
9955                            signature: GraphIdentityText::new(format!("{name}()"))?,
9956                        },
9957                    },
9958                    generation,
9959                )?)
9960            };
9961            let source = symbol(&source_name)?;
9962            let target = symbol(&target_name)?;
9963            let self_relation = LogicalRelation::new(
9964                &source,
9965                calls,
9966                RelationResolution::resolved(&source)?,
9967                ConfidenceClass::Exact,
9968                Completeness::Complete,
9969                generation,
9970            )?;
9971            let useful = LogicalRelation::new(
9972                &source,
9973                calls,
9974                RelationResolution::resolved(&target)?,
9975                ConfidenceClass::Exact,
9976                Completeness::Complete,
9977                generation,
9978            )?;
9979            if self_relation.key().digest() < useful.key().digest() {
9980                selected = Some((source, target, self_relation, useful));
9981                break;
9982            }
9983        }
9984        let (source, target, self_relation, useful) = selected
9985            .ok_or_else(|| io::Error::other("could not order a self edge before a useful edge"))?;
9986        insert_entities(&store.connection, fixture.project, [&source, &target])?;
9987        insert_relations(
9988            &store.connection,
9989            fixture.project,
9990            &[self_relation.clone(), useful.clone()],
9991        )?;
9992
9993        let frontier = [source.key().clone()];
9994        let first = store.repository_graph_resolved_adjacency_page(
9995            &frontier,
9996            RepositoryGraphDirection::Outbound,
9997            calls,
9998            None,
9999            1,
10000            None,
10001        )?;
10002        require(
10003            first.rows.len() == 1
10004                && first.rows[0].detail.relation.key() == useful.key()
10005                && first.rows[0].detail.relation.key() != self_relation.key(),
10006            "self relation consumed the resolved adjacency limit before a useful edge",
10007        )?;
10008
10009        let cancellation = projectatlas_core::IndexCancellation::new();
10010        cancellation.cancel();
10011        let cancelled = IndexWorkControl::new(cancellation, None);
10012        let cancellation_error = require_db_error(
10013            store.repository_graph_resolved_adjacency_page(
10014                &frontier,
10015                RepositoryGraphDirection::Outbound,
10016                calls,
10017                None,
10018                1,
10019                Some(&cancelled),
10020            ),
10021            "cancelled resolved adjacency succeeded",
10022        )?;
10023        require(
10024            matches!(
10025                cancellation_error,
10026                DbError::IndexWork(projectatlas_core::IndexWorkFailure::Cancelled {
10027                    stage: IndexWorkStage::RepositoryTraversal
10028                })
10029            ),
10030            &format!("resolved adjacency cancellation was not typed: {cancellation_error}"),
10031        )?;
10032        Ok(())
10033    }
10034
10035    #[test]
10036    fn repository_graph_staging_checkpoints_ownership_before_graph_writes()
10037    -> Result<(), Box<dyn Error>> {
10038        let temp = tempfile::tempdir()?;
10039        let project_root = temp.path().join("staging-ownership");
10040        let stage = project_root.join(".projectatlas").join("graph-stage-test");
10041        fs::create_dir_all(&stage)?;
10042        let database = stage.join("projectatlas.db");
10043        let project = ProjectInstanceId::from_bytes([7; 16])?;
10044        let _store =
10045            AtlasStore::create_repository_graph_staging(&database, &project_root, project)?;
10046
10047        let main_database_only = temp.path().join("staging-main-only.db");
10048        fs::copy(&database, &main_database_only)?;
10049        require(
10050            AtlasStore::repository_graph_staging_belongs_to(
10051                &main_database_only,
10052                &project_root,
10053                project,
10054            )?,
10055            "staging ownership depended on WAL sidecars before graph writes",
10056        )
10057    }
10058
10059    #[cfg(windows)]
10060    #[test]
10061    fn repository_graph_staging_accepts_case_only_root_rename() -> Result<(), Box<dyn Error>> {
10062        let temp = tempfile::tempdir()?;
10063        let original_root = temp.path().join("CaseOnlyStageRoot");
10064        let intermediate_root = temp.path().join("CaseOnlyStageRoot-intermediate");
10065        let renamed_root = temp.path().join("caseonlystageroot");
10066        let stage = original_root.join(".projectatlas").join("graph-stage-case");
10067        fs::create_dir_all(&stage)?;
10068        let database = stage.join("projectatlas.db");
10069        let project = ProjectInstanceId::from_bytes([8; 16])?;
10070        drop(AtlasStore::create_repository_graph_staging(
10071            &database,
10072            &original_root,
10073            project,
10074        )?);
10075
10076        fs::rename(&original_root, &intermediate_root)?;
10077        fs::rename(&intermediate_root, &renamed_root)?;
10078        let renamed_database = renamed_root
10079            .join(".projectatlas")
10080            .join("graph-stage-case")
10081            .join("projectatlas.db");
10082        require(
10083            AtlasStore::repository_graph_staging_belongs_to(
10084                &renamed_database,
10085                &renamed_root,
10086                project,
10087            )?,
10088            "case-only renamed staging root was not re-canonicalized",
10089        )
10090    }
10091
10092    #[test]
10093    fn repository_graph_staging_persists_document_reasons_for_bounded_copy()
10094    -> Result<(), Box<dyn Error>> {
10095        let temp = tempfile::tempdir()?;
10096        let project_root = temp.path().join("staging-document-reasons");
10097        fs::create_dir(&project_root)?;
10098        let main_database = project_root.join("projectatlas.db");
10099        let mut main = AtlasStore::open_for_project(&main_database, &project_root)?;
10100        let project = main
10101            .project_instance_id()?
10102            .ok_or_else(|| io::Error::other("staging fixture identity is missing"))?;
10103        let generation = IndexGeneration::new(1);
10104        let stage_database = project_root.join("graph-stage.db");
10105        let mut stage =
10106            AtlasStore::create_repository_graph_staging(&stage_database, &project_root, project)?;
10107        let nodes = [
10108            graph_node(".", NodeKind::Folder, None),
10109            graph_node("docs", NodeKind::Folder, Some(".")),
10110            graph_node("docs/guide.md", NodeKind::File, Some("docs")),
10111        ];
10112        stage.replace_scan(&nodes)?;
10113        let document = GraphEntity::new(
10114            project,
10115            EntitySelector::File {
10116                path: RepositoryFilePath::new(Path::new("docs/guide.md"))?,
10117            },
10118            generation,
10119        )?;
10120        let unresolved_document = LogicalRelation::new(
10121            &document,
10122            GraphRelationKind::Extended(ExtendedRelationKind::Documents),
10123            RelationResolution::Unresolved {
10124                reference: GraphIdentityText::new("missing.md")?,
10125            },
10126            ConfidenceClass::Exact,
10127            Completeness::Complete,
10128            generation,
10129        )?;
10130        let unrelated = LogicalRelation::new(
10131            &document,
10132            GraphRelationKind::Extended(ExtendedRelationKind::Configures),
10133            RelationResolution::Unresolved {
10134                reference: GraphIdentityText::new("setting")?,
10135            },
10136            ConfidenceClass::Low,
10137            Completeness::Partial,
10138            generation,
10139        )?;
10140        let mut staging = stage.begin_repository_graph_staging(project, generation)?;
10141        staging.append_batch(
10142            std::slice::from_ref(&document),
10143            &[unresolved_document.clone(), unrelated.clone()],
10144            &[],
10145            &[],
10146            &[],
10147            &[],
10148        )?;
10149        let mixed = require_db_error(
10150            staging.set_document_unresolved_reasons(&[
10151                (
10152                    unresolved_document.key().clone(),
10153                    DocumentTargetUnresolvedReason::Missing,
10154                ),
10155                (
10156                    unrelated.key().clone(),
10157                    DocumentTargetUnresolvedReason::Unsupported,
10158                ),
10159            ]),
10160            "staging accepted a reason for a non-document relation",
10161        )?;
10162        require(
10163            matches!(mixed, DbError::GraphRowShape { .. }),
10164            "staging mixed reason batch returned the wrong error",
10165        )?;
10166        let relation_key = unresolved_document.key().digest_bytes()?;
10167        let staged_reason = staging.transaction.query_row(
10168            "SELECT document_unresolved_reason FROM graph_relations WHERE relation_key = ?1",
10169            [&relation_key[..]],
10170            |row| row.get::<_, Option<String>>(0),
10171        )?;
10172        require(
10173            staged_reason.is_none(),
10174            "failed staged reason batch exposed partial mutation",
10175        )?;
10176        staging.set_document_unresolved_reasons(&[(
10177            unresolved_document.key().clone(),
10178            DocumentTargetUnresolvedReason::Missing,
10179        )])?;
10180        staging.complete()?;
10181        stage.checkpoint_repository_graph_staging()?;
10182
10183        let cancellation = projectatlas_core::IndexCancellation::new();
10184        cancellation.cancel();
10185        let canceled = IndexWorkControl::new(cancellation, None);
10186        let mut canceled_publication =
10187            main.begin_index_publication("canceled-staged-document-reasons")?;
10188        canceled_publication.begin_scan_replacement()?;
10189        canceled_publication.upsert_scan_node_batch(&nodes)?;
10190        canceled_publication.finish_scan_replacement()?;
10191        let cancellation_error = require_db_error(
10192            canceled_publication.replace_repository_graph_from_staging(
10193                project,
10194                &stage,
10195                Some(&canceled),
10196            ),
10197            "canceled stage copy committed",
10198        )?;
10199        require(
10200            matches!(
10201                cancellation_error,
10202                DbError::IndexWork(projectatlas_core::IndexWorkFailure::Cancelled {
10203                    stage: IndexWorkStage::Publication
10204                })
10205            ),
10206            "canceled stage copy returned the wrong error",
10207        )?;
10208        drop(canceled_publication);
10209        require(
10210            main.repository_graph_generation()?.is_none(),
10211            "canceled stage copy exposed a graph generation",
10212        )?;
10213
10214        let mut publication = main.begin_index_publication("staged-document-reasons")?;
10215        publication.begin_scan_replacement()?;
10216        publication.upsert_scan_node_batch(&nodes)?;
10217        publication.finish_scan_replacement()?;
10218        publication.replace_repository_graph_from_staging(project, &stage, None)?;
10219        publication.complete()?;
10220
10221        let copied = main.repository_graph_relation_rows(
10222            RepositoryGraphRelationQuery::Family {
10223                relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
10224            },
10225            10,
10226            None,
10227        )?;
10228        require(
10229            copied.rows.len() == 1
10230                && copied.rows[0].document_unresolved_reason
10231                    == Some(DocumentTargetUnresolvedReason::Missing),
10232            "stage-to-publication copy lost the closed document reason",
10233        )?;
10234        Ok(())
10235    }
10236
10237    #[test]
10238    fn resolution_keys_round_trip_reopen_and_preserve_all_dependency_states()
10239    -> Result<(), Box<dyn Error>> {
10240        let temp = tempfile::tempdir()?;
10241        let project_root = temp.path().join("resolution-round-trip");
10242        let atlas_dir = project_root.join(".projectatlas");
10243        fs::create_dir_all(&atlas_dir)?;
10244        let db_path = atlas_dir.join("projectatlas.db");
10245        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
10246        let fixture = publish_resolution_fixture(&mut writer, "resolution-round-trip")?;
10247        assert_query_indexes(&writer)?;
10248
10249        let counts = writer.connection.query_row(
10250            "SELECT
10251                 (SELECT COUNT(*) FROM graph_resolution_keys),
10252                 (SELECT COUNT(*) FROM graph_entity_exports),
10253                 (SELECT COUNT(*) FROM graph_relation_dependencies)",
10254            [],
10255            |row| {
10256                Ok((
10257                    row.get::<_, i64>(0)?,
10258                    row.get::<_, i64>(1)?,
10259                    row.get::<_, i64>(2)?,
10260                ))
10261            },
10262        )?;
10263        require_eq(&counts, &(3, 1, 3), "deduplicated resolution-key rows")?;
10264        let states = writer
10265            .connection
10266            .prepare(
10267                "SELECT relation.resolution_status, COUNT(*)
10268                   FROM graph_relation_dependencies AS dependency
10269                   JOIN graph_relations AS relation
10270                     ON relation.project_instance_id = dependency.project_instance_id
10271                    AND relation.relation_key = dependency.relation_key
10272                  GROUP BY relation.resolution_status
10273                  ORDER BY relation.resolution_status",
10274            )?
10275            .query_map([], |row| {
10276                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
10277            })?
10278            .collect::<Result<Vec<_>, _>>()?;
10279        require_eq(
10280            &states,
10281            &vec![
10282                ("ambiguous".to_string(), 1),
10283                ("resolved".to_string(), 1),
10284                ("unresolved".to_string(), 1),
10285            ],
10286            "dependency resolution states",
10287        )?;
10288        drop(writer);
10289
10290        let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
10291        let exports = reader.repository_export_keys_for_paths(
10292            fixture.graph.project,
10293            &["src/Äuth.rs".to_string()],
10294            10,
10295        )?;
10296        require_eq(&exports.truncated, &false, "export key truncation")?;
10297        require_eq(
10298            &exports.rows,
10299            &vec![fixture.resolved.clone()],
10300            "export key round trip",
10301        )?;
10302        let candidates = reader.repository_resolution_candidates(&fixture.resolved, 10)?;
10303        require_eq(&candidates.truncated, &false, "candidate truncation")?;
10304        require_eq(
10305            &candidates.rows,
10306            &vec![fixture.graph.entities[4].clone()],
10307            "candidate export round trip",
10308        )?;
10309        let batch_candidates = reader.repository_resolution_candidates_for_keys(
10310            fixture.graph.project,
10311            &[
10312                fixture.unresolved.clone(),
10313                fixture.resolved.clone(),
10314                fixture.ambiguous.clone(),
10315                fixture.resolved.clone(),
10316            ],
10317            10,
10318        )?;
10319        require_eq(
10320            &batch_candidates.truncated,
10321            &false,
10322            "batch candidate truncation",
10323        )?;
10324        require_eq(
10325            &batch_candidates.rows.len(),
10326            &1,
10327            "batch candidate deduplication",
10328        )?;
10329        require_eq(
10330            batch_candidates.rows[0].key(),
10331            &fixture.resolved,
10332            "batch candidate selecting key",
10333        )?;
10334        require_eq(
10335            batch_candidates.rows[0].entity(),
10336            &fixture.graph.entities[4],
10337            "batch candidate entity",
10338        )?;
10339        let affected = reader.repository_affected_source_paths(
10340            fixture.graph.project,
10341            &[
10342                fixture.resolved.clone(),
10343                fixture.resolved,
10344                fixture.ambiguous,
10345                fixture.unresolved,
10346            ],
10347            10,
10348        )?;
10349        require_eq(&affected.truncated, &false, "affected path truncation")?;
10350        require_eq(
10351            &affected.rows,
10352            &vec![RepositoryFilePath::new(Path::new("src/Äuth.rs"))?],
10353            "resolved ambiguous and unresolved dependency owners",
10354        )?;
10355        reader.finish_index_read_snapshot()?;
10356        Ok(())
10357    }
10358
10359    #[test]
10360    fn high_degree_dependency_closure_is_unique_and_reports_overflow_before_mutation()
10361    -> Result<(), Box<dyn Error>> {
10362        let temp = tempfile::tempdir()?;
10363        let project_root = temp.path().join("high-degree-resolution");
10364        fs::create_dir_all(&project_root)?;
10365        let db_path = project_root.join("projectatlas.db");
10366        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10367        let project = store
10368            .project_instance_id()?
10369            .ok_or_else(|| io::Error::other("bound identity is missing"))?;
10370        let generation = IndexGeneration::new(1);
10371        let dependency_key = declaration_key(
10372            project,
10373            "sharedTarget",
10374            GraphRelationKind::Extended(ExtendedRelationKind::References),
10375        )?;
10376        let mut nodes = vec![
10377            graph_node(".", NodeKind::Folder, None),
10378            graph_node("src", NodeKind::Folder, Some(".")),
10379        ];
10380        let mut entities = Vec::new();
10381        let mut relations = Vec::new();
10382        let mut dependencies = Vec::new();
10383        for index in 0..5 {
10384            let path = format!("src/caller-{index}.rs");
10385            nodes.push(graph_node(&path, NodeKind::File, Some("src")));
10386            let entity = GraphEntity::new(
10387                project,
10388                EntitySelector::File {
10389                    path: RepositoryFilePath::new(Path::new(&path))?,
10390                },
10391                generation,
10392            )?;
10393            let relation = LogicalRelation::new(
10394                &entity,
10395                GraphRelationKind::Extended(ExtendedRelationKind::References),
10396                RelationResolution::Unresolved {
10397                    reference: GraphIdentityText::new("sharedTarget")?,
10398                },
10399                ConfidenceClass::High,
10400                Completeness::Complete,
10401                generation,
10402            )?;
10403            dependencies.push(RelationDependencyKey::new(
10404                relation.key().clone(),
10405                dependency_key.clone(),
10406            )?);
10407            entities.push(entity);
10408            relations.push(relation);
10409        }
10410        dependencies.push(dependencies[0].clone());
10411        let mut publication = store.begin_index_publication("high-degree-resolution")?;
10412        publication.begin_scan_replacement()?;
10413        publication.upsert_scan_node_batch(&nodes)?;
10414        publication.finish_scan_replacement()?;
10415        publication.replace_repository_graph_with_resolution_keys(
10416            project,
10417            &entities,
10418            &relations,
10419            &[],
10420            &[],
10421            &[],
10422            &dependencies,
10423        )?;
10424        publication.complete()?;
10425
10426        let before = store.index_publication()?;
10427        let bounded = store.repository_affected_source_paths(
10428            project,
10429            std::slice::from_ref(&dependency_key),
10430            2,
10431        )?;
10432        require_eq(&bounded.rows.len(), &2, "bounded affected path count")?;
10433        require_eq(&bounded.truncated, &true, "bounded affected path overflow")?;
10434        require_eq(
10435            &store.index_publication()?,
10436            &before,
10437            "overflow lookup mutated publication state",
10438        )?;
10439        let complete = store.repository_affected_source_paths(project, &[dependency_key], 10)?;
10440        require_eq(&complete.rows.len(), &5, "complete affected path count")?;
10441        require_eq(
10442            &complete.truncated,
10443            &false,
10444            "complete affected path overflow",
10445        )?;
10446        let unique = complete.rows.iter().collect::<BTreeSet<_>>();
10447        require_eq(&unique.len(), &5, "affected paths are unique")?;
10448        Ok(())
10449    }
10450
10451    #[test]
10452    fn affected_source_footprint_accounts_exact_owned_rows_and_uses_path_indexes()
10453    -> Result<(), Box<dyn Error>> {
10454        let temp = tempfile::tempdir()?;
10455        let project_root = temp.path().join("affected-source-footprint");
10456        fs::create_dir_all(&project_root)?;
10457        let db_path = project_root.join("projectatlas.db");
10458        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10459        let fixture = publish_resolution_fixture(&mut store, "affected-source-footprint")?;
10460        replace_fixture_symbol_rows(&mut store, "src/Äuth.rs", 1)?;
10461        store.connection.execute(
10462            "INSERT INTO graph_coverage(
10463                 project_instance_id, scope_kind, scope_path, relation_scope,
10464                 relation_kind, state, total, covered, omitted, reason, reached_limit
10465             ) VALUES(?1, 'path', 'src/Äuth.rs', NULL, NULL,
10466                      'complete', 1, 1, 0, NULL, NULL)",
10467            [&fixture.graph.project.as_bytes()[..]],
10468        )?;
10469
10470        let mut paths = (0..=RESOLUTION_PATHS_PER_QUERY)
10471            .map(|index| format!("missing/{index:04}.rs"))
10472            .collect::<Vec<_>>();
10473        paths.extend(["src/Äuth.rs".to_string(), "src/Äuth.rs".to_string()]);
10474        let footprint =
10475            store.repository_affected_source_footprint(fixture.graph.project, &paths, 100)?;
10476        require_eq(&footprint.rows, &20, "exact affected persisted rows")?;
10477        require(
10478            footprint.retained_bytes > footprint.rows,
10479            "affected footprint omitted decoded bytes",
10480        )?;
10481        require_eq(&footprint.truncated, &false, "exact footprint truncation")?;
10482
10483        let sql = format!("EXPLAIN QUERY PLAN {}", affected_source_footprint_sql(1));
10484        let values = [
10485            Value::Blob(fixture.graph.project.as_bytes().to_vec()),
10486            Value::Text("src/Äuth.rs".to_string()),
10487            Value::Integer(101),
10488        ];
10489        let mut statement = store.connection.prepare(&sql)?;
10490        let details = statement
10491            .query_map(params_from_iter(values.iter()), |row| {
10492                row.get::<_, String>(3)
10493            })?
10494            .collect::<Result<Vec<_>, _>>()?;
10495        for index in [
10496            "sqlite_autoindex_source_parse_metadata_1",
10497            "idx_symbols_path",
10498            "idx_symbol_relations_path",
10499            "idx_graph_entities_path",
10500            "idx_graph_entities_manifest_path",
10501            "idx_graph_relations_source_kind",
10502            "idx_graph_occurrences_file_span",
10503            "idx_graph_coverage_path",
10504            "idx_graph_entity_exports_owner",
10505            "idx_graph_relation_dependencies_owner",
10506            "sqlite_autoindex_graph_resolution_keys_1",
10507        ] {
10508            require(
10509                details.iter().any(|detail| detail.contains(index)),
10510                &format!("affected footprint missed {index}; plan was {details:?}"),
10511            )?;
10512        }
10513        require(
10514            details.iter().all(|detail| !detail.contains("SCAN graph_")),
10515            &format!("affected footprint scanned graph storage: {details:?}"),
10516        )?;
10517        Ok(())
10518    }
10519
10520    #[test]
10521    fn affected_source_footprint_reports_high_degree_overflow_before_mutation()
10522    -> Result<(), Box<dyn Error>> {
10523        let temp = tempfile::tempdir()?;
10524        let project_root = temp.path().join("affected-source-degree");
10525        fs::create_dir_all(&project_root)?;
10526        let db_path = project_root.join("projectatlas.db");
10527        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10528        let fixture = publish_resolution_fixture(&mut store, "affected-source-degree")?;
10529        replace_fixture_symbol_rows(&mut store, "src/Äuth.rs", 25)?;
10530        let before = store.index_publication()?;
10531
10532        let bounded = store.repository_affected_source_footprint(
10533            fixture.graph.project,
10534            &["src/Äuth.rs".to_string()],
10535            5,
10536        )?;
10537        require_eq(&bounded.rows, &6, "footprint limit plus one sentinel")?;
10538        require_eq(&bounded.truncated, &true, "high-degree footprint overflow")?;
10539        require(
10540            bounded.retained_bytes > 0,
10541            "bounded footprint lost retained bytes",
10542        )?;
10543        require_eq(
10544            &store.index_publication()?,
10545            &before,
10546            "footprint overflow mutated publication",
10547        )?;
10548        Ok(())
10549    }
10550
10551    #[test]
10552    fn affected_source_footprint_validates_inputs_identity_and_graph_availability()
10553    -> Result<(), Box<dyn Error>> {
10554        let temp = tempfile::tempdir()?;
10555        let selected_root = temp.path().join("selected");
10556        let other_root = temp.path().join("other");
10557        fs::create_dir_all(&selected_root)?;
10558        fs::create_dir_all(&other_root)?;
10559        let mut selected =
10560            AtlasStore::open_for_project(&selected_root.join("projectatlas.db"), &selected_root)?;
10561        let fixture = publish_resolution_fixture(&mut selected, "footprint-validation")?;
10562        let other = AtlasStore::open_for_project(&other_root.join("projectatlas.db"), &other_root)?;
10563        let other_project = other
10564            .project_instance_id()?
10565            .ok_or_else(|| io::Error::other("other bound identity is missing"))?;
10566
10567        let invalid_limit = require_db_error(
10568            selected.repository_affected_source_footprint(
10569                fixture.graph.project,
10570                &["src/Äuth.rs".to_string()],
10571                0,
10572            ),
10573            "zero footprint limit was accepted",
10574        )?;
10575        require(
10576            matches!(invalid_limit, DbError::GraphContract(_)),
10577            &format!("invalid footprint limit returned {invalid_limit}"),
10578        )?;
10579        let invalid_path = require_db_error(
10580            selected.repository_affected_source_footprint(
10581                fixture.graph.project,
10582                &["../outside.rs".to_string()],
10583                10,
10584            ),
10585            "escaping footprint path was accepted",
10586        )?;
10587        require(
10588            matches!(invalid_path, DbError::GraphContract(_)),
10589            &format!("invalid footprint path returned {invalid_path}"),
10590        )?;
10591        let mismatched = require_db_error(
10592            selected.repository_affected_source_footprint(
10593                other_project,
10594                &["src/Äuth.rs".to_string()],
10595                10,
10596            ),
10597            "mismatched project footprint was accepted",
10598        )?;
10599        require(
10600            matches!(mismatched, DbError::GraphProjectIdentityMismatch { .. }),
10601            &format!("mismatched footprint returned {mismatched}"),
10602        )?;
10603        require_eq(
10604            &other.repository_affected_source_footprint(
10605                other_project,
10606                &["src/Äuth.rs".to_string()],
10607                10,
10608            )?,
10609            &empty_affected_source_footprint(),
10610            "unpublished graph footprint",
10611        )?;
10612        Ok(())
10613    }
10614
10615    #[test]
10616    fn affected_source_footprint_rejects_missing_resolution_witnesses() -> Result<(), Box<dyn Error>>
10617    {
10618        let temp = tempfile::tempdir()?;
10619        let project_root = temp.path().join("affected-source-corruption");
10620        fs::create_dir_all(&project_root)?;
10621        let db_path = project_root.join("projectatlas.db");
10622        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10623        let fixture = publish_resolution_fixture(&mut store, "affected-source-corruption")?;
10624        store
10625            .connection
10626            .execute_batch("PRAGMA foreign_keys = OFF")?;
10627        store.connection.execute(
10628            "DELETE FROM graph_resolution_keys
10629              WHERE project_instance_id = ?1
10630                AND resolution_domain = ?2
10631                AND key_digest = ?3",
10632            params![
10633                &fixture.graph.project.as_bytes()[..],
10634                fixture.resolved.domain().as_str(),
10635                &fixture.resolved.digest_bytes()[..],
10636            ],
10637        )?;
10638        let error = require_db_error(
10639            store.repository_affected_source_footprint(
10640                fixture.graph.project,
10641                &["src/Äuth.rs".to_string()],
10642                100,
10643            ),
10644            "missing resolution witness returned a partial footprint",
10645        )?;
10646        require(
10647            matches!(error, DbError::Sqlite(_)),
10648            &format!("missing witness returned the wrong error: {error}"),
10649        )?;
10650        Ok(())
10651    }
10652
10653    #[test]
10654    fn resolution_key_failures_roll_back_and_owner_foreign_keys_cascade()
10655    -> Result<(), Box<dyn Error>> {
10656        let temp = tempfile::tempdir()?;
10657        let project_root = temp.path().join("resolution-failures");
10658        fs::create_dir_all(&project_root)?;
10659        let db_path = project_root.join("projectatlas.db");
10660        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10661        let fixture = publish_resolution_fixture(&mut store, "resolution-failures")?;
10662        let before_publication = store.index_publication()?;
10663        let before_counts = store.connection.query_row(
10664            "SELECT
10665                 (SELECT COUNT(*) FROM graph_resolution_keys),
10666                 (SELECT COUNT(*) FROM graph_entity_exports),
10667                 (SELECT COUNT(*) FROM graph_relation_dependencies)",
10668            [],
10669            |row| {
10670                Ok((
10671                    row.get::<_, i64>(0)?,
10672                    row.get::<_, i64>(1)?,
10673                    row.get::<_, i64>(2)?,
10674                ))
10675            },
10676        )?;
10677
10678        let replacement = graph_fixture(fixture.graph.project, IndexGeneration::new(2))?;
10679        let invalid_export = EntityResolutionKey::new(
10680            replacement.entities[0].key().clone(),
10681            fixture.resolved.clone(),
10682        )?;
10683        {
10684            let mut publication = store.begin_index_publication("resolution-failures")?;
10685            let error = require_db_error(
10686                publication.replace_repository_graph_with_resolution_keys(
10687                    replacement.project,
10688                    &replacement.entities,
10689                    &replacement.relations,
10690                    &replacement.occurrences,
10691                    &replacement.coverage,
10692                    &[invalid_export],
10693                    &[],
10694                ),
10695                "source-less export owner unexpectedly published",
10696            )?;
10697            require(
10698                matches!(error, DbError::GraphRowShape { .. }),
10699                &format!("invalid export owner returned the wrong error: {error}"),
10700            )?;
10701        }
10702        require_eq(
10703            &store.index_publication()?,
10704            &before_publication,
10705            "failed key publication generation",
10706        )?;
10707        let after_counts = store.connection.query_row(
10708            "SELECT
10709                 (SELECT COUNT(*) FROM graph_resolution_keys),
10710                 (SELECT COUNT(*) FROM graph_entity_exports),
10711                 (SELECT COUNT(*) FROM graph_relation_dependencies)",
10712            [],
10713            |row| {
10714                Ok((
10715                    row.get::<_, i64>(0)?,
10716                    row.get::<_, i64>(1)?,
10717                    row.get::<_, i64>(2)?,
10718                ))
10719            },
10720        )?;
10721        require_eq(
10722            &after_counts,
10723            &before_counts,
10724            "failed key publication durable rows",
10725        )?;
10726        require_eq(
10727            &store
10728                .repository_resolution_candidates(&fixture.resolved, 10)?
10729                .rows,
10730            &vec![fixture.graph.entities[4].clone()],
10731            "failed key publication previous candidates",
10732        )?;
10733
10734        store.connection.execute(
10735            "UPDATE graph_resolution_keys
10736                SET canonical_identity = 'conflicting collision witness'
10737              WHERE project_instance_id = ?1
10738                AND resolution_domain = ?2
10739                AND key_digest = ?3",
10740            params![
10741                &fixture.graph.project.as_bytes()[..],
10742                fixture.resolved.domain().as_str(),
10743                &fixture.resolved.digest_bytes()[..],
10744            ],
10745        )?;
10746        let collision = require_db_error(
10747            store.repository_resolution_candidates(&fixture.resolved, 10),
10748            "conflicting resolution witness was accepted",
10749        )?;
10750        require(
10751            matches!(collision, DbError::ResolutionKeyCollision { .. }),
10752            &format!("conflicting witness returned the wrong error: {collision}"),
10753        )?;
10754        let corrupt_page = require_db_error(
10755            store.repository_export_keys_for_paths(
10756                fixture.graph.project,
10757                &["src/Äuth.rs".to_string()],
10758                10,
10759            ),
10760            "corrupt witness returned a partial key page",
10761        )?;
10762        require(
10763            matches!(corrupt_page, DbError::GraphContract(_)),
10764            &format!("corrupt witness returned the wrong page error: {corrupt_page}"),
10765        )?;
10766        store.connection.execute(
10767            "UPDATE graph_resolution_keys
10768                SET canonical_identity = ?1
10769              WHERE project_instance_id = ?2
10770                AND resolution_domain = ?3
10771                AND key_digest = ?4",
10772            params![
10773                fixture.resolved.canonical_identity(),
10774                &fixture.graph.project.as_bytes()[..],
10775                fixture.resolved.domain().as_str(),
10776                &fixture.resolved.digest_bytes()[..],
10777            ],
10778        )?;
10779
10780        let relation_digest = fixture.graph.relations[2].key().digest_bytes()?;
10781        store.connection.execute(
10782            "DELETE FROM graph_relations
10783              WHERE project_instance_id = ?1 AND relation_key = ?2",
10784            params![&fixture.graph.project.as_bytes()[..], &relation_digest[..]],
10785        )?;
10786        let remaining_dependencies = store.connection.query_row(
10787            "SELECT COUNT(*) FROM graph_relation_dependencies",
10788            [],
10789            |row| row.get::<_, i64>(0),
10790        )?;
10791        require_eq(
10792            &remaining_dependencies,
10793            &(before_counts.2 - 1),
10794            "relation-owner dependency cascade",
10795        )?;
10796        let entity_digest = fixture.graph.entities[4].key().digest_bytes()?;
10797        store.connection.execute(
10798            "DELETE FROM graph_entities
10799              WHERE project_instance_id = ?1 AND entity_key = ?2",
10800            params![&fixture.graph.project.as_bytes()[..], &entity_digest[..]],
10801        )?;
10802        let remaining_exports =
10803            store
10804                .connection
10805                .query_row("SELECT COUNT(*) FROM graph_entity_exports", [], |row| {
10806                    row.get::<_, i64>(0)
10807                })?;
10808        require_eq(&remaining_exports, &0, "entity-owner export cascade")?;
10809        Ok(())
10810    }
10811
10812    #[test]
10813    fn affected_replacement_swaps_resolution_keys_and_collects_only_touched_orphans()
10814    -> Result<(), Box<dyn Error>> {
10815        let temp = tempfile::tempdir()?;
10816        let project_root = temp.path().join("resolution-replacement");
10817        fs::create_dir_all(&project_root)?;
10818        let db_path = project_root.join("projectatlas.db");
10819        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10820        let first = publish_resolution_fixture(&mut store, "resolution-replacement")?;
10821        let replacement = graph_fixture(first.graph.project, IndexGeneration::new(2))?;
10822        let renamed = declaration_key(
10823            first.graph.project,
10824            "verifySession",
10825            GraphRelationKind::Legacy(RelationKind::Calls),
10826        )?;
10827        let exports = vec![EntityResolutionKey::new(
10828            replacement.entities[4].key().clone(),
10829            renamed.clone(),
10830        )?];
10831        let dependencies = vec![
10832            RelationDependencyKey::new(replacement.relations[0].key().clone(), renamed.clone())?,
10833            RelationDependencyKey::new(
10834                replacement.relations[1].key().clone(),
10835                first.ambiguous.clone(),
10836            )?,
10837            RelationDependencyKey::new(
10838                replacement.relations[2].key().clone(),
10839                first.unresolved.clone(),
10840            )?,
10841        ];
10842        let mut publication = store.begin_index_publication("resolution-replacement")?;
10843        publication.replace_repository_graph_for_paths_with_resolution_keys(
10844            replacement.project,
10845            &["src/Äuth.rs".to_string()],
10846            &replacement.entities,
10847            &replacement.relations,
10848            &replacement.occurrences,
10849            &replacement.coverage,
10850            &exports,
10851            &dependencies,
10852        )?;
10853        publication.complete()?;
10854
10855        require_eq(
10856            &store
10857                .repository_resolution_candidates(&first.resolved, 10)?
10858                .rows
10859                .len(),
10860            &0,
10861            "removed export candidates",
10862        )?;
10863        require_eq(
10864            &store
10865                .repository_affected_source_paths(
10866                    first.graph.project,
10867                    std::slice::from_ref(&first.resolved),
10868                    10,
10869                )?
10870                .rows
10871                .len(),
10872            &0,
10873            "removed dependency owners",
10874        )?;
10875        require_eq(
10876            &store.repository_resolution_candidates(&renamed, 10)?.rows,
10877            &vec![replacement.entities[4].clone()],
10878            "renamed export candidates",
10879        )?;
10880        let exports = store.repository_export_keys_for_paths(
10881            first.graph.project,
10882            &["src/Äuth.rs".to_string()],
10883            10,
10884        )?;
10885        require_eq(&exports.rows, &vec![renamed], "replacement export keys")?;
10886        let registry_rows = store.connection.query_row(
10887            "SELECT COUNT(*) FROM graph_resolution_keys",
10888            [],
10889            |row| row.get::<_, i64>(0),
10890        )?;
10891        require_eq(&registry_rows, &3, "touched orphan witness cleanup")?;
10892        require_eq(
10893            &store
10894                .index_publication()?
10895                .ok_or_else(|| io::Error::other("replacement publication is missing"))?
10896                .generation,
10897            &IndexGeneration::new(2),
10898            "replacement generation",
10899        )?;
10900        Ok(())
10901    }
10902
10903    #[test]
10904    fn affected_graph_replacement_preserves_only_the_unaffected_closure()
10905    -> Result<(), Box<dyn Error>> {
10906        let temp = tempfile::tempdir()?;
10907        let project_root = temp.path().join("affected-closure");
10908        let atlas_dir = project_root.join(".projectatlas");
10909        fs::create_dir_all(&atlas_dir)?;
10910        let db_path = atlas_dir.join("projectatlas.db");
10911        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10912
10913        let project = store
10914            .project_instance_id()?
10915            .ok_or_else(|| io::Error::other("bound affected-closure identity is missing"))?;
10916        let generation_one = IndexGeneration::new(1);
10917        let project_entity = GraphEntity::new(project, EntitySelector::Project, generation_one)?;
10918        let affected_folder = GraphEntity::new(
10919            project,
10920            EntitySelector::Folder {
10921                path: RepositoryNodePath::new(Path::new("src/a"))?,
10922            },
10923            generation_one,
10924        )?;
10925        let affected_file = GraphEntity::new(
10926            project,
10927            EntitySelector::File {
10928                path: RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
10929            },
10930            generation_one,
10931        )?;
10932        let case_distinct_file = GraphEntity::new(
10933            project,
10934            EntitySelector::File {
10935                path: RepositoryFilePath::new(Path::new("src/A/keep.rs"))?,
10936            },
10937            generation_one,
10938        )?;
10939        let package = GraphEntity::new(
10940            project,
10941            EntitySelector::Package {
10942                package: PackageSelector {
10943                    manager: GraphIdentityText::new("cargo")?,
10944                    name: GraphIdentityText::new("api")?,
10945                    manifest: RepositoryFilePath::new(Path::new("packages/api/Cargo.toml"))?,
10946                },
10947            },
10948            generation_one,
10949        )?;
10950        let orphan_external = GraphEntity::new(
10951            project,
10952            EntitySelector::External {
10953                external: ExternalSelector {
10954                    system: GraphIdentityText::new("crates.io")?,
10955                    identity: GraphIdentityText::new("orphan@1")?,
10956                },
10957            },
10958            generation_one,
10959        )?;
10960        let retained_external = GraphEntity::new(
10961            project,
10962            EntitySelector::External {
10963                external: ExternalSelector {
10964                    system: GraphIdentityText::new("crates.io")?,
10965                    identity: GraphIdentityText::new("retained@1")?,
10966                },
10967            },
10968            generation_one,
10969        )?;
10970        let occurrence_owned_external = GraphEntity::new(
10971            project,
10972            EntitySelector::External {
10973                external: ExternalSelector {
10974                    system: GraphIdentityText::new("crates.io")?,
10975                    identity: GraphIdentityText::new("occurrence-owned@1")?,
10976                },
10977            },
10978            generation_one,
10979        )?;
10980        let affected_relation = LogicalRelation::new(
10981            &affected_file,
10982            GraphRelationKind::Legacy(RelationKind::DependsOn),
10983            RelationResolution::external(&orphan_external)?,
10984            ConfidenceClass::Exact,
10985            Completeness::Complete,
10986            generation_one,
10987        )?;
10988        let package_relation = LogicalRelation::new(
10989            &package,
10990            GraphRelationKind::Legacy(RelationKind::DependsOn),
10991            RelationResolution::external(&orphan_external)?,
10992            ConfidenceClass::Exact,
10993            Completeness::Complete,
10994            generation_one,
10995        )?;
10996        let retained_relation = LogicalRelation::new(
10997            &case_distinct_file,
10998            GraphRelationKind::Legacy(RelationKind::DependsOn),
10999            RelationResolution::external(&retained_external)?,
11000            ConfidenceClass::Exact,
11001            Completeness::Complete,
11002            generation_one,
11003        )?;
11004        let project_external_relation = LogicalRelation::new(
11005            &project_entity,
11006            GraphRelationKind::Legacy(RelationKind::DependsOn),
11007            RelationResolution::external(&retained_external)?,
11008            ConfidenceClass::Exact,
11009            Completeness::Complete,
11010            generation_one,
11011        )?;
11012        let occurrence_backed_project_relation = LogicalRelation::new(
11013            &project_entity,
11014            GraphRelationKind::Legacy(RelationKind::DependsOn),
11015            RelationResolution::external(&occurrence_owned_external)?,
11016            ConfidenceClass::Exact,
11017            Completeness::Complete,
11018            generation_one,
11019        )?;
11020        let affected_occurrence = RelationOccurrence::new(
11021            &affected_relation,
11022            RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
11023            SourceSpan::new(3, 0, 3, 12)?,
11024            generation_one,
11025        )?;
11026        let retained_occurrence = RelationOccurrence::new(
11027            &retained_relation,
11028            RepositoryFilePath::new(Path::new("src/A/keep.rs"))?,
11029            SourceSpan::new(5, 0, 5, 14)?,
11030            generation_one,
11031        )?;
11032        let retained_relation_affected_occurrence = RelationOccurrence::new(
11033            &retained_relation,
11034            RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
11035            SourceSpan::new(6, 0, 6, 14)?,
11036            generation_one,
11037        )?;
11038        let project_relation_occurrence = RelationOccurrence::new(
11039            &occurrence_backed_project_relation,
11040            RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
11041            SourceSpan::new(9, 0, 9, 16)?,
11042            generation_one,
11043        )?;
11044        let initial_coverage = vec![
11045            CoverageRecord::new(
11046                CoverageScope::Project,
11047                None,
11048                CoverageState::Complete,
11049                4,
11050                0,
11051                generation_one,
11052                None,
11053                None,
11054            )?,
11055            CoverageRecord::new(
11056                CoverageScope::Path {
11057                    path: RepositoryNodePath::new(Path::new("src/a"))?,
11058                },
11059                None,
11060                CoverageState::Partial,
11061                1,
11062                1,
11063                generation_one,
11064                Some(GraphIdentityText::new("affected coverage")?),
11065                Some(GraphLimitKind::Rows),
11066            )?,
11067            CoverageRecord::new(
11068                CoverageScope::Path {
11069                    path: RepositoryNodePath::new(Path::new("src/A"))?,
11070                },
11071                None,
11072                CoverageState::Complete,
11073                1,
11074                0,
11075                generation_one,
11076                None,
11077                None,
11078            )?,
11079        ];
11080        {
11081            let mut publication = store.begin_index_publication("affected-closure")?;
11082            publication.begin_scan_replacement()?;
11083            publication.upsert_scan_node_batch(&[
11084                graph_node(".", NodeKind::Folder, None),
11085                graph_node("src", NodeKind::Folder, Some(".")),
11086                graph_node("src/a", NodeKind::Folder, Some("src")),
11087                graph_node("src/a/local.rs", NodeKind::File, Some("src/a")),
11088                graph_node("src/a/new.rs", NodeKind::File, Some("src/a")),
11089                graph_node("src/A", NodeKind::Folder, Some("src")),
11090                graph_node("src/A/keep.rs", NodeKind::File, Some("src/A")),
11091                graph_node("packages", NodeKind::Folder, Some(".")),
11092                graph_node("packages/api", NodeKind::Folder, Some("packages")),
11093                graph_node(
11094                    "packages/api/Cargo.toml",
11095                    NodeKind::File,
11096                    Some("packages/api"),
11097                ),
11098                graph_node("README.md", NodeKind::File, Some(".")),
11099            ])?;
11100            publication.finish_scan_replacement()?;
11101            publication.replace_repository_graph(
11102                project,
11103                &[
11104                    project_entity.clone(),
11105                    affected_folder,
11106                    affected_file.clone(),
11107                    case_distinct_file.clone(),
11108                    package.clone(),
11109                    orphan_external.clone(),
11110                    retained_external.clone(),
11111                    occurrence_owned_external.clone(),
11112                ],
11113                &[
11114                    affected_relation,
11115                    package_relation,
11116                    retained_relation,
11117                    project_external_relation,
11118                    occurrence_backed_project_relation,
11119                ],
11120                &[
11121                    affected_occurrence,
11122                    retained_occurrence,
11123                    retained_relation_affected_occurrence,
11124                    project_relation_occurrence,
11125                ],
11126                &initial_coverage,
11127            )?;
11128            publication.complete()?;
11129        }
11130
11131        let affected_paths = [
11132            RepositoryNodePath::new(Path::new("src/a"))?,
11133            RepositoryNodePath::new(Path::new("packages/api/Cargo.toml"))?,
11134        ];
11135        let (candidates, statements) = trace_statements(&mut store, |store| {
11136            affected_external_candidates(&store.connection, &affected_paths)
11137        })?;
11138        require(
11139            candidates.contains(&orphan_external.key().digest_bytes()?),
11140            "batched external cleanup omitted an affected candidate",
11141        )?;
11142        require_eq(
11143            &statements
11144                .iter()
11145                .filter(|statement| {
11146                    statement.contains("WITH affected(entity_key)")
11147                        && statement.contains("idx_graph_relations_source_kind")
11148                        && statement.contains("idx_graph_relations_target_kind")
11149                })
11150                .count(),
11151            &1,
11152            "bounded external candidate adjacency batches",
11153        )?;
11154
11155        let generation_two = IndexGeneration::new(2);
11156        let replacement_folder = GraphEntity::new(
11157            project,
11158            EntitySelector::Folder {
11159                path: RepositoryNodePath::new(Path::new("src/a"))?,
11160            },
11161            generation_two,
11162        )?;
11163        let replacement_file = GraphEntity::new(
11164            project,
11165            EntitySelector::File {
11166                path: RepositoryFilePath::new(Path::new("src/a/new.rs"))?,
11167            },
11168            generation_two,
11169        )?;
11170        let retained_external_for_relation = GraphEntity::new(
11171            project,
11172            retained_external.selector().clone(),
11173            generation_two,
11174        )?;
11175        let replacement_relation = LogicalRelation::new(
11176            &replacement_file,
11177            GraphRelationKind::Legacy(RelationKind::DependsOn),
11178            RelationResolution::external(&retained_external_for_relation)?,
11179            ConfidenceClass::Exact,
11180            Completeness::Complete,
11181            generation_two,
11182        )?;
11183        let replacement_occurrence = RelationOccurrence::new(
11184            &replacement_relation,
11185            RepositoryFilePath::new(Path::new("src/a/new.rs"))?,
11186            SourceSpan::new(7, 0, 7, 10)?,
11187            generation_two,
11188        )?;
11189        let replacement_coverage = CoverageRecord::new(
11190            CoverageScope::Path {
11191                path: RepositoryNodePath::new(Path::new("src/a"))?,
11192            },
11193            None,
11194            CoverageState::Complete,
11195            1,
11196            0,
11197            generation_two,
11198            None,
11199            None,
11200        )?;
11201        {
11202            let mut publication = store.begin_index_publication("affected-closure")?;
11203            publication.replace_repository_graph_for_paths(
11204                project,
11205                &["src/a".to_string(), "packages/api/Cargo.toml".to_string()],
11206                &[replacement_folder, replacement_file.clone()],
11207                &[replacement_relation],
11208                &[replacement_occurrence],
11209                &[replacement_coverage],
11210            )?;
11211            publication.complete()?;
11212        }
11213
11214        drop(store);
11215        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11216
11217        require_eq(
11218            &store.repository_graph_entity(affected_file.key())?,
11219            &None,
11220            "affected descendant removal",
11221        )?;
11222        require_eq(
11223            &store.repository_graph_entity(package.key())?,
11224            &None,
11225            "manifest-owned package removal",
11226        )?;
11227        require_eq(
11228            &store.repository_graph_entity(orphan_external.key())?,
11229            &None,
11230            "candidate-bounded orphan external cleanup",
11231        )?;
11232        require_eq(
11233            &store.repository_graph_entity(occurrence_owned_external.key())?,
11234            &None,
11235            "final affected occurrence relation and external cleanup",
11236        )?;
11237        let preserved_case = store
11238            .repository_graph_entity(case_distinct_file.key())?
11239            .ok_or_else(|| io::Error::other("case-distinct sibling was removed"))?;
11240        require_eq(
11241            &preserved_case.generation(),
11242            &generation_two,
11243            "case-distinct sibling generation injection",
11244        )?;
11245        let preserved_external = store
11246            .repository_graph_entity(retained_external.key())?
11247            .ok_or_else(|| io::Error::other("referenced external entity was removed"))?;
11248        require_eq(
11249            &preserved_external.generation(),
11250            &generation_two,
11251            "unaffected external generation injection",
11252        )?;
11253        let retained_relations = store.repository_graph_relations(
11254            RepositoryGraphRelationQuery::Outbound {
11255                source: case_distinct_file.key().clone(),
11256            },
11257            10,
11258        )?;
11259        require_eq(
11260            &retained_relations.rows.len(),
11261            &1,
11262            "relation with one unaffected occurrence",
11263        )?;
11264        require_eq(
11265            &store
11266                .repository_graph_occurrences(&retained_relations.rows[0], 10)?
11267                .rows
11268                .len(),
11269            &1,
11270            "only the affected occurrence was removed",
11271        )?;
11272        let replacement_relations = store.repository_graph_relations(
11273            RepositoryGraphRelationQuery::Outbound {
11274                source: replacement_file.key().clone(),
11275            },
11276            10,
11277        )?;
11278        require_eq(
11279            &replacement_relations.rows.len(),
11280            &1,
11281            "replacement relation count",
11282        )?;
11283        require_eq(
11284            &store
11285                .repository_graph_occurrences(&replacement_relations.rows[0], 10)?
11286                .rows
11287                .len(),
11288            &1,
11289            "replacement source occurrence",
11290        )?;
11291        let affected_coverage = store.repository_graph_coverage(
11292            project,
11293            &CoverageScope::Path {
11294                path: RepositoryNodePath::new(Path::new("src/a"))?,
11295            },
11296            10,
11297        )?;
11298        require(
11299            affected_coverage.rows.len() == 1
11300                && affected_coverage.rows[0].state() == CoverageState::Complete,
11301            "affected path coverage was not replaced",
11302        )?;
11303        let case_coverage = store.repository_graph_coverage(
11304            project,
11305            &CoverageScope::Path {
11306                path: RepositoryNodePath::new(Path::new("src/A"))?,
11307            },
11308            10,
11309        )?;
11310        require_eq(
11311            &case_coverage.rows.len(),
11312            &1,
11313            "case-distinct coverage preservation",
11314        )?;
11315        require_eq(
11316            &store
11317                .repository_graph_coverage(project, &CoverageScope::Project, 10)?
11318                .rows
11319                .len(),
11320            &1,
11321            "unaffected project coverage preservation",
11322        )?;
11323        require_eq(
11324            &store
11325                .repository_graph_relations(
11326                    RepositoryGraphRelationQuery::Outbound {
11327                        source: project_entity.key().clone(),
11328                    },
11329                    10,
11330                )?
11331                .rows
11332                .len(),
11333            &1,
11334            "project-to-external relation preservation",
11335        )?;
11336
11337        store.finish_index_read_snapshot()?;
11338        drop(store);
11339        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
11340        let generation_three = IndexGeneration::new(3);
11341        let root_project = GraphEntity::new(project, EntitySelector::Project, generation_three)?;
11342        let readme = GraphEntity::new(
11343            project,
11344            EntitySelector::File {
11345                path: RepositoryFilePath::new(Path::new("README.md"))?,
11346            },
11347            generation_three,
11348        )?;
11349        let root_coverage = CoverageRecord::new(
11350            CoverageScope::Project,
11351            None,
11352            CoverageState::Complete,
11353            1,
11354            0,
11355            generation_three,
11356            None,
11357            None,
11358        )?;
11359        {
11360            let mut publication = store.begin_index_publication("affected-closure")?;
11361            publication.replace_repository_graph_for_paths(
11362                project,
11363                &[".".to_string()],
11364                &[root_project.clone(), readme],
11365                &[],
11366                &[],
11367                &[root_coverage],
11368            )?;
11369            publication.complete()?;
11370        }
11371        drop(store);
11372        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11373        require_eq(
11374            &store.repository_graph_entity(case_distinct_file.key())?,
11375            &None,
11376            "root replacement stale local removal",
11377        )?;
11378        require_eq(
11379            &store.repository_graph_entity(retained_external.key())?,
11380            &None,
11381            "root replacement external removal",
11382        )?;
11383        require_eq(
11384            &store
11385                .repository_graph_relations(
11386                    RepositoryGraphRelationQuery::Outbound {
11387                        source: root_project.key().clone(),
11388                    },
11389                    10,
11390                )?
11391                .rows
11392                .len(),
11393            &0,
11394            "root replacement project relation removal",
11395        )?;
11396        require_eq(
11397            &store
11398                .repository_graph_coverage(project, &CoverageScope::Project, 10)?
11399                .rows
11400                .len(),
11401            &1,
11402            "root replacement project coverage",
11403        )?;
11404        require_eq(
11405            &store
11406                .repository_graph_coverage(
11407                    project,
11408                    &CoverageScope::Path {
11409                        path: RepositoryNodePath::new(Path::new("src/A"))?,
11410                    },
11411                    10,
11412                )?
11413                .rows
11414                .len(),
11415            &0,
11416            "root replacement path coverage removal",
11417        )?;
11418
11419        store.finish_index_read_snapshot()?;
11420        drop(store);
11421        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
11422        {
11423            let mut projection = store.begin_index_projection_refresh("affected-closure")?;
11424            projection.replace_file_texts_for_paths(
11425                &["README.md".to_string()],
11426                &[IndexedFileText {
11427                    path: "README.md".to_string(),
11428                    content_hash: Some("readme-hash".to_string()),
11429                    byte_count: 7,
11430                    line_count: 1,
11431                    content: "# Atlas".to_string(),
11432                }],
11433            )?;
11434            projection.complete()?;
11435        }
11436        drop(store);
11437        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11438        let error = require_db_error(
11439            store.repository_graph_entity(root_project.key()),
11440            "non-graph publication blessed stale graph rows",
11441        )?;
11442        require(
11443            matches!(
11444                error,
11445                DbError::GraphRowShape {
11446                    table: "project_identity",
11447                    ..
11448                }
11449            ),
11450            &format!("unexpected stale graph generation error: {error}"),
11451        )?;
11452        store.finish_index_read_snapshot()?;
11453        Ok(())
11454    }
11455
11456    /// Assert one reader sees a complete internally consistent graph projection.
11457    fn require_graph_projection(
11458        store: &AtlasStore,
11459        fixture: &GraphFixture,
11460        generation: IndexGeneration,
11461        lexical_content: &str,
11462    ) -> Result<(), Box<dyn Error>> {
11463        let publication = store
11464            .index_publication()?
11465            .ok_or_else(|| io::Error::other("graph publication metadata missing"))?;
11466        require_eq(
11467            &publication.state,
11468            &IndexPublicationState::Complete,
11469            "graph publication state",
11470        )?;
11471        require_eq(
11472            &publication.generation,
11473            &generation,
11474            "graph publication generation",
11475        )?;
11476        let source = fixture
11477            .entities
11478            .iter()
11479            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
11480            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
11481        let entity = store
11482            .repository_graph_entity(source.key())?
11483            .ok_or_else(|| io::Error::other("source graph entity missing"))?;
11484        require_eq(&entity.generation(), &generation, "graph entity generation")?;
11485        let relations = store.repository_graph_relations(
11486            RepositoryGraphRelationQuery::Outbound {
11487                source: source.key().clone(),
11488            },
11489            10,
11490        )?;
11491        require_eq(&relations.rows.len(), &4, "graph relation count")?;
11492        require(
11493            relations
11494                .rows
11495                .iter()
11496                .all(|relation| relation.generation() == generation),
11497            "graph relation generation mismatch",
11498        )?;
11499        let calls = relations
11500            .rows
11501            .iter()
11502            .find(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls))
11503            .ok_or_else(|| io::Error::other("call relation missing"))?;
11504        let occurrences = store.repository_graph_occurrences(calls, 10)?;
11505        require_eq(&occurrences.rows.len(), &2, "graph occurrence count")?;
11506        require(
11507            occurrences
11508                .rows
11509                .iter()
11510                .all(|occurrence| occurrence.generation() == generation),
11511            "graph occurrence generation mismatch",
11512        )?;
11513        let occurrence_pages = store.repository_graph_occurrence_pages(&relations.rows, 1, None)?;
11514        require_eq(
11515            &occurrence_pages.len(),
11516            &relations.rows.len(),
11517            "batched occurrence page count",
11518        )?;
11519        for (relation, page) in relations.rows.iter().zip(&occurrence_pages) {
11520            let is_calls = relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls);
11521            require_eq(
11522                &page.rows.len(),
11523                &usize::from(is_calls),
11524                "batched occurrence rows",
11525            )?;
11526            require_eq(&page.truncated, &is_calls, "batched occurrence truncation")?;
11527        }
11528        let coverage =
11529            store.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10)?;
11530        let expected_coverage = fixture
11531            .coverage
11532            .iter()
11533            .filter(|record| matches!(record.scope(), CoverageScope::Project))
11534            .count();
11535        require_eq(
11536            &coverage.rows.len(),
11537            &expected_coverage,
11538            "graph coverage count",
11539        )?;
11540        require(
11541            coverage
11542                .rows
11543                .iter()
11544                .all(|record| record.generation() == generation),
11545            "graph coverage generation mismatch",
11546        )?;
11547        let lexical = store
11548            .load_file_text("src/Äuth.rs")?
11549            .ok_or_else(|| io::Error::other("lexical source row missing"))?;
11550        require_eq(
11551            &lexical.content.as_str(),
11552            &lexical_content,
11553            "lexical source generation",
11554        )?;
11555        require_eq(
11556            &store.symbol_relation_count()?,
11557            &1,
11558            "legacy symbol relation compatibility",
11559        )?;
11560        Ok(())
11561    }
11562
11563    #[test]
11564    fn document_relations_require_closed_reasons_and_reuse_indexed_inbound_reads()
11565    -> Result<(), Box<dyn Error>> {
11566        let temp = tempfile::tempdir()?;
11567        let root = temp.path().join("document-graph");
11568        fs::create_dir(&root)?;
11569        let database = temp.path().join("projectatlas.db");
11570        let mut store = AtlasStore::open_for_project(&database, &root)?;
11571        let project = store
11572            .project_instance_id()?
11573            .ok_or_else(|| io::Error::other("document fixture identity is missing"))?;
11574        let generation = IndexGeneration::new(1);
11575        let document_path = RepositoryFilePath::new(Path::new("docs/guide.md"))?;
11576        let document = GraphEntity::new(
11577            project,
11578            EntitySelector::File {
11579                path: document_path.clone(),
11580            },
11581            generation,
11582        )?;
11583        let heading = GraphEntity::new(
11584            project,
11585            EntitySelector::Symbol {
11586                symbol: SymbolSelector {
11587                    file: document_path,
11588                    name: GraphIdentityText::new("Installation")?,
11589                    kind: SymbolKind::Heading,
11590                    parent: None,
11591                    signature: GraphIdentityText::new("# Installation")?,
11592                },
11593            },
11594            generation,
11595        )?;
11596        let unresolved_document = LogicalRelation::new(
11597            &document,
11598            GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11599            RelationResolution::Unresolved {
11600                reference: GraphIdentityText::new("missing.md#setup")?,
11601            },
11602            ConfidenceClass::Exact,
11603            Completeness::Complete,
11604            generation,
11605        )?;
11606        let resolved_document = LogicalRelation::new(
11607            &document,
11608            GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11609            RelationResolution::resolved(&heading)?,
11610            ConfidenceClass::Exact,
11611            Completeness::Complete,
11612            generation,
11613        )?;
11614        let unrelated = LogicalRelation::new(
11615            &document,
11616            GraphRelationKind::Extended(ExtendedRelationKind::Configures),
11617            RelationResolution::Unresolved {
11618                reference: GraphIdentityText::new("setting")?,
11619            },
11620            ConfidenceClass::Low,
11621            Completeness::Partial,
11622            generation,
11623        )?;
11624
11625        let mut publication = store.begin_index_publication("document-reasons")?;
11626        publication.begin_scan_replacement()?;
11627        publication.upsert_scan_node_batch(&[
11628            graph_node(".", NodeKind::Folder, None),
11629            graph_node("docs", NodeKind::Folder, Some(".")),
11630            graph_node("docs/guide.md", NodeKind::File, Some("docs")),
11631        ])?;
11632        publication.finish_scan_replacement()?;
11633        publication.replace_repository_graph(
11634            project,
11635            &[document, heading.clone()],
11636            &[
11637                unresolved_document.clone(),
11638                resolved_document.clone(),
11639                unrelated.clone(),
11640            ],
11641            &[],
11642            &[],
11643        )?;
11644        let incomplete = require_db_error(
11645            validate_complete_document_unresolved_reasons(&publication.connection),
11646            "unresolved document relation omitted its reason",
11647        )?;
11648        require(
11649            matches!(incomplete, DbError::GraphRowShape { .. }),
11650            "missing document reason returned the wrong error",
11651        )?;
11652        let mixed = require_db_error(
11653            publication.set_document_unresolved_reasons(&[
11654                (
11655                    unresolved_document.key().clone(),
11656                    DocumentTargetUnresolvedReason::Missing,
11657                ),
11658                (
11659                    unrelated.key().clone(),
11660                    DocumentTargetUnresolvedReason::Unsupported,
11661                ),
11662            ]),
11663            "non-document relation accepted a document reason",
11664        )?;
11665        require(
11666            matches!(mixed, DbError::GraphRowShape { .. }),
11667            "mixed document-reason batch returned the wrong error",
11668        )?;
11669        let unresolved_key = unresolved_document.key().digest_bytes()?;
11670        let retained_reason = publication.connection.query_row(
11671            "SELECT document_unresolved_reason FROM graph_relations WHERE relation_key = ?1",
11672            [&unresolved_key[..]],
11673            |row| row.get::<_, Option<String>>(0),
11674        )?;
11675        require(
11676            retained_reason.is_none(),
11677            "failed document-reason batch exposed partial mutation",
11678        )?;
11679        publication.set_document_unresolved_reasons(&[(
11680            unresolved_document.key().clone(),
11681            DocumentTargetUnresolvedReason::Missing,
11682        )])?;
11683        publication.complete()?;
11684
11685        let family = store.repository_graph_relation_rows(
11686            RepositoryGraphRelationQuery::Family {
11687                relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11688            },
11689            10,
11690            None,
11691        )?;
11692        require_eq(&family.rows.len(), &2, "document relation family")?;
11693        require(
11694            family.rows.iter().any(|row| {
11695                matches!(
11696                    row.relation.resolution(),
11697                    RelationResolution::Unresolved { .. }
11698                ) && row.document_unresolved_reason == Some(DocumentTargetUnresolvedReason::Missing)
11699            }) && family.rows.iter().any(|row| {
11700                matches!(
11701                    row.relation.resolution(),
11702                    RelationResolution::Resolved { .. }
11703                ) && row.document_unresolved_reason.is_none()
11704            }),
11705            "document reason or resolved relation did not round-trip",
11706        )?;
11707        let inbound = store.repository_graph_relation_rows(
11708            RepositoryGraphRelationQuery::Inbound {
11709                target: heading.key().clone(),
11710            },
11711            10,
11712            None,
11713        )?;
11714        require(
11715            inbound.rows.len() == 1
11716                && inbound.rows[0].relation.kind()
11717                    == GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11718            "inbound heading read did not reuse document relation storage",
11719        )?;
11720        let frontier = [family.rows[0].source.key().clone()];
11721        let legacy = store.repository_graph_adjacency_page_filtered_bounded(
11722            &frontier,
11723            RepositoryGraphDirection::Outbound,
11724            None,
11725            None,
11726            10,
11727            maximum_repository_graph_read_budget()?,
11728            None,
11729        )?;
11730        require(
11731            legacy.page.rows.len() == 1
11732                && legacy.page.rows[0].detail.relation.kind()
11733                    == GraphRelationKind::Extended(ExtendedRelationKind::Configures),
11734            "legacy unfiltered adjacency admitted documents before its limit",
11735        )?;
11736        let explicit = store.repository_graph_adjacency_page_filtered_bounded_with_documents(
11737            &frontier,
11738            RepositoryGraphDirection::Outbound,
11739            None,
11740            true,
11741            None,
11742            10,
11743            maximum_repository_graph_read_budget()?,
11744            None,
11745        )?;
11746        require_eq(&explicit.page.rows.len(), &3, "explicit document adjacency")?;
11747        let exact = store.repository_graph_adjacency_page_filtered_bounded(
11748            &frontier,
11749            RepositoryGraphDirection::Outbound,
11750            Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
11751            None,
11752            10,
11753            maximum_repository_graph_read_budget()?,
11754            None,
11755        )?;
11756        require_eq(&exact.page.rows.len(), &2, "exact document adjacency")?;
11757        let first = store.repository_graph_adjacency_page_filtered_bounded_with_documents(
11758            &frontier,
11759            RepositoryGraphDirection::Outbound,
11760            None,
11761            true,
11762            None,
11763            1,
11764            maximum_repository_graph_read_budget()?,
11765            None,
11766        )?;
11767        let cursor = first
11768            .page
11769            .continuation
11770            .as_ref()
11771            .ok_or_else(|| io::Error::other("document visibility cursor is missing"))?;
11772        let mismatch = require_db_error(
11773            store.repository_graph_adjacency_page_filtered_bounded_with_documents(
11774                &frontier,
11775                RepositoryGraphDirection::Outbound,
11776                None,
11777                false,
11778                Some(cursor),
11779                1,
11780                maximum_repository_graph_read_budget()?,
11781                None,
11782            ),
11783            "document visibility cursor was reused under legacy filtering",
11784        )?;
11785        require(
11786            matches!(mismatch, DbError::GraphContract(_)),
11787            "document visibility cursor mismatch returned the wrong error",
11788        )?;
11789        assert_query_indexes(&store)?;
11790
11791        let resolved_key = resolved_document.key().digest_bytes()?;
11792        let contradictory = store.connection.execute(
11793            "UPDATE graph_relations
11794                SET document_unresolved_reason = 'missing'
11795              WHERE relation_key = ?1",
11796            [&resolved_key[..]],
11797        );
11798        require(
11799            contradictory.is_err(),
11800            "resolved document relation accepted an unresolved reason",
11801        )?;
11802        let invalid = store.connection.execute(
11803            "UPDATE graph_relations
11804                SET document_unresolved_reason = 'network_error'
11805              WHERE relation_key = ?1",
11806            [&unresolved_key[..]],
11807        );
11808        require(invalid.is_err(), "open document reason value was accepted")?;
11809        drop(store);
11810
11811        let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
11812        let reopened_family = reopened.repository_graph_relation_rows(
11813            RepositoryGraphRelationQuery::Family {
11814                relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11815            },
11816            10,
11817            None,
11818        )?;
11819        require_eq(
11820            &reopened_family.rows.len(),
11821            &2,
11822            "reopened document relations",
11823        )?;
11824        Ok(())
11825    }
11826
11827    #[test]
11828    fn document_unresolved_reasons_publish_across_multiple_graph_row_chunks()
11829    -> Result<(), Box<dyn Error>> {
11830        let temp = tempfile::tempdir()?;
11831        let root = temp.path().join("document-reason-chunks");
11832        fs::create_dir(&root)?;
11833        let database = temp.path().join("projectatlas.db");
11834        let mut store = AtlasStore::open_for_project(&database, &root)?;
11835        let project = store
11836            .project_instance_id()?
11837            .ok_or_else(|| io::Error::other("document chunk fixture identity is missing"))?;
11838        let baseline_generation = IndexGeneration::new(1);
11839        let generation = IndexGeneration::new(2);
11840        let baseline_document = GraphEntity::new(
11841            project,
11842            EntitySelector::File {
11843                path: RepositoryFilePath::new(Path::new("docs/baseline.md"))?,
11844            },
11845            baseline_generation,
11846        )?;
11847        let baseline_relation = LogicalRelation::new(
11848            &baseline_document,
11849            GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11850            RelationResolution::Unresolved {
11851                reference: GraphIdentityText::new("baseline-missing.md")?,
11852            },
11853            ConfidenceClass::Exact,
11854            Completeness::Complete,
11855            baseline_generation,
11856        )?;
11857        let mut baseline = store.begin_index_publication("document-reason-chunks")?;
11858        baseline.upsert_scan_node_batch(&[graph_node(
11859            "docs/baseline.md",
11860            NodeKind::File,
11861            Some("docs"),
11862        )])?;
11863        baseline.replace_repository_graph(
11864            project,
11865            std::slice::from_ref(&baseline_document),
11866            std::slice::from_ref(&baseline_relation),
11867            &[],
11868            &[],
11869        )?;
11870        baseline.set_document_unresolved_reasons(&[(
11871            baseline_relation.key().clone(),
11872            DocumentTargetUnresolvedReason::Missing,
11873        )])?;
11874        baseline.complete()?;
11875        require_eq(
11876            &store.repository_graph_generation()?,
11877            &Some(baseline_generation),
11878            "baseline generation was not complete",
11879        )?;
11880        let document_path = RepositoryFilePath::new(Path::new("docs/index.md"))?;
11881        let document = GraphEntity::new(
11882            project,
11883            EntitySelector::File {
11884                path: document_path,
11885            },
11886            generation,
11887        )?;
11888        let total = GraphLimits::MAX_ROWS as usize * 2 + 1;
11889        let mut relations = Vec::with_capacity(total);
11890        for index in 0..total {
11891            relations.push(LogicalRelation::new(
11892                &document,
11893                GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11894                RelationResolution::Unresolved {
11895                    reference: GraphIdentityText::new(format!("missing-{index:05}.md"))?,
11896                },
11897                ConfidenceClass::Exact,
11898                Completeness::Complete,
11899                generation,
11900            )?);
11901        }
11902        let reasons = relations
11903            .iter()
11904            .map(|relation| {
11905                (
11906                    relation.key().clone(),
11907                    DocumentTargetUnresolvedReason::Missing,
11908                )
11909            })
11910            .collect::<Vec<_>>();
11911        let mut duplicate_reasons = reasons.clone();
11912        duplicate_reasons.push(reasons[0].clone());
11913        let mut contradictory_reasons = reasons.clone();
11914        contradictory_reasons.push((
11915            reasons[0].0.clone(),
11916            DocumentTargetUnresolvedReason::Unsupported,
11917        ));
11918
11919        let populate = |publication: &mut IndexPublicationGuard<'_>| -> DbResult<()> {
11920            publication.upsert_scan_node_batch(&[graph_node(
11921                "docs/index.md",
11922                NodeKind::File,
11923                Some("docs"),
11924            )])?;
11925            publication.replace_repository_graph(
11926                project,
11927                std::slice::from_ref(&document),
11928                &relations,
11929                &[],
11930                &[],
11931            )
11932        };
11933
11934        let mut boundaries = store.begin_index_publication("document-reason-chunks")?;
11935        populate(&mut boundaries)?;
11936        let duplicate = boundaries.set_document_unresolved_reasons(&duplicate_reasons);
11937        require(
11938            matches!(duplicate, Err(DbError::GraphContract(_))),
11939            "duplicate document reason crossing chunks was accepted",
11940        )?;
11941        let untouched = boundaries.connection.query_row(
11942            "SELECT COUNT(*) FROM graph_relations
11943              WHERE document_unresolved_reason IS NOT NULL",
11944            [],
11945            |row| row.get::<_, i64>(0),
11946        )?;
11947        require_eq(&untouched, &0, "duplicate batch exposed a partial chunk")?;
11948        let contradictory = boundaries.set_document_unresolved_reasons(&contradictory_reasons);
11949        require(
11950            matches!(contradictory, Err(DbError::GraphContract(_))),
11951            "contradictory document reason crossing chunks was accepted",
11952        )?;
11953        let contradiction_untouched = boundaries.connection.query_row(
11954            "SELECT COUNT(*) FROM graph_relations
11955              WHERE document_unresolved_reason IS NOT NULL",
11956            [],
11957            |row| row.get::<_, i64>(0),
11958        )?;
11959        require_eq(
11960            &contradiction_untouched,
11961            &0,
11962            "contradictory batch exposed a partial chunk",
11963        )?;
11964        for count in [
11965            GraphLimits::MAX_ROWS as usize - 1,
11966            GraphLimits::MAX_ROWS as usize,
11967            GraphLimits::MAX_ROWS as usize + 1,
11968        ] {
11969            boundaries.set_document_unresolved_reasons(&reasons[..count])?;
11970            let changed = boundaries.connection.query_row(
11971                "SELECT COUNT(*) FROM graph_relations
11972                  WHERE document_unresolved_reason IS NOT NULL",
11973                [],
11974                |row| row.get::<_, i64>(0),
11975            )?;
11976            require_eq(
11977                &changed,
11978                &i64::try_from(count)?,
11979                "document reason boundary count",
11980            )?;
11981        }
11982        drop(boundaries);
11983        require_eq(
11984            &store.repository_graph_generation()?,
11985            &Some(baseline_generation),
11986            "boundary publication changed the current generation",
11987        )?;
11988
11989        let mut preflight = store.begin_index_publication("document-reason-chunks")?;
11990        populate(&mut preflight)?;
11991        let preflight_control = IndexWorkControl::with_deadline(
11992            IndexCancellation::new(),
11993            Instant::now() + Duration::from_millis(10),
11994        );
11995        let preflight_error =
11996            preflight.set_document_unresolved_reasons_controlled(&reasons, &preflight_control);
11997        require(
11998            matches!(
11999                preflight_error,
12000                Err(DbError::IndexWork(
12001                    projectatlas_core::IndexWorkFailure::DeadlineExceeded {
12002                        stage: IndexWorkStage::Publication
12003                    }
12004                ))
12005            ),
12006            "preflight deadline was not observed during document-reason validation",
12007        )?;
12008        let preflight_untouched = preflight.connection.query_row(
12009            "SELECT COUNT(*) FROM graph_relations
12010              WHERE document_unresolved_reason IS NOT NULL",
12011            [],
12012            |row| row.get::<_, i64>(0),
12013        )?;
12014        require_eq(
12015            &preflight_untouched,
12016            &0,
12017            "preflight deadline exposed a partial document-reason update",
12018        )?;
12019        drop(preflight);
12020        require_eq(
12021            &store.repository_graph_generation()?,
12022            &Some(baseline_generation),
12023            "preflight deadline changed the current generation",
12024        )?;
12025
12026        let mut preflight_retry = store.begin_index_publication("document-reason-chunks")?;
12027        populate(&mut preflight_retry)?;
12028        preflight_retry.set_document_unresolved_reasons(&reasons)?;
12029        let retried_count = preflight_retry.connection.query_row(
12030            "SELECT COUNT(*) FROM graph_relations
12031              WHERE document_unresolved_reason IS NOT NULL",
12032            [],
12033            |row| row.get::<_, i64>(0),
12034        )?;
12035        require_eq(
12036            &retried_count,
12037            &i64::try_from(total)?,
12038            "successful retry did not publish every document reason",
12039        )?;
12040        drop(preflight_retry);
12041        require_eq(
12042            &store.repository_graph_generation()?,
12043            &Some(baseline_generation),
12044            "successful preflight retry changed the current generation before completion",
12045        )?;
12046
12047        let mut canceled_publication = store.begin_index_publication("document-reason-chunks")?;
12048        populate(&mut canceled_publication)?;
12049        canceled_publication
12050            .set_document_unresolved_reasons(&reasons[..GraphLimits::MAX_ROWS as usize])?;
12051        let cancellation = projectatlas_core::IndexCancellation::new();
12052        cancellation.cancel();
12053        let canceled_control = IndexWorkControl::new(cancellation, None);
12054        let canceled = canceled_publication.set_document_unresolved_reasons_controlled(
12055            &reasons[GraphLimits::MAX_ROWS as usize..],
12056            &canceled_control,
12057        );
12058        require(
12059            matches!(
12060                canceled,
12061                Err(DbError::IndexWork(
12062                    projectatlas_core::IndexWorkFailure::Cancelled {
12063                        stage: IndexWorkStage::Publication
12064                    }
12065                ))
12066            ),
12067            "post-first-chunk cancellation was accepted",
12068        )?;
12069        let canceled_first_chunk = canceled_publication.connection.query_row(
12070            "SELECT COUNT(*) FROM graph_relations
12071              WHERE document_unresolved_reason IS NOT NULL",
12072            [],
12073            |row| row.get::<_, i64>(0),
12074        )?;
12075        require_eq(
12076            &canceled_first_chunk,
12077            &i64::from(GraphLimits::MAX_ROWS),
12078            "cancellation did not leave the first chunk observable inside the parent transaction",
12079        )?;
12080        drop(canceled_publication);
12081        require_eq(
12082            &store.repository_graph_generation()?,
12083            &Some(baseline_generation),
12084            "canceled publication changed the current generation",
12085        )?;
12086
12087        let mut in_chunk_canceled = store.begin_index_publication("document-reason-chunks")?;
12088        populate(&mut in_chunk_canceled)?;
12089        let cancellation = IndexCancellation::new();
12090        let control = IndexWorkControl::new(cancellation.clone(), None);
12091        let attempted_updates = Arc::new(AtomicUsize::new(0));
12092        let attempted_updates_hook = Arc::clone(&attempted_updates);
12093        in_chunk_canceled.connection.update_hook(Some(
12094            move |action: rusqlite::hooks::Action, _database: &str, table: &str, _rowid: i64| {
12095                if action == rusqlite::hooks::Action::SQLITE_UPDATE
12096                    && table == "graph_relations"
12097                    && attempted_updates_hook.fetch_add(1, Ordering::Relaxed) == 0
12098                {
12099                    cancellation.cancel();
12100                }
12101            },
12102        ))?;
12103        let in_chunk = in_chunk_canceled.set_document_unresolved_reasons_controlled(
12104            &reasons[..GraphLimits::MAX_ROWS as usize],
12105            &control,
12106        );
12107        in_chunk_canceled
12108            .connection
12109            .update_hook(None::<fn(rusqlite::hooks::Action, &str, &str, i64)>)?;
12110        require(
12111            matches!(
12112                in_chunk,
12113                Err(DbError::IndexWork(
12114                    projectatlas_core::IndexWorkFailure::Cancelled {
12115                        stage: IndexWorkStage::Publication
12116                    }
12117                ))
12118            ),
12119            "in-chunk cancellation was not typed",
12120        )?;
12121        require(
12122            attempted_updates.load(Ordering::Relaxed) >= 1,
12123            "in-chunk cancellation did not follow an attempted update",
12124        )?;
12125        let in_chunk_untouched = in_chunk_canceled.connection.query_row(
12126            "SELECT COUNT(*) FROM graph_relations
12127              WHERE document_unresolved_reason IS NOT NULL",
12128            [],
12129            |row| row.get::<_, i64>(0),
12130        )?;
12131        require_eq(
12132            &in_chunk_untouched,
12133            &0,
12134            "in-chunk cancellation exposed a partial document-reason update",
12135        )?;
12136        drop(in_chunk_canceled);
12137        require_eq(
12138            &store.repository_graph_generation()?,
12139            &Some(baseline_generation),
12140            "in-chunk cancellation changed the current generation",
12141        )?;
12142
12143        let mut in_chunk_retry = store.begin_index_publication("document-reason-chunks")?;
12144        populate(&mut in_chunk_retry)?;
12145        in_chunk_retry
12146            .set_document_unresolved_reasons(&reasons[..GraphLimits::MAX_ROWS as usize])?;
12147        let in_chunk_retry_count = in_chunk_retry.connection.query_row(
12148            "SELECT COUNT(*) FROM graph_relations
12149              WHERE document_unresolved_reason IS NOT NULL",
12150            [],
12151            |row| row.get::<_, i64>(0),
12152        )?;
12153        require_eq(
12154            &in_chunk_retry_count,
12155            &i64::from(GraphLimits::MAX_ROWS),
12156            "writer reuse after in-chunk cancellation did not retry cleanly",
12157        )?;
12158        drop(in_chunk_retry);
12159        require_eq(
12160            &store.repository_graph_generation()?,
12161            &Some(baseline_generation),
12162            "in-chunk retry changed the current generation before completion",
12163        )?;
12164
12165        let mut publication = store.begin_index_publication("document-reason-chunks")?;
12166        populate(&mut publication)?;
12167        publication.connection.execute_batch(
12168            "CREATE TRIGGER fail_document_reason_chunk
12169             BEFORE UPDATE OF document_unresolved_reason ON graph_relations
12170             WHEN NEW.reference_text = 'missing-10000.md'
12171             BEGIN
12172                 SELECT RAISE(ABORT, 'injected document reason failure');
12173             END;",
12174        )?;
12175        let fault = publication.set_document_unresolved_reasons(&reasons);
12176        require(
12177            matches!(fault, Err(DbError::Sqlite(_))),
12178            "fault between document-reason chunks was accepted",
12179        )?;
12180        let rolled_back = publication.connection.query_row(
12181            "SELECT COUNT(*) FROM graph_relations
12182              WHERE document_unresolved_reason IS NOT NULL",
12183            [],
12184            |row| row.get::<_, i64>(0),
12185        )?;
12186        require_eq(
12187            &rolled_back,
12188            &0,
12189            "fault between chunks exposed an earlier chunk",
12190        )?;
12191        drop(publication);
12192        require(
12193            store.repository_graph_generation()? == Some(baseline_generation),
12194            "fault between chunks replaced the current generation",
12195        )?;
12196
12197        let mut publication = store.begin_index_publication("document-reason-chunks")?;
12198        populate(&mut publication)?;
12199        let changes_before = publication.connection.total_changes();
12200        publication.set_document_unresolved_reasons(&reasons)?;
12201        let changes_after = publication.connection.total_changes();
12202        require_eq(
12203            &changes_after.saturating_sub(changes_before),
12204            &u64::try_from(total)?,
12205            "bounded reason update changed rows",
12206        )?;
12207        publication.complete()?;
12208        let (relation_count, reason_count): (i64, i64) = store.connection.query_row(
12209            "SELECT COUNT(*), COUNT(document_unresolved_reason)
12210               FROM graph_relations
12211              WHERE relation_scope = 'extended' AND relation_kind = 'documents'",
12212            [],
12213            |row| Ok((row.get(0)?, row.get(1)?)),
12214        )?;
12215        require_eq(
12216            &relation_count,
12217            &i64::try_from(total)?,
12218            "complete document relation count",
12219        )?;
12220        require_eq(
12221            &reason_count,
12222            &i64::try_from(total)?,
12223            "complete document reason count",
12224        )?;
12225        assert_query_indexes(&store)?;
12226        Ok(())
12227    }
12228
12229    #[test]
12230    fn high_fanout_document_refresh_has_bounded_sql_and_changed_rows() -> Result<(), Box<dyn Error>>
12231    {
12232        const FANOUT: usize = 256;
12233        const EXPECTED_STATEMENTS: usize = 2_083;
12234        const EXPECTED_CHANGED_ROWS: u64 = 1_031;
12235
12236        let temp = tempfile::tempdir()?;
12237        let root = temp.path().join("high-fanout-document");
12238        fs::create_dir(&root)?;
12239        let database = temp.path().join("projectatlas.db");
12240        let mut store = AtlasStore::open_for_project(&database, &root)?;
12241        let project = store
12242            .project_instance_id()?
12243            .ok_or_else(|| io::Error::other("high-fanout fixture identity is missing"))?;
12244        let document_path = RepositoryFilePath::new(Path::new("docs/high-fanout.md"))?;
12245
12246        let graph = |generation: IndexGeneration| -> DbResult<_> {
12247            let document = GraphEntity::new(
12248                project,
12249                EntitySelector::File {
12250                    path: document_path.clone(),
12251                },
12252                generation,
12253            )?;
12254            let mut entities = vec![document.clone()];
12255            let mut relations = Vec::with_capacity(FANOUT);
12256            let mut occurrences = Vec::with_capacity(FANOUT);
12257            for (index, line) in (2_u32..).take(FANOUT).enumerate() {
12258                let target = GraphEntity::new(
12259                    project,
12260                    EntitySelector::File {
12261                        path: RepositoryFilePath::new(Path::new(&format!(
12262                            "src/target_{index:04}.rs"
12263                        )))?,
12264                    },
12265                    generation,
12266                )?;
12267                let relation = LogicalRelation::new(
12268                    &document,
12269                    GraphRelationKind::Extended(ExtendedRelationKind::Documents),
12270                    RelationResolution::resolved(&target)?,
12271                    ConfidenceClass::Exact,
12272                    Completeness::Complete,
12273                    generation,
12274                )?;
12275                occurrences.push(RelationOccurrence::new(
12276                    &relation,
12277                    document_path.clone(),
12278                    SourceSpan::new(line, 0, line, 20)?,
12279                    generation,
12280                )?);
12281                entities.push(target);
12282                relations.push(relation);
12283            }
12284            Ok((entities, relations, occurrences))
12285        };
12286
12287        let mut nodes = vec![
12288            graph_node(".", NodeKind::Folder, None),
12289            graph_node("docs", NodeKind::Folder, Some(".")),
12290            graph_node("docs/high-fanout.md", NodeKind::File, Some("docs")),
12291            graph_node("src", NodeKind::Folder, Some(".")),
12292        ];
12293        nodes.extend((0..FANOUT).map(|index| {
12294            graph_node(
12295                &format!("src/target_{index:04}.rs"),
12296                NodeKind::File,
12297                Some("src"),
12298            )
12299        }));
12300        let (entities, relations, occurrences) = graph(IndexGeneration::new(1))?;
12301        {
12302            let mut publication = store.begin_index_publication("high-fanout-document")?;
12303            publication.begin_scan_replacement()?;
12304            for batch in nodes.chunks(128) {
12305                publication.upsert_scan_node_batch(batch)?;
12306            }
12307            publication.finish_scan_replacement()?;
12308            publication.replace_repository_graph(
12309                project,
12310                &entities,
12311                &relations,
12312                &occurrences,
12313                &[],
12314            )?;
12315            publication.complete()?;
12316        }
12317
12318        let (entities, relations, occurrences) = graph(IndexGeneration::new(2))?;
12319        TRACED_STATEMENTS.with(|statements| statements.borrow_mut().clear());
12320        store.connection.trace_v2(
12321            rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT,
12322            Some(record_traced_event),
12323        );
12324        let changed_before = store.connection.total_changes();
12325        let refresh = (|| -> DbResult<()> {
12326            let mut publication = store.begin_index_publication("high-fanout-document")?;
12327            publication.replace_repository_graph_for_paths(
12328                project,
12329                &["docs/high-fanout.md".to_string()],
12330                &entities,
12331                &relations,
12332                &occurrences,
12333                &[],
12334            )?;
12335            publication.complete()
12336        })();
12337        store
12338            .connection
12339            .trace_v2(rusqlite::trace::TraceEventCodes::empty(), None);
12340        refresh?;
12341        let changed_rows = store.connection.total_changes() - changed_before;
12342        let statements =
12343            TRACED_STATEMENTS.with(|statements| std::mem::take(&mut *statements.borrow_mut()));
12344
12345        require_eq(
12346            &statements.len(),
12347            &EXPECTED_STATEMENTS,
12348            "high-fanout refresh statement count",
12349        )?;
12350        require_eq(
12351            &changed_rows,
12352            &EXPECTED_CHANGED_ROWS,
12353            "high-fanout refresh changed rows",
12354        )?;
12355        let relation_count = usize::try_from(store.connection.query_row(
12356            "SELECT COUNT(*) FROM graph_relations
12357              WHERE relation_scope = 'extended' AND relation_kind = 'documents'",
12358            [],
12359            |row| row.get::<_, i64>(0),
12360        )?)?;
12361        require_eq(&relation_count, &FANOUT, "retained document relation count")?;
12362        Ok(())
12363    }
12364
12365    #[test]
12366    fn every_graph_limit_kind_round_trips_and_unknown_spelling_rolls_back()
12367    -> Result<(), Box<dyn Error>> {
12368        let temp = tempfile::tempdir()?;
12369        let root = temp.path().join("graph-limit-kinds");
12370        fs::create_dir_all(&root)?;
12371        let database = root.join("projectatlas.db");
12372        let mut store = AtlasStore::open_for_project(&database, &root)?;
12373        let project = store
12374            .project_instance_id()?
12375            .ok_or_else(|| io::Error::other("graph-limit fixture identity is missing"))?;
12376        let generation = IndexGeneration::new(1);
12377        let mut coverage = Vec::with_capacity(GraphLimitKind::ALL.len());
12378        for kind in GraphLimitKind::ALL {
12379            coverage.push(CoverageRecord::new(
12380                CoverageScope::Path {
12381                    path: RepositoryNodePath::new(Path::new(&format!(
12382                        "limits/{}.rs",
12383                        kind.as_str()
12384                    )))?,
12385                },
12386                None,
12387                CoverageState::Partial,
12388                1,
12389                1,
12390                generation,
12391                Some(GraphIdentityText::new("graph limit reached")?),
12392                Some(kind),
12393            )?);
12394        }
12395        let mut publication = store.begin_index_publication("graph-limit-kinds")?;
12396        publication.replace_repository_graph(project, &[], &[], &[], &coverage)?;
12397        publication.complete()?;
12398
12399        let verify = |store: &AtlasStore| -> Result<(), Box<dyn Error>> {
12400            for kind in GraphLimitKind::ALL {
12401                let page = store.repository_graph_coverage(
12402                    project,
12403                    &CoverageScope::Path {
12404                        path: RepositoryNodePath::new(Path::new(&format!(
12405                            "limits/{}.rs",
12406                            kind.as_str()
12407                        )))?,
12408                    },
12409                    1,
12410                )?;
12411                require_eq(&page.rows.len(), &1, "graph-limit coverage row count")?;
12412                require_eq(
12413                    &page.rows[0].reached_limit(),
12414                    &Some(kind),
12415                    "graph-limit coverage spelling",
12416                )?;
12417            }
12418            Ok(())
12419        };
12420        verify(&store)?;
12421
12422        store.connection.execute_batch("BEGIN IMMEDIATE")?;
12423        let invalid = (|| -> DbResult<()> {
12424            store.connection.execute("DELETE FROM graph_coverage", [])?;
12425            store.connection.execute(
12426                "INSERT INTO graph_coverage(
12427                    project_instance_id, scope_kind, state, total, covered, omitted,
12428                    reason, reached_limit
12429                 ) VALUES(?1, 'project', 'partial', 2, 1, 1, 'invalid limit', 'rowz')",
12430                [&project.as_bytes()[..]],
12431            )?;
12432            Ok(())
12433        })();
12434        require(
12435            matches!(invalid, Err(DbError::Sqlite(_))),
12436            "unknown graph-limit spelling bypassed the SQLite constraint",
12437        )?;
12438        store.connection.execute_batch("ROLLBACK")?;
12439        verify(&store)?;
12440        drop(store);
12441
12442        let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
12443        verify(&reopened)?;
12444        Ok(())
12445    }
12446
12447    #[test]
12448    fn typed_graph_round_trips_through_bounded_indexed_queries() -> Result<(), Box<dyn Error>> {
12449        let temp = tempfile::tempdir()?;
12450        let project_root = temp.path().join("typed-graph");
12451        let atlas_dir = project_root.join(".projectatlas");
12452        fs::create_dir_all(&atlas_dir)?;
12453        let db_path = atlas_dir.join("projectatlas.db");
12454        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
12455        let fixture = publish_fixture(&mut writer, "typed-graph")?;
12456        drop(writer);
12457        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12458
12459        for expected in &fixture.entities {
12460            require_eq(
12461                &store.repository_graph_entity(expected.key())?,
12462                &Some(expected.clone()),
12463                "stable entity lookup",
12464            )?;
12465        }
12466
12467        let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
12468        let truncated =
12469            store.repository_graph_entities_by_path(fixture.project, &source_path, 1)?;
12470        require(
12471            truncated.truncated && truncated.rows.len() == 1,
12472            "entity LIMIT + 1",
12473        )?;
12474        let path_rows =
12475            store.repository_graph_entities_by_path(fixture.project, &source_path, 10)?;
12476        require(
12477            !path_rows.truncated && path_rows.rows.len() == 2,
12478            "Unicode/case path lookup",
12479        )?;
12480        for (result, context) in [
12481            (
12482                store.repository_graph_entities_by_path(fixture.project, &source_path, 0),
12483                "zero entity page limit",
12484            ),
12485            (
12486                store.repository_graph_entities_by_path(
12487                    fixture.project,
12488                    &source_path,
12489                    GraphLimits::MAX_ROWS + 1,
12490                ),
12491                "over-ceiling entity page limit",
12492            ),
12493        ] {
12494            let error = require_db_error(result, context)?;
12495            require(
12496                matches!(error, DbError::GraphContract(_)),
12497                &format!("unexpected {context} error: {error}"),
12498            )?;
12499        }
12500
12501        let source = fixture
12502            .entities
12503            .iter()
12504            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
12505            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
12506        let outbound = store.repository_graph_relations(
12507            RepositoryGraphRelationQuery::Outbound {
12508                source: source.key().clone(),
12509            },
12510            10,
12511        )?;
12512        require_eq(&outbound.rows.len(), &4, "all resolution states")?;
12513        require(
12514            outbound.rows.iter().any(|relation| {
12515                matches!(relation.resolution(), RelationResolution::Resolved { .. })
12516            }) && outbound.rows.iter().any(|relation| {
12517                matches!(relation.resolution(), RelationResolution::Ambiguous { .. })
12518            }) && outbound.rows.iter().any(|relation| {
12519                matches!(relation.resolution(), RelationResolution::Unresolved { .. })
12520            }) && outbound.rows.iter().any(|relation| {
12521                matches!(relation.resolution(), RelationResolution::External { .. })
12522            }),
12523            "resolution variants did not round-trip",
12524        )?;
12525        let outbound_truncated = store.repository_graph_relations(
12526            RepositoryGraphRelationQuery::Outbound {
12527                source: source.key().clone(),
12528            },
12529            3,
12530        )?;
12531        require(
12532            outbound_truncated.truncated && outbound_truncated.rows.len() == 3,
12533            "relation LIMIT + 1",
12534        )?;
12535        for (limit, context) in [
12536            (0, "zero relation page limit"),
12537            (
12538                GraphLimits::MAX_ROWS + 1,
12539                "over-ceiling relation page limit",
12540            ),
12541        ] {
12542            let error = require_db_error(
12543                store.repository_graph_relations(
12544                    RepositoryGraphRelationQuery::Outbound {
12545                        source: source.key().clone(),
12546                    },
12547                    limit,
12548                ),
12549                context,
12550            )?;
12551            require(
12552                matches!(error, DbError::GraphContract(_)),
12553                &format!("unexpected {context} error: {error}"),
12554            )?;
12555        }
12556
12557        let symbol = fixture
12558            .entities
12559            .iter()
12560            .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
12561            .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
12562        let inbound = store.repository_graph_relations(
12563            RepositoryGraphRelationQuery::Inbound {
12564                target: symbol.key().clone(),
12565            },
12566            10,
12567        )?;
12568        require_eq(&inbound.rows.len(), &1, "inbound relation lookup")?;
12569        let calls = store.repository_graph_relations(
12570            RepositoryGraphRelationQuery::Family {
12571                relation: GraphRelationKind::Legacy(RelationKind::Calls),
12572            },
12573            10,
12574        )?;
12575        require_eq(&calls.rows.len(), &1, "relation-family lookup")?;
12576        let occurrence_page = store.repository_graph_occurrences(&calls.rows[0], 1)?;
12577        require(
12578            occurrence_page.truncated && occurrence_page.rows.len() == 1,
12579            "occurrence LIMIT + 1",
12580        )?;
12581        let all_occurrences = store.repository_graph_occurrences(&calls.rows[0], 10)?;
12582        require_eq(
12583            &all_occurrences.rows.len(),
12584            &2,
12585            "logical relation occurrence retention",
12586        )?;
12587        let error = require_db_error(
12588            store.repository_graph_occurrences(&calls.rows[0], 0),
12589            "zero occurrence page limit was accepted",
12590        )?;
12591        require(
12592            matches!(error, DbError::GraphContract(_)),
12593            &format!("unexpected zero occurrence-limit error: {error}"),
12594        )?;
12595        let error = require_db_error(
12596            store.repository_graph_occurrences(&calls.rows[0], GraphLimits::MAX_OCCURRENCES + 1),
12597            "over-ceiling occurrence page was accepted",
12598        )?;
12599        require(
12600            matches!(error, DbError::GraphContract(_)),
12601            &format!("unexpected occurrence-limit error: {error}"),
12602        )?;
12603
12604        let project_coverage =
12605            store.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10)?;
12606        let path_coverage = store.repository_graph_coverage(
12607            fixture.project,
12608            &CoverageScope::Path {
12609                path: RepositoryNodePath::new(Path::new("src"))?,
12610            },
12611            10,
12612        )?;
12613        require_eq(&project_coverage.rows.len(), &8, "project coverage states")?;
12614        require_eq(&path_coverage.rows.len(), &1, "path coverage state")?;
12615        require(
12616            project_coverage
12617                .rows
12618                .iter()
12619                .any(|row| row.state() == CoverageState::NoCandidates && row.total() == 0),
12620            "zero-candidate coverage did not round-trip",
12621        )?;
12622        require(
12623            project_coverage.rows.iter().any(|row| {
12624                row.state() == CoverageState::Complete
12625                    && row.total() == 0
12626                    && row.relation()
12627                        == Some(GraphRelationKind::Extended(ExtendedRelationKind::Tests))
12628            }),
12629            "non-document complete-zero coverage changed public state",
12630        )?;
12631        require(
12632            path_coverage.rows[0].state() == CoverageState::Partial,
12633            "partial coverage did not round-trip",
12634        )?;
12635        for (limit, context) in [
12636            (0, "zero coverage page limit"),
12637            (
12638                GraphLimits::MAX_ROWS + 1,
12639                "over-ceiling coverage page limit",
12640            ),
12641        ] {
12642            let error = require_db_error(
12643                store.repository_graph_coverage(fixture.project, &CoverageScope::Project, limit),
12644                context,
12645            )?;
12646            require(
12647                matches!(error, DbError::GraphContract(_)),
12648                &format!("unexpected {context} error: {error}"),
12649            )?;
12650        }
12651
12652        let source_next = GraphEntity::new(
12653            fixture.project,
12654            source.selector().clone(),
12655            IndexGeneration::new(2),
12656        )?;
12657        let symbol_next = GraphEntity::new(
12658            fixture.project,
12659            symbol.selector().clone(),
12660            IndexGeneration::new(2),
12661        )?;
12662        let next_generation_call = LogicalRelation::new(
12663            &source_next,
12664            GraphRelationKind::Legacy(RelationKind::Calls),
12665            RelationResolution::resolved(&symbol_next)?,
12666            ConfidenceClass::Exact,
12667            Completeness::Complete,
12668            IndexGeneration::new(2),
12669        )?;
12670        let error = require_db_error(
12671            store.repository_graph_occurrences(&next_generation_call, 10),
12672            "generation-mismatched occurrence request was accepted",
12673        )?;
12674        require(
12675            matches!(error, DbError::GraphContract(_)),
12676            &format!("unexpected occurrence generation error: {error}"),
12677        )?;
12678
12679        let lexical = store
12680            .load_file_text("src/Äuth.rs")?
12681            .ok_or_else(|| io::Error::other("lexical source row missing"))?;
12682        require_eq(
12683            &lexical.content,
12684            &"fn verifyToken()".to_string(),
12685            "lexical owner",
12686        )?;
12687        require_eq(
12688            &store.symbol_relation_count()?,
12689            &1,
12690            "legacy relation projection changed",
12691        )?;
12692
12693        store.finish_index_read_snapshot()?;
12694        drop(store);
12695        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
12696        let mut publication = writer.begin_index_publication("typed-graph")?;
12697        publication.replace_repository_graph_for_paths(
12698            fixture.project,
12699            &["src/unrelated.rs".to_string()],
12700            &[],
12701            &[],
12702            &[],
12703            &[],
12704        )?;
12705        publication.complete()?;
12706        drop(writer);
12707        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12708        let reused = store
12709            .repository_graph_entity(source.key())?
12710            .ok_or_else(|| io::Error::other("unchanged entity disappeared"))?;
12711        require_eq(
12712            &reused.generation(),
12713            &IndexGeneration::new(2),
12714            "unchanged graph row generation injection",
12715        )?;
12716        for expected in &fixture.entities {
12717            let reused = store
12718                .repository_graph_entity(expected.key())?
12719                .ok_or_else(|| io::Error::other("unchanged graph entity disappeared"))?;
12720            require_eq(
12721                &reused.generation(),
12722                &IndexGeneration::new(2),
12723                "incremental graph row reuse",
12724            )?;
12725        }
12726        assert_query_indexes(&store)?;
12727        store.finish_index_read_snapshot()?;
12728        Ok(())
12729    }
12730
12731    #[test]
12732    fn batched_adjacency_uses_direction_owned_indexes_and_stable_keysets()
12733    -> Result<(), Box<dyn Error>> {
12734        let temp = tempfile::tempdir()?;
12735        let project_root = temp.path().join("batched-adjacency");
12736        let atlas_dir = project_root.join(".projectatlas");
12737        fs::create_dir_all(&atlas_dir)?;
12738        let db_path = atlas_dir.join("projectatlas.db");
12739        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
12740        let fixture = publish_fixture(&mut writer, "batched-adjacency")?;
12741        drop(writer);
12742        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12743
12744        let source = fixture
12745            .entities
12746            .iter()
12747            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
12748            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
12749        let symbol = fixture
12750            .entities
12751            .iter()
12752            .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
12753            .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
12754        let external = fixture
12755            .entities
12756            .iter()
12757            .find(|entity| matches!(entity.selector(), EntitySelector::External { .. }))
12758            .ok_or_else(|| io::Error::other("external fixture missing"))?;
12759        let outbound_frontier = vec![source.key().clone()];
12760
12761        let first = store.repository_graph_adjacency_page(
12762            &outbound_frontier,
12763            RepositoryGraphDirection::Outbound,
12764            None,
12765            2,
12766            None,
12767        )?;
12768        require(
12769            first.truncated && first.rows.len() == 2 && first.continuation.is_some(),
12770            "outbound adjacency did not retain its LIMIT + 1 keyset",
12771        )?;
12772        require(
12773            first.rows.iter().all(|row| {
12774                row.frontier_index == 0
12775                    && row.frontier == source.key().clone()
12776                    && row.direction == RepositoryGraphDirection::Outbound
12777            }),
12778            "outbound adjacency lost its selecting frontier",
12779        )?;
12780        let continuation = first
12781            .continuation
12782            .clone()
12783            .ok_or_else(|| io::Error::other("outbound continuation missing"))?;
12784        let second = store.repository_graph_adjacency_page(
12785            &outbound_frontier,
12786            RepositoryGraphDirection::Outbound,
12787            Some(&continuation),
12788            10,
12789            None,
12790        )?;
12791        require(
12792            !second.truncated && second.rows.len() == 2 && second.continuation.is_none(),
12793            "outbound continuation did not finish the stable relation order",
12794        )?;
12795        let ordinary = store.repository_graph_relations(
12796            RepositoryGraphRelationQuery::Outbound {
12797                source: source.key().clone(),
12798            },
12799            10,
12800        )?;
12801        let combined = first
12802            .rows
12803            .into_iter()
12804            .chain(second.rows)
12805            .map(|row| row.detail.relation)
12806            .collect::<Vec<_>>();
12807        require_eq(
12808            &combined,
12809            &ordinary.rows,
12810            "adjacency keyset order versus ordinary relation order",
12811        )?;
12812        let calls = GraphRelationKind::Legacy(RelationKind::Calls);
12813        let filtered = store.repository_graph_adjacency_page_filtered(
12814            &outbound_frontier,
12815            RepositoryGraphDirection::Outbound,
12816            Some(calls),
12817            None,
12818            10,
12819            None,
12820        )?;
12821        let expected_calls = ordinary
12822            .rows
12823            .iter()
12824            .filter(|relation| relation.kind() == calls)
12825            .cloned()
12826            .collect::<Vec<_>>();
12827        require_eq(
12828            &filtered
12829                .rows
12830                .iter()
12831                .map(|row| row.detail.relation.clone())
12832                .collect::<Vec<_>>(),
12833            &expected_calls,
12834            "filtered adjacency versus ordinary family selection",
12835        )?;
12836        require(
12837            !filtered.truncated && filtered.continuation.is_none(),
12838            "filtered adjacency unexpectedly truncated its complete family",
12839        )?;
12840        let resolved = store.repository_graph_resolved_adjacency_page(
12841            &outbound_frontier,
12842            RepositoryGraphDirection::Outbound,
12843            calls,
12844            None,
12845            10,
12846            None,
12847        )?;
12848        require_eq(
12849            &resolved
12850                .rows
12851                .iter()
12852                .map(|row| row.detail.relation.clone())
12853                .collect::<Vec<_>>(),
12854            &expected_calls,
12855            "resolved adjacency versus exact local family selection",
12856        )?;
12857        let unresolved = store.repository_graph_resolved_adjacency_page(
12858            &outbound_frontier,
12859            RepositoryGraphDirection::Outbound,
12860            GraphRelationKind::Extended(ExtendedRelationKind::Configures),
12861            None,
12862            10,
12863            None,
12864        )?;
12865        require_eq(
12866            &unresolved.rows.len(),
12867            &0,
12868            "resolved adjacency retained unresolved rows",
12869        )?;
12870        let external_only = store.repository_graph_resolved_adjacency_page(
12871            &outbound_frontier,
12872            RepositoryGraphDirection::Outbound,
12873            GraphRelationKind::Legacy(RelationKind::DependsOn),
12874            None,
12875            10,
12876            None,
12877        )?;
12878        require_eq(
12879            &external_only.rows.len(),
12880            &0,
12881            "resolved adjacency retained external rows",
12882        )?;
12883        let mut resolved_cursor = continuation.clone();
12884        resolved_cursor.relation = Some(calls);
12885        resolved_cursor.resolved_only = true;
12886        let encoded = serde_json::to_string(&resolved_cursor)?;
12887        let decoded: RepositoryGraphAdjacencyContinuation = serde_json::from_str(&encoded)?;
12888        require(
12889            encoded.contains("\"resolved_only\":true") && decoded == resolved_cursor,
12890            "resolved adjacency cursor did not preserve its mode through serde",
12891        )?;
12892        let resolved_mode_mismatch = require_db_error(
12893            store.repository_graph_adjacency_page_filtered(
12894                &outbound_frontier,
12895                RepositoryGraphDirection::Outbound,
12896                Some(calls),
12897                Some(&decoded),
12898                1,
12899                None,
12900            ),
12901            "resolved adjacency cursor was accepted by ordinary adjacency",
12902        )?;
12903        require(
12904            matches!(resolved_mode_mismatch, DbError::GraphContract(_)),
12905            &format!("resolved cursor mismatch returned {resolved_mode_mismatch}"),
12906        )?;
12907
12908        let inbound_frontier = vec![symbol.key().clone(), external.key().clone()];
12909        let inbound = store.repository_graph_adjacency_page(
12910            &inbound_frontier,
12911            RepositoryGraphDirection::Inbound,
12912            None,
12913            10,
12914            None,
12915        )?;
12916        require(
12917            !inbound.truncated
12918                && inbound.rows.len() == 2
12919                && inbound.rows[0].frontier_index == 0
12920                && inbound.rows[0].frontier == symbol.key().clone()
12921                && inbound.rows[1].frontier_index == 1
12922                && inbound.rows[1].frontier == external.key().clone()
12923                && inbound
12924                    .rows
12925                    .iter()
12926                    .all(|row| row.direction == RepositoryGraphDirection::Inbound),
12927            "inbound adjacency did not preserve bounded frontier order",
12928        )?;
12929
12930        for (direction, expected_index) in [
12931            (
12932                RepositoryGraphDirection::Outbound,
12933                "idx_graph_relations_source_kind",
12934            ),
12935            (
12936                RepositoryGraphDirection::Inbound,
12937                "idx_graph_relations_target_kind",
12938            ),
12939        ] {
12940            for continuation_index in [None, Some(0)] {
12941                let sql = format!(
12942                    "EXPLAIN QUERY PLAN {}",
12943                    adjacency_relation_sql(2, direction, continuation_index, false, false, false,)
12944                );
12945                let mut statement = store.connection.prepare(&sql)?;
12946                let mut bindings = vec![Value::Blob(fixture.project.as_bytes().to_vec())];
12947                bindings.push(Value::Blob(source.key().digest_bytes()?.to_vec()));
12948                if continuation_index.is_some() {
12949                    bindings.extend([
12950                        Value::Text(String::new()),
12951                        Value::Text(String::new()),
12952                        Value::Blob(vec![0; 32]),
12953                    ]);
12954                }
12955                bindings.extend([
12956                    Value::Integer(11),
12957                    Value::Blob(symbol.key().digest_bytes()?.to_vec()),
12958                    Value::Integer(11),
12959                    Value::Integer(11),
12960                ]);
12961                let details = statement
12962                    .query_map(params_from_iter(bindings.iter()), |row| {
12963                        row.get::<_, String>(3)
12964                    })?
12965                    .collect::<Result<Vec<_>, _>>()?;
12966                require(
12967                    details.iter().any(|detail| detail.contains(expected_index))
12968                        && details
12969                            .iter()
12970                            .all(|detail| !detail.contains("SCAN relation")),
12971                    &format!(
12972                        "{direction:?} adjacency plan (continuation_index={continuation_index:?}) did not own its index: {details:?}"
12973                    ),
12974                )?;
12975            }
12976
12977            let (scope, kind) = relation_parts(calls);
12978            let filtered_sql = format!(
12979                "EXPLAIN QUERY PLAN {}",
12980                adjacency_relation_sql(2, direction, None, true, false, false)
12981            );
12982            let mut filtered_statement = store.connection.prepare(&filtered_sql)?;
12983            let filtered_bindings = [
12984                Value::Blob(fixture.project.as_bytes().to_vec()),
12985                Value::Blob(source.key().digest_bytes()?.to_vec()),
12986                Value::Text(scope.to_string()),
12987                Value::Text(kind.to_string()),
12988                Value::Integer(11),
12989                Value::Blob(symbol.key().digest_bytes()?.to_vec()),
12990                Value::Text(scope.to_string()),
12991                Value::Text(kind.to_string()),
12992                Value::Integer(11),
12993                Value::Integer(11),
12994            ];
12995            let filtered_details = filtered_statement
12996                .query_map(params_from_iter(filtered_bindings.iter()), |row| {
12997                    row.get::<_, String>(3)
12998                })?
12999                .collect::<Result<Vec<_>, _>>()?;
13000            require(
13001                filtered_details
13002                    .iter()
13003                    .any(|detail| detail.contains(expected_index))
13004                    && filtered_details
13005                        .iter()
13006                        .all(|detail| !detail.contains("SCAN relation")),
13007                &format!(
13008                    "filtered {direction:?} adjacency plan did not own its index: {filtered_details:?}"
13009                ),
13010            )?;
13011
13012            let resolved_sql = format!(
13013                "EXPLAIN QUERY PLAN {}",
13014                adjacency_relation_sql(2, direction, None, true, true, false)
13015            );
13016            let mut resolved_statement = store.connection.prepare(&resolved_sql)?;
13017            let resolved_bindings = [
13018                Value::Blob(fixture.project.as_bytes().to_vec()),
13019                Value::Text(RESOLUTION_STATUS_RESOLVED.to_string()),
13020                Value::Blob(source.key().digest_bytes()?.to_vec()),
13021                Value::Text(scope.to_string()),
13022                Value::Text(kind.to_string()),
13023                Value::Integer(11),
13024                Value::Blob(symbol.key().digest_bytes()?.to_vec()),
13025                Value::Text(scope.to_string()),
13026                Value::Text(kind.to_string()),
13027                Value::Integer(11),
13028                Value::Integer(11),
13029            ];
13030            let resolved_details = resolved_statement
13031                .query_map(params_from_iter(resolved_bindings.iter()), |row| {
13032                    row.get::<_, String>(3)
13033                })?
13034                .collect::<Result<Vec<_>, _>>()?;
13035            require(
13036                resolved_details
13037                    .iter()
13038                    .any(|detail| detail.contains(expected_index))
13039                    && resolved_details
13040                        .iter()
13041                        .all(|detail| !detail.contains("SCAN relation")),
13042                &format!(
13043                    "resolved {direction:?} adjacency plan did not own its index: {resolved_details:?}"
13044                ),
13045            )?;
13046        }
13047
13048        let duplicate_error = require_db_error(
13049            store.repository_graph_adjacency_page(
13050                &[source.key().clone(), source.key().clone()],
13051                RepositoryGraphDirection::Outbound,
13052                None,
13053                1,
13054                None,
13055            ),
13056            "duplicate adjacency frontier was accepted",
13057        )?;
13058        require(
13059            matches!(duplicate_error, DbError::GraphContract(_)),
13060            &format!("unexpected duplicate-frontier error: {duplicate_error}"),
13061        )?;
13062        let oversized = vec![source.key().clone(); MAX_REPOSITORY_GRAPH_FRONTIER + 1];
13063        let oversized_error = require_db_error(
13064            store.repository_graph_adjacency_page(
13065                &oversized,
13066                RepositoryGraphDirection::Outbound,
13067                None,
13068                1,
13069                None,
13070            ),
13071            "oversized adjacency frontier was accepted",
13072        )?;
13073        require(
13074            matches!(oversized_error, DbError::GraphContract(_)),
13075            &format!("unexpected oversized-frontier error: {oversized_error}"),
13076        )?;
13077        let foreign = GraphEntity::new(
13078            ProjectInstanceId::from_bytes([0x7f; 16])?,
13079            EntitySelector::Project,
13080            IndexGeneration::new(1),
13081        )?;
13082        let project_error = require_db_error(
13083            store.repository_graph_adjacency_page(
13084                &[source.key().clone(), foreign.key().clone()],
13085                RepositoryGraphDirection::Outbound,
13086                None,
13087                1,
13088                None,
13089            ),
13090            "mixed-project adjacency frontier was accepted",
13091        )?;
13092        require(
13093            matches!(project_error, DbError::GraphProjectIdentityMismatch { .. }),
13094            &format!("unexpected mixed-project error: {project_error}"),
13095        )?;
13096        let direction_error = require_db_error(
13097            store.repository_graph_adjacency_page(
13098                &outbound_frontier,
13099                RepositoryGraphDirection::Inbound,
13100                Some(&continuation),
13101                1,
13102                None,
13103            ),
13104            "cross-direction adjacency continuation was accepted",
13105        )?;
13106        require(
13107            matches!(direction_error, DbError::GraphContract(_)),
13108            &format!("unexpected continuation-direction error: {direction_error}"),
13109        )?;
13110        let mut filtered_continuation = continuation.clone();
13111        filtered_continuation.relation = Some(calls);
13112        let family_error = require_db_error(
13113            store.repository_graph_adjacency_page_filtered(
13114                &outbound_frontier,
13115                RepositoryGraphDirection::Outbound,
13116                Some(GraphRelationKind::Extended(
13117                    ExtendedRelationKind::Configures,
13118                )),
13119                Some(&filtered_continuation),
13120                1,
13121                None,
13122            ),
13123            "cross-family adjacency continuation was accepted",
13124        )?;
13125        require(
13126            matches!(family_error, DbError::GraphContract(_)),
13127            &format!("unexpected continuation-family error: {family_error}"),
13128        )?;
13129        let frontier_error = require_db_error(
13130            store.repository_graph_adjacency_page(
13131                &[external.key().clone()],
13132                RepositoryGraphDirection::Outbound,
13133                Some(&continuation),
13134                1,
13135                None,
13136            ),
13137            "cross-frontier adjacency continuation was accepted",
13138        )?;
13139        require(
13140            matches!(frontier_error, DbError::GraphContract(_)),
13141            &format!("unexpected continuation-frontier error: {frontier_error}"),
13142        )?;
13143
13144        let inbound_first = store.repository_graph_adjacency_page(
13145            &inbound_frontier,
13146            RepositoryGraphDirection::Inbound,
13147            None,
13148            1,
13149            None,
13150        )?;
13151        let inbound_continuation = inbound_first
13152            .continuation
13153            .ok_or_else(|| io::Error::other("inbound continuation missing"))?;
13154        require(
13155            store.repository_graph_adjacency_continuation_has_filtered_rows(
13156                &inbound_continuation,
13157                ConfidenceClass::Low,
13158                ContentSelection::UnspecifiedLegacy,
13159                None,
13160            )?,
13161            "multi-frontier continuation lost its remaining admitted relation",
13162        )?;
13163        let mut exhausted_continuation = inbound_continuation.clone();
13164        exhausted_continuation.relation = Some(GraphRelationKind::Extended(
13165            ExtendedRelationKind::Configures,
13166        ));
13167        require(
13168            !store.repository_graph_adjacency_continuation_has_filtered_rows(
13169                &exhausted_continuation,
13170                ConfidenceClass::Low,
13171                ContentSelection::UnspecifiedLegacy,
13172                None,
13173            )?,
13174            "multi-frontier continuation retained an excluded relation",
13175        )?;
13176        let reordered_frontier = vec![external.key().clone(), symbol.key().clone()];
13177        let reordered_error = require_db_error(
13178            store.repository_graph_adjacency_page(
13179                &reordered_frontier,
13180                RepositoryGraphDirection::Inbound,
13181                Some(&inbound_continuation),
13182                1,
13183                None,
13184            ),
13185            "reordered adjacency continuation frontier was accepted",
13186        )?;
13187        require(
13188            matches!(reordered_error, DbError::GraphContract(_)),
13189            &format!("unexpected reordered-frontier error: {reordered_error}"),
13190        )?;
13191        let empty_frontier_error = require_db_error(
13192            store.repository_graph_adjacency_page(
13193                &[],
13194                RepositoryGraphDirection::Inbound,
13195                Some(&inbound_continuation),
13196                1,
13197                None,
13198            ),
13199            "adjacency continuation without a frontier was accepted",
13200        )?;
13201        require(
13202            matches!(empty_frontier_error, DbError::GraphContract(_)),
13203            &format!("unexpected empty-frontier error: {empty_frontier_error}"),
13204        )?;
13205
13206        let mut foreign_project_continuation = continuation.clone();
13207        foreign_project_continuation.project = foreign.key().project();
13208        let continuation_project_error = require_db_error(
13209            store.repository_graph_adjacency_page(
13210                &outbound_frontier,
13211                RepositoryGraphDirection::Outbound,
13212                Some(&foreign_project_continuation),
13213                1,
13214                None,
13215            ),
13216            "cross-project adjacency continuation was accepted",
13217        )?;
13218        require(
13219            matches!(continuation_project_error, DbError::GraphContract(_)),
13220            &format!("unexpected continuation-project error: {continuation_project_error}"),
13221        )?;
13222        let mut stale_generation_continuation = continuation.clone();
13223        stale_generation_continuation.generation = stale_generation_continuation
13224            .generation
13225            .checked_next()
13226            .ok_or_else(|| io::Error::other("fixture generation overflowed"))?;
13227        let continuation_generation_error = require_db_error(
13228            store.repository_graph_adjacency_page(
13229                &outbound_frontier,
13230                RepositoryGraphDirection::Outbound,
13231                Some(&stale_generation_continuation),
13232                1,
13233                None,
13234            ),
13235            "cross-generation adjacency continuation was accepted",
13236        )?;
13237        require(
13238            matches!(continuation_generation_error, DbError::GraphContract(_)),
13239            &format!("unexpected continuation-generation error: {continuation_generation_error}"),
13240        )?;
13241
13242        let mut maximum_frontier = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER);
13243        for index in 0..MAX_REPOSITORY_GRAPH_FRONTIER {
13244            let entity = GraphEntity::new(
13245                fixture.project,
13246                EntitySelector::External {
13247                    external: ExternalSelector {
13248                        system: GraphIdentityText::new("work-envelope")?,
13249                        identity: GraphIdentityText::new(format!("candidate-{index}"))?,
13250                    },
13251                },
13252                continuation.generation,
13253            )?;
13254            maximum_frontier.push(entity.key().clone());
13255        }
13256        let bounded_work = store.repository_graph_adjacency_page(
13257            &maximum_frontier,
13258            RepositoryGraphDirection::Outbound,
13259            None,
13260            38,
13261            None,
13262        )?;
13263        require(
13264            bounded_work.rows.is_empty() && !bounded_work.truncated,
13265            "maximum adjacency frontier rejected work below the intermediate ceiling",
13266        )?;
13267        let excessive_work_error = require_db_error(
13268            store.repository_graph_adjacency_page(
13269                &maximum_frontier,
13270                RepositoryGraphDirection::Outbound,
13271                None,
13272                39,
13273                None,
13274            ),
13275            "adjacency intermediate work above the ceiling was accepted",
13276        )?;
13277        require(
13278            matches!(excessive_work_error, DbError::GraphContract(_)),
13279            &format!("unexpected intermediate-work error: {excessive_work_error}"),
13280        )?;
13281
13282        let successful_cancellation = projectatlas_core::IndexCancellation::new();
13283        let successful_control = IndexWorkControl::new(successful_cancellation.clone(), None);
13284        store.repository_graph_adjacency_page(
13285            &outbound_frontier,
13286            RepositoryGraphDirection::Outbound,
13287            None,
13288            10,
13289            Some(&successful_control),
13290        )?;
13291        successful_cancellation.cancel();
13292        require_eq(
13293            &store
13294                .connection
13295                .query_row("SELECT 1", [], |row| row.get::<_, i64>(0))?,
13296            &1,
13297            "cleared adjacency progress handler",
13298        )?;
13299
13300        let cancellation = projectatlas_core::IndexCancellation::new();
13301        cancellation.cancel();
13302        let control = IndexWorkControl::new(cancellation, None);
13303        let cancelled = store.repository_graph_adjacency_page(
13304            &outbound_frontier,
13305            RepositoryGraphDirection::Outbound,
13306            None,
13307            10,
13308            Some(&control),
13309        );
13310        require(
13311            matches!(
13312                cancelled,
13313                Err(DbError::IndexWork(
13314                    projectatlas_core::IndexWorkFailure::Cancelled {
13315                        stage: IndexWorkStage::RepositoryTraversal
13316                    }
13317                ))
13318            ),
13319            "adjacency cancellation was not typed",
13320        )?;
13321        store.finish_index_read_snapshot()?;
13322        Ok(())
13323    }
13324
13325    #[test]
13326    fn detailed_relation_storage_statements_are_indexed_and_batch_bounded()
13327    -> Result<(), Box<dyn Error>> {
13328        use DetailedRelationTraceStatement::{
13329            ActiveGraphGeneration, AdjacencyRelations, ProjectIdentity, PublicationMetadata,
13330            PurposeOwners, RelationEntities, RelationOccurrences, RelationsByDigest,
13331        };
13332
13333        let temp = tempfile::tempdir()?;
13334        let project_root = temp.path().join("detailed-relation-storage");
13335        let atlas_dir = project_root.join(".projectatlas");
13336        fs::create_dir_all(&atlas_dir)?;
13337        let db_path = atlas_dir.join("projectatlas.db");
13338        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
13339        let fixture =
13340            publish_detailed_relation_trace_fixture(&mut writer, "detailed-relation-storage")?;
13341        drop(writer);
13342        let mut store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
13343        let generation = store
13344            .repository_graph_generation()?
13345            .ok_or_else(|| io::Error::other("graph generation missing"))?;
13346        let budget = maximum_repository_graph_read_budget()?;
13347
13348        assert_cursor_hydration_indexes(&store)?;
13349
13350        let one_frontier = vec![fixture.source.key().clone()];
13351        let many_frontier = std::iter::once(fixture.source.key().clone())
13352            .chain(
13353                fixture
13354                    .relations
13355                    .iter()
13356                    .filter_map(|relation| relation.resolution().resolved_target().cloned())
13357                    .take(3),
13358            )
13359            .collect::<Vec<_>>();
13360        require_eq(
13361            &many_frontier.len(),
13362            &4,
13363            "multi-frontier trace fixture cardinality",
13364        )?;
13365        for (context, frontier) in [
13366            ("one-frontier adjacency", one_frontier),
13367            ("many-frontier adjacency", many_frontier),
13368        ] {
13369            let (page, statements) = trace_statements(&mut store, |store| {
13370                store.repository_graph_adjacency_page_bounded(
13371                    &frontier,
13372                    RepositoryGraphDirection::Outbound,
13373                    None,
13374                    4,
13375                    budget,
13376                    None,
13377                )
13378            })?;
13379            require(
13380                page.page.rows.len() == 4 && page.page.truncated,
13381                &format!("{context} did not exercise its bounded sentinel"),
13382            )?;
13383            require_traced_statement_multiset(
13384                &statements,
13385                &[
13386                    (ProjectIdentity, 1),
13387                    (PublicationMetadata, 1),
13388                    (ActiveGraphGeneration, 1),
13389                    (AdjacencyRelations, 1),
13390                    (RelationEntities, 1),
13391                ],
13392                context,
13393            )?;
13394        }
13395
13396        let relation_digests = fixture
13397            .relations
13398            .iter()
13399            .map(|relation| relation.key().digest_bytes())
13400            .collect::<Result<Vec<_>, _>>()?;
13401        for (context, digests, endpoint_chunks) in [
13402            ("one-relation hydration", &relation_digests[..1], 1),
13403            (
13404                "128 unique endpoint hydration",
13405                &relation_digests[..GRAPH_ENTITY_HYDRATION_CHUNK - 1],
13406                1,
13407            ),
13408            (
13409                "129 unique endpoint hydration",
13410                &relation_digests[..GRAPH_ENTITY_HYDRATION_CHUNK],
13411                2,
13412            ),
13413            ("maximum relation hydration", relation_digests.as_slice(), 3),
13414        ] {
13415            let (relations, statements) = trace_statements(&mut store, |store| {
13416                store.repository_graph_relation_rows_by_digest(
13417                    fixture.project,
13418                    generation,
13419                    digests,
13420                    budget,
13421                    None,
13422                )
13423            })?;
13424            require_eq(
13425                &relations.rows.len(),
13426                &digests.len(),
13427                &format!("{context} returned relation count"),
13428            )?;
13429            require_traced_statement_multiset(
13430                &statements,
13431                &[
13432                    (ProjectIdentity, 1),
13433                    (PublicationMetadata, 1),
13434                    (ActiveGraphGeneration, 1),
13435                    (RelationsByDigest, 1),
13436                    (RelationEntities, endpoint_chunks),
13437                ],
13438                context,
13439            )?;
13440        }
13441
13442        for (context, relations) in [
13443            ("one-relation occurrences", &fixture.relations[..1]),
13444            (
13445                "maximum relation occurrence batch",
13446                fixture.relations.as_slice(),
13447            ),
13448        ] {
13449            let (pages, statements) = trace_statements(&mut store, |store| {
13450                store.repository_graph_occurrence_pages_bounded(relations, 1, budget, None)
13451            })?;
13452            require(
13453                pages.pages.len() == relations.len()
13454                    && pages
13455                        .pages
13456                        .iter()
13457                        .all(|page| page.rows.len() == 1 && page.truncated),
13458                &format!("{context} did not retain one row plus its occurrence sentinel"),
13459            )?;
13460            require_traced_statement_multiset(
13461                &statements,
13462                &[
13463                    (ProjectIdentity, 1),
13464                    (PublicationMetadata, 1),
13465                    (ActiveGraphGeneration, 1),
13466                    (RelationOccurrences, 1),
13467                ],
13468                context,
13469            )?;
13470        }
13471        let mut oversized_occurrence_batch = fixture.relations.clone();
13472        oversized_occurrence_batch.push(fixture.relations[0].clone());
13473        let oversized_occurrences = require_db_error(
13474            store.repository_graph_occurrence_pages_bounded(
13475                &oversized_occurrence_batch,
13476                1,
13477                budget,
13478                None,
13479            ),
13480            "oversized relation occurrence batch was accepted",
13481        )?;
13482        require(
13483            matches!(oversized_occurrences, DbError::GraphContract(_)),
13484            &format!("unexpected oversized occurrence error: {oversized_occurrences}"),
13485        )?;
13486
13487        let one_purpose_path = vec![".".to_string()];
13488        let one_purpose_chunk = std::iter::once(".".to_string())
13489            .chain(
13490                (1..crate::MAX_PURPOSE_CURATION_BATCH_ROWS)
13491                    .map(|index| format!("missing/purpose-{index}.rs")),
13492            )
13493            .collect::<Vec<_>>();
13494        let two_purpose_chunks = one_purpose_chunk
13495            .iter()
13496            .cloned()
13497            .chain(std::iter::once("missing/purpose-overflow.rs".to_string()))
13498            .collect::<Vec<_>>();
13499        for (context, paths, expected) in [
13500            ("one purpose owner", one_purpose_path.as_slice(), 1),
13501            (
13502                "one full purpose-owner chunk",
13503                one_purpose_chunk.as_slice(),
13504                1,
13505            ),
13506            ("two purpose-owner chunks", two_purpose_chunks.as_slice(), 2),
13507        ] {
13508            let (_purposes, statements) = trace_statements(&mut store, |store| {
13509                store.load_purpose_owner_nodes_by_paths_controlled(
13510                    fixture.project,
13511                    generation,
13512                    paths,
13513                    budget,
13514                    None,
13515                )
13516            })?;
13517            require_traced_statement_multiset(
13518                &statements,
13519                &[
13520                    (ProjectIdentity, 1),
13521                    (PublicationMetadata, 1),
13522                    (ActiveGraphGeneration, 1),
13523                    (PurposeOwners, expected),
13524                ],
13525                context,
13526            )?;
13527        }
13528
13529        store.finish_index_read_snapshot()?;
13530        Ok(())
13531    }
13532
13533    #[test]
13534    fn cursor_hydration_is_ordered_bounded_and_fail_closed() -> Result<(), Box<dyn Error>> {
13535        let temp = tempfile::tempdir()?;
13536        let project_root = temp.path().join("cursor-hydration");
13537        let atlas_dir = project_root.join(".projectatlas");
13538        fs::create_dir_all(&atlas_dir)?;
13539        let db_path = atlas_dir.join("projectatlas.db");
13540        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
13541        let fixture = publish_fixture(&mut writer, "cursor-hydration")?;
13542        writer.connection.execute(
13543            "INSERT INTO graph_relation_occurrences(
13544                 relation_key, file_path, start_line, start_column, end_line, end_column
13545             ) VALUES(?1, 'Cargo.toml', 1, 0, 1, 1)",
13546            params![&fixture.relations[0].key().digest_bytes()?[..]],
13547        )?;
13548        writer.connection.execute(
13549            "INSERT INTO graph_coverage(
13550                 project_instance_id, scope_kind, scope_path, relation_scope,
13551                 relation_kind, state, total, covered, omitted, reason, reached_limit
13552             ) VALUES(?1, 'path', 'Cargo.toml', NULL, NULL,
13553                      'complete', 1, 1, 0, NULL, NULL)",
13554            params![&fixture.project.as_bytes()[..]],
13555        )?;
13556        drop(writer);
13557        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
13558        let generation = store
13559            .repository_graph_generation()?
13560            .ok_or_else(|| io::Error::other("graph generation missing"))?;
13561        let full_budget = RepositoryGraphReadBudget::new(
13562            MAX_REPOSITORY_GRAPH_FRONTIER as u32,
13563            MAX_REPOSITORY_GRAPH_FRONTIER as u32,
13564            RepositoryGraphReadBudget::MAX_DECODED_BYTES,
13565            RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES,
13566            RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
13567        )?;
13568
13569        let expected_entities = fixture
13570            .entities
13571            .iter()
13572            .rev()
13573            .take(3)
13574            .cloned()
13575            .collect::<Vec<_>>();
13576        let entity_digests = expected_entities
13577            .iter()
13578            .map(|entity| entity.key().digest_bytes())
13579            .collect::<Result<Vec<_>, _>>()?;
13580        let entity_batch = store.repository_graph_entities_by_digest(
13581            fixture.project,
13582            generation,
13583            &entity_digests,
13584            full_budget,
13585            None,
13586        )?;
13587        require_eq(
13588            &entity_batch.rows,
13589            &expected_entities,
13590            "ordered entity cursor hydration",
13591        )?;
13592        require_eq(
13593            &entity_batch.work,
13594            &RepositoryGraphReadWork {
13595                requested_rows: 3,
13596                returned_rows: 3,
13597                decoded_bytes: entity_batch.work.decoded_bytes,
13598                hydrated_entities: 3,
13599                hydrated_paths: 2,
13600            },
13601            "exact entity cursor work",
13602        )?;
13603        require(
13604            entity_batch.work.decoded_bytes > 0,
13605            "entity cursor decoded no SQLite payload bytes",
13606        )?;
13607
13608        let file_entity = fixture
13609            .entities
13610            .iter()
13611            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
13612            .ok_or_else(|| io::Error::other("file anchor fixture missing"))?;
13613        let file_anchor = store.repository_graph_entity_bounded(
13614            file_entity.key(),
13615            generation,
13616            full_budget,
13617            None,
13618        )?;
13619        require_eq(
13620            &file_anchor.rows,
13621            &vec![file_entity.clone()],
13622            "exact file anchor hydration",
13623        )?;
13624        require_eq(
13625            &file_anchor.work,
13626            &RepositoryGraphReadWork {
13627                requested_rows: 1,
13628                returned_rows: 1,
13629                decoded_bytes: file_anchor.work.decoded_bytes,
13630                hydrated_entities: 1,
13631                hydrated_paths: 1,
13632            },
13633            "exact file anchor work",
13634        )?;
13635        let exact_file_budget =
13636            RepositoryGraphReadBudget::new(1, 1, file_anchor.work.decoded_bytes, 1, 1)?;
13637        require_eq(
13638            &store.repository_graph_entity_bounded(
13639                file_entity.key(),
13640                generation,
13641                exact_file_budget,
13642                None,
13643            )?,
13644            &file_anchor,
13645            "exact file anchor envelope",
13646        )?;
13647        require_eq(
13648            &store.repository_graph_entity(file_entity.key())?,
13649            &Some(file_entity.clone()),
13650            "legacy file anchor wrapper compatibility",
13651        )?;
13652        let file_decode_overrun = require_db_error(
13653            store.repository_graph_entity_bounded(
13654                file_entity.key(),
13655                generation,
13656                RepositoryGraphReadBudget::new(1, 1, file_anchor.work.decoded_bytes - 1, 1, 1)?,
13657                None,
13658            ),
13659            "file anchor decoded-byte overrun was accepted",
13660        )?;
13661        require(
13662            matches!(file_decode_overrun, DbError::GraphContract(_)),
13663            &format!("unexpected file anchor envelope error: {file_decode_overrun}"),
13664        )?;
13665        let missing_file_selector = EntitySelector::File {
13666            path: RepositoryFilePath::new(Path::new("missing-anchor.rs"))?,
13667        };
13668        let missing_file_key = GraphEntityKey::new(fixture.project, &missing_file_selector);
13669        let missing_file = store.repository_graph_entity_bounded(
13670            &missing_file_key,
13671            generation,
13672            full_budget,
13673            None,
13674        )?;
13675        require(
13676            missing_file.rows.is_empty()
13677                && missing_file.work
13678                    == (RepositoryGraphReadWork {
13679                        requested_rows: 1,
13680                        returned_rows: 0,
13681                        decoded_bytes: 0,
13682                        hydrated_entities: 0,
13683                        hydrated_paths: 0,
13684                    }),
13685            "missing file anchor did not return exact empty work",
13686        )?;
13687
13688        let anchor_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
13689        let path_anchors = store.repository_graph_entities_by_path_bounded(
13690            fixture.project,
13691            generation,
13692            &anchor_path,
13693            1,
13694            full_budget,
13695            None,
13696        )?;
13697        require(
13698            path_anchors.page.rows.len() == 1
13699                && path_anchors.page.truncated
13700                && matches!(
13701                    path_anchors.page.rows[0].selector(),
13702                    EntitySelector::File { .. }
13703                )
13704                && path_anchors.work.requested_rows == 1
13705                && path_anchors.work.returned_rows == 1
13706                && path_anchors.work.decoded_bytes > 0
13707                && path_anchors.work.hydrated_entities == 2
13708                && path_anchors.work.hydrated_paths == 1,
13709            "path anchor page lost stable order or sentinel work",
13710        )?;
13711        let exact_path_anchor_budget =
13712            RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes, 2, 1)?;
13713        require_eq(
13714            &store.repository_graph_entities_by_path_bounded(
13715                fixture.project,
13716                generation,
13717                &anchor_path,
13718                1,
13719                exact_path_anchor_budget,
13720                None,
13721            )?,
13722            &path_anchors,
13723            "exact path anchor envelope",
13724        )?;
13725        require_eq(
13726            &store.repository_graph_entities_by_path(fixture.project, &anchor_path, 1)?,
13727            &path_anchors.page,
13728            "legacy path anchor wrapper compatibility",
13729        )?;
13730        for (budget, limit, context) in [
13731            (
13732                RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes - 1, 2, 1)?,
13733                1,
13734                "path anchor decoded-byte overrun was accepted",
13735            ),
13736            (
13737                RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes, 1, 1)?,
13738                1,
13739                "path anchor sentinel entity overrun was accepted",
13740            ),
13741            (
13742                exact_path_anchor_budget,
13743                2,
13744                "path anchor returned-row overrun was accepted",
13745            ),
13746        ] {
13747            let error = require_db_error(
13748                store.repository_graph_entities_by_path_bounded(
13749                    fixture.project,
13750                    generation,
13751                    &anchor_path,
13752                    limit,
13753                    budget,
13754                    None,
13755                ),
13756                context,
13757            )?;
13758            require(
13759                matches!(error, DbError::GraphContract(_)),
13760                &format!("unexpected path anchor envelope error: {error}"),
13761            )?;
13762        }
13763        let anchor_cancellation = projectatlas_core::IndexCancellation::new();
13764        anchor_cancellation.cancel();
13765        let anchor_control = IndexWorkControl::new(anchor_cancellation, None);
13766        let cancelled_anchor = store.repository_graph_entities_by_path_bounded(
13767            fixture.project,
13768            generation,
13769            &anchor_path,
13770            1,
13771            full_budget,
13772            Some(&anchor_control),
13773        );
13774        require(
13775            matches!(
13776                cancelled_anchor,
13777                Err(DbError::IndexWork(
13778                    projectatlas_core::IndexWorkFailure::Cancelled {
13779                        stage: IndexWorkStage::RepositoryTraversal
13780                    }
13781                ))
13782            ),
13783            "path anchor cancellation was not typed",
13784        )?;
13785        let expired_anchor = IndexWorkControl::with_deadline(
13786            projectatlas_core::IndexCancellation::new(),
13787            Instant::now(),
13788        );
13789        let anchor_deadline = store.repository_graph_entity_bounded(
13790            file_entity.key(),
13791            generation,
13792            full_budget,
13793            Some(&expired_anchor),
13794        );
13795        require(
13796            matches!(
13797                anchor_deadline,
13798                Err(DbError::IndexWork(
13799                    projectatlas_core::IndexWorkFailure::DeadlineExceeded {
13800                        stage: IndexWorkStage::RepositoryTraversal
13801                    }
13802                ))
13803            ),
13804            "file anchor deadline was not typed",
13805        )?;
13806
13807        let expected_relations = fixture.relations.iter().rev().cloned().collect::<Vec<_>>();
13808        let relation_digests = expected_relations
13809            .iter()
13810            .map(|relation| relation.key().digest_bytes())
13811            .collect::<Result<Vec<_>, _>>()?;
13812        let relation_rows = store.repository_graph_relation_rows_by_digest(
13813            fixture.project,
13814            generation,
13815            &relation_digests,
13816            full_budget,
13817            None,
13818        )?;
13819        require_eq(
13820            &relation_rows
13821                .rows
13822                .iter()
13823                .map(|row| row.relation.clone())
13824                .collect::<Vec<_>>(),
13825            &expected_relations,
13826            "ordered relation cursor hydration",
13827        )?;
13828        require(
13829            relation_rows.rows.iter().all(|row| {
13830                row.source.key().project() == fixture.project
13831                    && row
13832                        .target
13833                        .as_ref()
13834                        .is_none_or(|target| target.key().project() == fixture.project)
13835            }),
13836            "relation cursor hydration returned an unvalidated endpoint",
13837        )?;
13838        require_eq(
13839            &relation_rows.work,
13840            &RepositoryGraphReadWork {
13841                requested_rows: 4,
13842                returned_rows: 4,
13843                decoded_bytes: relation_rows.work.decoded_bytes,
13844                hydrated_entities: 3,
13845                hydrated_paths: 1,
13846            },
13847            "exact relation cursor work",
13848        )?;
13849        require(
13850            relation_rows.work.decoded_bytes > entity_batch.work.decoded_bytes,
13851            "relation cursor did not meter relation and endpoint payload bytes",
13852        )?;
13853
13854        let exact_entity_budget =
13855            RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes, 3, 2)?;
13856        require_eq(
13857            &store
13858                .repository_graph_entities_by_digest(
13859                    fixture.project,
13860                    generation,
13861                    &entity_digests,
13862                    exact_entity_budget,
13863                    None,
13864                )?
13865                .work,
13866            &entity_batch.work,
13867            "exact entity envelope",
13868        )?;
13869        let exact_relation_budget =
13870            RepositoryGraphReadBudget::new(4, 4, relation_rows.work.decoded_bytes, 3, 1)?;
13871        require_eq(
13872            &store
13873                .repository_graph_relation_rows_by_digest(
13874                    fixture.project,
13875                    generation,
13876                    &relation_digests,
13877                    exact_relation_budget,
13878                    None,
13879                )?
13880                .work,
13881            &relation_rows.work,
13882            "exact relation envelope",
13883        )?;
13884
13885        for (budget, context) in [
13886            (
13887                RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes - 1, 3, 2)?,
13888                "decoded-byte envelope overrun was accepted",
13889            ),
13890            (
13891                RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes, 3, 1)?,
13892                "purpose-path envelope overrun was accepted",
13893            ),
13894        ] {
13895            let error = require_db_error(
13896                store.repository_graph_entities_by_digest(
13897                    fixture.project,
13898                    generation,
13899                    &entity_digests,
13900                    budget,
13901                    None,
13902                ),
13903                context,
13904            )?;
13905            require(
13906                matches!(error, DbError::GraphContract(_)),
13907                &format!("unexpected entity envelope error: {error}"),
13908            )?;
13909        }
13910        let endpoint_budget =
13911            RepositoryGraphReadBudget::new(4, 4, relation_rows.work.decoded_bytes, 2, 1)?;
13912        let endpoint_error = require_db_error(
13913            store.repository_graph_relation_rows_by_digest(
13914                fixture.project,
13915                generation,
13916                &relation_digests,
13917                endpoint_budget,
13918                None,
13919            ),
13920            "endpoint entity envelope overrun was accepted",
13921        )?;
13922        require(
13923            matches!(endpoint_error, DbError::GraphContract(_)),
13924            &format!("unexpected endpoint envelope error: {endpoint_error}"),
13925        )?;
13926
13927        for (budget, context) in [
13928            (
13929                RepositoryGraphReadBudget::new(
13930                    2,
13931                    3,
13932                    RepositoryGraphReadBudget::MAX_DECODED_BYTES,
13933                    3,
13934                    2,
13935                )?,
13936                "requested-row envelope overrun was accepted",
13937            ),
13938            (
13939                RepositoryGraphReadBudget::new(
13940                    3,
13941                    2,
13942                    RepositoryGraphReadBudget::MAX_DECODED_BYTES,
13943                    3,
13944                    2,
13945                )?,
13946                "returned-row envelope overrun was accepted",
13947            ),
13948        ] {
13949            let error = require_db_error(
13950                store.repository_graph_entities_by_digest(
13951                    fixture.project,
13952                    generation,
13953                    &entity_digests,
13954                    budget,
13955                    None,
13956                ),
13957                context,
13958            )?;
13959            require(
13960                matches!(error, DbError::GraphContract(_)),
13961                &format!("unexpected row-envelope error: {error}"),
13962            )?;
13963        }
13964
13965        for invalid in [
13966            RepositoryGraphReadBudget::new(0, 1, 1, 1, 1),
13967            RepositoryGraphReadBudget::new(
13968                RepositoryGraphReadBudget::MAX_REQUESTED_ROWS + 1,
13969                1,
13970                1,
13971                1,
13972                1,
13973            ),
13974            RepositoryGraphReadBudget::new(
13975                1,
13976                RepositoryGraphReadBudget::MAX_RETURNED_ROWS + 1,
13977                1,
13978                1,
13979                1,
13980            ),
13981            RepositoryGraphReadBudget::new(
13982                1,
13983                1,
13984                RepositoryGraphReadBudget::MAX_DECODED_BYTES + 1,
13985                1,
13986                1,
13987            ),
13988            RepositoryGraphReadBudget::new(
13989                1,
13990                1,
13991                1,
13992                RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES + 1,
13993                1,
13994            ),
13995            RepositoryGraphReadBudget::new(
13996                1,
13997                1,
13998                1,
13999                1,
14000                RepositoryGraphReadBudget::MAX_HYDRATED_PATHS + 1,
14001            ),
14002        ] {
14003            require(
14004                matches!(invalid, Err(GraphContractError::InvalidLimits { .. })),
14005                "invalid graph read budget was accepted",
14006            )?;
14007        }
14008
14009        let empty_entities = store.repository_graph_entities_by_digest(
14010            fixture.project,
14011            generation,
14012            &[],
14013            full_budget,
14014            None,
14015        )?;
14016        let empty_relations = store.repository_graph_relation_rows_by_digest(
14017            fixture.project,
14018            generation,
14019            &[],
14020            full_budget,
14021            None,
14022        )?;
14023        require(
14024            empty_entities.rows.is_empty()
14025                && empty_relations.rows.is_empty()
14026                && empty_entities.work
14027                    == (RepositoryGraphReadWork {
14028                        requested_rows: 0,
14029                        returned_rows: 0,
14030                        decoded_bytes: 0,
14031                        hydrated_entities: 0,
14032                        hydrated_paths: 0,
14033                    })
14034                && empty_relations.work == empty_entities.work,
14035            "empty cursor hydration was not stable",
14036        )?;
14037
14038        let purpose_paths = vec![
14039            "Cargo.toml".to_string(),
14040            ".".to_string(),
14041            "src/Äuth.rs".to_string(),
14042            "src".to_string(),
14043        ];
14044        let purpose_batch = store.load_purpose_owner_nodes_by_paths_controlled(
14045            fixture.project,
14046            generation,
14047            &purpose_paths,
14048            full_budget,
14049            None,
14050        )?;
14051        require_eq(
14052            &purpose_batch
14053                .rows
14054                .iter()
14055                .map(|node| node.node.path.clone())
14056                .collect::<Vec<_>>(),
14057            &purpose_paths,
14058            "ordered purpose-owner hydration",
14059        )?;
14060        require_eq(
14061            &purpose_batch.work,
14062            &RepositoryGraphReadWork {
14063                requested_rows: 4,
14064                returned_rows: 4,
14065                decoded_bytes: purpose_batch.work.decoded_bytes,
14066                hydrated_entities: 0,
14067                hydrated_paths: 4,
14068            },
14069            "exact purpose-owner work",
14070        )?;
14071        require(
14072            purpose_batch.work.decoded_bytes > 0,
14073            "purpose-owner hydration decoded no SQLite payload bytes",
14074        )?;
14075        let exact_purpose_budget =
14076            RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes, 1, 4)?;
14077        require_eq(
14078            &store
14079                .load_purpose_owner_nodes_by_paths_controlled(
14080                    fixture.project,
14081                    generation,
14082                    &purpose_paths,
14083                    exact_purpose_budget,
14084                    None,
14085                )?
14086                .work,
14087            &purpose_batch.work,
14088            "exact purpose-owner envelope",
14089        )?;
14090        for (budget, context) in [
14091            (
14092                RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes - 1, 1, 4)?,
14093                "purpose-owner decoded-byte overrun was accepted",
14094            ),
14095            (
14096                RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes, 1, 3)?,
14097                "purpose-owner path overrun was accepted",
14098            ),
14099        ] {
14100            let error = require_db_error(
14101                store.load_purpose_owner_nodes_by_paths_controlled(
14102                    fixture.project,
14103                    generation,
14104                    &purpose_paths,
14105                    budget,
14106                    None,
14107                ),
14108                context,
14109            )?;
14110            require(
14111                matches!(error, DbError::GraphContract(_)),
14112                &format!("unexpected purpose-owner envelope error: {error}"),
14113            )?;
14114        }
14115        let mut missing_purpose_paths = purpose_paths.clone();
14116        missing_purpose_paths.push("missing/purpose-owner.rs".to_string());
14117        let missing_purpose = store.load_purpose_owner_nodes_by_paths_controlled(
14118            fixture.project,
14119            generation,
14120            &missing_purpose_paths,
14121            full_budget,
14122            None,
14123        )?;
14124        require_eq(
14125            &missing_purpose
14126                .rows
14127                .iter()
14128                .map(|node| node.node.path.clone())
14129                .collect::<Vec<_>>(),
14130            &purpose_paths,
14131            "absent purpose-owner candidate ordering",
14132        )?;
14133        require(
14134            missing_purpose.work.requested_rows == 5
14135                && missing_purpose.work.returned_rows == 4
14136                && missing_purpose.work.hydrated_paths == 4,
14137            "absent purpose-owner candidate work was not exact",
14138        )?;
14139        let duplicate_purpose = require_db_error(
14140            store.load_purpose_owner_nodes_by_paths_controlled(
14141                fixture.project,
14142                generation,
14143                &[purpose_paths[0].clone(), purpose_paths[0].clone()],
14144                full_budget,
14145                None,
14146            ),
14147            "duplicate purpose-owner paths were accepted",
14148        )?;
14149        require(
14150            matches!(duplicate_purpose, DbError::GraphContract(_)),
14151            &format!("unexpected duplicate purpose-owner error: {duplicate_purpose}"),
14152        )?;
14153        let purpose_cancellation = projectatlas_core::IndexCancellation::new();
14154        purpose_cancellation.cancel();
14155        let purpose_control = IndexWorkControl::new(purpose_cancellation, None);
14156        let cancelled_purpose = store.load_purpose_owner_nodes_by_paths_controlled(
14157            fixture.project,
14158            generation,
14159            &purpose_paths,
14160            full_budget,
14161            Some(&purpose_control),
14162        );
14163        require(
14164            matches!(
14165                cancelled_purpose,
14166                Err(DbError::IndexWork(
14167                    projectatlas_core::IndexWorkFailure::Cancelled {
14168                        stage: IndexWorkStage::RepositoryTraversal
14169                    }
14170                ))
14171            ),
14172            "purpose-owner cancellation was not typed",
14173        )?;
14174
14175        let occurrence_batch = store.repository_graph_occurrence_pages_bounded(
14176            &fixture.relations,
14177            1,
14178            full_budget,
14179            None,
14180        )?;
14181        require(
14182            occurrence_batch.pages.len() == fixture.relations.len()
14183                && occurrence_batch.pages[0].rows.len() == 1
14184                && occurrence_batch.pages[0].truncated
14185                && occurrence_batch.pages[1..]
14186                    .iter()
14187                    .all(|page| page.rows.is_empty() && !page.truncated),
14188            "batched occurrence pages lost owner order or truncation state",
14189        )?;
14190        require_eq(
14191            &occurrence_batch.work,
14192            &RepositoryGraphReadWork {
14193                requested_rows: 4,
14194                returned_rows: 1,
14195                decoded_bytes: occurrence_batch.work.decoded_bytes,
14196                hydrated_entities: 0,
14197                hydrated_paths: 2,
14198            },
14199            "exact occurrence batch work including sentinel path",
14200        )?;
14201        let exact_occurrence_budget =
14202            RepositoryGraphReadBudget::new(4, 1, occurrence_batch.work.decoded_bytes, 1, 2)?;
14203        require_eq(
14204            &store.repository_graph_occurrence_pages_bounded(
14205                &fixture.relations,
14206                1,
14207                exact_occurrence_budget,
14208                None,
14209            )?,
14210            &occurrence_batch,
14211            "exact occurrence envelope",
14212        )?;
14213        require_eq(
14214            &store.repository_graph_occurrence_pages(&fixture.relations, 1, None)?,
14215            &occurrence_batch.pages,
14216            "legacy occurrence wrapper compatibility",
14217        )?;
14218        for (budget, limit, context) in [
14219            (
14220                RepositoryGraphReadBudget::new(
14221                    4,
14222                    1,
14223                    occurrence_batch.work.decoded_bytes - 1,
14224                    1,
14225                    2,
14226                )?,
14227                1,
14228                "occurrence decoded-byte overrun was accepted",
14229            ),
14230            (
14231                RepositoryGraphReadBudget::new(4, 1, occurrence_batch.work.decoded_bytes, 1, 1)?,
14232                1,
14233                "occurrence sentinel path overrun was accepted",
14234            ),
14235            (
14236                RepositoryGraphReadBudget::new(
14237                    4,
14238                    1,
14239                    RepositoryGraphReadBudget::MAX_DECODED_BYTES,
14240                    1,
14241                    RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
14242                )?,
14243                3,
14244                "occurrence returned-row overrun was accepted",
14245            ),
14246        ] {
14247            let error = require_db_error(
14248                store.repository_graph_occurrence_pages_bounded(
14249                    &fixture.relations,
14250                    limit,
14251                    budget,
14252                    None,
14253                ),
14254                context,
14255            )?;
14256            require(
14257                matches!(error, DbError::GraphContract(_)),
14258                &format!("unexpected occurrence envelope error: {error}"),
14259            )?;
14260        }
14261        let occurrence_cancellation = projectatlas_core::IndexCancellation::new();
14262        occurrence_cancellation.cancel();
14263        let occurrence_control = IndexWorkControl::new(occurrence_cancellation, None);
14264        let cancelled_occurrences = store.repository_graph_occurrence_pages_bounded(
14265            &fixture.relations,
14266            1,
14267            full_budget,
14268            Some(&occurrence_control),
14269        );
14270        require(
14271            matches!(
14272                cancelled_occurrences,
14273                Err(DbError::IndexWork(
14274                    projectatlas_core::IndexWorkFailure::Cancelled {
14275                        stage: IndexWorkStage::RepositoryTraversal
14276                    }
14277                ))
14278            ),
14279            "occurrence batch cancellation was not typed",
14280        )?;
14281        let expired_occurrences = IndexWorkControl::with_deadline(
14282            projectatlas_core::IndexCancellation::new(),
14283            Instant::now(),
14284        );
14285        let occurrence_deadline = store.repository_graph_occurrence_pages_bounded(
14286            &fixture.relations,
14287            1,
14288            full_budget,
14289            Some(&expired_occurrences),
14290        );
14291        require(
14292            matches!(
14293                occurrence_deadline,
14294                Err(DbError::IndexWork(
14295                    projectatlas_core::IndexWorkFailure::DeadlineExceeded {
14296                        stage: IndexWorkStage::RepositoryTraversal
14297                    }
14298                ))
14299            ),
14300            "occurrence batch deadline was not typed",
14301        )?;
14302
14303        let coverage_paths = vec![
14304            RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
14305            RepositoryNodePath::new(Path::new("Cargo.toml"))?,
14306        ];
14307        let coverage_batch = store.repository_graph_path_coverage_bounded(
14308            fixture.project,
14309            generation,
14310            &coverage_paths,
14311            full_budget,
14312            None,
14313        )?;
14314        require_eq(
14315            &coverage_batch
14316                .page
14317                .rows
14318                .iter()
14319                .map(|coverage| match coverage.scope() {
14320                    CoverageScope::Path { path } => path.as_str().to_string(),
14321                    CoverageScope::Project => "project".to_string(),
14322                })
14323                .collect::<Vec<_>>(),
14324            &vec!["Cargo.toml".to_string(), "src/Äuth.rs".to_string()],
14325            "stable path coverage order",
14326        )?;
14327        require(
14328            !coverage_batch.page.truncated
14329                && coverage_batch.work
14330                    == (RepositoryGraphReadWork {
14331                        requested_rows: 2,
14332                        returned_rows: 2,
14333                        decoded_bytes: coverage_batch.work.decoded_bytes,
14334                        hydrated_entities: 0,
14335                        hydrated_paths: 2,
14336                    })
14337                && coverage_batch.work.decoded_bytes > 0,
14338            "exact coverage batch work was incomplete",
14339        )?;
14340        let exact_coverage_budget =
14341            RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes, 1, 2)?;
14342        require_eq(
14343            &store.repository_graph_path_coverage_bounded(
14344                fixture.project,
14345                generation,
14346                &coverage_paths,
14347                exact_coverage_budget,
14348                None,
14349            )?,
14350            &coverage_batch,
14351            "exact coverage envelope",
14352        )?;
14353        require_eq(
14354            &store.repository_graph_path_coverage(fixture.project, &coverage_paths, None)?,
14355            &coverage_batch.page,
14356            "legacy coverage wrapper compatibility",
14357        )?;
14358        for (budget, context) in [
14359            (
14360                RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes - 1, 1, 2)?,
14361                "coverage decoded-byte overrun was accepted",
14362            ),
14363            (
14364                RepositoryGraphReadBudget::new(2, 1, coverage_batch.work.decoded_bytes, 1, 2)?,
14365                "coverage returned-row overrun was accepted",
14366            ),
14367            (
14368                RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes, 1, 1)?,
14369                "coverage hydrated-path overrun was accepted",
14370            ),
14371        ] {
14372            let error = require_db_error(
14373                store.repository_graph_path_coverage_bounded(
14374                    fixture.project,
14375                    generation,
14376                    &coverage_paths,
14377                    budget,
14378                    None,
14379                ),
14380                context,
14381            )?;
14382            require(
14383                matches!(error, DbError::GraphContract(_)),
14384                &format!("unexpected coverage envelope error: {error}"),
14385            )?;
14386        }
14387        let coverage_cancellation = projectatlas_core::IndexCancellation::new();
14388        coverage_cancellation.cancel();
14389        let coverage_control = IndexWorkControl::new(coverage_cancellation, None);
14390        let cancelled_coverage = store.repository_graph_path_coverage_bounded(
14391            fixture.project,
14392            generation,
14393            &coverage_paths,
14394            full_budget,
14395            Some(&coverage_control),
14396        );
14397        require(
14398            matches!(
14399                cancelled_coverage,
14400                Err(DbError::IndexWork(
14401                    projectatlas_core::IndexWorkFailure::Cancelled {
14402                        stage: IndexWorkStage::RepositoryTraversal
14403                    }
14404                ))
14405            ),
14406            "coverage batch cancellation was not typed",
14407        )?;
14408        let expired_coverage = IndexWorkControl::with_deadline(
14409            projectatlas_core::IndexCancellation::new(),
14410            Instant::now(),
14411        );
14412        let coverage_deadline = store.repository_graph_path_coverage_bounded(
14413            fixture.project,
14414            generation,
14415            &coverage_paths,
14416            full_budget,
14417            Some(&expired_coverage),
14418        );
14419        require(
14420            matches!(
14421                coverage_deadline,
14422                Err(DbError::IndexWork(
14423                    projectatlas_core::IndexWorkFailure::DeadlineExceeded {
14424                        stage: IndexWorkStage::RepositoryTraversal
14425                    }
14426                ))
14427            ),
14428            "coverage batch deadline was not typed",
14429        )?;
14430
14431        let missing = [0xff; 32];
14432        let missing_entity = require_db_error(
14433            store.repository_graph_entities_by_digest(
14434                fixture.project,
14435                generation,
14436                &[entity_digests[0], missing],
14437                full_budget,
14438                None,
14439            ),
14440            "missing entity cursor key returned a partial set",
14441        )?;
14442        require(
14443            matches!(
14444                missing_entity,
14445                DbError::GraphRowShape {
14446                    table: "graph_entities",
14447                    ..
14448                }
14449            ),
14450            &format!("unexpected missing-entity error: {missing_entity}"),
14451        )?;
14452        let missing_relation = require_db_error(
14453            store.repository_graph_relation_rows_by_digest(
14454                fixture.project,
14455                generation,
14456                &[relation_digests[0], missing],
14457                full_budget,
14458                None,
14459            ),
14460            "missing relation cursor key returned a partial set",
14461        )?;
14462        require(
14463            matches!(
14464                missing_relation,
14465                DbError::GraphRowShape {
14466                    table: "graph_relations",
14467                    ..
14468                }
14469            ),
14470            &format!("unexpected missing-relation error: {missing_relation}"),
14471        )?;
14472
14473        let duplicate = require_db_error(
14474            store.repository_graph_entities_by_digest(
14475                fixture.project,
14476                generation,
14477                &[entity_digests[0], entity_digests[0]],
14478                full_budget,
14479                None,
14480            ),
14481            "duplicate entity cursor keys were accepted",
14482        )?;
14483        require(
14484            matches!(duplicate, DbError::GraphContract(_)),
14485            &format!("unexpected duplicate hydration error: {duplicate}"),
14486        )?;
14487        let oversized = vec![[0; 32]; MAX_REPOSITORY_GRAPH_FRONTIER + 1];
14488        let oversized = require_db_error(
14489            store.repository_graph_relation_rows_by_digest(
14490                fixture.project,
14491                generation,
14492                &oversized,
14493                full_budget,
14494                None,
14495            ),
14496            "oversized relation cursor key set was accepted",
14497        )?;
14498        require(
14499            matches!(oversized, DbError::GraphContract(_)),
14500            &format!("unexpected oversized hydration error: {oversized}"),
14501        )?;
14502
14503        let foreign_project = ProjectInstanceId::from_bytes([0x7f; 16])?;
14504        let foreign = require_db_error(
14505            store.repository_graph_entities_by_digest(
14506                foreign_project,
14507                generation,
14508                &entity_digests,
14509                full_budget,
14510                None,
14511            ),
14512            "cross-project entity cursor hydration was accepted",
14513        )?;
14514        require(
14515            matches!(foreign, DbError::GraphProjectIdentityMismatch { .. }),
14516            &format!("unexpected cross-project hydration error: {foreign}"),
14517        )?;
14518        let stale_generation = generation
14519            .checked_next()
14520            .ok_or_else(|| io::Error::other("fixture generation overflowed"))?;
14521        let stale = require_db_error(
14522            store.repository_graph_relation_rows_by_digest(
14523                fixture.project,
14524                stale_generation,
14525                &relation_digests,
14526                full_budget,
14527                None,
14528            ),
14529            "stale-generation relation cursor hydration was accepted",
14530        )?;
14531        require(
14532            matches!(stale, DbError::GraphContract(_)),
14533            &format!("unexpected stale-generation hydration error: {stale}"),
14534        )?;
14535        for entities in [true, false] {
14536            let cancellation = projectatlas_core::IndexCancellation::new();
14537            cancellation.cancel();
14538            let control = IndexWorkControl::new(cancellation, None);
14539            let cancelled = if entities {
14540                store
14541                    .repository_graph_entities_by_digest(
14542                        fixture.project,
14543                        generation,
14544                        &entity_digests,
14545                        full_budget,
14546                        Some(&control),
14547                    )
14548                    .map(|_| ())
14549            } else {
14550                store
14551                    .repository_graph_relation_rows_by_digest(
14552                        fixture.project,
14553                        generation,
14554                        &relation_digests,
14555                        full_budget,
14556                        Some(&control),
14557                    )
14558                    .map(|_| ())
14559            };
14560            require(
14561                matches!(
14562                    cancelled,
14563                    Err(DbError::IndexWork(
14564                        projectatlas_core::IndexWorkFailure::Cancelled {
14565                            stage: IndexWorkStage::RepositoryTraversal
14566                        }
14567                    ))
14568                ),
14569                "cursor hydration cancellation was not typed",
14570            )?;
14571        }
14572        let expired = IndexWorkControl::with_deadline(
14573            projectatlas_core::IndexCancellation::new(),
14574            Instant::now(),
14575        );
14576        let deadline = store.repository_graph_entities_by_digest(
14577            fixture.project,
14578            generation,
14579            &entity_digests,
14580            full_budget,
14581            Some(&expired),
14582        );
14583        require(
14584            matches!(
14585                deadline,
14586                Err(DbError::IndexWork(
14587                    projectatlas_core::IndexWorkFailure::DeadlineExceeded {
14588                        stage: IndexWorkStage::RepositoryTraversal
14589                    }
14590                ))
14591            ),
14592            "cursor hydration deadline was not typed",
14593        )?;
14594
14595        let source = fixture
14596            .entities
14597            .iter()
14598            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
14599            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
14600        let adjacency = store.repository_graph_adjacency_page_bounded(
14601            &[source.key().clone()],
14602            RepositoryGraphDirection::Outbound,
14603            None,
14604            1,
14605            full_budget,
14606            None,
14607        )?;
14608        require(
14609            adjacency.page.rows.len() == 1
14610                && adjacency.page.truncated
14611                && adjacency.work.requested_rows == 1
14612                && adjacency.work.returned_rows == 1
14613                && adjacency.work.decoded_bytes > 0
14614                && adjacency.work.hydrated_entities > 0
14615                && adjacency.work.hydrated_paths > 0,
14616            "bounded adjacency page omitted exact raw or endpoint work",
14617        )?;
14618        let exact_adjacency_budget = RepositoryGraphReadBudget::new(
14619            1,
14620            1,
14621            adjacency.work.decoded_bytes,
14622            adjacency.work.hydrated_entities,
14623            adjacency.work.hydrated_paths,
14624        )?;
14625        require_eq(
14626            &store.repository_graph_adjacency_page_bounded(
14627                &[source.key().clone()],
14628                RepositoryGraphDirection::Outbound,
14629                None,
14630                1,
14631                exact_adjacency_budget,
14632                None,
14633            )?,
14634            &adjacency,
14635            "exact adjacency envelope",
14636        )?;
14637        let adjacency_overrun = require_db_error(
14638            store.repository_graph_adjacency_page_bounded(
14639                &[source.key().clone()],
14640                RepositoryGraphDirection::Outbound,
14641                None,
14642                1,
14643                RepositoryGraphReadBudget::new(
14644                    1,
14645                    1,
14646                    adjacency.work.decoded_bytes - 1,
14647                    adjacency.work.hydrated_entities,
14648                    adjacency.work.hydrated_paths,
14649                )?,
14650                None,
14651            ),
14652            "adjacency decoded-byte overrun was accepted",
14653        )?;
14654        require(
14655            matches!(adjacency_overrun, DbError::GraphContract(_)),
14656            &format!("unexpected adjacency envelope error: {adjacency_overrun}"),
14657        )?;
14658        let adjacency_return_limit = require_db_error(
14659            store.repository_graph_adjacency_page_bounded(
14660                &[source.key().clone()],
14661                RepositoryGraphDirection::Outbound,
14662                None,
14663                2,
14664                exact_adjacency_budget,
14665                None,
14666            ),
14667            "adjacency page limit exceeded the return budget",
14668        )?;
14669        require(
14670            matches!(adjacency_return_limit, DbError::GraphContract(_)),
14671            &format!("unexpected adjacency return-budget error: {adjacency_return_limit}"),
14672        )?;
14673        let continuation = adjacency
14674            .page
14675            .continuation
14676            .ok_or_else(|| io::Error::other("adjacency continuation missing"))?;
14677        let encoded =
14678            serde_json::to_vec(&(RepositoryGraphDirection::Outbound, continuation.clone()))?;
14679        let decoded: (
14680            RepositoryGraphDirection,
14681            RepositoryGraphAdjacencyContinuation,
14682        ) = serde_json::from_slice(&encoded)?;
14683        require_eq(
14684            &decoded,
14685            &(RepositoryGraphDirection::Outbound, continuation),
14686            "opaque relation cursor serde round trip",
14687        )?;
14688
14689        assert_cursor_hydration_indexes(&store)?;
14690        store.finish_index_read_snapshot()?;
14691        Ok(())
14692    }
14693
14694    #[test]
14695    fn continued_adjacency_seeks_past_high_degree_prefixes() -> Result<(), Box<dyn Error>> {
14696        let temp = tempfile::tempdir()?;
14697        let project_root = temp.path().join("continued-adjacency-work");
14698        let atlas_dir = project_root.join(".projectatlas");
14699        fs::create_dir_all(&atlas_dir)?;
14700        let db_path = atlas_dir.join("projectatlas.db");
14701        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
14702        let fixture = publish_fixture(&mut writer, "continued-adjacency-work")?;
14703        let source = fixture
14704            .entities
14705            .iter()
14706            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
14707            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
14708        let external = fixture
14709            .entities
14710            .iter()
14711            .find(|entity| matches!(entity.selector(), EntitySelector::External { .. }))
14712            .ok_or_else(|| io::Error::other("external fixture missing"))?;
14713        writer.connection.execute(
14714            "WITH RECURSIVE sequence(value) AS (
14715                 VALUES(1)
14716                 UNION ALL
14717                 SELECT value + 1 FROM sequence WHERE value < 100000
14718             )
14719             INSERT INTO graph_relations(
14720                 relation_key, project_instance_id, canonical_identity,
14721                 source_entity_key, relation_scope, relation_kind,
14722                 resolution_status, target_entity_key, reference_text,
14723                 candidate_count, confidence, completeness
14724             )
14725             SELECT CAST(printf('%032d', 1000000 + value) AS BLOB),
14726                    ?1, printf('perf-%06d', value), ?2,
14727                    'legacy', 'calls', 'resolved', ?3, NULL, NULL,
14728                    'exact', 'complete'
14729               FROM sequence",
14730            params![
14731                fixture.project.as_bytes().as_slice(),
14732                source.key().digest_bytes()?.as_slice(),
14733                external.key().digest_bytes()?.as_slice(),
14734            ],
14735        )?;
14736        drop(writer);
14737
14738        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
14739        let generation = store
14740            .repository_graph_generation()?
14741            .ok_or_else(|| io::Error::other("graph generation missing"))?;
14742        let source_digest = source.key().digest_bytes()?;
14743        let external_digest = external.key().digest_bytes()?;
14744        let skipped_frontier = vec![source.key().clone(), external.key().clone()];
14745        let skipped_continuation = RepositoryGraphAdjacencyContinuation {
14746            project: fixture.project,
14747            generation,
14748            direction: RepositoryGraphDirection::Outbound,
14749            relation: None,
14750            resolved_only: false,
14751            include_documents: false,
14752            frontier: vec![source_digest, external_digest],
14753            frontier_index: 1,
14754            relation_scope: String::new(),
14755            relation_kind: String::new(),
14756            relation_key: [0; 32],
14757        };
14758        let skipped_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
14759        let skipped_counter = std::sync::Arc::clone(&skipped_steps);
14760        store.connection.progress_handler(
14761            1_000,
14762            Some(move || {
14763                skipped_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
14764                false
14765            }),
14766        )?;
14767        let skipped_result = store.repository_graph_adjacency_page(
14768            &skipped_frontier,
14769            RepositoryGraphDirection::Outbound,
14770            Some(&skipped_continuation),
14771            2,
14772            None,
14773        );
14774        store.connection.progress_handler(0, None::<fn() -> bool>)?;
14775        skipped_result?;
14776        require(
14777            skipped_steps.load(std::sync::atomic::Ordering::Relaxed) < 100_000,
14778            "continued adjacency scanned a completed high-degree frontier branch",
14779        )?;
14780
14781        let last_key: [u8; 32] = format!("{:032}", 1_100_000)
14782            .into_bytes()
14783            .try_into()
14784            .map_err(|_source| io::Error::other("high-degree cursor key width changed"))?;
14785        let deep_continuation = RepositoryGraphAdjacencyContinuation {
14786            project: fixture.project,
14787            generation,
14788            direction: RepositoryGraphDirection::Outbound,
14789            relation: None,
14790            resolved_only: false,
14791            include_documents: false,
14792            frontier: vec![source_digest],
14793            frontier_index: 0,
14794            relation_scope: "legacy".to_string(),
14795            relation_kind: "calls".to_string(),
14796            relation_key: last_key,
14797        };
14798        let deep_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
14799        let deep_counter = std::sync::Arc::clone(&deep_steps);
14800        store.connection.progress_handler(
14801            1_000,
14802            Some(move || {
14803                deep_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
14804                false
14805            }),
14806        )?;
14807        let deep_result = store.repository_graph_adjacency_page(
14808            &[source.key().clone()],
14809            RepositoryGraphDirection::Outbound,
14810            Some(&deep_continuation),
14811            2,
14812            None,
14813        );
14814        store.connection.progress_handler(0, None::<fn() -> bool>)?;
14815        deep_result?;
14816        require(
14817            deep_steps.load(std::sync::atomic::Ordering::Relaxed) < 100_000,
14818            "continued adjacency rescanned a high-degree keyset prefix",
14819        )?;
14820        store.finish_index_read_snapshot()?;
14821        Ok(())
14822    }
14823
14824    #[test]
14825    fn bounded_rust_frontier_matches_indexed_recursive_cte_on_cycles_and_high_degree()
14826    -> Result<(), Box<dyn Error>> {
14827        const HIGH_DEGREE: usize = 4_096;
14828        const RECURSIVE_CTE: &str = "WITH RECURSIVE walk(entity_key) AS (
14829                VALUES(?2)
14830                UNION
14831                SELECT relation.target_entity_key
14832                  FROM graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
14833                  JOIN walk ON relation.source_entity_key = walk.entity_key
14834                 WHERE relation.project_instance_id = ?1
14835                   AND relation.relation_scope = 'legacy'
14836                   AND relation.relation_kind = 'calls'
14837                   AND relation.target_entity_key IS NOT NULL
14838            )
14839            SELECT entity_key FROM walk ORDER BY entity_key";
14840
14841        let temp = tempfile::tempdir()?;
14842        let project_root = temp.path().join("frontier-cte-comparison");
14843        let atlas_dir = project_root.join(".projectatlas");
14844        fs::create_dir_all(&atlas_dir)?;
14845        let db_path = atlas_dir.join("projectatlas.db");
14846        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
14847        let fixture = publish_fixture(&mut writer, "frontier-cte-comparison")?;
14848        let source = fixture
14849            .entities
14850            .iter()
14851            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
14852            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
14853        let generation = source.generation();
14854        let mut high_degree_entities = Vec::with_capacity(HIGH_DEGREE);
14855        let mut cyclic_relations = Vec::with_capacity(HIGH_DEGREE * 2);
14856        for index in 0..HIGH_DEGREE {
14857            let target = GraphEntity::new(
14858                fixture.project,
14859                EntitySelector::External {
14860                    external: ExternalSelector {
14861                        system: GraphIdentityText::new("frontier-measurement")?,
14862                        identity: GraphIdentityText::new(format!("node-{index:05}"))?,
14863                    },
14864                },
14865                generation,
14866            )?;
14867            cyclic_relations.push(LogicalRelation::new(
14868                source,
14869                GraphRelationKind::Legacy(RelationKind::Calls),
14870                RelationResolution::external(&target)?,
14871                ConfidenceClass::Exact,
14872                Completeness::Complete,
14873                generation,
14874            )?);
14875            cyclic_relations.push(LogicalRelation::new(
14876                &target,
14877                GraphRelationKind::Legacy(RelationKind::Calls),
14878                RelationResolution::resolved(source)?,
14879                ConfidenceClass::Exact,
14880                Completeness::Complete,
14881                generation,
14882            )?);
14883            high_degree_entities.push(target);
14884        }
14885        let transaction = writer.connection.transaction()?;
14886        insert_entities(&transaction, fixture.project, &high_degree_entities)?;
14887        insert_relations(&transaction, fixture.project, &cyclic_relations)?;
14888        transaction.commit()?;
14889        drop(writer);
14890
14891        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
14892        let project = fixture.project.as_bytes();
14893        let source_key = source.key().digest_bytes()?;
14894        let plan_sql = format!("EXPLAIN QUERY PLAN {RECURSIVE_CTE}");
14895        let cte_plan = store
14896            .connection
14897            .prepare(&plan_sql)?
14898            .query_map(params![&project[..], &source_key[..]], |row| {
14899                row.get::<_, String>(3)
14900            })?
14901            .collect::<Result<Vec<_>, _>>()?;
14902        require(
14903            cte_plan
14904                .iter()
14905                .any(|detail| detail.contains("idx_graph_relations_source_kind"))
14906                && cte_plan
14907                    .iter()
14908                    .all(|detail| !detail.contains("SCAN relation")),
14909            &format!("recursive CTE did not retain the source-owned index: {cte_plan:?}"),
14910        )?;
14911
14912        let cte_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
14913        let cte_counter = std::sync::Arc::clone(&cte_steps);
14914        store.connection.progress_handler(
14915            1_000,
14916            Some(move || {
14917                cte_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
14918                false
14919            }),
14920        )?;
14921        let cte_started = Instant::now();
14922        let cte_result = store
14923            .connection
14924            .prepare(RECURSIVE_CTE)?
14925            .query_map(params![&project[..], &source_key[..]], |row| {
14926                row.get::<_, Vec<u8>>(0)
14927            })?
14928            .map(|row| fixed_bytes::<32>("recursive_cte.entity_key", row?))
14929            .collect::<DbResult<Vec<_>>>();
14930        let cte_elapsed = cte_started.elapsed();
14931        store.connection.progress_handler(0, None::<fn() -> bool>)?;
14932        let cte_nodes = cte_result?;
14933
14934        let rust_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
14935        let rust_counter = std::sync::Arc::clone(&rust_steps);
14936        store.connection.progress_handler(
14937            1_000,
14938            Some(move || {
14939                rust_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
14940                false
14941            }),
14942        )?;
14943        let rust_started = Instant::now();
14944        let rust_result = collect_bounded_outbound_calls(&store, source.key());
14945        let rust_elapsed = rust_started.elapsed();
14946        store.connection.progress_handler(0, None::<fn() -> bool>)?;
14947        let (rust_nodes, inspected_edges, peak_frontier) = rust_result?;
14948        let (repeated_nodes, repeated_edges, repeated_peak) =
14949            collect_bounded_outbound_calls(&store, source.key())?;
14950
14951        require_eq(
14952            &rust_nodes,
14953            &cte_nodes,
14954            "Rust frontier versus recursive CTE topology",
14955        )?;
14956        require_eq(
14957            &repeated_nodes,
14958            &rust_nodes,
14959            "deterministic Rust frontier order",
14960        )?;
14961        require_eq(
14962            &repeated_edges,
14963            &inspected_edges,
14964            "deterministic inspected edges",
14965        )?;
14966        require_eq(
14967            &repeated_peak,
14968            &peak_frontier,
14969            "deterministic peak frontier",
14970        )?;
14971        require_eq(
14972            &rust_nodes.len(),
14973            &(HIGH_DEGREE + 2),
14974            "cycle-safe high-degree topology",
14975        )?;
14976        require_eq(
14977            &inspected_edges,
14978            &(HIGH_DEGREE * 2 + 1),
14979            "bounded frontier inspected every call edge once",
14980        )?;
14981        require(
14982            cte_steps.load(std::sync::atomic::Ordering::Relaxed) > 0
14983                && rust_steps.load(std::sync::atomic::Ordering::Relaxed) > 0
14984                && cte_elapsed > Duration::ZERO
14985                && rust_elapsed > Duration::ZERO,
14986            "frontier comparison did not record VM work and elapsed time",
14987        )?;
14988        let retained_key_bytes = rust_nodes
14989            .len()
14990            .checked_add(peak_frontier)
14991            .and_then(|keys| keys.checked_mul(std::mem::size_of::<[u8; 32]>()))
14992            .ok_or_else(|| io::Error::other("retained key measurement overflowed"))?;
14993        let output_key_bytes = rust_nodes
14994            .len()
14995            .checked_mul(std::mem::size_of::<[u8; 32]>())
14996            .ok_or_else(|| io::Error::other("output key measurement overflowed"))?;
14997        require(
14998            retained_key_bytes <= GraphLimits::MAX_OUTPUT_BYTES as usize
14999                && output_key_bytes <= GraphLimits::MAX_OUTPUT_BYTES as usize,
15000            "bounded Rust frontier exceeded the shared compact byte ceiling",
15001        )?;
15002
15003        store.connection.progress_handler(1, Some(|| true))?;
15004        let cancelled_cte = (|| -> Result<Vec<Vec<u8>>, rusqlite::Error> {
15005            let mut statement = store.connection.prepare(RECURSIVE_CTE)?;
15006            statement
15007                .query_map(params![&project[..], &source_key[..]], |row| {
15008                    row.get::<_, Vec<u8>>(0)
15009                })?
15010                .collect()
15011        })();
15012        store.connection.progress_handler(0, None::<fn() -> bool>)?;
15013        require(
15014            matches!(
15015                cancelled_cte,
15016                Err(rusqlite::Error::SqliteFailure(ref failure, _))
15017                    if failure.code == rusqlite::ErrorCode::OperationInterrupted
15018            ),
15019            "recursive CTE did not stop through the SQLite progress handler",
15020        )?;
15021        let cancellation = projectatlas_core::IndexCancellation::new();
15022        cancellation.cancel();
15023        let control = IndexWorkControl::new(cancellation, None);
15024        let cancelled_rust = store.repository_graph_adjacency_page_filtered(
15025            &[source.key().clone()],
15026            RepositoryGraphDirection::Outbound,
15027            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
15028            None,
15029            1,
15030            Some(&control),
15031        );
15032        require(
15033            matches!(
15034                cancelled_rust,
15035                Err(DbError::IndexWork(
15036                    projectatlas_core::IndexWorkFailure::Cancelled {
15037                        stage: IndexWorkStage::RepositoryTraversal
15038                    }
15039                ))
15040            ),
15041            "bounded Rust frontier did not retain typed cancellation",
15042        )?;
15043        store.finish_index_read_snapshot()?;
15044        Ok(())
15045    }
15046
15047    #[cfg(feature = "sqlite-progress-test-observer")]
15048    #[test]
15049    fn zero_candidate_coverage_discovery_is_cancellable_at_scale() -> Result<(), Box<dyn Error>> {
15050        use crate::sqlite_progress_test_observer::{
15051            SqliteReadProgressEvent, observe_sqlite_read_progress,
15052        };
15053        use std::cell::Cell;
15054        use std::rc::Rc;
15055
15056        const POSITIVE_DOCUMENT_ROWS: usize = 100_000;
15057
15058        let temp = tempfile::tempdir()?;
15059        let project_root = temp.path().join("coverage-zero-candidate-scale");
15060        let atlas_dir = project_root.join(".projectatlas");
15061        fs::create_dir_all(&atlas_dir)?;
15062        let db_path = atlas_dir.join("projectatlas.db");
15063        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
15064        let fixture = publish_fixture(&mut writer, "coverage-zero-candidate-scale")?;
15065        writer.connection.execute(
15066            "DELETE FROM graph_coverage
15067              WHERE relation_scope = 'extended'
15068                AND relation_kind = 'documents'
15069                AND state = 'complete' AND total = 0",
15070            [],
15071        )?;
15072        writer.connection.execute(
15073            "WITH RECURSIVE sequence(value) AS (
15074                 VALUES(1)
15075                 UNION ALL
15076                 SELECT value + 1 FROM sequence WHERE value < ?2
15077             )
15078             INSERT INTO graph_coverage(
15079                 project_instance_id, scope_kind, scope_path,
15080                 relation_scope, relation_kind, state,
15081                 total, covered, omitted, reason, reached_limit
15082             )
15083             SELECT ?1, 'path', printf('src/perf-%06d.rs', value),
15084                    'extended', 'documents', 'complete',
15085                    1, 1, 0, NULL, NULL
15086               FROM sequence",
15087            params![
15088                fixture.project.as_bytes().as_slice(),
15089                i64::try_from(POSITIVE_DOCUMENT_ROWS)?,
15090            ],
15091        )?;
15092        writer.connection.execute(
15093            "INSERT INTO graph_coverage(
15094                 project_instance_id, scope_kind, scope_path,
15095                 relation_scope, relation_kind, state,
15096                 total, covered, omitted, reason, reached_limit
15097             ) VALUES (?1, 'path', 'docs/empty.md',
15098                       'extended', 'documents', 'complete',
15099                       0, 0, 0, NULL, NULL)",
15100            params![fixture.project.as_bytes().as_slice()],
15101        )?;
15102        drop(writer);
15103
15104        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15105        let cancellation = projectatlas_core::IndexCancellation::new();
15106        let control = IndexWorkControl::new(cancellation.clone(), None);
15107        let inspected_steps = Rc::new(Cell::new(0_usize));
15108        let page = observe_sqlite_read_progress(
15109            {
15110                let inspected_steps = Rc::clone(&inspected_steps);
15111                move |event| {
15112                    if matches!(
15113                        event,
15114                        SqliteReadProgressEvent::CallbackEntered {
15115                            stage: IndexWorkStage::RepositoryTraversal
15116                        }
15117                    ) {
15118                        inspected_steps.set(inspected_steps.get().saturating_add(1_000));
15119                        cancellation.cancel();
15120                    }
15121                }
15122            },
15123            || {
15124                store.repository_coverage_page_controlled(
15125                    fixture.project,
15126                    &RepositoryCoverageQuery {
15127                        start_index: 0,
15128                        limit: 1,
15129                        path_prefix: None,
15130                        parser: None,
15131                        provider: None,
15132                        relation: None,
15133                        state: Some(CoverageState::NoCandidates),
15134                        reason: None,
15135                    },
15136                    Some(&control),
15137                )
15138            },
15139        );
15140        require(
15141            matches!(
15142                page,
15143                Err(DbError::IndexWork(
15144                    projectatlas_core::IndexWorkFailure::Cancelled {
15145                        stage: IndexWorkStage::RepositoryTraversal
15146                    }
15147                ))
15148            ),
15149            "scaled zero-candidate discovery did not propagate cancellation",
15150        )?;
15151        require(
15152            inspected_steps.get() <= 1_000,
15153            "zero-candidate discovery exceeded one SQLite progress interval before cancellation",
15154        )?;
15155        store.finish_index_read_snapshot()?;
15156        Ok(())
15157    }
15158
15159    #[test]
15160    fn coverage_discovery_filters_provenance_and_fails_closed_after_reopen()
15161    -> Result<(), Box<dyn Error>> {
15162        let temp = tempfile::tempdir()?;
15163        let project_root = temp.path().join("coverage-discovery");
15164        fs::create_dir_all(&project_root)?;
15165        let db_path = project_root.join("projectatlas.db");
15166        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
15167        let fixture = publish_fixture(&mut writer, "coverage-discovery")?;
15168        let sibling_path = "src/Äuth.rs.backup";
15169        let sibling_coverage = CoverageRecord::new(
15170            CoverageScope::Path {
15171                path: RepositoryNodePath::new(Path::new(sibling_path))?,
15172            },
15173            None,
15174            CoverageState::Complete,
15175            1,
15176            0,
15177            IndexGeneration::new(2),
15178            None,
15179            None,
15180        )?;
15181        let mut publication = writer.begin_index_publication("coverage-discovery-sibling")?;
15182        publication.replace_repository_graph_for_paths(
15183            fixture.project,
15184            &[sibling_path.to_string()],
15185            &[],
15186            &[],
15187            &[],
15188            &[sibling_coverage],
15189        )?;
15190        publication.complete()?;
15191        drop(writer);
15192
15193        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15194        let project_value = Value::Blob(fixture.project.as_bytes().to_vec());
15195        assert_coverage_discovery_plan(
15196            &store.connection,
15197            "EXPLAIN QUERY PLAN
15198             SELECT coverage.id FROM graph_coverage AS coverage
15199              WHERE coverage.project_instance_id = ?
15200                AND coverage.scope_kind = 'path'
15201                AND coverage.scope_path >= ? AND coverage.scope_path < ?
15202                AND (coverage.scope_path = ? OR coverage.scope_path >= ?)
15203              ORDER BY coverage.scope_path, coverage.relation_scope, coverage.relation_kind,
15204                       coverage.state, coverage.id LIMIT 11",
15205            &[
15206                project_value.clone(),
15207                Value::Text("src/Äuth.rs".to_string()),
15208                Value::Text("src/Äuth.rs0".to_string()),
15209                Value::Text("src/Äuth.rs".to_string()),
15210                Value::Text("src/Äuth.rs/".to_string()),
15211            ],
15212            &["idx_graph_coverage_scope_order"],
15213            false,
15214            "coverage path filter",
15215        )?;
15216        assert_coverage_discovery_plan(
15217            &store.connection,
15218            "EXPLAIN QUERY PLAN
15219             SELECT coverage.id FROM graph_coverage AS coverage
15220              WHERE coverage.project_instance_id = ? AND coverage.state = ?
15221              ORDER BY coverage.state, coverage.scope_path, coverage.id LIMIT 11",
15222            &[project_value.clone(), Value::Text("failed".to_string())],
15223            &["idx_graph_coverage_discovery_state"],
15224            false,
15225            "coverage state filter",
15226        )?;
15227        assert_coverage_discovery_plan(
15228            &store.connection,
15229            "EXPLAIN QUERY PLAN
15230             SELECT coverage.id FROM graph_coverage AS coverage
15231              WHERE coverage.project_instance_id = ?
15232                AND coverage.state = 'complete' AND coverage.total = 0
15233                AND coverage.relation_scope = 'extended'
15234                AND coverage.relation_kind = 'documents'
15235              ORDER BY coverage.relation_scope, coverage.relation_kind,
15236                       coverage.state, coverage.id LIMIT 11",
15237            std::slice::from_ref(&project_value),
15238            &["idx_graph_coverage_relation_state"],
15239            false,
15240            "zero-candidate coverage filter",
15241        )?;
15242        assert_coverage_discovery_plan(
15243            &store.connection,
15244            "EXPLAIN QUERY PLAN
15245             SELECT coverage.id FROM graph_coverage AS coverage
15246              WHERE coverage.project_instance_id = ?
15247                AND coverage.state = 'complete'
15248                AND NOT (coverage.total = 0
15249                         AND coverage.relation_scope IS 'extended'
15250                         AND coverage.relation_kind IS 'documents')
15251              ORDER BY coverage.state, coverage.scope_path, coverage.id LIMIT 11",
15252            std::slice::from_ref(&project_value),
15253            &["idx_graph_coverage_discovery_state"],
15254            false,
15255            "positive-complete coverage filter",
15256        )?;
15257        assert_coverage_discovery_plan(
15258            &store.connection,
15259            "EXPLAIN QUERY PLAN
15260             SELECT coverage.id FROM graph_coverage AS coverage
15261              WHERE coverage.project_instance_id = ? AND coverage.reason = ?
15262              ORDER BY coverage.reason, coverage.scope_path, coverage.id LIMIT 11",
15263            &[
15264                project_value.clone(),
15265                Value::Text("parser failed".to_string()),
15266            ],
15267            &["idx_graph_coverage_discovery_reason"],
15268            false,
15269            "coverage reason filter",
15270        )?;
15271        for (column, index, context) in [
15272            (
15273                "source_parser",
15274                "idx_source_parse_metadata_source_parser_path",
15275                "coverage parser filter",
15276            ),
15277            (
15278                "fact_parser",
15279                "idx_source_parse_metadata_fact_parser_path",
15280                "coverage provider filter",
15281            ),
15282        ] {
15283            assert_coverage_discovery_plan(
15284                &store.connection,
15285                &format!(
15286                    "EXPLAIN QUERY PLAN
15287                     SELECT coverage.id
15288                       FROM source_parse_metadata AS metadata
15289                       CROSS JOIN graph_coverage AS coverage
15290                         ON coverage.scope_kind = 'path'
15291                        AND coverage.scope_path = metadata.path
15292                      WHERE coverage.project_instance_id = ?
15293                        AND metadata.{column} = ?
15294                      ORDER BY metadata.path, coverage.id LIMIT 11"
15295                ),
15296                &[
15297                    project_value.clone(),
15298                    Value::Text("tree-sitter".to_string()),
15299                ],
15300                &[index, "idx_graph_coverage_identity"],
15301                true,
15302                context,
15303            )?;
15304        }
15305        let all = store.repository_coverage_page(
15306            fixture.project,
15307            &RepositoryCoverageQuery {
15308                start_index: 0,
15309                limit: 2,
15310                path_prefix: None,
15311                parser: None,
15312                provider: None,
15313                relation: None,
15314                state: None,
15315                reason: None,
15316            },
15317        )?;
15318        require(
15319            all.truncated && all.rows.len() == 2,
15320            "coverage discovery did not use LIMIT + 1",
15321        )?;
15322        let all_states = store.repository_coverage_page(
15323            fixture.project,
15324            &RepositoryCoverageQuery {
15325                start_index: 0,
15326                limit: 20,
15327                path_prefix: None,
15328                parser: None,
15329                provider: None,
15330                relation: None,
15331                state: None,
15332                reason: None,
15333            },
15334        )?;
15335        for state in [
15336            CoverageState::Complete,
15337            CoverageState::NoCandidates,
15338            CoverageState::Partial,
15339            CoverageState::Failed,
15340            CoverageState::Ignored,
15341            CoverageState::Oversized,
15342            CoverageState::Quarantined,
15343            CoverageState::Stale,
15344        ] {
15345            require(
15346                all_states
15347                    .rows
15348                    .iter()
15349                    .any(|row| row.coverage.state() == state),
15350                &format!("coverage discovery omitted {state:?}"),
15351            )?;
15352        }
15353
15354        let complete = store.repository_coverage_page(
15355            fixture.project,
15356            &RepositoryCoverageQuery {
15357                start_index: 0,
15358                limit: 10,
15359                path_prefix: None,
15360                parser: None,
15361                provider: None,
15362                relation: None,
15363                state: Some(CoverageState::Complete),
15364                reason: None,
15365            },
15366        )?;
15367        let complete_contract = !complete.rows.is_empty()
15368            && complete
15369                .rows
15370                .iter()
15371                .all(|row| row.coverage.state() == CoverageState::Complete)
15372            && complete.rows.iter().any(|row| {
15373                row.coverage.total() == 0
15374                    && row.coverage.relation()
15375                        == Some(GraphRelationKind::Extended(ExtendedRelationKind::Tests))
15376            });
15377        require(
15378            complete_contract,
15379            &format!(
15380                "complete coverage filter changed a non-document zero row or admitted no-candidates: {:?}",
15381                complete.rows
15382            ),
15383        )?;
15384        let no_candidates = store.repository_coverage_page(
15385            fixture.project,
15386            &RepositoryCoverageQuery {
15387                start_index: 0,
15388                limit: 10,
15389                path_prefix: None,
15390                parser: None,
15391                provider: None,
15392                relation: None,
15393                state: Some(CoverageState::NoCandidates),
15394                reason: None,
15395            },
15396        )?;
15397        require(
15398            !no_candidates.rows.is_empty()
15399                && no_candidates.rows.iter().all(|row| {
15400                    row.coverage.state() == CoverageState::NoCandidates && row.coverage.total() == 0
15401                }),
15402            "zero-candidate coverage filter admitted a positive-complete row",
15403        )?;
15404
15405        let exact_path = store.repository_coverage_page(
15406            fixture.project,
15407            &RepositoryCoverageQuery {
15408                start_index: 0,
15409                limit: 1,
15410                path_prefix: Some("src/Äuth.rs".to_string()),
15411                parser: None,
15412                provider: None,
15413                relation: None,
15414                state: None,
15415                reason: None,
15416            },
15417        )?;
15418        require(
15419            !exact_path.truncated && exact_path.rows.len() == 1,
15420            "exact coverage path admitted a lexical sibling",
15421        )?;
15422        require_eq(
15423            exact_path.rows[0].coverage.scope(),
15424            &CoverageScope::Path {
15425                path: RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
15426            },
15427            "exact coverage path scope",
15428        )?;
15429
15430        let parsed = store.repository_coverage_page(
15431            fixture.project,
15432            &RepositoryCoverageQuery {
15433                start_index: 0,
15434                limit: 10,
15435                path_prefix: Some("src/Äuth.rs".to_string()),
15436                parser: Some(ParserKind::TreeSitter),
15437                provider: Some(ParserKind::TreeSitter),
15438                relation: None,
15439                state: Some(CoverageState::Complete),
15440                reason: None,
15441            },
15442        )?;
15443        require_eq(&parsed.rows.len(), &1, "parser/provider filtered coverage")?;
15444        require_eq(
15445            &parsed.rows[0].parser,
15446            &Some(ParserKind::TreeSitter),
15447            "source parser provenance",
15448        )?;
15449        require_eq(
15450            &parsed.rows[0].provider,
15451            &Some(ParserKind::TreeSitter),
15452            "fact provider provenance",
15453        )?;
15454
15455        let failed_calls = store.repository_coverage_page(
15456            fixture.project,
15457            &RepositoryCoverageQuery {
15458                start_index: 0,
15459                limit: 10,
15460                path_prefix: None,
15461                parser: None,
15462                provider: None,
15463                relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
15464                state: Some(CoverageState::Failed),
15465                reason: Some("parser failed".to_string()),
15466            },
15467        )?;
15468        require_eq(&failed_calls.rows.len(), &1, "combined coverage filters")?;
15469        require_eq(
15470            &failed_calls.rows[0].coverage.state(),
15471            &CoverageState::Failed,
15472            "failed coverage state",
15473        )?;
15474
15475        let absent = store.repository_coverage_page(
15476            fixture.project,
15477            &RepositoryCoverageQuery {
15478                start_index: 0,
15479                limit: 10,
15480                path_prefix: None,
15481                parser: None,
15482                provider: Some(ParserKind::Manifest),
15483                relation: None,
15484                state: None,
15485                reason: None,
15486            },
15487        )?;
15488        require(
15489            absent.rows.is_empty(),
15490            "provider filter returned a false match",
15491        )?;
15492        store.finish_index_read_snapshot()?;
15493        drop(store);
15494
15495        let writer = AtlasStore::open_for_project(&db_path, &project_root)?;
15496        writer.connection.execute(
15497            "UPDATE source_parse_metadata SET source_parser = 'corrupt-parser'
15498              WHERE path = 'src/Äuth.rs'",
15499            [],
15500        )?;
15501        drop(writer);
15502        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15503        let error = require_db_error(
15504            store.repository_coverage_page(
15505                fixture.project,
15506                &RepositoryCoverageQuery {
15507                    start_index: 0,
15508                    limit: 10,
15509                    path_prefix: Some("src/Äuth.rs".to_string()),
15510                    parser: None,
15511                    provider: None,
15512                    relation: None,
15513                    state: None,
15514                    reason: None,
15515                },
15516            ),
15517            "corrupt parser provenance was accepted",
15518        )?;
15519        require(
15520            matches!(error, DbError::InvalidEnum { .. }),
15521            &format!("unexpected parser corruption error: {error}"),
15522        )?;
15523        Ok(())
15524    }
15525
15526    #[test]
15527    fn navigation_connections_cover_families_prefixes_truncation_and_reopen()
15528    -> Result<(), Box<dyn Error>> {
15529        let temp = tempfile::tempdir()?;
15530        let root = temp.path().join("navigation-connections");
15531        fs::create_dir_all(&root)?;
15532        let db_path = root.join("projectatlas.db");
15533        let mut store = AtlasStore::open_for_project(&db_path, &root)?;
15534        let fixture = publish_navigation_fixture(&mut store, "navigation-connections")?;
15535        let owners = vec![
15536            RepositoryNavigationNode {
15537                path: fixture.api_path.clone(),
15538                kind: NodeKind::File,
15539            },
15540            RepositoryNavigationNode {
15541                path: "src/auth".to_string(),
15542                kind: NodeKind::Folder,
15543            },
15544            RepositoryNavigationNode {
15545                path: fixture.manifest_path.clone(),
15546                kind: NodeKind::File,
15547            },
15548            RepositoryNavigationNode {
15549                path: ".".to_string(),
15550                kind: NodeKind::Folder,
15551            },
15552        ];
15553        let pages = store.repository_navigation_connections(&owners, 2, 20)?;
15554        require_eq(&pages.len(), &owners.len(), "navigation owner count")?;
15555        let api = &pages[0];
15556        let families = api
15557            .counts
15558            .iter()
15559            .map(|count| count.kind)
15560            .collect::<Vec<_>>();
15561        require_eq(
15562            &families,
15563            &NAVIGATION_CONNECTION_FAMILIES
15564                .iter()
15565                .map(|&(kind, _, _)| kind)
15566                .collect::<Vec<_>>(),
15567            "all navigation families",
15568        )?;
15569        let calls = api
15570            .counts
15571            .iter()
15572            .find(|count| count.kind == RankedConnectionKind::Call)
15573            .ok_or_else(|| io::Error::other("call navigation count is missing"))?;
15574        require_eq(&calls.count, &2, "bounded high-degree call count")?;
15575        require_eq(&calls.truncated, &true, "high-degree call truncation")?;
15576        require_eq(&api.truncated, &true, "file aggregate truncation")?;
15577        require(
15578            api.connections.iter().any(|connection| {
15579                connection.kind == RankedConnectionKind::Test
15580                    && connection.direction == RankedConnectionDirection::Inbound
15581            }),
15582            "inbound test connection was not projected",
15583        )?;
15584
15585        let folder = &pages[1];
15586        let folder_imports = folder
15587            .counts
15588            .iter()
15589            .find(|count| count.kind == RankedConnectionKind::Import)
15590            .ok_or_else(|| io::Error::other("folder import count is missing"))?;
15591        require_eq(
15592            &folder_imports.count,
15593            &1,
15594            "folder prefix import count excluding sibling authz.rs",
15595        )?;
15596        let manifest = &pages[2];
15597        require(
15598            manifest
15599                .counts
15600                .iter()
15601                .any(|count| count.kind == RankedConnectionKind::Package && count.count == 1),
15602            "manifest-owned package context is missing",
15603        )?;
15604        require(
15605            pages[3]
15606                .counts
15607                .iter()
15608                .any(|count| count.kind == RankedConnectionKind::Call),
15609            "root aggregate omitted bounded call context",
15610        )?;
15611
15612        let globally_sampled = store.repository_navigation_connections(&owners[..1], 10, 3)?;
15613        require_eq(
15614            &globally_sampled[0].connections.len(),
15615            &3,
15616            "global connection sample limit",
15617        )?;
15618        require_eq(
15619            &globally_sampled[0].counts.len(),
15620            &NAVIGATION_CONNECTION_FAMILIES.len(),
15621            "global sample retained all family counts",
15622        )?;
15623        require(
15624            globally_sampled[0]
15625                .counts
15626                .iter()
15627                .all(|count| !count.truncated),
15628            "global sample incorrectly reported family overflow",
15629        )?;
15630        require_eq(
15631            &globally_sampled[0].truncated,
15632            &true,
15633            "global sample truncation",
15634        )?;
15635
15636        drop(store);
15637        let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
15638        require_eq(
15639            &reader.project_instance_id()?,
15640            &Some(fixture.project),
15641            "reopened navigation identity",
15642        )?;
15643        let reopened = reader.repository_navigation_connections(&owners[..1], 2, 20)?;
15644        require_eq(
15645            &reopened[0].counts,
15646            &api.counts,
15647            "reopened navigation counts",
15648        )?;
15649        Ok(())
15650    }
15651
15652    #[test]
15653    fn navigation_connections_use_owned_indexes_and_fail_all_or_error_on_corruption()
15654    -> Result<(), Box<dyn Error>> {
15655        let temp = tempfile::tempdir()?;
15656        let root = temp.path().join("navigation-plan-corruption");
15657        fs::create_dir_all(&root)?;
15658        let db_path = root.join("projectatlas.db");
15659        let mut store = AtlasStore::open_for_project(&db_path, &root)?;
15660        let fixture = publish_navigation_fixture(&mut store, "navigation-plan-corruption")?;
15661        let owner = RepositoryNavigationNode {
15662            path: fixture.api_path,
15663            kind: NodeKind::File,
15664        };
15665        for (direction, expected_index) in [
15666            (
15667                RankedConnectionDirection::Outbound,
15668                "idx_graph_relations_source_kind",
15669            ),
15670            (
15671                RankedConnectionDirection::Inbound,
15672                "idx_graph_relations_target_kind",
15673            ),
15674        ] {
15675            let mut values = Vec::new();
15676            let sql = navigation_connection_branch(
15677                0,
15678                &owner,
15679                RankedConnectionKind::Call,
15680                "legacy",
15681                "calls",
15682                direction,
15683                3,
15684                &mut values,
15685            );
15686            let mut statement = store
15687                .connection
15688                .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
15689            let details = statement
15690                .query_map(params_from_iter(values.iter()), |row| {
15691                    row.get::<_, String>(3)
15692                })?
15693                .collect::<Result<Vec<_>, _>>()?;
15694            for expected in [
15695                expected_index,
15696                "idx_graph_entities_path",
15697                "idx_graph_entities_manifest_path",
15698            ] {
15699                require(
15700                    details.iter().any(|detail| detail.contains(expected)),
15701                    &format!("navigation plan missed {expected}: {details:?}"),
15702                )?;
15703            }
15704            require(
15705                details.iter().all(|detail| !detail.contains("SCAN graph_")),
15706                &format!("navigation plan scanned graph storage: {details:?}"),
15707            )?;
15708        }
15709        let folder_owner = RepositoryNavigationNode {
15710            path: "src/auth".to_string(),
15711            kind: NodeKind::Folder,
15712        };
15713        for (direction, expected_index) in [
15714            (
15715                RankedConnectionDirection::Outbound,
15716                "idx_graph_relations_source_kind",
15717            ),
15718            (
15719                RankedConnectionDirection::Inbound,
15720                "idx_graph_relations_target_kind",
15721            ),
15722        ] {
15723            let mut values = Vec::new();
15724            let sql = navigation_connection_branch(
15725                0,
15726                &folder_owner,
15727                RankedConnectionKind::Call,
15728                "legacy",
15729                "calls",
15730                direction,
15731                3,
15732                &mut values,
15733            );
15734            let details = store
15735                .connection
15736                .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?
15737                .query_map(params_from_iter(values.iter()), |row| {
15738                    row.get::<_, String>(3)
15739                })?
15740                .collect::<Result<Vec<_>, _>>()?;
15741            for expected in [
15742                expected_index,
15743                "idx_graph_entities_path",
15744                "idx_graph_entities_manifest_path",
15745            ] {
15746                require(
15747                    details.iter().any(|detail| detail.contains(expected)),
15748                    &format!("folder navigation plan missed {expected}: {details:?}"),
15749                )?;
15750            }
15751            require(
15752                details.iter().all(|detail| !detail.contains("SCAN graph_")),
15753                &format!("folder navigation plan scanned graph storage: {details:?}"),
15754            )?;
15755        }
15756        let root_owner = RepositoryNavigationNode {
15757            path: ".".to_string(),
15758            kind: NodeKind::Folder,
15759        };
15760        let mut root_values = Vec::new();
15761        let root_sql = navigation_connection_branch(
15762            0,
15763            &root_owner,
15764            RankedConnectionKind::Call,
15765            "legacy",
15766            "calls",
15767            RankedConnectionDirection::Outbound,
15768            2,
15769            &mut root_values,
15770        );
15771        let root_details = store
15772            .connection
15773            .prepare(&format!("EXPLAIN QUERY PLAN {root_sql}"))?
15774            .query_map(params_from_iter(root_values.iter()), |row| {
15775                row.get::<_, String>(3)
15776            })?
15777            .collect::<Result<Vec<_>, _>>()?;
15778        require(
15779            root_details
15780                .iter()
15781                .any(|detail| detail.contains("idx_graph_relations_kind_order")),
15782            &format!("root navigation plan missed family index: {root_details:?}"),
15783        )?;
15784        require(
15785            root_details
15786                .iter()
15787                .all(|detail| !detail.contains("SCAN graph_")),
15788            &format!("root navigation plan scanned graph storage: {root_details:?}"),
15789        )?;
15790
15791        store
15792            .connection
15793            .execute_batch("PRAGMA ignore_check_constraints = ON")?;
15794        store.connection.execute(
15795            "UPDATE graph_relations
15796                SET resolution_status = 'unresolved', reference_text = 'broken-route'
15797              WHERE relation_scope = 'extended' AND relation_kind = 'routes-to'",
15798            [],
15799        )?;
15800        let error = require_db_error(
15801            store.repository_navigation_connections(&[owner], 4, 20),
15802            "corrupt navigation relation returned a partial page",
15803        )?;
15804        require(
15805            matches!(error, DbError::GraphRowShape { .. }),
15806            &format!("corrupt navigation relation returned {error}"),
15807        )?;
15808        Ok(())
15809    }
15810
15811    #[test]
15812    fn graph_queries_fail_closed_on_corrupt_normalized_rows() -> Result<(), Box<dyn Error>> {
15813        let temp = tempfile::tempdir()?;
15814        let project_root = temp.path().join("graph-corruption");
15815        let atlas_dir = project_root.join(".projectatlas");
15816        fs::create_dir_all(&atlas_dir)?;
15817        let db_path = atlas_dir.join("projectatlas.db");
15818        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
15819        let fixture = publish_fixture(&mut store, "graph-corruption")?;
15820        drop(store);
15821        let store = AtlasStore::open_for_project(&db_path, &project_root)?;
15822        let source = fixture
15823            .entities
15824            .iter()
15825            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
15826            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
15827        let folder = fixture
15828            .entities
15829            .iter()
15830            .find(|entity| matches!(entity.selector(), EntitySelector::Folder { .. }))
15831            .ok_or_else(|| io::Error::other("folder fixture missing"))?;
15832        let symbol = fixture
15833            .entities
15834            .iter()
15835            .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
15836            .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
15837        let ambiguous = fixture
15838            .relations
15839            .iter()
15840            .find(|relation| matches!(relation.resolution(), RelationResolution::Ambiguous { .. }))
15841            .ok_or_else(|| io::Error::other("ambiguous relation fixture missing"))?;
15842        let source_digest = source.key().digest_bytes()?;
15843        let folder_digest = folder.key().digest_bytes()?;
15844        let symbol_digest = symbol.key().digest_bytes()?;
15845        let ambiguous_digest = ambiguous.key().digest_bytes()?;
15846        let source_canonical = store.connection.query_row(
15847            "SELECT canonical_identity FROM graph_entities WHERE entity_key = ?1",
15848            [&source_digest[..]],
15849            |row| row.get::<_, String>(0),
15850        )?;
15851        let symbol_canonical = store.connection.query_row(
15852            "SELECT canonical_identity FROM graph_entities WHERE entity_key = ?1",
15853            [&symbol_digest[..]],
15854            |row| row.get::<_, String>(0),
15855        )?;
15856        store
15857            .connection
15858            .execute_batch("PRAGMA ignore_check_constraints = ON")?;
15859
15860        store.connection.execute(
15861            "UPDATE graph_entities SET entity_kind = 'corrupt' WHERE entity_key = ?1",
15862            [&source_digest[..]],
15863        )?;
15864        {
15865            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15866            let error = require_db_error(
15867                reader.repository_graph_entity(source.key()),
15868                "malformed graph enum was accepted",
15869            )?;
15870            require(
15871                matches!(error, DbError::InvalidEnum { .. }),
15872                &format!("unexpected malformed-enum error: {error}"),
15873            )?;
15874            reader.finish_index_read_snapshot()?;
15875        }
15876        store.connection.execute(
15877            "UPDATE graph_entities SET entity_kind = 'file' WHERE entity_key = ?1",
15878            [&source_digest[..]],
15879        )?;
15880
15881        store.connection.execute(
15882            "UPDATE graph_relations SET candidate_count = 0 WHERE relation_key = ?1",
15883            [&ambiguous_digest[..]],
15884        )?;
15885        {
15886            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15887            let error = require_db_error(
15888                reader.repository_graph_relations(
15889                    RepositoryGraphRelationQuery::Outbound {
15890                        source: source.key().clone(),
15891                    },
15892                    10,
15893                ),
15894                "zero ambiguity count was accepted",
15895            )?;
15896            require(
15897                matches!(error, DbError::GraphRowShape { .. }),
15898                &format!("unexpected candidate-count error: {error}"),
15899            )?;
15900            let adjacency_error = require_db_error(
15901                reader.repository_graph_adjacency_page(
15902                    &[source.key().clone()],
15903                    RepositoryGraphDirection::Outbound,
15904                    None,
15905                    10,
15906                    None,
15907                ),
15908                "corrupt adjacency relation returned a partial page",
15909            )?;
15910            require(
15911                matches!(adjacency_error, DbError::GraphRowShape { .. }),
15912                &format!("unexpected adjacency row-shape error: {adjacency_error}"),
15913            )?;
15914            reader.finish_index_read_snapshot()?;
15915        }
15916        store.connection.execute(
15917            "UPDATE graph_relations SET candidate_count = 2 WHERE relation_key = ?1",
15918            [&ambiguous_digest[..]],
15919        )?;
15920
15921        store.connection.execute(
15922            "UPDATE graph_relations SET resolution_status = 'resolved'
15923              WHERE relation_key = ?1",
15924            [&ambiguous_digest[..]],
15925        )?;
15926        {
15927            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15928            let error = require_db_error(
15929                reader.repository_graph_relations(
15930                    RepositoryGraphRelationQuery::Outbound {
15931                        source: source.key().clone(),
15932                    },
15933                    10,
15934                ),
15935                "contradictory resolution columns were accepted",
15936            )?;
15937            require(
15938                matches!(error, DbError::GraphRowShape { .. }),
15939                &format!("unexpected resolution-shape error: {error}"),
15940            )?;
15941            reader.finish_index_read_snapshot()?;
15942        }
15943        store.connection.execute(
15944            "UPDATE graph_relations SET resolution_status = 'ambiguous'
15945              WHERE relation_key = ?1",
15946            [&ambiguous_digest[..]],
15947        )?;
15948
15949        store.connection.execute(
15950            "UPDATE graph_coverage SET total = 999
15951              WHERE scope_kind = 'project' AND relation_scope IS NULL",
15952            [],
15953        )?;
15954        {
15955            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15956            let error = require_db_error(
15957                reader.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10),
15958                "contradictory coverage total was accepted",
15959            )?;
15960            require(
15961                matches!(error, DbError::GraphRowShape { .. }),
15962                &format!("unexpected coverage-total error: {error}"),
15963            )?;
15964            reader.finish_index_read_snapshot()?;
15965        }
15966        store.connection.execute(
15967            "UPDATE graph_coverage SET total = covered + omitted
15968              WHERE scope_kind = 'project' AND relation_scope IS NULL",
15969            [],
15970        )?;
15971
15972        store.connection.execute(
15973            "UPDATE project_identity SET active_generation = 99 WHERE singleton = 1",
15974            [],
15975        )?;
15976        {
15977            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15978            let error = require_db_error(
15979                reader.repository_graph_entity(source.key()),
15980                "mismatched typed graph generation was accepted",
15981            )?;
15982            require(
15983                matches!(error, DbError::GraphRowShape { .. }),
15984                &format!("unexpected typed-generation error: {error}"),
15985            )?;
15986            reader.finish_index_read_snapshot()?;
15987        }
15988        store.connection.execute(
15989            "UPDATE project_identity SET active_generation = 1 WHERE singleton = 1",
15990            [],
15991        )?;
15992
15993        store.connection.execute(
15994            "UPDATE graph_entities SET canonical_identity = 'different-collision-witness'
15995              WHERE entity_key = ?1",
15996            [&source_digest[..]],
15997        )?;
15998        {
15999            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16000            let error = require_db_error(
16001                reader.repository_graph_entity(source.key()),
16002                "canonical collision witness was accepted",
16003            )?;
16004            require(
16005                matches!(error, DbError::GraphContract(_)),
16006                &format!("unexpected collision-witness error: {error}"),
16007            )?;
16008            reader.finish_index_read_snapshot()?;
16009        }
16010        store.connection.execute(
16011            "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
16012            params![source_canonical, &source_digest[..]],
16013        )?;
16014
16015        store.connection.execute(
16016            "UPDATE graph_entities SET entity_key = zeroblob(32) WHERE entity_key = ?1",
16017            [&folder_digest[..]],
16018        )?;
16019        let folder_path = RepositoryNodePath::new(Path::new("src"))?;
16020        {
16021            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16022            let error = require_db_error(
16023                reader.repository_graph_entities_by_path(fixture.project, &folder_path, 10),
16024                "invalid stable digest was accepted",
16025            )?;
16026            require(
16027                matches!(error, DbError::GraphContract(_)),
16028                &format!("unexpected stable-digest error: {error}"),
16029            )?;
16030            reader.finish_index_read_snapshot()?;
16031        }
16032        store.connection.execute(
16033            "UPDATE graph_entities SET entity_key = ?1 WHERE entity_key = zeroblob(32)",
16034            [&folder_digest[..]],
16035        )?;
16036
16037        store.connection.execute(
16038            "UPDATE graph_entities SET entity_key = X'01' WHERE entity_key = ?1",
16039            [&folder_digest[..]],
16040        )?;
16041        {
16042            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16043            let error = require_db_error(
16044                reader.repository_graph_entities_by_path(fixture.project, &folder_path, 10),
16045                "short graph key blob was accepted",
16046            )?;
16047            require(
16048                matches!(
16049                    error,
16050                    DbError::InvalidBlobLength {
16051                        field: "graph_entities.entity_key",
16052                        expected: 32,
16053                        found: 1
16054                    }
16055                ),
16056                &format!("unexpected graph-key length error: {error}"),
16057            )?;
16058            reader.finish_index_read_snapshot()?;
16059        }
16060        store.connection.execute(
16061            "UPDATE graph_entities SET entity_key = ?1 WHERE entity_key = X'01'",
16062            [&folder_digest[..]],
16063        )?;
16064
16065        store.connection.execute(
16066            "UPDATE graph_entities SET canonical_identity = X'00' WHERE entity_key = ?1",
16067            [&symbol_digest[..]],
16068        )?;
16069        let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
16070        {
16071            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16072            let error = require_db_error(
16073                reader.repository_graph_entities_by_path(fixture.project, &source_path, 10),
16074                "later row conversion failure returned a successful partial page",
16075            )?;
16076            require(
16077                matches!(error, DbError::Sqlite(_)),
16078                &format!("unexpected later-row conversion error: {error}"),
16079            )?;
16080            reader.finish_index_read_snapshot()?;
16081        }
16082        store.connection.execute(
16083            "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
16084            params![symbol_canonical, &symbol_digest[..]],
16085        )?;
16086        store
16087            .connection
16088            .execute_batch("PRAGMA ignore_check_constraints = OFF")?;
16089        Ok(())
16090    }
16091
16092    #[test]
16093    fn identity_rejections_are_generation_atomic_indexed_and_reopenable()
16094    -> Result<(), Box<dyn Error>> {
16095        let temp = tempfile::tempdir()?;
16096        let project_root = temp.path().join("identity-rejections");
16097        let atlas_dir = project_root.join(".projectatlas");
16098        fs::create_dir_all(&atlas_dir)?;
16099        let db_path = atlas_dir.join("projectatlas.db");
16100        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
16101        let fixture = publish_fixture(&mut writer, "identity-rejections")?;
16102        let graph_v2 = graph_fixture(fixture.project, IndexGeneration::new(2))?;
16103        let graph_v3 = graph_fixture(fixture.project, IndexGeneration::new(3))?;
16104        let graph_v4 = graph_fixture(fixture.project, IndexGeneration::new(4))?;
16105        let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
16106        let first = GraphIdentityRejection {
16107            path: source_path.clone(),
16108            span: SourceSpan::new(2, 0, 2, 3)?,
16109            parser: ParserKind::TreeSitter,
16110            field: GraphIdentityField::Symbol,
16111            reason: GraphIdentityRejectionReason::Empty,
16112            fact_index: 0,
16113        };
16114        let first_distinct = GraphIdentityRejection {
16115            fact_index: 1,
16116            ..first.clone()
16117        };
16118        let mut first_rows = vec![first, first_distinct];
16119        first_rows.push(first_rows[0].clone());
16120        let expected_first = first_rows[..2].to_vec();
16121        {
16122            let mut publication = writer.begin_index_publication("identity-rejections")?;
16123            publication.replace_repository_graph(
16124                fixture.project,
16125                &graph_v2.entities,
16126                &graph_v2.relations,
16127                &graph_v2.occurrences,
16128                &graph_v2.coverage,
16129            )?;
16130            publication.replace_graph_identity_rejections(fixture.project, &first_rows)?;
16131            publication.complete()?;
16132        }
16133        let first_rows = writer.repository_graph_identity_rejections(
16134            fixture.project,
16135            std::slice::from_ref(&source_path),
16136            10,
16137            None,
16138        )?;
16139        require_eq(
16140            &first_rows,
16141            &expected_first,
16142            "first typed rejection publication",
16143        )?;
16144
16145        let query_plan = writer
16146            .connection
16147            .prepare(
16148                "EXPLAIN QUERY PLAN
16149                   SELECT file_path
16150                     FROM graph_identity_rejections
16151                    WHERE project_instance_id = ?1
16152                      AND generation = ?2
16153                      AND file_path IN (?3)",
16154            )?
16155            .query_map(
16156                params![&fixture.project.as_bytes()[..], 2_i64, source_path.as_str()],
16157                |row| row.get::<_, String>(3),
16158            )?
16159            .collect::<Result<Vec<_>, _>>()?
16160            .join(" ");
16161        require(
16162            query_plan.contains("idx_graph_identity_rejections_generation_path"),
16163            "typed rejection lookup did not use its generation/path index",
16164        )?;
16165
16166        let reopened = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16167        let reopened_rows = reopened.repository_graph_identity_rejections(
16168            fixture.project,
16169            std::slice::from_ref(&source_path),
16170            10,
16171            None,
16172        )?;
16173        require_eq(
16174            &reopened_rows,
16175            &expected_first,
16176            "typed rejection survives SQLite reopen",
16177        )?;
16178        reopened.finish_index_read_snapshot()?;
16179
16180        let second = GraphIdentityRejection {
16181            path: source_path.clone(),
16182            span: SourceSpan::new(4, 0, 4, 4)?,
16183            parser: ParserKind::TreeSitter,
16184            field: GraphIdentityField::Signature,
16185            reason: GraphIdentityRejectionReason::SurroundingWhitespace,
16186            fact_index: 0,
16187        };
16188        let expected_second = vec![second.clone()];
16189        {
16190            let mut publication = writer.begin_index_publication("identity-rejections")?;
16191            publication.replace_repository_graph_for_paths(
16192                fixture.project,
16193                &[source_path.as_str().to_string()],
16194                &graph_v3.entities,
16195                &graph_v3.relations,
16196                &graph_v3.occurrences,
16197                &graph_v3.coverage,
16198            )?;
16199            publication.replace_graph_identity_rejections(
16200                fixture.project,
16201                std::slice::from_ref(&second),
16202            )?;
16203            publication.complete()?;
16204        }
16205        let incremental_rows = writer.repository_graph_identity_rejections(
16206            fixture.project,
16207            std::slice::from_ref(&source_path),
16208            10,
16209            None,
16210        )?;
16211        require_eq(
16212            &incremental_rows,
16213            &expected_second,
16214            "incremental path replacement swaps typed rejection rows",
16215        )?;
16216        let third = GraphIdentityRejection {
16217            path: source_path.clone(),
16218            span: SourceSpan::new(6, 0, 6, 6)?,
16219            parser: ParserKind::TreeSitter,
16220            field: GraphIdentityField::Package,
16221            reason: GraphIdentityRejectionReason::Oversized,
16222            fact_index: 0,
16223        };
16224        let expected_third = vec![third.clone()];
16225        {
16226            let mut publication = writer.begin_index_publication("identity-rejections")?;
16227            publication.replace_repository_graph(
16228                fixture.project,
16229                &graph_v4.entities,
16230                &graph_v4.relations,
16231                &graph_v4.occurrences,
16232                &graph_v4.coverage,
16233            )?;
16234            publication
16235                .replace_graph_identity_rejections(fixture.project, std::slice::from_ref(&third))?;
16236            let missing = GraphEntity::new(
16237                fixture.project,
16238                EntitySelector::File {
16239                    path: RepositoryFilePath::new(Path::new("src/missing.rs"))?,
16240                },
16241                IndexGeneration::new(4),
16242            )?;
16243            let error = publication.replace_repository_graph_for_paths(
16244                fixture.project,
16245                &["src/missing.rs".to_string()],
16246                &[missing],
16247                &[],
16248                &[],
16249                &[],
16250            );
16251            require(
16252                error.is_err(),
16253                "late graph fault did not abort staged rejection",
16254            )?;
16255        }
16256        let retained_after_fault = writer.repository_graph_identity_rejections(
16257            fixture.project,
16258            std::slice::from_ref(&source_path),
16259            10,
16260            None,
16261        )?;
16262        require_eq(
16263            &retained_after_fault,
16264            &expected_second,
16265            "late publication fault retained the prior complete generation",
16266        )?;
16267
16268        {
16269            let mut publication = writer.begin_index_publication("identity-rejections")?;
16270            publication.replace_repository_graph(
16271                fixture.project,
16272                &graph_v4.entities,
16273                &graph_v4.relations,
16274                &graph_v4.occurrences,
16275                &graph_v4.coverage,
16276            )?;
16277            publication
16278                .replace_graph_identity_rejections(fixture.project, std::slice::from_ref(&third))?;
16279            publication.complete()?;
16280        }
16281        let retried = writer.repository_graph_identity_rejections(
16282            fixture.project,
16283            std::slice::from_ref(&source_path),
16284            10,
16285            None,
16286        )?;
16287        require_eq(
16288            &retried,
16289            &expected_third,
16290            "typed rejection retry replaces prior generation",
16291        )?;
16292        let retained_raw_identity_count = writer.connection.query_row(
16293            "SELECT COUNT(*)
16294               FROM graph_identity_rejections
16295              WHERE file_path LIKE '%bad%'
16296                 OR reason LIKE '%bad%'",
16297            [],
16298            |row| row.get::<_, i64>(0),
16299        )?;
16300        require_eq(
16301            &retained_raw_identity_count,
16302            &0,
16303            "typed rejection table retained no invalid raw identity material",
16304        )?;
16305        Ok(())
16306    }
16307
16308    #[test]
16309    fn incremental_rejection_ceiling_covers_preserved_and_incoming_rows()
16310    -> Result<(), Box<dyn Error>> {
16311        let temp = tempfile::tempdir()?;
16312        let root = temp.path().join("identity-rejection-ceiling");
16313        fs::create_dir_all(root.join(".projectatlas"))?;
16314        let database = root.join(".projectatlas/projectatlas.db");
16315        let mut store = AtlasStore::open_for_project(&database, &root)?;
16316        let fixture = publish_fixture(&mut store, "identity-rejection-ceiling")?;
16317        let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
16318        let preserved_count = usize::try_from(GraphLimits::MAX_ROWS)?.saturating_sub(1);
16319        let preserved = (0..preserved_count)
16320            .map(|index| {
16321                let line = u32::try_from(index.saturating_add(1))?;
16322                Ok(GraphIdentityRejection {
16323                    path: source_path.clone(),
16324                    span: SourceSpan::new(line, 0, line, 0)?,
16325                    parser: ParserKind::TreeSitter,
16326                    field: GraphIdentityField::Symbol,
16327                    reason: GraphIdentityRejectionReason::Empty,
16328                    fact_index: u64::try_from(index)?,
16329                })
16330            })
16331            .collect::<Result<Vec<_>, Box<dyn Error>>>()?;
16332        let graph_v2 = graph_fixture(fixture.project, IndexGeneration::new(2))?;
16333        {
16334            let mut publication = store.begin_index_publication("identity-rejection-ceiling")?;
16335            publication.replace_repository_graph(
16336                fixture.project,
16337                &graph_v2.entities,
16338                &graph_v2.relations,
16339                &graph_v2.occurrences,
16340                &graph_v2.coverage,
16341            )?;
16342            publication.replace_graph_identity_rejections(fixture.project, &preserved)?;
16343            publication.complete()?;
16344        }
16345        let incoming = vec![
16346            GraphIdentityRejection {
16347                path: source_path.clone(),
16348                span: SourceSpan::new(20_000, 0, 20_000, 0)?,
16349                parser: ParserKind::TreeSitter,
16350                field: GraphIdentityField::RelationTarget,
16351                reason: GraphIdentityRejectionReason::ControlCharacters,
16352                fact_index: 20_000,
16353            },
16354            GraphIdentityRejection {
16355                path: source_path.clone(),
16356                span: SourceSpan::new(20_001, 0, 20_001, 0)?,
16357                parser: ParserKind::TreeSitter,
16358                field: GraphIdentityField::RelationTarget,
16359                reason: GraphIdentityRejectionReason::ControlCharacters,
16360                fact_index: 20_001,
16361            },
16362        ];
16363        {
16364            let mut publication = store.begin_index_publication("identity-rejection-overflow")?;
16365            publication.replace_repository_graph_for_paths(
16366                fixture.project,
16367                &["Cargo.toml".to_string()],
16368                &[],
16369                &[],
16370                &[],
16371                &[],
16372            )?;
16373            let error =
16374                match publication.replace_graph_identity_rejections(fixture.project, &incoming) {
16375                    Ok(()) => {
16376                        return Err(io::Error::other(
16377                            "preserved plus incoming rows unexpectedly exceeded silently",
16378                        )
16379                        .into());
16380                    }
16381                    Err(error) => error,
16382                };
16383            require(
16384                matches!(
16385                    error,
16386                    DbError::GraphContract(GraphContractError::InvalidLimits { .. })
16387                ),
16388                "combined rejection ceiling returned an unrelated error",
16389            )?;
16390        }
16391        require_eq(
16392            &store.repository_graph_generation()?,
16393            &Some(IndexGeneration::new(2)),
16394            "combined rejection ceiling retained the prior generation",
16395        )?;
16396        require_eq(
16397            &store
16398                .repository_graph_identity_rejections(
16399                    fixture.project,
16400                    std::slice::from_ref(&source_path),
16401                    GraphLimits::MAX_ROWS,
16402                    None,
16403                )?
16404                .len(),
16405            &preserved_count,
16406            "combined rejection ceiling did not preserve complete prior rows",
16407        )?;
16408
16409        let graph_v3 = graph_fixture(fixture.project, IndexGeneration::new(3))?;
16410        {
16411            let mut publication = store.begin_index_publication("identity-rejection-retry")?;
16412            publication.replace_repository_graph_for_paths(
16413                fixture.project,
16414                std::slice::from_ref(&source_path.as_str().to_string()),
16415                &graph_v3.entities,
16416                &graph_v3.relations,
16417                &graph_v3.occurrences,
16418                &graph_v3.coverage,
16419            )?;
16420            publication.replace_graph_identity_rejections(fixture.project, &incoming)?;
16421            publication.complete()?;
16422        }
16423        let retried = store.repository_graph_identity_rejections(
16424            fixture.project,
16425            std::slice::from_ref(&source_path),
16426            GraphLimits::MAX_ROWS,
16427            None,
16428        )?;
16429        require_eq(
16430            &retried,
16431            &incoming,
16432            "rejection ceiling retry after affected replacement",
16433        )?;
16434        Ok(())
16435    }
16436
16437    #[test]
16438    fn graph_publication_failure_rolls_back_text_graph_and_generation_for_readers()
16439    -> Result<(), Box<dyn Error>> {
16440        let temp = tempfile::tempdir()?;
16441        let project_root = temp.path().join("graph-publication");
16442        let atlas_dir = project_root.join(".projectatlas");
16443        fs::create_dir_all(&atlas_dir)?;
16444        let db_path = atlas_dir.join("projectatlas.db");
16445        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
16446        let fixture_v1 = publish_fixture(&mut writer, "graph-publication")?;
16447        let old_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16448        require_graph_projection(
16449            &old_reader,
16450            &fixture_v1,
16451            IndexGeneration::new(1),
16452            "fn verifyToken()",
16453        )?;
16454
16455        let missing_entity = GraphEntity::new(
16456            fixture_v1.project,
16457            EntitySelector::File {
16458                path: RepositoryFilePath::new(Path::new("src/missing.rs"))?,
16459            },
16460            IndexGeneration::new(2),
16461        )?;
16462        {
16463            let mut publication = writer.begin_index_publication("graph-publication")?;
16464            publication.replace_file_texts_for_paths(
16465                &["src/Äuth.rs".to_string()],
16466                &[IndexedFileText {
16467                    path: "src/Äuth.rs".to_string(),
16468                    content_hash: Some("hash-new".to_string()),
16469                    byte_count: "fn verifyTokenUpdated()".len(),
16470                    line_count: 1,
16471                    content: "fn verifyTokenUpdated()".to_string(),
16472                }],
16473            )?;
16474            let error = require_db_error(
16475                publication.replace_repository_graph_for_paths(
16476                    fixture_v1.project,
16477                    &["src/Äuth.rs".to_string(), "src/missing.rs".to_string()],
16478                    &[missing_entity],
16479                    &[],
16480                    &[],
16481                    &[],
16482                ),
16483                "missing-node graph publication unexpectedly succeeded",
16484            )?;
16485            require(
16486                matches!(error, DbError::Sqlite(_)),
16487                &format!("unexpected late graph publication error: {error}"),
16488            )?;
16489        }
16490
16491        require_graph_projection(
16492            &writer,
16493            &fixture_v1,
16494            IndexGeneration::new(1),
16495            "fn verifyToken()",
16496        )?;
16497        let rolled_back_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16498        require_graph_projection(
16499            &rolled_back_reader,
16500            &fixture_v1,
16501            IndexGeneration::new(1),
16502            "fn verifyToken()",
16503        )?;
16504        require_graph_projection(
16505            &old_reader,
16506            &fixture_v1,
16507            IndexGeneration::new(1),
16508            "fn verifyToken()",
16509        )?;
16510        rolled_back_reader.finish_index_read_snapshot()?;
16511
16512        let project = writer
16513            .project_instance_id()?
16514            .ok_or_else(|| io::Error::other("bound writer identity is missing"))?;
16515        let fixture_v2 = graph_fixture(project, IndexGeneration::new(2))?;
16516        {
16517            let mut publication = writer.begin_index_publication("graph-publication")?;
16518            publication.replace_file_texts_for_paths(
16519                &["src/Äuth.rs".to_string()],
16520                &[IndexedFileText {
16521                    path: "src/Äuth.rs".to_string(),
16522                    content_hash: Some("hash-new".to_string()),
16523                    byte_count: "fn verifyTokenUpdated()".len(),
16524                    line_count: 1,
16525                    content: "fn verifyTokenUpdated()".to_string(),
16526                }],
16527            )?;
16528            publication.replace_repository_graph(
16529                fixture_v2.project,
16530                &fixture_v2.entities,
16531                &fixture_v2.relations,
16532                &fixture_v2.occurrences,
16533                &fixture_v2.coverage,
16534            )?;
16535            publication.complete()?;
16536        }
16537
16538        require_graph_projection(
16539            &old_reader,
16540            &fixture_v1,
16541            IndexGeneration::new(1),
16542            "fn verifyToken()",
16543        )?;
16544        let new_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16545        require_graph_projection(
16546            &new_reader,
16547            &fixture_v2,
16548            IndexGeneration::new(2),
16549            "fn verifyTokenUpdated()",
16550        )?;
16551        new_reader.finish_index_read_snapshot()?;
16552        old_reader.finish_index_read_snapshot()?;
16553        Ok(())
16554    }
16555}