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/// Resolution rows whose retained ownership remains charged after a bounded read.
66#[derive(Debug)]
67pub struct RepositoryResolutionReadPage<T> {
68    /// Ordinary sorted/truncated graph page, with unchanged row semantics.
69    pub page: RepositoryGraphPage<T>,
70    /// Conservative returned-row envelope to retain through consumer transfers.
71    pub retained_bytes: u64,
72}
73
74impl<T> RepositoryResolutionReadPage<T> {
75    /// Construct an empty result without retaining decoded row payloads.
76    fn empty() -> Self {
77        Self {
78            page: empty_page(),
79            retained_bytes: 0,
80        }
81    }
82}
83
84/// Validated resource envelope for one bounded repository-graph database read.
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86pub struct RepositoryGraphReadBudget {
87    /// Maximum input keys, frontier selectors, or purpose-owner paths.
88    requested_rows: NonZeroU32,
89    /// Maximum fully reconstructed rows returned to the caller.
90    returned_rows: NonZeroU32,
91    /// Maximum raw `SQLite` payload bytes decoded by the complete batch.
92    decoded_bytes: NonZeroU64,
93    /// Maximum unique entities reconstructed by the complete batch.
94    hydrated_entities: NonZeroU32,
95    /// Maximum unique purpose-owning repository paths retained from entities.
96    hydrated_paths: NonZeroU32,
97}
98
99impl RepositoryGraphReadBudget {
100    /// Absolute compact-key or purpose-path request ceiling for one batch.
101    pub const MAX_REQUESTED_ROWS: u32 = GraphLimits::MAX_ROWS;
102    /// Absolute reconstructed-row ceiling for one batch.
103    pub const MAX_RETURNED_ROWS: u32 = GraphLimits::MAX_ROWS;
104    /// Absolute decoded payload ceiling for one hydration batch.
105    pub const MAX_DECODED_BYTES: u64 = 32 * 1_024 * 1_024;
106    /// Absolute unique-entity ceiling including one adjacency sentinel.
107    pub const MAX_HYDRATED_ENTITIES: u32 = 2 * (GraphLimits::MAX_ROWS + 1);
108    /// Absolute unique purpose-owner path ceiling for one hydration batch.
109    pub const MAX_HYDRATED_PATHS: u32 = 2 * (GraphLimits::MAX_ROWS + 1);
110
111    /// Construct one bounded repository-graph read envelope.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error when a limit is zero or above its absolute batch
116    /// ceiling.
117    pub fn new(
118        requested_rows: u32,
119        returned_rows: u32,
120        decoded_bytes: u64,
121        hydrated_entities: u32,
122        hydrated_paths: u32,
123    ) -> Result<Self, GraphContractError> {
124        if requested_rows == 0 || requested_rows > Self::MAX_REQUESTED_ROWS {
125            return Err(GraphContractError::InvalidLimits {
126                reason: "graph read requested-row budget is zero or above the batch ceiling",
127            });
128        }
129        if returned_rows == 0 || returned_rows > Self::MAX_RETURNED_ROWS {
130            return Err(GraphContractError::InvalidLimits {
131                reason: "graph read returned-row budget is zero or above the batch ceiling",
132            });
133        }
134        if decoded_bytes == 0 || decoded_bytes > Self::MAX_DECODED_BYTES {
135            return Err(GraphContractError::InvalidLimits {
136                reason: "graph read decoded-byte budget is zero or above the batch ceiling",
137            });
138        }
139        if hydrated_entities == 0 || hydrated_entities > Self::MAX_HYDRATED_ENTITIES {
140            return Err(GraphContractError::InvalidLimits {
141                reason: "graph read entity budget is zero or above the batch ceiling",
142            });
143        }
144        if hydrated_paths == 0 || hydrated_paths > Self::MAX_HYDRATED_PATHS {
145            return Err(GraphContractError::InvalidLimits {
146                reason: "graph read path budget is zero or above the batch ceiling",
147            });
148        }
149        Ok(Self {
150            requested_rows: NonZeroU32::new(requested_rows).ok_or(
151                GraphContractError::InvalidLimits {
152                    reason: "graph read requested-row budget must be nonzero",
153                },
154            )?,
155            returned_rows: NonZeroU32::new(returned_rows).ok_or(
156                GraphContractError::InvalidLimits {
157                    reason: "graph read returned-row budget must be nonzero",
158                },
159            )?,
160            decoded_bytes: NonZeroU64::new(decoded_bytes).ok_or(
161                GraphContractError::InvalidLimits {
162                    reason: "graph read decoded-byte budget must be nonzero",
163                },
164            )?,
165            hydrated_entities: NonZeroU32::new(hydrated_entities).ok_or(
166                GraphContractError::InvalidLimits {
167                    reason: "graph read entity budget must be nonzero",
168                },
169            )?,
170            hydrated_paths: NonZeroU32::new(hydrated_paths).ok_or(
171                GraphContractError::InvalidLimits {
172                    reason: "graph read path budget must be nonzero",
173                },
174            )?,
175        })
176    }
177
178    /// Maximum input keys, frontier selectors, or purpose-owner paths.
179    #[must_use]
180    pub const fn requested_rows(self) -> u32 {
181        self.requested_rows.get()
182    }
183
184    /// Maximum fully reconstructed rows.
185    #[must_use]
186    pub const fn returned_rows(self) -> u32 {
187        self.returned_rows.get()
188    }
189
190    /// Maximum decoded raw payload bytes.
191    #[must_use]
192    pub const fn decoded_bytes(self) -> u64 {
193        self.decoded_bytes.get()
194    }
195
196    /// Maximum unique hydrated entities.
197    #[must_use]
198    pub const fn hydrated_entities(self) -> u32 {
199        self.hydrated_entities.get()
200    }
201
202    /// Maximum unique purpose-owner paths.
203    #[must_use]
204    pub const fn hydrated_paths(self) -> u32 {
205        self.hydrated_paths.get()
206    }
207}
208
209/// Exact work observed while hydrating one stable-key graph batch.
210#[derive(Clone, Copy, Debug, Eq, PartialEq)]
211pub struct RepositoryGraphReadWork {
212    /// Input keys, frontier selectors, or purpose-owner paths supplied.
213    pub requested_rows: u32,
214    /// Fully reconstructed rows returned to the caller.
215    pub returned_rows: u32,
216    /// Raw `SQLite` BLOB, TEXT, and fixed scalar bytes decoded.
217    pub decoded_bytes: u64,
218    /// Unique entities reconstructed from normalized rows.
219    pub hydrated_entities: u32,
220    /// Unique purpose-owning repository paths retained from those entities.
221    pub hydrated_paths: u32,
222}
223
224/// Fully reconstructed graph rows plus their exact bounded read work.
225#[derive(Clone, Debug, Eq, PartialEq)]
226pub struct RepositoryGraphReadBatch<T> {
227    /// Fully validated rows in caller key order.
228    pub rows: Vec<T>,
229    /// Exact resource use for the complete successful batch.
230    pub work: RepositoryGraphReadWork,
231}
232
233/// One stable paged graph result plus exact database work for the page attempt.
234#[derive(Clone, Debug, Eq, PartialEq)]
235pub struct RepositoryGraphReadPage<T> {
236    /// Stable bounded page, including its truncation sentinel result.
237    pub page: RepositoryGraphPage<T>,
238    /// Exact work for all decoded rows, including a removed sentinel.
239    pub work: RepositoryGraphReadWork,
240}
241
242/// Ordered per-owner graph pages plus exact aggregate database work.
243#[derive(Clone, Debug, Eq, PartialEq)]
244pub struct RepositoryGraphReadPages<T> {
245    /// Pages in caller owner order.
246    pub pages: Vec<RepositoryGraphPage<T>>,
247    /// Exact aggregate work across every page and truncation sentinel.
248    pub work: RepositoryGraphReadWork,
249}
250
251/// Bounded filters for opt-in project-wide coverage discovery.
252#[derive(Clone, Debug, Eq, PartialEq)]
253pub struct RepositoryCoverageQuery {
254    /// Zero-based result offset after filters are applied.
255    pub start_index: u32,
256    /// Maximum rows returned before the overflow sentinel.
257    pub limit: u32,
258    /// Optional normalized repository path prefix.
259    pub path_prefix: Option<String>,
260    /// Optional source parser pass.
261    pub parser: Option<ParserKind>,
262    /// Optional derived-fact provider pass.
263    pub provider: Option<ParserKind>,
264    /// Optional relation family.
265    pub relation: Option<GraphRelationKind>,
266    /// Optional coverage lifecycle state.
267    pub state: Option<CoverageState>,
268    /// Optional exact persisted reason.
269    pub reason: Option<String>,
270}
271
272/// One discovered coverage row with parse and fact-provider provenance.
273#[derive(Clone, Debug, Eq, PartialEq)]
274pub struct RepositoryCoverageRow {
275    /// Validated normalized graph coverage record.
276    pub coverage: CoverageRecord,
277    /// Source parser pass for path-scoped coverage.
278    pub parser: Option<ParserKind>,
279    /// Fact provider pass for path-scoped coverage.
280    pub provider: Option<ParserKind>,
281}
282
283/// One folder or file whose current graph context should enrich navigation.
284#[derive(Clone, Debug, Eq, PartialEq)]
285pub struct RepositoryNavigationNode {
286    /// Exact repository-relative path.
287    pub path: String,
288    /// Folder or file ownership semantics used by the set query.
289    pub kind: NodeKind,
290}
291
292/// Bounded current graph evidence for one folder or file navigation row.
293#[derive(Clone, Debug, Eq, PartialEq)]
294pub struct RepositoryNavigationConnections {
295    /// Exact repository-relative owner path.
296    pub path: String,
297    /// Sparse stable-order family counts.
298    pub counts: Vec<RankedConnectionCount>,
299    /// Bounded stable-order connection sample.
300    pub connections: Vec<RankedConnection>,
301    /// Whether the bounded sample omitted any validated relation through family or global overflow.
302    pub truncated: bool,
303}
304
305/// Maximum owners admitted to one generated set-oriented navigation statement.
306const NAVIGATION_CONNECTION_OWNER_CHUNK: usize = 8;
307
308/// Stable family order and normalized persisted selectors for navigation context.
309const NAVIGATION_CONNECTION_FAMILIES: &[(RankedConnectionKind, &str, &str)] = &[
310    (RankedConnectionKind::Package, "legacy", "depends-on"),
311    (RankedConnectionKind::Import, "legacy", "imports"),
312    (RankedConnectionKind::Call, "legacy", "calls"),
313    (RankedConnectionKind::Reference, "extended", "references"),
314    (RankedConnectionKind::Test, "extended", "tests"),
315    (RankedConnectionKind::Route, "extended", "routes-to"),
316    (RankedConnectionKind::Config, "extended", "configures"),
317];
318
319/// Conservative persisted footprint owned by exact affected source paths.
320#[derive(Clone, Copy, Debug, Eq, PartialEq)]
321pub struct RepositoryAffectedSourceFootprint {
322    /// Existing persisted rows, including a conservative resolution-witness allowance.
323    pub rows: u64,
324    /// UTF-8, BLOB, and fixed-width scalar bytes represented by those rows.
325    pub retained_bytes: u64,
326    /// Whether `rows` reached the caller's `LIMIT + 1` overflow sentinel.
327    pub truncated: bool,
328}
329
330/// One persisted export candidate paired with the canonical key that selected it.
331#[derive(Clone, Debug, Eq, PartialEq)]
332pub struct RepositoryResolutionCandidate {
333    /// Exact canonical key exported by the entity.
334    key: CanonicalResolutionKey,
335    /// Typed entity that currently exports the key.
336    entity: GraphEntity,
337}
338
339impl RepositoryResolutionCandidate {
340    /// Borrow the canonical key that selected this candidate.
341    #[must_use]
342    pub const fn key(&self) -> &CanonicalResolutionKey {
343        &self.key
344    }
345
346    /// Borrow the typed export candidate.
347    #[must_use]
348    pub const fn entity(&self) -> &GraphEntity {
349        &self.entity
350    }
351}
352
353/// Closed relation lookup shapes owned by normalized graph storage.
354#[derive(Clone, Debug, Eq, PartialEq)]
355pub enum RepositoryGraphRelationQuery {
356    /// Relations whose source is one exact stable entity.
357    Outbound {
358        /// Exact project-qualified source key.
359        source: GraphEntityKey,
360    },
361    /// Relations whose resolved or external target is one exact stable entity.
362    Inbound {
363        /// Exact project-qualified target key.
364        target: GraphEntityKey,
365    },
366    /// Relations in one typed legacy or extended family.
367    Family {
368        /// Exact relation family.
369        relation: GraphRelationKind,
370    },
371}
372
373/// Direction of one batched normalized-graph adjacency read.
374#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
375pub enum RepositoryGraphDirection {
376    /// Relations whose source is in the selected frontier.
377    Outbound,
378    /// Relations whose retained target is in the selected frontier.
379    Inbound,
380}
381
382/// One normalized relation with the endpoint entities already hydrated.
383#[derive(Clone, Debug, Eq, PartialEq)]
384pub struct RepositoryGraphRelationRow {
385    /// Fully reconstructed normalized relation.
386    pub relation: LogicalRelation,
387    /// Exact source entity named by the relation.
388    pub source: GraphEntity,
389    /// Retained resolved or external target, when the relation has one.
390    pub target: Option<GraphEntity>,
391    /// Closed reason retained only for an unresolved `documents` relation.
392    pub document_unresolved_reason: Option<DocumentTargetUnresolvedReason>,
393}
394
395/// One normalized relation with its file-bearing source classification.
396#[derive(Clone, Debug, Eq, PartialEq)]
397pub struct RepositoryGraphClassifiedRelationRow {
398    /// Fully hydrated normalized relation row.
399    pub detail: RepositoryGraphRelationRow,
400    /// Classification of a file, symbol owner, or package manifest source.
401    pub source_classification: Option<ContentClassification>,
402}
403
404/// Opaque keyset used only to continue one bounded adjacency request.
405#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
406pub struct RepositoryGraphAdjacencyContinuation {
407    /// Project whose read snapshot produced this keyset.
408    project: ProjectInstanceId,
409    /// Complete graph generation whose read snapshot produced this keyset.
410    generation: IndexGeneration,
411    /// Direction whose stable order produced this keyset.
412    direction: RepositoryGraphDirection,
413    /// Optional exact relation family whose stable order produced this keyset.
414    relation: Option<GraphRelationKind>,
415    /// Whether the request admitted only exact local resolutions.
416    #[serde(default, skip_serializing_if = "is_default")]
417    resolved_only: bool,
418    /// Whether unfiltered adjacency admitted the classified document family.
419    #[serde(default, skip_serializing_if = "is_default")]
420    include_documents: bool,
421    /// Ordered frontier identity whose result order produced this keyset.
422    frontier: Vec<[u8; 32]>,
423    /// Zero-based frontier position of the last returned relation.
424    frontier_index: u32,
425    /// Persisted relation family scope of the last returned relation.
426    relation_scope: String,
427    /// Persisted relation family value of the last returned relation.
428    relation_kind: String,
429    /// Stable compact key of the last returned relation.
430    relation_key: [u8; 32],
431}
432
433/// One logical relation paired with the frontier entity that selected it.
434#[derive(Clone, Debug, Eq, PartialEq)]
435pub struct RepositoryGraphAdjacencyRow {
436    /// Zero-based position of the selecting entity in the request frontier.
437    pub frontier_index: u32,
438    /// Exact project-qualified entity that selected this relation.
439    pub frontier: GraphEntityKey,
440    /// Direction relative to the selecting frontier entity.
441    pub direction: RepositoryGraphDirection,
442    /// Normalized relation and its already-hydrated endpoints.
443    pub detail: RepositoryGraphRelationRow,
444}
445
446/// One bounded direction-specific adjacency page.
447#[derive(Clone, Debug, Eq, PartialEq)]
448pub struct RepositoryGraphAdjacencyPage {
449    /// Fully validated rows in deterministic frontier and relation order.
450    pub rows: Vec<RepositoryGraphAdjacencyRow>,
451    /// Whether at least one additional validated relation exists.
452    pub truncated: bool,
453    /// Opaque continuation for the same frontier and direction when truncated.
454    pub continuation: Option<RepositoryGraphAdjacencyContinuation>,
455}
456
457/// One bounded adjacency page plus exact database work for the page attempt.
458#[derive(Clone, Debug, Eq, PartialEq)]
459pub struct RepositoryGraphAdjacencyReadPage {
460    /// Stable direction-specific page and continuation state.
461    pub page: RepositoryGraphAdjacencyPage,
462    /// Exact raw-row and endpoint hydration work for the complete page.
463    pub work: RepositoryGraphReadWork,
464}
465
466/// Maximum unique entities admitted to one batched adjacency statement.
467pub const MAX_REPOSITORY_GRAPH_FRONTIER: usize = 256;
468
469/// Maximum relation rows admitted across all per-frontier query branches.
470const MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS: usize = GraphLimits::MAX_ROWS as usize + 1;
471
472/// Maximum stable entity keys hydrated through one prepared `VALUES` join.
473const GRAPH_ENTITY_HYDRATION_CHUNK: usize = 128;
474
475/// Raw normalized entity row collected before domain reconstruction.
476struct EntityRow {
477    /// Compact stable entity key.
478    key: Vec<u8>,
479    /// Owning project identity.
480    project: Vec<u8>,
481    /// Canonical collision witness.
482    canonical: String,
483    /// Normalized selector variant.
484    kind: String,
485    /// Folder, file, or symbol repository path.
486    repository_path: Option<String>,
487    /// Package ecosystem.
488    package_manager: Option<String>,
489    /// Manifest package name.
490    package_name: Option<String>,
491    /// Owning package manifest.
492    manifest_path: Option<String>,
493    /// Declaration name.
494    symbol_name: Option<String>,
495    /// Declaration kind.
496    symbol_kind: Option<String>,
497    /// Optional containing declaration.
498    symbol_parent: Option<String>,
499    /// Stable declaration signature.
500    symbol_signature: Option<String>,
501    /// External namespace.
502    external_system: Option<String>,
503    /// Identity inside the external namespace.
504    external_identity: Option<String>,
505}
506
507/// Raw normalized relation row collected before domain reconstruction.
508struct RelationRow {
509    /// Compact stable relation key.
510    key: Vec<u8>,
511    /// Owning project identity.
512    project: Vec<u8>,
513    /// Canonical collision witness.
514    canonical: String,
515    /// Stable source entity key.
516    source: Vec<u8>,
517    /// Legacy or extended family scope.
518    relation_scope: String,
519    /// Family spelling within the scope.
520    relation_kind: String,
521    /// Resolution lifecycle state.
522    resolution_status: String,
523    /// Optional resolved or external target key.
524    target: Option<Vec<u8>>,
525    /// Optional unresolved reference text.
526    reference: Option<String>,
527    /// Optional ambiguous candidate count.
528    candidate_count: Option<i64>,
529    /// Optional closed reason for an unresolved document target.
530    document_unresolved_reason: Option<String>,
531    /// Coarse trust class.
532    confidence: String,
533    /// Producer completeness.
534    completeness: String,
535}
536
537/// Raw relation-family row with its source classification projected in SQL.
538struct ClassifiedRelationRow {
539    /// Normalized relation columns.
540    relation: RelationRow,
541    /// Persisted source classification for file-bearing entities.
542    source_classification: Option<String>,
543}
544
545/// One raw relation paired with its selecting frontier position.
546struct AdjacencyRelationRow {
547    /// Zero-based position inside the request frontier.
548    frontier_index: u32,
549    /// Raw normalized relation selected by the indexed adjacency branch.
550    relation: RelationRow,
551}
552
553/// Raw normalized relation occurrence row.
554struct OccurrenceRow {
555    /// Stable logical relation key.
556    relation: Vec<u8>,
557    /// Exact repository-local source file.
558    file_path: String,
559    /// First one-based source line.
560    start_line: i64,
561    /// First zero-based source column.
562    start_column: i64,
563    /// Last one-based source line.
564    end_line: i64,
565    /// Exclusive zero-based end column.
566    end_column: i64,
567}
568
569/// Raw normalized graph coverage row.
570struct CoverageRow {
571    /// Owning project identity.
572    project: Vec<u8>,
573    /// Project or path scope discriminator.
574    scope_kind: String,
575    /// Optional repository path scope.
576    scope_path: Option<String>,
577    /// Optional legacy or extended relation scope.
578    relation_scope: Option<String>,
579    /// Optional relation family spelling.
580    relation_kind: Option<String>,
581    /// Coverage lifecycle state.
582    state: String,
583    /// Persisted total items in scope.
584    total: i64,
585    /// Successfully covered items.
586    covered: i64,
587    /// Omitted or untrusted items.
588    omitted: i64,
589    /// Optional actionable explanation.
590    reason: Option<String>,
591    /// Optional reached product limit.
592    reached_limit: Option<String>,
593    /// Optional source parser pass joined from file metadata.
594    parser: Option<String>,
595    /// Optional derived-fact provider pass joined from file metadata.
596    provider: Option<String>,
597}
598
599/// Mutable accounting retained only for one bounded hydration call.
600pub(crate) struct RepositoryGraphReadMeter {
601    /// Validated caller envelope.
602    budget: RepositoryGraphReadBudget,
603    /// Input selectors admitted before any query runs.
604    requested_rows: u32,
605    /// Raw payload bytes decoded so far.
606    decoded_bytes: u64,
607    /// Unique entities reconstructed so far.
608    hydrated_entities: u32,
609    /// Unique purpose-owning paths retained from hydrated entities.
610    hydrated_paths: HashSet<String>,
611}
612
613impl RepositoryGraphReadMeter {
614    /// Admit one request before any `SQLite` work begins.
615    pub(crate) fn new(budget: RepositoryGraphReadBudget, requested_rows: usize) -> DbResult<Self> {
616        let requested_rows =
617            u32::try_from(requested_rows).map_err(|_source| GraphContractError::InvalidLimits {
618                reason: "graph read requested-row count overflowed",
619            })?;
620        if requested_rows > budget.requested_rows() {
621            return Err(GraphContractError::InvalidLimits {
622                reason: "graph read requested rows exceed the batch budget",
623            }
624            .into());
625        }
626        Ok(Self {
627            budget,
628            requested_rows,
629            decoded_bytes: 0,
630            hydrated_entities: 0,
631            hydrated_paths: HashSet::new(),
632        })
633    }
634
635    /// Charge one decoded raw row before it leaves the row iterator.
636    pub(crate) fn record_decoded_bytes(&mut self, bytes: u64) -> DbResult<()> {
637        let decoded_bytes =
638            self.decoded_bytes
639                .checked_add(bytes)
640                .ok_or(GraphContractError::InvalidLimits {
641                    reason: "graph read decoded-byte accounting overflowed",
642                })?;
643        if decoded_bytes > self.budget.decoded_bytes() {
644            return Err(GraphContractError::InvalidLimits {
645                reason: "graph read decoded bytes exceed the batch budget",
646            }
647            .into());
648        }
649        self.decoded_bytes = decoded_bytes;
650        Ok(())
651    }
652
653    /// Charge one unique reconstructed entity and its exact purpose owner path.
654    fn record_entity(&mut self, entity: &GraphEntity) -> DbResult<()> {
655        let hydrated_entities =
656            self.hydrated_entities
657                .checked_add(1)
658                .ok_or(GraphContractError::InvalidLimits {
659                    reason: "graph read entity accounting overflowed",
660                })?;
661        if hydrated_entities > self.budget.hydrated_entities() {
662            return Err(GraphContractError::InvalidLimits {
663                reason: "graph read hydrated entities exceed the batch budget",
664            }
665            .into());
666        }
667        if let Some(path) = graph_entity_purpose_owner(entity) {
668            self.record_hydrated_path(path)?;
669        }
670        self.hydrated_entities = hydrated_entities;
671        Ok(())
672    }
673
674    /// Charge one unique repository path hydrated from authoritative node state.
675    pub(crate) fn record_hydrated_path(&mut self, path: &str) -> DbResult<()> {
676        if self.hydrated_paths.contains(path) {
677            return Ok(());
678        }
679        let hydrated_paths = u32::try_from(self.hydrated_paths.len()).map_err(|_source| {
680            GraphContractError::InvalidLimits {
681                reason: "graph read path accounting overflowed",
682            }
683        })?;
684        if hydrated_paths >= self.budget.hydrated_paths() {
685            return Err(GraphContractError::InvalidLimits {
686                reason: "graph read hydrated paths exceed the batch budget",
687            }
688            .into());
689        }
690        self.hydrated_paths.insert(path.to_string());
691        Ok(())
692    }
693
694    /// Finish exact work only after every requested row was reconstructed.
695    pub(crate) fn finish(self, returned_rows: usize) -> DbResult<RepositoryGraphReadWork> {
696        let returned_rows =
697            u32::try_from(returned_rows).map_err(|_source| GraphContractError::InvalidLimits {
698                reason: "graph read returned-row count overflowed",
699            })?;
700        if returned_rows > self.budget.returned_rows() {
701            return Err(GraphContractError::InvalidLimits {
702                reason: "graph read returned rows exceed the batch budget",
703            }
704            .into());
705        }
706        let hydrated_paths = u32::try_from(self.hydrated_paths.len()).map_err(|_source| {
707            GraphContractError::InvalidLimits {
708                reason: "graph read path accounting overflowed",
709            }
710        })?;
711        Ok(RepositoryGraphReadWork {
712            requested_rows: self.requested_rows,
713            returned_rows,
714            decoded_bytes: self.decoded_bytes,
715            hydrated_entities: self.hydrated_entities,
716            hydrated_paths,
717        })
718    }
719}
720
721/// One bounded flattened relation row used only for navigation enrichment.
722struct NavigationConnectionRow {
723    /// Zero-based owner position inside the current statement chunk.
724    owner_index: usize,
725    /// Closed connection family selected by the query branch.
726    kind: RankedConnectionKind,
727    /// Direction relative to the owner.
728    direction: RankedConnectionDirection,
729    /// Stable relation key used for deterministic ordering and deduplication.
730    relation_key: Vec<u8>,
731    /// Persisted resolution lifecycle state.
732    resolution_status: String,
733    /// Persisted unresolved or ambiguous identity.
734    reference: Option<String>,
735    /// Related normalized entity kind, absent only for unresolved output.
736    entity_kind: Option<String>,
737    /// Related repository path.
738    repository_path: Option<String>,
739    /// Related package ecosystem.
740    package_manager: Option<String>,
741    /// Related package name.
742    package_name: Option<String>,
743    /// Related package manifest.
744    manifest_path: Option<String>,
745    /// Related declaration name.
746    symbol_name: Option<String>,
747    /// Related external namespace.
748    external_system: Option<String>,
749    /// Related external identity.
750    external_identity: Option<String>,
751}
752
753/// Return one empty navigation page while retaining the requested owner path.
754fn empty_navigation_connections(path: &str) -> RepositoryNavigationConnections {
755    RepositoryNavigationConnections {
756        path: path.to_string(),
757        counts: Vec::new(),
758        connections: Vec::new(),
759        truncated: false,
760    }
761}
762
763/// Load one bounded chunk with a single compound set-oriented statement.
764fn collect_navigation_connection_rows(
765    connection: &Connection,
766    owners: &[RepositoryNavigationNode],
767    family_limit_plus_one: i64,
768) -> DbResult<Vec<NavigationConnectionRow>> {
769    let mut branches = Vec::with_capacity(owners.len() * NAVIGATION_CONNECTION_FAMILIES.len() * 2);
770    let mut values = Vec::new();
771    for (owner_index, owner) in owners.iter().enumerate() {
772        for &(kind, scope, relation) in NAVIGATION_CONNECTION_FAMILIES {
773            branches.push(navigation_connection_branch(
774                owner_index,
775                owner,
776                kind,
777                scope,
778                relation,
779                RankedConnectionDirection::Outbound,
780                family_limit_plus_one,
781                &mut values,
782            ));
783            if owner.kind != NodeKind::Folder || owner.path != "." {
784                branches.push(navigation_connection_branch(
785                    owner_index,
786                    owner,
787                    kind,
788                    scope,
789                    relation,
790                    RankedConnectionDirection::Inbound,
791                    family_limit_plus_one,
792                    &mut values,
793                ));
794            }
795        }
796    }
797    let sql = branches.join(" UNION ALL ");
798    let mut statement = connection.prepare(&sql)?;
799    let mut rows = statement.query(params_from_iter(values))?;
800    let mut collected = Vec::new();
801    while let Some(row) = rows.next()? {
802        collected.push(navigation_connection_row(row)?);
803    }
804    Ok(collected)
805}
806
807/// Build one indexed outbound or inbound query branch for one owner and family.
808fn navigation_connection_branch(
809    owner_index: usize,
810    owner: &RepositoryNavigationNode,
811    kind: RankedConnectionKind,
812    scope: &'static str,
813    relation: &'static str,
814    direction: RankedConnectionDirection,
815    family_limit_plus_one: i64,
816    values: &mut Vec<Value>,
817) -> String {
818    values.push(Value::Integer(owner_index as i64));
819    values.push(Value::Text(
820        navigation_connection_kind_name(kind).to_string(),
821    ));
822    if owner.kind == NodeKind::Folder
823        && owner.path == "."
824        && direction == RankedConnectionDirection::Outbound
825    {
826        values.push(Value::Text(scope.to_string()));
827        values.push(Value::Text(relation.to_string()));
828        values.push(Value::Integer(family_limit_plus_one));
829        return "SELECT * FROM (
830                    SELECT ? AS owner_index, ? AS expected_kind, 'outbound' AS direction,
831                           r.relation_key, r.relation_scope, r.relation_kind,
832                           r.resolution_status, r.reference_text,
833                           related.entity_kind, related.repository_path,
834                           related.package_manager, related.package_name, related.manifest_path,
835                           related.symbol_name, related.external_system, related.external_identity
836                      FROM graph_relations r INDEXED BY idx_graph_relations_kind_order
837                      LEFT JOIN graph_entities related
838                        ON related.entity_key = r.target_entity_key
839                     WHERE r.relation_scope = ? AND r.relation_kind = ?
840                     ORDER BY r.relation_key
841                     LIMIT ?
842                )"
843        .to_string();
844    }
845    let (relation_key, related_key, index, direction_name) = match direction {
846        RankedConnectionDirection::Outbound => (
847            "source_entity_key",
848            "target_entity_key",
849            "idx_graph_relations_source_kind",
850            "outbound",
851        ),
852        RankedConnectionDirection::Inbound => (
853            "target_entity_key",
854            "source_entity_key",
855            "idx_graph_relations_target_kind",
856            "inbound",
857        ),
858    };
859    let owned = navigation_owned_entity_sql(owner, values);
860    let exclude_internal = if direction == RankedConnectionDirection::Inbound {
861        let owned_sources = navigation_owned_entity_sql(owner, values);
862        format!(" AND r.source_entity_key NOT IN ({owned_sources})")
863    } else {
864        String::new()
865    };
866    values.push(Value::Text(scope.to_string()));
867    values.push(Value::Text(relation.to_string()));
868    values.push(Value::Integer(family_limit_plus_one));
869    format!(
870        "SELECT * FROM (
871             SELECT ? AS owner_index, ? AS expected_kind, '{direction_name}' AS direction,
872                    r.relation_key, r.relation_scope, r.relation_kind,
873                    r.resolution_status, r.reference_text,
874                    related.entity_kind, related.repository_path,
875                    related.package_manager, related.package_name, related.manifest_path,
876                    related.symbol_name, related.external_system, related.external_identity
877               FROM graph_relations r INDEXED BY {index}
878               LEFT JOIN graph_entities related ON related.entity_key = r.{related_key}
879              WHERE r.{relation_key} IN ({owned})
880                {exclude_internal}
881                AND r.relation_scope = ? AND r.relation_kind = ?
882              ORDER BY r.relation_key
883              LIMIT ?
884         )"
885    )
886}
887
888/// Build the indexed entity-key ownership set for one navigation owner.
889fn navigation_owned_entity_sql(
890    owner: &RepositoryNavigationNode,
891    values: &mut Vec<Value>,
892) -> String {
893    match owner.kind {
894        NodeKind::File => {
895            values.push(Value::Text(owner.path.clone()));
896            values.push(Value::Text(owner.path.clone()));
897            "SELECT entity_key
898               FROM graph_entities INDEXED BY idx_graph_entities_path
899              WHERE repository_path = ?
900             UNION
901             SELECT entity_key
902               FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
903              WHERE manifest_path = ?"
904                .to_string()
905        }
906        NodeKind::Folder if owner.path == "." => "SELECT entity_key
907               FROM graph_entities INDEXED BY idx_graph_entities_path
908              WHERE repository_path IS NOT NULL
909             UNION
910             SELECT entity_key
911               FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
912              WHERE manifest_path IS NOT NULL"
913            .to_string(),
914        NodeKind::Folder => {
915            let lower = format!("{}/", owner.path);
916            let upper = format!("{}0", owner.path);
917            values.push(Value::Text(owner.path.clone()));
918            values.push(Value::Text(lower.clone()));
919            values.push(Value::Text(upper.clone()));
920            values.push(Value::Text(owner.path.clone()));
921            values.push(Value::Text(lower));
922            values.push(Value::Text(upper));
923            "SELECT entity_key
924               FROM graph_entities INDEXED BY idx_graph_entities_path
925              WHERE repository_path = ?
926             UNION
927             SELECT entity_key
928               FROM graph_entities INDEXED BY idx_graph_entities_path
929              WHERE repository_path >= ? AND repository_path < ?
930             UNION
931             SELECT entity_key
932               FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
933              WHERE manifest_path = ?
934             UNION
935             SELECT entity_key
936               FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
937              WHERE manifest_path >= ? AND manifest_path < ?"
938                .to_string()
939        }
940    }
941}
942
943/// Decode one flattened navigation row without accepting partial corruption.
944fn navigation_connection_row(row: &Row<'_>) -> DbResult<NavigationConnectionRow> {
945    let owner_index = count_to_usize("navigation_connection.owner_index", row.get(0)?)?;
946    let expected_kind: String = row.get(1)?;
947    let direction = match row.get::<_, String>(2)?.as_str() {
948        "outbound" => RankedConnectionDirection::Outbound,
949        "inbound" => RankedConnectionDirection::Inbound,
950        _ => {
951            return Err(DbError::GraphRowShape {
952                table: "graph_relations",
953                reason: "navigation relation direction is invalid",
954            });
955        }
956    };
957    let relation_key: Vec<u8> = row.get(3)?;
958    if relation_key.len() != 32 {
959        return Err(DbError::InvalidBlobLength {
960            field: "graph_relations.relation_key",
961            expected: 32,
962            found: relation_key.len(),
963        });
964    }
965    let relation_scope: String = row.get(4)?;
966    let relation_kind: String = row.get(5)?;
967    let kind = navigation_connection_kind(&relation_scope, &relation_kind)?;
968    if navigation_connection_kind_name(kind) != expected_kind {
969        return Err(DbError::GraphRowShape {
970            table: "graph_relations",
971            reason: "navigation relation family does not match its query branch",
972        });
973    }
974    let resolution_status: String = row.get(6)?;
975    if !matches!(
976        resolution_status.as_str(),
977        "resolved" | "ambiguous" | "unresolved" | "external"
978    ) {
979        return Err(DbError::InvalidEnum {
980            field: "graph_relations.resolution_status",
981            value: resolution_status,
982        });
983    }
984    Ok(NavigationConnectionRow {
985        owner_index,
986        kind,
987        direction,
988        relation_key,
989        resolution_status,
990        reference: row.get(7)?,
991        entity_kind: row.get(8)?,
992        repository_path: row.get(9)?,
993        package_manager: row.get(10)?,
994        package_name: row.get(11)?,
995        manifest_path: row.get(12)?,
996        symbol_name: row.get(13)?,
997        external_system: row.get(14)?,
998        external_identity: row.get(15)?,
999    })
1000}
1001
1002/// Compose deterministic per-owner pages from fully decoded rows.
1003fn navigation_connection_pages(
1004    owners: &[RepositoryNavigationNode],
1005    mut rows: Vec<NavigationConnectionRow>,
1006    family_limit: usize,
1007    sample_limit: usize,
1008) -> DbResult<Vec<RepositoryNavigationConnections>> {
1009    rows.sort_by(|left, right| {
1010        left.owner_index
1011            .cmp(&right.owner_index)
1012            .then_with(|| left.kind.cmp(&right.kind))
1013            .then_with(|| {
1014                navigation_direction_order(left.direction)
1015                    .cmp(&navigation_direction_order(right.direction))
1016            })
1017            .then_with(|| left.relation_key.cmp(&right.relation_key))
1018    });
1019    let mut grouped =
1020        BTreeMap::<(usize, RankedConnectionKind), Vec<NavigationConnectionRow>>::new();
1021    for row in rows {
1022        if row.owner_index >= owners.len() {
1023            return Err(DbError::GraphRowShape {
1024                table: "graph_relations",
1025                reason: "navigation relation owner index is outside its request chunk",
1026            });
1027        }
1028        grouped
1029            .entry((row.owner_index, row.kind))
1030            .or_default()
1031            .push(row);
1032    }
1033
1034    owners
1035        .iter()
1036        .enumerate()
1037        .map(|(owner_index, owner)| {
1038            let mut page = empty_navigation_connections(&owner.path);
1039            for &(kind, _, _) in NAVIGATION_CONNECTION_FAMILIES {
1040                let Some(rows) = grouped.remove(&(owner_index, kind)) else {
1041                    continue;
1042                };
1043                let mut seen = HashSet::new();
1044                let unique = rows
1045                    .into_iter()
1046                    .filter(|row| seen.insert(row.relation_key.clone()))
1047                    .collect::<Vec<_>>();
1048                let truncated = unique.len() > family_limit;
1049                let count = unique.len().min(family_limit);
1050                page.counts.push(RankedConnectionCount {
1051                    kind,
1052                    count,
1053                    truncated,
1054                });
1055                for (family_index, row) in unique.into_iter().enumerate() {
1056                    let target = navigation_connection_target(&row)?;
1057                    if family_index < family_limit && page.connections.len() < sample_limit {
1058                        page.connections.push(RankedConnection {
1059                            kind,
1060                            direction: row.direction,
1061                            target,
1062                        });
1063                    } else if family_index < family_limit {
1064                        page.truncated = true;
1065                    }
1066                }
1067                page.truncated |= truncated;
1068            }
1069            Ok(page)
1070        })
1071        .collect()
1072}
1073
1074/// Convert a persisted endpoint or unresolved reference into its compact target.
1075fn navigation_connection_target(row: &NavigationConnectionRow) -> DbResult<RankedConnectionTarget> {
1076    if row.direction == RankedConnectionDirection::Outbound
1077        && matches!(row.resolution_status.as_str(), "ambiguous" | "unresolved")
1078    {
1079        let reference = row.reference.clone().ok_or(DbError::GraphRowShape {
1080            table: "graph_relations",
1081            reason: "unresolved navigation relation is missing its reference",
1082        })?;
1083        if row.entity_kind.is_some() {
1084            return Err(DbError::GraphRowShape {
1085                table: "graph_relations",
1086                reason: "unresolved navigation relation retained a target entity",
1087            });
1088        }
1089        return Ok(RankedConnectionTarget::Unresolved { reference });
1090    }
1091    if !matches!(row.resolution_status.as_str(), "resolved" | "external") {
1092        return Err(DbError::GraphRowShape {
1093            table: "graph_relations",
1094            reason: "inbound navigation relation has no resolved target",
1095        });
1096    }
1097    match row.entity_kind.as_deref() {
1098        Some("project") => Ok(RankedConnectionTarget::Local {
1099            path: ".".to_string(),
1100            symbol: None,
1101        }),
1102        Some("folder" | "file") => Ok(RankedConnectionTarget::Local {
1103            path: required_navigation_target(
1104                row.repository_path.as_deref(),
1105                "local navigation target is missing its repository path",
1106            )?,
1107            symbol: None,
1108        }),
1109        Some("symbol") => Ok(RankedConnectionTarget::Local {
1110            path: required_navigation_target(
1111                row.repository_path.as_deref(),
1112                "symbol navigation target is missing its repository path",
1113            )?,
1114            symbol: Some(required_navigation_target(
1115                row.symbol_name.as_deref(),
1116                "symbol navigation target is missing its declaration name",
1117            )?),
1118        }),
1119        Some("package") => Ok(RankedConnectionTarget::Package {
1120            manager: required_navigation_target(
1121                row.package_manager.as_deref(),
1122                "package navigation target is missing its manager",
1123            )?,
1124            name: required_navigation_target(
1125                row.package_name.as_deref(),
1126                "package navigation target is missing its name",
1127            )?,
1128            manifest: required_navigation_target(
1129                row.manifest_path.as_deref(),
1130                "package navigation target is missing its manifest",
1131            )?,
1132        }),
1133        Some("external") => Ok(RankedConnectionTarget::External {
1134            system: required_navigation_target(
1135                row.external_system.as_deref(),
1136                "external navigation target is missing its system",
1137            )?,
1138            identity: required_navigation_target(
1139                row.external_identity.as_deref(),
1140                "external navigation target is missing its identity",
1141            )?,
1142        }),
1143        Some(_) => Err(DbError::GraphRowShape {
1144            table: "graph_entities",
1145            reason: "navigation endpoint has an unsupported entity kind",
1146        }),
1147        None => Err(DbError::GraphRowShape {
1148            table: "graph_relations",
1149            reason: "resolved navigation relation target is missing",
1150        }),
1151    }
1152}
1153
1154/// Clone one required nonempty target field.
1155fn required_navigation_target(value: Option<&str>, reason: &'static str) -> DbResult<String> {
1156    value
1157        .filter(|value| !value.is_empty())
1158        .map(str::to_string)
1159        .ok_or(DbError::GraphRowShape {
1160            table: "graph_entities",
1161            reason,
1162        })
1163}
1164
1165/// Map persisted relation family fields to the navigation family inventory.
1166fn navigation_connection_kind(scope: &str, relation: &str) -> DbResult<RankedConnectionKind> {
1167    NAVIGATION_CONNECTION_FAMILIES
1168        .iter()
1169        .find_map(|&(kind, expected_scope, expected_relation)| {
1170            (scope == expected_scope && relation == expected_relation).then_some(kind)
1171        })
1172        .ok_or(DbError::GraphRowShape {
1173            table: "graph_relations",
1174            reason: "relation family is not available to navigation enrichment",
1175        })
1176}
1177
1178/// Return the stable compact name for one navigation family.
1179const fn navigation_connection_kind_name(kind: RankedConnectionKind) -> &'static str {
1180    match kind {
1181        RankedConnectionKind::Package => "package",
1182        RankedConnectionKind::Import => "import",
1183        RankedConnectionKind::Call => "call",
1184        RankedConnectionKind::Reference => "reference",
1185        RankedConnectionKind::Test => "test",
1186        RankedConnectionKind::Route => "route",
1187        RankedConnectionKind::Config => "config",
1188    }
1189}
1190
1191/// Return stable outbound-before-inbound sample order.
1192const fn navigation_direction_order(direction: RankedConnectionDirection) -> u8 {
1193    match direction {
1194        RankedConnectionDirection::Outbound => 0,
1195        RankedConnectionDirection::Inbound => 1,
1196    }
1197}
1198
1199impl AtlasStore {
1200    /// Load bounded current graph context for folder and file navigation rows.
1201    ///
1202    /// Owners are processed through a fixed-size set-oriented statement per
1203    /// chunk. Each family uses separate indexed outbound and inbound branches,
1204    /// exact file plus manifest ownership, or bounded folder-prefix ownership.
1205    /// No partial result is returned if any statement or row fails.
1206    ///
1207    /// # Errors
1208    ///
1209    /// Returns an error for invalid paths or limits, unavailable publication
1210    /// state, `SQLite` failures, or any corrupt relation or endpoint row.
1211    pub fn repository_navigation_connections(
1212        &self,
1213        owners: &[RepositoryNavigationNode],
1214        family_limit: u32,
1215        sample_limit: usize,
1216    ) -> DbResult<Vec<RepositoryNavigationConnections>> {
1217        let family_limit_plus_one = validated_limit_plus_one(
1218            family_limit,
1219            GraphLimits::MAX_ROWS,
1220            "navigation connection rows must be nonzero and within the product ceiling",
1221        )?;
1222        if sample_limit == 0 || sample_limit > GraphLimits::MAX_ROWS as usize {
1223            return Err(GraphContractError::InvalidLimits {
1224                reason: "navigation connection sample must be nonzero and within the product ceiling",
1225            }
1226            .into());
1227        }
1228        for owner in owners {
1229            match owner.kind {
1230                NodeKind::Folder => {
1231                    RepositoryNodePath::new(Path::new(&owner.path))?;
1232                }
1233                NodeKind::File => {
1234                    RepositoryFilePath::new(Path::new(&owner.path))?;
1235                }
1236            }
1237        }
1238        if owners.is_empty() {
1239            return Ok(Vec::new());
1240        }
1241        if self.repository_graph_generation()?.is_none() {
1242            return Ok(owners
1243                .iter()
1244                .map(|owner| empty_navigation_connections(&owner.path))
1245                .collect());
1246        }
1247        let project = load_project_identity(&self.connection)?
1248            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
1249        require_bound_project_identity(&self.connection, project)?;
1250
1251        let mut result = Vec::with_capacity(owners.len());
1252        for chunk in owners.chunks(NAVIGATION_CONNECTION_OWNER_CHUNK) {
1253            let rows =
1254                collect_navigation_connection_rows(&self.connection, chunk, family_limit_plus_one)?;
1255            result.extend(navigation_connection_pages(
1256                chunk,
1257                rows,
1258                family_limit as usize,
1259                sample_limit,
1260            )?);
1261        }
1262        Ok(result)
1263    }
1264
1265    /// Load one typed graph entity by its compact stable key.
1266    ///
1267    /// # Errors
1268    ///
1269    /// Returns an error when publication state, project identity, row shape,
1270    /// canonical identity, or persisted key material is invalid.
1271    pub fn repository_graph_entity(&self, key: &GraphEntityKey) -> DbResult<Option<GraphEntity>> {
1272        let Some(generation) = self.repository_graph_generation()? else {
1273            return Ok(None);
1274        };
1275        if !verify_project_identity(&self.connection, key.project())? {
1276            return Ok(None);
1277        }
1278        Ok(self
1279            .repository_graph_entity_bounded(
1280                key,
1281                generation,
1282                maximum_repository_graph_read_budget()?,
1283                None,
1284            )?
1285            .rows
1286            .into_iter()
1287            .next())
1288    }
1289
1290    /// Load one optional exact entity under a stable graph read envelope.
1291    ///
1292    /// # Errors
1293    ///
1294    /// Returns an error for a stale project or generation, cancellation,
1295    /// `SQLite` failure, corrupt entity state, or any decoded-byte, entity, or
1296    /// path hydration overrun. A missing exact key returns an empty successful
1297    /// batch with exact zero returned work.
1298    pub fn repository_graph_entity_bounded(
1299        &self,
1300        key: &GraphEntityKey,
1301        generation: IndexGeneration,
1302        budget: RepositoryGraphReadBudget,
1303        control: Option<&IndexWorkControl>,
1304    ) -> DbResult<RepositoryGraphReadBatch<GraphEntity>> {
1305        self.require_repository_graph_snapshot(key.project(), generation)?;
1306        let digest = key.digest_bytes()?;
1307        let mut meter = RepositoryGraphReadMeter::new(budget, 1)?;
1308        let mut entities = load_graph_entities_by_digest_metered(
1309            self,
1310            &[digest],
1311            key.project(),
1312            generation,
1313            control,
1314            Some(&mut meter),
1315        )?;
1316        let rows = entities.remove(&digest).into_iter().collect::<Vec<_>>();
1317        let work = meter.finish(rows.len())?;
1318        Ok(RepositoryGraphReadBatch { rows, work })
1319    }
1320
1321    /// Hydrate an ordered unique set of graph entities from compact stable keys.
1322    ///
1323    /// # Errors
1324    ///
1325    /// Returns an error for duplicate or oversized input, a stale project or
1326    /// generation, cancellation, a missing entity, `SQLite` failure, or any
1327    /// invalid persisted key or canonical identity. No partial set is returned.
1328    pub fn repository_graph_entities_by_digest(
1329        &self,
1330        project: ProjectInstanceId,
1331        generation: IndexGeneration,
1332        digests: &[[u8; 32]],
1333        budget: RepositoryGraphReadBudget,
1334        control: Option<&IndexWorkControl>,
1335    ) -> DbResult<RepositoryGraphReadBatch<GraphEntity>> {
1336        validate_graph_hydration_request(digests)?;
1337        self.require_repository_graph_snapshot(project, generation)?;
1338        let mut meter = RepositoryGraphReadMeter::new(budget, digests.len())?;
1339        let mut entities = load_graph_entities_by_digest_metered(
1340            self,
1341            digests,
1342            project,
1343            generation,
1344            control,
1345            Some(&mut meter),
1346        )?;
1347        let mut ordered = Vec::with_capacity(digests.len());
1348        for digest in digests {
1349            ordered.push(entities.remove(digest).ok_or(DbError::GraphRowShape {
1350                table: "graph_entities",
1351                reason: "requested graph entity is missing",
1352            })?);
1353        }
1354        let work = meter.finish(ordered.len())?;
1355        Ok(RepositoryGraphReadBatch {
1356            rows: ordered,
1357            work,
1358        })
1359    }
1360
1361    /// Load a bounded page of entities that use one exact repository path.
1362    ///
1363    /// # Errors
1364    ///
1365    /// Returns an error for invalid limits, unavailable publication state,
1366    /// project mismatch, `SQLite` failure, or any corrupt row in the page.
1367    pub fn repository_graph_entities_by_path(
1368        &self,
1369        project: ProjectInstanceId,
1370        path: &RepositoryNodePath,
1371        limit: u32,
1372    ) -> DbResult<RepositoryGraphPage<GraphEntity>> {
1373        validated_limit_plus_one(
1374            limit,
1375            GraphLimits::MAX_ROWS,
1376            "graph rows must be nonzero and within the product ceiling",
1377        )?;
1378        let Some(generation) = self.repository_graph_generation()? else {
1379            return Ok(empty_page());
1380        };
1381        if !verify_project_identity(&self.connection, project)? {
1382            return Ok(empty_page());
1383        }
1384        Ok(self
1385            .repository_graph_entities_by_path_bounded(
1386                project,
1387                generation,
1388                path,
1389                limit,
1390                maximum_repository_graph_read_budget()?,
1391                None,
1392            )?
1393            .page)
1394    }
1395
1396    /// Load one exact-path entity page under a stable graph read envelope.
1397    ///
1398    /// # Errors
1399    ///
1400    /// Returns the same fail-closed errors as
1401    /// [`Self::repository_graph_entities_by_path`], rejects a stale project or
1402    /// generation and any returned-row, decoded-byte, entity, or path hydration
1403    /// overrun, and meters the raw truncation sentinel.
1404    pub fn repository_graph_entities_by_path_bounded(
1405        &self,
1406        project: ProjectInstanceId,
1407        generation: IndexGeneration,
1408        path: &RepositoryNodePath,
1409        limit: u32,
1410        budget: RepositoryGraphReadBudget,
1411        control: Option<&IndexWorkControl>,
1412    ) -> DbResult<RepositoryGraphReadPage<GraphEntity>> {
1413        let limit_plus_one = validated_limit_plus_one(
1414            limit,
1415            GraphLimits::MAX_ROWS,
1416            "graph rows must be nonzero and within the product ceiling",
1417        )?;
1418        self.require_repository_graph_snapshot(project, generation)?;
1419        let mut meter = RepositoryGraphReadMeter::new(budget, 1)?;
1420        let raw = with_sqlite_read_progress(
1421            &self.connection,
1422            control,
1423            IndexWorkStage::RepositoryTraversal,
1424            || {
1425                let mut statement = self.connection.prepare_cached(
1426                    "SELECT entity_key, project_instance_id, canonical_identity, entity_kind,
1427                            repository_path, package_manager, package_name, manifest_path,
1428                            symbol_name, symbol_kind, symbol_parent, symbol_signature,
1429                            external_system, external_identity
1430                       FROM graph_entities
1431                      WHERE project_instance_id = ?1 AND repository_path = ?2
1432                      ORDER BY entity_kind, entity_key
1433                      LIMIT ?3",
1434                )?;
1435                collect_entity_rows_metered(
1436                    statement.query(params![
1437                        &project.as_bytes()[..],
1438                        path.as_str(),
1439                        limit_plus_one
1440                    ])?,
1441                    &mut meter,
1442                )
1443            },
1444        )?;
1445        let page = page_from_raw(raw, limit, |row| {
1446            let entity = entity_from_row(row, project, generation)?;
1447            meter.record_entity(&entity)?;
1448            Ok(entity)
1449        })?;
1450        let work = meter.finish(page.rows.len())?;
1451        Ok(RepositoryGraphReadPage { page, work })
1452    }
1453
1454    /// Load a bounded stable-order page of local entrypoint candidate entities.
1455    ///
1456    /// This read is intentionally page-shaped and has no persistence side
1457    /// effects. Callers use the truncation sentinel to refuse conclusions
1458    /// when the complete entity scope does not fit its declared bound.
1459    ///
1460    /// # Errors
1461    ///
1462    /// Returns a database error when the requested snapshot is unavailable,
1463    /// the limit or budget is invalid, the read is cancelled, or SQLite
1464    /// cannot execute the bounded query.
1465    pub fn repository_graph_entrypoint_candidates_page_bounded(
1466        &self,
1467        project: ProjectInstanceId,
1468        generation: IndexGeneration,
1469        limit: u32,
1470        selection: ContentSelection,
1471        budget: RepositoryGraphReadBudget,
1472        control: Option<&IndexWorkControl>,
1473    ) -> DbResult<RepositoryGraphReadPage<GraphEntity>> {
1474        let limit_plus_one = validated_limit_plus_one(
1475            limit,
1476            GraphLimits::MAX_ROWS,
1477            "graph entity rows must be nonzero and within the product ceiling",
1478        )?;
1479        self.require_repository_graph_snapshot(project, generation)?;
1480        let mut meter = RepositoryGraphReadMeter::new(budget, 1)?;
1481        let selection_filter = match selection {
1482            ContentSelection::UnspecifiedLegacy => "",
1483            ContentSelection::Source => {
1484                " AND EXISTS (SELECT 1 FROM file_content_classifications AS classification
1485                               WHERE classification.path = graph_entities.repository_path
1486                                 AND classification.classification = 'source')"
1487            }
1488            ContentSelection::Documentation => {
1489                " AND EXISTS (SELECT 1 FROM file_content_classifications AS classification
1490                               WHERE classification.path = graph_entities.repository_path
1491                                 AND classification.classification = 'documentation')"
1492            }
1493            ContentSelection::Both => {
1494                " AND EXISTS (SELECT 1 FROM file_content_classifications AS classification
1495                               WHERE classification.path = graph_entities.repository_path
1496                                 AND classification.classification IN ('source', 'documentation'))"
1497            }
1498        };
1499        let sql = format!(
1500            "SELECT entity_key, project_instance_id, canonical_identity, entity_kind,
1501                    repository_path, package_manager, package_name, manifest_path,
1502                    symbol_name, symbol_kind, symbol_parent, symbol_signature,
1503                    external_system, external_identity
1504               FROM graph_entities
1505              WHERE project_instance_id = ?1
1506                AND entity_kind IN ('file', 'symbol'){selection_filter}
1507              ORDER BY entity_key
1508              LIMIT ?2"
1509        );
1510        let raw = with_sqlite_read_progress(
1511            &self.connection,
1512            control,
1513            IndexWorkStage::RepositoryTraversal,
1514            || {
1515                let mut statement = self.connection.prepare_cached(&sql)?;
1516                collect_entity_rows_metered(
1517                    statement.query(params![&project.as_bytes()[..], limit_plus_one])?,
1518                    &mut meter,
1519                )
1520            },
1521        )?;
1522        let page = page_from_raw(raw, limit, |row| {
1523            let entity = entity_from_row(row, project, generation)?;
1524            meter.record_entity(&entity)?;
1525            Ok(entity)
1526        })?;
1527        let work = meter.finish(page.rows.len())?;
1528        Ok(RepositoryGraphReadPage { page, work })
1529    }
1530
1531    /// Load bounded graph entities that export one exact canonical resolution key.
1532    ///
1533    /// # Errors
1534    ///
1535    /// Returns an error for invalid limits, a project mismatch, a conflicting
1536    /// canonical witness, corrupt graph rows, or `SQLite` failure.
1537    pub fn repository_resolution_candidates(
1538        &self,
1539        key: &CanonicalResolutionKey,
1540        limit: u32,
1541    ) -> DbResult<RepositoryGraphPage<GraphEntity>> {
1542        let limit_plus_one = validated_limit_plus_one(
1543            limit,
1544            GraphLimits::MAX_ROWS,
1545            "resolution candidates must be nonzero and within the product ceiling",
1546        )?;
1547        let Some(generation) = self.repository_graph_generation()? else {
1548            return Ok(empty_page());
1549        };
1550        if !verify_project_identity(&self.connection, key.project())? {
1551            return Ok(empty_page());
1552        }
1553        if !validate_persisted_resolution_key(&self.connection, key)? {
1554            return Ok(empty_page());
1555        }
1556        let raw = {
1557            let mut statement = self.connection.prepare_cached(
1558                "SELECT entity.entity_key, entity.project_instance_id,
1559                        entity.canonical_identity, entity.entity_kind,
1560                        entity.repository_path, entity.package_manager,
1561                        entity.package_name, entity.manifest_path,
1562                        entity.symbol_name, entity.symbol_kind,
1563                        entity.symbol_parent, entity.symbol_signature,
1564                        entity.external_system, entity.external_identity
1565                   FROM graph_entity_exports AS export
1566                        INDEXED BY idx_graph_entity_exports_key
1567                   JOIN graph_entities AS entity
1568                     ON entity.project_instance_id = export.project_instance_id
1569                    AND entity.entity_key = export.entity_key
1570                  WHERE export.project_instance_id = ?1
1571                    AND export.resolution_domain = ?2
1572                    AND export.key_digest = ?3
1573                  ORDER BY export.entity_key
1574                  LIMIT ?4",
1575            )?;
1576            collect_entity_rows(statement.query(params![
1577                &key.project().as_bytes()[..],
1578                key.domain().as_str(),
1579                &key.digest_bytes()[..],
1580                limit_plus_one,
1581            ])?)?
1582        };
1583        page_from_raw(raw, limit, |row| {
1584            entity_from_row(row, key.project(), generation)
1585        })
1586    }
1587
1588    /// Load bounded export candidates for a canonical-key batch in one set-oriented pass.
1589    ///
1590    /// Results retain the selecting key so callers can resolve several relation
1591    /// occurrences without issuing one query per dependency key. Duplicate input
1592    /// keys and duplicate candidate bindings are returned once in stable order.
1593    ///
1594    /// # Errors
1595    ///
1596    /// Returns an error for invalid limits, mixed projects, a conflicting
1597    /// canonical witness, corrupt graph rows, or `SQLite` failure. A terminal row
1598    /// conversion failure rejects the complete operation rather than returning a
1599    /// partial candidate set.
1600    pub fn repository_resolution_candidates_for_keys(
1601        &self,
1602        project: ProjectInstanceId,
1603        keys: &[CanonicalResolutionKey],
1604        limit: u32,
1605    ) -> DbResult<RepositoryGraphPage<RepositoryResolutionCandidate>> {
1606        self.repository_resolution_candidates_for_keys_with_byte_limit(
1607            project,
1608            keys,
1609            limit,
1610            u64::MAX,
1611        )
1612        .map(|read| read.page)
1613    }
1614
1615    /// Read canonical resolution state under the caller's remaining operation bytes.
1616    ///
1617    /// # Errors
1618    /// Returns the same validation/storage failures as [`Self::repository_resolution_candidates_for_keys`],
1619    /// or a typed indexing output-byte refusal before cloning or decoding an owner.
1620    pub fn repository_resolution_candidates_for_keys_with_byte_limit(
1621        &self,
1622        project: ProjectInstanceId,
1623        keys: &[CanonicalResolutionKey],
1624        limit: u32,
1625        byte_limit: u64,
1626    ) -> DbResult<RepositoryResolutionReadPage<RepositoryResolutionCandidate>> {
1627        validated_limit_plus_one(
1628            limit,
1629            GraphLimits::MAX_ROWS,
1630            "resolution candidates must be nonzero and within the product ceiling",
1631        )?;
1632        let Some(generation) = self.repository_graph_generation()? else {
1633            return Ok(RepositoryResolutionReadPage::empty());
1634        };
1635        if !verify_project_identity(&self.connection, project)? {
1636            return Ok(RepositoryResolutionReadPage::empty());
1637        }
1638        let mut budget = ResolutionReadBudget::new(byte_limit);
1639        let keys = normalized_resolution_keys(project, keys, &mut budget)?;
1640        validate_persisted_resolution_keys(&self.connection, &keys, &budget)?;
1641        let mut candidates = Vec::new();
1642        for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
1643            if chunk.is_empty() {
1644                continue;
1645            }
1646            let values_clause = anonymous_values_clause(chunk.len(), 4);
1647            let sql = format!(
1648                "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
1649                      AS (VALUES {values_clause})
1650                 SELECT entity.entity_key, entity.project_instance_id,
1651                        entity.canonical_identity, entity.entity_kind,
1652                        entity.repository_path, entity.package_manager,
1653                        entity.package_name, entity.manifest_path,
1654                        entity.symbol_name, entity.symbol_kind,
1655                        entity.symbol_parent, entity.symbol_signature,
1656                        entity.external_system, entity.external_identity,
1657                        stored.project_instance_id, stored.resolution_domain,
1658                        stored.key_digest, stored.canonical_identity
1659                   FROM requested
1660                   JOIN graph_resolution_keys AS stored
1661                     ON stored.project_instance_id = requested.project_instance_id
1662                    AND stored.resolution_domain = requested.resolution_domain
1663                    AND stored.key_digest = requested.key_digest
1664                    AND stored.canonical_identity = requested.canonical_identity
1665                   JOIN graph_entity_exports AS export
1666                        INDEXED BY idx_graph_entity_exports_key
1667                     ON export.project_instance_id = stored.project_instance_id
1668                    AND export.resolution_domain = stored.resolution_domain
1669                    AND export.key_digest = stored.key_digest
1670                   JOIN graph_entities AS entity
1671                     ON entity.project_instance_id = export.project_instance_id
1672                    AND entity.entity_key = export.entity_key
1673                  ORDER BY stored.resolution_domain, stored.key_digest, export.entity_key
1674                  LIMIT ?"
1675            );
1676            let mut values = resolution_key_values(chunk, true);
1677            values.push(Value::Integer(i64::from(limit) + 1));
1678            let mut statement = self.connection.prepare(&sql)?;
1679            let mut rows = statement.query(params_from_iter(values.iter()))?;
1680            while let Some(row) = rows.next()? {
1681                budget.reserve_output(ResolutionReadBudget::row_bytes(
1682                    row,
1683                    std::mem::size_of::<RepositoryResolutionCandidate>(),
1684                )?)?;
1685                let entity = entity_from_row(entity_row(row)?, project, generation)?;
1686                let key_project = project_from_blob(
1687                    "graph_resolution_keys.project_instance_id",
1688                    row.get::<_, Vec<u8>>(14)?,
1689                )?;
1690                require_project(project, key_project)?;
1691                let domain_text = row.get::<_, String>(15)?;
1692                let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
1693                let digest = fixed_bytes::<32>(
1694                    "graph_resolution_keys.key_digest",
1695                    row.get::<_, Vec<u8>>(16)?,
1696                )?;
1697                let key = CanonicalResolutionKey::from_persisted(
1698                    key_project,
1699                    domain,
1700                    digest,
1701                    row.get(17)?,
1702                )?;
1703                candidates.push(RepositoryResolutionCandidate { key, entity });
1704            }
1705            if candidates.len() > limit as usize {
1706                break;
1707            }
1708        }
1709        candidates.sort_by(|left, right| {
1710            left.key
1711                .cmp(&right.key)
1712                .then_with(|| left.entity.key().digest().cmp(right.entity.key().digest()))
1713                .then_with(|| {
1714                    left.entity
1715                        .key()
1716                        .canonical_identity()
1717                        .cmp(right.entity.key().canonical_identity())
1718                })
1719        });
1720        candidates.dedup_by(|left, right| {
1721            left.key == right.key && left.entity.key() == right.entity.key()
1722        });
1723        let truncated = candidates.len() > limit as usize;
1724        candidates.truncate(limit as usize);
1725        // Small Vecs may retain more than the two inline slots per admitted
1726        // row in the envelope. Keep their actual excess capacity through transfer.
1727        let extra_slots = candidates
1728            .capacity()
1729            .saturating_sub(candidates.len().saturating_mul(2));
1730        let retained_bytes = budget.output_retained.saturating_add(
1731            (extra_slots as u64)
1732                .saturating_mul(std::mem::size_of::<RepositoryResolutionCandidate>() as u64),
1733        );
1734        Ok(RepositoryResolutionReadPage {
1735            page: RepositoryGraphPage {
1736                rows: candidates,
1737                truncated,
1738            },
1739            retained_bytes,
1740        })
1741    }
1742
1743    /// Load bounded canonical export keys previously owned by exact source paths.
1744    ///
1745    /// The result is deduplicated and sorted by canonical key identity so callers
1746    /// can union it deterministically with newly staged exports.
1747    ///
1748    /// # Errors
1749    ///
1750    /// Returns an error for invalid paths or limits, project mismatch, corrupt
1751    /// persisted keys, canonical witness collisions, or `SQLite` failure.
1752    pub fn repository_export_keys_for_paths(
1753        &self,
1754        project: ProjectInstanceId,
1755        paths: &[String],
1756        limit: u32,
1757    ) -> DbResult<RepositoryGraphPage<CanonicalResolutionKey>> {
1758        self.repository_export_keys_for_paths_with_byte_limit(project, paths, limit, u64::MAX)
1759            .map(|read| read.page)
1760    }
1761
1762    /// Read canonical resolution state under the caller's remaining operation bytes.
1763    ///
1764    /// # Errors
1765    /// Returns the same validation/storage failures as [`Self::repository_export_keys_for_paths`],
1766    /// or a typed indexing output-byte refusal before cloning or decoding an owner.
1767    pub fn repository_export_keys_for_paths_with_byte_limit(
1768        &self,
1769        project: ProjectInstanceId,
1770        paths: &[String],
1771        limit: u32,
1772        byte_limit: u64,
1773    ) -> DbResult<RepositoryResolutionReadPage<CanonicalResolutionKey>> {
1774        validated_limit_plus_one(
1775            limit,
1776            GraphLimits::MAX_ROWS,
1777            "resolution export keys 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(RepositoryResolutionReadPage::empty());
1783        }
1784        let mut budget = ResolutionReadBudget::new(byte_limit);
1785        for path in paths {
1786            budget.reserve(4_u64.saturating_mul(128_u64.saturating_add(path.len() as u64)))?;
1787        }
1788        let paths = normalized_file_paths(paths)?;
1789        let keys = resolution_keys_for_owner_paths(
1790            &self.connection,
1791            project,
1792            &paths,
1793            ResolutionOwner::EntityExports,
1794            Some(limit),
1795            &mut budget,
1796        )?;
1797        Ok(RepositoryResolutionReadPage {
1798            page: page_from_ordered_set(keys, limit),
1799            retained_bytes: budget.output_retained,
1800        })
1801    }
1802
1803    /// Find the bounded distinct source paths that depend on canonical keys.
1804    ///
1805    /// `truncated` is set from aggregate `LIMIT + 1` handling. Callers must
1806    /// escalate to a full refresh before opening a publication transaction when
1807    /// it is true.
1808    ///
1809    /// # Errors
1810    ///
1811    /// Returns an error for invalid limits, mixed projects, canonical witness
1812    /// collisions, invalid persisted paths, or `SQLite` failure. No mutation is
1813    /// performed.
1814    pub fn repository_affected_source_paths(
1815        &self,
1816        project: ProjectInstanceId,
1817        keys: &[CanonicalResolutionKey],
1818        limit: u32,
1819    ) -> DbResult<RepositoryGraphPage<RepositoryFilePath>> {
1820        self.repository_affected_source_paths_with_byte_limit(project, keys, limit, u64::MAX)
1821            .map(|read| read.page)
1822    }
1823
1824    /// Read canonical resolution state under the caller's remaining operation bytes.
1825    ///
1826    /// # Errors
1827    /// Returns the same validation/storage failures as [`Self::repository_affected_source_paths`],
1828    /// or a typed indexing output-byte refusal before cloning or decoding an owner.
1829    pub fn repository_affected_source_paths_with_byte_limit(
1830        &self,
1831        project: ProjectInstanceId,
1832        keys: &[CanonicalResolutionKey],
1833        limit: u32,
1834        byte_limit: u64,
1835    ) -> DbResult<RepositoryResolutionReadPage<RepositoryFilePath>> {
1836        validated_limit_plus_one(
1837            limit,
1838            GraphLimits::MAX_ROWS,
1839            "affected source paths must be nonzero and within the product ceiling",
1840        )?;
1841        if self.repository_graph_generation()?.is_none()
1842            || !verify_project_identity(&self.connection, project)?
1843        {
1844            return Ok(RepositoryResolutionReadPage::empty());
1845        }
1846        let mut budget = ResolutionReadBudget::new(byte_limit);
1847        let keys = normalized_resolution_keys(project, keys, &mut budget)?;
1848        validate_persisted_resolution_keys(&self.connection, &keys, &budget)?;
1849        let paths = affected_source_paths(&self.connection, &keys, limit, &mut budget)?;
1850        Ok(RepositoryResolutionReadPage {
1851            page: page_from_ordered_set(paths, limit),
1852            retained_bytes: budget.output_retained,
1853        })
1854    }
1855
1856    /// Account the persisted closure owned by exact affected source paths.
1857    ///
1858    /// Resolution witnesses are conservatively counted once per retained export
1859    /// or dependency binding. Callers must require a full refresh when
1860    /// `truncated` is true; in that case `rows` is the `limit + 1` lower bound
1861    /// and `retained_bytes` covers only that bounded prefix.
1862    ///
1863    /// # Errors
1864    ///
1865    /// Returns an error for invalid paths or limits, corrupt persisted rows,
1866    /// unavailable owned indexes, or any `SQLite` preparation, iteration, or
1867    /// conversion failure. An unavailable graph returns an empty footprint;
1868    /// a differently bound graph returns the typed project-mismatch error.
1869    pub fn repository_affected_source_footprint(
1870        &self,
1871        project: ProjectInstanceId,
1872        paths: &[String],
1873        limit: u32,
1874    ) -> DbResult<RepositoryAffectedSourceFootprint> {
1875        let limit_plus_one = validated_limit_plus_one(
1876            limit,
1877            GraphLimits::MAX_ROWS,
1878            "affected source footprint rows must be nonzero and within the product ceiling",
1879        )?;
1880        if self.repository_graph_generation()?.is_none()
1881            || !verify_project_identity(&self.connection, project)?
1882        {
1883            return Ok(empty_affected_source_footprint());
1884        }
1885        let paths = normalized_file_paths(paths)?;
1886        affected_source_footprint(&self.connection, project, &paths, limit_plus_one)
1887    }
1888
1889    /// Load a bounded page of logical relations through one indexed query shape.
1890    ///
1891    /// # Errors
1892    ///
1893    /// Returns an error for invalid limits, mismatched project identities,
1894    /// `SQLite` failure, or any corrupt entity/relation row in the complete page.
1895    pub fn repository_graph_relations(
1896        &self,
1897        query: RepositoryGraphRelationQuery,
1898        limit: u32,
1899    ) -> DbResult<RepositoryGraphPage<LogicalRelation>> {
1900        let page = self.repository_graph_relation_rows(query, limit, None)?;
1901        Ok(RepositoryGraphPage {
1902            rows: page.rows.into_iter().map(|row| row.relation).collect(),
1903            truncated: page.truncated,
1904        })
1905    }
1906
1907    /// Load the highest-degree resolved endpoints for one relation family.
1908    ///
1909    /// Ranking considers every exact local resolution from the current
1910    /// publication while hydrating only the requested bounded endpoint page.
1911    /// Self-relations are excluded because they cannot expand a preview.
1912    ///
1913    /// # Errors
1914    ///
1915    /// Returns an error for invalid limits, cancellation, `SQLite` failure, or
1916    /// any corrupt entity row in the complete page.
1917    pub fn repository_graph_resolved_relation_hubs(
1918        &self,
1919        relation: GraphRelationKind,
1920        limit: u32,
1921        control: Option<&IndexWorkControl>,
1922    ) -> DbResult<RepositoryGraphPage<GraphEntity>> {
1923        if let Some(control) = control {
1924            control.check(IndexWorkStage::RepositoryTraversal)?;
1925        }
1926        let limit_plus_one = validated_limit_plus_one(
1927            limit,
1928            GraphLimits::MAX_ROWS,
1929            "resolved graph hub rows must be nonzero and within the product ceiling",
1930        )?;
1931        let Some(generation) = self.repository_graph_generation()? else {
1932            return Ok(empty_page());
1933        };
1934        let project =
1935            load_project_identity(&self.connection)?.ok_or(DbError::GraphPublicationUnavailable)?;
1936        let (scope, kind) = relation_parts(relation);
1937        let raw_keys = with_sqlite_read_progress(
1938            &self.connection,
1939            control,
1940            IndexWorkStage::RepositoryTraversal,
1941            || {
1942                let mut statement = self
1943                    .connection
1944                    .prepare_cached(resolved_relation_hub_keys_sql())?;
1945                let mut rows = statement.query(params![
1946                    &project.as_bytes()[..],
1947                    scope,
1948                    kind,
1949                    RESOLUTION_STATUS_RESOLVED,
1950                    limit_plus_one,
1951                ])?;
1952                let mut keys = Vec::new();
1953                while let Some(row) = rows.next()? {
1954                    keys.push(row.get::<_, Vec<u8>>(0)?);
1955                }
1956                Ok(keys)
1957            },
1958        )?;
1959        let keys = raw_keys
1960            .into_iter()
1961            .map(|key| fixed_bytes::<32>("graph_entities.entity_key", key))
1962            .collect::<DbResult<Vec<_>>>()?;
1963        let mut entities =
1964            load_graph_entities_by_digest_metered(self, &keys, project, generation, control, None)?;
1965        let ordered = keys
1966            .into_iter()
1967            .map(|key| {
1968                entities.remove(&key).ok_or(DbError::GraphRowShape {
1969                    table: "graph_relations",
1970                    reason: "ranked resolved endpoint entity is missing",
1971                })
1972            })
1973            .collect::<DbResult<Vec<_>>>()?;
1974        page_from_raw(ordered, limit, Ok)
1975    }
1976
1977    /// Load a bounded relation page with unique endpoint hydration.
1978    ///
1979    /// # Errors
1980    ///
1981    /// Returns an error for invalid limits, mismatched project identities,
1982    /// cancellation, `SQLite` failure, or any corrupt entity/relation row in
1983    /// the complete page.
1984    pub fn repository_graph_relation_rows(
1985        &self,
1986        query: RepositoryGraphRelationQuery,
1987        limit: u32,
1988        control: Option<&IndexWorkControl>,
1989    ) -> DbResult<RepositoryGraphPage<RepositoryGraphRelationRow>> {
1990        let limit_plus_one = validated_limit_plus_one(
1991            limit,
1992            GraphLimits::MAX_ROWS,
1993            "graph rows must be nonzero and within the product ceiling",
1994        )?;
1995        let Some(generation) = self.repository_graph_generation()? else {
1996            return Ok(empty_page());
1997        };
1998        let (project, raw) = match query {
1999            RepositoryGraphRelationQuery::Outbound { source } => {
2000                let project = source.project();
2001                if !verify_project_identity(&self.connection, project)? {
2002                    return Ok(empty_page());
2003                }
2004                let raw = with_sqlite_read_progress(
2005                    &self.connection,
2006                    control,
2007                    IndexWorkStage::RepositoryTraversal,
2008                    || {
2009                        self.collect_relation_rows_by_key(
2010                            "source_entity_key",
2011                            &source.digest_bytes()?,
2012                            limit_plus_one,
2013                        )
2014                    },
2015                )?;
2016                (project, raw)
2017            }
2018            RepositoryGraphRelationQuery::Inbound { target } => {
2019                let project = target.project();
2020                if !verify_project_identity(&self.connection, project)? {
2021                    return Ok(empty_page());
2022                }
2023                let raw = with_sqlite_read_progress(
2024                    &self.connection,
2025                    control,
2026                    IndexWorkStage::RepositoryTraversal,
2027                    || {
2028                        self.collect_relation_rows_by_key(
2029                            "target_entity_key",
2030                            &target.digest_bytes()?,
2031                            limit_plus_one,
2032                        )
2033                    },
2034                )?;
2035                (project, raw)
2036            }
2037            RepositoryGraphRelationQuery::Family { relation } => {
2038                let project = load_project_identity(&self.connection)?
2039                    .ok_or(DbError::GraphPublicationUnavailable)?;
2040                let (scope, kind) = relation_parts(relation);
2041                let raw = with_sqlite_read_progress(
2042                    &self.connection,
2043                    control,
2044                    IndexWorkStage::RepositoryTraversal,
2045                    || {
2046                        #[cfg(feature = "sqlite-progress-test-observer")]
2047                        crate::sqlite_progress_test_observer::notify(
2048                            crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryEntered,
2049                        );
2050                        let result = (|| {
2051                            let mut statement = self.connection.prepare_cached(
2052                                "SELECT relation_key, project_instance_id, canonical_identity,
2053                                    source_entity_key, relation_scope, relation_kind,
2054                                    resolution_status, target_entity_key, reference_text,
2055                                    candidate_count, document_unresolved_reason,
2056                                    confidence, completeness
2057                               FROM graph_relations
2058                              WHERE project_instance_id = ?1
2059                                AND relation_scope = ?2 AND relation_kind = ?3
2060                              ORDER BY relation_key
2061                              LIMIT ?4",
2062                            )?;
2063                            collect_relation_rows(statement.query(params![
2064                                &project.as_bytes()[..],
2065                                scope,
2066                                kind,
2067                                limit_plus_one
2068                            ])?)
2069                        })();
2070                        #[cfg(feature = "sqlite-progress-test-observer")]
2071                        crate::sqlite_progress_test_observer::notify(
2072                            crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryExited,
2073                        );
2074                        result
2075                    },
2076                )?;
2077                (project, raw)
2078            }
2079        };
2080        let entities = load_relation_entities(self, &raw, project, generation, control)?;
2081        page_from_raw(raw, limit, |row| {
2082            relation_detail_from_row(&entities, row, project, generation)
2083        })
2084    }
2085
2086    /// Load one relation-family page with pre-limit source classification selection.
2087    ///
2088    /// Omitted selection preserves the existing family candidate universe and
2089    /// relation-key order. Explicit selection filters file-bearing source
2090    /// entities inside the indexed relation query before `LIMIT`; the returned
2091    /// source classification is projected by the same statement.
2092    ///
2093    /// # Errors
2094    ///
2095    /// Returns an error for invalid limits, cancellation, `SQLite` failures,
2096    /// missing or corrupt source classifications, or corrupt graph rows. No
2097    /// partial page is returned.
2098    pub fn repository_graph_classified_relation_family_rows(
2099        &self,
2100        relation: GraphRelationKind,
2101        selection: ContentSelection,
2102        limit: u32,
2103        control: Option<&IndexWorkControl>,
2104    ) -> DbResult<RepositoryGraphPage<RepositoryGraphClassifiedRelationRow>> {
2105        let limit_plus_one = validated_limit_plus_one(
2106            limit,
2107            GraphLimits::MAX_ROWS,
2108            "graph rows must be nonzero and within the product ceiling",
2109        )?;
2110        let Some(generation) = self.repository_graph_generation()? else {
2111            return Ok(empty_page());
2112        };
2113        let project =
2114            load_project_identity(&self.connection)?.ok_or(DbError::GraphPublicationUnavailable)?;
2115        let (scope, kind) = relation_parts(relation);
2116        let sql = classified_relation_family_sql(selection);
2117        let mut bindings = vec![
2118            Value::Blob(project.as_bytes().to_vec()),
2119            Value::Text(scope.to_string()),
2120            Value::Text(kind.to_string()),
2121        ];
2122        match selection {
2123            ContentSelection::UnspecifiedLegacy => {}
2124            ContentSelection::Source => bindings.push(Value::Text(
2125                ContentClassification::Source.as_str().to_string(),
2126            )),
2127            ContentSelection::Documentation => bindings.push(Value::Text(
2128                ContentClassification::Documentation.as_str().to_string(),
2129            )),
2130            ContentSelection::Both => bindings.extend([
2131                Value::Text(ContentClassification::Source.as_str().to_string()),
2132                Value::Text(ContentClassification::Documentation.as_str().to_string()),
2133            ]),
2134        }
2135        bindings.push(Value::Integer(limit_plus_one));
2136        let raw = with_sqlite_read_progress(
2137            &self.connection,
2138            control,
2139            IndexWorkStage::RepositoryTraversal,
2140            || {
2141                #[cfg(feature = "sqlite-progress-test-observer")]
2142                crate::sqlite_progress_test_observer::notify(
2143                    crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryEntered,
2144                );
2145                let result = (|| {
2146                    let mut statement = self.connection.prepare_cached(&sql)?;
2147                    collect_classified_relation_rows(
2148                        statement.query(params_from_iter(bindings.iter()))?,
2149                    )
2150                })();
2151                #[cfg(feature = "sqlite-progress-test-observer")]
2152                crate::sqlite_progress_test_observer::notify(
2153                    crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryExited,
2154                );
2155                result
2156            },
2157        )?;
2158        let relation_rows = raw.iter().map(|row| &row.relation).collect::<Vec<_>>();
2159        let entities =
2160            load_relation_entity_references(self, &relation_rows, project, generation, control)?;
2161        page_from_raw(raw, limit, |row| {
2162            classified_relation_detail_from_row(&entities, row, project, generation)
2163        })
2164    }
2165
2166    /// Hydrate an ordered unique set of normalized relations from compact stable keys.
2167    ///
2168    /// # Errors
2169    ///
2170    /// Returns an error for duplicate or oversized input, a stale project or
2171    /// generation, cancellation, a missing relation or endpoint, `SQLite`
2172    /// failure, or any invalid persisted key or canonical identity. No partial
2173    /// set is returned.
2174    pub fn repository_graph_relation_rows_by_digest(
2175        &self,
2176        project: ProjectInstanceId,
2177        generation: IndexGeneration,
2178        digests: &[[u8; 32]],
2179        budget: RepositoryGraphReadBudget,
2180        control: Option<&IndexWorkControl>,
2181    ) -> DbResult<RepositoryGraphReadBatch<RepositoryGraphRelationRow>> {
2182        validate_graph_hydration_request(digests)?;
2183        self.require_repository_graph_snapshot(project, generation)?;
2184        let mut meter = RepositoryGraphReadMeter::new(budget, digests.len())?;
2185        if digests.is_empty() {
2186            return Ok(RepositoryGraphReadBatch {
2187                rows: Vec::new(),
2188                work: meter.finish(0)?,
2189            });
2190        }
2191        let sql = graph_relation_hydration_sql(digests.len());
2192        let mut bindings = digests
2193            .iter()
2194            .map(|digest| Value::Blob(digest.to_vec()))
2195            .collect::<Vec<_>>();
2196        bindings.push(Value::Blob(project.as_bytes().to_vec()));
2197        let raw = with_sqlite_read_progress(
2198            &self.connection,
2199            control,
2200            IndexWorkStage::RepositoryTraversal,
2201            || {
2202                let mut statement = self.connection.prepare(&sql)?;
2203                collect_relation_rows_metered(
2204                    statement.query(params_from_iter(bindings.iter()))?,
2205                    &mut meter,
2206                )
2207            },
2208        )?;
2209        let mut relations = HashMap::with_capacity(raw.len());
2210        for row in raw {
2211            let digest = fixed_bytes::<32>("graph_relations.relation_key", row.key.clone())?;
2212            if relations.insert(digest, row).is_some() {
2213                return Err(DbError::GraphRowShape {
2214                    table: "graph_relations",
2215                    reason: "batched relation hydration returned a duplicate key",
2216                });
2217            }
2218        }
2219        let mut ordered = Vec::with_capacity(digests.len());
2220        for digest in digests {
2221            ordered.push(relations.remove(digest).ok_or(DbError::GraphRowShape {
2222                table: "graph_relations",
2223                reason: "requested graph relation is missing",
2224            })?);
2225        }
2226        let references = ordered.iter().collect::<Vec<_>>();
2227        let entities = load_relation_entity_references_metered(
2228            self,
2229            &references,
2230            project,
2231            generation,
2232            control,
2233            Some(&mut meter),
2234        )?;
2235        let rows = ordered
2236            .into_iter()
2237            .map(|row| relation_detail_from_row(&entities, row, project, generation))
2238            .collect::<DbResult<Vec<_>>>()?;
2239        let work = meter.finish(rows.len())?;
2240        Ok(RepositoryGraphReadBatch { rows, work })
2241    }
2242
2243    /// Load one bounded legacy direction-specific adjacency page for a unique frontier.
2244    ///
2245    /// The complete frontier is bound through one statement whose indexed
2246    /// per-frontier branches each cap their candidate rows before the stable
2247    /// compound order. Endpoint entities are hydrated in bounded set-oriented
2248    /// batches. The opaque continuation is valid only with the same project,
2249    /// generation, frontier, and direction inside the same request snapshot.
2250    ///
2251    /// # Errors
2252    ///
2253    /// Returns an error for invalid or mixed-project frontiers, invalid limits
2254    /// or continuation state, cancellation, `SQLite` failures, or any corrupt
2255    /// relation or endpoint row. No partial page is returned.
2256    pub fn repository_graph_adjacency_page(
2257        &self,
2258        frontier: &[GraphEntityKey],
2259        direction: RepositoryGraphDirection,
2260        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2261        limit: u32,
2262        control: Option<&IndexWorkControl>,
2263    ) -> DbResult<RepositoryGraphAdjacencyPage> {
2264        Ok(self
2265            .repository_graph_adjacency_page_bounded(
2266                frontier,
2267                direction,
2268                continuation,
2269                limit,
2270                maximum_repository_graph_read_budget()?,
2271                control,
2272            )?
2273            .page)
2274    }
2275
2276    /// Load one bounded adjacency page and report exact database work.
2277    ///
2278    /// # Errors
2279    ///
2280    /// Returns the same fail-closed errors as
2281    /// [`Self::repository_graph_adjacency_page`] and rejects any page whose
2282    /// returned, decoded, endpoint, or path hydration crosses `budget`.
2283    pub fn repository_graph_adjacency_page_bounded(
2284        &self,
2285        frontier: &[GraphEntityKey],
2286        direction: RepositoryGraphDirection,
2287        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2288        limit: u32,
2289        budget: RepositoryGraphReadBudget,
2290        control: Option<&IndexWorkControl>,
2291    ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
2292        self.repository_graph_adjacency_page_filtered_bounded(
2293            frontier,
2294            direction,
2295            None,
2296            continuation,
2297            limit,
2298            budget,
2299            control,
2300        )
2301    }
2302
2303    /// Load one bounded direction-specific adjacency page for an optional exact family.
2304    ///
2305    /// An unfiltered request preserves the legacy family set and excludes
2306    /// `documents`; an exact `documents` request remains selectable.
2307    ///
2308    /// # Errors
2309    ///
2310    /// Returns the same fail-closed errors as
2311    /// [`Self::repository_graph_adjacency_page`] and binds the optional family
2312    /// to continuation state.
2313    pub fn repository_graph_adjacency_page_filtered(
2314        &self,
2315        frontier: &[GraphEntityKey],
2316        direction: RepositoryGraphDirection,
2317        relation: Option<GraphRelationKind>,
2318        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2319        limit: u32,
2320        control: Option<&IndexWorkControl>,
2321    ) -> DbResult<RepositoryGraphAdjacencyPage> {
2322        Ok(self
2323            .repository_graph_adjacency_page_filtered_bounded(
2324                frontier,
2325                direction,
2326                relation,
2327                continuation,
2328                limit,
2329                maximum_repository_graph_read_budget()?,
2330                control,
2331            )?
2332            .page)
2333    }
2334
2335    /// Load one bounded exact-family adjacency page containing only local resolutions.
2336    ///
2337    /// # Errors
2338    ///
2339    /// Returns the same fail-closed errors as
2340    /// [`Self::repository_graph_adjacency_page_filtered`] and binds the local
2341    /// resolution filter to continuation state.
2342    pub fn repository_graph_resolved_adjacency_page(
2343        &self,
2344        frontier: &[GraphEntityKey],
2345        direction: RepositoryGraphDirection,
2346        relation: GraphRelationKind,
2347        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2348        limit: u32,
2349        control: Option<&IndexWorkControl>,
2350    ) -> DbResult<RepositoryGraphAdjacencyPage> {
2351        Ok(self
2352            .repository_graph_adjacency_page_filtered_by_resolution_bounded(
2353                frontier,
2354                direction,
2355                Some(relation),
2356                true,
2357                false,
2358                continuation,
2359                limit,
2360                maximum_repository_graph_read_budget()?,
2361                control,
2362            )?
2363            .page)
2364    }
2365
2366    /// Load one legacy family-filtered adjacency page and report exact database work.
2367    ///
2368    /// An unfiltered request excludes `documents` before limits are applied.
2369    ///
2370    /// # Errors
2371    ///
2372    /// Returns the same fail-closed errors as
2373    /// [`Self::repository_graph_adjacency_page_filtered`] and rejects any page
2374    /// whose complete query work crosses `budget`.
2375    pub fn repository_graph_adjacency_page_filtered_bounded(
2376        &self,
2377        frontier: &[GraphEntityKey],
2378        direction: RepositoryGraphDirection,
2379        relation: Option<GraphRelationKind>,
2380        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2381        limit: u32,
2382        budget: RepositoryGraphReadBudget,
2383        control: Option<&IndexWorkControl>,
2384    ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
2385        self.repository_graph_adjacency_page_filtered_bounded_with_documents(
2386            frontier,
2387            direction,
2388            relation,
2389            false,
2390            continuation,
2391            limit,
2392            budget,
2393            control,
2394        )
2395    }
2396
2397    /// Load one family-filtered adjacency page with explicit document visibility.
2398    ///
2399    /// `include_documents` affects only an unfiltered request. An exact
2400    /// `documents` family always remains selectable. The effective choice is
2401    /// applied before branch limits and bound into continuation identity.
2402    ///
2403    /// # Errors
2404    ///
2405    /// Returns the same fail-closed errors and bounded-work accounting as
2406    /// [`Self::repository_graph_adjacency_page_filtered_bounded`].
2407    #[allow(clippy::too_many_arguments)]
2408    pub fn repository_graph_adjacency_page_filtered_bounded_with_documents(
2409        &self,
2410        frontier: &[GraphEntityKey],
2411        direction: RepositoryGraphDirection,
2412        relation: Option<GraphRelationKind>,
2413        include_documents: bool,
2414        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2415        limit: u32,
2416        budget: RepositoryGraphReadBudget,
2417        control: Option<&IndexWorkControl>,
2418    ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
2419        self.repository_graph_adjacency_page_filtered_by_resolution_bounded(
2420            frontier,
2421            direction,
2422            relation,
2423            false,
2424            include_documents,
2425            continuation,
2426            limit,
2427            budget,
2428            control,
2429        )
2430    }
2431
2432    /// Check one exact relation family without admitting or decoding a row.
2433    ///
2434    /// This is the terminal probe used when a bounded traversal has exhausted
2435    /// its edge allowance. It distinguishes an empty adjacency from a pending
2436    /// edge without granting the caller another ordinary adjacency row.
2437    ///
2438    /// # Errors
2439    ///
2440    /// Returns an error for an invalid entity key, cancellation, or a SQLite
2441    /// failure.
2442    pub fn repository_graph_adjacency_is_empty(
2443        &self,
2444        key: &GraphEntityKey,
2445        direction: RepositoryGraphDirection,
2446        relation: GraphRelationKind,
2447        control: Option<&IndexWorkControl>,
2448    ) -> DbResult<bool> {
2449        let project = key.project();
2450        if !verify_project_identity(&self.connection, project)?
2451            || self.repository_graph_generation()?.is_none()
2452        {
2453            return Ok(true);
2454        }
2455        let digest = key.digest_bytes()?;
2456        let (key_column, index_name) = match direction {
2457            RepositoryGraphDirection::Outbound => {
2458                ("source_entity_key", "idx_graph_relations_source_kind")
2459            }
2460            RepositoryGraphDirection::Inbound => {
2461                ("target_entity_key", "idx_graph_relations_target_kind")
2462            }
2463        };
2464        let (scope, kind) = relation_parts(relation);
2465        let sql = format!(
2466            "SELECT EXISTS(
2467                 SELECT 1
2468                   FROM graph_relations AS relation INDEXED BY {index_name}
2469                  WHERE relation.project_instance_id = ?1
2470                    AND relation.{key_column} = ?2
2471                    AND relation.relation_scope = ?3
2472                    AND relation.relation_kind = ?4
2473             )"
2474        );
2475        let bindings = [
2476            Value::Blob(project.as_bytes().to_vec()),
2477            Value::Blob(digest.to_vec()),
2478            Value::Text(scope.to_string()),
2479            Value::Text(kind.to_string()),
2480        ];
2481        with_sqlite_read_progress(
2482            &self.connection,
2483            control,
2484            IndexWorkStage::RepositoryTraversal,
2485            || {
2486                self.connection
2487                    .query_row(&sql, params_from_iter(bindings.iter()), |row| {
2488                        row.get::<_, bool>(0)
2489                    })
2490                    .map(|has_row| !has_row)
2491                    .map_err(Into::into)
2492            },
2493        )
2494    }
2495
2496    /// Check one exact relation family after applying confidence and endpoint
2497    /// content admission without decoding or admitting a row.
2498    ///
2499    /// This is the bounded terminal probe used by callers that must distinguish
2500    /// an empty admitted frontier from a pending relation without consuming an
2501    /// ordinary traversal edge.
2502    ///
2503    /// # Errors
2504    ///
2505    /// Returns an error for an invalid entity key, cancellation, or a SQLite
2506    /// failure.
2507    pub fn repository_graph_adjacency_is_empty_filtered(
2508        &self,
2509        key: &GraphEntityKey,
2510        direction: RepositoryGraphDirection,
2511        relation: GraphRelationKind,
2512        minimum_confidence: ConfidenceClass,
2513        selection: ContentSelection,
2514        control: Option<&IndexWorkControl>,
2515    ) -> DbResult<bool> {
2516        let project = key.project();
2517        if !verify_project_identity(&self.connection, project)?
2518            || self.repository_graph_generation()?.is_none()
2519        {
2520            return Ok(true);
2521        }
2522        let digest = key.digest_bytes()?;
2523        let (key_column, index_name, endpoint_column) = match direction {
2524            RepositoryGraphDirection::Outbound => (
2525                "source_entity_key",
2526                "idx_graph_relations_source_kind",
2527                "target_entity_key",
2528            ),
2529            RepositoryGraphDirection::Inbound => (
2530                "target_entity_key",
2531                "idx_graph_relations_target_kind",
2532                "source_entity_key",
2533            ),
2534        };
2535        let (scope, kind) = relation_parts(relation);
2536        let confidence_filter = match minimum_confidence {
2537            ConfidenceClass::Exact => "AND relation.confidence = 'exact'",
2538            ConfidenceClass::High => "AND relation.confidence IN ('exact', 'high')",
2539            ConfidenceClass::Medium => "AND relation.confidence IN ('exact', 'high', 'medium')",
2540            ConfidenceClass::Low => "",
2541        };
2542        let endpoint_joins = if selection == ContentSelection::UnspecifiedLegacy {
2543            String::new()
2544        } else {
2545            format!(
2546                "LEFT JOIN graph_entities AS endpoint
2547                   ON endpoint.project_instance_id = relation.project_instance_id
2548                  AND endpoint.entity_key = relation.{endpoint_column}
2549                 LEFT JOIN file_content_classifications AS endpoint_classification
2550                   ON endpoint_classification.path = CASE endpoint.entity_kind
2551                        WHEN 'file' THEN endpoint.repository_path
2552                        WHEN 'symbol' THEN endpoint.repository_path
2553                        WHEN 'package' THEN endpoint.manifest_path
2554                    END"
2555            )
2556        };
2557        let selection_filter = match selection {
2558            ContentSelection::UnspecifiedLegacy => "",
2559            ContentSelection::Source => {
2560                "AND (relation.resolution_status NOT IN ('resolved', 'external')
2561                      OR relation.resolution_status = 'external'
2562                      OR (relation.relation_scope = 'extended'
2563                          AND relation.relation_kind = 'documents')
2564                      OR (relation.resolution_status = 'resolved'
2565                          AND endpoint_classification.classification = 'source'))"
2566            }
2567            ContentSelection::Documentation => {
2568                "AND (relation.resolution_status NOT IN ('resolved', 'external')
2569                      OR relation.resolution_status = 'external'
2570                      OR (relation.relation_scope = 'extended'
2571                          AND relation.relation_kind = 'documents')
2572                      OR (relation.resolution_status = 'resolved'
2573                          AND endpoint_classification.classification = 'documentation'))"
2574            }
2575            ContentSelection::Both => {
2576                "AND (relation.resolution_status NOT IN ('resolved', 'external')
2577                      OR relation.resolution_status = 'external'
2578                      OR (relation.relation_scope = 'extended'
2579                          AND relation.relation_kind = 'documents')
2580                      OR (relation.resolution_status = 'resolved'
2581                          AND endpoint_classification.classification IN ('source', 'documentation')))"
2582            }
2583        };
2584        let sql = format!(
2585            "SELECT EXISTS(
2586                 SELECT 1
2587                   FROM graph_relations AS relation INDEXED BY {index_name}
2588                    {endpoint_joins}
2589                  WHERE relation.project_instance_id = ?1
2590                    AND relation.{key_column} = ?2
2591                    AND relation.relation_scope = ?3
2592                    AND relation.relation_kind = ?4
2593                    {confidence_filter}
2594                    {selection_filter}
2595             )"
2596        );
2597        let bindings = [
2598            Value::Blob(project.as_bytes().to_vec()),
2599            Value::Blob(digest.to_vec()),
2600            Value::Text(scope.to_string()),
2601            Value::Text(kind.to_string()),
2602        ];
2603        with_sqlite_read_progress(
2604            &self.connection,
2605            control,
2606            IndexWorkStage::RepositoryTraversal,
2607            || {
2608                self.connection
2609                    .query_row(&sql, params_from_iter(bindings.iter()), |row| {
2610                        row.get::<_, bool>(0)
2611                    })
2612                    .map(|has_row| !has_row)
2613                    .map_err(Into::into)
2614            },
2615        )
2616    }
2617
2618    /// Check whether a prior adjacency page has any admitted rows remaining.
2619    ///
2620    /// The continuation is validated against its original project, generation,
2621    /// direction, family, and frontier. No relation row is decoded or charged
2622    /// to the caller's ordinary page budget.
2623    ///
2624    /// # Errors
2625    ///
2626    /// Returns an error for a stale or invalid continuation, cancellation, or a
2627    /// SQLite failure.
2628    pub fn repository_graph_adjacency_continuation_has_filtered_rows(
2629        &self,
2630        continuation: &RepositoryGraphAdjacencyContinuation,
2631        minimum_confidence: ConfidenceClass,
2632        selection: ContentSelection,
2633        control: Option<&IndexWorkControl>,
2634    ) -> DbResult<bool> {
2635        let project = continuation.project;
2636        self.require_repository_graph_snapshot(project, continuation.generation)?;
2637        if continuation.frontier.is_empty()
2638            || continuation.frontier_index as usize >= continuation.frontier.len()
2639        {
2640            return Err(GraphContractError::InvalidLimits {
2641                reason: "graph adjacency continuation has an invalid frontier",
2642            }
2643            .into());
2644        }
2645        let (key_column, index_name, endpoint_column) = match continuation.direction {
2646            RepositoryGraphDirection::Outbound => (
2647                "source_entity_key",
2648                "idx_graph_relations_source_kind",
2649                "target_entity_key",
2650            ),
2651            RepositoryGraphDirection::Inbound => (
2652                "target_entity_key",
2653                "idx_graph_relations_target_kind",
2654                "source_entity_key",
2655            ),
2656        };
2657        let confidence_filter = match minimum_confidence {
2658            ConfidenceClass::Exact => "AND relation.confidence = 'exact'",
2659            ConfidenceClass::High => "AND relation.confidence IN ('exact', 'high')",
2660            ConfidenceClass::Medium => "AND relation.confidence IN ('exact', 'high', 'medium')",
2661            ConfidenceClass::Low => "",
2662        };
2663        let endpoint_joins = if selection == ContentSelection::UnspecifiedLegacy {
2664            String::new()
2665        } else {
2666            format!(
2667                "LEFT JOIN graph_entities AS endpoint
2668                   ON endpoint.project_instance_id = relation.project_instance_id
2669                  AND endpoint.entity_key = relation.{endpoint_column}
2670                 LEFT JOIN file_content_classifications AS endpoint_classification
2671                   ON endpoint_classification.path = CASE endpoint.entity_kind
2672                        WHEN 'file' THEN endpoint.repository_path
2673                        WHEN 'symbol' THEN endpoint.repository_path
2674                        WHEN 'package' THEN endpoint.manifest_path
2675                    END"
2676            )
2677        };
2678        let selection_filter = match selection {
2679            ContentSelection::UnspecifiedLegacy => "",
2680            ContentSelection::Source => {
2681                "AND (relation.resolution_status NOT IN ('resolved', 'external')
2682                      OR relation.resolution_status = 'external'
2683                      OR (relation.relation_scope = 'extended'
2684                          AND relation.relation_kind = 'documents')
2685                      OR (relation.resolution_status = 'resolved'
2686                          AND endpoint_classification.classification = 'source'))"
2687            }
2688            ContentSelection::Documentation => {
2689                "AND (relation.resolution_status NOT IN ('resolved', 'external')
2690                      OR relation.resolution_status = 'external'
2691                      OR (relation.relation_scope = 'extended'
2692                          AND relation.relation_kind = 'documents')
2693                      OR (relation.resolution_status = 'resolved'
2694                          AND endpoint_classification.classification = 'documentation'))"
2695            }
2696            ContentSelection::Both => {
2697                "AND (relation.resolution_status NOT IN ('resolved', 'external')
2698                      OR relation.resolution_status = 'external'
2699                      OR (relation.relation_scope = 'extended'
2700                          AND relation.relation_kind = 'documents')
2701                      OR (relation.resolution_status = 'resolved'
2702                          AND endpoint_classification.classification IN ('source', 'documentation')))"
2703            }
2704        };
2705        let relation_filter = continuation.relation.map(|relation| {
2706            let (scope, kind) = relation_parts(relation);
2707            (scope, kind)
2708        });
2709        let relation_filter_sql = if relation_filter.is_some() {
2710            "AND relation.relation_scope = ? AND relation.relation_kind = ?"
2711        } else {
2712            ""
2713        };
2714        let resolution_filter = if continuation.resolved_only {
2715            "AND relation.resolution_status = 'resolved'
2716             AND relation.target_entity_key IS NOT NULL
2717             AND relation.source_entity_key <> relation.target_entity_key"
2718        } else {
2719            ""
2720        };
2721        let document_filter = if continuation.relation.is_some() || continuation.include_documents {
2722            ""
2723        } else {
2724            "AND NOT (
2725                 relation.relation_scope = 'extended'
2726                 AND relation.relation_kind = 'documents'
2727             )"
2728        };
2729        let mut bindings = Vec::new();
2730        let branches = (continuation.frontier_index as usize..continuation.frontier.len())
2731            .map(|frontier_index| {
2732                let is_continuation_frontier =
2733                    frontier_index == continuation.frontier_index as usize;
2734                let keyset = if is_continuation_frontier {
2735                    "AND (relation.relation_scope, relation.relation_kind,
2736                          relation.relation_key) > (?, ?, ?)"
2737                } else {
2738                    ""
2739                };
2740                bindings.push(Value::Blob(project.as_bytes().to_vec()));
2741                bindings.push(Value::Blob(continuation.frontier[frontier_index].to_vec()));
2742                if let Some((scope, kind)) = relation_filter {
2743                    bindings.push(Value::Text(scope.to_string()));
2744                    bindings.push(Value::Text(kind.to_string()));
2745                }
2746                if is_continuation_frontier {
2747                    bindings.push(Value::Text(continuation.relation_scope.clone()));
2748                    bindings.push(Value::Text(continuation.relation_kind.clone()));
2749                    bindings.push(Value::Blob(continuation.relation_key.to_vec()));
2750                }
2751                format!(
2752                    "SELECT 1
2753                       FROM graph_relations AS relation INDEXED BY {index_name}
2754                        {endpoint_joins}
2755                      WHERE relation.project_instance_id = ?
2756                        AND relation.{key_column} = ?
2757                        {relation_filter_sql}
2758                        {resolution_filter}
2759                        {document_filter}
2760                        {confidence_filter}
2761                        {selection_filter}
2762                        {keyset}"
2763                )
2764            })
2765            .collect::<Vec<_>>()
2766            .join(" UNION ALL ");
2767        let sql = format!("SELECT EXISTS(SELECT 1 FROM ({branches}) AS pending LIMIT 1)");
2768        with_sqlite_read_progress(
2769            &self.connection,
2770            control,
2771            IndexWorkStage::RepositoryTraversal,
2772            || {
2773                self.connection
2774                    .query_row(&sql, params_from_iter(bindings.iter()), |row| {
2775                        row.get::<_, bool>(0)
2776                    })
2777                    .map_err(Into::into)
2778            },
2779        )
2780    }
2781
2782    /// Load one optionally family- and local-resolution-filtered adjacency page.
2783    fn repository_graph_adjacency_page_filtered_by_resolution_bounded(
2784        &self,
2785        frontier: &[GraphEntityKey],
2786        direction: RepositoryGraphDirection,
2787        relation: Option<GraphRelationKind>,
2788        resolved_only: bool,
2789        include_documents: bool,
2790        continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2791        limit: u32,
2792        budget: RepositoryGraphReadBudget,
2793        control: Option<&IndexWorkControl>,
2794    ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
2795        let include_documents = include_documents
2796            || relation == Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents));
2797        let limit_plus_one = validated_limit_plus_one(
2798            limit,
2799            GraphLimits::MAX_ROWS,
2800            "graph adjacency rows must be nonzero and within the product ceiling",
2801        )?;
2802        if limit > budget.returned_rows() {
2803            return Err(GraphContractError::InvalidLimits {
2804                reason: "graph adjacency page limit exceeds the return budget",
2805            }
2806            .into());
2807        }
2808        if frontier.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
2809            return Err(GraphContractError::InvalidLimits {
2810                reason: "graph adjacency frontier exceeds the product ceiling",
2811            }
2812            .into());
2813        }
2814        if frontier.is_empty() {
2815            if continuation.is_some() {
2816                return Err(GraphContractError::InvalidLimits {
2817                    reason: "graph adjacency continuation requires a nonempty frontier",
2818                }
2819                .into());
2820            }
2821            let meter = RepositoryGraphReadMeter::new(budget, 0)?;
2822            return Ok(RepositoryGraphAdjacencyReadPage {
2823                page: empty_adjacency_page(),
2824                work: meter.finish(0)?,
2825            });
2826        }
2827        let mut meter = RepositoryGraphReadMeter::new(budget, frontier.len())?;
2828        let limit_plus_one_usize = usize::try_from(limit_plus_one).map_err(|_source| {
2829            GraphContractError::InvalidLimits {
2830                reason: "graph adjacency page limit overflowed",
2831            }
2832        })?;
2833        let project = frontier[0].project();
2834        let mut unique = BTreeSet::new();
2835        let mut frontier_digests = Vec::with_capacity(frontier.len());
2836        for key in frontier {
2837            require_project(project, key.project())?;
2838            let digest = key.digest_bytes()?;
2839            if !unique.insert(digest) {
2840                return Err(GraphContractError::InvalidLimits {
2841                    reason: "graph adjacency frontier must contain unique entities",
2842                }
2843                .into());
2844            }
2845            frontier_digests.push(digest);
2846        }
2847
2848        let Some(generation) = self.repository_graph_generation()? else {
2849            if continuation.is_some() {
2850                return Err(GraphContractError::InvalidLimits {
2851                    reason: "graph adjacency continuation has no active generation",
2852                }
2853                .into());
2854            }
2855            return Ok(RepositoryGraphAdjacencyReadPage {
2856                page: empty_adjacency_page(),
2857                work: meter.finish(0)?,
2858            });
2859        };
2860        if !verify_project_identity(&self.connection, project)? {
2861            if continuation.is_some() {
2862                return Err(GraphContractError::InvalidLimits {
2863                    reason: "graph adjacency continuation does not match the bound project",
2864                }
2865                .into());
2866            }
2867            return Ok(RepositoryGraphAdjacencyReadPage {
2868                page: empty_adjacency_page(),
2869                work: meter.finish(0)?,
2870            });
2871        }
2872
2873        if let Some(continuation) = continuation
2874            && (continuation.project != project
2875                || continuation.generation != generation
2876                || continuation.direction != direction
2877                || continuation.relation != relation
2878                || continuation.resolved_only != resolved_only
2879                || continuation.include_documents != include_documents
2880                || continuation.frontier != frontier_digests
2881                || continuation.frontier_index as usize >= frontier.len())
2882        {
2883            return Err(GraphContractError::InvalidLimits {
2884                reason: "graph adjacency continuation does not match the request",
2885            }
2886            .into());
2887        }
2888
2889        let continuation_index = continuation.map_or(0, |value| value.frontier_index as usize);
2890        let active_frontier = frontier.len() - continuation_index;
2891        let work_rows = active_frontier.checked_mul(limit_plus_one_usize).ok_or(
2892            GraphContractError::InvalidLimits {
2893                reason: "graph adjacency intermediate row ceiling overflowed",
2894            },
2895        )?;
2896        if work_rows > MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS {
2897            return Err(GraphContractError::InvalidLimits {
2898                reason: "graph adjacency frontier and page exceed the intermediate row ceiling",
2899            }
2900            .into());
2901        }
2902        if resolved_only && relation.is_none() {
2903            return Err(GraphContractError::InvalidLimits {
2904                reason: "resolved graph adjacency requires an exact relation family",
2905            }
2906            .into());
2907        }
2908
2909        let bindings_per_frontier = if relation.is_some() { 4 } else { 2 };
2910        let mut bindings = Vec::with_capacity(
2911            active_frontier * bindings_per_frontier + continuation.map_or(2, |_| 5),
2912        );
2913        bindings.push(Value::Blob(project.as_bytes().to_vec()));
2914        if resolved_only {
2915            bindings.push(Value::Text(RESOLUTION_STATUS_RESOLVED.to_string()));
2916        }
2917        for (index, digest) in frontier_digests.iter().enumerate().skip(continuation_index) {
2918            bindings.push(Value::Blob(digest.to_vec()));
2919            if let Some(relation) = relation {
2920                let (scope, kind) = relation_parts(relation);
2921                bindings.push(Value::Text(scope.to_string()));
2922                bindings.push(Value::Text(kind.to_string()));
2923            }
2924            if index == continuation_index
2925                && let Some(continuation) = continuation
2926            {
2927                bindings.push(Value::Text(continuation.relation_scope.clone()));
2928                bindings.push(Value::Text(continuation.relation_kind.clone()));
2929                bindings.push(Value::Blob(continuation.relation_key.to_vec()));
2930            }
2931            bindings.push(Value::Integer(limit_plus_one));
2932        }
2933        bindings.push(Value::Integer(limit_plus_one));
2934
2935        let sql = adjacency_relation_sql(
2936            frontier.len(),
2937            direction,
2938            continuation.map(|value| value.frontier_index as usize),
2939            relation.is_some(),
2940            resolved_only,
2941            include_documents,
2942        );
2943        let raw = with_sqlite_read_progress(
2944            &self.connection,
2945            control,
2946            IndexWorkStage::RepositoryTraversal,
2947            || {
2948                let mut statement = self.connection.prepare(&sql)?;
2949                collect_adjacency_relation_rows_metered(
2950                    statement.query(params_from_iter(bindings.iter()))?,
2951                    &mut meter,
2952                )
2953            },
2954        )?;
2955        let truncated = raw.len() > limit as usize;
2956        let next = if truncated {
2957            let last = raw.get(limit as usize - 1).ok_or(DbError::GraphRowShape {
2958                table: "graph_relations",
2959                reason: "adjacency truncation row is missing",
2960            })?;
2961            Some(RepositoryGraphAdjacencyContinuation {
2962                project,
2963                generation,
2964                direction,
2965                relation,
2966                resolved_only,
2967                include_documents,
2968                frontier: frontier_digests,
2969                frontier_index: last.frontier_index,
2970                relation_scope: last.relation.relation_scope.clone(),
2971                relation_kind: last.relation.relation_kind.clone(),
2972                relation_key: fixed_bytes::<32>(
2973                    "graph_relations.relation_key",
2974                    last.relation.key.clone(),
2975                )?,
2976            })
2977        } else {
2978            None
2979        };
2980        let relation_rows = raw.iter().map(|row| &row.relation).collect::<Vec<_>>();
2981        let entities = load_relation_entity_references_metered(
2982            self,
2983            &relation_rows,
2984            project,
2985            generation,
2986            control,
2987            Some(&mut meter),
2988        )?;
2989        let mut rows = Vec::with_capacity(raw.len());
2990        for row in raw {
2991            if let Some(control) = control {
2992                control.check(IndexWorkStage::RepositoryTraversal)?;
2993            }
2994            let frontier_key = frontier
2995                .get(row.frontier_index as usize)
2996                .ok_or(DbError::GraphRowShape {
2997                    table: "graph_relations",
2998                    reason: "adjacency row has an invalid frontier position",
2999                })?
3000                .clone();
3001            rows.push(RepositoryGraphAdjacencyRow {
3002                frontier_index: row.frontier_index,
3003                frontier: frontier_key,
3004                direction,
3005                detail: relation_detail_from_row(&entities, row.relation, project, generation)?,
3006            });
3007        }
3008        if truncated {
3009            rows.pop();
3010        }
3011        let work = meter.finish(rows.len())?;
3012        Ok(RepositoryGraphAdjacencyReadPage {
3013            page: RepositoryGraphAdjacencyPage {
3014                rows,
3015                truncated,
3016                continuation: next,
3017            },
3018            work,
3019        })
3020    }
3021
3022    /// Load bounded exact source occurrences for one logical relation.
3023    ///
3024    /// # Errors
3025    ///
3026    /// Returns an error for invalid limits, project mismatch, unavailable
3027    /// publication state, `SQLite` failure, or any invalid span or key.
3028    pub fn repository_graph_occurrences(
3029        &self,
3030        relation: &LogicalRelation,
3031        limit: u32,
3032    ) -> DbResult<RepositoryGraphPage<RelationOccurrence>> {
3033        let limit_plus_one = validated_limit_plus_one(
3034            limit,
3035            GraphLimits::MAX_OCCURRENCES,
3036            "graph occurrences must be nonzero and within the product ceiling",
3037        )?;
3038        let Some(generation) = self.repository_graph_generation()? else {
3039            return Ok(empty_page());
3040        };
3041        if relation.generation() != generation {
3042            return Err(
3043                projectatlas_core::graph::GraphContractError::GenerationMismatch {
3044                    context: "relation occurrence query",
3045                }
3046                .into(),
3047            );
3048        }
3049        if !verify_project_identity(&self.connection, relation.key().project())? {
3050            return Ok(empty_page());
3051        }
3052        let raw = {
3053            let mut statement = self.connection.prepare_cached(
3054                "SELECT relation_key, file_path, start_line, start_column,
3055                        end_line, end_column
3056                   FROM graph_relation_occurrences
3057                  WHERE relation_key = ?1
3058                  ORDER BY file_path, start_line, start_column, end_line, end_column
3059                  LIMIT ?2",
3060            )?;
3061            let mut rows =
3062                statement.query(params![&relation.key().digest_bytes()?[..], limit_plus_one])?;
3063            let mut collected = Vec::new();
3064            while let Some(row) = rows.next()? {
3065                collected.push(occurrence_row(row)?);
3066            }
3067            collected
3068        };
3069        page_from_raw(raw, limit, |row| {
3070            occurrence_from_row(row, relation, generation)
3071        })
3072    }
3073
3074    /// Load per-relation occurrence pages through one bounded set-oriented statement.
3075    ///
3076    /// Result pages retain input order. Every generated branch uses the
3077    /// relation-leading unique index and admits only `limit + 1` rows before
3078    /// the final stable merge.
3079    ///
3080    /// # Errors
3081    ///
3082    /// Returns an error for mixed projects or generations, oversized input or
3083    /// intermediate work, cancellation, unavailable publication state,
3084    /// `SQLite` failure, or any invalid span or key.
3085    pub fn repository_graph_occurrence_pages(
3086        &self,
3087        relations: &[LogicalRelation],
3088        limit: u32,
3089        control: Option<&IndexWorkControl>,
3090    ) -> DbResult<Vec<RepositoryGraphPage<RelationOccurrence>>> {
3091        Ok(self
3092            .repository_graph_occurrence_pages_bounded(
3093                relations,
3094                limit,
3095                maximum_repository_graph_read_budget()?,
3096                control,
3097            )?
3098            .pages)
3099    }
3100
3101    /// Load ordered per-relation occurrence pages under one exact read envelope.
3102    ///
3103    /// # Errors
3104    ///
3105    /// Returns the same fail-closed errors as
3106    /// [`Self::repository_graph_occurrence_pages`] and rejects any aggregate
3107    /// returned-row, decoded-byte, or occurrence-path hydration overrun. Raw
3108    /// `limit + 1` sentinels are included in decoded and path work.
3109    pub fn repository_graph_occurrence_pages_bounded(
3110        &self,
3111        relations: &[LogicalRelation],
3112        limit: u32,
3113        budget: RepositoryGraphReadBudget,
3114        control: Option<&IndexWorkControl>,
3115    ) -> DbResult<RepositoryGraphReadPages<RelationOccurrence>> {
3116        let limit_plus_one = validated_limit_plus_one(
3117            limit,
3118            GraphLimits::MAX_OCCURRENCES,
3119            "graph occurrences must be nonzero and within the product ceiling",
3120        )?;
3121        if relations.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
3122            return Err(GraphContractError::InvalidLimits {
3123                reason: "graph occurrence batch exceeds the product ceiling",
3124            }
3125            .into());
3126        }
3127        let mut meter = RepositoryGraphReadMeter::new(budget, relations.len())?;
3128        if relations.is_empty() {
3129            return Ok(RepositoryGraphReadPages {
3130                pages: Vec::new(),
3131                work: meter.finish(0)?,
3132            });
3133        }
3134        let work_rows = relations.len().checked_mul(limit_plus_one as usize).ok_or(
3135            GraphContractError::InvalidLimits {
3136                reason: "graph occurrence batch work overflowed",
3137            },
3138        )?;
3139        if work_rows > MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS {
3140            return Err(GraphContractError::InvalidLimits {
3141                reason: "graph occurrence batch exceeds the intermediate row ceiling",
3142            }
3143            .into());
3144        }
3145        let Some(generation) = self.repository_graph_generation()? else {
3146            return Ok(RepositoryGraphReadPages {
3147                pages: (0..relations.len()).map(|_| empty_page()).collect(),
3148                work: meter.finish(0)?,
3149            });
3150        };
3151        let project = relations[0].key().project();
3152        if !verify_project_identity(&self.connection, project)? {
3153            return Ok(RepositoryGraphReadPages {
3154                pages: (0..relations.len()).map(|_| empty_page()).collect(),
3155                work: meter.finish(0)?,
3156            });
3157        }
3158        let mut bindings = Vec::with_capacity(relations.len() * 2);
3159        for relation in relations {
3160            require_project(project, relation.key().project())?;
3161            if relation.generation() != generation {
3162                return Err(GraphContractError::GenerationMismatch {
3163                    context: "relation occurrence batch query",
3164                }
3165                .into());
3166            }
3167            bindings.push(Value::Blob(relation.key().digest_bytes()?.to_vec()));
3168            bindings.push(Value::Integer(limit_plus_one));
3169        }
3170        let sql = occurrence_pages_sql(relations.len());
3171        let raw = with_sqlite_read_progress(
3172            &self.connection,
3173            control,
3174            IndexWorkStage::RepositoryTraversal,
3175            || {
3176                let mut statement = self.connection.prepare(&sql)?;
3177                let mut queried = statement.query(params_from_iter(bindings.iter()))?;
3178                let mut grouped = (0..relations.len())
3179                    .map(|_| Vec::new())
3180                    .collect::<Vec<Vec<OccurrenceRow>>>();
3181                while let Some(row) = queried.next()? {
3182                    let index_value = row.get::<_, i64>(0)?;
3183                    let index =
3184                        usize::try_from(index_value).map_err(|source| DbError::InvalidCount {
3185                            field: "graph_relation_occurrences.relation_index",
3186                            value: index_value,
3187                            source,
3188                        })?;
3189                    let group = grouped.get_mut(index).ok_or(DbError::GraphRowShape {
3190                        table: "graph_relation_occurrences",
3191                        reason: "occurrence batch returned an invalid relation position",
3192                    })?;
3193                    let occurrence = occurrence_row_at(row, 1)?;
3194                    meter.record_decoded_bytes(
3195                        occurrence_row_decoded_bytes(&occurrence)?
3196                            .checked_add(8)
3197                            .ok_or(GraphContractError::InvalidLimits {
3198                                reason: "graph occurrence batch decoded row size overflowed",
3199                            })?,
3200                    )?;
3201                    group.push(occurrence);
3202                }
3203                Ok(grouped)
3204            },
3205        )?;
3206        let pages = raw
3207            .into_iter()
3208            .zip(relations)
3209            .map(|(rows, relation)| {
3210                page_from_raw(rows, limit, |row| {
3211                    let occurrence = occurrence_from_row(row, relation, generation)?;
3212                    meter.record_hydrated_path(occurrence.file().as_str())?;
3213                    Ok(occurrence)
3214                })
3215            })
3216            .collect::<DbResult<Vec<_>>>()?;
3217        let returned_rows = pages.iter().try_fold(0_usize, |count, page| {
3218            count
3219                .checked_add(page.rows.len())
3220                .ok_or(GraphContractError::InvalidLimits {
3221                    reason: "graph occurrence returned-row accounting overflowed",
3222                })
3223        })?;
3224        let work = meter.finish(returned_rows)?;
3225        Ok(RepositoryGraphReadPages { pages, work })
3226    }
3227
3228    /// Load bounded coverage rows for one exact project or path scope.
3229    ///
3230    /// # Errors
3231    ///
3232    /// Returns an error for invalid limits, unavailable publication state,
3233    /// project mismatch, `SQLite` failure, or any inconsistent coverage row.
3234    pub fn repository_graph_coverage(
3235        &self,
3236        project: ProjectInstanceId,
3237        scope: &CoverageScope,
3238        limit: u32,
3239    ) -> DbResult<RepositoryGraphPage<CoverageRecord>> {
3240        let limit_plus_one = validated_limit_plus_one(
3241            limit,
3242            GraphLimits::MAX_ROWS,
3243            "graph rows must be nonzero and within the product ceiling",
3244        )?;
3245        let Some(generation) = self.repository_graph_generation()? else {
3246            return Ok(empty_page());
3247        };
3248        if !verify_project_identity(&self.connection, project)? {
3249            return Ok(empty_page());
3250        }
3251        let (scope_kind, scope_path) = coverage_scope_parts(scope);
3252        let raw = {
3253            let mut statement = self.connection.prepare_cached(
3254                "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
3255                        relation_kind, state, total, covered, omitted, reason, reached_limit,
3256                        NULL, NULL
3257                   FROM graph_coverage
3258                  WHERE project_instance_id = ?1
3259                    AND scope_kind = ?2 AND scope_path IS ?3
3260                  ORDER BY relation_scope, relation_kind, state, id
3261                  LIMIT ?4",
3262            )?;
3263            let mut rows = statement.query(params![
3264                &project.as_bytes()[..],
3265                scope_kind,
3266                scope_path,
3267                limit_plus_one
3268            ])?;
3269            let mut collected = Vec::new();
3270            while let Some(row) = rows.next()? {
3271                collected.push(coverage_row(row)?);
3272            }
3273            collected
3274        };
3275        page_from_raw(raw, limit, |row| {
3276            coverage_from_row(row, project, generation)
3277        })
3278    }
3279
3280    /// Load current coverage for a bounded unique set of exact repository paths.
3281    ///
3282    /// The complete path set is bound to one prepared statement so service
3283    /// traversal never performs one coverage query per returned node.
3284    ///
3285    /// # Errors
3286    ///
3287    /// Returns an error for duplicate or oversized path sets, cancellation,
3288    /// unavailable publication state, project mismatch, `SQLite` failure, or
3289    /// invalid persisted coverage.
3290    pub fn repository_graph_path_coverage(
3291        &self,
3292        project: ProjectInstanceId,
3293        paths: &[RepositoryNodePath],
3294        control: Option<&IndexWorkControl>,
3295    ) -> DbResult<RepositoryGraphPage<CoverageRecord>> {
3296        validate_path_coverage_request(paths)?;
3297        if paths.is_empty() {
3298            return Ok(empty_page());
3299        }
3300        let Some(generation) = self.repository_graph_generation()? else {
3301            return Ok(empty_page());
3302        };
3303        if !verify_project_identity(&self.connection, project)? {
3304            return Ok(empty_page());
3305        }
3306        Ok(self
3307            .repository_graph_path_coverage_bounded(
3308                project,
3309                generation,
3310                paths,
3311                maximum_repository_graph_read_budget()?,
3312                control,
3313            )?
3314            .page)
3315    }
3316
3317    /// Load current exact-path coverage under one stable graph read envelope.
3318    ///
3319    /// # Errors
3320    ///
3321    /// Returns the same fail-closed errors as
3322    /// [`Self::repository_graph_path_coverage`], rejects a stale project or
3323    /// generation and any returned-row, decoded-byte, or coverage-path
3324    /// hydration overrun, and never returns a partial batch. The raw truncation
3325    /// sentinel is included in decoded and path work.
3326    pub fn repository_graph_path_coverage_bounded(
3327        &self,
3328        project: ProjectInstanceId,
3329        generation: IndexGeneration,
3330        paths: &[RepositoryNodePath],
3331        budget: RepositoryGraphReadBudget,
3332        control: Option<&IndexWorkControl>,
3333    ) -> DbResult<RepositoryGraphReadPage<CoverageRecord>> {
3334        validate_path_coverage_request(paths)?;
3335        self.require_repository_graph_snapshot(project, generation)?;
3336        let mut meter = RepositoryGraphReadMeter::new(budget, paths.len())?;
3337        if paths.is_empty() {
3338            return Ok(RepositoryGraphReadPage {
3339                page: empty_page(),
3340                work: meter.finish(0)?,
3341            });
3342        }
3343
3344        let sql = path_coverage_sql(paths.len());
3345        let mut bindings = Vec::with_capacity(paths.len() + 3);
3346        bindings.push(Value::Blob(project.as_bytes().to_vec()));
3347        bindings.push(Value::Text("path".to_string()));
3348        bindings.extend(
3349            paths
3350                .iter()
3351                .map(|path| Value::Text(path.as_str().to_string())),
3352        );
3353        bindings.push(Value::Integer(i64::from(GraphLimits::MAX_ROWS) + 1));
3354        let raw = with_sqlite_read_progress(
3355            &self.connection,
3356            control,
3357            IndexWorkStage::RepositoryTraversal,
3358            || {
3359                let mut statement = self.connection.prepare(&sql)?;
3360                let mut rows = statement.query(params_from_iter(bindings.iter()))?;
3361                let mut collected = Vec::new();
3362                while let Some(row) = rows.next()? {
3363                    let coverage = coverage_row(row)?;
3364                    meter.record_decoded_bytes(coverage_row_decoded_bytes(&coverage)?)?;
3365                    collected.push(coverage);
3366                }
3367                Ok(collected)
3368            },
3369        )?;
3370        let page = page_from_raw(raw, GraphLimits::MAX_ROWS, |row| {
3371            let coverage = coverage_from_row(row, project, generation)?;
3372            if let CoverageScope::Path { path } = coverage.scope() {
3373                meter.record_hydrated_path(path.as_str())?;
3374            }
3375            Ok(coverage)
3376        })?;
3377        let work = meter.finish(page.rows.len())?;
3378        Ok(RepositoryGraphReadPage { page, work })
3379    }
3380
3381    /// Discover one bounded page of current coverage with optional typed filters.
3382    ///
3383    /// # Errors
3384    ///
3385    /// Returns an error for invalid bounds, unavailable publication state,
3386    /// project mismatch, `SQLite` failure, or invalid persisted coverage or
3387    /// parser provenance.
3388    pub fn repository_coverage_page(
3389        &self,
3390        project: ProjectInstanceId,
3391        query: &RepositoryCoverageQuery,
3392    ) -> DbResult<RepositoryGraphPage<RepositoryCoverageRow>> {
3393        self.repository_coverage_page_controlled(project, query, None)
3394    }
3395
3396    /// Discover coverage through the shared cancellation and deadline boundary.
3397    ///
3398    /// # Errors
3399    ///
3400    /// Returns the same errors as [`Self::repository_coverage_page`] plus typed
3401    /// cancellation or deadline failure while `SQLite` is scanning candidates.
3402    pub fn repository_coverage_page_controlled(
3403        &self,
3404        project: ProjectInstanceId,
3405        query: &RepositoryCoverageQuery,
3406        control: Option<&IndexWorkControl>,
3407    ) -> DbResult<RepositoryGraphPage<RepositoryCoverageRow>> {
3408        let limit_plus_one = validated_limit_plus_one(
3409            query.limit,
3410            GraphLimits::MAX_ROWS,
3411            "coverage rows must be nonzero and within the product ceiling",
3412        )?;
3413        if query.start_index >= GraphLimits::MAX_ROWS {
3414            return Err(GraphContractError::InvalidLimits {
3415                reason: "coverage start index is at or above the product ceiling",
3416            }
3417            .into());
3418        }
3419        let Some(generation) = self.repository_graph_generation()? else {
3420            return Ok(empty_page());
3421        };
3422        if !verify_project_identity(&self.connection, project)? {
3423            return Ok(empty_page());
3424        }
3425
3426        let provenance_driven = query.parser.is_some() || query.provider.is_some();
3427        let path_prefix = query.path_prefix.as_deref().filter(|prefix| *prefix != ".");
3428        let mut sql = String::from(
3429            "SELECT coverage.project_instance_id, coverage.scope_kind,
3430                    coverage.scope_path, coverage.relation_scope,
3431                    coverage.relation_kind, coverage.state, coverage.total,
3432                    coverage.covered, coverage.omitted, coverage.reason,
3433                    coverage.reached_limit, metadata.source_parser,
3434                    metadata.fact_parser
3435               FROM ",
3436        );
3437        if provenance_driven {
3438            sql.push_str(
3439                "source_parse_metadata AS metadata
3440                 CROSS JOIN graph_coverage AS coverage
3441                   ON coverage.scope_kind = 'path'
3442                  AND coverage.scope_path = metadata.path",
3443            );
3444        } else {
3445            sql.push_str(
3446                "graph_coverage AS coverage
3447                 LEFT JOIN source_parse_metadata AS metadata
3448                   ON metadata.path = coverage.scope_path",
3449            );
3450        }
3451        sql.push_str(" WHERE coverage.project_instance_id = ?");
3452        let mut values = vec![Value::Blob(project.as_bytes().to_vec())];
3453
3454        if let Some(prefix) = path_prefix {
3455            sql.push_str(
3456                " AND coverage.scope_kind = 'path'
3457                  AND coverage.scope_path >= ? AND coverage.scope_path < ?
3458                  AND (coverage.scope_path = ? OR coverage.scope_path >= ?)",
3459            );
3460            values.push(Value::Text(prefix.to_string()));
3461            values.push(Value::Text(format!("{prefix}0")));
3462            values.push(Value::Text(prefix.to_string()));
3463            values.push(Value::Text(format!("{prefix}/")));
3464        }
3465        if let Some(parser) = query.parser {
3466            sql.push_str(" AND metadata.source_parser = ?");
3467            values.push(Value::Text(parser.to_string()));
3468        }
3469        if let Some(provider) = query.provider {
3470            sql.push_str(" AND metadata.fact_parser = ?");
3471            values.push(Value::Text(provider.to_string()));
3472        }
3473        if let Some(relation) = query.relation {
3474            let (scope, kind) = relation_parts(relation);
3475            sql.push_str(" AND coverage.relation_scope = ? AND coverage.relation_kind = ?");
3476            values.push(Value::Text(scope.to_string()));
3477            values.push(Value::Text(kind.to_string()));
3478        }
3479        if let Some(state) = query.state {
3480            match state {
3481                CoverageState::NoCandidates => {
3482                    sql.push_str(
3483                        " AND coverage.state = 'complete' AND coverage.total = 0 AND coverage.relation_scope = 'extended' AND coverage.relation_kind = 'documents'",
3484                    );
3485                }
3486                CoverageState::Complete => {
3487                    sql.push_str(
3488                        " AND coverage.state = 'complete' AND NOT (coverage.total = 0 AND coverage.relation_scope IS 'extended' AND coverage.relation_kind IS 'documents')",
3489                    );
3490                }
3491                _ => {
3492                    sql.push_str(" AND coverage.state = ?");
3493                    values.push(Value::Text(coverage_state_name(state).to_string()));
3494                }
3495            }
3496        }
3497        if let Some(reason) = query.reason.as_deref() {
3498            sql.push_str(" AND coverage.reason = ?");
3499            values.push(Value::Text(reason.to_string()));
3500        }
3501
3502        if matches!(query.state, Some(CoverageState::NoCandidates)) {
3503            sql.push_str(
3504                " ORDER BY coverage.relation_scope, coverage.relation_kind,
3505                          coverage.state, coverage.id",
3506            );
3507        } else if provenance_driven {
3508            sql.push_str(" ORDER BY metadata.path, coverage.id");
3509        } else if path_prefix.is_some() {
3510            sql.push_str(
3511                " ORDER BY coverage.scope_path, coverage.relation_scope,
3512                          coverage.relation_kind, coverage.state, coverage.id",
3513            );
3514        } else if query.relation.is_some() {
3515            sql.push_str(
3516                " ORDER BY coverage.relation_scope, coverage.relation_kind,
3517                          coverage.state, coverage.id",
3518            );
3519        } else if query.state.is_some() {
3520            sql.push_str(" ORDER BY coverage.state, coverage.scope_path, coverage.id");
3521        } else if query.reason.is_some() {
3522            sql.push_str(" ORDER BY coverage.reason, coverage.scope_path, coverage.id");
3523        } else {
3524            sql.push_str(
3525                " ORDER BY coverage.scope_kind, coverage.scope_path,
3526                          coverage.relation_scope, coverage.relation_kind,
3527                          coverage.state, coverage.id",
3528            );
3529        }
3530        sql.push_str(" LIMIT ? OFFSET ?");
3531        values.push(Value::Integer(limit_plus_one));
3532        values.push(Value::Integer(i64::from(query.start_index)));
3533
3534        let raw = with_sqlite_read_progress(
3535            &self.connection,
3536            control,
3537            IndexWorkStage::RepositoryTraversal,
3538            || {
3539                let mut statement = self.connection.prepare_cached(&sql)?;
3540                let mut rows = statement.query(params_from_iter(values.iter()))?;
3541                let mut collected = Vec::new();
3542                while let Some(row) = rows.next()? {
3543                    collected.push(coverage_row(row)?);
3544                }
3545                Ok(collected)
3546            },
3547        )?;
3548        page_from_raw(raw, query.limit, |row| {
3549            coverage_discovery_from_row(row, project, generation)
3550        })
3551    }
3552
3553    /// Load bounded typed identity rejections for the current graph generation.
3554    ///
3555    /// # Errors
3556    ///
3557    /// Returns an error for invalid bounds or paths, unavailable publication
3558    /// state, project mismatch, cancellation, `SQLite` failure, or corrupt
3559    /// persisted rejection data.
3560    pub fn repository_graph_identity_rejections(
3561        &self,
3562        project: ProjectInstanceId,
3563        paths: &[RepositoryNodePath],
3564        limit: u32,
3565        control: Option<&IndexWorkControl>,
3566    ) -> DbResult<Vec<GraphIdentityRejection>> {
3567        if limit == 0 || limit > GraphLimits::MAX_ROWS {
3568            return Err(GraphContractError::InvalidLimits {
3569                reason: "identity rejection limit must be nonzero and within the product ceiling",
3570            }
3571            .into());
3572        }
3573        let Some(generation) = self.repository_graph_generation()? else {
3574            return Ok(Vec::new());
3575        };
3576        if !verify_project_identity(&self.connection, project)? {
3577            return Ok(Vec::new());
3578        }
3579        if paths.is_empty() {
3580            return Ok(Vec::new());
3581        }
3582        if paths.len() > GraphLimits::MAX_ROWS as usize {
3583            return Err(GraphContractError::InvalidLimits {
3584                reason: "identity rejection path set exceeds the product ceiling",
3585            }
3586            .into());
3587        }
3588        let placeholders = numbered_placeholders(4, paths.len());
3589        let sql = format!(
3590            "SELECT file_path, start_line, start_column, end_line, end_column,
3591             parser, field, reason, fact_index
3592               FROM graph_identity_rejections
3593              WHERE project_instance_id = ?1 AND generation = ?2
3594                AND file_path IN ({placeholders})
3595               ORDER BY file_path, start_line, start_column, end_line, end_column,
3596                        parser, field, reason, fact_index, id
3597              LIMIT ?3"
3598        );
3599        let mut values = Vec::with_capacity(paths.len() + 3);
3600        values.push(Value::Blob(project.as_bytes().to_vec()));
3601        values.push(Value::Integer(i64::try_from(generation.get()).map_err(
3602            |_error| GraphContractError::InvalidLimits {
3603                reason: "identity rejection generation exceeded SQLite integer range",
3604            },
3605        )?));
3606        values.push(Value::Integer(i64::from(limit)));
3607        values.extend(
3608            paths
3609                .iter()
3610                .map(|path| Value::Text(path.as_str().to_string())),
3611        );
3612        with_sqlite_read_progress(
3613            &self.connection,
3614            control,
3615            IndexWorkStage::RepositoryTraversal,
3616            || {
3617                let mut statement = self.connection.prepare_cached(&sql)?;
3618                let mut rows = statement.query(params_from_iter(values.iter()))?;
3619                let mut rejections = Vec::new();
3620                while let Some(row) = rows.next()? {
3621                    rejections.push(graph_identity_rejection_from_row(row)?);
3622                }
3623                Ok(rejections)
3624            },
3625        )
3626    }
3627
3628    /// Return the complete generation used to reconstruct normalized graph rows.
3629    ///
3630    /// # Errors
3631    ///
3632    /// Returns an error when publication metadata is incomplete or disagrees
3633    /// with the project identity's active graph generation.
3634    pub fn repository_graph_generation(&self) -> DbResult<Option<IndexGeneration>> {
3635        let Some(publication) = self.index_publication()? else {
3636            return Ok(None);
3637        };
3638        if publication.state != IndexPublicationState::Complete
3639            || publication.generation == IndexGeneration::ZERO
3640        {
3641            return Err(DbError::GraphPublicationUnavailable);
3642        }
3643        let Some(graph_generation) = load_graph_generation(&self.connection)? else {
3644            return Ok(None);
3645        };
3646        if graph_generation == IndexGeneration::ZERO || graph_generation != publication.generation {
3647            return Err(DbError::GraphRowShape {
3648                table: "project_identity",
3649                reason: "typed graph generation does not match complete publication",
3650            });
3651        }
3652        Ok(Some(graph_generation))
3653    }
3654
3655    /// Require one exact published graph snapshot for cursor-owned hydration.
3656    pub(crate) fn require_repository_graph_snapshot(
3657        &self,
3658        project: ProjectInstanceId,
3659        generation: IndexGeneration,
3660    ) -> DbResult<()> {
3661        require_bound_project_identity(&self.connection, project)?;
3662        let current = self
3663            .repository_graph_generation()?
3664            .ok_or(DbError::GraphPublicationUnavailable)?;
3665        if current != generation {
3666            return Err(GraphContractError::InvalidLimits {
3667                reason: "graph hydration generation does not match the current publication",
3668            }
3669            .into());
3670        }
3671        Ok(())
3672    }
3673
3674    /// Collect one indexed relation page by source or target key.
3675    fn collect_relation_rows_by_key(
3676        &self,
3677        key_column: &'static str,
3678        key: &[u8; 32],
3679        limit_plus_one: i64,
3680    ) -> DbResult<Vec<RelationRow>> {
3681        let sql = match key_column {
3682            "source_entity_key" => {
3683                "SELECT relation_key, project_instance_id, canonical_identity,
3684                        source_entity_key, relation_scope, relation_kind,
3685                        resolution_status, target_entity_key, reference_text,
3686                        candidate_count, document_unresolved_reason, confidence, completeness
3687                   FROM graph_relations
3688                  WHERE source_entity_key = ?1
3689                  ORDER BY relation_scope, relation_kind, relation_key
3690                  LIMIT ?2"
3691            }
3692            "target_entity_key" => {
3693                "SELECT relation_key, project_instance_id, canonical_identity,
3694                        source_entity_key, relation_scope, relation_kind,
3695                        resolution_status, target_entity_key, reference_text,
3696                        candidate_count, document_unresolved_reason, confidence, completeness
3697                   FROM graph_relations
3698                  WHERE target_entity_key = ?1
3699                  ORDER BY relation_scope, relation_kind, relation_key
3700                  LIMIT ?2"
3701            }
3702            _ => {
3703                return Err(DbError::GraphRowShape {
3704                    table: "graph_relations",
3705                    reason: "unsupported internal relation lookup",
3706                });
3707            }
3708        };
3709        let mut statement = self.connection.prepare_cached(sql)?;
3710        collect_relation_rows(statement.query(params![&key[..], limit_plus_one])?)
3711    }
3712}
3713
3714/// Decode the complete normalized graph from one private `SQLite` backup.
3715pub(crate) fn capture_derived_graph(
3716    connection: &Connection,
3717    project: ProjectInstanceId,
3718    generation: IndexGeneration,
3719    budget: &mut SnapshotBudget,
3720) -> DbResult<CapturedGraph> {
3721    let mut entities = Vec::new();
3722    let mut entities_by_key = HashMap::new();
3723    {
3724        let mut statement = connection.prepare(
3725            "SELECT entity_key, project_instance_id, canonical_identity, entity_kind,
3726                    repository_path, package_manager, package_name, manifest_path,
3727                    symbol_name, symbol_kind, symbol_parent, symbol_signature,
3728                    external_system, external_identity
3729               FROM graph_entities
3730              WHERE project_instance_id = ?1
3731              ORDER BY entity_key",
3732        )?;
3733        let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3734        while let Some(row) = rows.next()? {
3735            let raw = entity_row(row)?;
3736            budget.admit(entity_row_decoded_bytes(&raw)?)?;
3737            let entity = entity_from_row(raw, project, generation)?;
3738            let key = entity.key().digest_bytes()?;
3739            if entities_by_key.insert(key, entity.clone()).is_some() {
3740                return Err(DbError::DerivedSnapshotInvalid {
3741                    reason: "private capture contains a duplicate entity",
3742                });
3743            }
3744            entities.push(entity);
3745        }
3746    }
3747
3748    let mut relations = Vec::new();
3749    let mut relations_by_key = HashMap::new();
3750    let mut document_unresolved_reasons = Vec::new();
3751    {
3752        let mut statement = connection.prepare(
3753            "SELECT relation_key, project_instance_id, canonical_identity,
3754                    source_entity_key, relation_scope, relation_kind,
3755                    resolution_status, target_entity_key, reference_text,
3756                    candidate_count, document_unresolved_reason, confidence, completeness
3757               FROM graph_relations
3758              WHERE project_instance_id = ?1
3759              ORDER BY relation_key",
3760        )?;
3761        let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3762        while let Some(row) = rows.next()? {
3763            let raw = relation_row(row)?;
3764            budget.admit(relation_row_decoded_bytes(&raw)?)?;
3765            let document_unresolved_reason = document_unresolved_reason_from_row(&raw)?;
3766            let relation = relation_from_row(&entities_by_key, raw, project, generation)?;
3767            let key = relation.key().digest_bytes()?;
3768            if relations_by_key.insert(key, relation.clone()).is_some() {
3769                return Err(DbError::DerivedSnapshotInvalid {
3770                    reason: "private capture contains a duplicate relation",
3771                });
3772            }
3773            if let Some(reason) = document_unresolved_reason {
3774                document_unresolved_reasons.push((key, reason));
3775            }
3776            relations.push(relation);
3777        }
3778    }
3779
3780    let mut occurrences = Vec::new();
3781    {
3782        let mut statement = connection.prepare(
3783            "SELECT relation_key, file_path, start_line, start_column, end_line, end_column
3784               FROM graph_relation_occurrences
3785              ORDER BY relation_key, file_path, start_line, start_column, end_line, end_column",
3786        )?;
3787        let mut rows = statement.query([])?;
3788        while let Some(row) = rows.next()? {
3789            let raw = occurrence_row(row)?;
3790            budget.admit(occurrence_row_decoded_bytes(&raw)?)?;
3791            let key = fixed_bytes::<32>(
3792                "graph_relation_occurrences.relation_key",
3793                raw.relation.clone(),
3794            )?;
3795            let relation = relations_by_key
3796                .get(&key)
3797                .ok_or(DbError::DerivedSnapshotInvalid {
3798                    reason: "private capture occurrence owner is absent",
3799                })?;
3800            occurrences.push(occurrence_from_row(raw, relation, generation)?);
3801        }
3802    }
3803
3804    let mut coverage = Vec::new();
3805    {
3806        let mut statement = connection.prepare(
3807            "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
3808                    relation_kind, state, total, covered, omitted, reason,
3809                    reached_limit, NULL, NULL
3810               FROM graph_coverage
3811              WHERE project_instance_id = ?1
3812              ORDER BY scope_kind, scope_path, relation_scope, relation_kind, state, id",
3813        )?;
3814        let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3815        while let Some(row) = rows.next()? {
3816            let raw = coverage_row(row)?;
3817            budget.admit(coverage_row_decoded_bytes(&raw)?)?;
3818            coverage.push(coverage_from_row(raw, project, generation)?);
3819        }
3820    }
3821
3822    let mut entity_exports = Vec::new();
3823    {
3824        let mut statement = connection.prepare(
3825            "SELECT key.project_instance_id, key.resolution_domain, key.key_digest,
3826                    key.canonical_identity, export.entity_key
3827               FROM graph_entity_exports AS export
3828               JOIN graph_resolution_keys AS key
3829                 ON key.project_instance_id = export.project_instance_id
3830                AND key.resolution_domain = export.resolution_domain
3831                AND key.key_digest = export.key_digest
3832              WHERE export.project_instance_id = ?1
3833              ORDER BY export.entity_key, key.resolution_domain, key.canonical_identity",
3834        )?;
3835        let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3836        while let Some(row) = rows.next()? {
3837            let key = resolution_key_from_row(row, project)?;
3838            let owner =
3839                fixed_bytes::<32>("graph_entity_exports.entity_key", row.get::<_, Vec<u8>>(4)?)?;
3840            budget.admit(decoded_payload_bytes(
3841                [
3842                    key.canonical_identity().len(),
3843                    key.digest_bytes().len(),
3844                    owner.len(),
3845                ],
3846                16,
3847            )?)?;
3848            entity_exports.push((owner, key));
3849        }
3850    }
3851
3852    let mut relation_dependencies = Vec::new();
3853    {
3854        let mut statement = connection.prepare(
3855            "SELECT key.project_instance_id, key.resolution_domain, key.key_digest,
3856                    key.canonical_identity, dependency.relation_key
3857               FROM graph_relation_dependencies AS dependency
3858               JOIN graph_resolution_keys AS key
3859                 ON key.project_instance_id = dependency.project_instance_id
3860                AND key.resolution_domain = dependency.resolution_domain
3861                AND key.key_digest = dependency.key_digest
3862              WHERE dependency.project_instance_id = ?1
3863              ORDER BY dependency.relation_key, key.resolution_domain, key.canonical_identity",
3864        )?;
3865        let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3866        while let Some(row) = rows.next()? {
3867            let key = resolution_key_from_row(row, project)?;
3868            let owner = fixed_bytes::<32>(
3869                "graph_relation_dependencies.relation_key",
3870                row.get::<_, Vec<u8>>(4)?,
3871            )?;
3872            budget.admit(decoded_payload_bytes(
3873                [
3874                    key.canonical_identity().len(),
3875                    key.digest_bytes().len(),
3876                    owner.len(),
3877                ],
3878                16,
3879            )?)?;
3880            relation_dependencies.push((owner, key));
3881        }
3882    }
3883
3884    Ok(CapturedGraph {
3885        file_classifications: Vec::new(),
3886        entities,
3887        relations,
3888        document_unresolved_reasons,
3889        occurrences,
3890        coverage,
3891        entity_exports,
3892        relation_dependencies,
3893    })
3894}
3895
3896impl AtlasStore {
3897    /// Return whether an existing file is an exact disposable stage for this project.
3898    ///
3899    /// This intentionally validates only staging ownership metadata because disposable
3900    /// stores omit normal read indexes and are not ordinary `ProjectAtlas` databases.
3901    ///
3902    /// # Errors
3903    ///
3904    /// Returns an error when the file cannot be opened read-only or its ownership
3905    /// metadata cannot be read.
3906    pub fn repository_graph_staging_belongs_to(
3907        path: &Path,
3908        root: &Path,
3909        project: ProjectInstanceId,
3910    ) -> DbResult<bool> {
3911        let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
3912        let marker = connection
3913            .query_row(
3914                "SELECT value FROM metadata WHERE key = ?1",
3915                [GRAPH_STAGING_MARKER_KEY],
3916                |row| row.get::<_, String>(0),
3917            )
3918            .optional()?;
3919        let schema_version = connection
3920            .query_row(
3921                "SELECT value FROM metadata WHERE key = ?1",
3922                [schema::SCHEMA_VERSION_KEY],
3923                |row| row.get::<_, String>(0),
3924            )
3925            .optional()?
3926            .and_then(|value| value.parse::<i64>().ok());
3927        let Some(schema_version) = schema_version else {
3928            return Ok(false);
3929        };
3930        let expected_root = projectatlas_core::CanonicalProjectRoot::from_path(root)?;
3931        let roots_match = match schema_version {
3932            schema::SCHEMA_VERSION => {
3933                load_project_root_identity(&connection)?.is_some_and(|stored_root| {
3934                    prove_existing_root_equivalence(expected_root.as_path(), stored_root.as_path())
3935                        .is_ok()
3936                })
3937            }
3938            // Predecessor stages intentionally lack native identity. The
3939            // staging marker and project identity checked below are their
3940            // staging-specific ownership proof; general predecessor admission
3941            // remains strict about legacy root authority.
3942            schema::CANONICAL_ROOT_PREDECESSOR_SCHEMA_VERSION => true,
3943            _ => false,
3944        };
3945        Ok(roots_match
3946            && load_project_identity(&connection)? == Some(project)
3947            && marker.as_deref() == Some(GRAPH_STAGING_MARKER_VALUE))
3948    }
3949
3950    /// Create a new disposable graph staging store with the selected project identity.
3951    ///
3952    /// # Errors
3953    ///
3954    /// Returns an error when the path already exists, schema initialization or
3955    /// root binding fails, or the selected identity cannot be stored.
3956    pub fn create_repository_graph_staging(
3957        path: &Path,
3958        root: &Path,
3959        project: ProjectInstanceId,
3960    ) -> DbResult<Self> {
3961        if path.exists() {
3962            return Err(DbError::GraphRowShape {
3963                table: "repository graph staging database",
3964                reason: "staging path already exists",
3965            });
3966        }
3967        let mut store = Self::open_for_project(path, root)?;
3968        set_project_identity(&store.connection, project)?;
3969        store.connection.execute(
3970            "INSERT INTO metadata(key, value) VALUES (?1, ?2)
3971             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
3972            params![GRAPH_STAGING_MARKER_KEY, GRAPH_STAGING_MARKER_VALUE],
3973        )?;
3974        drop_graph_rebuildable_indexes(&store.connection)?;
3975        store.checkpoint_repository_graph_staging()?;
3976        store.validated_project_instance_id = Some(project);
3977        Ok(store)
3978    }
3979
3980    /// Checkpoint and truncate a completed disposable graph staging WAL.
3981    ///
3982    /// # Errors
3983    ///
3984    /// Returns an error when another connection keeps the staging WAL busy or
3985    /// `SQLite` cannot complete the checkpoint.
3986    pub fn checkpoint_repository_graph_staging(&self) -> DbResult<()> {
3987        let (busy, _log_frames, _checkpointed_frames) =
3988            self.connection
3989                .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
3990                    Ok((
3991                        row.get::<_, i64>(0)?,
3992                        row.get::<_, i64>(1)?,
3993                        row.get::<_, i64>(2)?,
3994                    ))
3995                })?;
3996        if busy != 0 {
3997            return Err(DbError::GraphRowShape {
3998                table: "repository graph staging database",
3999                reason: "WAL checkpoint remained busy",
4000            });
4001        }
4002        Ok(())
4003    }
4004
4005    /// Begin one disposable typed repository-graph staging transaction.
4006    ///
4007    /// # Errors
4008    ///
4009    /// Returns an error when the store belongs to another project, the generation
4010    /// is zero, or `SQLite` cannot begin or clear the staging transaction.
4011    pub fn begin_repository_graph_staging(
4012        &mut self,
4013        project: ProjectInstanceId,
4014        generation: IndexGeneration,
4015    ) -> DbResult<RepositoryGraphStagingGuard<'_>> {
4016        if generation == IndexGeneration::ZERO {
4017            return Err(GraphContractError::InvalidGeneration.into());
4018        }
4019        let transaction = self.connection.transaction()?;
4020        require_bound_project_identity(&transaction, project)?;
4021        transaction.execute("DELETE FROM graph_resolution_keys", [])?;
4022        transaction.execute("DELETE FROM graph_coverage", [])?;
4023        transaction.execute("DELETE FROM graph_identity_rejections", [])?;
4024        transaction.execute("DELETE FROM graph_relations", [])?;
4025        transaction.execute("DELETE FROM graph_entities", [])?;
4026        Ok(RepositoryGraphStagingGuard {
4027            transaction,
4028            project,
4029            generation,
4030        })
4031    }
4032}
4033
4034impl RepositoryGraphStagingGuard<'_> {
4035    /// Append borrowed entity rows without cloning the staged domain objects.
4036    ///
4037    /// # Errors
4038    ///
4039    /// Returns an error for foreign project or generation rows, stable-key
4040    /// collisions, or `SQLite` failures.
4041    pub fn append_entity_refs(&mut self, entities: &[&GraphEntity]) -> DbResult<()> {
4042        if entities.iter().any(|entity| {
4043            entity.key().project() != self.project || entity.generation() != self.generation
4044        }) {
4045            return Err(GraphContractError::GenerationMismatch {
4046                context: "repository graph staging entities",
4047            }
4048            .into());
4049        }
4050        insert_entities(&self.transaction, self.project, entities.iter().copied())
4051    }
4052
4053    /// Append one fully validated graph and resolution-key batch.
4054    ///
4055    /// # Errors
4056    ///
4057    /// Returns an error for foreign project or generation rows, invalid owner
4058    /// bindings, stable-key collisions, or `SQLite` failures.
4059    #[allow(clippy::too_many_arguments)]
4060    pub fn append_batch(
4061        &mut self,
4062        entities: &[GraphEntity],
4063        relations: &[LogicalRelation],
4064        occurrences: &[RelationOccurrence],
4065        coverage: &[CoverageRecord],
4066        entity_exports: &[EntityResolutionKey],
4067        relation_dependencies: &[RelationDependencyKey],
4068    ) -> DbResult<()> {
4069        validate_graph_batch(
4070            self.project,
4071            self.generation,
4072            entities,
4073            relations,
4074            occurrences,
4075            coverage,
4076        )?;
4077        validate_resolution_key_batch(self.project, entity_exports, relation_dependencies)?;
4078        insert_graph_batch(
4079            &self.transaction,
4080            self.project,
4081            entities,
4082            relations,
4083            occurrences,
4084            coverage,
4085        )?;
4086        insert_resolution_key_batch(
4087            &self.transaction,
4088            self.project,
4089            entity_exports,
4090            relation_dependencies,
4091        )
4092    }
4093
4094    /// Attach closed reasons to staged unresolved `documents` relations.
4095    ///
4096    /// # Errors
4097    ///
4098    /// Returns an error without partial mutation for duplicate keys,
4099    /// incompatible staged relations, or `SQLite` failures.
4100    pub fn set_document_unresolved_reasons(
4101        &mut self,
4102        reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4103    ) -> DbResult<()> {
4104        self.set_document_unresolved_reasons_with_control(reasons, None)
4105    }
4106
4107    /// Attach staged document reasons while observing publication cancellation.
4108    ///
4109    /// # Errors
4110    ///
4111    /// Returns an error without partial mutation when cancellation, duplicate
4112    /// keys, incompatible staged relations, or `SQLite` failures occur.
4113    pub fn set_document_unresolved_reasons_controlled(
4114        &mut self,
4115        reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4116        control: &IndexWorkControl,
4117    ) -> DbResult<()> {
4118        self.set_document_unresolved_reasons_with_control(reasons, Some(control))
4119    }
4120
4121    /// Apply staged reasons through one savepoint with optional cancellation checks.
4122    fn set_document_unresolved_reasons_with_control(
4123        &mut self,
4124        reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4125        control: Option<&IndexWorkControl>,
4126    ) -> DbResult<()> {
4127        let savepoint = self.transaction.savepoint()?;
4128        write_document_unresolved_reasons(&savepoint, reasons, control)?;
4129        savepoint.commit()?;
4130        Ok(())
4131    }
4132
4133    /// Commit the staged graph and bind it to the requested generation.
4134    ///
4135    /// # Errors
4136    ///
4137    /// Returns an error when `SQLite` cannot persist the generation or commit.
4138    pub fn complete(self) -> DbResult<()> {
4139        validate_complete_document_unresolved_reasons(&self.transaction)?;
4140        set_graph_generation(&self.transaction, self.generation)?;
4141        self.transaction.commit()?;
4142        Ok(())
4143    }
4144}
4145
4146/// Normalized graph columns copied from one typed disposable staging database.
4147const GRAPH_STAGE_COPY_TABLES: &[(&str, &str)] = &[
4148    (
4149        "graph_entities",
4150        "entity_key, project_instance_id, canonical_identity, entity_kind, repository_path, \
4151         package_manager, package_name, manifest_path, symbol_name, symbol_kind, symbol_parent, \
4152         symbol_signature, external_system, external_identity",
4153    ),
4154    (
4155        "graph_relations",
4156        "relation_key, project_instance_id, canonical_identity, source_entity_key, \
4157         relation_scope, relation_kind, resolution_status, target_entity_key, reference_text, \
4158         candidate_count, document_unresolved_reason, confidence, completeness",
4159    ),
4160    (
4161        "graph_relation_occurrences",
4162        "relation_key, file_path, start_line, start_column, end_line, end_column",
4163    ),
4164    (
4165        "graph_coverage",
4166        "project_instance_id, scope_kind, scope_path, relation_scope, relation_kind, state, \
4167         total, covered, omitted, reason, reached_limit",
4168    ),
4169    (
4170        "graph_resolution_keys",
4171        "project_instance_id, resolution_domain, key_digest, canonical_identity",
4172    ),
4173    (
4174        "graph_entity_exports",
4175        "project_instance_id, entity_key, owner_path, resolution_domain, key_digest",
4176    ),
4177    (
4178        "graph_relation_dependencies",
4179        "project_instance_id, relation_key, owner_path, resolution_domain, key_digest",
4180    ),
4181];
4182
4183/// Non-unique graph read indexes rebuilt once after a full bulk replacement.
4184const GRAPH_REBUILDABLE_INDEX_NAMES: &[&str] = &[
4185    "idx_graph_entities_path",
4186    "idx_graph_entities_package",
4187    "idx_graph_entities_manifest_path",
4188    "idx_graph_entities_symbol",
4189    "idx_graph_entities_external",
4190    "idx_graph_relations_source_kind",
4191    "idx_graph_relations_target_kind",
4192    "idx_graph_relations_kind_order",
4193    "idx_graph_relations_kind_resolution",
4194    "idx_graph_occurrences_file_span",
4195    "idx_graph_coverage_scope_state",
4196    "idx_graph_coverage_scope_order",
4197    "idx_graph_coverage_path",
4198    "idx_graph_coverage_relation_state",
4199    "idx_graph_coverage_discovery_state",
4200    "idx_graph_coverage_discovery_reason",
4201    "idx_graph_entity_exports_key",
4202    "idx_graph_entity_exports_owner",
4203    "idx_graph_relation_dependencies_key",
4204    "idx_graph_relation_dependencies_owner",
4205];
4206
4207/// Drop only the known derived read indexes around one bulk graph load.
4208fn drop_graph_rebuildable_indexes(connection: &Connection) -> DbResult<()> {
4209    for name in GRAPH_REBUILDABLE_INDEX_NAMES {
4210        connection.execute(&format!("DROP INDEX IF EXISTS {name}"), [])?;
4211    }
4212    Ok(())
4213}
4214
4215/// Capture exact live index definitions before a transactional bulk graph load.
4216fn graph_rebuildable_index_sql(connection: &Connection) -> DbResult<Vec<String>> {
4217    GRAPH_REBUILDABLE_INDEX_NAMES
4218        .iter()
4219        .map(|name| {
4220            connection
4221                .query_row(
4222                    "SELECT sql FROM sqlite_schema WHERE type = 'index' AND name = ?1",
4223                    [name],
4224                    |row| row.get::<_, String>(0),
4225                )
4226                .optional()?
4227                .ok_or(DbError::GraphRowShape {
4228                    table: "repository graph indexes",
4229                    reason: "required rebuildable index is missing",
4230                })
4231        })
4232        .collect()
4233}
4234/// Maximum rows copied through one bounded multi-row `SQLite` insert.
4235const GRAPH_STAGE_COPY_ROWS: usize = 512;
4236
4237/// Insert one bounded group of decoded staging values.
4238fn insert_graph_stage_values(
4239    target: &Connection,
4240    table: &'static str,
4241    columns: &'static str,
4242    column_count: usize,
4243    values: &[Value],
4244) -> DbResult<()> {
4245    let rows = values.len() / column_count;
4246    let row = format!(
4247        "({})",
4248        std::iter::repeat_n("?", column_count)
4249            .collect::<Vec<_>>()
4250            .join(",")
4251    );
4252    let placeholders = std::iter::repeat_n(row, rows).collect::<Vec<_>>().join(",");
4253    let insert_sql = format!("INSERT INTO {table}({columns}) VALUES{placeholders}");
4254    target.execute(&insert_sql, params_from_iter(values.iter()))?;
4255    Ok(())
4256}
4257
4258/// Copy one normalized table through bounded decoded multi-row inserts.
4259fn copy_graph_stage_table(
4260    source: &Connection,
4261    target: &Connection,
4262    table: &'static str,
4263    columns: &'static str,
4264    control: Option<&IndexWorkControl>,
4265) -> DbResult<()> {
4266    let column_count = columns.split(',').count();
4267    let select_sql = format!("SELECT {columns} FROM {table}");
4268    let mut select = source.prepare(&select_sql)?;
4269    let mut rows = select.query([])?;
4270    let mut copied = 0_usize;
4271    let mut values = Vec::with_capacity(GRAPH_STAGE_COPY_ROWS.saturating_mul(column_count));
4272    while let Some(row) = rows.next()? {
4273        if copied.is_multiple_of(256)
4274            && let Some(control) = control
4275        {
4276            control.check(IndexWorkStage::Publication)?;
4277        }
4278        for column in 0..column_count {
4279            values.push(row.get::<_, Value>(column)?);
4280        }
4281        copied = copied.saturating_add(1);
4282        if copied.is_multiple_of(GRAPH_STAGE_COPY_ROWS) {
4283            insert_graph_stage_values(target, table, columns, column_count, &values)?;
4284            values.clear();
4285        }
4286    }
4287    if !values.is_empty() {
4288        insert_graph_stage_values(target, table, columns, column_count, &values)?;
4289    }
4290    Ok(())
4291}
4292
4293impl IndexPublicationGuard<'_> {
4294    /// Replace the complete graph from one typed disposable staging database.
4295    ///
4296    /// # Errors
4297    ///
4298    /// Returns an error when staging integrity, project identity, generation,
4299    /// normalized constraints, cancellation, or `SQLite` copying fails.
4300    pub fn replace_repository_graph_from_staging(
4301        &mut self,
4302        project: ProjectInstanceId,
4303        staging: &AtlasStore,
4304        control: Option<&IndexWorkControl>,
4305    ) -> DbResult<()> {
4306        let generation = self.pending_graph_generation()?;
4307        let staged_project = load_project_identity(&staging.connection)?;
4308        if staged_project != Some(project) {
4309            return Err(DbError::GraphProjectIdentityMismatch {
4310                expected: project.to_string(),
4311                found: staged_project
4312                    .map_or_else(|| "missing".to_string(), |value| value.to_string()),
4313            });
4314        }
4315        if load_graph_generation(&staging.connection)? != Some(generation) {
4316            return Err(GraphContractError::GenerationMismatch {
4317                context: "staged repository graph",
4318            }
4319            .into());
4320        }
4321        let quick_check = staging
4322            .connection
4323            .query_row("PRAGMA quick_check(1)", [], |row| row.get::<_, String>(0))?;
4324        if quick_check != "ok" {
4325            return Err(DbError::GraphRowShape {
4326                table: "repository graph staging database",
4327                reason: "quick check failed",
4328            });
4329        }
4330        let mut foreign_keys = staging.connection.prepare("PRAGMA foreign_key_check")?;
4331        if foreign_keys.query([])?.next()?.is_some() {
4332            return Err(DbError::GraphRowShape {
4333                table: "repository graph staging database",
4334                reason: "foreign key check failed",
4335            });
4336        }
4337        validate_complete_document_unresolved_reasons(&staging.connection)?;
4338
4339        let savepoint = self.store.connection.savepoint()?;
4340        require_bound_project_identity(&savepoint, project)?;
4341        prepare_graph_identity_rejections(&savepoint, project, generation, None)?;
4342        let rebuildable_indexes = graph_rebuildable_index_sql(&savepoint)?;
4343        savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
4344        savepoint.execute("DELETE FROM graph_coverage", [])?;
4345        savepoint.execute("DELETE FROM graph_relations", [])?;
4346        savepoint.execute("DELETE FROM graph_entities", [])?;
4347        drop_graph_rebuildable_indexes(&savepoint)?;
4348        for (table, columns) in GRAPH_STAGE_COPY_TABLES {
4349            copy_graph_stage_table(&staging.connection, &savepoint, table, columns, control)?;
4350        }
4351        set_graph_generation(&savepoint, generation)?;
4352        for index_sql in rebuildable_indexes {
4353            savepoint.execute_batch(&index_sql)?;
4354        }
4355        savepoint.commit()?;
4356        Ok(())
4357    }
4358
4359    /// Replace the complete normalized repository graph inside this publication.
4360    ///
4361    /// # Errors
4362    ///
4363    /// Returns an error when records do not belong to the pending generation or
4364    /// selected project, a stable-key collision is detected, or `SQLite` fails.
4365    pub fn replace_repository_graph(
4366        &mut self,
4367        project: ProjectInstanceId,
4368        entities: &[GraphEntity],
4369        relations: &[LogicalRelation],
4370        occurrences: &[RelationOccurrence],
4371        coverage: &[CoverageRecord],
4372    ) -> DbResult<()> {
4373        self.replace_repository_graph_with_resolution_keys(
4374            project,
4375            entities,
4376            relations,
4377            occurrences,
4378            coverage,
4379            &[],
4380            &[],
4381        )
4382    }
4383
4384    /// Replace the complete graph and canonical resolution-key projection atomically.
4385    ///
4386    /// Derived non-unique read indexes are rebuilt once after the bulk write,
4387    /// using their exact captured definitions. Constraints remain active, and
4388    /// the same savepoint restores both rows and indexes on failure.
4389    ///
4390    /// # Errors
4391    ///
4392    /// Returns an error when graph or key records do not belong to the pending
4393    /// generation and selected project, a stable-key collision is detected, an
4394    /// owner has no exact source path, or `SQLite` fails.
4395    #[allow(clippy::too_many_arguments)]
4396    pub fn replace_repository_graph_with_resolution_keys(
4397        &mut self,
4398        project: ProjectInstanceId,
4399        entities: &[GraphEntity],
4400        relations: &[LogicalRelation],
4401        occurrences: &[RelationOccurrence],
4402        coverage: &[CoverageRecord],
4403        entity_exports: &[EntityResolutionKey],
4404        relation_dependencies: &[RelationDependencyKey],
4405    ) -> DbResult<()> {
4406        let generation = self.pending_graph_generation()?;
4407        validate_graph_batch(
4408            project,
4409            generation,
4410            entities,
4411            relations,
4412            occurrences,
4413            coverage,
4414        )?;
4415        validate_resolution_key_batch(project, entity_exports, relation_dependencies)?;
4416        let savepoint = self.store.connection.savepoint()?;
4417        require_bound_project_identity(&savepoint, project)?;
4418        prepare_graph_identity_rejections(&savepoint, project, generation, None)?;
4419        let rebuildable_indexes = graph_rebuildable_index_sql(&savepoint)?;
4420        savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
4421        savepoint.execute("DELETE FROM graph_coverage", [])?;
4422        savepoint.execute("DELETE FROM graph_relations", [])?;
4423        savepoint.execute("DELETE FROM graph_entities", [])?;
4424        drop_graph_rebuildable_indexes(&savepoint)?;
4425        insert_graph_batch(
4426            &savepoint,
4427            project,
4428            entities,
4429            relations,
4430            occurrences,
4431            coverage,
4432        )?;
4433        insert_resolution_key_batch(&savepoint, project, entity_exports, relation_dependencies)?;
4434        set_graph_generation(&savepoint, generation)?;
4435        for index_sql in rebuildable_indexes {
4436            savepoint.execute_batch(&index_sql)?;
4437        }
4438        savepoint.commit()?;
4439        Ok(())
4440    }
4441
4442    /// Replace the normalized graph closure owned by affected repository paths.
4443    ///
4444    /// Unchanged rows stay physically untouched and are reconstructed at the next
4445    /// complete publication generation. The caller supplies the complete new
4446    /// closure for the affected paths.
4447    ///
4448    /// # Errors
4449    ///
4450    /// Returns an error when records do not belong to the pending generation or
4451    /// selected project, a stable-key collision is detected, or `SQLite` fails.
4452    pub fn replace_repository_graph_for_paths(
4453        &mut self,
4454        project: ProjectInstanceId,
4455        affected_paths: &[String],
4456        entities: &[GraphEntity],
4457        relations: &[LogicalRelation],
4458        occurrences: &[RelationOccurrence],
4459        coverage: &[CoverageRecord],
4460    ) -> DbResult<()> {
4461        self.replace_repository_graph_for_paths_with_resolution_keys(
4462            project,
4463            affected_paths,
4464            entities,
4465            relations,
4466            occurrences,
4467            coverage,
4468            &[],
4469            &[],
4470        )
4471    }
4472
4473    /// Replace one affected graph closure and its canonical resolution keys atomically.
4474    ///
4475    /// # Errors
4476    ///
4477    /// Returns an error when graph or key records do not belong to the pending
4478    /// generation and selected project, a stable-key collision is detected, an
4479    /// owner has no exact source path, or `SQLite` fails.
4480    #[allow(clippy::too_many_arguments)]
4481    pub fn replace_repository_graph_for_paths_with_resolution_keys(
4482        &mut self,
4483        project: ProjectInstanceId,
4484        affected_paths: &[String],
4485        entities: &[GraphEntity],
4486        relations: &[LogicalRelation],
4487        occurrences: &[RelationOccurrence],
4488        coverage: &[CoverageRecord],
4489        entity_exports: &[EntityResolutionKey],
4490        relation_dependencies: &[RelationDependencyKey],
4491    ) -> DbResult<()> {
4492        let generation = self.pending_graph_generation()?;
4493        validate_graph_batch(
4494            project,
4495            generation,
4496            entities,
4497            relations,
4498            occurrences,
4499            coverage,
4500        )?;
4501        validate_resolution_key_batch(project, entity_exports, relation_dependencies)?;
4502        let affected_paths = affected_paths
4503            .iter()
4504            .map(|path| RepositoryNodePath::new(Path::new(path)))
4505            .collect::<Result<Vec<_>, _>>()?;
4506        let savepoint = self.store.connection.savepoint()?;
4507        require_bound_project_identity(&savepoint, project)?;
4508        if affected_paths.iter().any(|path| path.as_str() == ".") {
4509            prepare_graph_identity_rejections(&savepoint, project, generation, None)?;
4510            savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
4511            savepoint.execute("DELETE FROM graph_coverage", [])?;
4512            savepoint.execute("DELETE FROM graph_relations", [])?;
4513            savepoint.execute("DELETE FROM graph_entities", [])?;
4514            insert_graph_batch(
4515                &savepoint,
4516                project,
4517                entities,
4518                relations,
4519                occurrences,
4520                coverage,
4521            )?;
4522            insert_resolution_key_batch(
4523                &savepoint,
4524                project,
4525                entity_exports,
4526                relation_dependencies,
4527            )?;
4528            set_graph_generation(&savepoint, generation)?;
4529            savepoint.commit()?;
4530            return Ok(());
4531        }
4532        prepare_graph_identity_rejections(&savepoint, project, generation, Some(&affected_paths))?;
4533        let touched_keys = resolution_keys_for_owner_paths(
4534            &savepoint,
4535            project,
4536            &affected_paths,
4537            ResolutionOwner::Both,
4538            None,
4539            &mut ResolutionReadBudget::new(u64::MAX),
4540        )?;
4541        let mut orphan_candidates = affected_external_candidates(&savepoint, &affected_paths)?;
4542        invalidate_repository_graph_paths(&savepoint, &affected_paths, &mut orphan_candidates)?;
4543        insert_graph_batch(
4544            &savepoint,
4545            project,
4546            entities,
4547            relations,
4548            occurrences,
4549            coverage,
4550        )?;
4551        insert_resolution_key_batch(&savepoint, project, entity_exports, relation_dependencies)?;
4552        for entity in entities {
4553            if matches!(entity.selector(), EntitySelector::External { .. }) {
4554                orphan_candidates.insert(entity.key().digest_bytes()?);
4555            }
4556        }
4557        remove_orphan_external_candidates(&savepoint, &orphan_candidates)?;
4558        remove_touched_orphan_resolution_keys(&savepoint, &touched_keys)?;
4559        set_graph_generation(&savepoint, generation)?;
4560        savepoint.commit()?;
4561        Ok(())
4562    }
4563
4564    /// Attach closed reasons to unresolved `documents` relations in this publication.
4565    ///
4566    /// Call this after the owning graph replacement and before completing the
4567    /// parent publication. The complete input is validated before prepared
4568    /// chunks at or below the graph row ceiling are applied, and every key must
4569    /// select exactly one unresolved `documents` relation.
4570    ///
4571    /// # Errors
4572    ///
4573    /// Returns an error before mutation for duplicate keys, or if a key does
4574    /// not identify one compatible relation in the selected project.
4575    pub fn set_document_unresolved_reasons(
4576        &mut self,
4577        reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4578    ) -> DbResult<()> {
4579        self.set_document_unresolved_reasons_with_control(reasons, None)
4580    }
4581
4582    /// Attach document reasons while observing publication cancellation.
4583    ///
4584    /// # Errors
4585    ///
4586    /// Returns an error without partial mutation when cancellation, duplicate
4587    /// keys, incompatible relations, or `SQLite` failures occur.
4588    pub fn set_document_unresolved_reasons_controlled(
4589        &mut self,
4590        reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4591        control: &IndexWorkControl,
4592    ) -> DbResult<()> {
4593        self.set_document_unresolved_reasons_with_control(reasons, Some(control))
4594    }
4595
4596    /// Apply publication reasons through one savepoint with optional cancellation checks.
4597    fn set_document_unresolved_reasons_with_control(
4598        &mut self,
4599        reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4600        control: Option<&IndexWorkControl>,
4601    ) -> DbResult<()> {
4602        let savepoint = self.store.validated_savepoint()?;
4603        write_document_unresolved_reasons(&savepoint, reasons, control)?;
4604        savepoint.commit()?;
4605        Ok(())
4606    }
4607
4608    /// Replace bounded parser identity rejection rows in this publication.
4609    ///
4610    /// Append the typed details prepared by the graph replacement in this
4611    /// publication. The replacement method clears or rebinds the existing rows
4612    /// before changing graph nodes, so these inserts share its parent transaction;
4613    /// the complete resulting generation must remain within the same row ceiling.
4614    ///
4615    /// # Errors
4616    ///
4617    /// Returns an error for a foreign project/path, an oversized complete
4618    /// generation, invalid span data, or `SQLite` failure.
4619    pub fn replace_graph_identity_rejections(
4620        &mut self,
4621        project: ProjectInstanceId,
4622        rejections: &[GraphIdentityRejection],
4623    ) -> DbResult<()> {
4624        if rejections.len() > GraphLimits::MAX_ROWS as usize {
4625            return Err(GraphContractError::InvalidLimits {
4626                reason: "graph identity rejection batch exceeds the graph row ceiling",
4627            }
4628            .into());
4629        }
4630        let generation = self.pending_graph_generation()?;
4631        let savepoint = self.store.validated_savepoint()?;
4632        require_bound_project_identity(&savepoint, project)?;
4633        insert_graph_identity_rejections(&savepoint, project, generation, rejections)?;
4634        let generation_value =
4635            sqlite_count("graph_identity_rejections.generation", generation.get())?;
4636        let persisted_count = savepoint.query_row(
4637            "SELECT COUNT(*)
4638               FROM graph_identity_rejections
4639              WHERE project_instance_id = ?1 AND generation = ?2",
4640            params![&project.as_bytes()[..], generation_value],
4641            |row| row.get::<_, i64>(0),
4642        )?;
4643        if persisted_count > i64::from(GraphLimits::MAX_ROWS) {
4644            return Err(GraphContractError::InvalidLimits {
4645                reason: "complete graph identity rejection generation exceeds the graph row ceiling",
4646            }
4647            .into());
4648        }
4649        savepoint.commit()?;
4650        Ok(())
4651    }
4652
4653    /// Return the generation that will become complete if this guard commits.
4654    fn pending_graph_generation(&self) -> DbResult<IndexGeneration> {
4655        self.previous_generation
4656            .checked_next()
4657            .ok_or(DbError::PublicationGenerationOverflow)
4658    }
4659}
4660
4661/// Validate and atomically update one complete document-reason publication.
4662fn write_document_unresolved_reasons(
4663    connection: &Connection,
4664    reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4665    control: Option<&IndexWorkControl>,
4666) -> DbResult<()> {
4667    if let Some(control) = control {
4668        control.check(IndexWorkStage::Publication)?;
4669    }
4670    validate_document_unresolved_reasons(connection, reasons, control)?;
4671    for chunk in reasons.chunks(GraphLimits::MAX_ROWS as usize) {
4672        if let Some(control) = control {
4673            control.check(IndexWorkStage::Publication)?;
4674        }
4675        write_document_unresolved_reason_chunk(connection, chunk, control)?;
4676    }
4677    Ok(())
4678}
4679
4680/// Validate every document-reason key before any chunk can mutate the graph.
4681fn validate_document_unresolved_reasons(
4682    connection: &Connection,
4683    reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4684    control: Option<&IndexWorkControl>,
4685) -> DbResult<()> {
4686    let mut keys = HashSet::with_capacity(reasons.len());
4687    for (index, (key, _)) in reasons.iter().enumerate() {
4688        if index.is_multiple_of(256)
4689            && let Some(control) = control
4690        {
4691            control.check(IndexWorkStage::Publication)?;
4692        }
4693        if !keys.insert(key.digest_bytes()?) {
4694            return Err(GraphContractError::InvalidLimits {
4695                reason: "document unresolved reason batch repeats a relation key",
4696            }
4697            .into());
4698        }
4699    }
4700    let mut validate = connection.prepare_cached(
4701        "SELECT EXISTS(
4702                 SELECT 1
4703                   FROM graph_relations
4704                        INDEXED BY idx_graph_relations_project_key
4705                  WHERE relation_key = ?1
4706                    AND project_instance_id = ?2
4707                    AND relation_scope = 'extended'
4708                    AND relation_kind = 'documents'
4709                    AND resolution_status = 'unresolved'
4710             )",
4711    )?;
4712    for (index, (key, _)) in reasons.iter().enumerate() {
4713        if index.is_multiple_of(256)
4714            && let Some(control) = control
4715        {
4716            control.check(IndexWorkStage::Publication)?;
4717        }
4718        let digest = key.digest_bytes()?;
4719        let compatible = validate
4720            .query_row(params![&digest[..], &key.project().as_bytes()[..]], |row| {
4721                row.get::<_, bool>(0)
4722            })?;
4723        if !compatible {
4724            return Err(DbError::GraphRowShape {
4725                table: "graph_relations",
4726                reason: "document unresolved reason target is missing or incompatible",
4727            });
4728        }
4729    }
4730    drop(validate);
4731    Ok(())
4732}
4733
4734/// Apply one prepared document-reason chunk inside the caller-owned savepoint.
4735fn write_document_unresolved_reason_chunk(
4736    connection: &Connection,
4737    reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4738    control: Option<&IndexWorkControl>,
4739) -> DbResult<()> {
4740    let mut statement = connection.prepare_cached(
4741        "UPDATE graph_relations
4742                SET document_unresolved_reason = ?2
4743              WHERE relation_key = ?1
4744                AND project_instance_id = ?3
4745                AND relation_scope = 'extended'
4746                AND relation_kind = 'documents'
4747                AND resolution_status = 'unresolved'",
4748    )?;
4749    for (index, (key, reason)) in reasons.iter().enumerate() {
4750        if index.is_multiple_of(256)
4751            && let Some(control) = control
4752        {
4753            control.check(IndexWorkStage::Publication)?;
4754        }
4755        let digest = key.digest_bytes()?;
4756        let changed = statement.execute(params![
4757            &digest[..],
4758            reason.as_str(),
4759            &key.project().as_bytes()[..],
4760        ])?;
4761        debug_assert_eq!(changed, 1);
4762    }
4763    if let Some(control) = control {
4764        control.check(IndexWorkStage::Publication)?;
4765    }
4766    Ok(())
4767}
4768
4769/// Require every unresolved `documents` relation, and only such a relation, to own a reason.
4770pub(crate) fn validate_complete_document_unresolved_reasons(
4771    connection: &Connection,
4772) -> DbResult<()> {
4773    let invalid = connection
4774        .query_row(
4775            "SELECT relation_key
4776               FROM graph_relations
4777              WHERE (
4778                    relation_scope = 'extended'
4779                AND relation_kind = 'documents'
4780                AND resolution_status = 'unresolved'
4781                AND document_unresolved_reason IS NULL
4782              ) OR (
4783                    document_unresolved_reason IS NOT NULL
4784                AND NOT (
4785                        relation_scope = 'extended'
4786                    AND relation_kind = 'documents'
4787                    AND resolution_status = 'unresolved'
4788                )
4789              )
4790              LIMIT 1",
4791            [],
4792            |row| row.get::<_, Vec<u8>>(0),
4793        )
4794        .optional()?;
4795    if invalid.is_some() {
4796        return Err(DbError::GraphRowShape {
4797            table: "graph_relations",
4798            reason: "published document unresolved reason invariant is incomplete",
4799        });
4800    }
4801    Ok(())
4802}
4803
4804/// Conservative key count that stays below `SQLite`'s legacy bind ceiling.
4805const RESOLUTION_KEYS_PER_QUERY: usize = 200;
4806/// Conservative path count that stays below `SQLite`'s legacy bind ceiling.
4807const RESOLUTION_PATHS_PER_QUERY: usize = 400;
4808/// Conservative affected-entity count for one external-candidate adjacency batch.
4809const EXTERNAL_CANDIDATE_KEYS_PER_QUERY: usize = 400;
4810
4811/// Closed owner projections that retain canonical resolution keys.
4812#[derive(Clone, Copy)]
4813enum ResolutionOwner {
4814    /// Entity-export bindings only.
4815    EntityExports,
4816    /// Both binding families, used for touched-key garbage collection.
4817    Both,
4818}
4819
4820impl ResolutionOwner {
4821    /// Return the owner tables and their path-first access indexes.
4822    fn tables(self) -> &'static [(&'static str, &'static str)] {
4823        match self {
4824            Self::EntityExports => &[("graph_entity_exports", "idx_graph_entity_exports_owner")],
4825            Self::Both => &[
4826                ("graph_entity_exports", "idx_graph_entity_exports_owner"),
4827                (
4828                    "graph_relation_dependencies",
4829                    "idx_graph_relation_dependencies_owner",
4830                ),
4831            ],
4832        }
4833    }
4834}
4835
4836/// Validate and deduplicate exact source paths before querying owner bindings.
4837fn normalized_file_paths(paths: &[String]) -> DbResult<Vec<RepositoryNodePath>> {
4838    let mut normalized = BTreeSet::new();
4839    for path in paths {
4840        let file = RepositoryFilePath::new(Path::new(path))?;
4841        normalized.insert(RepositoryNodePath::new(Path::new(file.as_str()))?);
4842    }
4843    Ok(normalized.into_iter().collect())
4844}
4845
4846/// Caller-owned allowance for resolution batches used while indexing state remains live.
4847struct ResolutionReadBudget {
4848    /// The existing operation ceiling; MAX preserves legacy standalone wrappers.
4849    limit: u64,
4850    /// Conservative owned payload and conversion/query envelopes already reserved.
4851    retained: u64,
4852    /// Returned rows stay live when input/query scratch drops at method return.
4853    output_retained: u64,
4854}
4855
4856impl ResolutionReadBudget {
4857    /// Start one read without claiming a second operation allowance.
4858    const fn new(limit: u64) -> Self {
4859        Self {
4860            limit,
4861            retained: 0,
4862            output_retained: 0,
4863        }
4864    }
4865
4866    /// Reject before materializing a row or query-input clone.
4867    fn check(&self, additional: u64) -> DbResult<()> {
4868        let requested = self.retained.saturating_add(additional);
4869        if requested > self.limit {
4870            return Err(DbError::IndexWork(
4871                projectatlas_core::IndexWorkFailure::resource_limit(
4872                    IndexWorkStage::SymbolParsing,
4873                    projectatlas_core::IndexWorkResource::OutputBytes,
4874                    self.limit,
4875                    requested,
4876                ),
4877            ));
4878        }
4879        Ok(())
4880    }
4881
4882    /// Admit the next retained owner, leaving the prior ledger intact on refusal.
4883    fn reserve(&mut self, additional: u64) -> DbResult<()> {
4884        self.check(additional)?;
4885        self.retained = self.retained.saturating_add(additional);
4886        Ok(())
4887    }
4888
4889    /// Keep half the four-owner decode envelope until the page is consumed;
4890    /// the other half covers the new registry/set during its ownership transfer.
4891    fn reserve_output(&mut self, bytes: u64) -> DbResult<()> {
4892        self.reserve(bytes)?;
4893        self.output_retained = self.output_retained.saturating_add(bytes / 2);
4894        Ok(())
4895    }
4896
4897    /// Inspect borrowed SQLite cells before any owned decode. Four copies cover
4898    /// raw conversion, reconstructed identity and vector/tree metadata transitions.
4899    fn row_bytes(row: &Row<'_>, inline: usize) -> DbResult<u64> {
4900        let mut bytes = 128_u64.saturating_add(inline as u64);
4901        for column in 0..row.as_ref().column_count() {
4902            bytes = bytes.saturating_add(match row.get_ref(column)? {
4903                rusqlite::types::ValueRef::Text(value) | rusqlite::types::ValueRef::Blob(value) => {
4904                    value.len() as u64
4905                }
4906                _ => 8,
4907            });
4908        }
4909        Ok(bytes.saturating_mul(4))
4910    }
4911}
4912
4913/// Validate project ownership and deduplicate canonical keys deterministically.
4914fn normalized_resolution_keys(
4915    project: ProjectInstanceId,
4916    keys: &[CanonicalResolutionKey],
4917    budget: &mut ResolutionReadBudget,
4918) -> DbResult<Vec<CanonicalResolutionKey>> {
4919    let mut normalized = BTreeSet::new();
4920    for key in keys {
4921        if key.project() != project {
4922            return Err(DbError::GraphProjectIdentityMismatch {
4923                expected: project.to_string(),
4924                found: key.project().to_string(),
4925            });
4926        }
4927        if !normalized.contains(key) {
4928            budget.reserve(
4929                4_u64.saturating_mul(
4930                    128_u64
4931                        .saturating_add((2 * std::mem::size_of::<CanonicalResolutionKey>()) as u64)
4932                        .saturating_add(key.retained_heap_bytes()),
4933                ),
4934            )?;
4935            normalized.insert(key.clone());
4936        }
4937    }
4938    Ok(normalized.into_iter().collect())
4939}
4940
4941/// Convert a sorted unique set into one `LIMIT + 1` page.
4942fn page_from_ordered_set<T: Ord>(rows: BTreeSet<T>, limit: u32) -> RepositoryGraphPage<T> {
4943    let truncated = rows.len() > limit as usize;
4944    RepositoryGraphPage {
4945        rows: rows.into_iter().take(limit as usize).collect(),
4946        truncated,
4947    }
4948}
4949
4950/// Load and validate canonical keys retained by path-owned graph projections.
4951fn resolution_keys_for_owner_paths(
4952    connection: &Connection,
4953    project: ProjectInstanceId,
4954    paths: &[RepositoryNodePath],
4955    owners: ResolutionOwner,
4956    limit: Option<u32>,
4957    budget: &mut ResolutionReadBudget,
4958) -> DbResult<BTreeSet<CanonicalResolutionKey>> {
4959    let mut keys = BTreeSet::new();
4960    for (table, index) in owners.tables() {
4961        for chunk in paths.chunks(RESOLUTION_PATHS_PER_QUERY) {
4962            if chunk.is_empty() {
4963                continue;
4964            }
4965            let placeholders = vec!["?"; chunk.len()].join(",");
4966            let limit_clause = limit.map_or("", |_| " LIMIT ?");
4967            let sql = format!(
4968                "SELECT DISTINCT resolution.project_instance_id,
4969                        resolution.resolution_domain, resolution.key_digest,
4970                        resolution.canonical_identity
4971                   FROM {table} AS owner INDEXED BY {index}
4972                   JOIN graph_resolution_keys AS resolution
4973                     ON resolution.project_instance_id = owner.project_instance_id
4974                    AND resolution.resolution_domain = owner.resolution_domain
4975                    AND resolution.key_digest = owner.key_digest
4976                  WHERE owner.project_instance_id = ?
4977                    AND owner.owner_path IN ({placeholders})
4978                  ORDER BY resolution.resolution_domain, resolution.key_digest{limit_clause}"
4979            );
4980            let mut values = Vec::with_capacity(chunk.len() + 2);
4981            values.push(Value::Blob(project.as_bytes().to_vec()));
4982            values.extend(
4983                chunk
4984                    .iter()
4985                    .map(|path| Value::Text(path.as_str().to_string())),
4986            );
4987            if let Some(limit) = limit {
4988                values.push(Value::Integer(i64::from(limit) + 1));
4989            }
4990            let mut statement = connection.prepare(&sql)?;
4991            let mut rows = statement.query(params_from_iter(values.iter()))?;
4992            while let Some(row) = rows.next()? {
4993                let bytes = ResolutionReadBudget::row_bytes(
4994                    row,
4995                    std::mem::size_of::<CanonicalResolutionKey>(),
4996                )?;
4997                budget.check(bytes)?;
4998                let key = resolution_key_from_row(row, project)?;
4999                if !keys.contains(&key) {
5000                    budget.reserve_output(bytes)?;
5001                    keys.insert(key);
5002                }
5003            }
5004            if limit.is_some_and(|limit| keys.len() > limit as usize) {
5005                return Ok(keys);
5006            }
5007        }
5008    }
5009    Ok(keys)
5010}
5011
5012/// Reconstruct one canonical key row without accepting malformed persisted data.
5013fn resolution_key_from_row(
5014    row: &Row<'_>,
5015    expected_project: ProjectInstanceId,
5016) -> DbResult<CanonicalResolutionKey> {
5017    let project = project_from_blob(
5018        "graph_resolution_keys.project_instance_id",
5019        row.get::<_, Vec<u8>>(0)?,
5020    )?;
5021    require_project(expected_project, project)?;
5022    let domain_text = row.get::<_, String>(1)?;
5023    let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
5024    let digest = fixed_bytes::<32>(
5025        "graph_resolution_keys.key_digest",
5026        row.get::<_, Vec<u8>>(2)?,
5027    )?;
5028    Ok(CanonicalResolutionKey::from_persisted(
5029        project,
5030        domain,
5031        digest,
5032        row.get(3)?,
5033    )?)
5034}
5035
5036/// Validate one requested key against any retained canonical witness.
5037fn validate_persisted_resolution_key(
5038    connection: &Connection,
5039    key: &CanonicalResolutionKey,
5040) -> DbResult<bool> {
5041    let stored = connection
5042        .query_row(
5043            "SELECT canonical_identity
5044               FROM graph_resolution_keys
5045              WHERE project_instance_id = ?1
5046                AND resolution_domain = ?2
5047                AND key_digest = ?3",
5048            params![
5049                &key.project().as_bytes()[..],
5050                key.domain().as_str(),
5051                &key.digest_bytes()[..],
5052            ],
5053            |row| row.get::<_, String>(0),
5054        )
5055        .optional()?;
5056    let Some(stored) = stored else {
5057        return Ok(false);
5058    };
5059    if stored != key.canonical_identity() {
5060        return Err(DbError::ResolutionKeyCollision {
5061            domain: key.domain().as_str(),
5062            digest: key.digest_bytes(),
5063        });
5064    }
5065    CanonicalResolutionKey::from_persisted(
5066        key.project(),
5067        key.domain(),
5068        key.digest_bytes(),
5069        stored,
5070    )?;
5071    Ok(true)
5072}
5073
5074/// Validate all retained witnesses for a key batch without per-key queries.
5075fn validate_persisted_resolution_keys(
5076    connection: &Connection,
5077    keys: &[CanonicalResolutionKey],
5078    budget: &ResolutionReadBudget,
5079) -> DbResult<()> {
5080    for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
5081        if chunk.is_empty() {
5082            continue;
5083        }
5084        let values_clause = anonymous_values_clause(chunk.len(), 4);
5085        let sql = format!(
5086            "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
5087                  AS (VALUES {values_clause})
5088             SELECT requested.resolution_domain, requested.key_digest,
5089                    requested.canonical_identity, stored.canonical_identity
5090               FROM requested
5091               JOIN graph_resolution_keys AS stored
5092                 ON stored.project_instance_id = requested.project_instance_id
5093                AND stored.resolution_domain = requested.resolution_domain
5094                AND stored.key_digest = requested.key_digest
5095              ORDER BY requested.resolution_domain, requested.key_digest"
5096        );
5097        let values = resolution_key_values(chunk, true);
5098        let mut statement = connection.prepare(&sql)?;
5099        let mut rows = statement.query(params_from_iter(values.iter()))?;
5100        while let Some(row) = rows.next()? {
5101            budget.check(ResolutionReadBudget::row_bytes(row, 0)?)?;
5102            let domain_text = row.get::<_, String>(0)?;
5103            let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
5104            let digest = fixed_bytes::<32>(
5105                "graph_resolution_keys.key_digest",
5106                row.get::<_, Vec<u8>>(1)?,
5107            )?;
5108            let requested = row.get::<_, String>(2)?;
5109            let stored = row.get::<_, String>(3)?;
5110            if requested != stored {
5111                return Err(DbError::ResolutionKeyCollision {
5112                    domain: domain.as_str(),
5113                    digest,
5114                });
5115            }
5116        }
5117    }
5118    Ok(())
5119}
5120
5121/// Return distinct ordered dependency-owning paths for a bounded key set.
5122fn affected_source_paths(
5123    connection: &Connection,
5124    keys: &[CanonicalResolutionKey],
5125    limit: u32,
5126    budget: &mut ResolutionReadBudget,
5127) -> DbResult<BTreeSet<RepositoryFilePath>> {
5128    let mut paths = BTreeSet::new();
5129    for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
5130        if chunk.is_empty() {
5131            continue;
5132        }
5133        let values_clause = anonymous_values_clause(chunk.len(), 4);
5134        let sql = format!(
5135            "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
5136                  AS (VALUES {values_clause})
5137             SELECT DISTINCT dependency.owner_path
5138               FROM requested
5139               JOIN graph_resolution_keys AS stored
5140                 ON stored.project_instance_id = requested.project_instance_id
5141                AND stored.resolution_domain = requested.resolution_domain
5142                AND stored.key_digest = requested.key_digest
5143                AND stored.canonical_identity = requested.canonical_identity
5144               JOIN graph_relation_dependencies AS dependency
5145                    INDEXED BY idx_graph_relation_dependencies_key
5146                 ON dependency.project_instance_id = stored.project_instance_id
5147                AND dependency.resolution_domain = stored.resolution_domain
5148                AND dependency.key_digest = stored.key_digest
5149              ORDER BY dependency.owner_path
5150              LIMIT ?"
5151        );
5152        let mut values = resolution_key_values(chunk, true);
5153        values.push(Value::Integer(i64::from(limit) + 1));
5154        let mut statement = connection.prepare(&sql)?;
5155        let mut rows = statement.query(params_from_iter(values.iter()))?;
5156        while let Some(row) = rows.next()? {
5157            let bytes =
5158                ResolutionReadBudget::row_bytes(row, std::mem::size_of::<RepositoryFilePath>())?;
5159            budget.check(bytes)?;
5160            let path = RepositoryFilePath::new(Path::new(&row.get::<_, String>(0)?))?;
5161            if !paths.contains(&path) {
5162                budget.reserve_output(bytes)?;
5163                paths.insert(path);
5164            }
5165        }
5166        if paths.len() > limit as usize {
5167            return Ok(paths);
5168        }
5169    }
5170    Ok(paths)
5171}
5172
5173/// Account one exact-path closure through bounded index-owned branches.
5174fn affected_source_footprint(
5175    connection: &Connection,
5176    project: ProjectInstanceId,
5177    paths: &[RepositoryNodePath],
5178    limit_plus_one: i64,
5179) -> DbResult<RepositoryAffectedSourceFootprint> {
5180    let maximum_rows = u64::try_from(limit_plus_one).map_err(|source| DbError::InvalidCount {
5181        field: "affected_source_footprint.limit_plus_one",
5182        value: limit_plus_one,
5183        source,
5184    })?;
5185    let mut footprint = empty_affected_source_footprint();
5186    for chunk in paths.chunks(RESOLUTION_PATHS_PER_QUERY) {
5187        if chunk.is_empty() {
5188            continue;
5189        }
5190        let remaining = maximum_rows.saturating_sub(footprint.rows);
5191        if remaining == 0 {
5192            footprint.truncated = true;
5193            break;
5194        }
5195        let sql = affected_source_footprint_sql(chunk.len());
5196        let mut values = Vec::with_capacity(chunk.len() + 2);
5197        values.push(Value::Blob(project.as_bytes().to_vec()));
5198        values.extend(
5199            chunk
5200                .iter()
5201                .map(|path| Value::Text(path.as_str().to_string())),
5202        );
5203        values.push(Value::Integer(i64::try_from(remaining).map_err(
5204            |source| DbError::InvalidCount {
5205                field: "affected_source_footprint.remaining_rows",
5206                value: i64::MAX,
5207                source,
5208            },
5209        )?));
5210        let mut statement = connection.prepare(&sql)?;
5211        let mut rows = statement.query(params_from_iter(values.iter()))?;
5212        while let Some(row) = rows.next()? {
5213            let bytes = nonnegative_u64(
5214                "affected_source_footprint.retained_bytes",
5215                row.get::<_, i64>(0)?,
5216            )?;
5217            footprint.rows = footprint.rows.saturating_add(1);
5218            footprint.retained_bytes = footprint.retained_bytes.saturating_add(bytes);
5219        }
5220        if footprint.rows >= maximum_rows {
5221            footprint.truncated = true;
5222            break;
5223        }
5224    }
5225    Ok(footprint)
5226}
5227
5228/// Build one bounded union of exact-path footprint branches.
5229fn affected_source_footprint_sql(path_count: usize) -> String {
5230    let requested = vec!["(?)"; path_count].join(",");
5231    format!(
5232        "WITH selected_project(project_instance_id) AS (VALUES (?)),
5233              requested(path) AS (VALUES {requested})
5234         SELECT retained_bytes
5235           FROM (
5236             SELECT length(CAST(metadata.path AS BLOB))
5237                    + coalesce(length(CAST(metadata.language AS BLOB)), 0)
5238                    + length(CAST(metadata.source_parser AS BLOB))
5239                    + length(CAST(metadata.fact_parser AS BLOB)) + 16
5240                    + length(CAST(metadata.updated_at AS BLOB)) AS retained_bytes
5241               FROM requested
5242               JOIN source_parse_metadata AS metadata
5243                    INDEXED BY sqlite_autoindex_source_parse_metadata_1
5244                 ON metadata.path = requested.path
5245             UNION ALL
5246             SELECT 32 + length(CAST(symbol.path AS BLOB))
5247                    + coalesce(length(CAST(symbol.language AS BLOB)), 0)
5248                    + length(CAST(symbol.name AS BLOB))
5249                    + length(CAST(symbol.kind AS BLOB))
5250                    + length(CAST(symbol.signature AS BLOB))
5251                    + coalesce(length(CAST(symbol.documentation AS BLOB)), 0)
5252                    + coalesce(length(CAST(symbol.parent AS BLOB)), 0)
5253                    + length(CAST(symbol.parser AS BLOB))
5254                    + coalesce(length(CAST(symbol.detail AS BLOB)), 0)
5255                    + length(CAST(symbol.created_at AS BLOB))
5256                    + length(CAST(symbol.updated_at AS BLOB))
5257               FROM requested
5258               JOIN symbols AS symbol INDEXED BY idx_symbols_path
5259                 ON symbol.path = requested.path
5260             UNION ALL
5261             SELECT 16 + length(CAST(symbol_relation.path AS BLOB))
5262                    + length(CAST(symbol_relation.source_name AS BLOB))
5263                    + length(CAST(symbol_relation.target_name AS BLOB))
5264                    + length(CAST(symbol_relation.kind AS BLOB))
5265                    + length(CAST(symbol_relation.context AS BLOB))
5266                    + length(CAST(symbol_relation.parser AS BLOB))
5267                    + length(CAST(symbol_relation.created_at AS BLOB))
5268               FROM requested
5269               JOIN symbol_relations AS symbol_relation INDEXED BY idx_symbol_relations_path
5270                 ON symbol_relation.path = requested.path
5271             UNION ALL
5272             SELECT 48 + length(CAST(entity.canonical_identity AS BLOB))
5273                    + length(CAST(entity.entity_kind AS BLOB))
5274                    + coalesce(length(CAST(entity.repository_path AS BLOB)), 0)
5275                    + coalesce(length(CAST(entity.package_manager AS BLOB)), 0)
5276                    + coalesce(length(CAST(entity.package_name AS BLOB)), 0)
5277                    + coalesce(length(CAST(entity.manifest_path AS BLOB)), 0)
5278                    + coalesce(length(CAST(entity.symbol_name AS BLOB)), 0)
5279                    + coalesce(length(CAST(entity.symbol_kind AS BLOB)), 0)
5280                    + coalesce(length(CAST(entity.symbol_parent AS BLOB)), 0)
5281                    + coalesce(length(CAST(entity.symbol_signature AS BLOB)), 0)
5282                    + coalesce(length(CAST(entity.external_system AS BLOB)), 0)
5283                    + coalesce(length(CAST(entity.external_identity AS BLOB)), 0)
5284               FROM requested
5285               JOIN graph_entities AS entity INDEXED BY idx_graph_entities_path
5286                 ON entity.repository_path = requested.path
5287               JOIN selected_project
5288                 ON selected_project.project_instance_id = entity.project_instance_id
5289             UNION ALL
5290             SELECT 48 + length(CAST(entity.canonical_identity AS BLOB))
5291                    + length(CAST(entity.entity_kind AS BLOB))
5292                    + coalesce(length(CAST(entity.repository_path AS BLOB)), 0)
5293                    + coalesce(length(CAST(entity.package_manager AS BLOB)), 0)
5294                    + coalesce(length(CAST(entity.package_name AS BLOB)), 0)
5295                    + coalesce(length(CAST(entity.manifest_path AS BLOB)), 0)
5296                    + coalesce(length(CAST(entity.symbol_name AS BLOB)), 0)
5297                    + coalesce(length(CAST(entity.symbol_kind AS BLOB)), 0)
5298                    + coalesce(length(CAST(entity.symbol_parent AS BLOB)), 0)
5299                    + coalesce(length(CAST(entity.symbol_signature AS BLOB)), 0)
5300                    + coalesce(length(CAST(entity.external_system AS BLOB)), 0)
5301                    + coalesce(length(CAST(entity.external_identity AS BLOB)), 0)
5302               FROM requested
5303               JOIN graph_entities AS entity INDEXED BY idx_graph_entities_manifest_path
5304                 ON entity.manifest_path = requested.path
5305               JOIN selected_project
5306                 ON selected_project.project_instance_id = entity.project_instance_id
5307             UNION ALL
5308             SELECT 80 + length(CAST(relation.canonical_identity AS BLOB))
5309                    + length(CAST(relation.relation_scope AS BLOB))
5310                    + length(CAST(relation.relation_kind AS BLOB))
5311                    + length(CAST(relation.resolution_status AS BLOB))
5312                    + coalesce(length(relation.target_entity_key), 0)
5313                    + coalesce(length(CAST(relation.reference_text AS BLOB)), 0)
5314                    + CASE WHEN relation.candidate_count IS NULL THEN 0 ELSE 8 END
5315                    + length(CAST(relation.confidence AS BLOB))
5316                    + length(CAST(relation.completeness AS BLOB))
5317               FROM requested
5318               JOIN graph_entities AS entity INDEXED BY idx_graph_entities_path
5319                 ON entity.repository_path = requested.path
5320               JOIN selected_project
5321                 ON selected_project.project_instance_id = entity.project_instance_id
5322               JOIN graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
5323                 ON relation.source_entity_key = entity.entity_key
5324                AND relation.project_instance_id = selected_project.project_instance_id
5325             UNION ALL
5326             SELECT 80 + length(CAST(relation.canonical_identity AS BLOB))
5327                    + length(CAST(relation.relation_scope AS BLOB))
5328                    + length(CAST(relation.relation_kind AS BLOB))
5329                    + length(CAST(relation.resolution_status AS BLOB))
5330                    + coalesce(length(relation.target_entity_key), 0)
5331                    + coalesce(length(CAST(relation.reference_text AS BLOB)), 0)
5332                    + CASE WHEN relation.candidate_count IS NULL THEN 0 ELSE 8 END
5333                    + length(CAST(relation.confidence AS BLOB))
5334                    + length(CAST(relation.completeness AS BLOB))
5335               FROM requested
5336               JOIN graph_entities AS entity INDEXED BY idx_graph_entities_manifest_path
5337                 ON entity.manifest_path = requested.path
5338               JOIN selected_project
5339                 ON selected_project.project_instance_id = entity.project_instance_id
5340               JOIN graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
5341                 ON relation.source_entity_key = entity.entity_key
5342                AND relation.project_instance_id = selected_project.project_instance_id
5343             UNION ALL
5344             SELECT 72 + length(CAST(occurrence.file_path AS BLOB))
5345               FROM requested
5346               JOIN graph_relation_occurrences AS occurrence
5347                    INDEXED BY idx_graph_occurrences_file_span
5348                 ON occurrence.file_path = requested.path
5349             UNION ALL
5350             SELECT 48 + length(CAST(coverage.scope_kind AS BLOB))
5351                    + coalesce(length(CAST(coverage.scope_path AS BLOB)), 0)
5352                    + coalesce(length(CAST(coverage.relation_scope AS BLOB)), 0)
5353                    + coalesce(length(CAST(coverage.relation_kind AS BLOB)), 0)
5354                    + length(CAST(coverage.state AS BLOB))
5355                    + coalesce(length(CAST(coverage.reason AS BLOB)), 0)
5356                    + coalesce(length(CAST(coverage.reached_limit AS BLOB)), 0)
5357               FROM requested
5358               JOIN graph_coverage AS coverage INDEXED BY idx_graph_coverage_path
5359                 ON coverage.scope_path = requested.path
5360               JOIN selected_project
5361                 ON selected_project.project_instance_id = coverage.project_instance_id
5362             UNION ALL
5363             SELECT 80 + length(CAST(export.owner_path AS BLOB))
5364                    + length(CAST(export.resolution_domain AS BLOB))
5365               FROM requested
5366               JOIN graph_entity_exports AS export INDEXED BY idx_graph_entity_exports_owner
5367                 ON export.owner_path = requested.path
5368               JOIN selected_project
5369                 ON selected_project.project_instance_id = export.project_instance_id
5370             UNION ALL
5371             SELECT length(witness.project_instance_id)
5372                    + length(CAST(witness.resolution_domain AS BLOB))
5373                    + length(witness.key_digest)
5374                    + length(CAST(witness.canonical_identity AS BLOB))
5375               FROM requested
5376               JOIN graph_entity_exports AS export INDEXED BY idx_graph_entity_exports_owner
5377                 ON export.owner_path = requested.path
5378               JOIN selected_project
5379                 ON selected_project.project_instance_id = export.project_instance_id
5380               LEFT JOIN graph_resolution_keys AS witness
5381                 ON witness.project_instance_id = export.project_instance_id
5382                AND witness.resolution_domain = export.resolution_domain
5383                AND witness.key_digest = export.key_digest
5384             UNION ALL
5385             SELECT 80 + length(CAST(dependency.owner_path AS BLOB))
5386                    + length(CAST(dependency.resolution_domain AS BLOB))
5387               FROM requested
5388               JOIN graph_relation_dependencies AS dependency
5389                    INDEXED BY idx_graph_relation_dependencies_owner
5390                 ON dependency.owner_path = requested.path
5391               JOIN selected_project
5392                 ON selected_project.project_instance_id = dependency.project_instance_id
5393             UNION ALL
5394             SELECT length(witness.project_instance_id)
5395                    + length(CAST(witness.resolution_domain AS BLOB))
5396                    + length(witness.key_digest)
5397                    + length(CAST(witness.canonical_identity AS BLOB))
5398               FROM requested
5399               JOIN graph_relation_dependencies AS dependency
5400                    INDEXED BY idx_graph_relation_dependencies_owner
5401                 ON dependency.owner_path = requested.path
5402               JOIN selected_project
5403                 ON selected_project.project_instance_id = dependency.project_instance_id
5404               LEFT JOIN graph_resolution_keys AS witness
5405                 ON witness.project_instance_id = dependency.project_instance_id
5406                AND witness.resolution_domain = dependency.resolution_domain
5407                AND witness.key_digest = dependency.key_digest
5408           )
5409          LIMIT ?"
5410    )
5411}
5412
5413/// Build an anonymous `VALUES` clause with fixed columns per row.
5414fn anonymous_values_clause(rows: usize, columns: usize) -> String {
5415    let row = format!("({})", vec!["?"; columns].join(","));
5416    vec![row; rows].join(",")
5417}
5418
5419/// Bind one canonical-key batch in the same order as its `VALUES` rows.
5420fn resolution_key_values(keys: &[CanonicalResolutionKey], witness: bool) -> Vec<Value> {
5421    let columns = if witness { 4 } else { 3 };
5422    let mut values = Vec::with_capacity(keys.len() * columns);
5423    for key in keys {
5424        values.push(Value::Blob(key.project().as_bytes().to_vec()));
5425        values.push(Value::Text(key.domain().as_str().to_string()));
5426        values.push(Value::Blob(key.digest_bytes().to_vec()));
5427        if witness {
5428            values.push(Value::Text(key.canonical_identity().to_string()));
5429        }
5430    }
5431    values
5432}
5433
5434/// Validate graph-owner bindings before acquiring the savepoint.
5435fn validate_resolution_key_batch(
5436    project: ProjectInstanceId,
5437    entity_exports: &[EntityResolutionKey],
5438    relation_dependencies: &[RelationDependencyKey],
5439) -> DbResult<()> {
5440    let foreign = entity_exports.iter().find_map(|binding| {
5441        (binding.entity().project() != project || binding.key().project() != project)
5442            .then(|| binding.key().project())
5443    });
5444    let foreign = foreign.or_else(|| {
5445        relation_dependencies.iter().find_map(|binding| {
5446            (binding.relation().project() != project || binding.key().project() != project)
5447                .then(|| binding.key().project())
5448        })
5449    });
5450    if let Some(found) = foreign {
5451        return Err(DbError::GraphProjectIdentityMismatch {
5452            expected: project.to_string(),
5453            found: found.to_string(),
5454        });
5455    }
5456    Ok(())
5457}
5458
5459/// Insert a validated resolution-key projection through prepared owner statements.
5460fn insert_resolution_key_batch(
5461    connection: &Connection,
5462    project: ProjectInstanceId,
5463    entity_exports: &[EntityResolutionKey],
5464    relation_dependencies: &[RelationDependencyKey],
5465) -> DbResult<()> {
5466    let mut insert_key = connection.prepare_cached(
5467        "INSERT INTO graph_resolution_keys(
5468             project_instance_id, resolution_domain, key_digest, canonical_identity
5469         ) VALUES(?1, ?2, ?3, ?4)
5470         ON CONFLICT(project_instance_id, resolution_domain, key_digest)
5471         DO UPDATE SET canonical_identity = excluded.canonical_identity
5472         WHERE graph_resolution_keys.canonical_identity = excluded.canonical_identity",
5473    )?;
5474    let mut insert_export = connection.prepare_cached(
5475        "INSERT INTO graph_entity_exports(
5476             project_instance_id, entity_key, owner_path, resolution_domain, key_digest
5477         )
5478         SELECT entity.project_instance_id, entity.entity_key,
5479                CASE entity.entity_kind
5480                    WHEN 'file' THEN entity.repository_path
5481                    WHEN 'symbol' THEN entity.repository_path
5482                    WHEN 'package' THEN entity.manifest_path
5483                END,
5484                ?3, ?4
5485           FROM graph_entities AS entity
5486          WHERE entity.project_instance_id = ?1 AND entity.entity_key = ?2
5487            AND entity.entity_kind IN ('file', 'symbol', 'package')
5488         ON CONFLICT(project_instance_id, entity_key, resolution_domain, key_digest)
5489         DO UPDATE SET owner_path = excluded.owner_path",
5490    )?;
5491    let mut insert_dependency = connection.prepare_cached(
5492        "INSERT INTO graph_relation_dependencies(
5493             project_instance_id, relation_key, owner_path, resolution_domain, key_digest
5494         )
5495         SELECT relation.project_instance_id, relation.relation_key,
5496                CASE source.entity_kind
5497                    WHEN 'file' THEN source.repository_path
5498                    WHEN 'symbol' THEN source.repository_path
5499                    WHEN 'package' THEN source.manifest_path
5500                END,
5501                ?3, ?4
5502           FROM graph_relations AS relation
5503           JOIN graph_entities AS source
5504             ON source.project_instance_id = relation.project_instance_id
5505            AND source.entity_key = relation.source_entity_key
5506          WHERE relation.project_instance_id = ?1 AND relation.relation_key = ?2
5507            AND source.entity_kind IN ('file', 'symbol', 'package')
5508         ON CONFLICT(project_instance_id, relation_key, resolution_domain, key_digest)
5509         DO UPDATE SET owner_path = excluded.owner_path",
5510    )?;
5511
5512    for binding in entity_exports {
5513        insert_resolution_key(&mut insert_key, binding.key())?;
5514        let key = binding.key();
5515        let inserted = insert_export.execute(params![
5516            &project.as_bytes()[..],
5517            &binding.entity().digest_bytes()?[..],
5518            key.domain().as_str(),
5519            &key.digest_bytes()[..],
5520        ])?;
5521        if inserted != 1 {
5522            return Err(DbError::GraphRowShape {
5523                table: "graph_entity_exports",
5524                reason: "export keys require a local file, symbol, or package owner",
5525            });
5526        }
5527    }
5528    for binding in relation_dependencies {
5529        insert_resolution_key(&mut insert_key, binding.key())?;
5530        let key = binding.key();
5531        let inserted = insert_dependency.execute(params![
5532            &project.as_bytes()[..],
5533            &binding.relation().digest_bytes()?[..],
5534            key.domain().as_str(),
5535            &key.digest_bytes()[..],
5536        ])?;
5537        if inserted != 1 {
5538            return Err(DbError::GraphRowShape {
5539                table: "graph_relation_dependencies",
5540                reason: "dependency keys require a local file, symbol, or package source",
5541            });
5542        }
5543    }
5544    Ok(())
5545}
5546
5547/// Insert one key witness, rejecting equal digests with different material.
5548fn insert_resolution_key(
5549    statement: &mut rusqlite::CachedStatement<'_>,
5550    key: &CanonicalResolutionKey,
5551) -> DbResult<()> {
5552    let inserted = statement.execute(params![
5553        &key.project().as_bytes()[..],
5554        key.domain().as_str(),
5555        &key.digest_bytes()[..],
5556        key.canonical_identity(),
5557    ])?;
5558    if inserted == 0 {
5559        return Err(DbError::ResolutionKeyCollision {
5560            domain: key.domain().as_str(),
5561            digest: key.digest_bytes(),
5562        });
5563    }
5564    Ok(())
5565}
5566
5567/// Delete only touched witness rows left without either owner family.
5568fn remove_touched_orphan_resolution_keys(
5569    connection: &Connection,
5570    keys: &BTreeSet<CanonicalResolutionKey>,
5571) -> DbResult<()> {
5572    let keys = keys.iter().cloned().collect::<Vec<_>>();
5573    for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
5574        if chunk.is_empty() {
5575            continue;
5576        }
5577        let values_clause = anonymous_values_clause(chunk.len(), 3);
5578        let sql = format!(
5579            "WITH touched(project_instance_id, resolution_domain, key_digest)
5580                  AS (VALUES {values_clause})
5581             DELETE FROM graph_resolution_keys
5582              WHERE rowid IN (
5583                    SELECT stored.rowid
5584                      FROM graph_resolution_keys AS stored
5585                      JOIN touched
5586                        ON touched.project_instance_id = stored.project_instance_id
5587                       AND touched.resolution_domain = stored.resolution_domain
5588                       AND touched.key_digest = stored.key_digest
5589                     WHERE NOT EXISTS (
5590                               SELECT 1 FROM graph_entity_exports AS export
5591                                WHERE export.project_instance_id = stored.project_instance_id
5592                                  AND export.resolution_domain = stored.resolution_domain
5593                                  AND export.key_digest = stored.key_digest
5594                           )
5595                       AND NOT EXISTS (
5596                               SELECT 1 FROM graph_relation_dependencies AS dependency
5597                                WHERE dependency.project_instance_id = stored.project_instance_id
5598                                  AND dependency.resolution_domain = stored.resolution_domain
5599                                  AND dependency.key_digest = stored.key_digest
5600                           )
5601              )"
5602        );
5603        let values = resolution_key_values(chunk, false);
5604        connection.execute(&sql, params_from_iter(values.iter()))?;
5605    }
5606    Ok(())
5607}
5608
5609/// Collect external entities whose relation to an affected local entity may vanish.
5610fn affected_external_candidates(
5611    connection: &Connection,
5612    affected_paths: &[RepositoryNodePath],
5613) -> DbResult<HashSet<[u8; 32]>> {
5614    let mut by_repository_path = connection.prepare_cached(
5615        "SELECT entity_key FROM graph_entities
5616          WHERE repository_path = ?1
5617             OR (repository_path >= ?2 AND repository_path < ?3)",
5618    )?;
5619    let mut by_manifest_path = connection.prepare_cached(
5620        "SELECT entity_key FROM graph_entities
5621          WHERE manifest_path = ?1 OR (manifest_path >= ?2 AND manifest_path < ?3)",
5622    )?;
5623    let mut local_keys = HashSet::new();
5624    for path in affected_paths {
5625        let path = path.as_str();
5626        let (descendant_start, descendant_end) = repository_descendant_bounds(path);
5627        for statement in [&mut by_repository_path, &mut by_manifest_path] {
5628            let mut rows = statement.query(params![path, descendant_start, descendant_end])?;
5629            while let Some(row) = rows.next()? {
5630                local_keys.insert(fixed_bytes::<32>(
5631                    "graph_entities.entity_key",
5632                    row.get::<_, Vec<u8>>(0)?,
5633                )?);
5634            }
5635        }
5636    }
5637
5638    let mut candidates = HashSet::new();
5639    let local_keys = local_keys.into_iter().collect::<Vec<_>>();
5640    for chunk in local_keys.chunks(EXTERNAL_CANDIDATE_KEYS_PER_QUERY) {
5641        let sql = external_candidate_batch_sql(chunk.len());
5642        let mut statement = connection.prepare_cached(&sql)?;
5643        let mut rows = statement.query(params_from_iter(chunk.iter().map(|key| &key[..])))?;
5644        while let Some(row) = rows.next()? {
5645            candidates.insert(fixed_bytes::<32>(
5646                "graph_entities.entity_key",
5647                row.get::<_, Vec<u8>>(0)?,
5648            )?);
5649        }
5650    }
5651    Ok(candidates)
5652}
5653
5654/// Build one two-direction indexed adjacency query for a bounded affected-key batch.
5655fn external_candidate_batch_sql(key_count: usize) -> String {
5656    let values = anonymous_values_clause(key_count, 1);
5657    format!(
5658        "WITH affected(entity_key) AS (VALUES {values})
5659         SELECT relation.target_entity_key
5660           FROM affected
5661           CROSS JOIN graph_relations AS relation
5662                      INDEXED BY idx_graph_relations_source_kind
5663           JOIN graph_entities AS external
5664             ON external.entity_key = relation.target_entity_key
5665          WHERE relation.source_entity_key = affected.entity_key
5666            AND external.entity_kind = 'external'
5667         UNION ALL
5668         SELECT relation.source_entity_key
5669           FROM affected
5670           CROSS JOIN graph_relations AS relation
5671                      INDEXED BY idx_graph_relations_target_kind
5672           JOIN graph_entities AS external
5673             ON external.entity_key = relation.source_entity_key
5674          WHERE relation.target_entity_key = affected.entity_key
5675            AND external.entity_kind = 'external'"
5676    )
5677}
5678
5679/// Delete one affected local closure through statements prepared once per batch.
5680fn invalidate_repository_graph_paths(
5681    connection: &Connection,
5682    affected_paths: &[RepositoryNodePath],
5683    orphan_candidates: &mut HashSet<[u8; 32]>,
5684) -> DbResult<()> {
5685    let mut affected_relations = HashSet::new();
5686    let mut relation_occurrences = connection.prepare_cached(
5687        "SELECT relation_key FROM graph_relation_occurrences
5688          WHERE file_path = ?1 OR (file_path >= ?2 AND file_path < ?3)",
5689    )?;
5690    let mut occurrences = connection.prepare_cached(
5691        "DELETE FROM graph_relation_occurrences
5692          WHERE file_path = ?1 OR (file_path >= ?2 AND file_path < ?3)",
5693    )?;
5694    let mut coverage = connection.prepare_cached(
5695        "DELETE FROM graph_coverage
5696          INDEXED BY idx_graph_coverage_path
5697          WHERE scope_kind = 'path'
5698            AND (scope_path = ?1 OR (scope_path >= ?2 AND scope_path < ?3))",
5699    )?;
5700    let mut entities_by_path = connection.prepare_cached(
5701        "DELETE FROM graph_entities
5702          WHERE repository_path = ?1
5703             OR (repository_path >= ?2 AND repository_path < ?3)",
5704    )?;
5705    let mut entities_by_manifest = connection.prepare_cached(
5706        "DELETE FROM graph_entities
5707          WHERE manifest_path = ?1 OR (manifest_path >= ?2 AND manifest_path < ?3)",
5708    )?;
5709    for path in affected_paths {
5710        let path = path.as_str();
5711        let (descendant_start, descendant_end) = repository_descendant_bounds(path);
5712        let mut rows =
5713            relation_occurrences.query(params![path, descendant_start, descendant_end])?;
5714        while let Some(row) = rows.next()? {
5715            affected_relations.insert(fixed_bytes::<32>(
5716                "graph_relation_occurrences.relation_key",
5717                row.get::<_, Vec<u8>>(0)?,
5718            )?);
5719        }
5720        occurrences.execute(params![path, descendant_start, descendant_end])?;
5721        coverage.execute(params![path, descendant_start, descendant_end])?;
5722        entities_by_path.execute(params![path, descendant_start, descendant_end])?;
5723        entities_by_manifest.execute(params![path, descendant_start, descendant_end])?;
5724    }
5725    collect_external_relation_endpoints(connection, &affected_relations, orphan_candidates)?;
5726    let mut relation = connection.prepare_cached(
5727        "DELETE FROM graph_relations
5728          WHERE relation_key = ?1
5729            AND NOT EXISTS (
5730                SELECT 1 FROM graph_relation_occurrences
5731                 WHERE relation_key = ?1
5732            )",
5733    )?;
5734    for relation_key in affected_relations {
5735        relation.execute([&relation_key[..]])?;
5736    }
5737    Ok(())
5738}
5739
5740/// Retain external endpoints whose occurrence-backed relation may be removed.
5741fn collect_external_relation_endpoints(
5742    connection: &Connection,
5743    relation_keys: &HashSet<[u8; 32]>,
5744    candidates: &mut HashSet<[u8; 32]>,
5745) -> DbResult<()> {
5746    let mut endpoints = connection.prepare_cached(
5747        "SELECT source_entity_key, target_entity_key
5748           FROM graph_relations
5749          WHERE relation_key = ?1",
5750    )?;
5751    let mut is_external = connection.prepare_cached(
5752        "SELECT EXISTS(
5753            SELECT 1 FROM graph_entities
5754             WHERE entity_key = ?1 AND entity_kind = 'external'
5755        )",
5756    )?;
5757    for relation_key in relation_keys {
5758        let endpoints = endpoints
5759            .query_row([&relation_key[..]], |row| {
5760                Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, Option<Vec<u8>>>(1)?))
5761            })
5762            .optional()?;
5763        let Some((source, target)) = endpoints else {
5764            continue;
5765        };
5766        for endpoint in [Some(source), target].into_iter().flatten() {
5767            let endpoint = fixed_bytes::<32>("graph_relations endpoint", endpoint)?;
5768            if is_external.query_row([&endpoint[..]], |row| row.get::<_, bool>(0))? {
5769                candidates.insert(endpoint);
5770            }
5771        }
5772    }
5773    Ok(())
5774}
5775
5776/// Remove only candidate external entities that no surviving relation references.
5777fn remove_orphan_external_candidates(
5778    connection: &Connection,
5779    candidates: &HashSet<[u8; 32]>,
5780) -> DbResult<()> {
5781    let mut statement = connection.prepare_cached(
5782        "DELETE FROM graph_entities
5783          WHERE entity_key = ?1 AND entity_kind = 'external'
5784            AND NOT EXISTS (
5785                SELECT 1 FROM graph_relations INDEXED BY idx_graph_relations_source_kind
5786                 WHERE source_entity_key = ?1
5787            )
5788            AND NOT EXISTS (
5789                SELECT 1 FROM graph_relations INDEXED BY idx_graph_relations_target_kind
5790                 WHERE target_entity_key = ?1
5791            )",
5792    )?;
5793    for candidate in candidates {
5794        statement.execute([&candidate[..]])?;
5795    }
5796    Ok(())
5797}
5798
5799/// Return case-preserving indexed bounds for every slash-delimited descendant.
5800fn repository_descendant_bounds(path: &str) -> (String, String) {
5801    (format!("{path}/"), format!("{path}0"))
5802}
5803
5804/// Validate ownership and generation before any graph mutation occurs.
5805fn validate_graph_batch(
5806    project: ProjectInstanceId,
5807    generation: IndexGeneration,
5808    entities: &[GraphEntity],
5809    relations: &[LogicalRelation],
5810    occurrences: &[RelationOccurrence],
5811    coverage: &[CoverageRecord],
5812) -> DbResult<()> {
5813    if entities
5814        .iter()
5815        .any(|entity| entity.key().project() != project)
5816        || relations
5817            .iter()
5818            .any(|relation| relation.key().project() != project)
5819        || occurrences
5820            .iter()
5821            .any(|occurrence| occurrence.relation().project() != project)
5822    {
5823        return Err(DbError::GraphProjectIdentityMismatch {
5824            expected: project.to_string(),
5825            found: "record from another project".to_string(),
5826        });
5827    }
5828    if entities
5829        .iter()
5830        .any(|entity| entity.generation() != generation)
5831        || relations
5832            .iter()
5833            .any(|relation| relation.generation() != generation)
5834        || occurrences
5835            .iter()
5836            .any(|occurrence| occurrence.generation() != generation)
5837        || coverage
5838            .iter()
5839            .any(|record| record.generation() != generation)
5840    {
5841        return Err(
5842            projectatlas_core::graph::GraphContractError::GenerationMismatch {
5843                context: "repository graph publication batch",
5844            }
5845            .into(),
5846        );
5847    }
5848    Ok(())
5849}
5850
5851/// Insert one validated graph batch through cached normalized statements.
5852fn insert_graph_batch(
5853    connection: &Connection,
5854    project: ProjectInstanceId,
5855    entities: &[GraphEntity],
5856    relations: &[LogicalRelation],
5857    occurrences: &[RelationOccurrence],
5858    coverage: &[CoverageRecord],
5859) -> DbResult<()> {
5860    insert_entities(connection, project, entities)?;
5861    insert_relations(connection, project, relations)?;
5862    insert_occurrences(connection, occurrences)?;
5863    insert_coverage(connection, project, coverage)
5864}
5865
5866/// Insert typed entities while refusing compact-key collisions.
5867fn insert_entities<'entity>(
5868    connection: &Connection,
5869    project: ProjectInstanceId,
5870    entities: impl IntoIterator<Item = &'entity GraphEntity>,
5871) -> DbResult<()> {
5872    let mut insert = connection.prepare_cached(
5873        "INSERT INTO graph_entities(
5874            entity_key, project_instance_id, canonical_identity, entity_kind,
5875            repository_path, package_manager, package_name, manifest_path,
5876            symbol_name, symbol_kind, symbol_parent, symbol_signature,
5877            external_system, external_identity
5878         ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
5879         ON CONFLICT(entity_key) DO NOTHING",
5880    )?;
5881    let mut existing = connection.prepare_cached(
5882        "SELECT project_instance_id, canonical_identity
5883           FROM graph_entities WHERE entity_key = ?1",
5884    )?;
5885    for entity in entities {
5886        let columns = entity_columns(entity.selector());
5887        let key = entity.key().digest_bytes()?;
5888        insert.execute(params![
5889            &key[..],
5890            &project.as_bytes()[..],
5891            entity.key().canonical_identity(),
5892            columns.kind,
5893            columns.repository_path,
5894            columns.package_manager,
5895            columns.package_name,
5896            columns.manifest_path,
5897            columns.symbol_name,
5898            columns.symbol_kind,
5899            columns.symbol_parent,
5900            columns.symbol_signature,
5901            columns.external_system,
5902            columns.external_identity,
5903        ])?;
5904        let (stored_project, stored_canonical): (Vec<u8>, String) =
5905            existing.query_row([&key[..]], |row| Ok((row.get(0)?, row.get(1)?)))?;
5906        if fixed_bytes::<16>("graph_entities.project_instance_id", stored_project)?
5907            != project.as_bytes()
5908            || stored_canonical != entity.key().canonical_identity()
5909        {
5910            return Err(
5911                projectatlas_core::graph::GraphContractError::StableKeyCollision {
5912                    digest: entity.key().digest().to_string(),
5913                }
5914                .into(),
5915            );
5916        }
5917    }
5918    Ok(())
5919}
5920
5921/// Insert typed logical relations while allowing trust metadata to refresh.
5922fn insert_relations(
5923    connection: &Connection,
5924    project: ProjectInstanceId,
5925    relations: &[LogicalRelation],
5926) -> DbResult<()> {
5927    let mut statement = connection.prepare_cached(
5928        "INSERT INTO graph_relations(
5929            relation_key, project_instance_id, canonical_identity, source_entity_key,
5930            relation_scope, relation_kind, resolution_status, target_entity_key,
5931            reference_text, candidate_count, confidence, completeness
5932         ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
5933         ON CONFLICT(relation_key) DO UPDATE SET
5934            confidence = excluded.confidence,
5935            completeness = excluded.completeness
5936         WHERE graph_relations.project_instance_id = excluded.project_instance_id
5937           AND graph_relations.canonical_identity = excluded.canonical_identity
5938           AND graph_relations.source_entity_key = excluded.source_entity_key
5939           AND graph_relations.relation_scope = excluded.relation_scope
5940           AND graph_relations.relation_kind = excluded.relation_kind
5941           AND graph_relations.resolution_status = excluded.resolution_status
5942           AND graph_relations.target_entity_key IS excluded.target_entity_key
5943           AND graph_relations.reference_text IS excluded.reference_text
5944           AND graph_relations.candidate_count IS excluded.candidate_count",
5945    )?;
5946    for relation in relations {
5947        let (scope, kind) = relation_parts(relation.kind());
5948        let resolution = resolution_columns(relation.resolution())?;
5949        let key = relation.key().digest_bytes()?;
5950        let source = relation.source().digest_bytes()?;
5951        let changed = statement.execute(params![
5952            &key[..],
5953            &project.as_bytes()[..],
5954            relation.key().canonical_identity(),
5955            &source[..],
5956            scope,
5957            kind,
5958            resolution.status,
5959            resolution.target.as_ref().map(|target| &target[..]),
5960            resolution.reference,
5961            resolution.candidate_count,
5962            confidence_name(relation.confidence()),
5963            completeness_name(relation.completeness()),
5964        ])?;
5965        if changed == 0 {
5966            return Err(
5967                projectatlas_core::graph::GraphContractError::StableKeyCollision {
5968                    digest: relation.key().digest().to_string(),
5969                }
5970                .into(),
5971            );
5972        }
5973    }
5974    Ok(())
5975}
5976
5977/// Insert every exact source occurrence without duplicating logical evidence.
5978fn insert_occurrences(connection: &Connection, occurrences: &[RelationOccurrence]) -> DbResult<()> {
5979    let mut statement = connection.prepare_cached(
5980        "INSERT INTO graph_relation_occurrences(
5981            relation_key, file_path, start_line, start_column, end_line, end_column
5982         ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)
5983         ON CONFLICT(relation_key, file_path, start_line, start_column, end_line, end_column)
5984         DO NOTHING",
5985    )?;
5986    for occurrence in occurrences {
5987        let key = occurrence.relation().digest_bytes()?;
5988        let span = occurrence.span();
5989        statement.execute(params![
5990            &key[..],
5991            occurrence.file().as_str(),
5992            i64::from(span.start_line()),
5993            i64::from(span.start_column()),
5994            i64::from(span.end_line()),
5995            i64::from(span.end_column()),
5996        ])?;
5997    }
5998    Ok(())
5999}
6000
6001/// Replace coverage rows by their normalized identity.
6002fn insert_coverage(
6003    connection: &Connection,
6004    project: ProjectInstanceId,
6005    coverage: &[CoverageRecord],
6006) -> DbResult<()> {
6007    let mut remove = connection.prepare_cached(
6008        "DELETE FROM graph_coverage
6009          WHERE project_instance_id = ?1 AND scope_kind = ?2 AND scope_path IS ?3
6010            AND relation_scope IS ?4 AND relation_kind IS ?5",
6011    )?;
6012    let mut insert = connection.prepare_cached(
6013        "INSERT INTO graph_coverage(
6014            project_instance_id, scope_kind, scope_path, relation_scope, relation_kind,
6015            state, total, covered, omitted, reason, reached_limit
6016         ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
6017    )?;
6018    for record in coverage {
6019        let (scope_kind, scope_path) = coverage_scope_parts(record.scope());
6020        let (relation_scope, relation_kind) = record
6021            .relation()
6022            .map(relation_parts)
6023            .map_or((None, None), |(scope, kind)| (Some(scope), Some(kind)));
6024        let values = params![
6025            &project.as_bytes()[..],
6026            scope_kind,
6027            scope_path,
6028            relation_scope,
6029            relation_kind,
6030        ];
6031        remove.execute(values)?;
6032        insert.execute(params![
6033            &project.as_bytes()[..],
6034            scope_kind,
6035            scope_path,
6036            relation_scope,
6037            relation_kind,
6038            coverage_state_name(record.state()),
6039            sqlite_count("graph_coverage.total", record.total())?,
6040            sqlite_count("graph_coverage.covered", record.covered())?,
6041            sqlite_count("graph_coverage.omitted", record.omitted())?,
6042            record.reason().map(GraphIdentityText::as_str),
6043            record.reached_limit().map(GraphLimitKind::as_str),
6044        ])?;
6045    }
6046    Ok(())
6047}
6048
6049/// Rebind or remove identity details before replacing their owning graph paths.
6050///
6051/// The graph tables use one project-wide active generation rather than a
6052/// generation column per row. Identity details are the one bounded diagnostic
6053/// table that carries the generation explicitly, so replacement must prepare it
6054/// in the same savepoint before node deletion can cascade stale rows.
6055fn prepare_graph_identity_rejections(
6056    connection: &Connection,
6057    project: ProjectInstanceId,
6058    generation: IndexGeneration,
6059    affected_paths: Option<&[RepositoryNodePath]>,
6060) -> DbResult<()> {
6061    let generation = sqlite_count("graph_identity_rejections.generation", generation.get())?;
6062    let project_bytes = project.as_bytes();
6063    match affected_paths {
6064        None => {
6065            connection.execute(
6066                "DELETE FROM graph_identity_rejections WHERE project_instance_id = ?1",
6067                [project_bytes.as_slice()],
6068            )?;
6069        }
6070        Some(paths) => {
6071            connection.execute(
6072                "UPDATE graph_identity_rejections
6073                    SET generation = ?2
6074                  WHERE project_instance_id = ?1",
6075                params![project_bytes.as_slice(), generation],
6076            )?;
6077            let mut remove = connection.prepare_cached(
6078                "DELETE FROM graph_identity_rejections
6079                  WHERE project_instance_id = ?1
6080                    AND generation = ?2
6081                    AND (file_path = ?3 OR (file_path >= ?4 AND file_path < ?5))",
6082            )?;
6083            for path in paths {
6084                let (descendant_start, descendant_end) =
6085                    repository_descendant_bounds(path.as_str());
6086                remove.execute(params![
6087                    project_bytes.as_slice(),
6088                    generation,
6089                    path.as_str(),
6090                    descendant_start,
6091                    descendant_end,
6092                ])?;
6093            }
6094        }
6095    }
6096    Ok(())
6097}
6098
6099/// Insert bounded typed parser identity rejection rows through one statement.
6100fn insert_graph_identity_rejections(
6101    connection: &Connection,
6102    project: ProjectInstanceId,
6103    generation: IndexGeneration,
6104    rejections: &[GraphIdentityRejection],
6105) -> DbResult<()> {
6106    let mut insert = connection.prepare_cached(
6107        "INSERT INTO graph_identity_rejections(
6108            project_instance_id, generation, file_path,
6109            start_line, start_column, end_line, end_column,
6110             parser, field, reason, fact_index
6111         ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
6112         ON CONFLICT(
6113            project_instance_id, generation, file_path, start_line, start_column,
6114             end_line, end_column, parser, field, reason, fact_index
6115         ) DO NOTHING",
6116    )?;
6117    let generation = sqlite_count("graph_identity_rejections.generation", generation.get())?;
6118    for rejection in rejections {
6119        let span = rejection.span;
6120        insert.execute(params![
6121            &project.as_bytes()[..],
6122            generation,
6123            rejection.path.as_str(),
6124            i64::from(span.start_line()),
6125            i64::from(span.start_column()),
6126            i64::from(span.end_line()),
6127            i64::from(span.end_column()),
6128            rejection.parser.to_string(),
6129            rejection.field.to_string(),
6130            rejection.reason.to_string(),
6131            i64::try_from(rejection.fact_index).map_err(|_error| {
6132                GraphContractError::InvalidLimits {
6133                    reason: "graph identity rejection fact index exceeded SQLite integer range",
6134                }
6135            })?,
6136        ])?;
6137    }
6138    Ok(())
6139}
6140
6141/// Borrowed normalized selector columns for one entity insert.
6142struct EntityColumns<'selector> {
6143    /// Normalized selector variant.
6144    kind: &'static str,
6145    /// Folder, file, or symbol repository path.
6146    repository_path: Option<&'selector str>,
6147    /// Package ecosystem.
6148    package_manager: Option<&'selector str>,
6149    /// Package name.
6150    package_name: Option<&'selector str>,
6151    /// Package manifest path.
6152    manifest_path: Option<&'selector str>,
6153    /// Declaration name.
6154    symbol_name: Option<&'selector str>,
6155    /// Declaration kind.
6156    symbol_kind: Option<&'static str>,
6157    /// Optional containing declaration.
6158    symbol_parent: Option<&'selector str>,
6159    /// Stable declaration signature.
6160    symbol_signature: Option<&'selector str>,
6161    /// External namespace.
6162    external_system: Option<&'selector str>,
6163    /// External identity.
6164    external_identity: Option<&'selector str>,
6165}
6166
6167/// Map one typed selector to its normalized database columns.
6168fn entity_columns(selector: &EntitySelector) -> EntityColumns<'_> {
6169    match selector {
6170        EntitySelector::Project => EntityColumns {
6171            kind: "project",
6172            repository_path: None,
6173            package_manager: None,
6174            package_name: None,
6175            manifest_path: None,
6176            symbol_name: None,
6177            symbol_kind: None,
6178            symbol_parent: None,
6179            symbol_signature: None,
6180            external_system: None,
6181            external_identity: None,
6182        },
6183        EntitySelector::Folder { path } => EntityColumns {
6184            kind: "folder",
6185            repository_path: Some(path.as_str()),
6186            package_manager: None,
6187            package_name: None,
6188            manifest_path: None,
6189            symbol_name: None,
6190            symbol_kind: None,
6191            symbol_parent: None,
6192            symbol_signature: None,
6193            external_system: None,
6194            external_identity: None,
6195        },
6196        EntitySelector::File { path } => EntityColumns {
6197            kind: "file",
6198            repository_path: Some(path.as_str()),
6199            package_manager: None,
6200            package_name: None,
6201            manifest_path: None,
6202            symbol_name: None,
6203            symbol_kind: None,
6204            symbol_parent: None,
6205            symbol_signature: None,
6206            external_system: None,
6207            external_identity: None,
6208        },
6209        EntitySelector::Package { package } => EntityColumns {
6210            kind: "package",
6211            repository_path: None,
6212            package_manager: Some(package.manager.as_str()),
6213            package_name: Some(package.name.as_str()),
6214            manifest_path: Some(package.manifest.as_str()),
6215            symbol_name: None,
6216            symbol_kind: None,
6217            symbol_parent: None,
6218            symbol_signature: None,
6219            external_system: None,
6220            external_identity: None,
6221        },
6222        EntitySelector::Symbol { symbol } => EntityColumns {
6223            kind: "symbol",
6224            repository_path: Some(symbol.file.as_str()),
6225            package_manager: None,
6226            package_name: None,
6227            manifest_path: None,
6228            symbol_name: Some(symbol.name.as_str()),
6229            symbol_kind: Some(symbol_kind_name(symbol.kind)),
6230            symbol_parent: symbol.parent.as_ref().map(GraphIdentityText::as_str),
6231            symbol_signature: Some(symbol.signature.as_str()),
6232            external_system: None,
6233            external_identity: None,
6234        },
6235        EntitySelector::External { external } => EntityColumns {
6236            kind: "external",
6237            repository_path: None,
6238            package_manager: None,
6239            package_name: None,
6240            manifest_path: None,
6241            symbol_name: None,
6242            symbol_kind: None,
6243            symbol_parent: None,
6244            symbol_signature: None,
6245            external_system: Some(external.system.as_str()),
6246            external_identity: Some(external.identity.as_str()),
6247        },
6248    }
6249}
6250
6251/// Borrowed normalized resolution columns for one relation insert.
6252struct ResolutionColumns<'resolution> {
6253    /// Normalized resolution state.
6254    status: &'static str,
6255    /// Optional resolved or external target.
6256    target: Option<[u8; 32]>,
6257    /// Optional unresolved reference text.
6258    reference: Option<&'resolution str>,
6259    /// Optional ambiguous candidate count.
6260    candidate_count: Option<i64>,
6261}
6262
6263/// Map typed resolution state to normalized database columns.
6264fn resolution_columns(resolution: &RelationResolution) -> DbResult<ResolutionColumns<'_>> {
6265    match resolution {
6266        RelationResolution::Resolved { target, .. } => Ok(ResolutionColumns {
6267            status: "resolved",
6268            target: Some(target.digest_bytes()?),
6269            reference: None,
6270            candidate_count: None,
6271        }),
6272        RelationResolution::Ambiguous {
6273            reference,
6274            candidates,
6275        } => Ok(ResolutionColumns {
6276            status: "ambiguous",
6277            target: None,
6278            reference: Some(reference.as_str()),
6279            candidate_count: Some(i64::from(candidates.get())),
6280        }),
6281        RelationResolution::Unresolved { reference } => Ok(ResolutionColumns {
6282            status: "unresolved",
6283            target: None,
6284            reference: Some(reference.as_str()),
6285            candidate_count: None,
6286        }),
6287        RelationResolution::External { target, .. } => Ok(ResolutionColumns {
6288            status: "external",
6289            target: Some(target.digest_bytes()?),
6290            reference: None,
6291            candidate_count: None,
6292        }),
6293    }
6294}
6295
6296/// Reconstruct one typed entity and validate persisted key witnesses.
6297fn entity_from_row(
6298    row: EntityRow,
6299    expected_project: ProjectInstanceId,
6300    generation: IndexGeneration,
6301) -> DbResult<GraphEntity> {
6302    let project = project_from_blob("graph_entities.project_instance_id", row.project.clone())?;
6303    require_project(expected_project, project)?;
6304    validate_entity_row_shape(&row)?;
6305    let selector = match row.kind.as_str() {
6306        "project" => EntitySelector::Project,
6307        "folder" => EntitySelector::Folder {
6308            path: RepositoryNodePath::new(Path::new(required_text(
6309                "graph_entities",
6310                "folder path is missing",
6311                row.repository_path.as_deref(),
6312            )?))?,
6313        },
6314        "file" => EntitySelector::File {
6315            path: RepositoryFilePath::new(Path::new(required_text(
6316                "graph_entities",
6317                "file path is missing",
6318                row.repository_path.as_deref(),
6319            )?))?,
6320        },
6321        "package" => EntitySelector::Package {
6322            package: PackageSelector {
6323                manager: GraphIdentityText::new(required_text(
6324                    "graph_entities",
6325                    "package manager is missing",
6326                    row.package_manager.as_deref(),
6327                )?)?,
6328                name: GraphIdentityText::new(required_text(
6329                    "graph_entities",
6330                    "package name is missing",
6331                    row.package_name.as_deref(),
6332                )?)?,
6333                manifest: RepositoryFilePath::new(Path::new(required_text(
6334                    "graph_entities",
6335                    "package manifest is missing",
6336                    row.manifest_path.as_deref(),
6337                )?))?,
6338            },
6339        },
6340        "symbol" => EntitySelector::Symbol {
6341            symbol: SymbolSelector {
6342                file: RepositoryFilePath::new(Path::new(required_text(
6343                    "graph_entities",
6344                    "symbol file is missing",
6345                    row.repository_path.as_deref(),
6346                )?))?,
6347                name: GraphIdentityText::new(required_text(
6348                    "graph_entities",
6349                    "symbol name is missing",
6350                    row.symbol_name.as_deref(),
6351                )?)?,
6352                kind: parse_symbol_kind(required_text(
6353                    "graph_entities",
6354                    "symbol kind is missing",
6355                    row.symbol_kind.as_deref(),
6356                )?)?,
6357                parent: row.symbol_parent.map(GraphIdentityText::new).transpose()?,
6358                signature: GraphIdentityText::new(required_text(
6359                    "graph_entities",
6360                    "symbol signature is missing",
6361                    row.symbol_signature.as_deref(),
6362                )?)?,
6363            },
6364        },
6365        "external" => EntitySelector::External {
6366            external: ExternalSelector {
6367                system: GraphIdentityText::new(required_text(
6368                    "graph_entities",
6369                    "external system is missing",
6370                    row.external_system.as_deref(),
6371                )?)?,
6372                identity: GraphIdentityText::new(required_text(
6373                    "graph_entities",
6374                    "external identity is missing",
6375                    row.external_identity.as_deref(),
6376                )?)?,
6377            },
6378        },
6379        value => {
6380            return Err(DbError::InvalidEnum {
6381                field: "graph_entities.entity_kind",
6382                value: value.to_string(),
6383            });
6384        }
6385    };
6386    let entity = GraphEntity::new(project, selector, generation)?;
6387    validate_entity_key(&entity, row.key, &row.canonical)?;
6388    Ok(entity)
6389}
6390
6391/// Validate selector-column shape independently of physical schema checks.
6392fn validate_entity_row_shape(row: &EntityRow) -> DbResult<()> {
6393    let repository = row.repository_path.is_some();
6394    let package = (
6395        row.package_manager.is_some(),
6396        row.package_name.is_some(),
6397        row.manifest_path.is_some(),
6398    );
6399    let symbol = (
6400        row.symbol_name.is_some(),
6401        row.symbol_kind.is_some(),
6402        row.symbol_parent.is_some(),
6403        row.symbol_signature.is_some(),
6404    );
6405    let external = (
6406        row.external_system.is_some(),
6407        row.external_identity.is_some(),
6408    );
6409    let valid = match row.kind.as_str() {
6410        "project" => {
6411            !repository
6412                && package == (false, false, false)
6413                && symbol == (false, false, false, false)
6414                && external == (false, false)
6415        }
6416        "folder" | "file" => {
6417            repository
6418                && package == (false, false, false)
6419                && symbol == (false, false, false, false)
6420                && external == (false, false)
6421        }
6422        "package" => {
6423            !repository
6424                && package == (true, true, true)
6425                && symbol == (false, false, false, false)
6426                && external == (false, false)
6427        }
6428        "symbol" => {
6429            repository
6430                && package == (false, false, false)
6431                && symbol.0
6432                && symbol.1
6433                && symbol.3
6434                && external == (false, false)
6435        }
6436        "external" => {
6437            !repository
6438                && package == (false, false, false)
6439                && symbol == (false, false, false, false)
6440                && external == (true, true)
6441        }
6442        _ => true,
6443    };
6444    if !valid {
6445        return Err(DbError::GraphRowShape {
6446            table: "graph_entities",
6447            reason: "selector columns contradict entity kind",
6448        });
6449    }
6450    Ok(())
6451}
6452
6453/// Build the key-only endpoint-ranking query used by resolved graph previews.
6454fn resolved_relation_hub_keys_sql() -> &'static str {
6455    "WITH endpoints(entity_key) AS (
6456         SELECT relation.source_entity_key
6457           FROM graph_relations AS relation INDEXED BY idx_graph_relations_kind_resolution
6458          WHERE relation.project_instance_id = ?1
6459            AND relation.relation_scope = ?2
6460            AND relation.relation_kind = ?3
6461            AND relation.resolution_status = ?4
6462            AND relation.target_entity_key IS NOT NULL
6463            AND relation.source_entity_key <> relation.target_entity_key
6464         UNION ALL
6465         SELECT relation.target_entity_key
6466           FROM graph_relations AS relation INDEXED BY idx_graph_relations_kind_resolution
6467          WHERE relation.project_instance_id = ?1
6468            AND relation.relation_scope = ?2
6469            AND relation.relation_kind = ?3
6470            AND relation.resolution_status = ?4
6471            AND relation.target_entity_key IS NOT NULL
6472            AND relation.source_entity_key <> relation.target_entity_key
6473     ), endpoint_degrees(entity_key, degree) AS (
6474         SELECT entity_key, COUNT(*)
6475           FROM endpoints
6476          GROUP BY entity_key
6477     )
6478     SELECT entity_key
6479       FROM endpoint_degrees
6480      ORDER BY endpoint_degrees.degree DESC, endpoint_degrees.entity_key
6481      LIMIT ?5"
6482}
6483
6484/// Return whether an optionally serialized graph-request field is its default.
6485fn is_default<T: Default + PartialEq>(value: &T) -> bool {
6486    value == &T::default()
6487}
6488
6489/// Build one direction-owned batched adjacency statement.
6490fn adjacency_relation_sql(
6491    frontier_count: usize,
6492    direction: RepositoryGraphDirection,
6493    continuation_index: Option<usize>,
6494    relation_filter: bool,
6495    resolved_only: bool,
6496    include_documents: bool,
6497) -> String {
6498    let (key_column, index_name) = match direction {
6499        RepositoryGraphDirection::Outbound => {
6500            ("source_entity_key", "idx_graph_relations_source_kind")
6501        }
6502        RepositoryGraphDirection::Inbound => {
6503            ("target_entity_key", "idx_graph_relations_target_kind")
6504        }
6505    };
6506    let request = if resolved_only {
6507        "request(project_instance_id, resolution_status) AS (VALUES (?, ?))"
6508    } else {
6509        "request(project_instance_id) AS (VALUES (?))"
6510    };
6511    let document_filter = if relation_filter || include_documents {
6512        ""
6513    } else {
6514        "AND NOT (
6515             relation.relation_scope = 'extended'
6516             AND relation.relation_kind = 'documents'
6517         )"
6518    };
6519    let relation_filter = if relation_filter {
6520        "AND relation.relation_scope = ? AND relation.relation_kind = ?"
6521    } else {
6522        ""
6523    };
6524    let resolution_filter = if resolved_only {
6525        "AND relation.resolution_status = request.resolution_status
6526         AND relation.target_entity_key IS NOT NULL
6527         AND relation.source_entity_key <> relation.target_entity_key"
6528    } else {
6529        ""
6530    };
6531    let branches = (continuation_index.unwrap_or(0)..frontier_count)
6532        .map(|frontier_index| {
6533            let continuation = if continuation_index == Some(frontier_index) {
6534                "AND (relation.relation_scope, relation.relation_kind,
6535                      relation.relation_key) > (?, ?, ?)"
6536                    .to_string()
6537            } else {
6538                String::new()
6539            };
6540            format!(
6541                "SELECT * FROM (
6542                     SELECT {frontier_index} AS frontier_index,
6543                            relation.relation_key, relation.project_instance_id,
6544                            relation.canonical_identity, relation.source_entity_key,
6545                            relation.relation_scope, relation.relation_kind,
6546                            relation.resolution_status, relation.target_entity_key,
6547                            relation.reference_text, relation.candidate_count,
6548                            relation.document_unresolved_reason,
6549                            relation.confidence, relation.completeness
6550                       FROM graph_relations AS relation INDEXED BY {index_name}
6551                       CROSS JOIN request
6552                      WHERE relation.{key_column} = ?
6553                        AND relation.project_instance_id = request.project_instance_id
6554                        {relation_filter}
6555                        {resolution_filter}
6556                        {document_filter}
6557                        {continuation}
6558                      ORDER BY relation.relation_scope, relation.relation_kind,
6559                               relation.relation_key
6560                      LIMIT ?
6561                 )"
6562            )
6563        })
6564        .collect::<Vec<_>>()
6565        .join(" UNION ALL ");
6566    format!(
6567        "WITH {request}
6568         {branches}
6569         ORDER BY frontier_index, relation_scope, relation_kind,
6570                  relation_key
6571         LIMIT ?"
6572    )
6573}
6574
6575/// Build one anonymous fixed-column `VALUES` clause.
6576fn graph_values_clause(rows: usize, columns: usize) -> String {
6577    let row = format!("({})", vec!["?"; columns].join(", "));
6578    vec![row; rows].join(", ")
6579}
6580
6581/// Build one indexed stable-key entity hydration statement.
6582fn graph_entity_hydration_sql(entity_count: usize) -> String {
6583    format!(
6584        "WITH requested(entity_key) AS (VALUES {})
6585         SELECT entity.entity_key, entity.project_instance_id,
6586                entity.canonical_identity, entity.entity_kind,
6587                entity.repository_path, entity.package_manager,
6588                entity.package_name, entity.manifest_path,
6589                entity.symbol_name, entity.symbol_kind,
6590                entity.symbol_parent, entity.symbol_signature,
6591                entity.external_system, entity.external_identity
6592           FROM requested
6593           JOIN graph_entities AS entity
6594             ON entity.entity_key = requested.entity_key
6595          WHERE entity.project_instance_id = ?",
6596        graph_values_clause(entity_count, 1),
6597    )
6598}
6599
6600/// Build one project-scoped stable-key relation hydration statement.
6601fn graph_relation_hydration_sql(relation_count: usize) -> String {
6602    format!(
6603        "WITH requested(relation_key) AS (VALUES {})
6604         SELECT relation.relation_key, relation.project_instance_id,
6605                relation.canonical_identity, relation.source_entity_key,
6606                relation.relation_scope, relation.relation_kind,
6607                relation.resolution_status, relation.target_entity_key,
6608                relation.reference_text, relation.candidate_count,
6609                relation.document_unresolved_reason,
6610                relation.confidence, relation.completeness
6611           FROM requested
6612           JOIN graph_relations AS relation INDEXED BY idx_graph_relations_project_key
6613             ON relation.relation_key = requested.relation_key
6614            AND relation.project_instance_id = ?",
6615        graph_values_clause(relation_count, 1),
6616    )
6617}
6618
6619/// Build one indexed family query with source classification projected once.
6620fn classified_relation_family_sql(selection: ContentSelection) -> String {
6621    let selection_predicate = match selection {
6622        ContentSelection::UnspecifiedLegacy => "",
6623        ContentSelection::Source | ContentSelection::Documentation => {
6624            "AND source_classification.classification = ?"
6625        }
6626        ContentSelection::Both => "AND source_classification.classification IN (?, ?)",
6627    };
6628    format!(
6629        "SELECT relation.relation_key, relation.project_instance_id,
6630                relation.canonical_identity, relation.source_entity_key,
6631                relation.relation_scope, relation.relation_kind,
6632                relation.resolution_status, relation.target_entity_key,
6633                relation.reference_text, relation.candidate_count,
6634                relation.document_unresolved_reason,
6635                relation.confidence, relation.completeness,
6636                source_classification.classification
6637           FROM graph_relations AS relation INDEXED BY idx_graph_relations_kind_order
6638           JOIN graph_entities AS source
6639             ON source.project_instance_id = relation.project_instance_id
6640            AND source.entity_key = relation.source_entity_key
6641           LEFT JOIN file_content_classifications AS source_classification
6642             ON source_classification.path = CASE source.entity_kind
6643                    WHEN 'file' THEN source.repository_path
6644                    WHEN 'symbol' THEN source.repository_path
6645                    WHEN 'package' THEN source.manifest_path
6646                END
6647          WHERE relation.project_instance_id = ?
6648            AND relation.relation_scope = ?
6649            AND relation.relation_kind = ?
6650            {selection_predicate}
6651          ORDER BY relation.relation_key
6652          LIMIT ?"
6653    )
6654}
6655
6656/// Build direction-independent per-relation occurrence branches.
6657fn occurrence_pages_sql(relation_count: usize) -> String {
6658    let branches = (0..relation_count)
6659        .map(|index| {
6660            format!(
6661                "SELECT {index} AS relation_index, relation_key, file_path,
6662                        start_line, start_column, end_line, end_column
6663                   FROM (
6664                        SELECT relation_key, file_path, start_line, start_column,
6665                               end_line, end_column
6666                          FROM graph_relation_occurrences
6667                         WHERE relation_key = ?
6668                         ORDER BY file_path, start_line, start_column,
6669                                  end_line, end_column
6670                         LIMIT ?
6671                   ) AS occurrence_page_{index}"
6672            )
6673        })
6674        .collect::<Vec<_>>()
6675        .join(" UNION ALL ");
6676    format!(
6677        "{branches}
6678         ORDER BY relation_index, file_path, start_line, start_column,
6679                  end_line, end_column"
6680    )
6681}
6682
6683/// Build the set-oriented exact-path coverage hydration statement.
6684fn path_coverage_sql(path_count: usize) -> String {
6685    let path_bindings = vec!["?"; path_count].join(", ");
6686    format!(
6687        "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
6688                relation_kind, state, total, covered, omitted, reason, reached_limit,
6689                NULL, NULL
6690           FROM graph_coverage
6691          WHERE project_instance_id = ?
6692            AND scope_kind = ?
6693            AND scope_path IN ({path_bindings})
6694          ORDER BY scope_path, relation_scope, relation_kind, state, id
6695          LIMIT ?"
6696    )
6697}
6698
6699/// Load every unique relation endpoint through bounded set-oriented joins.
6700fn load_relation_entities(
6701    store: &AtlasStore,
6702    rows: &[RelationRow],
6703    project: ProjectInstanceId,
6704    generation: IndexGeneration,
6705    control: Option<&IndexWorkControl>,
6706) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
6707    let references = rows.iter().collect::<Vec<_>>();
6708    load_relation_entity_references(store, &references, project, generation, control)
6709}
6710
6711/// Load relation endpoint references shared by ordinary and adjacency pages.
6712fn load_relation_entity_references(
6713    store: &AtlasStore,
6714    rows: &[&RelationRow],
6715    project: ProjectInstanceId,
6716    generation: IndexGeneration,
6717    control: Option<&IndexWorkControl>,
6718) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
6719    load_relation_entity_references_metered(store, rows, project, generation, control, None)
6720}
6721
6722/// Load relation endpoint references with optional exact batch accounting.
6723fn load_relation_entity_references_metered(
6724    store: &AtlasStore,
6725    rows: &[&RelationRow],
6726    project: ProjectInstanceId,
6727    generation: IndexGeneration,
6728    control: Option<&IndexWorkControl>,
6729    meter: Option<&mut RepositoryGraphReadMeter>,
6730) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
6731    let mut digests = BTreeSet::new();
6732    for row in rows {
6733        let row_project =
6734            project_from_blob("graph_relations.project_instance_id", row.project.clone())?;
6735        require_project(project, row_project)?;
6736        digests.insert(fixed_bytes::<32>(
6737            "graph_relations.source_entity_key",
6738            row.source.clone(),
6739        )?);
6740        if let Some(target) = &row.target {
6741            digests.insert(fixed_bytes::<32>(
6742                "graph_relations.target_entity_key",
6743                target.clone(),
6744            )?);
6745        }
6746    }
6747    load_graph_entities_by_digest_metered(
6748        store,
6749        &digests.into_iter().collect::<Vec<_>>(),
6750        project,
6751        generation,
6752        control,
6753        meter,
6754    )
6755}
6756
6757/// Hydrate one unique stable-key set with optional exact batch accounting.
6758fn load_graph_entities_by_digest_metered(
6759    store: &AtlasStore,
6760    digests: &[[u8; 32]],
6761    project: ProjectInstanceId,
6762    generation: IndexGeneration,
6763    control: Option<&IndexWorkControl>,
6764    mut meter: Option<&mut RepositoryGraphReadMeter>,
6765) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
6766    let mut entities = HashMap::with_capacity(digests.len());
6767    for chunk in digests.chunks(GRAPH_ENTITY_HYDRATION_CHUNK) {
6768        if let Some(control) = control {
6769            control.check(IndexWorkStage::RepositoryTraversal)?;
6770        }
6771        let sql = graph_entity_hydration_sql(chunk.len());
6772        let mut bindings = chunk
6773            .iter()
6774            .map(|digest| Value::Blob(digest.to_vec()))
6775            .collect::<Vec<_>>();
6776        bindings.push(Value::Blob(project.as_bytes().to_vec()));
6777        let raw = with_sqlite_read_progress(
6778            &store.connection,
6779            control,
6780            IndexWorkStage::RepositoryTraversal,
6781            || {
6782                let mut statement = store.connection.prepare(&sql)?;
6783                let rows = statement.query(params_from_iter(bindings.iter()))?;
6784                if let Some(meter) = meter.as_deref_mut() {
6785                    collect_entity_rows_metered(rows, meter)
6786                } else {
6787                    collect_entity_rows(rows)
6788                }
6789            },
6790        )?;
6791        for row in raw {
6792            let entity = entity_from_row(row, project, generation)?;
6793            let digest = entity.key().digest_bytes()?;
6794            if entities.contains_key(&digest) {
6795                return Err(DbError::GraphRowShape {
6796                    table: "graph_entities",
6797                    reason: "batched entity hydration returned a duplicate key",
6798                });
6799            }
6800            if let Some(meter) = meter.as_deref_mut() {
6801                meter.record_entity(&entity)?;
6802            }
6803            entities.insert(digest, entity);
6804        }
6805    }
6806    Ok(entities)
6807}
6808
6809/// Construct the compatibility envelope used by legacy bounded read wrappers.
6810fn maximum_repository_graph_read_budget() -> DbResult<RepositoryGraphReadBudget> {
6811    Ok(RepositoryGraphReadBudget::new(
6812        RepositoryGraphReadBudget::MAX_REQUESTED_ROWS,
6813        RepositoryGraphReadBudget::MAX_RETURNED_ROWS,
6814        RepositoryGraphReadBudget::MAX_DECODED_BYTES,
6815        RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES,
6816        RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
6817    )?)
6818}
6819
6820/// Validate one bounded unique exact-path coverage request.
6821fn validate_path_coverage_request(paths: &[RepositoryNodePath]) -> DbResult<()> {
6822    if paths.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
6823        return Err(GraphContractError::InvalidLimits {
6824            reason: "graph coverage path set exceeds the product ceiling",
6825        }
6826        .into());
6827    }
6828    if paths
6829        .iter()
6830        .map(RepositoryNodePath::as_str)
6831        .collect::<BTreeSet<_>>()
6832        .len()
6833        != paths.len()
6834    {
6835        return Err(GraphContractError::InvalidLimits {
6836            reason: "graph coverage paths must be unique",
6837        }
6838        .into());
6839    }
6840    Ok(())
6841}
6842
6843/// Validate the bounded unique stable-key set shared by cursor hydration calls.
6844fn validate_graph_hydration_request(digests: &[[u8; 32]]) -> DbResult<()> {
6845    if digests.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
6846        return Err(GraphContractError::InvalidLimits {
6847            reason: "graph hydration key set exceeds the product ceiling",
6848        }
6849        .into());
6850    }
6851    if digests.iter().copied().collect::<HashSet<_>>().len() != digests.len() {
6852        return Err(GraphContractError::InvalidLimits {
6853            reason: "graph hydration keys must be unique",
6854        }
6855        .into());
6856    }
6857    Ok(())
6858}
6859
6860/// Return the exact repository path that can own authored purpose for an entity.
6861fn graph_entity_purpose_owner(entity: &GraphEntity) -> Option<&str> {
6862    match entity.selector() {
6863        EntitySelector::Project => Some("."),
6864        EntitySelector::Folder { path } => Some(path.as_str()),
6865        EntitySelector::File { path } => Some(path.as_str()),
6866        EntitySelector::Package { package } => Some(package.manifest.as_str()),
6867        EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
6868        EntitySelector::External { .. } => None,
6869    }
6870}
6871
6872/// Return the admitted file path that owns an entity classification.
6873fn graph_entity_classification_owner(entity: &GraphEntity) -> Option<&str> {
6874    match entity.selector() {
6875        EntitySelector::File { path } => Some(path.as_str()),
6876        EntitySelector::Package { package } => Some(package.manifest.as_str()),
6877        EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
6878        EntitySelector::Project
6879        | EntitySelector::Folder { .. }
6880        | EntitySelector::External { .. } => None,
6881    }
6882}
6883
6884/// Reconstruct one classified family row without a follow-up classification read.
6885fn classified_relation_detail_from_row(
6886    entities: &HashMap<[u8; 32], GraphEntity>,
6887    row: ClassifiedRelationRow,
6888    expected_project: ProjectInstanceId,
6889    generation: IndexGeneration,
6890) -> DbResult<RepositoryGraphClassifiedRelationRow> {
6891    let source_classification = row
6892        .source_classification
6893        .map(parse_classification)
6894        .transpose()?;
6895    let detail = relation_detail_from_row(entities, row.relation, expected_project, generation)?;
6896    match (
6897        graph_entity_classification_owner(&detail.source),
6898        source_classification,
6899    ) {
6900        (Some(path), None) => Err(DbError::FileContentClassificationMissing {
6901            path: path.to_string(),
6902        }),
6903        (None, Some(_)) => Err(DbError::GraphRowShape {
6904            table: "file_content_classifications",
6905            reason: "non-file-bearing graph source retained a classification",
6906        }),
6907        (_, source_classification) => Ok(RepositoryGraphClassifiedRelationRow {
6908            detail,
6909            source_classification,
6910        }),
6911    }
6912}
6913
6914/// Reconstruct one relation and retain its already-hydrated endpoint entities.
6915fn relation_detail_from_row(
6916    entities: &HashMap<[u8; 32], GraphEntity>,
6917    row: RelationRow,
6918    expected_project: ProjectInstanceId,
6919    generation: IndexGeneration,
6920) -> DbResult<RepositoryGraphRelationRow> {
6921    let source_key = fixed_bytes::<32>("graph_relations.source_entity_key", row.source.clone())?;
6922    let target_key = row
6923        .target
6924        .as_ref()
6925        .map(|target| fixed_bytes::<32>("graph_relations.target_entity_key", target.clone()))
6926        .transpose()?;
6927    let document_unresolved_reason = document_unresolved_reason_from_row(&row)?;
6928    let relation = relation_from_row(entities, row, expected_project, generation)?;
6929    let source = entities
6930        .get(&source_key)
6931        .cloned()
6932        .ok_or(DbError::GraphRowShape {
6933            table: "graph_relations",
6934            reason: "source entity is missing",
6935        })?;
6936    let target = target_key
6937        .map(|key| {
6938            entities.get(&key).cloned().ok_or(DbError::GraphRowShape {
6939                table: "graph_relations",
6940                reason: "retained target entity is missing",
6941            })
6942        })
6943        .transpose()?;
6944    Ok(RepositoryGraphRelationRow {
6945        relation,
6946        source,
6947        target,
6948        document_unresolved_reason,
6949    })
6950}
6951
6952/// Reconstruct one typed logical relation through existing domain constructors.
6953fn relation_from_row(
6954    entities: &HashMap<[u8; 32], GraphEntity>,
6955    row: RelationRow,
6956    expected_project: ProjectInstanceId,
6957    generation: IndexGeneration,
6958) -> DbResult<LogicalRelation> {
6959    let _document_unresolved_reason = document_unresolved_reason_from_row(&row)?;
6960    let project = project_from_blob("graph_relations.project_instance_id", row.project.clone())?;
6961    require_project(expected_project, project)?;
6962    let source_key = fixed_bytes::<32>("graph_relations.source_entity_key", row.source.clone())?;
6963    let source = entities
6964        .get(&source_key)
6965        .cloned()
6966        .ok_or(DbError::GraphRowShape {
6967            table: "graph_relations",
6968            reason: "source entity is missing",
6969        })?;
6970    let kind = parse_relation_kind(&row.relation_scope, &row.relation_kind)?;
6971    let resolution = match row.resolution_status.as_str() {
6972        "resolved" => {
6973            require_relation_resolution_shape(&row, true, false, false)?;
6974            let target_key = fixed_bytes::<32>(
6975                "graph_relations.target_entity_key",
6976                row.target.clone().ok_or(DbError::GraphRowShape {
6977                    table: "graph_relations",
6978                    reason: "resolved target is missing",
6979                })?,
6980            )?;
6981            let target = entities
6982                .get(&target_key)
6983                .cloned()
6984                .ok_or(DbError::GraphRowShape {
6985                    table: "graph_relations",
6986                    reason: "resolved target entity is missing",
6987                })?;
6988            RelationResolution::resolved(&target)?
6989        }
6990        "external" => {
6991            require_relation_resolution_shape(&row, true, false, false)?;
6992            let target_key = fixed_bytes::<32>(
6993                "graph_relations.target_entity_key",
6994                row.target.clone().ok_or(DbError::GraphRowShape {
6995                    table: "graph_relations",
6996                    reason: "external target is missing",
6997                })?,
6998            )?;
6999            let target = entities
7000                .get(&target_key)
7001                .cloned()
7002                .ok_or(DbError::GraphRowShape {
7003                    table: "graph_relations",
7004                    reason: "external target entity is missing",
7005                })?;
7006            RelationResolution::external(&target)?
7007        }
7008        "ambiguous" => {
7009            require_relation_resolution_shape(&row, false, true, true)?;
7010            let candidates = positive_u32(
7011                "graph_relations.candidate_count",
7012                row.candidate_count.ok_or(DbError::GraphRowShape {
7013                    table: "graph_relations",
7014                    reason: "ambiguous candidate count is missing",
7015                })?,
7016            )?;
7017            RelationResolution::Ambiguous {
7018                reference: GraphIdentityText::new(row.reference.clone().ok_or(
7019                    DbError::GraphRowShape {
7020                        table: "graph_relations",
7021                        reason: "ambiguous reference is missing",
7022                    },
7023                )?)?,
7024                candidates,
7025            }
7026        }
7027        "unresolved" => {
7028            require_relation_resolution_shape(&row, false, true, false)?;
7029            RelationResolution::Unresolved {
7030                reference: GraphIdentityText::new(row.reference.clone().ok_or(
7031                    DbError::GraphRowShape {
7032                        table: "graph_relations",
7033                        reason: "unresolved reference is missing",
7034                    },
7035                )?)?,
7036            }
7037        }
7038        value => {
7039            return Err(DbError::InvalidEnum {
7040                field: "graph_relations.resolution_status",
7041                value: value.to_string(),
7042            });
7043        }
7044    };
7045    let relation = LogicalRelation::new(
7046        &source,
7047        kind,
7048        resolution,
7049        parse_confidence(&row.confidence)?,
7050        parse_completeness(&row.completeness)?,
7051        generation,
7052    )?;
7053    validate_relation_key(&relation, row.key, &row.canonical)?;
7054    Ok(relation)
7055}
7056
7057/// Validate and decode the optional unresolved-document reason columns.
7058fn document_unresolved_reason_from_row(
7059    row: &RelationRow,
7060) -> DbResult<Option<DocumentTargetUnresolvedReason>> {
7061    match (
7062        row.relation_scope.as_str(),
7063        row.relation_kind.as_str(),
7064        row.resolution_status.as_str(),
7065        row.document_unresolved_reason.as_deref(),
7066    ) {
7067        ("extended", "documents", "unresolved", Some(value)) => {
7068            DocumentTargetUnresolvedReason::from_db(value)
7069                .map(Some)
7070                .ok_or_else(|| DbError::InvalidEnum {
7071                    field: "graph_relations.document_unresolved_reason",
7072                    value: value.to_string(),
7073                })
7074        }
7075        ("extended", "documents", "unresolved", None) => Err(DbError::GraphRowShape {
7076            table: "graph_relations",
7077            reason: "unresolved document relation is missing its closed reason",
7078        }),
7079        (_, _, _, None) => Ok(None),
7080        _ => Err(DbError::GraphRowShape {
7081            table: "graph_relations",
7082            reason: "document unresolved reason contradicts relation family or status",
7083        }),
7084    }
7085}
7086
7087/// Reject contradictory normalized resolution columns.
7088fn require_relation_resolution_shape(
7089    row: &RelationRow,
7090    target_required: bool,
7091    reference_required: bool,
7092    candidates_required: bool,
7093) -> DbResult<()> {
7094    let valid = row.target.is_some() == target_required
7095        && row.reference.is_some() == reference_required
7096        && row.candidate_count.is_some() == candidates_required;
7097    if !valid {
7098        return Err(DbError::GraphRowShape {
7099            table: "graph_relations",
7100            reason: "resolution columns contradict status",
7101        });
7102    }
7103    Ok(())
7104}
7105
7106/// Reconstruct one exact relation occurrence.
7107fn occurrence_from_row(
7108    row: OccurrenceRow,
7109    relation: &LogicalRelation,
7110    generation: IndexGeneration,
7111) -> DbResult<RelationOccurrence> {
7112    let stored_key = fixed_bytes::<32>("graph_relation_occurrences.relation_key", row.relation)?;
7113    if stored_key != relation.key().digest_bytes()? {
7114        return Err(DbError::GraphRowShape {
7115            table: "graph_relation_occurrences",
7116            reason: "occurrence relation key does not match query",
7117        });
7118    }
7119    RelationOccurrence::new(
7120        relation,
7121        RepositoryFilePath::new(Path::new(&row.file_path))?,
7122        SourceSpan::new(
7123            positive_u32_value("graph_relation_occurrences.start_line", row.start_line)?,
7124            nonnegative_u32("graph_relation_occurrences.start_column", row.start_column)?,
7125            positive_u32_value("graph_relation_occurrences.end_line", row.end_line)?,
7126            nonnegative_u32("graph_relation_occurrences.end_column", row.end_column)?,
7127        )?,
7128        generation,
7129    )
7130    .map_err(Into::into)
7131}
7132
7133/// Reconstruct one graph coverage record and verify project ownership.
7134fn coverage_from_row(
7135    row: CoverageRow,
7136    expected_project: ProjectInstanceId,
7137    generation: IndexGeneration,
7138) -> DbResult<CoverageRecord> {
7139    let project = project_from_blob("graph_coverage.project_instance_id", row.project)?;
7140    require_project(expected_project, project)?;
7141    let scope = match (row.scope_kind.as_str(), row.scope_path) {
7142        ("project", None) => CoverageScope::Project,
7143        ("path", Some(path)) => CoverageScope::Path {
7144            path: RepositoryNodePath::new(Path::new(&path))?,
7145        },
7146        ("project" | "path", _) => {
7147            return Err(DbError::GraphRowShape {
7148                table: "graph_coverage",
7149                reason: "scope columns contradict scope kind",
7150            });
7151        }
7152        (value, _) => {
7153            return Err(DbError::InvalidEnum {
7154                field: "graph_coverage.scope_kind",
7155                value: value.to_string(),
7156            });
7157        }
7158    };
7159    let relation = match (row.relation_scope, row.relation_kind) {
7160        (None, None) => None,
7161        (Some(scope), Some(kind)) => Some(parse_relation_kind(&scope, &kind)?),
7162        _ => {
7163            return Err(DbError::GraphRowShape {
7164                table: "graph_coverage",
7165                reason: "relation scope and kind must both be present or absent",
7166            });
7167        }
7168    };
7169    let persisted_total = nonnegative_u64("graph_coverage.total", row.total)?;
7170    let covered = nonnegative_u64("graph_coverage.covered", row.covered)?;
7171    let omitted = nonnegative_u64("graph_coverage.omitted", row.omitted)?;
7172    let persisted_state = parse_coverage_state(&row.state)?;
7173    let state = if persisted_state == CoverageState::Complete
7174        && covered == 0
7175        && omitted == 0
7176        && relation == Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents))
7177    {
7178        CoverageState::NoCandidates
7179    } else {
7180        persisted_state
7181    };
7182    let record = CoverageRecord::new(
7183        scope,
7184        relation,
7185        state,
7186        covered,
7187        omitted,
7188        generation,
7189        row.reason.map(GraphIdentityText::new).transpose()?,
7190        row.reached_limit
7191            .as_deref()
7192            .map(parse_limit_kind)
7193            .transpose()?,
7194    )?;
7195    if record.total() != persisted_total {
7196        return Err(DbError::GraphRowShape {
7197            table: "graph_coverage",
7198            reason: "total does not equal covered plus omitted",
7199        });
7200    }
7201    Ok(record)
7202}
7203
7204/// Reconstruct discovered coverage together with strict parser provenance.
7205fn coverage_discovery_from_row(
7206    row: CoverageRow,
7207    expected_project: ProjectInstanceId,
7208    generation: IndexGeneration,
7209) -> DbResult<RepositoryCoverageRow> {
7210    let parser = row
7211        .parser
7212        .as_deref()
7213        .map(|value| parse_parser_kind("source_parse_metadata.source_parser", value))
7214        .transpose()?;
7215    let provider = row
7216        .provider
7217        .as_deref()
7218        .map(|value| parse_parser_kind("source_parse_metadata.fact_parser", value))
7219        .transpose()?;
7220    let coverage = coverage_from_row(row, expected_project, generation)?;
7221    Ok(RepositoryCoverageRow {
7222        coverage,
7223        parser,
7224        provider,
7225    })
7226}
7227
7228/// Fail with both project identities when normalized ownership differs.
7229fn require_project(expected: ProjectInstanceId, found: ProjectInstanceId) -> DbResult<()> {
7230    if expected != found {
7231        return Err(DbError::GraphProjectIdentityMismatch {
7232            expected: expected.to_string(),
7233            found: found.to_string(),
7234        });
7235    }
7236    Ok(())
7237}
7238
7239/// Validate one stored entity key and canonical collision witness.
7240fn validate_entity_key(
7241    entity: &GraphEntity,
7242    stored_key: Vec<u8>,
7243    stored_canonical: &str,
7244) -> DbResult<()> {
7245    let stored_key = fixed_bytes::<32>("graph_entities.entity_key", stored_key)?;
7246    if stored_key != entity.key().digest_bytes()? {
7247        return Err(projectatlas_core::graph::GraphContractError::InvalidStableKeyDigest.into());
7248    }
7249    if stored_canonical != entity.key().canonical_identity() {
7250        return Err(
7251            projectatlas_core::graph::GraphContractError::StableKeyCollision {
7252                digest: entity.key().digest().to_string(),
7253            }
7254            .into(),
7255        );
7256    }
7257    Ok(())
7258}
7259
7260/// Validate one stored relation key and canonical collision witness.
7261fn validate_relation_key(
7262    relation: &LogicalRelation,
7263    stored_key: Vec<u8>,
7264    stored_canonical: &str,
7265) -> DbResult<()> {
7266    let stored_key = fixed_bytes::<32>("graph_relations.relation_key", stored_key)?;
7267    if stored_key != relation.key().digest_bytes()? {
7268        return Err(projectatlas_core::graph::GraphContractError::InvalidStableKeyDigest.into());
7269    }
7270    if stored_canonical != relation.key().canonical_identity() {
7271        return Err(
7272            projectatlas_core::graph::GraphContractError::StableKeyCollision {
7273                digest: relation.key().digest().to_string(),
7274            }
7275            .into(),
7276        );
7277    }
7278    Ok(())
7279}
7280
7281/// Collect every raw entity row, including the truncation sentinel row.
7282fn collect_entity_rows(mut rows: rusqlite::Rows<'_>) -> DbResult<Vec<EntityRow>> {
7283    let mut collected = Vec::new();
7284    while let Some(row) = rows.next()? {
7285        collected.push(entity_row(row)?);
7286    }
7287    Ok(collected)
7288}
7289
7290/// Collect raw entity rows while enforcing decoded payload bytes.
7291fn collect_entity_rows_metered(
7292    mut rows: rusqlite::Rows<'_>,
7293    meter: &mut RepositoryGraphReadMeter,
7294) -> DbResult<Vec<EntityRow>> {
7295    let mut collected = Vec::new();
7296    while let Some(row) = rows.next()? {
7297        let raw = entity_row(row)?;
7298        meter.record_decoded_bytes(entity_row_decoded_bytes(&raw)?)?;
7299        collected.push(raw);
7300    }
7301    Ok(collected)
7302}
7303
7304/// Read one raw entity row without interpreting enum or selector values.
7305fn entity_row(row: &Row<'_>) -> rusqlite::Result<EntityRow> {
7306    Ok(EntityRow {
7307        key: row.get(0)?,
7308        project: row.get(1)?,
7309        canonical: row.get(2)?,
7310        kind: row.get(3)?,
7311        repository_path: row.get(4)?,
7312        package_manager: row.get(5)?,
7313        package_name: row.get(6)?,
7314        manifest_path: row.get(7)?,
7315        symbol_name: row.get(8)?,
7316        symbol_kind: row.get(9)?,
7317        symbol_parent: row.get(10)?,
7318        symbol_signature: row.get(11)?,
7319        external_system: row.get(12)?,
7320        external_identity: row.get(13)?,
7321    })
7322}
7323
7324/// Count exact dynamic payload bytes decoded for one normalized entity row.
7325fn entity_row_decoded_bytes(row: &EntityRow) -> DbResult<u64> {
7326    decoded_payload_bytes(
7327        [
7328            row.key.len(),
7329            row.project.len(),
7330            row.canonical.len(),
7331            row.kind.len(),
7332            row.repository_path.as_ref().map_or(0, String::len),
7333            row.package_manager.as_ref().map_or(0, String::len),
7334            row.package_name.as_ref().map_or(0, String::len),
7335            row.manifest_path.as_ref().map_or(0, String::len),
7336            row.symbol_name.as_ref().map_or(0, String::len),
7337            row.symbol_kind.as_ref().map_or(0, String::len),
7338            row.symbol_parent.as_ref().map_or(0, String::len),
7339            row.symbol_signature.as_ref().map_or(0, String::len),
7340            row.external_system.as_ref().map_or(0, String::len),
7341            row.external_identity.as_ref().map_or(0, String::len),
7342        ],
7343        0,
7344    )
7345}
7346
7347/// Collect every raw relation row, including the truncation sentinel row.
7348fn collect_relation_rows(mut rows: rusqlite::Rows<'_>) -> DbResult<Vec<RelationRow>> {
7349    let mut collected = Vec::new();
7350    while let Some(row) = rows.next()? {
7351        collected.push(relation_row(row)?);
7352    }
7353    Ok(collected)
7354}
7355
7356/// Collect relation-family rows and their joined source classifications.
7357fn collect_classified_relation_rows(
7358    mut rows: rusqlite::Rows<'_>,
7359) -> DbResult<Vec<ClassifiedRelationRow>> {
7360    let mut collected = Vec::new();
7361    while let Some(row) = rows.next()? {
7362        collected.push(ClassifiedRelationRow {
7363            relation: relation_row_at(row, 0)?,
7364            source_classification: row.get(13)?,
7365        });
7366    }
7367    Ok(collected)
7368}
7369
7370/// Collect raw relation rows while enforcing decoded payload bytes.
7371fn collect_relation_rows_metered(
7372    mut rows: rusqlite::Rows<'_>,
7373    meter: &mut RepositoryGraphReadMeter,
7374) -> DbResult<Vec<RelationRow>> {
7375    let mut collected = Vec::new();
7376    while let Some(row) = rows.next()? {
7377        let raw = relation_row(row)?;
7378        meter.record_decoded_bytes(relation_row_decoded_bytes(&raw)?)?;
7379        collected.push(raw);
7380    }
7381    Ok(collected)
7382}
7383
7384/// Collect raw adjacency rows and meter the truncation sentinel before return.
7385fn collect_adjacency_relation_rows_metered(
7386    mut rows: rusqlite::Rows<'_>,
7387    meter: &mut RepositoryGraphReadMeter,
7388) -> DbResult<Vec<AdjacencyRelationRow>> {
7389    let mut collected = Vec::new();
7390    while let Some(row) = rows.next()? {
7391        let raw = AdjacencyRelationRow {
7392            frontier_index: row.get(0)?,
7393            relation: relation_row_at(row, 1)?,
7394        };
7395        meter.record_decoded_bytes(
7396            relation_row_decoded_bytes(&raw.relation)?
7397                .checked_add(8)
7398                .ok_or(GraphContractError::InvalidLimits {
7399                    reason: "graph adjacency decoded row size overflowed",
7400                })?,
7401        )?;
7402        collected.push(raw);
7403    }
7404    Ok(collected)
7405}
7406
7407/// Read one raw relation row without interpreting enum or resolution values.
7408fn relation_row(row: &Row<'_>) -> rusqlite::Result<RelationRow> {
7409    relation_row_at(row, 0)
7410}
7411
7412/// Read one raw relation row beginning at the selected column offset.
7413fn relation_row_at(row: &Row<'_>, offset: usize) -> rusqlite::Result<RelationRow> {
7414    Ok(RelationRow {
7415        key: row.get(offset)?,
7416        project: row.get(offset + 1)?,
7417        canonical: row.get(offset + 2)?,
7418        source: row.get(offset + 3)?,
7419        relation_scope: row.get(offset + 4)?,
7420        relation_kind: row.get(offset + 5)?,
7421        resolution_status: row.get(offset + 6)?,
7422        target: row.get(offset + 7)?,
7423        reference: row.get(offset + 8)?,
7424        candidate_count: row.get(offset + 9)?,
7425        document_unresolved_reason: row.get(offset + 10)?,
7426        confidence: row.get(offset + 11)?,
7427        completeness: row.get(offset + 12)?,
7428    })
7429}
7430
7431/// Count exact dynamic and fixed payload bytes decoded for one relation row.
7432fn relation_row_decoded_bytes(row: &RelationRow) -> DbResult<u64> {
7433    decoded_payload_bytes(
7434        [
7435            row.key.len(),
7436            row.project.len(),
7437            row.canonical.len(),
7438            row.source.len(),
7439            row.relation_scope.len(),
7440            row.relation_kind.len(),
7441            row.resolution_status.len(),
7442            row.target.as_ref().map_or(0, Vec::len),
7443            row.reference.as_ref().map_or(0, String::len),
7444            row.document_unresolved_reason
7445                .as_ref()
7446                .map_or(0, String::len),
7447            row.confidence.len(),
7448            row.completeness.len(),
7449        ],
7450        8,
7451    )
7452}
7453
7454/// Sum decoded variable-width values plus fixed scalar widths without overflow.
7455fn decoded_payload_bytes(
7456    lengths: impl IntoIterator<Item = usize>,
7457    fixed_bytes: u64,
7458) -> DbResult<u64> {
7459    let mut decoded = fixed_bytes;
7460    for length in lengths {
7461        let length =
7462            u64::try_from(length).map_err(|_source| GraphContractError::InvalidLimits {
7463                reason: "graph read decoded field length overflowed",
7464            })?;
7465        decoded = decoded
7466            .checked_add(length)
7467            .ok_or(GraphContractError::InvalidLimits {
7468                reason: "graph read decoded row size overflowed",
7469            })?;
7470    }
7471    Ok(decoded)
7472}
7473
7474/// Read one raw relation occurrence row.
7475fn occurrence_row(row: &Row<'_>) -> rusqlite::Result<OccurrenceRow> {
7476    occurrence_row_at(row, 0)
7477}
7478
7479/// Read one raw relation occurrence row at a stable column offset.
7480fn occurrence_row_at(row: &Row<'_>, offset: usize) -> rusqlite::Result<OccurrenceRow> {
7481    Ok(OccurrenceRow {
7482        relation: row.get(offset)?,
7483        file_path: row.get(offset + 1)?,
7484        start_line: row.get(offset + 2)?,
7485        start_column: row.get(offset + 3)?,
7486        end_line: row.get(offset + 4)?,
7487        end_column: row.get(offset + 5)?,
7488    })
7489}
7490
7491/// Count exact occurrence payload bytes, including four fixed span scalars.
7492fn occurrence_row_decoded_bytes(row: &OccurrenceRow) -> DbResult<u64> {
7493    decoded_payload_bytes([row.relation.len(), row.file_path.len()], 32)
7494}
7495
7496/// Read one raw graph coverage row.
7497fn coverage_row(row: &Row<'_>) -> rusqlite::Result<CoverageRow> {
7498    Ok(CoverageRow {
7499        project: row.get(0)?,
7500        scope_kind: row.get(1)?,
7501        scope_path: row.get(2)?,
7502        relation_scope: row.get(3)?,
7503        relation_kind: row.get(4)?,
7504        state: row.get(5)?,
7505        total: row.get(6)?,
7506        covered: row.get(7)?,
7507        omitted: row.get(8)?,
7508        reason: row.get(9)?,
7509        reached_limit: row.get(10)?,
7510        parser: row.get(11)?,
7511        provider: row.get(12)?,
7512    })
7513}
7514
7515/// Count exact coverage payload bytes, including three fixed count scalars.
7516fn coverage_row_decoded_bytes(row: &CoverageRow) -> DbResult<u64> {
7517    decoded_payload_bytes(
7518        [
7519            row.project.len(),
7520            row.scope_kind.len(),
7521            row.scope_path.as_ref().map_or(0, String::len),
7522            row.relation_scope.as_ref().map_or(0, String::len),
7523            row.relation_kind.as_ref().map_or(0, String::len),
7524            row.state.len(),
7525            row.reason.as_ref().map_or(0, String::len),
7526            row.reached_limit.as_ref().map_or(0, String::len),
7527            row.parser.as_ref().map_or(0, String::len),
7528            row.provider.as_ref().map_or(0, String::len),
7529        ],
7530        24,
7531    )
7532}
7533
7534/// Convert a fully collected raw page and validate the sentinel before truncating.
7535fn page_from_raw<Raw, Domain>(
7536    raw: Vec<Raw>,
7537    limit: u32,
7538    mut convert: impl FnMut(Raw) -> DbResult<Domain>,
7539) -> DbResult<RepositoryGraphPage<Domain>> {
7540    let mut rows = raw
7541        .into_iter()
7542        .map(&mut convert)
7543        .collect::<DbResult<Vec<_>>>()?;
7544    let truncated = rows.len() > limit as usize;
7545    if truncated {
7546        rows.pop();
7547    }
7548    Ok(RepositoryGraphPage { rows, truncated })
7549}
7550
7551/// Return an empty graph page when no project graph has been initialized.
7552fn empty_page<T>() -> RepositoryGraphPage<T> {
7553    RepositoryGraphPage {
7554        rows: Vec::new(),
7555        truncated: false,
7556    }
7557}
7558
7559/// Return an empty adjacency page when no selected graph/frontier is available.
7560fn empty_adjacency_page() -> RepositoryGraphAdjacencyPage {
7561    RepositoryGraphAdjacencyPage {
7562        rows: Vec::new(),
7563        truncated: false,
7564        continuation: None,
7565    }
7566}
7567
7568/// Return an empty footprint when no complete selected graph is available.
7569const fn empty_affected_source_footprint() -> RepositoryAffectedSourceFootprint {
7570    RepositoryAffectedSourceFootprint {
7571        rows: 0,
7572        retained_bytes: 0,
7573        truncated: false,
7574    }
7575}
7576
7577/// Validate and convert a requested page size into `LIMIT + 1`.
7578fn validated_limit_plus_one(limit: u32, ceiling: u32, reason: &'static str) -> DbResult<i64> {
7579    if limit == 0 || limit > ceiling {
7580        return Err(projectatlas_core::graph::GraphContractError::InvalidLimits { reason }.into());
7581    }
7582    Ok(i64::from(limit) + 1)
7583}
7584
7585/// Convert a fixed-width normalized BLOB without truncation.
7586fn fixed_bytes<const WIDTH: usize>(field: &'static str, bytes: Vec<u8>) -> DbResult<[u8; WIDTH]> {
7587    let found = bytes.len();
7588    bytes
7589        .try_into()
7590        .map_err(|_bytes| DbError::InvalidBlobLength {
7591            field,
7592            expected: WIDTH,
7593            found,
7594        })
7595}
7596
7597/// Reconstruct a project identity from its normalized binary column.
7598fn project_from_blob(field: &'static str, bytes: Vec<u8>) -> DbResult<ProjectInstanceId> {
7599    ProjectInstanceId::from_bytes(fixed_bytes::<16>(field, bytes)?).map_err(Into::into)
7600}
7601
7602/// Return a required text column or a stable row-shape failure.
7603fn required_text<'value>(
7604    table: &'static str,
7605    reason: &'static str,
7606    value: Option<&'value str>,
7607) -> DbResult<&'value str> {
7608    value.ok_or(DbError::GraphRowShape { table, reason })
7609}
7610
7611/// Convert a nonnegative `SQLite` count to `u64`.
7612fn nonnegative_u64(field: &'static str, value: i64) -> DbResult<u64> {
7613    u64::try_from(value).map_err(|source| DbError::InvalidCount {
7614        field,
7615        value,
7616        source,
7617    })
7618}
7619
7620/// Convert a positive `SQLite` count to `NonZeroU32`.
7621fn positive_u32(field: &'static str, value: i64) -> DbResult<NonZeroU32> {
7622    let value = positive_u32_value(field, value)?;
7623    NonZeroU32::new(value).ok_or(DbError::GraphRowShape {
7624        table: "graph_relations",
7625        reason: "candidate count must be positive",
7626    })
7627}
7628
7629/// Convert a positive `SQLite` integer to `u32`.
7630fn positive_u32_value(field: &'static str, value: i64) -> DbResult<u32> {
7631    let converted = u32::try_from(value).map_err(|source| DbError::InvalidCount {
7632        field,
7633        value,
7634        source,
7635    })?;
7636    if converted == 0 {
7637        return Err(DbError::GraphRowShape {
7638            table: "repository_graph",
7639            reason: "positive integer column contains zero",
7640        });
7641    }
7642    Ok(converted)
7643}
7644
7645/// Convert a nonnegative `SQLite` integer to `u32`.
7646fn nonnegative_u32(field: &'static str, value: i64) -> DbResult<u32> {
7647    u32::try_from(value).map_err(|source| DbError::InvalidCount {
7648        field,
7649        value,
7650        source,
7651    })
7652}
7653
7654/// Convert a domain count to one lossless `SQLite` integer.
7655fn sqlite_count(field: &'static str, value: u64) -> DbResult<i64> {
7656    i64::try_from(value).map_err(|_source| DbError::GraphCountOverflow { field, value })
7657}
7658
7659/// Split one typed relation family into its normalized scope and spelling.
7660const fn relation_parts(relation: GraphRelationKind) -> (&'static str, &'static str) {
7661    match relation {
7662        GraphRelationKind::Legacy(RelationKind::Contains) => ("legacy", "contains"),
7663        GraphRelationKind::Legacy(RelationKind::Imports) => ("legacy", "imports"),
7664        GraphRelationKind::Legacy(RelationKind::Calls) => ("legacy", "calls"),
7665        GraphRelationKind::Legacy(RelationKind::DependsOn) => ("legacy", "depends-on"),
7666        GraphRelationKind::Extended(ExtendedRelationKind::References) => ("extended", "references"),
7667        GraphRelationKind::Extended(ExtendedRelationKind::Tests) => ("extended", "tests"),
7668        GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo) => ("extended", "routes-to"),
7669        GraphRelationKind::Extended(ExtendedRelationKind::Configures) => ("extended", "configures"),
7670        GraphRelationKind::Extended(ExtendedRelationKind::Deploys) => ("extended", "deploys"),
7671        GraphRelationKind::Extended(ExtendedRelationKind::Reads) => ("extended", "reads"),
7672        GraphRelationKind::Extended(ExtendedRelationKind::Writes) => ("extended", "writes"),
7673        GraphRelationKind::Extended(ExtendedRelationKind::Documents) => ("extended", "documents"),
7674    }
7675}
7676
7677/// Parse one normalized relation family without accepting unknown values.
7678fn parse_relation_kind(scope: &str, kind: &str) -> DbResult<GraphRelationKind> {
7679    match (scope, kind) {
7680        ("legacy", "contains") => Ok(GraphRelationKind::Legacy(RelationKind::Contains)),
7681        ("legacy", "imports") => Ok(GraphRelationKind::Legacy(RelationKind::Imports)),
7682        ("legacy", "calls") => Ok(GraphRelationKind::Legacy(RelationKind::Calls)),
7683        ("legacy", "depends-on") => Ok(GraphRelationKind::Legacy(RelationKind::DependsOn)),
7684        ("extended", "references") => Ok(GraphRelationKind::Extended(
7685            ExtendedRelationKind::References,
7686        )),
7687        ("extended", "tests") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Tests)),
7688        ("extended", "routes-to") => {
7689            Ok(GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo))
7690        }
7691        ("extended", "configures") => Ok(GraphRelationKind::Extended(
7692            ExtendedRelationKind::Configures,
7693        )),
7694        ("extended", "deploys") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Deploys)),
7695        ("extended", "reads") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Reads)),
7696        ("extended", "writes") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Writes)),
7697        ("extended", "documents") => {
7698            Ok(GraphRelationKind::Extended(ExtendedRelationKind::Documents))
7699        }
7700        _ => Err(DbError::InvalidEnum {
7701            field: "graph_relations.relation_kind",
7702            value: format!("{scope}:{kind}"),
7703        }),
7704    }
7705}
7706
7707/// Return the normalized symbol-kind spelling.
7708const fn symbol_kind_name(kind: SymbolKind) -> &'static str {
7709    match kind {
7710        SymbolKind::Function => "function",
7711        SymbolKind::Method => "method",
7712        SymbolKind::Class => "class",
7713        SymbolKind::Struct => "struct",
7714        SymbolKind::Enum => "enum",
7715        SymbolKind::Trait => "trait",
7716        SymbolKind::Interface => "interface",
7717        SymbolKind::Module => "module",
7718        SymbolKind::Type => "type",
7719        SymbolKind::Value => "value",
7720        SymbolKind::Import => "import",
7721        SymbolKind::Package => "package",
7722        SymbolKind::Workspace => "workspace",
7723        SymbolKind::Dependency => "dependency",
7724        SymbolKind::Heading => "heading",
7725        SymbolKind::Unknown => "unknown",
7726    }
7727}
7728
7729/// Parse one normalized symbol-kind spelling.
7730fn parse_symbol_kind(value: &str) -> DbResult<SymbolKind> {
7731    match value {
7732        "function" => Ok(SymbolKind::Function),
7733        "method" => Ok(SymbolKind::Method),
7734        "class" => Ok(SymbolKind::Class),
7735        "struct" => Ok(SymbolKind::Struct),
7736        "enum" => Ok(SymbolKind::Enum),
7737        "trait" => Ok(SymbolKind::Trait),
7738        "interface" => Ok(SymbolKind::Interface),
7739        "module" => Ok(SymbolKind::Module),
7740        "type" => Ok(SymbolKind::Type),
7741        "value" => Ok(SymbolKind::Value),
7742        "import" => Ok(SymbolKind::Import),
7743        "package" => Ok(SymbolKind::Package),
7744        "workspace" => Ok(SymbolKind::Workspace),
7745        "dependency" => Ok(SymbolKind::Dependency),
7746        "heading" => Ok(SymbolKind::Heading),
7747        "unknown" => Ok(SymbolKind::Unknown),
7748        _ => Err(DbError::InvalidEnum {
7749            field: "graph_entities.symbol_kind",
7750            value: value.to_string(),
7751        }),
7752    }
7753}
7754
7755/// Return the normalized confidence spelling.
7756const fn confidence_name(confidence: ConfidenceClass) -> &'static str {
7757    match confidence {
7758        ConfidenceClass::Exact => "exact",
7759        ConfidenceClass::High => "high",
7760        ConfidenceClass::Medium => "medium",
7761        ConfidenceClass::Low => "low",
7762    }
7763}
7764
7765/// Parse one normalized confidence spelling.
7766fn parse_confidence(value: &str) -> DbResult<ConfidenceClass> {
7767    match value {
7768        "exact" => Ok(ConfidenceClass::Exact),
7769        "high" => Ok(ConfidenceClass::High),
7770        "medium" => Ok(ConfidenceClass::Medium),
7771        "low" => Ok(ConfidenceClass::Low),
7772        _ => Err(DbError::InvalidEnum {
7773            field: "graph_relations.confidence",
7774            value: value.to_string(),
7775        }),
7776    }
7777}
7778
7779/// Return the normalized completeness spelling.
7780const fn completeness_name(completeness: Completeness) -> &'static str {
7781    match completeness {
7782        Completeness::Complete => "complete",
7783        Completeness::Partial => "partial",
7784    }
7785}
7786
7787/// Parse one normalized completeness spelling.
7788fn parse_completeness(value: &str) -> DbResult<Completeness> {
7789    match value {
7790        "complete" => Ok(Completeness::Complete),
7791        "partial" => Ok(Completeness::Partial),
7792        _ => Err(DbError::InvalidEnum {
7793            field: "graph_relations.completeness",
7794            value: value.to_string(),
7795        }),
7796    }
7797}
7798
7799/// Return normalized coverage scope columns.
7800fn coverage_scope_parts(scope: &CoverageScope) -> (&'static str, Option<&str>) {
7801    match scope {
7802        CoverageScope::Project => ("project", None),
7803        CoverageScope::Path { path } => ("path", Some(path.as_str())),
7804    }
7805}
7806
7807/// Return the normalized coverage lifecycle spelling.
7808const fn coverage_state_name(state: CoverageState) -> &'static str {
7809    match state {
7810        CoverageState::Complete | CoverageState::NoCandidates => "complete",
7811        CoverageState::Partial => "partial",
7812        CoverageState::Failed => "failed",
7813        CoverageState::Ignored => "ignored",
7814        CoverageState::Oversized => "oversized",
7815        CoverageState::Quarantined => "quarantined",
7816        CoverageState::Stale => "stale",
7817    }
7818}
7819
7820/// Parse one normalized coverage lifecycle spelling.
7821pub(crate) fn parse_coverage_state(value: &str) -> DbResult<CoverageState> {
7822    match value {
7823        "complete" => Ok(CoverageState::Complete),
7824        "partial" => Ok(CoverageState::Partial),
7825        "failed" => Ok(CoverageState::Failed),
7826        "ignored" => Ok(CoverageState::Ignored),
7827        "oversized" => Ok(CoverageState::Oversized),
7828        "quarantined" => Ok(CoverageState::Quarantined),
7829        "stale" => Ok(CoverageState::Stale),
7830        _ => Err(DbError::InvalidEnum {
7831            field: "graph_coverage.state",
7832            value: value.to_string(),
7833        }),
7834    }
7835}
7836
7837/// Parse one normalized parser provenance spelling without fallback coercion.
7838fn parse_parser_kind(field: &'static str, value: &str) -> DbResult<ParserKind> {
7839    match value {
7840        "tree-sitter" => Ok(ParserKind::TreeSitter),
7841        "manifest" => Ok(ParserKind::Manifest),
7842        "structural" => Ok(ParserKind::Structural),
7843        "fallback" => Ok(ParserKind::Fallback),
7844        _ => Err(DbError::InvalidEnum {
7845            field,
7846            value: value.to_string(),
7847        }),
7848    }
7849}
7850
7851/// Decode one persisted graph identity field without coercing unknown values.
7852fn parse_graph_identity_field(value: String) -> DbResult<GraphIdentityField> {
7853    match value.as_str() {
7854        "package" => Ok(GraphIdentityField::Package),
7855        "symbol" => Ok(GraphIdentityField::Symbol),
7856        "parent" => Ok(GraphIdentityField::Parent),
7857        "signature" => Ok(GraphIdentityField::Signature),
7858        "relation.source" => Ok(GraphIdentityField::RelationSource),
7859        "relation.target" => Ok(GraphIdentityField::RelationTarget),
7860        "resolution-key" => Ok(GraphIdentityField::ResolutionKey),
7861        _ => Err(DbError::InvalidEnum {
7862            field: "graph_identity_rejections.field",
7863            value,
7864        }),
7865    }
7866}
7867
7868/// Decode one persisted graph identity rejection reason without coercion.
7869fn parse_graph_identity_rejection_reason(value: String) -> DbResult<GraphIdentityRejectionReason> {
7870    match value.as_str() {
7871        "empty" => Ok(GraphIdentityRejectionReason::Empty),
7872        "surrounding-whitespace" => Ok(GraphIdentityRejectionReason::SurroundingWhitespace),
7873        "control-characters" => Ok(GraphIdentityRejectionReason::ControlCharacters),
7874        "oversized" => Ok(GraphIdentityRejectionReason::Oversized),
7875        "reserved-namespace" => Ok(GraphIdentityRejectionReason::ReservedNamespace),
7876        "contract" => Ok(GraphIdentityRejectionReason::Contract),
7877        _ => Err(DbError::InvalidEnum {
7878            field: "graph_identity_rejections.reason",
7879            value,
7880        }),
7881    }
7882}
7883
7884/// Decode one persisted graph identity rejection row with domain validation.
7885fn graph_identity_rejection_from_row(row: &Row<'_>) -> DbResult<GraphIdentityRejection> {
7886    let file_path = row.get::<_, String>(0)?;
7887    let start_line =
7888        u32::try_from(row.get::<_, i64>(1)?).map_err(|_error| DbError::GraphRowShape {
7889            table: "graph_identity_rejections",
7890            reason: "start line does not fit the graph span domain",
7891        })?;
7892    let start_column =
7893        u32::try_from(row.get::<_, i64>(2)?).map_err(|_error| DbError::GraphRowShape {
7894            table: "graph_identity_rejections",
7895            reason: "start column does not fit the graph span domain",
7896        })?;
7897    let end_line =
7898        u32::try_from(row.get::<_, i64>(3)?).map_err(|_error| DbError::GraphRowShape {
7899            table: "graph_identity_rejections",
7900            reason: "end line does not fit the graph span domain",
7901        })?;
7902    let end_column =
7903        u32::try_from(row.get::<_, i64>(4)?).map_err(|_error| DbError::GraphRowShape {
7904            table: "graph_identity_rejections",
7905            reason: "end column does not fit the graph span domain",
7906        })?;
7907    Ok(GraphIdentityRejection {
7908        path: RepositoryNodePath::new(Path::new(&file_path))?,
7909        span: SourceSpan::new(start_line, start_column, end_line, end_column)?,
7910        parser: parse_parser_kind(
7911            "graph_identity_rejections.parser",
7912            &row.get::<_, String>(5)?,
7913        )?,
7914        field: parse_graph_identity_field(row.get(6)?)?,
7915        reason: parse_graph_identity_rejection_reason(row.get(7)?)?,
7916        fact_index: u64::try_from(row.get::<_, i64>(8)?).map_err(|_error| {
7917            DbError::GraphRowShape {
7918                table: "graph_identity_rejections",
7919                reason: "fact index does not fit the graph identity domain",
7920            }
7921        })?,
7922    })
7923}
7924
7925/// Parse one normalized reached-limit spelling.
7926pub(crate) fn parse_limit_kind(value: &str) -> DbResult<GraphLimitKind> {
7927    GraphLimitKind::from_stable_name(value).ok_or_else(|| DbError::InvalidEnum {
7928        field: "graph_coverage.reached_limit",
7929        value: value.to_string(),
7930    })
7931}
7932
7933#[cfg(test)]
7934mod tests {
7935    use super::*;
7936    use crate::IndexedFileText;
7937    use projectatlas_core::symbols::{CodeSymbol, ParserKind, SymbolGraph, SymbolRelation};
7938    use projectatlas_core::{IndexCancellation, Node, NodeKind};
7939    use std::cell::RefCell;
7940    use std::error::Error;
7941    use std::fmt::Debug;
7942    use std::fs;
7943    use std::io;
7944    use std::sync::{
7945        Arc,
7946        atomic::{AtomicUsize, Ordering},
7947    };
7948    use std::time::{Duration, Instant};
7949
7950    thread_local! {
7951        /// Statements executed by the connection currently under test.
7952        static TRACED_STATEMENTS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
7953    }
7954
7955    /// Record one statement executed synchronously by the traced test connection.
7956    fn record_traced_statement(sql: &str) {
7957        TRACED_STATEMENTS.with(|statements| statements.borrow_mut().push(sql.to_string()));
7958    }
7959
7960    /// Execute one database operation while retaining its connection-local SQL trace.
7961    fn trace_statements<T>(
7962        store: &mut AtlasStore,
7963        operation: impl FnOnce(&AtlasStore) -> DbResult<T>,
7964    ) -> Result<(T, Vec<String>), Box<dyn Error>> {
7965        TRACED_STATEMENTS.with(|statements| statements.borrow_mut().clear());
7966        store.connection.trace_v2(
7967            rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT,
7968            Some(record_traced_event),
7969        );
7970        let result = operation(store);
7971        store
7972            .connection
7973            .trace_v2(rusqlite::trace::TraceEventCodes::empty(), None);
7974        let statements =
7975            TRACED_STATEMENTS.with(|statements| std::mem::take(&mut *statements.borrow_mut()));
7976        Ok((result?, statements))
7977    }
7978
7979    /// Adapt `rusqlite` statement trace events to the SQL text collector used by tests.
7980    #[allow(clippy::needless_pass_by_value)]
7981    fn record_traced_event(event: rusqlite::trace::TraceEvent<'_>) {
7982        if let rusqlite::trace::TraceEvent::Stmt(_, sql) = event {
7983            record_traced_statement(sql);
7984        }
7985    }
7986
7987    /// Closed production statement families allowed during detailed relation reads.
7988    #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
7989    enum DetailedRelationTraceStatement {
7990        /// Bound project singleton verification.
7991        ProjectIdentity,
7992        /// Complete publication metadata lookup.
7993        PublicationMetadata,
7994        /// Active normalized-graph generation lookup.
7995        ActiveGraphGeneration,
7996        /// Direction-owned indexed adjacency query.
7997        AdjacencyRelations,
7998        /// Stable-key relation hydration.
7999        RelationsByDigest,
8000        /// Stable-key endpoint entity hydration.
8001        RelationEntities,
8002        /// Per-relation occurrence hydration.
8003        RelationOccurrences,
8004        /// Purpose-owning node hydration.
8005        PurposeOwners,
8006    }
8007
8008    /// Classify one exact production statement emitted by a detailed relation read.
8009    fn classify_detailed_relation_statement(
8010        statement: &str,
8011    ) -> Option<DetailedRelationTraceStatement> {
8012        if statement
8013            .contains("SELECT project_instance_id FROM project_identity WHERE singleton = 1")
8014        {
8015            Some(DetailedRelationTraceStatement::ProjectIdentity)
8016        } else if statement.contains("SELECT state.value, fingerprint.value, generation.value")
8017            && statement.contains("FROM metadata AS state")
8018        {
8019            Some(DetailedRelationTraceStatement::PublicationMetadata)
8020        } else if statement
8021            .contains("SELECT active_generation FROM project_identity WHERE singleton = 1")
8022        {
8023            Some(DetailedRelationTraceStatement::ActiveGraphGeneration)
8024        } else if statement.contains("FROM graph_relations AS relation INDEXED BY")
8025            && statement.contains("idx_graph_relations_source_kind")
8026        {
8027            Some(DetailedRelationTraceStatement::AdjacencyRelations)
8028        } else if statement
8029            .contains("JOIN graph_relations AS relation INDEXED BY idx_graph_relations_project_key")
8030        {
8031            Some(DetailedRelationTraceStatement::RelationsByDigest)
8032        } else if statement.contains("JOIN graph_entities AS entity") {
8033            Some(DetailedRelationTraceStatement::RelationEntities)
8034        } else if statement.contains("FROM graph_relation_occurrences") {
8035            Some(DetailedRelationTraceStatement::RelationOccurrences)
8036        } else if statement.contains("FROM nodes n")
8037            && statement.contains("JOIN purposes p")
8038            && statement.contains("LEFT JOIN summaries s")
8039        {
8040            Some(DetailedRelationTraceStatement::PurposeOwners)
8041        } else {
8042            None
8043        }
8044    }
8045
8046    /// Require the complete traced statement multiset for one production read.
8047    fn require_traced_statement_multiset(
8048        statements: &[String],
8049        expected: &[(DetailedRelationTraceStatement, usize)],
8050        context: &str,
8051    ) -> Result<(), Box<dyn Error>> {
8052        let mut actual = BTreeMap::new();
8053        for statement in statements {
8054            let family = classify_detailed_relation_statement(statement).ok_or_else(|| {
8055                io::Error::other(format!(
8056                    "{context} executed an unclassified production statement: {statement}"
8057                ))
8058            })?;
8059            *actual.entry(family).or_insert(0) += 1;
8060        }
8061        let expected = expected.iter().copied().collect::<BTreeMap<_, _>>();
8062        require_eq(
8063            &actual,
8064            &expected,
8065            &format!("{context} complete statement multiset: {statements:?}"),
8066        )
8067    }
8068
8069    /// Traverse one relation family with the same bounded adjacency primitive used by the service.
8070    fn collect_bounded_outbound_calls(
8071        store: &AtlasStore,
8072        anchor: &GraphEntityKey,
8073    ) -> Result<(Vec<[u8; 32]>, usize, usize), Box<dyn Error>> {
8074        let mut visited = BTreeMap::new();
8075        visited.insert(anchor.digest_bytes()?, anchor.clone());
8076        let mut frontier = vec![anchor.clone()];
8077        let mut inspected_edges = 0_usize;
8078        let mut peak_frontier = frontier.len();
8079        let calls = GraphRelationKind::Legacy(RelationKind::Calls);
8080
8081        while !frontier.is_empty() {
8082            let mut next = BTreeMap::new();
8083            for chunk in frontier.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
8084                let work_per_row = MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS / chunk.len();
8085                let page_limit = work_per_row
8086                    .saturating_sub(1)
8087                    .min(GraphLimits::MAX_ROWS as usize)
8088                    .max(1) as u32;
8089                let mut continuation = None;
8090                loop {
8091                    let page = store.repository_graph_adjacency_page_filtered(
8092                        chunk,
8093                        RepositoryGraphDirection::Outbound,
8094                        Some(calls),
8095                        continuation.as_ref(),
8096                        page_limit,
8097                        None,
8098                    )?;
8099                    inspected_edges = inspected_edges
8100                        .checked_add(page.rows.len())
8101                        .ok_or_else(|| io::Error::other("measured edge count overflowed"))?;
8102                    for row in page.rows {
8103                        if let Some(target) = row.detail.target {
8104                            let digest = target.key().digest_bytes()?;
8105                            if !visited.contains_key(&digest) {
8106                                next.entry(digest).or_insert_with(|| target.key().clone());
8107                            }
8108                        }
8109                    }
8110                    if !page.truncated {
8111                        break;
8112                    }
8113                    continuation = Some(page.continuation.ok_or_else(|| {
8114                        io::Error::other("truncated adjacency page omitted its continuation")
8115                    })?);
8116                }
8117            }
8118            frontier = next.into_values().collect();
8119            peak_frontier = peak_frontier.max(frontier.len());
8120            for key in &frontier {
8121                visited.insert(key.digest_bytes()?, key.clone());
8122            }
8123        }
8124
8125        Ok((
8126            visited.into_keys().collect(),
8127            inspected_edges,
8128            peak_frontier,
8129        ))
8130    }
8131
8132    /// Coherent typed graph fixture used by storage, corruption, and publication tests.
8133    struct GraphFixture {
8134        /// Owning project identity.
8135        project: ProjectInstanceId,
8136        /// Every entity selector variant.
8137        entities: Vec<GraphEntity>,
8138        /// Every relation resolution state.
8139        relations: Vec<LogicalRelation>,
8140        /// Two occurrences for one logical relation.
8141        occurrences: Vec<RelationOccurrence>,
8142        /// Every coverage lifecycle state.
8143        coverage: Vec<CoverageRecord>,
8144    }
8145
8146    /// Maximum production relation/occurrence fixture used by SQL trace tests.
8147    struct DetailedRelationTraceFixture {
8148        /// Owning project identity.
8149        project: ProjectInstanceId,
8150        /// Common source whose unique targets cross entity hydration chunks.
8151        source: GraphEntity,
8152        /// Maximum accepted relation batch.
8153        relations: Vec<LogicalRelation>,
8154    }
8155
8156    /// Canonical keys used by export, ambiguity, and unresolved dependency tests.
8157    struct ResolutionFixture {
8158        /// Published typed graph.
8159        graph: GraphFixture,
8160        /// Key exported by the fixture symbol and consumed by a resolved relation.
8161        resolved: CanonicalResolutionKey,
8162        /// Key consumed by an ambiguous relation.
8163        ambiguous: CanonicalResolutionKey,
8164        /// Key consumed by an unresolved relation.
8165        unresolved: CanonicalResolutionKey,
8166    }
8167
8168    /// Build one complete typed graph for the selected generation.
8169    fn graph_fixture(
8170        project: ProjectInstanceId,
8171        generation: IndexGeneration,
8172    ) -> Result<GraphFixture, Box<dyn Error>> {
8173        let project_entity = GraphEntity::new(project, EntitySelector::Project, generation)?;
8174        let folder = GraphEntity::new(
8175            project,
8176            EntitySelector::Folder {
8177                path: RepositoryNodePath::new(Path::new("src"))?,
8178            },
8179            generation,
8180        )?;
8181        let file = GraphEntity::new(
8182            project,
8183            EntitySelector::File {
8184                path: RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
8185            },
8186            generation,
8187        )?;
8188        let package = GraphEntity::new(
8189            project,
8190            EntitySelector::Package {
8191                package: PackageSelector {
8192                    manager: GraphIdentityText::new("cargo")?,
8193                    name: GraphIdentityText::new("ProjectAtlas")?,
8194                    manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
8195                },
8196            },
8197            generation,
8198        )?;
8199        let symbol = GraphEntity::new(
8200            project,
8201            EntitySelector::Symbol {
8202                symbol: SymbolSelector {
8203                    file: RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
8204                    name: GraphIdentityText::new("verifyToken")?,
8205                    kind: SymbolKind::Function,
8206                    parent: Some(GraphIdentityText::new("Auth")?),
8207                    signature: GraphIdentityText::new("verifyToken(&str)")?,
8208                },
8209            },
8210            generation,
8211        )?;
8212        let external = GraphEntity::new(
8213            project,
8214            EntitySelector::External {
8215                external: ExternalSelector {
8216                    system: GraphIdentityText::new("crates.io")?,
8217                    identity: GraphIdentityText::new("serde@1")?,
8218                },
8219            },
8220            generation,
8221        )?;
8222
8223        let resolved = LogicalRelation::new(
8224            &file,
8225            GraphRelationKind::Legacy(RelationKind::Calls),
8226            RelationResolution::resolved(&symbol)?,
8227            ConfidenceClass::Exact,
8228            Completeness::Complete,
8229            generation,
8230        )?;
8231        let ambiguous = LogicalRelation::new(
8232            &file,
8233            GraphRelationKind::Extended(ExtendedRelationKind::References),
8234            RelationResolution::Ambiguous {
8235                reference: GraphIdentityText::new("Session")?,
8236                candidates: NonZeroU32::new(2)
8237                    .ok_or_else(|| io::Error::other("fixture candidate count is zero"))?,
8238            },
8239            ConfidenceClass::Medium,
8240            Completeness::Partial,
8241            generation,
8242        )?;
8243        let unresolved = LogicalRelation::new(
8244            &file,
8245            GraphRelationKind::Extended(ExtendedRelationKind::Configures),
8246            RelationResolution::Unresolved {
8247                reference: GraphIdentityText::new("AUTH_KEY")?,
8248            },
8249            ConfidenceClass::Low,
8250            Completeness::Partial,
8251            generation,
8252        )?;
8253        let external_relation = LogicalRelation::new(
8254            &file,
8255            GraphRelationKind::Legacy(RelationKind::DependsOn),
8256            RelationResolution::external(&external)?,
8257            ConfidenceClass::High,
8258            Completeness::Complete,
8259            generation,
8260        )?;
8261        let occurrences = vec![
8262            RelationOccurrence::new(
8263                &resolved,
8264                RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
8265                SourceSpan::new(10, 4, 10, 18)?,
8266                generation,
8267            )?,
8268            RelationOccurrence::new(
8269                &resolved,
8270                RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
8271                SourceSpan::new(22, 2, 22, 16)?,
8272                generation,
8273            )?,
8274        ];
8275        let coverage = vec![
8276            CoverageRecord::new(
8277                CoverageScope::Project,
8278                None,
8279                CoverageState::Complete,
8280                4,
8281                0,
8282                generation,
8283                None,
8284                None,
8285            )?,
8286            CoverageRecord::new(
8287                CoverageScope::Project,
8288                Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
8289                CoverageState::NoCandidates,
8290                0,
8291                0,
8292                generation,
8293                None,
8294                None,
8295            )?,
8296            CoverageRecord::new(
8297                CoverageScope::Project,
8298                Some(GraphRelationKind::Extended(ExtendedRelationKind::Tests)),
8299                CoverageState::Complete,
8300                0,
8301                0,
8302                generation,
8303                None,
8304                None,
8305            )?,
8306            CoverageRecord::new(
8307                CoverageScope::Path {
8308                    path: RepositoryNodePath::new(Path::new("src"))?,
8309                },
8310                None,
8311                CoverageState::Partial,
8312                3,
8313                1,
8314                generation,
8315                Some(GraphIdentityText::new("one parser region omitted")?),
8316                Some(GraphLimitKind::Rows),
8317            )?,
8318            incomplete_coverage(
8319                GraphRelationKind::Legacy(RelationKind::Calls),
8320                CoverageState::Failed,
8321                "parser failed",
8322                generation,
8323            )?,
8324            incomplete_coverage(
8325                GraphRelationKind::Legacy(RelationKind::Imports),
8326                CoverageState::Ignored,
8327                "ignored by policy",
8328                generation,
8329            )?,
8330            incomplete_coverage(
8331                GraphRelationKind::Legacy(RelationKind::Contains),
8332                CoverageState::Oversized,
8333                "file exceeded limit",
8334                generation,
8335            )?,
8336            incomplete_coverage(
8337                GraphRelationKind::Legacy(RelationKind::DependsOn),
8338                CoverageState::Quarantined,
8339                "provider quarantined",
8340                generation,
8341            )?,
8342            incomplete_coverage(
8343                GraphRelationKind::Extended(ExtendedRelationKind::References),
8344                CoverageState::Stale,
8345                "source changed",
8346                generation,
8347            )?,
8348        ];
8349        Ok(GraphFixture {
8350            project,
8351            entities: vec![project_entity, folder, file, package, symbol, external],
8352            relations: vec![resolved, ambiguous, unresolved, external_relation],
8353            occurrences,
8354            coverage,
8355        })
8356    }
8357
8358    /// Construct one project-qualified declaration resolution key.
8359    fn declaration_key(
8360        project: ProjectInstanceId,
8361        identity: &str,
8362        relation: GraphRelationKind,
8363    ) -> Result<CanonicalResolutionKey, Box<dyn Error>> {
8364        Ok(CanonicalResolutionKey::new(
8365            project,
8366            ResolutionKeyDomain::Declaration,
8367            &GraphIdentityText::new("tree-sitter")?,
8368            &GraphIdentityText::new("rust")?,
8369            None,
8370            Some(&GraphIdentityText::new("crate")?),
8371            Some(relation),
8372            &GraphIdentityText::new(identity)?,
8373        ))
8374    }
8375
8376    /// Publish one graph with duplicate-safe export and all dependency states.
8377    fn publish_resolution_fixture(
8378        store: &mut AtlasStore,
8379        fingerprint: &str,
8380    ) -> Result<ResolutionFixture, Box<dyn Error>> {
8381        let project = store
8382            .project_instance_id()?
8383            .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
8384        let graph = graph_fixture(project, IndexGeneration::new(1))?;
8385        let resolved = declaration_key(
8386            project,
8387            "verifyToken",
8388            GraphRelationKind::Legacy(RelationKind::Calls),
8389        )?;
8390        let ambiguous = declaration_key(
8391            project,
8392            "Session",
8393            GraphRelationKind::Extended(ExtendedRelationKind::References),
8394        )?;
8395        let unresolved = declaration_key(
8396            project,
8397            "AUTH_KEY",
8398            GraphRelationKind::Extended(ExtendedRelationKind::Configures),
8399        )?;
8400        let export = EntityResolutionKey::new(graph.entities[4].key().clone(), resolved.clone())?;
8401        let exports = vec![export.clone(), export];
8402        let dependencies = vec![
8403            RelationDependencyKey::new(graph.relations[0].key().clone(), resolved.clone())?,
8404            RelationDependencyKey::new(graph.relations[1].key().clone(), ambiguous.clone())?,
8405            RelationDependencyKey::new(graph.relations[2].key().clone(), unresolved.clone())?,
8406        ];
8407        let mut publication = store.begin_index_publication(fingerprint)?;
8408        publication.begin_scan_replacement()?;
8409        publication.upsert_scan_node_batch(&[
8410            graph_node(".", NodeKind::Folder, None),
8411            graph_node("src", NodeKind::Folder, Some(".")),
8412            graph_node("src/Äuth.rs", NodeKind::File, Some("src")),
8413            graph_node("Cargo.toml", NodeKind::File, Some(".")),
8414        ])?;
8415        publication.finish_scan_replacement()?;
8416        publication.replace_repository_graph_with_resolution_keys(
8417            graph.project,
8418            &graph.entities,
8419            &graph.relations,
8420            &graph.occurrences,
8421            &graph.coverage,
8422            &exports,
8423            &dependencies,
8424        )?;
8425        publication.complete()?;
8426        Ok(ResolutionFixture {
8427            graph,
8428            resolved,
8429            ambiguous,
8430            unresolved,
8431        })
8432    }
8433
8434    /// Replace one source-owned parser graph with a requested symbol degree.
8435    fn replace_fixture_symbol_rows(
8436        store: &mut AtlasStore,
8437        path: &str,
8438        symbol_count: usize,
8439    ) -> Result<(), Box<dyn Error>> {
8440        let symbols = (0..symbol_count)
8441            .map(|index| CodeSymbol {
8442                path: path.to_string(),
8443                language: Some("rust".to_string()),
8444                name: format!("symbol_{index}"),
8445                kind: SymbolKind::Function,
8446                signature: format!("fn symbol_{index}()"),
8447                exported: index == 0,
8448                documentation: None,
8449                line_start: index + 1,
8450                line_end: index + 1,
8451                source_selector: None,
8452                parent: None,
8453                parser: ParserKind::TreeSitter,
8454                detail: Some("function_item".to_string()),
8455            })
8456            .collect();
8457        store.replace_symbol_graph(&SymbolGraph {
8458            path: path.to_string(),
8459            language: Some("rust".to_string()),
8460            parser: ParserKind::TreeSitter,
8461            symbols,
8462            relations: vec![SymbolRelation {
8463                path: path.to_string(),
8464                source_name: "symbol_0".to_string(),
8465                target_name: "dependency".to_string(),
8466                kind: RelationKind::Calls,
8467                line: 1,
8468                context: "dependency()".to_string(),
8469                parser: ParserKind::TreeSitter,
8470            }],
8471        })?;
8472        Ok(())
8473    }
8474
8475    /// Construct one non-complete project-wide coverage row.
8476    fn incomplete_coverage(
8477        relation: GraphRelationKind,
8478        state: CoverageState,
8479        reason: &str,
8480        generation: IndexGeneration,
8481    ) -> Result<CoverageRecord, Box<dyn Error>> {
8482        Ok(CoverageRecord::new(
8483            CoverageScope::Project,
8484            Some(relation),
8485            state,
8486            0,
8487            1,
8488            generation,
8489            Some(GraphIdentityText::new(reason)?),
8490            None,
8491        )?)
8492    }
8493
8494    /// Construct one local-source node for graph publication fixtures.
8495    fn graph_node(path: &str, kind: NodeKind, parent_path: Option<&str>) -> Node {
8496        Node {
8497            path: path.to_string(),
8498            kind,
8499            parent_path: parent_path.map(str::to_string),
8500            extension: None,
8501            language: None,
8502            size_bytes: None,
8503            mtime_ns: None,
8504            content_hash: None,
8505        }
8506    }
8507
8508    /// Persisted all-family fixture for folder/file navigation enrichment.
8509    struct NavigationFixture {
8510        /// Selected project identity.
8511        project: ProjectInstanceId,
8512        /// Exact source path used by file-level assertions.
8513        api_path: String,
8514        /// Exact manifest path used by package-ownership assertions.
8515        manifest_path: String,
8516    }
8517
8518    /// Publish every navigation family plus skewed inbound call context.
8519    fn publish_navigation_fixture(
8520        store: &mut AtlasStore,
8521        fingerprint: &str,
8522    ) -> Result<NavigationFixture, Box<dyn Error>> {
8523        let project = store
8524            .project_instance_id()?
8525            .ok_or_else(|| io::Error::other("navigation fixture identity is missing"))?;
8526        let generation = IndexGeneration::new(1);
8527        let api_path = "src/auth/api.rs".to_string();
8528        let manifest_path = "Cargo.toml".to_string();
8529        let mut nodes = vec![
8530            graph_node(".", NodeKind::Folder, None),
8531            graph_node("src", NodeKind::Folder, Some(".")),
8532            graph_node("src/auth", NodeKind::Folder, Some("src")),
8533            graph_node(&api_path, NodeKind::File, Some("src/auth")),
8534            graph_node("src/auth/caller.rs", NodeKind::File, Some("src/auth")),
8535            graph_node("src/other.rs", NodeKind::File, Some("src")),
8536            graph_node("src/authz.rs", NodeKind::File, Some("src")),
8537            graph_node("tests", NodeKind::Folder, Some(".")),
8538            graph_node("tests/api_test.rs", NodeKind::File, Some("tests")),
8539            graph_node("clients", NodeKind::Folder, Some(".")),
8540            graph_node(&manifest_path, NodeKind::File, Some(".")),
8541        ];
8542        let api = GraphEntity::new(
8543            project,
8544            EntitySelector::File {
8545                path: RepositoryFilePath::new(Path::new(&api_path))?,
8546            },
8547            generation,
8548        )?;
8549        let internal_caller = GraphEntity::new(
8550            project,
8551            EntitySelector::File {
8552                path: RepositoryFilePath::new(Path::new("src/auth/caller.rs"))?,
8553            },
8554            generation,
8555        )?;
8556        let other = GraphEntity::new(
8557            project,
8558            EntitySelector::File {
8559                path: RepositoryFilePath::new(Path::new("src/other.rs"))?,
8560            },
8561            generation,
8562        )?;
8563        let sibling = GraphEntity::new(
8564            project,
8565            EntitySelector::File {
8566                path: RepositoryFilePath::new(Path::new("src/authz.rs"))?,
8567            },
8568            generation,
8569        )?;
8570        let test = GraphEntity::new(
8571            project,
8572            EntitySelector::File {
8573                path: RepositoryFilePath::new(Path::new("tests/api_test.rs"))?,
8574            },
8575            generation,
8576        )?;
8577        let package = GraphEntity::new(
8578            project,
8579            EntitySelector::Package {
8580                package: PackageSelector {
8581                    manager: GraphIdentityText::new("cargo")?,
8582                    name: GraphIdentityText::new("projectatlas-navigation")?,
8583                    manifest: RepositoryFilePath::new(Path::new(&manifest_path))?,
8584                },
8585            },
8586            generation,
8587        )?;
8588        let mut entities = vec![
8589            api.clone(),
8590            internal_caller.clone(),
8591            other.clone(),
8592            sibling.clone(),
8593            test.clone(),
8594            package.clone(),
8595        ];
8596        let mut relations = vec![
8597            LogicalRelation::new(
8598                &api,
8599                GraphRelationKind::Legacy(RelationKind::DependsOn),
8600                RelationResolution::resolved(&package)?,
8601                ConfidenceClass::Exact,
8602                Completeness::Complete,
8603                generation,
8604            )?,
8605            LogicalRelation::new(
8606                &api,
8607                GraphRelationKind::Legacy(RelationKind::Imports),
8608                RelationResolution::resolved(&other)?,
8609                ConfidenceClass::Exact,
8610                Completeness::Complete,
8611                generation,
8612            )?,
8613            LogicalRelation::new(
8614                &api,
8615                GraphRelationKind::Legacy(RelationKind::Calls),
8616                RelationResolution::resolved(&other)?,
8617                ConfidenceClass::Exact,
8618                Completeness::Complete,
8619                generation,
8620            )?,
8621            LogicalRelation::new(
8622                &api,
8623                GraphRelationKind::Extended(ExtendedRelationKind::References),
8624                RelationResolution::Unresolved {
8625                    reference: GraphIdentityText::new("SessionStore")?,
8626                },
8627                ConfidenceClass::High,
8628                Completeness::Partial,
8629                generation,
8630            )?,
8631            LogicalRelation::new(
8632                &test,
8633                GraphRelationKind::Extended(ExtendedRelationKind::Tests),
8634                RelationResolution::resolved(&api)?,
8635                ConfidenceClass::Exact,
8636                Completeness::Complete,
8637                generation,
8638            )?,
8639            LogicalRelation::new(
8640                &api,
8641                GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo),
8642                RelationResolution::resolved(&other)?,
8643                ConfidenceClass::High,
8644                Completeness::Complete,
8645                generation,
8646            )?,
8647            LogicalRelation::new(
8648                &api,
8649                GraphRelationKind::Extended(ExtendedRelationKind::Configures),
8650                RelationResolution::Unresolved {
8651                    reference: GraphIdentityText::new("AUTH_MODE")?,
8652                },
8653                ConfidenceClass::High,
8654                Completeness::Partial,
8655                generation,
8656            )?,
8657            LogicalRelation::new(
8658                &internal_caller,
8659                GraphRelationKind::Legacy(RelationKind::Calls),
8660                RelationResolution::resolved(&api)?,
8661                ConfidenceClass::Exact,
8662                Completeness::Complete,
8663                generation,
8664            )?,
8665            LogicalRelation::new(
8666                &sibling,
8667                GraphRelationKind::Legacy(RelationKind::Imports),
8668                RelationResolution::resolved(&other)?,
8669                ConfidenceClass::Exact,
8670                Completeness::Complete,
8671                generation,
8672            )?,
8673        ];
8674        for index in 0..4 {
8675            let path = format!("clients/caller-{index}.rs");
8676            nodes.push(graph_node(&path, NodeKind::File, Some("clients")));
8677            let caller = GraphEntity::new(
8678                project,
8679                EntitySelector::File {
8680                    path: RepositoryFilePath::new(Path::new(&path))?,
8681                },
8682                generation,
8683            )?;
8684            relations.push(LogicalRelation::new(
8685                &caller,
8686                GraphRelationKind::Legacy(RelationKind::Calls),
8687                RelationResolution::resolved(&api)?,
8688                ConfidenceClass::Exact,
8689                Completeness::Complete,
8690                generation,
8691            )?);
8692            entities.push(caller);
8693        }
8694
8695        let mut publication = store.begin_index_publication(fingerprint)?;
8696        publication.begin_scan_replacement()?;
8697        publication.upsert_scan_node_batch(&nodes)?;
8698        publication.finish_scan_replacement()?;
8699        publication.replace_repository_graph(project, &entities, &relations, &[], &[])?;
8700        publication.complete()?;
8701        Ok(NavigationFixture {
8702            project,
8703            api_path,
8704            manifest_path,
8705        })
8706    }
8707
8708    /// Return a test failure without relying on panic-only assertions.
8709    fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
8710        if condition {
8711            Ok(())
8712        } else {
8713            Err(io::Error::other(message.to_string()).into())
8714        }
8715    }
8716
8717    /// Compare values while preserving useful failure context in fallible tests.
8718    fn require_eq<T: Debug + PartialEq>(
8719        actual: &T,
8720        expected: &T,
8721        context: &str,
8722    ) -> Result<(), Box<dyn Error>> {
8723        require(
8724            actual == expected,
8725            &format!("{context}: expected {expected:?}, found {actual:?}"),
8726        )
8727    }
8728
8729    /// Require a database operation to fail and return its typed error.
8730    fn require_db_error<T>(result: DbResult<T>, message: &str) -> Result<DbError, Box<dyn Error>> {
8731        let Err(error) = result else {
8732            return Err(io::Error::other(message.to_string()).into());
8733        };
8734        Ok(error)
8735    }
8736
8737    /// Prove cursor hydration seeks through stable-key indexes.
8738    fn assert_cursor_hydration_indexes(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
8739        let project = store
8740            .project_instance_id()?
8741            .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
8742        let cases = [
8743            (
8744                "entity cursor hydration",
8745                graph_entity_hydration_sql(2),
8746                vec![
8747                    Value::Blob(vec![0; 32]),
8748                    Value::Blob(vec![1; 32]),
8749                    Value::Blob(project.as_bytes().to_vec()),
8750                ],
8751                "project_instance_id=? AND entity_key=?",
8752                "SCAN entity",
8753                false,
8754            ),
8755            (
8756                "relation cursor hydration",
8757                graph_relation_hydration_sql(2),
8758                vec![
8759                    Value::Blob(vec![0; 32]),
8760                    Value::Blob(vec![1; 32]),
8761                    Value::Blob(project.as_bytes().to_vec()),
8762                ],
8763                "idx_graph_relations_project_key",
8764                "SCAN relation",
8765                false,
8766            ),
8767            (
8768                "batched occurrence hydration",
8769                occurrence_pages_sql(2),
8770                vec![
8771                    Value::Blob(vec![0; 32]),
8772                    Value::Integer(2),
8773                    Value::Blob(vec![1; 32]),
8774                    Value::Integer(2),
8775                ],
8776                "sqlite_autoindex_graph_relation_occurrences_1",
8777                "SCAN graph_relation_occurrences",
8778                true,
8779            ),
8780            (
8781                "batched path coverage hydration",
8782                path_coverage_sql(2),
8783                vec![
8784                    Value::Blob(project.as_bytes().to_vec()),
8785                    Value::Text("path".to_string()),
8786                    Value::Text("Cargo.toml".to_string()),
8787                    Value::Text("src/Äuth.rs".to_string()),
8788                    Value::Integer(i64::from(GraphLimits::MAX_ROWS) + 1),
8789                ],
8790                "idx_graph_coverage_scope_order",
8791                "SCAN graph_coverage",
8792                false,
8793            ),
8794        ];
8795        for (context, sql, bindings, required_plan, forbidden_scan, allow_bounded_sort) in cases {
8796            let mut statement = store
8797                .connection
8798                .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
8799            let details = statement
8800                .query_map(params_from_iter(bindings.iter()), |row| {
8801                    row.get::<_, String>(3)
8802                })?
8803                .collect::<Result<Vec<_>, _>>()?;
8804            require(
8805                details.iter().any(|detail| detail.contains(required_plan))
8806                    && details.iter().all(|detail| {
8807                        !detail.contains(forbidden_scan)
8808                            && (allow_bounded_sort || !detail.contains("USE TEMP B-TREE"))
8809                    }),
8810                &format!(
8811                    "{context} did not use {required_plan} without a scan or sort: {details:?}"
8812                ),
8813            )?;
8814        }
8815        Ok(())
8816    }
8817
8818    /// Prove each normal graph query shape enters through its owning index.
8819    fn assert_query_indexes(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
8820        let cases: &[(&str, &str, &[&str])] = &[
8821            (
8822                "entity path lookup",
8823                "EXPLAIN QUERY PLAN
8824                 SELECT entity_key FROM graph_entities
8825                  WHERE project_instance_id = zeroblob(16)
8826                    AND repository_path = 'src/Äuth.rs'
8827                  ORDER BY entity_kind, entity_key
8828                  LIMIT 11",
8829                &["idx_graph_entities_path"],
8830            ),
8831            (
8832                "outbound relation lookup",
8833                "EXPLAIN QUERY PLAN
8834                 SELECT relation_key FROM graph_relations
8835                  WHERE source_entity_key = zeroblob(32)
8836                  ORDER BY relation_scope, relation_kind, relation_key
8837                  LIMIT 11",
8838                &["idx_graph_relations_source_kind"],
8839            ),
8840            (
8841                "inbound relation lookup",
8842                "EXPLAIN QUERY PLAN
8843                 SELECT relation_key FROM graph_relations
8844                  WHERE target_entity_key = zeroblob(32)
8845                  ORDER BY relation_scope, relation_kind, relation_key
8846                  LIMIT 11",
8847                &["idx_graph_relations_target_kind"],
8848            ),
8849            (
8850                "inbound documents lookup",
8851                "EXPLAIN QUERY PLAN
8852                 SELECT relation_key FROM graph_relations
8853                  WHERE target_entity_key = zeroblob(32)
8854                    AND relation_scope = 'extended'
8855                    AND relation_kind = 'documents'
8856                  ORDER BY relation_scope, relation_kind, relation_key
8857                  LIMIT 11",
8858                &["idx_graph_relations_target_kind"],
8859            ),
8860            (
8861                "relation family lookup",
8862                "EXPLAIN QUERY PLAN
8863                 SELECT relation_key FROM graph_relations
8864                  WHERE project_instance_id = zeroblob(16)
8865                    AND relation_scope = 'legacy'
8866                    AND relation_kind = 'calls'
8867                  ORDER BY relation_key
8868                  LIMIT 11",
8869                &["idx_graph_relations_kind_order"],
8870            ),
8871            (
8872                "relation occurrence lookup",
8873                "EXPLAIN QUERY PLAN
8874                 SELECT file_path FROM graph_relation_occurrences
8875                  WHERE relation_key = zeroblob(32)
8876                  ORDER BY file_path, start_line, start_column, end_line, end_column
8877                  LIMIT 11",
8878                &["sqlite_autoindex_graph_relation_occurrences_1"],
8879            ),
8880            (
8881                "occurrence path invalidation",
8882                "EXPLAIN QUERY PLAN
8883                 SELECT relation_key FROM graph_relation_occurrences
8884                  WHERE file_path = 'src'
8885                     OR (file_path >= 'src/' AND file_path < 'src0')",
8886                &["idx_graph_occurrences_file_span"],
8887            ),
8888            (
8889                "coverage path invalidation",
8890                "EXPLAIN QUERY PLAN
8891                 SELECT id FROM graph_coverage
8892                        INDEXED BY idx_graph_coverage_path
8893                  WHERE scope_kind = 'path'
8894                    AND (scope_path = 'src'
8895                     OR (scope_path >= 'src/' AND scope_path < 'src0'))",
8896                &["idx_graph_coverage_path"],
8897            ),
8898            (
8899                "entity repository-path invalidation",
8900                "EXPLAIN QUERY PLAN
8901                 SELECT entity_key FROM graph_entities
8902                  WHERE repository_path = 'src'
8903                     OR (repository_path >= 'src/' AND repository_path < 'src0')",
8904                &["idx_graph_entities_path"],
8905            ),
8906            (
8907                "entity manifest-path invalidation",
8908                "EXPLAIN QUERY PLAN
8909                 SELECT entity_key FROM graph_entities
8910                  WHERE manifest_path = 'src'
8911                     OR (manifest_path >= 'src/' AND manifest_path < 'src0')",
8912                &["idx_graph_entities_manifest_path"],
8913            ),
8914            (
8915                "outbound external cleanup candidate",
8916                "EXPLAIN QUERY PLAN
8917                 SELECT relation.target_entity_key
8918                   FROM graph_relations AS relation
8919                        INDEXED BY idx_graph_relations_source_kind
8920                   JOIN graph_entities AS external
8921                     ON external.entity_key = relation.target_entity_key
8922                  WHERE relation.source_entity_key = zeroblob(32)
8923                    AND external.entity_kind = 'external'",
8924                &["idx_graph_relations_source_kind"],
8925            ),
8926            (
8927                "inbound external cleanup candidate",
8928                "EXPLAIN QUERY PLAN
8929                 SELECT relation.source_entity_key
8930                   FROM graph_relations AS relation
8931                        INDEXED BY idx_graph_relations_target_kind
8932                   JOIN graph_entities AS external
8933                     ON external.entity_key = relation.source_entity_key
8934                  WHERE relation.target_entity_key = zeroblob(32)
8935                    AND external.entity_kind = 'external'",
8936                &["idx_graph_relations_target_kind"],
8937            ),
8938            (
8939                "candidate-bounded external cleanup",
8940                "EXPLAIN QUERY PLAN
8941                 DELETE FROM graph_entities
8942                  WHERE entity_key = zeroblob(32) AND entity_kind = 'external'
8943                    AND NOT EXISTS (
8944                        SELECT 1 FROM graph_relations
8945                               INDEXED BY idx_graph_relations_source_kind
8946                         WHERE source_entity_key = zeroblob(32)
8947                    )
8948                    AND NOT EXISTS (
8949                        SELECT 1 FROM graph_relations
8950                               INDEXED BY idx_graph_relations_target_kind
8951                         WHERE target_entity_key = zeroblob(32)
8952                    )",
8953                &[
8954                    "sqlite_autoindex_graph_entities_1",
8955                    "idx_graph_relations_source_kind",
8956                    "idx_graph_relations_target_kind",
8957                ],
8958            ),
8959            (
8960                "coverage scope lookup",
8961                "EXPLAIN QUERY PLAN
8962                 SELECT id FROM graph_coverage
8963                  WHERE project_instance_id = zeroblob(16)
8964                    AND scope_kind = 'path'
8965                    AND scope_path IS 'src'
8966                  ORDER BY relation_scope, relation_kind, state, id
8967                  LIMIT 11",
8968                &["idx_graph_coverage_scope_order"],
8969            ),
8970            (
8971                "resolution witness lookup",
8972                "EXPLAIN QUERY PLAN
8973                 SELECT canonical_identity FROM graph_resolution_keys
8974                  WHERE project_instance_id = zeroblob(16)
8975                    AND resolution_domain = 'declaration'
8976                    AND key_digest = zeroblob(32)",
8977                &["sqlite_autoindex_graph_resolution_keys_1"],
8978            ),
8979            (
8980                "resolution export lookup",
8981                "EXPLAIN QUERY PLAN
8982                 SELECT entity_key FROM graph_entity_exports
8983                  WHERE project_instance_id = zeroblob(16)
8984                    AND resolution_domain = 'declaration'
8985                    AND key_digest = zeroblob(32)
8986                  ORDER BY entity_key",
8987                &["idx_graph_entity_exports_key"],
8988            ),
8989            (
8990                "resolution export owner lookup",
8991                "EXPLAIN QUERY PLAN
8992                 SELECT resolution_domain, key_digest, entity_key
8993                   FROM graph_entity_exports
8994                  WHERE project_instance_id = zeroblob(16)
8995                    AND owner_path = 'src/Äuth.rs'
8996                  ORDER BY resolution_domain, key_digest, entity_key",
8997                &["idx_graph_entity_exports_owner"],
8998            ),
8999            (
9000                "resolution dependency lookup",
9001                "EXPLAIN QUERY PLAN
9002                 SELECT owner_path, relation_key FROM graph_relation_dependencies
9003                  WHERE project_instance_id = zeroblob(16)
9004                    AND resolution_domain = 'declaration'
9005                    AND key_digest = zeroblob(32)
9006                  ORDER BY owner_path, relation_key",
9007                &["idx_graph_relation_dependencies_key"],
9008            ),
9009            (
9010                "resolution dependency owner lookup",
9011                "EXPLAIN QUERY PLAN
9012                 SELECT resolution_domain, key_digest, relation_key
9013                   FROM graph_relation_dependencies
9014                  WHERE project_instance_id = zeroblob(16)
9015                    AND owner_path = 'src/Äuth.rs'
9016                  ORDER BY resolution_domain, key_digest, relation_key",
9017                &["idx_graph_relation_dependencies_owner"],
9018            ),
9019            (
9020                "relation composite integrity lookup",
9021                "EXPLAIN QUERY PLAN
9022                 SELECT relation_key FROM graph_relations
9023                  WHERE project_instance_id = zeroblob(16)
9024                    AND relation_key = zeroblob(32)",
9025                &["idx_graph_relations_project_key"],
9026            ),
9027            (
9028                "document unresolved reason validation",
9029                "EXPLAIN QUERY PLAN
9030                 SELECT EXISTS(
9031                     SELECT 1
9032                       FROM graph_relations
9033                            INDEXED BY idx_graph_relations_project_key
9034                      WHERE relation_key = zeroblob(32)
9035                        AND project_instance_id = zeroblob(16)
9036                        AND relation_scope = 'extended'
9037                        AND relation_kind = 'documents'
9038                        AND resolution_status = 'unresolved'
9039                 )",
9040                &["idx_graph_relations_project_key"],
9041            ),
9042        ];
9043
9044        for (context, sql, required_indexes) in cases {
9045            let mut statement = store.connection.prepare(sql)?;
9046            let details = statement
9047                .query_map([], |row| row.get::<_, String>(3))?
9048                .collect::<Result<Vec<_>, _>>()?;
9049            require(
9050                required_indexes
9051                    .iter()
9052                    .all(|index| details.iter().any(|detail| detail.contains(index))),
9053                &format!("{context} did not use {required_indexes:?}; query plan was {details:?}"),
9054            )?;
9055            require(
9056                details.iter().all(|detail| {
9057                    !detail.contains("SCAN graph_") && !detail.contains("USE TEMP B-TREE")
9058                }),
9059                &format!("{context} was not bounded by index order: {details:?}"),
9060            )?;
9061        }
9062
9063        for key_count in [2, EXTERNAL_CANDIDATE_KEYS_PER_QUERY] {
9064            let sql = format!(
9065                "EXPLAIN QUERY PLAN {}",
9066                external_candidate_batch_sql(key_count)
9067            );
9068            let bindings = (0..key_count)
9069                .map(|_| Value::Blob(vec![0_u8; 32]))
9070                .collect::<Vec<_>>();
9071            let mut statement = store.connection.prepare(&sql)?;
9072            let details = statement
9073                .query_map(params_from_iter(bindings.iter()), |row| {
9074                    row.get::<_, String>(3)
9075                })?
9076                .collect::<Result<Vec<_>, _>>()?;
9077            require(
9078                [
9079                    "SEARCH relation USING INDEX idx_graph_relations_source_kind (source_entity_key=?)",
9080                    "SEARCH relation USING INDEX idx_graph_relations_target_kind (target_entity_key=?)",
9081                ]
9082                .iter()
9083                .all(|seek| details.iter().any(|detail| detail.contains(seek)))
9084                    && details.iter().all(|detail| {
9085                        !detail.contains("SCAN relation")
9086                            && !detail.contains("SCAN graph_relations")
9087                            && !detail.contains("USE TEMP B-TREE")
9088                    }),
9089                &format!(
9090                    "{key_count}-key external cleanup batch was not driven by indexed point seeks: {details:?}"
9091                ),
9092            )?;
9093        }
9094        Ok(())
9095    }
9096
9097    /// Require one coverage-discovery query shape to seek through its owning indexes.
9098    fn assert_coverage_discovery_plan(
9099        connection: &Connection,
9100        sql: &str,
9101        values: &[Value],
9102        required_indexes: &[&str],
9103        allow_bounded_partial_sort: bool,
9104        context: &str,
9105    ) -> Result<(), Box<dyn Error>> {
9106        let mut statement = connection.prepare(sql)?;
9107        let details = statement
9108            .query_map(params_from_iter(values.iter()), |row| {
9109                row.get::<_, String>(3)
9110            })?
9111            .collect::<Result<Vec<_>, _>>()?;
9112        require(
9113            required_indexes
9114                .iter()
9115                .all(|index| details.iter().any(|detail| detail.contains(index))),
9116            &format!("{context} did not use {required_indexes:?}; query plan was {details:?}"),
9117        )?;
9118        require(
9119            details.iter().all(|detail| {
9120                let uses_temporary_sort = detail.contains("USE TEMP B-TREE");
9121                let bounded_partial_sort = detail.contains("USE TEMP B-TREE FOR LAST");
9122                !detail.contains("SCAN coverage")
9123                    && (!uses_temporary_sort
9124                        || (allow_bounded_partial_sort && bounded_partial_sort))
9125            }),
9126            &format!("{context} used an unbounded scan or sort: {details:?}"),
9127        )?;
9128        Ok(())
9129    }
9130
9131    /// Publish the maximum relation batch with two occurrences per unique target.
9132    fn publish_detailed_relation_trace_fixture(
9133        store: &mut AtlasStore,
9134        fingerprint: &str,
9135    ) -> Result<DetailedRelationTraceFixture, Box<dyn Error>> {
9136        let project = store
9137            .project_instance_id()?
9138            .ok_or_else(|| io::Error::other("bound trace fixture identity is missing"))?;
9139        let generation = IndexGeneration::new(1);
9140        let source_path = RepositoryFilePath::new(Path::new("src/trace-source.rs"))?;
9141        let source = GraphEntity::new(
9142            project,
9143            EntitySelector::File {
9144                path: source_path.clone(),
9145            },
9146            generation,
9147        )?;
9148        let mut entities = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER + 1);
9149        let mut relations = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER);
9150        let mut occurrences = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER * 2);
9151        entities.push(source.clone());
9152        for index in 0..MAX_REPOSITORY_GRAPH_FRONTIER {
9153            let name = format!("trace_target_{index:03}");
9154            let target = GraphEntity::new(
9155                project,
9156                EntitySelector::Symbol {
9157                    symbol: SymbolSelector {
9158                        file: source_path.clone(),
9159                        name: GraphIdentityText::new(&name)?,
9160                        kind: SymbolKind::Function,
9161                        parent: None,
9162                        signature: GraphIdentityText::new(format!("{name}()"))?,
9163                    },
9164                },
9165                generation,
9166            )?;
9167            let relation = LogicalRelation::new(
9168                &source,
9169                GraphRelationKind::Legacy(RelationKind::Calls),
9170                RelationResolution::resolved(&target)?,
9171                ConfidenceClass::Exact,
9172                Completeness::Complete,
9173                generation,
9174            )?;
9175            let first_line = u32::try_from(index * 2 + 1)?;
9176            let second_line = first_line + 1;
9177            occurrences.push(RelationOccurrence::new(
9178                &relation,
9179                source_path.clone(),
9180                SourceSpan::new(first_line, 1, first_line, 2)?,
9181                generation,
9182            )?);
9183            occurrences.push(RelationOccurrence::new(
9184                &relation,
9185                source_path.clone(),
9186                SourceSpan::new(second_line, 1, second_line, 2)?,
9187                generation,
9188            )?);
9189            entities.push(target);
9190            relations.push(relation);
9191        }
9192
9193        let mut publication = store.begin_index_publication(fingerprint)?;
9194        publication.begin_scan_replacement()?;
9195        publication.upsert_scan_node_batch(&[
9196            graph_node(".", NodeKind::Folder, None),
9197            graph_node("src", NodeKind::Folder, Some(".")),
9198            graph_node("src/trace-source.rs", NodeKind::File, Some("src")),
9199        ])?;
9200        publication.finish_scan_replacement()?;
9201        publication.replace_repository_graph(project, &entities, &relations, &occurrences, &[])?;
9202        publication.complete()?;
9203        Ok(DetailedRelationTraceFixture {
9204            project,
9205            source,
9206            relations,
9207        })
9208    }
9209
9210    /// Publish one complete fixture and its lexical source text.
9211    fn publish_fixture(
9212        store: &mut AtlasStore,
9213        fingerprint: &str,
9214    ) -> Result<GraphFixture, Box<dyn Error>> {
9215        let project = store
9216            .project_instance_id()?
9217            .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
9218        let mut fixture = graph_fixture(project, IndexGeneration::new(1))?;
9219        fixture.coverage.push(CoverageRecord::new(
9220            CoverageScope::Path {
9221                path: RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
9222            },
9223            None,
9224            CoverageState::Complete,
9225            1,
9226            0,
9227            IndexGeneration::new(1),
9228            None,
9229            None,
9230        )?);
9231        let mut occurrences = fixture.occurrences.clone();
9232        occurrences.push(fixture.occurrences[0].clone());
9233        let mut publication = store.begin_index_publication(fingerprint)?;
9234        publication.begin_scan_replacement()?;
9235        publication.upsert_scan_node_batch(&[
9236            graph_node(".", NodeKind::Folder, None),
9237            graph_node("src", NodeKind::Folder, Some(".")),
9238            graph_node("src/Äuth.rs", NodeKind::File, Some("src")),
9239            graph_node("Cargo.toml", NodeKind::File, Some(".")),
9240        ])?;
9241        publication.finish_scan_replacement()?;
9242        publication.replace_symbol_graph(&SymbolGraph {
9243            path: "src/Äuth.rs".to_string(),
9244            language: Some("rust".to_string()),
9245            parser: ParserKind::TreeSitter,
9246            symbols: Vec::new(),
9247            relations: vec![SymbolRelation {
9248                path: "src/Äuth.rs".to_string(),
9249                source_name: "verifyToken".to_string(),
9250                target_name: "legacyTarget".to_string(),
9251                kind: RelationKind::Calls,
9252                line: 10,
9253                context: "legacyTarget()".to_string(),
9254                parser: ParserKind::TreeSitter,
9255            }],
9256        })?;
9257        publication.replace_file_texts_for_paths(
9258            &["src/Äuth.rs".to_string()],
9259            &[IndexedFileText {
9260                path: "src/Äuth.rs".to_string(),
9261                content_hash: Some("hash-old".to_string()),
9262                byte_count: 16,
9263                line_count: 1,
9264                content: "fn verifyToken()".to_string(),
9265            }],
9266        )?;
9267        publication.replace_repository_graph(
9268            fixture.project,
9269            &fixture.entities,
9270            &fixture.relations,
9271            &occurrences,
9272            &fixture.coverage,
9273        )?;
9274        publication.complete()?;
9275        Ok(fixture)
9276    }
9277
9278    #[test]
9279    fn classified_relation_family_filters_before_limit_and_preserves_legacy_page()
9280    -> Result<(), Box<dyn Error>> {
9281        let temp = tempfile::tempdir()?;
9282        let root = temp.path().join("classified-relation-family");
9283        fs::create_dir_all(&root)?;
9284        let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9285        let project = store
9286            .project_instance_id()?
9287            .ok_or_else(|| io::Error::other("classified fixture identity is missing"))?;
9288        let generation = IndexGeneration::new(1);
9289        let paths = (0..6)
9290            .map(|index| format!("src/source-{index}.rs"))
9291            .collect::<Vec<_>>();
9292        let sources = paths
9293            .iter()
9294            .map(|path| {
9295                GraphEntity::new(
9296                    project,
9297                    EntitySelector::File {
9298                        path: RepositoryFilePath::new(Path::new(path))?,
9299                    },
9300                    generation,
9301                )
9302                .map_err(Into::into)
9303            })
9304            .collect::<Result<Vec<_>, Box<dyn Error>>>()?;
9305        let external = GraphEntity::new(
9306            project,
9307            EntitySelector::External {
9308                external: ExternalSelector {
9309                    system: GraphIdentityText::new("classified-family")?,
9310                    identity: GraphIdentityText::new("shared-target")?,
9311                },
9312            },
9313            generation,
9314        )?;
9315        let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9316        let mut relations = sources
9317            .iter()
9318            .map(|source| {
9319                LogicalRelation::new(
9320                    source,
9321                    calls,
9322                    RelationResolution::external(&external)?,
9323                    ConfidenceClass::Exact,
9324                    Completeness::Complete,
9325                    generation,
9326                )
9327                .map_err(Into::into)
9328            })
9329            .collect::<Result<Vec<_>, Box<dyn Error>>>()?;
9330        relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
9331        let classifications = [
9332            ContentClassification::Documentation,
9333            ContentClassification::ConfigurationData,
9334            ContentClassification::OtherText,
9335            ContentClassification::Opaque,
9336            ContentClassification::Source,
9337            ContentClassification::Documentation,
9338        ];
9339        let rows = relations
9340            .iter()
9341            .zip(classifications)
9342            .map(|(relation, classification)| {
9343                let source = sources
9344                    .iter()
9345                    .find(|source| source.key() == relation.source())
9346                    .ok_or_else(|| io::Error::other("relation source fixture is missing"))?;
9347                let EntitySelector::File { path } = source.selector() else {
9348                    return Err(io::Error::other("relation source fixture is not a file"));
9349                };
9350                Ok(crate::FileContentClassification {
9351                    path: path.as_str().to_string(),
9352                    classification,
9353                })
9354            })
9355            .collect::<Result<Vec<_>, io::Error>>()?;
9356        let mut nodes = vec![
9357            graph_node(".", NodeKind::Folder, None),
9358            graph_node("src", NodeKind::Folder, Some(".")),
9359        ];
9360        nodes.extend(
9361            paths
9362                .iter()
9363                .map(|path| graph_node(path, NodeKind::File, Some("src"))),
9364        );
9365        let mut entities = sources;
9366        entities.push(external);
9367        let mut publication = store.begin_index_publication("classified-relation-family")?;
9368        publication.begin_scan_replacement()?;
9369        publication.upsert_scan_node_batch(&nodes)?;
9370        publication.finish_scan_replacement()?;
9371        publication.upsert_file_content_classification_batch(&rows)?;
9372        publication.replace_repository_graph(project, &entities, &relations, &[], &[])?;
9373        publication.complete()?;
9374
9375        let legacy = store.repository_graph_relation_rows(
9376            RepositoryGraphRelationQuery::Family { relation: calls },
9377            2,
9378            None,
9379        )?;
9380        let classified_legacy = store.repository_graph_classified_relation_family_rows(
9381            calls,
9382            ContentSelection::UnspecifiedLegacy,
9383            2,
9384            None,
9385        )?;
9386        require_eq(
9387            &classified_legacy
9388                .rows
9389                .iter()
9390                .map(|row| row.detail.clone())
9391                .collect::<Vec<_>>(),
9392            &legacy.rows,
9393            "omitted-selection relation rows and order",
9394        )?;
9395        require_eq(
9396            &classified_legacy.truncated,
9397            &legacy.truncated,
9398            "omitted-selection truncation",
9399        )?;
9400        require_eq(
9401            &classified_legacy
9402                .rows
9403                .iter()
9404                .map(|row| row.source_classification)
9405                .collect::<Vec<_>>(),
9406            &vec![
9407                Some(ContentClassification::Documentation),
9408                Some(ContentClassification::ConfigurationData),
9409            ],
9410            "omitted-selection additive classifications",
9411        )?;
9412
9413        let (source, source_statements) = trace_statements(&mut store, |store| {
9414            store.repository_graph_classified_relation_family_rows(
9415                calls,
9416                ContentSelection::Source,
9417                1,
9418                None,
9419            )
9420        })?;
9421        require(
9422            source.rows.len() == 1
9423                && !source.truncated
9424                && source.rows[0].detail.relation == relations[4]
9425                && source.rows[0].source_classification == Some(ContentClassification::Source),
9426            "source selection filtered only after a bounded mixed page",
9427        )?;
9428        require_eq(
9429            &source_statements
9430                .iter()
9431                .filter(|statement| statement.contains("file_content_classifications"))
9432                .count(),
9433            &1,
9434            "source classification statement count",
9435        )?;
9436        let documentation = store.repository_graph_classified_relation_family_rows(
9437            calls,
9438            ContentSelection::Documentation,
9439            1,
9440            None,
9441        )?;
9442        require(
9443            documentation.rows.len() == 1
9444                && documentation.truncated
9445                && documentation.rows[0].detail.relation == relations[0]
9446                && documentation.rows[0].source_classification
9447                    == Some(ContentClassification::Documentation),
9448            "documentation selection did not preserve pre-limit ordering",
9449        )?;
9450        let both = store.repository_graph_classified_relation_family_rows(
9451            calls,
9452            ContentSelection::Both,
9453            2,
9454            None,
9455        )?;
9456        require(
9457            both.rows.len() == 2
9458                && both.truncated
9459                && both.rows[0].detail.relation == relations[0]
9460                && both.rows[1].detail.relation == relations[4]
9461                && both.rows.iter().all(|row| {
9462                    row.source_classification.is_some_and(|classification| {
9463                        ContentSelection::Both.includes(classification)
9464                    })
9465                }),
9466            "combined selection admitted an excluded class or filtered after limit",
9467        )?;
9468
9469        for selection in [
9470            ContentSelection::UnspecifiedLegacy,
9471            ContentSelection::Source,
9472            ContentSelection::Both,
9473        ] {
9474            let mut bindings = vec![
9475                Value::Blob(project.as_bytes().to_vec()),
9476                Value::Text("legacy".to_string()),
9477                Value::Text("calls".to_string()),
9478            ];
9479            match selection {
9480                ContentSelection::UnspecifiedLegacy => {}
9481                ContentSelection::Source => bindings.push(Value::Text("source".to_string())),
9482                ContentSelection::Documentation => {
9483                    bindings.push(Value::Text("documentation".to_string()));
9484                }
9485                ContentSelection::Both => bindings.extend([
9486                    Value::Text("source".to_string()),
9487                    Value::Text("documentation".to_string()),
9488                ]),
9489            }
9490            bindings.push(Value::Integer(3));
9491            let sql = format!(
9492                "EXPLAIN QUERY PLAN {}",
9493                classified_relation_family_sql(selection)
9494            );
9495            let mut statement = store.connection.prepare(&sql)?;
9496            let details = statement
9497                .query_map(params_from_iter(bindings.iter()), |row| {
9498                    row.get::<_, String>(3)
9499                })?
9500                .collect::<Result<Vec<_>, _>>()?;
9501            require(
9502                details
9503                    .iter()
9504                    .any(|detail| detail.contains("idx_graph_relations_kind_order"))
9505                    && details
9506                        .iter()
9507                        .any(|detail| detail.contains("file_content_classifications"))
9508                    && details
9509                        .iter()
9510                        .all(|detail| !detail.contains("SCAN relation"))
9511                    && details
9512                        .iter()
9513                        .all(|detail| !detail.contains("USE TEMP B-TREE")),
9514                &format!("classified family query did not retain indexed ordering: {details:?}"),
9515            )?;
9516        }
9517
9518        let invalid = require_db_error(
9519            store.repository_graph_classified_relation_family_rows(
9520                calls,
9521                ContentSelection::Source,
9522                0,
9523                None,
9524            ),
9525            "zero classified family limit succeeded",
9526        )?;
9527        require(
9528            matches!(
9529                invalid,
9530                DbError::GraphContract(GraphContractError::InvalidLimits { .. })
9531            ),
9532            "zero classified family limit returned the wrong error",
9533        )?;
9534        Ok(())
9535    }
9536
9537    #[test]
9538    fn filtered_terminal_probe_classifies_the_directional_endpoint() -> Result<(), Box<dyn Error>> {
9539        let temp = tempfile::tempdir()?;
9540        let root = temp.path().join("filtered-terminal-probe");
9541        fs::create_dir_all(&root)?;
9542        let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9543        let project = store
9544            .project_instance_id()?
9545            .ok_or_else(|| io::Error::other("filtered probe fixture identity is missing"))?;
9546        let generation = IndexGeneration::new(1);
9547        let doc_source = GraphEntity::new(
9548            project,
9549            EntitySelector::File {
9550                path: RepositoryFilePath::new(Path::new("docs/source.md"))?,
9551            },
9552            generation,
9553        )?;
9554        let source_target = GraphEntity::new(
9555            project,
9556            EntitySelector::File {
9557                path: RepositoryFilePath::new(Path::new("src/target.rs"))?,
9558            },
9559            generation,
9560        )?;
9561        let source_source = GraphEntity::new(
9562            project,
9563            EntitySelector::File {
9564                path: RepositoryFilePath::new(Path::new("src/source.rs"))?,
9565            },
9566            generation,
9567        )?;
9568        let doc_target = GraphEntity::new(
9569            project,
9570            EntitySelector::File {
9571                path: RepositoryFilePath::new(Path::new("docs/target.md"))?,
9572            },
9573            generation,
9574        )?;
9575        let external_document_target = GraphEntity::new(
9576            project,
9577            EntitySelector::External {
9578                external: ExternalSelector {
9579                    system: GraphIdentityText::new("docs.example")?,
9580                    identity: GraphIdentityText::new("external-page")?,
9581                },
9582            },
9583            generation,
9584        )?;
9585        let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9586        let documents = GraphRelationKind::Extended(ExtendedRelationKind::Documents);
9587        let relations = vec![
9588            LogicalRelation::new(
9589                &doc_source,
9590                calls,
9591                RelationResolution::resolved(&source_target)?,
9592                ConfidenceClass::Exact,
9593                Completeness::Complete,
9594                generation,
9595            )?,
9596            LogicalRelation::new(
9597                &source_source,
9598                calls,
9599                RelationResolution::resolved(&doc_target)?,
9600                ConfidenceClass::Exact,
9601                Completeness::Complete,
9602                generation,
9603            )?,
9604            LogicalRelation::new(
9605                &doc_source,
9606                documents,
9607                RelationResolution::external(&external_document_target)?,
9608                ConfidenceClass::Exact,
9609                Completeness::Complete,
9610                generation,
9611            )?,
9612            LogicalRelation::new(
9613                &source_source,
9614                calls,
9615                RelationResolution::external(&external_document_target)?,
9616                ConfidenceClass::Exact,
9617                Completeness::Complete,
9618                generation,
9619            )?,
9620        ];
9621        let mut publication = store.begin_index_publication("filtered-terminal-probe")?;
9622        publication.begin_scan_replacement()?;
9623        publication.upsert_scan_node_batch(&[
9624            graph_node(".", NodeKind::Folder, None),
9625            graph_node("src", NodeKind::Folder, Some(".")),
9626            graph_node("docs", NodeKind::Folder, Some(".")),
9627            graph_node("src/target.rs", NodeKind::File, Some("src")),
9628            graph_node("src/source.rs", NodeKind::File, Some("src")),
9629            graph_node("docs/source.md", NodeKind::File, Some("docs")),
9630            graph_node("docs/target.md", NodeKind::File, Some("docs")),
9631        ])?;
9632        publication.finish_scan_replacement()?;
9633        publication.upsert_file_content_classification_batch(&[
9634            crate::FileContentClassification {
9635                path: "docs/source.md".to_string(),
9636                classification: ContentClassification::Documentation,
9637            },
9638            crate::FileContentClassification {
9639                path: "src/target.rs".to_string(),
9640                classification: ContentClassification::Source,
9641            },
9642            crate::FileContentClassification {
9643                path: "src/source.rs".to_string(),
9644                classification: ContentClassification::Source,
9645            },
9646            crate::FileContentClassification {
9647                path: "docs/target.md".to_string(),
9648                classification: ContentClassification::Documentation,
9649            },
9650        ])?;
9651        publication.replace_repository_graph(
9652            project,
9653            &[
9654                doc_source.clone(),
9655                source_target.clone(),
9656                source_source.clone(),
9657                doc_target.clone(),
9658                external_document_target,
9659            ],
9660            &relations,
9661            &[],
9662            &[],
9663        )?;
9664        publication.complete()?;
9665
9666        require(
9667            store.repository_graph_adjacency_is_empty_filtered(
9668                source_target.key(),
9669                RepositoryGraphDirection::Inbound,
9670                calls,
9671                ConfidenceClass::Exact,
9672                ContentSelection::Source,
9673                None,
9674            )?,
9675            "inbound documentation source was admitted under source selection",
9676        )?;
9677        require(
9678            !store.repository_graph_adjacency_is_empty_filtered(
9679                doc_target.key(),
9680                RepositoryGraphDirection::Inbound,
9681                calls,
9682                ConfidenceClass::Exact,
9683                ContentSelection::Source,
9684                None,
9685            )?,
9686            "inbound source endpoint was excluded under source selection",
9687        )?;
9688        require(
9689            !store.repository_graph_adjacency_is_empty_filtered(
9690                doc_source.key(),
9691                RepositoryGraphDirection::Outbound,
9692                calls,
9693                ConfidenceClass::Exact,
9694                ContentSelection::Source,
9695                None,
9696            )?,
9697            "outbound source-target endpoint was excluded under source selection",
9698        )?;
9699        require(
9700            !store.repository_graph_adjacency_is_empty_filtered(
9701                doc_source.key(),
9702                RepositoryGraphDirection::Outbound,
9703                documents,
9704                ConfidenceClass::Exact,
9705                ContentSelection::Source,
9706                None,
9707            )?,
9708            "external document endpoint was hidden under source selection",
9709        )?;
9710        require(
9711            !store.repository_graph_adjacency_is_empty_filtered(
9712                source_source.key(),
9713                RepositoryGraphDirection::Outbound,
9714                calls,
9715                ConfidenceClass::Exact,
9716                ContentSelection::Source,
9717                None,
9718            )?,
9719            "external non-document endpoint was hidden under source selection",
9720        )?;
9721        Ok(())
9722    }
9723
9724    #[test]
9725    fn resolved_relation_hubs_rank_the_complete_family_through_the_resolution_index()
9726    -> Result<(), Box<dyn Error>> {
9727        let temp = tempfile::tempdir()?;
9728        let root = temp.path().join("resolved-relation-hubs");
9729        fs::create_dir_all(&root)?;
9730        let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9731        let fixture = publish_fixture(&mut store, "resolved-relation-hubs")?;
9732
9733        let calls = store.repository_graph_resolved_relation_hubs(
9734            GraphRelationKind::Legacy(RelationKind::Calls),
9735            2,
9736            None,
9737        )?;
9738        let expected = BTreeSet::from([
9739            fixture.relations[0].source().digest().to_string(),
9740            fixture.relations[0]
9741                .resolution()
9742                .resolved_target()
9743                .ok_or_else(|| io::Error::other("resolved fixture target is missing"))?
9744                .digest()
9745                .to_string(),
9746        ]);
9747        let actual = calls
9748            .rows
9749            .iter()
9750            .map(|entity| entity.key().digest().to_string())
9751            .collect::<BTreeSet<_>>();
9752        require_eq(&actual, &expected, "resolved relation hubs")?;
9753        require_eq(&calls.truncated, &false, "resolved hub truncation")?;
9754
9755        let one = store.repository_graph_resolved_relation_hubs(
9756            GraphRelationKind::Legacy(RelationKind::Calls),
9757            1,
9758            None,
9759        )?;
9760        require_eq(&one.rows.len(), &1, "bounded resolved hub count")?;
9761        require_eq(&one.truncated, &true, "bounded resolved hub overflow")?;
9762        let unresolved = store.repository_graph_resolved_relation_hubs(
9763            GraphRelationKind::Extended(ExtendedRelationKind::Configures),
9764            2,
9765            None,
9766        )?;
9767        require_eq(&unresolved.rows.len(), &0, "unresolved hub exclusion")?;
9768        let external = store.repository_graph_resolved_relation_hubs(
9769            GraphRelationKind::Legacy(RelationKind::DependsOn),
9770            2,
9771            None,
9772        )?;
9773        require_eq(&external.rows.len(), &0, "external hub exclusion")?;
9774
9775        let invalid = require_db_error(
9776            store.repository_graph_resolved_relation_hubs(
9777                GraphRelationKind::Legacy(RelationKind::Calls),
9778                0,
9779                None,
9780            ),
9781            "zero resolved hub limit succeeded",
9782        )?;
9783        require(
9784            matches!(
9785                invalid,
9786                DbError::GraphContract(GraphContractError::InvalidLimits { .. })
9787            ),
9788            &format!("zero resolved hub limit returned {invalid}"),
9789        )?;
9790
9791        let mut statement = store.connection.prepare(&format!(
9792            "EXPLAIN QUERY PLAN {}",
9793            resolved_relation_hub_keys_sql()
9794        ))?;
9795        let details = statement
9796            .query_map(
9797                params![
9798                    &fixture.project.as_bytes()[..],
9799                    "legacy",
9800                    "calls",
9801                    RESOLUTION_STATUS_RESOLVED,
9802                    3_i64,
9803                ],
9804                |row| row.get::<_, String>(3),
9805            )?
9806            .collect::<Result<Vec<_>, _>>()?;
9807        require(
9808            details
9809                .iter()
9810                .filter(|detail| detail.contains("idx_graph_relations_kind_resolution"))
9811                .count()
9812                >= 2
9813                && details
9814                    .iter()
9815                    .all(|detail| !detail.contains("SCAN graph_relations"))
9816                && details
9817                    .iter()
9818                    .all(|detail| !detail.contains("graph_entities")),
9819            &format!(
9820                "resolved hub ranking did not limit indexed endpoint keys before hydration: {details:?}"
9821            ),
9822        )?;
9823        let cancellation = projectatlas_core::IndexCancellation::new();
9824        cancellation.cancel();
9825        let cancelled = IndexWorkControl::new(cancellation, None);
9826        let error = require_db_error(
9827            store.repository_graph_resolved_relation_hubs(
9828                GraphRelationKind::Legacy(RelationKind::Calls),
9829                2,
9830                Some(&cancelled),
9831            ),
9832            "cancelled resolved hub ranking succeeded",
9833        )?;
9834        require(
9835            matches!(
9836                error,
9837                DbError::IndexWork(projectatlas_core::IndexWorkFailure::Cancelled {
9838                    stage: IndexWorkStage::RepositoryTraversal
9839                })
9840            ),
9841            &format!("resolved hub cancellation was not typed: {error}"),
9842        )?;
9843        Ok(())
9844    }
9845
9846    #[test]
9847    fn resolved_preview_reads_bound_work_and_reject_corruption_when_paged()
9848    -> Result<(), Box<dyn Error>> {
9849        let temp = tempfile::tempdir()?;
9850        let root = temp.path().join("resolved-preview-bounds");
9851        fs::create_dir_all(&root)?;
9852        let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9853        let fixture =
9854            publish_detailed_relation_trace_fixture(&mut store, "resolved-preview-bounds")?;
9855        let source = fixture.source;
9856        let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9857        let (_, hub_statements) = trace_statements(&mut store, |store| {
9858            store.repository_graph_resolved_relation_hubs(calls, 1, None)
9859        })?;
9860        require(
9861            hub_statements.iter().all(|statement| {
9862                !statement.contains("ORDER BY relation.relation_key")
9863                    && !statement.contains("relation.canonical_identity")
9864            }),
9865            &format!("resolved hub lookup reconstructed omitted relation rows: {hub_statements:?}"),
9866        )?;
9867
9868        let budget = RepositoryGraphReadBudget::new(
9869            1,
9870            1,
9871            RepositoryGraphReadBudget::MAX_DECODED_BYTES,
9872            3,
9873            2,
9874        )?;
9875        let (first, adjacency_statements) = trace_statements(&mut store, |store| {
9876            store.repository_graph_adjacency_page_filtered_by_resolution_bounded(
9877                &[source.key().clone()],
9878                RepositoryGraphDirection::Outbound,
9879                Some(calls),
9880                true,
9881                false,
9882                None,
9883                1,
9884                budget,
9885                None,
9886            )
9887        })?;
9888        require(
9889            first.page.truncated
9890                && first.page.rows.len() == 1
9891                && first.page.continuation.is_some()
9892                && first.work.requested_rows == 1
9893                && first.work.returned_rows == 1
9894                && first.work.hydrated_entities <= 3
9895                && adjacency_statements.iter().all(|statement| {
9896                    !statement.contains("ORDER BY relation.relation_key")
9897                        || statement.contains("UNION ALL")
9898                }),
9899            &format!(
9900                "resolved adjacency exceeded its bounded evidence query: work={:?}, statements={adjacency_statements:?}",
9901                first.work
9902            ),
9903        )?;
9904
9905        let expired = IndexWorkControl::with_deadline(
9906            projectatlas_core::IndexCancellation::new(),
9907            Instant::now(),
9908        );
9909        for error in [
9910            require_db_error(
9911                store.repository_graph_resolved_relation_hubs(calls, 1, Some(&expired)),
9912                "expired resolved hub lookup succeeded",
9913            )?,
9914            require_db_error(
9915                store.repository_graph_resolved_adjacency_page(
9916                    &[source.key().clone()],
9917                    RepositoryGraphDirection::Outbound,
9918                    calls,
9919                    None,
9920                    1,
9921                    Some(&expired),
9922                ),
9923                "expired resolved adjacency lookup succeeded",
9924            )?,
9925        ] {
9926            require(
9927                matches!(
9928                    error,
9929                    DbError::IndexWork(projectatlas_core::IndexWorkFailure::DeadlineExceeded {
9930                        stage: IndexWorkStage::RepositoryTraversal
9931                    })
9932                ),
9933                &format!("resolved preview deadline was not typed: {error}"),
9934            )?;
9935        }
9936
9937        let continuation = first
9938            .page
9939            .continuation
9940            .ok_or_else(|| io::Error::other("resolved preview continuation is missing"))?;
9941        let mut relations = fixture.relations;
9942        relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
9943        let corrupt = relations
9944            .get(2)
9945            .ok_or_else(|| io::Error::other("late-page corruption relation is missing"))?;
9946        let corrupt_key = corrupt.key().digest_bytes()?;
9947        store
9948            .connection
9949            .execute_batch("PRAGMA ignore_check_constraints = ON")?;
9950        store.connection.execute(
9951            "UPDATE graph_relations
9952                SET canonical_identity = 'contradictory late-page relation'
9953              WHERE relation_key = ?1",
9954            [&corrupt_key[..]],
9955        )?;
9956        let healthy_first = store.repository_graph_resolved_adjacency_page(
9957            &[source.key().clone()],
9958            RepositoryGraphDirection::Outbound,
9959            calls,
9960            None,
9961            1,
9962            None,
9963        )?;
9964        require(
9965            healthy_first.rows.len() == 1 && healthy_first.continuation.is_some(),
9966            "corruption outside the bounded evidence page hid the healthy first page",
9967        )?;
9968        let error = require_db_error(
9969            store.repository_graph_resolved_adjacency_page(
9970                &[source.key().clone()],
9971                RepositoryGraphDirection::Outbound,
9972                calls,
9973                Some(&continuation),
9974                1,
9975                None,
9976            ),
9977            "late-page corrupt relation returned a partial adjacency page",
9978        )?;
9979        require(
9980            matches!(
9981                error,
9982                DbError::GraphContract(GraphContractError::StableKeyCollision { .. })
9983            ),
9984            &format!("late-page corrupt relation returned {error}"),
9985        )?;
9986        Ok(())
9987    }
9988
9989    #[test]
9990    fn resolved_preview_reads_reject_selected_relation_and_endpoint_corruption()
9991    -> Result<(), Box<dyn Error>> {
9992        let temp = tempfile::tempdir()?;
9993        let root = temp.path().join("resolved-preview-corruption");
9994        fs::create_dir_all(&root)?;
9995        let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9996        let fixture =
9997            publish_detailed_relation_trace_fixture(&mut store, "resolved-preview-corruption")?;
9998        let source = fixture.source;
9999        let mut relations = fixture.relations;
10000
10001        let calls = GraphRelationKind::Legacy(RelationKind::Calls);
10002        let healthy_hubs = store.repository_graph_resolved_relation_hubs(calls, 1, None)?;
10003        require(
10004            healthy_hubs.truncated
10005                && healthy_hubs.rows.len() == 1
10006                && healthy_hubs.rows[0].key() == source.key(),
10007            "healthy corruption fixture did not rank its source hub first",
10008        )?;
10009        let healthy_adjacency = store.repository_graph_resolved_adjacency_page(
10010            &[source.key().clone()],
10011            RepositoryGraphDirection::Outbound,
10012            calls,
10013            None,
10014            1,
10015            None,
10016        )?;
10017        require(
10018            healthy_adjacency.truncated && healthy_adjacency.rows.len() == 1,
10019            "healthy corruption fixture did not retain its adjacency sentinel",
10020        )?;
10021
10022        relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
10023        let top = relations
10024            .first()
10025            .ok_or_else(|| io::Error::other("top corruption relation is missing"))?;
10026        let source_key = source.key().digest_bytes()?;
10027        let top_key = top.key().digest_bytes()?;
10028        let top_target_key = top
10029            .resolution()
10030            .resolved_target()
10031            .ok_or_else(|| io::Error::other("top corruption target is missing"))?;
10032        let top_target = top_target_key.digest_bytes()?;
10033        store.connection.execute_batch(
10034            "PRAGMA foreign_keys = OFF;
10035             PRAGMA ignore_check_constraints = ON;",
10036        )?;
10037        store.connection.execute(
10038            "UPDATE graph_relations
10039                SET canonical_identity = 'contradictory visible relation identity'
10040              WHERE relation_key = ?1",
10041            [&top_key[..]],
10042        )?;
10043        let visible_identity = require_db_error(
10044            store.repository_graph_resolved_adjacency_page(
10045                &[source.key().clone()],
10046                RepositoryGraphDirection::Outbound,
10047                calls,
10048                None,
10049                1,
10050                None,
10051            ),
10052            "visible canonical-identity corruption returned an adjacency page",
10053        )?;
10054        require(
10055            matches!(
10056                visible_identity,
10057                DbError::GraphContract(GraphContractError::StableKeyCollision { .. })
10058            ),
10059            &format!("visible canonical-identity corruption returned {visible_identity}"),
10060        )?;
10061        store.connection.execute(
10062            "UPDATE graph_relations SET canonical_identity = ?1 WHERE relation_key = ?2",
10063            params![top.key().canonical_identity(), &top_key[..]],
10064        )?;
10065
10066        let visible_wrong_digest = [0_u8; 32];
10067        store.connection.execute(
10068            "UPDATE graph_relations SET relation_key = ?1 WHERE relation_key = ?2",
10069            params![&visible_wrong_digest[..], &top_key[..]],
10070        )?;
10071        let visible_digest = require_db_error(
10072            store.repository_graph_resolved_adjacency_page(
10073                &[source.key().clone()],
10074                RepositoryGraphDirection::Outbound,
10075                calls,
10076                None,
10077                1,
10078                None,
10079            ),
10080            "visible well-typed wrong digest returned an adjacency page",
10081        )?;
10082        require(
10083            matches!(
10084                visible_digest,
10085                DbError::GraphContract(GraphContractError::InvalidStableKeyDigest)
10086            ),
10087            &format!("visible well-typed wrong digest returned {visible_digest}"),
10088        )?;
10089        store.connection.execute(
10090            "UPDATE graph_relations SET relation_key = ?1 WHERE canonical_identity = ?2",
10091            params![&top_key[..], top.key().canonical_identity()],
10092        )?;
10093
10094        store.connection.execute(
10095            "UPDATE graph_entities SET canonical_identity = '' WHERE entity_key = ?1",
10096            [&source_key[..]],
10097        )?;
10098        let top_hub_endpoint = require_db_error(
10099            store.repository_graph_resolved_relation_hubs(calls, 1, None),
10100            "top-ranked corrupt hub endpoint returned a partial page",
10101        )?;
10102        require(
10103            matches!(top_hub_endpoint, DbError::GraphContract(_)),
10104            &format!("top-ranked corrupt hub endpoint returned {top_hub_endpoint}"),
10105        )?;
10106        store.connection.execute(
10107            "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
10108            params![source.key().canonical_identity(), &source_key[..]],
10109        )?;
10110
10111        store.connection.execute(
10112            "UPDATE graph_entities SET canonical_identity = '' WHERE entity_key = ?1",
10113            [&top_target[..]],
10114        )?;
10115        let top_adjacency_endpoint = require_db_error(
10116            store.repository_graph_resolved_adjacency_page(
10117                &[source.key().clone()],
10118                RepositoryGraphDirection::Outbound,
10119                calls,
10120                None,
10121                1,
10122                None,
10123            ),
10124            "top-page corrupt adjacency endpoint returned a partial page",
10125        )?;
10126        require(
10127            matches!(top_adjacency_endpoint, DbError::GraphContract(_)),
10128            &format!("top-page corrupt adjacency endpoint returned {top_adjacency_endpoint}"),
10129        )?;
10130        store.connection.execute(
10131            "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
10132            params![top_target_key.canonical_identity(), &top_target[..]],
10133        )?;
10134
10135        Ok(())
10136    }
10137
10138    #[test]
10139    fn resolved_adjacency_filters_self_edges_before_limits_and_binds_its_cursor()
10140    -> Result<(), Box<dyn Error>> {
10141        let temp = tempfile::tempdir()?;
10142        let root = temp.path().join("resolved-adjacency-self-edge");
10143        fs::create_dir_all(&root)?;
10144        let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
10145        let fixture = publish_fixture(&mut store, "resolved-adjacency-self-edge")?;
10146        let generation = IndexGeneration::new(1);
10147        let calls = GraphRelationKind::Legacy(RelationKind::Calls);
10148        let mut selected = None;
10149        for index in 0..1_000 {
10150            let source_name = format!("self_edge_source_{index}");
10151            let target_name = format!("self_edge_target_{index}");
10152            let symbol = |name: &str| -> Result<GraphEntity, Box<dyn Error>> {
10153                Ok(GraphEntity::new(
10154                    fixture.project,
10155                    EntitySelector::Symbol {
10156                        symbol: SymbolSelector {
10157                            file: RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
10158                            name: GraphIdentityText::new(name)?,
10159                            kind: SymbolKind::Function,
10160                            parent: None,
10161                            signature: GraphIdentityText::new(format!("{name}()"))?,
10162                        },
10163                    },
10164                    generation,
10165                )?)
10166            };
10167            let source = symbol(&source_name)?;
10168            let target = symbol(&target_name)?;
10169            let self_relation = LogicalRelation::new(
10170                &source,
10171                calls,
10172                RelationResolution::resolved(&source)?,
10173                ConfidenceClass::Exact,
10174                Completeness::Complete,
10175                generation,
10176            )?;
10177            let useful = LogicalRelation::new(
10178                &source,
10179                calls,
10180                RelationResolution::resolved(&target)?,
10181                ConfidenceClass::Exact,
10182                Completeness::Complete,
10183                generation,
10184            )?;
10185            if self_relation.key().digest() < useful.key().digest() {
10186                selected = Some((source, target, self_relation, useful));
10187                break;
10188            }
10189        }
10190        let (source, target, self_relation, useful) = selected
10191            .ok_or_else(|| io::Error::other("could not order a self edge before a useful edge"))?;
10192        insert_entities(&store.connection, fixture.project, [&source, &target])?;
10193        insert_relations(
10194            &store.connection,
10195            fixture.project,
10196            &[self_relation.clone(), useful.clone()],
10197        )?;
10198
10199        let frontier = [source.key().clone()];
10200        let first = store.repository_graph_resolved_adjacency_page(
10201            &frontier,
10202            RepositoryGraphDirection::Outbound,
10203            calls,
10204            None,
10205            1,
10206            None,
10207        )?;
10208        require(
10209            first.rows.len() == 1
10210                && first.rows[0].detail.relation.key() == useful.key()
10211                && first.rows[0].detail.relation.key() != self_relation.key(),
10212            "self relation consumed the resolved adjacency limit before a useful edge",
10213        )?;
10214
10215        let cancellation = projectatlas_core::IndexCancellation::new();
10216        cancellation.cancel();
10217        let cancelled = IndexWorkControl::new(cancellation, None);
10218        let cancellation_error = require_db_error(
10219            store.repository_graph_resolved_adjacency_page(
10220                &frontier,
10221                RepositoryGraphDirection::Outbound,
10222                calls,
10223                None,
10224                1,
10225                Some(&cancelled),
10226            ),
10227            "cancelled resolved adjacency succeeded",
10228        )?;
10229        require(
10230            matches!(
10231                cancellation_error,
10232                DbError::IndexWork(projectatlas_core::IndexWorkFailure::Cancelled {
10233                    stage: IndexWorkStage::RepositoryTraversal
10234                })
10235            ),
10236            &format!("resolved adjacency cancellation was not typed: {cancellation_error}"),
10237        )?;
10238        Ok(())
10239    }
10240
10241    #[test]
10242    fn repository_graph_staging_checkpoints_ownership_before_graph_writes()
10243    -> Result<(), Box<dyn Error>> {
10244        let temp = tempfile::tempdir()?;
10245        let project_root = temp.path().join("staging-ownership");
10246        let stage = project_root.join(".projectatlas").join("graph-stage-test");
10247        fs::create_dir_all(&stage)?;
10248        let database = stage.join("projectatlas.db");
10249        let project = ProjectInstanceId::from_bytes([7; 16])?;
10250        let _store =
10251            AtlasStore::create_repository_graph_staging(&database, &project_root, project)?;
10252
10253        let main_database_only = temp.path().join("staging-main-only.db");
10254        fs::copy(&database, &main_database_only)?;
10255        require(
10256            AtlasStore::repository_graph_staging_belongs_to(
10257                &main_database_only,
10258                &project_root,
10259                project,
10260            )?,
10261            "staging ownership depended on WAL sidecars before graph writes",
10262        )
10263    }
10264
10265    #[cfg(windows)]
10266    #[test]
10267    fn repository_graph_staging_accepts_case_only_root_rename() -> Result<(), Box<dyn Error>> {
10268        let temp = tempfile::tempdir()?;
10269        let original_root = temp.path().join("CaseOnlyStageRoot");
10270        let intermediate_root = temp.path().join("CaseOnlyStageRoot-intermediate");
10271        let renamed_root = temp.path().join("caseonlystageroot");
10272        let stage = original_root.join(".projectatlas").join("graph-stage-case");
10273        fs::create_dir_all(&stage)?;
10274        let database = stage.join("projectatlas.db");
10275        let project = ProjectInstanceId::from_bytes([8; 16])?;
10276        drop(AtlasStore::create_repository_graph_staging(
10277            &database,
10278            &original_root,
10279            project,
10280        )?);
10281
10282        fs::rename(&original_root, &intermediate_root)?;
10283        fs::rename(&intermediate_root, &renamed_root)?;
10284        let renamed_database = renamed_root
10285            .join(".projectatlas")
10286            .join("graph-stage-case")
10287            .join("projectatlas.db");
10288        require(
10289            AtlasStore::repository_graph_staging_belongs_to(
10290                &renamed_database,
10291                &renamed_root,
10292                project,
10293            )?,
10294            "case-only renamed staging root was not re-canonicalized",
10295        )
10296    }
10297
10298    #[test]
10299    fn repository_graph_staging_persists_document_reasons_for_bounded_copy()
10300    -> Result<(), Box<dyn Error>> {
10301        let temp = tempfile::tempdir()?;
10302        let project_root = temp.path().join("staging-document-reasons");
10303        fs::create_dir(&project_root)?;
10304        let main_database = project_root.join("projectatlas.db");
10305        let mut main = AtlasStore::open_for_project(&main_database, &project_root)?;
10306        let project = main
10307            .project_instance_id()?
10308            .ok_or_else(|| io::Error::other("staging fixture identity is missing"))?;
10309        let generation = IndexGeneration::new(1);
10310        let stage_database = project_root.join("graph-stage.db");
10311        let mut stage =
10312            AtlasStore::create_repository_graph_staging(&stage_database, &project_root, project)?;
10313        let nodes = [
10314            graph_node(".", NodeKind::Folder, None),
10315            graph_node("docs", NodeKind::Folder, Some(".")),
10316            graph_node("docs/guide.md", NodeKind::File, Some("docs")),
10317        ];
10318        stage.replace_scan(&nodes)?;
10319        let document = GraphEntity::new(
10320            project,
10321            EntitySelector::File {
10322                path: RepositoryFilePath::new(Path::new("docs/guide.md"))?,
10323            },
10324            generation,
10325        )?;
10326        let unresolved_document = LogicalRelation::new(
10327            &document,
10328            GraphRelationKind::Extended(ExtendedRelationKind::Documents),
10329            RelationResolution::Unresolved {
10330                reference: GraphIdentityText::new("missing.md")?,
10331            },
10332            ConfidenceClass::Exact,
10333            Completeness::Complete,
10334            generation,
10335        )?;
10336        let unrelated = LogicalRelation::new(
10337            &document,
10338            GraphRelationKind::Extended(ExtendedRelationKind::Configures),
10339            RelationResolution::Unresolved {
10340                reference: GraphIdentityText::new("setting")?,
10341            },
10342            ConfidenceClass::Low,
10343            Completeness::Partial,
10344            generation,
10345        )?;
10346        let mut staging = stage.begin_repository_graph_staging(project, generation)?;
10347        staging.append_batch(
10348            std::slice::from_ref(&document),
10349            &[unresolved_document.clone(), unrelated.clone()],
10350            &[],
10351            &[],
10352            &[],
10353            &[],
10354        )?;
10355        let mixed = require_db_error(
10356            staging.set_document_unresolved_reasons(&[
10357                (
10358                    unresolved_document.key().clone(),
10359                    DocumentTargetUnresolvedReason::Missing,
10360                ),
10361                (
10362                    unrelated.key().clone(),
10363                    DocumentTargetUnresolvedReason::Unsupported,
10364                ),
10365            ]),
10366            "staging accepted a reason for a non-document relation",
10367        )?;
10368        require(
10369            matches!(mixed, DbError::GraphRowShape { .. }),
10370            "staging mixed reason batch returned the wrong error",
10371        )?;
10372        let relation_key = unresolved_document.key().digest_bytes()?;
10373        let staged_reason = staging.transaction.query_row(
10374            "SELECT document_unresolved_reason FROM graph_relations WHERE relation_key = ?1",
10375            [&relation_key[..]],
10376            |row| row.get::<_, Option<String>>(0),
10377        )?;
10378        require(
10379            staged_reason.is_none(),
10380            "failed staged reason batch exposed partial mutation",
10381        )?;
10382        staging.set_document_unresolved_reasons(&[(
10383            unresolved_document.key().clone(),
10384            DocumentTargetUnresolvedReason::Missing,
10385        )])?;
10386        staging.complete()?;
10387        stage.checkpoint_repository_graph_staging()?;
10388
10389        let cancellation = projectatlas_core::IndexCancellation::new();
10390        cancellation.cancel();
10391        let canceled = IndexWorkControl::new(cancellation, None);
10392        let mut canceled_publication =
10393            main.begin_index_publication("canceled-staged-document-reasons")?;
10394        canceled_publication.begin_scan_replacement()?;
10395        canceled_publication.upsert_scan_node_batch(&nodes)?;
10396        canceled_publication.finish_scan_replacement()?;
10397        let cancellation_error = require_db_error(
10398            canceled_publication.replace_repository_graph_from_staging(
10399                project,
10400                &stage,
10401                Some(&canceled),
10402            ),
10403            "canceled stage copy committed",
10404        )?;
10405        require(
10406            matches!(
10407                cancellation_error,
10408                DbError::IndexWork(projectatlas_core::IndexWorkFailure::Cancelled {
10409                    stage: IndexWorkStage::Publication
10410                })
10411            ),
10412            "canceled stage copy returned the wrong error",
10413        )?;
10414        drop(canceled_publication);
10415        require(
10416            main.repository_graph_generation()?.is_none(),
10417            "canceled stage copy exposed a graph generation",
10418        )?;
10419
10420        let mut publication = main.begin_index_publication("staged-document-reasons")?;
10421        publication.begin_scan_replacement()?;
10422        publication.upsert_scan_node_batch(&nodes)?;
10423        publication.finish_scan_replacement()?;
10424        publication.replace_repository_graph_from_staging(project, &stage, None)?;
10425        publication.complete()?;
10426
10427        let copied = main.repository_graph_relation_rows(
10428            RepositoryGraphRelationQuery::Family {
10429                relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
10430            },
10431            10,
10432            None,
10433        )?;
10434        require(
10435            copied.rows.len() == 1
10436                && copied.rows[0].document_unresolved_reason
10437                    == Some(DocumentTargetUnresolvedReason::Missing),
10438            "stage-to-publication copy lost the closed document reason",
10439        )?;
10440        Ok(())
10441    }
10442
10443    #[test]
10444    fn resolution_batch_reads_admit_long_witnesses_before_decoding_or_cloning()
10445    -> Result<(), Box<dyn Error>> {
10446        let temp = tempfile::tempdir()?;
10447        let root = temp.path();
10448        let db = root.join("atlas.db");
10449        let mut store = AtlasStore::open_for_project(&db, root)?;
10450        let project = store
10451            .project_instance_id()?
10452            .ok_or("missing read-budget fixture identity")?;
10453        let graph = graph_fixture(project, IndexGeneration::new(1))?;
10454        let key = declaration_key(
10455            project,
10456            &"n".repeat(3_000),
10457            GraphRelationKind::Legacy(RelationKind::Calls),
10458        )?;
10459        let export = EntityResolutionKey::new(graph.entities[4].key().clone(), key.clone())?;
10460        let dependency = RelationDependencyKey::new(graph.relations[0].key().clone(), key.clone())?;
10461        {
10462            let mut publication = store.begin_index_publication("resolution-read-budget")?;
10463            publication.begin_scan_replacement()?;
10464            publication.upsert_scan_node_batch(&[
10465                graph_node(".", NodeKind::Folder, None),
10466                graph_node("src", NodeKind::Folder, Some(".")),
10467                graph_node("src/Äuth.rs", NodeKind::File, Some("src")),
10468                graph_node("Cargo.toml", NodeKind::File, Some(".")),
10469            ])?;
10470            publication.finish_scan_replacement()?;
10471            publication.replace_repository_graph_with_resolution_keys(
10472                project,
10473                &graph.entities,
10474                &graph.relations,
10475                &graph.occurrences,
10476                &graph.coverage,
10477                &[export],
10478                &[dependency],
10479            )?;
10480            publication.complete()?;
10481        }
10482        drop(store);
10483        let reader = AtlasStore::open_read_only_for_project(&db, root)?;
10484        let before = reader.index_publication()?;
10485        let paths = vec!["src/Äuth.rs".to_string()];
10486        let enough = 1_024 * 1_024;
10487        let exports =
10488            reader.repository_export_keys_for_paths_with_byte_limit(project, &paths, 10, enough)?;
10489        require_eq(
10490            &exports.page.rows,
10491            &vec![key.clone()],
10492            "bounded long export witness",
10493        )?;
10494        let candidates = reader.repository_resolution_candidates_for_keys_with_byte_limit(
10495            project,
10496            std::slice::from_ref(&key),
10497            10,
10498            enough,
10499        )?;
10500        require(
10501            candidates.page.rows.len() == 1,
10502            "bounded read lost its candidate",
10503        )?;
10504        let affected = reader.repository_affected_source_paths_with_byte_limit(
10505            project,
10506            std::slice::from_ref(&key),
10507            10,
10508            enough,
10509        )?;
10510        require(
10511            !affected.page.rows.is_empty(),
10512            "bounded read lost its dependent path",
10513        )?;
10514        require_eq(
10515            &reader
10516                .repository_export_keys_for_paths(project, &paths, 10)?
10517                .rows,
10518            &exports.page.rows,
10519            "legacy export reader changed output",
10520        )?;
10521        require_eq(
10522            &reader
10523                .repository_resolution_candidates_for_keys(
10524                    project,
10525                    &[key.clone(), key.clone()],
10526                    10,
10527                )?
10528                .rows,
10529            &candidates.page.rows,
10530            "legacy/duplicate candidate reader changed output",
10531        )?;
10532        require(
10533            exports.retained_bytes > 0
10534                && candidates.retained_bytes > 0
10535                && affected.retained_bytes > 0,
10536            "bounded reader dropped returned-page ownership",
10537        )?;
10538        for result in [
10539            reader
10540                .repository_export_keys_for_paths_with_byte_limit(project, &paths, 10, 1_024)
10541                .map(|read| read.page.rows.len()),
10542            reader
10543                .repository_resolution_candidates_for_keys_with_byte_limit(
10544                    project,
10545                    std::slice::from_ref(&key),
10546                    10,
10547                    1_024,
10548                )
10549                .map(|read| read.page.rows.len()),
10550            reader
10551                .repository_affected_source_paths_with_byte_limit(
10552                    project,
10553                    std::slice::from_ref(&key),
10554                    10,
10555                    1_024,
10556                )
10557                .map(|read| read.page.rows.len()),
10558        ] {
10559            require(
10560                matches!(
10561                    result,
10562                    Err(DbError::IndexWork(
10563                        projectatlas_core::IndexWorkFailure::ResourceLimitExceeded {
10564                            resource: projectatlas_core::IndexWorkResource::OutputBytes,
10565                            ..
10566                        }
10567                    ))
10568                ),
10569                "long resolution batch decoded or cloned past its allowance",
10570            )?;
10571        }
10572        require_eq(
10573            &reader.index_publication()?,
10574            &before,
10575            "bounded reads changed publication state",
10576        )?;
10577        Ok(())
10578    }
10579
10580    #[test]
10581    fn resolution_keys_round_trip_reopen_and_preserve_all_dependency_states()
10582    -> Result<(), Box<dyn Error>> {
10583        let temp = tempfile::tempdir()?;
10584        let project_root = temp.path().join("resolution-round-trip");
10585        let atlas_dir = project_root.join(".projectatlas");
10586        fs::create_dir_all(&atlas_dir)?;
10587        let db_path = atlas_dir.join("projectatlas.db");
10588        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
10589        let fixture = publish_resolution_fixture(&mut writer, "resolution-round-trip")?;
10590        assert_query_indexes(&writer)?;
10591
10592        let counts = writer.connection.query_row(
10593            "SELECT
10594                 (SELECT COUNT(*) FROM graph_resolution_keys),
10595                 (SELECT COUNT(*) FROM graph_entity_exports),
10596                 (SELECT COUNT(*) FROM graph_relation_dependencies)",
10597            [],
10598            |row| {
10599                Ok((
10600                    row.get::<_, i64>(0)?,
10601                    row.get::<_, i64>(1)?,
10602                    row.get::<_, i64>(2)?,
10603                ))
10604            },
10605        )?;
10606        require_eq(&counts, &(3, 1, 3), "deduplicated resolution-key rows")?;
10607        let states = writer
10608            .connection
10609            .prepare(
10610                "SELECT relation.resolution_status, COUNT(*)
10611                   FROM graph_relation_dependencies AS dependency
10612                   JOIN graph_relations AS relation
10613                     ON relation.project_instance_id = dependency.project_instance_id
10614                    AND relation.relation_key = dependency.relation_key
10615                  GROUP BY relation.resolution_status
10616                  ORDER BY relation.resolution_status",
10617            )?
10618            .query_map([], |row| {
10619                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
10620            })?
10621            .collect::<Result<Vec<_>, _>>()?;
10622        require_eq(
10623            &states,
10624            &vec![
10625                ("ambiguous".to_string(), 1),
10626                ("resolved".to_string(), 1),
10627                ("unresolved".to_string(), 1),
10628            ],
10629            "dependency resolution states",
10630        )?;
10631        drop(writer);
10632
10633        let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
10634        let exports = reader.repository_export_keys_for_paths(
10635            fixture.graph.project,
10636            &["src/Äuth.rs".to_string()],
10637            10,
10638        )?;
10639        require_eq(&exports.truncated, &false, "export key truncation")?;
10640        require_eq(
10641            &exports.rows,
10642            &vec![fixture.resolved.clone()],
10643            "export key round trip",
10644        )?;
10645        let candidates = reader.repository_resolution_candidates(&fixture.resolved, 10)?;
10646        require_eq(&candidates.truncated, &false, "candidate truncation")?;
10647        require_eq(
10648            &candidates.rows,
10649            &vec![fixture.graph.entities[4].clone()],
10650            "candidate export round trip",
10651        )?;
10652        let batch_candidates = reader.repository_resolution_candidates_for_keys(
10653            fixture.graph.project,
10654            &[
10655                fixture.unresolved.clone(),
10656                fixture.resolved.clone(),
10657                fixture.ambiguous.clone(),
10658                fixture.resolved.clone(),
10659            ],
10660            10,
10661        )?;
10662        require_eq(
10663            &batch_candidates.truncated,
10664            &false,
10665            "batch candidate truncation",
10666        )?;
10667        require_eq(
10668            &batch_candidates.rows.len(),
10669            &1,
10670            "batch candidate deduplication",
10671        )?;
10672        require_eq(
10673            batch_candidates.rows[0].key(),
10674            &fixture.resolved,
10675            "batch candidate selecting key",
10676        )?;
10677        require_eq(
10678            batch_candidates.rows[0].entity(),
10679            &fixture.graph.entities[4],
10680            "batch candidate entity",
10681        )?;
10682        let affected = reader.repository_affected_source_paths(
10683            fixture.graph.project,
10684            &[
10685                fixture.resolved.clone(),
10686                fixture.resolved,
10687                fixture.ambiguous,
10688                fixture.unresolved,
10689            ],
10690            10,
10691        )?;
10692        require_eq(&affected.truncated, &false, "affected path truncation")?;
10693        require_eq(
10694            &affected.rows,
10695            &vec![RepositoryFilePath::new(Path::new("src/Äuth.rs"))?],
10696            "resolved ambiguous and unresolved dependency owners",
10697        )?;
10698        reader.finish_index_read_snapshot()?;
10699        Ok(())
10700    }
10701
10702    #[test]
10703    fn high_degree_dependency_closure_is_unique_and_reports_overflow_before_mutation()
10704    -> Result<(), Box<dyn Error>> {
10705        let temp = tempfile::tempdir()?;
10706        let project_root = temp.path().join("high-degree-resolution");
10707        fs::create_dir_all(&project_root)?;
10708        let db_path = project_root.join("projectatlas.db");
10709        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10710        let project = store
10711            .project_instance_id()?
10712            .ok_or_else(|| io::Error::other("bound identity is missing"))?;
10713        let generation = IndexGeneration::new(1);
10714        let dependency_key = declaration_key(
10715            project,
10716            "sharedTarget",
10717            GraphRelationKind::Extended(ExtendedRelationKind::References),
10718        )?;
10719        let mut nodes = vec![
10720            graph_node(".", NodeKind::Folder, None),
10721            graph_node("src", NodeKind::Folder, Some(".")),
10722        ];
10723        let mut entities = Vec::new();
10724        let mut relations = Vec::new();
10725        let mut dependencies = Vec::new();
10726        for index in 0..5 {
10727            let path = format!("src/caller-{index}.rs");
10728            nodes.push(graph_node(&path, NodeKind::File, Some("src")));
10729            let entity = GraphEntity::new(
10730                project,
10731                EntitySelector::File {
10732                    path: RepositoryFilePath::new(Path::new(&path))?,
10733                },
10734                generation,
10735            )?;
10736            let relation = LogicalRelation::new(
10737                &entity,
10738                GraphRelationKind::Extended(ExtendedRelationKind::References),
10739                RelationResolution::Unresolved {
10740                    reference: GraphIdentityText::new("sharedTarget")?,
10741                },
10742                ConfidenceClass::High,
10743                Completeness::Complete,
10744                generation,
10745            )?;
10746            dependencies.push(RelationDependencyKey::new(
10747                relation.key().clone(),
10748                dependency_key.clone(),
10749            )?);
10750            entities.push(entity);
10751            relations.push(relation);
10752        }
10753        dependencies.push(dependencies[0].clone());
10754        let mut publication = store.begin_index_publication("high-degree-resolution")?;
10755        publication.begin_scan_replacement()?;
10756        publication.upsert_scan_node_batch(&nodes)?;
10757        publication.finish_scan_replacement()?;
10758        publication.replace_repository_graph_with_resolution_keys(
10759            project,
10760            &entities,
10761            &relations,
10762            &[],
10763            &[],
10764            &[],
10765            &dependencies,
10766        )?;
10767        publication.complete()?;
10768
10769        let before = store.index_publication()?;
10770        let bounded = store.repository_affected_source_paths(
10771            project,
10772            std::slice::from_ref(&dependency_key),
10773            2,
10774        )?;
10775        require_eq(&bounded.rows.len(), &2, "bounded affected path count")?;
10776        require_eq(&bounded.truncated, &true, "bounded affected path overflow")?;
10777        require_eq(
10778            &store.index_publication()?,
10779            &before,
10780            "overflow lookup mutated publication state",
10781        )?;
10782        let complete = store.repository_affected_source_paths(project, &[dependency_key], 10)?;
10783        require_eq(&complete.rows.len(), &5, "complete affected path count")?;
10784        require_eq(
10785            &complete.truncated,
10786            &false,
10787            "complete affected path overflow",
10788        )?;
10789        let unique = complete.rows.iter().collect::<BTreeSet<_>>();
10790        require_eq(&unique.len(), &5, "affected paths are unique")?;
10791        Ok(())
10792    }
10793
10794    #[test]
10795    fn affected_source_footprint_accounts_exact_owned_rows_and_uses_path_indexes()
10796    -> Result<(), Box<dyn Error>> {
10797        let temp = tempfile::tempdir()?;
10798        let project_root = temp.path().join("affected-source-footprint");
10799        fs::create_dir_all(&project_root)?;
10800        let db_path = project_root.join("projectatlas.db");
10801        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10802        let fixture = publish_resolution_fixture(&mut store, "affected-source-footprint")?;
10803        replace_fixture_symbol_rows(&mut store, "src/Äuth.rs", 1)?;
10804        store.connection.execute(
10805            "INSERT INTO graph_coverage(
10806                 project_instance_id, scope_kind, scope_path, relation_scope,
10807                 relation_kind, state, total, covered, omitted, reason, reached_limit
10808             ) VALUES(?1, 'path', 'src/Äuth.rs', NULL, NULL,
10809                      'complete', 1, 1, 0, NULL, NULL)",
10810            [&fixture.graph.project.as_bytes()[..]],
10811        )?;
10812
10813        let mut paths = (0..=RESOLUTION_PATHS_PER_QUERY)
10814            .map(|index| format!("missing/{index:04}.rs"))
10815            .collect::<Vec<_>>();
10816        paths.extend(["src/Äuth.rs".to_string(), "src/Äuth.rs".to_string()]);
10817        let footprint =
10818            store.repository_affected_source_footprint(fixture.graph.project, &paths, 100)?;
10819        require_eq(&footprint.rows, &20, "exact affected persisted rows")?;
10820        require(
10821            footprint.retained_bytes > footprint.rows,
10822            "affected footprint omitted decoded bytes",
10823        )?;
10824        require_eq(&footprint.truncated, &false, "exact footprint truncation")?;
10825
10826        let sql = format!("EXPLAIN QUERY PLAN {}", affected_source_footprint_sql(1));
10827        let values = [
10828            Value::Blob(fixture.graph.project.as_bytes().to_vec()),
10829            Value::Text("src/Äuth.rs".to_string()),
10830            Value::Integer(101),
10831        ];
10832        let mut statement = store.connection.prepare(&sql)?;
10833        let details = statement
10834            .query_map(params_from_iter(values.iter()), |row| {
10835                row.get::<_, String>(3)
10836            })?
10837            .collect::<Result<Vec<_>, _>>()?;
10838        for index in [
10839            "sqlite_autoindex_source_parse_metadata_1",
10840            "idx_symbols_path",
10841            "idx_symbol_relations_path",
10842            "idx_graph_entities_path",
10843            "idx_graph_entities_manifest_path",
10844            "idx_graph_relations_source_kind",
10845            "idx_graph_occurrences_file_span",
10846            "idx_graph_coverage_path",
10847            "idx_graph_entity_exports_owner",
10848            "idx_graph_relation_dependencies_owner",
10849            "sqlite_autoindex_graph_resolution_keys_1",
10850        ] {
10851            require(
10852                details.iter().any(|detail| detail.contains(index)),
10853                &format!("affected footprint missed {index}; plan was {details:?}"),
10854            )?;
10855        }
10856        require(
10857            details.iter().all(|detail| !detail.contains("SCAN graph_")),
10858            &format!("affected footprint scanned graph storage: {details:?}"),
10859        )?;
10860        Ok(())
10861    }
10862
10863    #[test]
10864    fn affected_source_footprint_reports_high_degree_overflow_before_mutation()
10865    -> Result<(), Box<dyn Error>> {
10866        let temp = tempfile::tempdir()?;
10867        let project_root = temp.path().join("affected-source-degree");
10868        fs::create_dir_all(&project_root)?;
10869        let db_path = project_root.join("projectatlas.db");
10870        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10871        let fixture = publish_resolution_fixture(&mut store, "affected-source-degree")?;
10872        replace_fixture_symbol_rows(&mut store, "src/Äuth.rs", 25)?;
10873        let before = store.index_publication()?;
10874
10875        let bounded = store.repository_affected_source_footprint(
10876            fixture.graph.project,
10877            &["src/Äuth.rs".to_string()],
10878            5,
10879        )?;
10880        require_eq(&bounded.rows, &6, "footprint limit plus one sentinel")?;
10881        require_eq(&bounded.truncated, &true, "high-degree footprint overflow")?;
10882        require(
10883            bounded.retained_bytes > 0,
10884            "bounded footprint lost retained bytes",
10885        )?;
10886        require_eq(
10887            &store.index_publication()?,
10888            &before,
10889            "footprint overflow mutated publication",
10890        )?;
10891        Ok(())
10892    }
10893
10894    #[test]
10895    fn affected_source_footprint_validates_inputs_identity_and_graph_availability()
10896    -> Result<(), Box<dyn Error>> {
10897        let temp = tempfile::tempdir()?;
10898        let selected_root = temp.path().join("selected");
10899        let other_root = temp.path().join("other");
10900        fs::create_dir_all(&selected_root)?;
10901        fs::create_dir_all(&other_root)?;
10902        let mut selected =
10903            AtlasStore::open_for_project(&selected_root.join("projectatlas.db"), &selected_root)?;
10904        let fixture = publish_resolution_fixture(&mut selected, "footprint-validation")?;
10905        let other = AtlasStore::open_for_project(&other_root.join("projectatlas.db"), &other_root)?;
10906        let other_project = other
10907            .project_instance_id()?
10908            .ok_or_else(|| io::Error::other("other bound identity is missing"))?;
10909
10910        let invalid_limit = require_db_error(
10911            selected.repository_affected_source_footprint(
10912                fixture.graph.project,
10913                &["src/Äuth.rs".to_string()],
10914                0,
10915            ),
10916            "zero footprint limit was accepted",
10917        )?;
10918        require(
10919            matches!(invalid_limit, DbError::GraphContract(_)),
10920            &format!("invalid footprint limit returned {invalid_limit}"),
10921        )?;
10922        let invalid_path = require_db_error(
10923            selected.repository_affected_source_footprint(
10924                fixture.graph.project,
10925                &["../outside.rs".to_string()],
10926                10,
10927            ),
10928            "escaping footprint path was accepted",
10929        )?;
10930        require(
10931            matches!(invalid_path, DbError::GraphContract(_)),
10932            &format!("invalid footprint path returned {invalid_path}"),
10933        )?;
10934        let mismatched = require_db_error(
10935            selected.repository_affected_source_footprint(
10936                other_project,
10937                &["src/Äuth.rs".to_string()],
10938                10,
10939            ),
10940            "mismatched project footprint was accepted",
10941        )?;
10942        require(
10943            matches!(mismatched, DbError::GraphProjectIdentityMismatch { .. }),
10944            &format!("mismatched footprint returned {mismatched}"),
10945        )?;
10946        require_eq(
10947            &other.repository_affected_source_footprint(
10948                other_project,
10949                &["src/Äuth.rs".to_string()],
10950                10,
10951            )?,
10952            &empty_affected_source_footprint(),
10953            "unpublished graph footprint",
10954        )?;
10955        Ok(())
10956    }
10957
10958    #[test]
10959    fn affected_source_footprint_rejects_missing_resolution_witnesses() -> Result<(), Box<dyn Error>>
10960    {
10961        let temp = tempfile::tempdir()?;
10962        let project_root = temp.path().join("affected-source-corruption");
10963        fs::create_dir_all(&project_root)?;
10964        let db_path = project_root.join("projectatlas.db");
10965        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10966        let fixture = publish_resolution_fixture(&mut store, "affected-source-corruption")?;
10967        store
10968            .connection
10969            .execute_batch("PRAGMA foreign_keys = OFF")?;
10970        store.connection.execute(
10971            "DELETE FROM graph_resolution_keys
10972              WHERE project_instance_id = ?1
10973                AND resolution_domain = ?2
10974                AND key_digest = ?3",
10975            params![
10976                &fixture.graph.project.as_bytes()[..],
10977                fixture.resolved.domain().as_str(),
10978                &fixture.resolved.digest_bytes()[..],
10979            ],
10980        )?;
10981        let error = require_db_error(
10982            store.repository_affected_source_footprint(
10983                fixture.graph.project,
10984                &["src/Äuth.rs".to_string()],
10985                100,
10986            ),
10987            "missing resolution witness returned a partial footprint",
10988        )?;
10989        require(
10990            matches!(error, DbError::Sqlite(_)),
10991            &format!("missing witness returned the wrong error: {error}"),
10992        )?;
10993        Ok(())
10994    }
10995
10996    #[test]
10997    fn resolution_key_failures_roll_back_and_owner_foreign_keys_cascade()
10998    -> Result<(), Box<dyn Error>> {
10999        let temp = tempfile::tempdir()?;
11000        let project_root = temp.path().join("resolution-failures");
11001        fs::create_dir_all(&project_root)?;
11002        let db_path = project_root.join("projectatlas.db");
11003        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
11004        let fixture = publish_resolution_fixture(&mut store, "resolution-failures")?;
11005        let before_publication = store.index_publication()?;
11006        let before_counts = store.connection.query_row(
11007            "SELECT
11008                 (SELECT COUNT(*) FROM graph_resolution_keys),
11009                 (SELECT COUNT(*) FROM graph_entity_exports),
11010                 (SELECT COUNT(*) FROM graph_relation_dependencies)",
11011            [],
11012            |row| {
11013                Ok((
11014                    row.get::<_, i64>(0)?,
11015                    row.get::<_, i64>(1)?,
11016                    row.get::<_, i64>(2)?,
11017                ))
11018            },
11019        )?;
11020
11021        let replacement = graph_fixture(fixture.graph.project, IndexGeneration::new(2))?;
11022        let invalid_export = EntityResolutionKey::new(
11023            replacement.entities[0].key().clone(),
11024            fixture.resolved.clone(),
11025        )?;
11026        {
11027            let mut publication = store.begin_index_publication("resolution-failures")?;
11028            let error = require_db_error(
11029                publication.replace_repository_graph_with_resolution_keys(
11030                    replacement.project,
11031                    &replacement.entities,
11032                    &replacement.relations,
11033                    &replacement.occurrences,
11034                    &replacement.coverage,
11035                    &[invalid_export],
11036                    &[],
11037                ),
11038                "source-less export owner unexpectedly published",
11039            )?;
11040            require(
11041                matches!(error, DbError::GraphRowShape { .. }),
11042                &format!("invalid export owner returned the wrong error: {error}"),
11043            )?;
11044        }
11045        require_eq(
11046            &store.index_publication()?,
11047            &before_publication,
11048            "failed key publication generation",
11049        )?;
11050        let after_counts = store.connection.query_row(
11051            "SELECT
11052                 (SELECT COUNT(*) FROM graph_resolution_keys),
11053                 (SELECT COUNT(*) FROM graph_entity_exports),
11054                 (SELECT COUNT(*) FROM graph_relation_dependencies)",
11055            [],
11056            |row| {
11057                Ok((
11058                    row.get::<_, i64>(0)?,
11059                    row.get::<_, i64>(1)?,
11060                    row.get::<_, i64>(2)?,
11061                ))
11062            },
11063        )?;
11064        require_eq(
11065            &after_counts,
11066            &before_counts,
11067            "failed key publication durable rows",
11068        )?;
11069        require_eq(
11070            &store
11071                .repository_resolution_candidates(&fixture.resolved, 10)?
11072                .rows,
11073            &vec![fixture.graph.entities[4].clone()],
11074            "failed key publication previous candidates",
11075        )?;
11076
11077        store.connection.execute(
11078            "UPDATE graph_resolution_keys
11079                SET canonical_identity = 'conflicting collision witness'
11080              WHERE project_instance_id = ?1
11081                AND resolution_domain = ?2
11082                AND key_digest = ?3",
11083            params![
11084                &fixture.graph.project.as_bytes()[..],
11085                fixture.resolved.domain().as_str(),
11086                &fixture.resolved.digest_bytes()[..],
11087            ],
11088        )?;
11089        let collision = require_db_error(
11090            store.repository_resolution_candidates(&fixture.resolved, 10),
11091            "conflicting resolution witness was accepted",
11092        )?;
11093        require(
11094            matches!(collision, DbError::ResolutionKeyCollision { .. }),
11095            &format!("conflicting witness returned the wrong error: {collision}"),
11096        )?;
11097        let corrupt_page = require_db_error(
11098            store.repository_export_keys_for_paths(
11099                fixture.graph.project,
11100                &["src/Äuth.rs".to_string()],
11101                10,
11102            ),
11103            "corrupt witness returned a partial key page",
11104        )?;
11105        require(
11106            matches!(corrupt_page, DbError::GraphContract(_)),
11107            &format!("corrupt witness returned the wrong page error: {corrupt_page}"),
11108        )?;
11109        store.connection.execute(
11110            "UPDATE graph_resolution_keys
11111                SET canonical_identity = ?1
11112              WHERE project_instance_id = ?2
11113                AND resolution_domain = ?3
11114                AND key_digest = ?4",
11115            params![
11116                fixture.resolved.canonical_identity(),
11117                &fixture.graph.project.as_bytes()[..],
11118                fixture.resolved.domain().as_str(),
11119                &fixture.resolved.digest_bytes()[..],
11120            ],
11121        )?;
11122
11123        let relation_digest = fixture.graph.relations[2].key().digest_bytes()?;
11124        store.connection.execute(
11125            "DELETE FROM graph_relations
11126              WHERE project_instance_id = ?1 AND relation_key = ?2",
11127            params![&fixture.graph.project.as_bytes()[..], &relation_digest[..]],
11128        )?;
11129        let remaining_dependencies = store.connection.query_row(
11130            "SELECT COUNT(*) FROM graph_relation_dependencies",
11131            [],
11132            |row| row.get::<_, i64>(0),
11133        )?;
11134        require_eq(
11135            &remaining_dependencies,
11136            &(before_counts.2 - 1),
11137            "relation-owner dependency cascade",
11138        )?;
11139        let entity_digest = fixture.graph.entities[4].key().digest_bytes()?;
11140        store.connection.execute(
11141            "DELETE FROM graph_entities
11142              WHERE project_instance_id = ?1 AND entity_key = ?2",
11143            params![&fixture.graph.project.as_bytes()[..], &entity_digest[..]],
11144        )?;
11145        let remaining_exports =
11146            store
11147                .connection
11148                .query_row("SELECT COUNT(*) FROM graph_entity_exports", [], |row| {
11149                    row.get::<_, i64>(0)
11150                })?;
11151        require_eq(&remaining_exports, &0, "entity-owner export cascade")?;
11152        Ok(())
11153    }
11154
11155    #[test]
11156    fn affected_replacement_swaps_resolution_keys_and_collects_only_touched_orphans()
11157    -> Result<(), Box<dyn Error>> {
11158        let temp = tempfile::tempdir()?;
11159        let project_root = temp.path().join("resolution-replacement");
11160        fs::create_dir_all(&project_root)?;
11161        let db_path = project_root.join("projectatlas.db");
11162        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
11163        let first = publish_resolution_fixture(&mut store, "resolution-replacement")?;
11164        let replacement = graph_fixture(first.graph.project, IndexGeneration::new(2))?;
11165        let renamed = declaration_key(
11166            first.graph.project,
11167            "verifySession",
11168            GraphRelationKind::Legacy(RelationKind::Calls),
11169        )?;
11170        let exports = vec![EntityResolutionKey::new(
11171            replacement.entities[4].key().clone(),
11172            renamed.clone(),
11173        )?];
11174        let dependencies = vec![
11175            RelationDependencyKey::new(replacement.relations[0].key().clone(), renamed.clone())?,
11176            RelationDependencyKey::new(
11177                replacement.relations[1].key().clone(),
11178                first.ambiguous.clone(),
11179            )?,
11180            RelationDependencyKey::new(
11181                replacement.relations[2].key().clone(),
11182                first.unresolved.clone(),
11183            )?,
11184        ];
11185        let mut publication = store.begin_index_publication("resolution-replacement")?;
11186        publication.replace_repository_graph_for_paths_with_resolution_keys(
11187            replacement.project,
11188            &["src/Äuth.rs".to_string()],
11189            &replacement.entities,
11190            &replacement.relations,
11191            &replacement.occurrences,
11192            &replacement.coverage,
11193            &exports,
11194            &dependencies,
11195        )?;
11196        publication.complete()?;
11197
11198        require_eq(
11199            &store
11200                .repository_resolution_candidates(&first.resolved, 10)?
11201                .rows
11202                .len(),
11203            &0,
11204            "removed export candidates",
11205        )?;
11206        require_eq(
11207            &store
11208                .repository_affected_source_paths(
11209                    first.graph.project,
11210                    std::slice::from_ref(&first.resolved),
11211                    10,
11212                )?
11213                .rows
11214                .len(),
11215            &0,
11216            "removed dependency owners",
11217        )?;
11218        require_eq(
11219            &store.repository_resolution_candidates(&renamed, 10)?.rows,
11220            &vec![replacement.entities[4].clone()],
11221            "renamed export candidates",
11222        )?;
11223        let exports = store.repository_export_keys_for_paths(
11224            first.graph.project,
11225            &["src/Äuth.rs".to_string()],
11226            10,
11227        )?;
11228        require_eq(&exports.rows, &vec![renamed], "replacement export keys")?;
11229        let registry_rows = store.connection.query_row(
11230            "SELECT COUNT(*) FROM graph_resolution_keys",
11231            [],
11232            |row| row.get::<_, i64>(0),
11233        )?;
11234        require_eq(&registry_rows, &3, "touched orphan witness cleanup")?;
11235        require_eq(
11236            &store
11237                .index_publication()?
11238                .ok_or_else(|| io::Error::other("replacement publication is missing"))?
11239                .generation,
11240            &IndexGeneration::new(2),
11241            "replacement generation",
11242        )?;
11243        Ok(())
11244    }
11245
11246    #[test]
11247    fn affected_graph_replacement_preserves_only_the_unaffected_closure()
11248    -> Result<(), Box<dyn Error>> {
11249        let temp = tempfile::tempdir()?;
11250        let project_root = temp.path().join("affected-closure");
11251        let atlas_dir = project_root.join(".projectatlas");
11252        fs::create_dir_all(&atlas_dir)?;
11253        let db_path = atlas_dir.join("projectatlas.db");
11254        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
11255
11256        let project = store
11257            .project_instance_id()?
11258            .ok_or_else(|| io::Error::other("bound affected-closure identity is missing"))?;
11259        let generation_one = IndexGeneration::new(1);
11260        let project_entity = GraphEntity::new(project, EntitySelector::Project, generation_one)?;
11261        let affected_folder = GraphEntity::new(
11262            project,
11263            EntitySelector::Folder {
11264                path: RepositoryNodePath::new(Path::new("src/a"))?,
11265            },
11266            generation_one,
11267        )?;
11268        let affected_file = GraphEntity::new(
11269            project,
11270            EntitySelector::File {
11271                path: RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
11272            },
11273            generation_one,
11274        )?;
11275        let case_distinct_file = GraphEntity::new(
11276            project,
11277            EntitySelector::File {
11278                path: RepositoryFilePath::new(Path::new("src/A/keep.rs"))?,
11279            },
11280            generation_one,
11281        )?;
11282        let package = GraphEntity::new(
11283            project,
11284            EntitySelector::Package {
11285                package: PackageSelector {
11286                    manager: GraphIdentityText::new("cargo")?,
11287                    name: GraphIdentityText::new("api")?,
11288                    manifest: RepositoryFilePath::new(Path::new("packages/api/Cargo.toml"))?,
11289                },
11290            },
11291            generation_one,
11292        )?;
11293        let orphan_external = GraphEntity::new(
11294            project,
11295            EntitySelector::External {
11296                external: ExternalSelector {
11297                    system: GraphIdentityText::new("crates.io")?,
11298                    identity: GraphIdentityText::new("orphan@1")?,
11299                },
11300            },
11301            generation_one,
11302        )?;
11303        let retained_external = GraphEntity::new(
11304            project,
11305            EntitySelector::External {
11306                external: ExternalSelector {
11307                    system: GraphIdentityText::new("crates.io")?,
11308                    identity: GraphIdentityText::new("retained@1")?,
11309                },
11310            },
11311            generation_one,
11312        )?;
11313        let occurrence_owned_external = GraphEntity::new(
11314            project,
11315            EntitySelector::External {
11316                external: ExternalSelector {
11317                    system: GraphIdentityText::new("crates.io")?,
11318                    identity: GraphIdentityText::new("occurrence-owned@1")?,
11319                },
11320            },
11321            generation_one,
11322        )?;
11323        let affected_relation = LogicalRelation::new(
11324            &affected_file,
11325            GraphRelationKind::Legacy(RelationKind::DependsOn),
11326            RelationResolution::external(&orphan_external)?,
11327            ConfidenceClass::Exact,
11328            Completeness::Complete,
11329            generation_one,
11330        )?;
11331        let package_relation = LogicalRelation::new(
11332            &package,
11333            GraphRelationKind::Legacy(RelationKind::DependsOn),
11334            RelationResolution::external(&orphan_external)?,
11335            ConfidenceClass::Exact,
11336            Completeness::Complete,
11337            generation_one,
11338        )?;
11339        let retained_relation = LogicalRelation::new(
11340            &case_distinct_file,
11341            GraphRelationKind::Legacy(RelationKind::DependsOn),
11342            RelationResolution::external(&retained_external)?,
11343            ConfidenceClass::Exact,
11344            Completeness::Complete,
11345            generation_one,
11346        )?;
11347        let project_external_relation = LogicalRelation::new(
11348            &project_entity,
11349            GraphRelationKind::Legacy(RelationKind::DependsOn),
11350            RelationResolution::external(&retained_external)?,
11351            ConfidenceClass::Exact,
11352            Completeness::Complete,
11353            generation_one,
11354        )?;
11355        let occurrence_backed_project_relation = LogicalRelation::new(
11356            &project_entity,
11357            GraphRelationKind::Legacy(RelationKind::DependsOn),
11358            RelationResolution::external(&occurrence_owned_external)?,
11359            ConfidenceClass::Exact,
11360            Completeness::Complete,
11361            generation_one,
11362        )?;
11363        let affected_occurrence = RelationOccurrence::new(
11364            &affected_relation,
11365            RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
11366            SourceSpan::new(3, 0, 3, 12)?,
11367            generation_one,
11368        )?;
11369        let retained_occurrence = RelationOccurrence::new(
11370            &retained_relation,
11371            RepositoryFilePath::new(Path::new("src/A/keep.rs"))?,
11372            SourceSpan::new(5, 0, 5, 14)?,
11373            generation_one,
11374        )?;
11375        let retained_relation_affected_occurrence = RelationOccurrence::new(
11376            &retained_relation,
11377            RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
11378            SourceSpan::new(6, 0, 6, 14)?,
11379            generation_one,
11380        )?;
11381        let project_relation_occurrence = RelationOccurrence::new(
11382            &occurrence_backed_project_relation,
11383            RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
11384            SourceSpan::new(9, 0, 9, 16)?,
11385            generation_one,
11386        )?;
11387        let initial_coverage = vec![
11388            CoverageRecord::new(
11389                CoverageScope::Project,
11390                None,
11391                CoverageState::Complete,
11392                4,
11393                0,
11394                generation_one,
11395                None,
11396                None,
11397            )?,
11398            CoverageRecord::new(
11399                CoverageScope::Path {
11400                    path: RepositoryNodePath::new(Path::new("src/a"))?,
11401                },
11402                None,
11403                CoverageState::Partial,
11404                1,
11405                1,
11406                generation_one,
11407                Some(GraphIdentityText::new("affected coverage")?),
11408                Some(GraphLimitKind::Rows),
11409            )?,
11410            CoverageRecord::new(
11411                CoverageScope::Path {
11412                    path: RepositoryNodePath::new(Path::new("src/A"))?,
11413                },
11414                None,
11415                CoverageState::Complete,
11416                1,
11417                0,
11418                generation_one,
11419                None,
11420                None,
11421            )?,
11422        ];
11423        {
11424            let mut publication = store.begin_index_publication("affected-closure")?;
11425            publication.begin_scan_replacement()?;
11426            publication.upsert_scan_node_batch(&[
11427                graph_node(".", NodeKind::Folder, None),
11428                graph_node("src", NodeKind::Folder, Some(".")),
11429                graph_node("src/a", NodeKind::Folder, Some("src")),
11430                graph_node("src/a/local.rs", NodeKind::File, Some("src/a")),
11431                graph_node("src/a/new.rs", NodeKind::File, Some("src/a")),
11432                graph_node("src/A", NodeKind::Folder, Some("src")),
11433                graph_node("src/A/keep.rs", NodeKind::File, Some("src/A")),
11434                graph_node("packages", NodeKind::Folder, Some(".")),
11435                graph_node("packages/api", NodeKind::Folder, Some("packages")),
11436                graph_node(
11437                    "packages/api/Cargo.toml",
11438                    NodeKind::File,
11439                    Some("packages/api"),
11440                ),
11441                graph_node("README.md", NodeKind::File, Some(".")),
11442            ])?;
11443            publication.finish_scan_replacement()?;
11444            publication.replace_repository_graph(
11445                project,
11446                &[
11447                    project_entity.clone(),
11448                    affected_folder,
11449                    affected_file.clone(),
11450                    case_distinct_file.clone(),
11451                    package.clone(),
11452                    orphan_external.clone(),
11453                    retained_external.clone(),
11454                    occurrence_owned_external.clone(),
11455                ],
11456                &[
11457                    affected_relation,
11458                    package_relation,
11459                    retained_relation,
11460                    project_external_relation,
11461                    occurrence_backed_project_relation,
11462                ],
11463                &[
11464                    affected_occurrence,
11465                    retained_occurrence,
11466                    retained_relation_affected_occurrence,
11467                    project_relation_occurrence,
11468                ],
11469                &initial_coverage,
11470            )?;
11471            publication.complete()?;
11472        }
11473
11474        let affected_paths = [
11475            RepositoryNodePath::new(Path::new("src/a"))?,
11476            RepositoryNodePath::new(Path::new("packages/api/Cargo.toml"))?,
11477        ];
11478        let (candidates, statements) = trace_statements(&mut store, |store| {
11479            affected_external_candidates(&store.connection, &affected_paths)
11480        })?;
11481        require(
11482            candidates.contains(&orphan_external.key().digest_bytes()?),
11483            "batched external cleanup omitted an affected candidate",
11484        )?;
11485        require_eq(
11486            &statements
11487                .iter()
11488                .filter(|statement| {
11489                    statement.contains("WITH affected(entity_key)")
11490                        && statement.contains("idx_graph_relations_source_kind")
11491                        && statement.contains("idx_graph_relations_target_kind")
11492                })
11493                .count(),
11494            &1,
11495            "bounded external candidate adjacency batches",
11496        )?;
11497
11498        let generation_two = IndexGeneration::new(2);
11499        let replacement_folder = GraphEntity::new(
11500            project,
11501            EntitySelector::Folder {
11502                path: RepositoryNodePath::new(Path::new("src/a"))?,
11503            },
11504            generation_two,
11505        )?;
11506        let replacement_file = GraphEntity::new(
11507            project,
11508            EntitySelector::File {
11509                path: RepositoryFilePath::new(Path::new("src/a/new.rs"))?,
11510            },
11511            generation_two,
11512        )?;
11513        let retained_external_for_relation = GraphEntity::new(
11514            project,
11515            retained_external.selector().clone(),
11516            generation_two,
11517        )?;
11518        let replacement_relation = LogicalRelation::new(
11519            &replacement_file,
11520            GraphRelationKind::Legacy(RelationKind::DependsOn),
11521            RelationResolution::external(&retained_external_for_relation)?,
11522            ConfidenceClass::Exact,
11523            Completeness::Complete,
11524            generation_two,
11525        )?;
11526        let replacement_occurrence = RelationOccurrence::new(
11527            &replacement_relation,
11528            RepositoryFilePath::new(Path::new("src/a/new.rs"))?,
11529            SourceSpan::new(7, 0, 7, 10)?,
11530            generation_two,
11531        )?;
11532        let replacement_coverage = CoverageRecord::new(
11533            CoverageScope::Path {
11534                path: RepositoryNodePath::new(Path::new("src/a"))?,
11535            },
11536            None,
11537            CoverageState::Complete,
11538            1,
11539            0,
11540            generation_two,
11541            None,
11542            None,
11543        )?;
11544        {
11545            let mut publication = store.begin_index_publication("affected-closure")?;
11546            publication.replace_repository_graph_for_paths(
11547                project,
11548                &["src/a".to_string(), "packages/api/Cargo.toml".to_string()],
11549                &[replacement_folder, replacement_file.clone()],
11550                &[replacement_relation],
11551                &[replacement_occurrence],
11552                &[replacement_coverage],
11553            )?;
11554            publication.complete()?;
11555        }
11556
11557        drop(store);
11558        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11559
11560        require_eq(
11561            &store.repository_graph_entity(affected_file.key())?,
11562            &None,
11563            "affected descendant removal",
11564        )?;
11565        require_eq(
11566            &store.repository_graph_entity(package.key())?,
11567            &None,
11568            "manifest-owned package removal",
11569        )?;
11570        require_eq(
11571            &store.repository_graph_entity(orphan_external.key())?,
11572            &None,
11573            "candidate-bounded orphan external cleanup",
11574        )?;
11575        require_eq(
11576            &store.repository_graph_entity(occurrence_owned_external.key())?,
11577            &None,
11578            "final affected occurrence relation and external cleanup",
11579        )?;
11580        let preserved_case = store
11581            .repository_graph_entity(case_distinct_file.key())?
11582            .ok_or_else(|| io::Error::other("case-distinct sibling was removed"))?;
11583        require_eq(
11584            &preserved_case.generation(),
11585            &generation_two,
11586            "case-distinct sibling generation injection",
11587        )?;
11588        let preserved_external = store
11589            .repository_graph_entity(retained_external.key())?
11590            .ok_or_else(|| io::Error::other("referenced external entity was removed"))?;
11591        require_eq(
11592            &preserved_external.generation(),
11593            &generation_two,
11594            "unaffected external generation injection",
11595        )?;
11596        let retained_relations = store.repository_graph_relations(
11597            RepositoryGraphRelationQuery::Outbound {
11598                source: case_distinct_file.key().clone(),
11599            },
11600            10,
11601        )?;
11602        require_eq(
11603            &retained_relations.rows.len(),
11604            &1,
11605            "relation with one unaffected occurrence",
11606        )?;
11607        require_eq(
11608            &store
11609                .repository_graph_occurrences(&retained_relations.rows[0], 10)?
11610                .rows
11611                .len(),
11612            &1,
11613            "only the affected occurrence was removed",
11614        )?;
11615        let replacement_relations = store.repository_graph_relations(
11616            RepositoryGraphRelationQuery::Outbound {
11617                source: replacement_file.key().clone(),
11618            },
11619            10,
11620        )?;
11621        require_eq(
11622            &replacement_relations.rows.len(),
11623            &1,
11624            "replacement relation count",
11625        )?;
11626        require_eq(
11627            &store
11628                .repository_graph_occurrences(&replacement_relations.rows[0], 10)?
11629                .rows
11630                .len(),
11631            &1,
11632            "replacement source occurrence",
11633        )?;
11634        let affected_coverage = store.repository_graph_coverage(
11635            project,
11636            &CoverageScope::Path {
11637                path: RepositoryNodePath::new(Path::new("src/a"))?,
11638            },
11639            10,
11640        )?;
11641        require(
11642            affected_coverage.rows.len() == 1
11643                && affected_coverage.rows[0].state() == CoverageState::Complete,
11644            "affected path coverage was not replaced",
11645        )?;
11646        let case_coverage = store.repository_graph_coverage(
11647            project,
11648            &CoverageScope::Path {
11649                path: RepositoryNodePath::new(Path::new("src/A"))?,
11650            },
11651            10,
11652        )?;
11653        require_eq(
11654            &case_coverage.rows.len(),
11655            &1,
11656            "case-distinct coverage preservation",
11657        )?;
11658        require_eq(
11659            &store
11660                .repository_graph_coverage(project, &CoverageScope::Project, 10)?
11661                .rows
11662                .len(),
11663            &1,
11664            "unaffected project coverage preservation",
11665        )?;
11666        require_eq(
11667            &store
11668                .repository_graph_relations(
11669                    RepositoryGraphRelationQuery::Outbound {
11670                        source: project_entity.key().clone(),
11671                    },
11672                    10,
11673                )?
11674                .rows
11675                .len(),
11676            &1,
11677            "project-to-external relation preservation",
11678        )?;
11679
11680        store.finish_index_read_snapshot()?;
11681        drop(store);
11682        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
11683        let generation_three = IndexGeneration::new(3);
11684        let root_project = GraphEntity::new(project, EntitySelector::Project, generation_three)?;
11685        let readme = GraphEntity::new(
11686            project,
11687            EntitySelector::File {
11688                path: RepositoryFilePath::new(Path::new("README.md"))?,
11689            },
11690            generation_three,
11691        )?;
11692        let root_coverage = CoverageRecord::new(
11693            CoverageScope::Project,
11694            None,
11695            CoverageState::Complete,
11696            1,
11697            0,
11698            generation_three,
11699            None,
11700            None,
11701        )?;
11702        {
11703            let mut publication = store.begin_index_publication("affected-closure")?;
11704            publication.replace_repository_graph_for_paths(
11705                project,
11706                &[".".to_string()],
11707                &[root_project.clone(), readme],
11708                &[],
11709                &[],
11710                &[root_coverage],
11711            )?;
11712            publication.complete()?;
11713        }
11714        drop(store);
11715        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11716        require_eq(
11717            &store.repository_graph_entity(case_distinct_file.key())?,
11718            &None,
11719            "root replacement stale local removal",
11720        )?;
11721        require_eq(
11722            &store.repository_graph_entity(retained_external.key())?,
11723            &None,
11724            "root replacement external removal",
11725        )?;
11726        require_eq(
11727            &store
11728                .repository_graph_relations(
11729                    RepositoryGraphRelationQuery::Outbound {
11730                        source: root_project.key().clone(),
11731                    },
11732                    10,
11733                )?
11734                .rows
11735                .len(),
11736            &0,
11737            "root replacement project relation removal",
11738        )?;
11739        require_eq(
11740            &store
11741                .repository_graph_coverage(project, &CoverageScope::Project, 10)?
11742                .rows
11743                .len(),
11744            &1,
11745            "root replacement project coverage",
11746        )?;
11747        require_eq(
11748            &store
11749                .repository_graph_coverage(
11750                    project,
11751                    &CoverageScope::Path {
11752                        path: RepositoryNodePath::new(Path::new("src/A"))?,
11753                    },
11754                    10,
11755                )?
11756                .rows
11757                .len(),
11758            &0,
11759            "root replacement path coverage removal",
11760        )?;
11761
11762        store.finish_index_read_snapshot()?;
11763        drop(store);
11764        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
11765        {
11766            let mut projection = store.begin_index_projection_refresh("affected-closure")?;
11767            projection.replace_file_texts_for_paths(
11768                &["README.md".to_string()],
11769                &[IndexedFileText {
11770                    path: "README.md".to_string(),
11771                    content_hash: Some("readme-hash".to_string()),
11772                    byte_count: 7,
11773                    line_count: 1,
11774                    content: "# Atlas".to_string(),
11775                }],
11776            )?;
11777            projection.complete()?;
11778        }
11779        drop(store);
11780        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11781        let error = require_db_error(
11782            store.repository_graph_entity(root_project.key()),
11783            "non-graph publication blessed stale graph rows",
11784        )?;
11785        require(
11786            matches!(
11787                error,
11788                DbError::GraphRowShape {
11789                    table: "project_identity",
11790                    ..
11791                }
11792            ),
11793            &format!("unexpected stale graph generation error: {error}"),
11794        )?;
11795        store.finish_index_read_snapshot()?;
11796        Ok(())
11797    }
11798
11799    /// Assert one reader sees a complete internally consistent graph projection.
11800    fn require_graph_projection(
11801        store: &AtlasStore,
11802        fixture: &GraphFixture,
11803        generation: IndexGeneration,
11804        lexical_content: &str,
11805    ) -> Result<(), Box<dyn Error>> {
11806        let publication = store
11807            .index_publication()?
11808            .ok_or_else(|| io::Error::other("graph publication metadata missing"))?;
11809        require_eq(
11810            &publication.state,
11811            &IndexPublicationState::Complete,
11812            "graph publication state",
11813        )?;
11814        require_eq(
11815            &publication.generation,
11816            &generation,
11817            "graph publication generation",
11818        )?;
11819        let source = fixture
11820            .entities
11821            .iter()
11822            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
11823            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
11824        let entity = store
11825            .repository_graph_entity(source.key())?
11826            .ok_or_else(|| io::Error::other("source graph entity missing"))?;
11827        require_eq(&entity.generation(), &generation, "graph entity generation")?;
11828        let relations = store.repository_graph_relations(
11829            RepositoryGraphRelationQuery::Outbound {
11830                source: source.key().clone(),
11831            },
11832            10,
11833        )?;
11834        require_eq(&relations.rows.len(), &4, "graph relation count")?;
11835        require(
11836            relations
11837                .rows
11838                .iter()
11839                .all(|relation| relation.generation() == generation),
11840            "graph relation generation mismatch",
11841        )?;
11842        let calls = relations
11843            .rows
11844            .iter()
11845            .find(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls))
11846            .ok_or_else(|| io::Error::other("call relation missing"))?;
11847        let occurrences = store.repository_graph_occurrences(calls, 10)?;
11848        require_eq(&occurrences.rows.len(), &2, "graph occurrence count")?;
11849        require(
11850            occurrences
11851                .rows
11852                .iter()
11853                .all(|occurrence| occurrence.generation() == generation),
11854            "graph occurrence generation mismatch",
11855        )?;
11856        let occurrence_pages = store.repository_graph_occurrence_pages(&relations.rows, 1, None)?;
11857        require_eq(
11858            &occurrence_pages.len(),
11859            &relations.rows.len(),
11860            "batched occurrence page count",
11861        )?;
11862        for (relation, page) in relations.rows.iter().zip(&occurrence_pages) {
11863            let is_calls = relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls);
11864            require_eq(
11865                &page.rows.len(),
11866                &usize::from(is_calls),
11867                "batched occurrence rows",
11868            )?;
11869            require_eq(&page.truncated, &is_calls, "batched occurrence truncation")?;
11870        }
11871        let coverage =
11872            store.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10)?;
11873        let expected_coverage = fixture
11874            .coverage
11875            .iter()
11876            .filter(|record| matches!(record.scope(), CoverageScope::Project))
11877            .count();
11878        require_eq(
11879            &coverage.rows.len(),
11880            &expected_coverage,
11881            "graph coverage count",
11882        )?;
11883        require(
11884            coverage
11885                .rows
11886                .iter()
11887                .all(|record| record.generation() == generation),
11888            "graph coverage generation mismatch",
11889        )?;
11890        let lexical = store
11891            .load_file_text("src/Äuth.rs")?
11892            .ok_or_else(|| io::Error::other("lexical source row missing"))?;
11893        require_eq(
11894            &lexical.content.as_str(),
11895            &lexical_content,
11896            "lexical source generation",
11897        )?;
11898        require_eq(
11899            &store.symbol_relation_count()?,
11900            &1,
11901            "legacy symbol relation compatibility",
11902        )?;
11903        Ok(())
11904    }
11905
11906    #[test]
11907    fn document_relations_require_closed_reasons_and_reuse_indexed_inbound_reads()
11908    -> Result<(), Box<dyn Error>> {
11909        let temp = tempfile::tempdir()?;
11910        let root = temp.path().join("document-graph");
11911        fs::create_dir(&root)?;
11912        let database = temp.path().join("projectatlas.db");
11913        let mut store = AtlasStore::open_for_project(&database, &root)?;
11914        let project = store
11915            .project_instance_id()?
11916            .ok_or_else(|| io::Error::other("document fixture identity is missing"))?;
11917        let generation = IndexGeneration::new(1);
11918        let document_path = RepositoryFilePath::new(Path::new("docs/guide.md"))?;
11919        let document = GraphEntity::new(
11920            project,
11921            EntitySelector::File {
11922                path: document_path.clone(),
11923            },
11924            generation,
11925        )?;
11926        let heading = GraphEntity::new(
11927            project,
11928            EntitySelector::Symbol {
11929                symbol: SymbolSelector {
11930                    file: document_path,
11931                    name: GraphIdentityText::new("Installation")?,
11932                    kind: SymbolKind::Heading,
11933                    parent: None,
11934                    signature: GraphIdentityText::new("# Installation")?,
11935                },
11936            },
11937            generation,
11938        )?;
11939        let unresolved_document = LogicalRelation::new(
11940            &document,
11941            GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11942            RelationResolution::Unresolved {
11943                reference: GraphIdentityText::new("missing.md#setup")?,
11944            },
11945            ConfidenceClass::Exact,
11946            Completeness::Complete,
11947            generation,
11948        )?;
11949        let resolved_document = LogicalRelation::new(
11950            &document,
11951            GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11952            RelationResolution::resolved(&heading)?,
11953            ConfidenceClass::Exact,
11954            Completeness::Complete,
11955            generation,
11956        )?;
11957        let unrelated = LogicalRelation::new(
11958            &document,
11959            GraphRelationKind::Extended(ExtendedRelationKind::Configures),
11960            RelationResolution::Unresolved {
11961                reference: GraphIdentityText::new("setting")?,
11962            },
11963            ConfidenceClass::Low,
11964            Completeness::Partial,
11965            generation,
11966        )?;
11967
11968        let mut publication = store.begin_index_publication("document-reasons")?;
11969        publication.begin_scan_replacement()?;
11970        publication.upsert_scan_node_batch(&[
11971            graph_node(".", NodeKind::Folder, None),
11972            graph_node("docs", NodeKind::Folder, Some(".")),
11973            graph_node("docs/guide.md", NodeKind::File, Some("docs")),
11974        ])?;
11975        publication.finish_scan_replacement()?;
11976        publication.replace_repository_graph(
11977            project,
11978            &[document, heading.clone()],
11979            &[
11980                unresolved_document.clone(),
11981                resolved_document.clone(),
11982                unrelated.clone(),
11983            ],
11984            &[],
11985            &[],
11986        )?;
11987        let incomplete = require_db_error(
11988            validate_complete_document_unresolved_reasons(&publication.connection),
11989            "unresolved document relation omitted its reason",
11990        )?;
11991        require(
11992            matches!(incomplete, DbError::GraphRowShape { .. }),
11993            "missing document reason returned the wrong error",
11994        )?;
11995        let mixed = require_db_error(
11996            publication.set_document_unresolved_reasons(&[
11997                (
11998                    unresolved_document.key().clone(),
11999                    DocumentTargetUnresolvedReason::Missing,
12000                ),
12001                (
12002                    unrelated.key().clone(),
12003                    DocumentTargetUnresolvedReason::Unsupported,
12004                ),
12005            ]),
12006            "non-document relation accepted a document reason",
12007        )?;
12008        require(
12009            matches!(mixed, DbError::GraphRowShape { .. }),
12010            "mixed document-reason batch returned the wrong error",
12011        )?;
12012        let unresolved_key = unresolved_document.key().digest_bytes()?;
12013        let retained_reason = publication.connection.query_row(
12014            "SELECT document_unresolved_reason FROM graph_relations WHERE relation_key = ?1",
12015            [&unresolved_key[..]],
12016            |row| row.get::<_, Option<String>>(0),
12017        )?;
12018        require(
12019            retained_reason.is_none(),
12020            "failed document-reason batch exposed partial mutation",
12021        )?;
12022        publication.set_document_unresolved_reasons(&[(
12023            unresolved_document.key().clone(),
12024            DocumentTargetUnresolvedReason::Missing,
12025        )])?;
12026        publication.complete()?;
12027
12028        let family = store.repository_graph_relation_rows(
12029            RepositoryGraphRelationQuery::Family {
12030                relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
12031            },
12032            10,
12033            None,
12034        )?;
12035        require_eq(&family.rows.len(), &2, "document relation family")?;
12036        require(
12037            family.rows.iter().any(|row| {
12038                matches!(
12039                    row.relation.resolution(),
12040                    RelationResolution::Unresolved { .. }
12041                ) && row.document_unresolved_reason == Some(DocumentTargetUnresolvedReason::Missing)
12042            }) && family.rows.iter().any(|row| {
12043                matches!(
12044                    row.relation.resolution(),
12045                    RelationResolution::Resolved { .. }
12046                ) && row.document_unresolved_reason.is_none()
12047            }),
12048            "document reason or resolved relation did not round-trip",
12049        )?;
12050        let inbound = store.repository_graph_relation_rows(
12051            RepositoryGraphRelationQuery::Inbound {
12052                target: heading.key().clone(),
12053            },
12054            10,
12055            None,
12056        )?;
12057        require(
12058            inbound.rows.len() == 1
12059                && inbound.rows[0].relation.kind()
12060                    == GraphRelationKind::Extended(ExtendedRelationKind::Documents),
12061            "inbound heading read did not reuse document relation storage",
12062        )?;
12063        let frontier = [family.rows[0].source.key().clone()];
12064        let legacy = store.repository_graph_adjacency_page_filtered_bounded(
12065            &frontier,
12066            RepositoryGraphDirection::Outbound,
12067            None,
12068            None,
12069            10,
12070            maximum_repository_graph_read_budget()?,
12071            None,
12072        )?;
12073        require(
12074            legacy.page.rows.len() == 1
12075                && legacy.page.rows[0].detail.relation.kind()
12076                    == GraphRelationKind::Extended(ExtendedRelationKind::Configures),
12077            "legacy unfiltered adjacency admitted documents before its limit",
12078        )?;
12079        let explicit = store.repository_graph_adjacency_page_filtered_bounded_with_documents(
12080            &frontier,
12081            RepositoryGraphDirection::Outbound,
12082            None,
12083            true,
12084            None,
12085            10,
12086            maximum_repository_graph_read_budget()?,
12087            None,
12088        )?;
12089        require_eq(&explicit.page.rows.len(), &3, "explicit document adjacency")?;
12090        let exact = store.repository_graph_adjacency_page_filtered_bounded(
12091            &frontier,
12092            RepositoryGraphDirection::Outbound,
12093            Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
12094            None,
12095            10,
12096            maximum_repository_graph_read_budget()?,
12097            None,
12098        )?;
12099        require_eq(&exact.page.rows.len(), &2, "exact document adjacency")?;
12100        let first = store.repository_graph_adjacency_page_filtered_bounded_with_documents(
12101            &frontier,
12102            RepositoryGraphDirection::Outbound,
12103            None,
12104            true,
12105            None,
12106            1,
12107            maximum_repository_graph_read_budget()?,
12108            None,
12109        )?;
12110        let cursor = first
12111            .page
12112            .continuation
12113            .as_ref()
12114            .ok_or_else(|| io::Error::other("document visibility cursor is missing"))?;
12115        let mismatch = require_db_error(
12116            store.repository_graph_adjacency_page_filtered_bounded_with_documents(
12117                &frontier,
12118                RepositoryGraphDirection::Outbound,
12119                None,
12120                false,
12121                Some(cursor),
12122                1,
12123                maximum_repository_graph_read_budget()?,
12124                None,
12125            ),
12126            "document visibility cursor was reused under legacy filtering",
12127        )?;
12128        require(
12129            matches!(mismatch, DbError::GraphContract(_)),
12130            "document visibility cursor mismatch returned the wrong error",
12131        )?;
12132        assert_query_indexes(&store)?;
12133
12134        let resolved_key = resolved_document.key().digest_bytes()?;
12135        let contradictory = store.connection.execute(
12136            "UPDATE graph_relations
12137                SET document_unresolved_reason = 'missing'
12138              WHERE relation_key = ?1",
12139            [&resolved_key[..]],
12140        );
12141        require(
12142            contradictory.is_err(),
12143            "resolved document relation accepted an unresolved reason",
12144        )?;
12145        let invalid = store.connection.execute(
12146            "UPDATE graph_relations
12147                SET document_unresolved_reason = 'network_error'
12148              WHERE relation_key = ?1",
12149            [&unresolved_key[..]],
12150        );
12151        require(invalid.is_err(), "open document reason value was accepted")?;
12152        drop(store);
12153
12154        let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
12155        let reopened_family = reopened.repository_graph_relation_rows(
12156            RepositoryGraphRelationQuery::Family {
12157                relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
12158            },
12159            10,
12160            None,
12161        )?;
12162        require_eq(
12163            &reopened_family.rows.len(),
12164            &2,
12165            "reopened document relations",
12166        )?;
12167        Ok(())
12168    }
12169
12170    #[test]
12171    fn document_unresolved_reasons_publish_across_multiple_graph_row_chunks()
12172    -> Result<(), Box<dyn Error>> {
12173        let temp = tempfile::tempdir()?;
12174        let root = temp.path().join("document-reason-chunks");
12175        fs::create_dir(&root)?;
12176        let database = temp.path().join("projectatlas.db");
12177        let mut store = AtlasStore::open_for_project(&database, &root)?;
12178        let project = store
12179            .project_instance_id()?
12180            .ok_or_else(|| io::Error::other("document chunk fixture identity is missing"))?;
12181        let baseline_generation = IndexGeneration::new(1);
12182        let generation = IndexGeneration::new(2);
12183        let baseline_document = GraphEntity::new(
12184            project,
12185            EntitySelector::File {
12186                path: RepositoryFilePath::new(Path::new("docs/baseline.md"))?,
12187            },
12188            baseline_generation,
12189        )?;
12190        let baseline_relation = LogicalRelation::new(
12191            &baseline_document,
12192            GraphRelationKind::Extended(ExtendedRelationKind::Documents),
12193            RelationResolution::Unresolved {
12194                reference: GraphIdentityText::new("baseline-missing.md")?,
12195            },
12196            ConfidenceClass::Exact,
12197            Completeness::Complete,
12198            baseline_generation,
12199        )?;
12200        let mut baseline = store.begin_index_publication("document-reason-chunks")?;
12201        baseline.upsert_scan_node_batch(&[graph_node(
12202            "docs/baseline.md",
12203            NodeKind::File,
12204            Some("docs"),
12205        )])?;
12206        baseline.replace_repository_graph(
12207            project,
12208            std::slice::from_ref(&baseline_document),
12209            std::slice::from_ref(&baseline_relation),
12210            &[],
12211            &[],
12212        )?;
12213        baseline.set_document_unresolved_reasons(&[(
12214            baseline_relation.key().clone(),
12215            DocumentTargetUnresolvedReason::Missing,
12216        )])?;
12217        baseline.complete()?;
12218        require_eq(
12219            &store.repository_graph_generation()?,
12220            &Some(baseline_generation),
12221            "baseline generation was not complete",
12222        )?;
12223        let document_path = RepositoryFilePath::new(Path::new("docs/index.md"))?;
12224        let document = GraphEntity::new(
12225            project,
12226            EntitySelector::File {
12227                path: document_path,
12228            },
12229            generation,
12230        )?;
12231        let total = GraphLimits::MAX_ROWS as usize * 2 + 1;
12232        let mut relations = Vec::with_capacity(total);
12233        for index in 0..total {
12234            relations.push(LogicalRelation::new(
12235                &document,
12236                GraphRelationKind::Extended(ExtendedRelationKind::Documents),
12237                RelationResolution::Unresolved {
12238                    reference: GraphIdentityText::new(format!("missing-{index:05}.md"))?,
12239                },
12240                ConfidenceClass::Exact,
12241                Completeness::Complete,
12242                generation,
12243            )?);
12244        }
12245        let reasons = relations
12246            .iter()
12247            .map(|relation| {
12248                (
12249                    relation.key().clone(),
12250                    DocumentTargetUnresolvedReason::Missing,
12251                )
12252            })
12253            .collect::<Vec<_>>();
12254        let mut duplicate_reasons = reasons.clone();
12255        duplicate_reasons.push(reasons[0].clone());
12256        let mut contradictory_reasons = reasons.clone();
12257        contradictory_reasons.push((
12258            reasons[0].0.clone(),
12259            DocumentTargetUnresolvedReason::Unsupported,
12260        ));
12261
12262        let populate = |publication: &mut IndexPublicationGuard<'_>| -> DbResult<()> {
12263            publication.upsert_scan_node_batch(&[graph_node(
12264                "docs/index.md",
12265                NodeKind::File,
12266                Some("docs"),
12267            )])?;
12268            publication.replace_repository_graph(
12269                project,
12270                std::slice::from_ref(&document),
12271                &relations,
12272                &[],
12273                &[],
12274            )
12275        };
12276
12277        let mut boundaries = store.begin_index_publication("document-reason-chunks")?;
12278        populate(&mut boundaries)?;
12279        let duplicate = boundaries.set_document_unresolved_reasons(&duplicate_reasons);
12280        require(
12281            matches!(duplicate, Err(DbError::GraphContract(_))),
12282            "duplicate document reason crossing chunks was accepted",
12283        )?;
12284        let untouched = boundaries.connection.query_row(
12285            "SELECT COUNT(*) FROM graph_relations
12286              WHERE document_unresolved_reason IS NOT NULL",
12287            [],
12288            |row| row.get::<_, i64>(0),
12289        )?;
12290        require_eq(&untouched, &0, "duplicate batch exposed a partial chunk")?;
12291        let contradictory = boundaries.set_document_unresolved_reasons(&contradictory_reasons);
12292        require(
12293            matches!(contradictory, Err(DbError::GraphContract(_))),
12294            "contradictory document reason crossing chunks was accepted",
12295        )?;
12296        let contradiction_untouched = boundaries.connection.query_row(
12297            "SELECT COUNT(*) FROM graph_relations
12298              WHERE document_unresolved_reason IS NOT NULL",
12299            [],
12300            |row| row.get::<_, i64>(0),
12301        )?;
12302        require_eq(
12303            &contradiction_untouched,
12304            &0,
12305            "contradictory batch exposed a partial chunk",
12306        )?;
12307        for count in [
12308            GraphLimits::MAX_ROWS as usize - 1,
12309            GraphLimits::MAX_ROWS as usize,
12310            GraphLimits::MAX_ROWS as usize + 1,
12311        ] {
12312            boundaries.set_document_unresolved_reasons(&reasons[..count])?;
12313            let changed = boundaries.connection.query_row(
12314                "SELECT COUNT(*) FROM graph_relations
12315                  WHERE document_unresolved_reason IS NOT NULL",
12316                [],
12317                |row| row.get::<_, i64>(0),
12318            )?;
12319            require_eq(
12320                &changed,
12321                &i64::try_from(count)?,
12322                "document reason boundary count",
12323            )?;
12324        }
12325        drop(boundaries);
12326        require_eq(
12327            &store.repository_graph_generation()?,
12328            &Some(baseline_generation),
12329            "boundary publication changed the current generation",
12330        )?;
12331
12332        let mut preflight = store.begin_index_publication("document-reason-chunks")?;
12333        populate(&mut preflight)?;
12334        let preflight_control = IndexWorkControl::with_deadline(
12335            IndexCancellation::new(),
12336            Instant::now() + Duration::from_millis(10),
12337        );
12338        let preflight_error =
12339            preflight.set_document_unresolved_reasons_controlled(&reasons, &preflight_control);
12340        require(
12341            matches!(
12342                preflight_error,
12343                Err(DbError::IndexWork(
12344                    projectatlas_core::IndexWorkFailure::DeadlineExceeded {
12345                        stage: IndexWorkStage::Publication
12346                    }
12347                ))
12348            ),
12349            "preflight deadline was not observed during document-reason validation",
12350        )?;
12351        let preflight_untouched = preflight.connection.query_row(
12352            "SELECT COUNT(*) FROM graph_relations
12353              WHERE document_unresolved_reason IS NOT NULL",
12354            [],
12355            |row| row.get::<_, i64>(0),
12356        )?;
12357        require_eq(
12358            &preflight_untouched,
12359            &0,
12360            "preflight deadline exposed a partial document-reason update",
12361        )?;
12362        drop(preflight);
12363        require_eq(
12364            &store.repository_graph_generation()?,
12365            &Some(baseline_generation),
12366            "preflight deadline changed the current generation",
12367        )?;
12368
12369        let mut preflight_retry = store.begin_index_publication("document-reason-chunks")?;
12370        populate(&mut preflight_retry)?;
12371        preflight_retry.set_document_unresolved_reasons(&reasons)?;
12372        let retried_count = preflight_retry.connection.query_row(
12373            "SELECT COUNT(*) FROM graph_relations
12374              WHERE document_unresolved_reason IS NOT NULL",
12375            [],
12376            |row| row.get::<_, i64>(0),
12377        )?;
12378        require_eq(
12379            &retried_count,
12380            &i64::try_from(total)?,
12381            "successful retry did not publish every document reason",
12382        )?;
12383        drop(preflight_retry);
12384        require_eq(
12385            &store.repository_graph_generation()?,
12386            &Some(baseline_generation),
12387            "successful preflight retry changed the current generation before completion",
12388        )?;
12389
12390        let mut canceled_publication = store.begin_index_publication("document-reason-chunks")?;
12391        populate(&mut canceled_publication)?;
12392        canceled_publication
12393            .set_document_unresolved_reasons(&reasons[..GraphLimits::MAX_ROWS as usize])?;
12394        let cancellation = projectatlas_core::IndexCancellation::new();
12395        cancellation.cancel();
12396        let canceled_control = IndexWorkControl::new(cancellation, None);
12397        let canceled = canceled_publication.set_document_unresolved_reasons_controlled(
12398            &reasons[GraphLimits::MAX_ROWS as usize..],
12399            &canceled_control,
12400        );
12401        require(
12402            matches!(
12403                canceled,
12404                Err(DbError::IndexWork(
12405                    projectatlas_core::IndexWorkFailure::Cancelled {
12406                        stage: IndexWorkStage::Publication
12407                    }
12408                ))
12409            ),
12410            "post-first-chunk cancellation was accepted",
12411        )?;
12412        let canceled_first_chunk = canceled_publication.connection.query_row(
12413            "SELECT COUNT(*) FROM graph_relations
12414              WHERE document_unresolved_reason IS NOT NULL",
12415            [],
12416            |row| row.get::<_, i64>(0),
12417        )?;
12418        require_eq(
12419            &canceled_first_chunk,
12420            &i64::from(GraphLimits::MAX_ROWS),
12421            "cancellation did not leave the first chunk observable inside the parent transaction",
12422        )?;
12423        drop(canceled_publication);
12424        require_eq(
12425            &store.repository_graph_generation()?,
12426            &Some(baseline_generation),
12427            "canceled publication changed the current generation",
12428        )?;
12429
12430        let mut in_chunk_canceled = store.begin_index_publication("document-reason-chunks")?;
12431        populate(&mut in_chunk_canceled)?;
12432        let cancellation = IndexCancellation::new();
12433        let control = IndexWorkControl::new(cancellation.clone(), None);
12434        let attempted_updates = Arc::new(AtomicUsize::new(0));
12435        let attempted_updates_hook = Arc::clone(&attempted_updates);
12436        in_chunk_canceled.connection.update_hook(Some(
12437            move |action: rusqlite::hooks::Action, _database: &str, table: &str, _rowid: i64| {
12438                if action == rusqlite::hooks::Action::SQLITE_UPDATE
12439                    && table == "graph_relations"
12440                    && attempted_updates_hook.fetch_add(1, Ordering::Relaxed) == 0
12441                {
12442                    cancellation.cancel();
12443                }
12444            },
12445        ))?;
12446        let in_chunk = in_chunk_canceled.set_document_unresolved_reasons_controlled(
12447            &reasons[..GraphLimits::MAX_ROWS as usize],
12448            &control,
12449        );
12450        in_chunk_canceled
12451            .connection
12452            .update_hook(None::<fn(rusqlite::hooks::Action, &str, &str, i64)>)?;
12453        require(
12454            matches!(
12455                in_chunk,
12456                Err(DbError::IndexWork(
12457                    projectatlas_core::IndexWorkFailure::Cancelled {
12458                        stage: IndexWorkStage::Publication
12459                    }
12460                ))
12461            ),
12462            "in-chunk cancellation was not typed",
12463        )?;
12464        require(
12465            attempted_updates.load(Ordering::Relaxed) >= 1,
12466            "in-chunk cancellation did not follow an attempted update",
12467        )?;
12468        let in_chunk_untouched = in_chunk_canceled.connection.query_row(
12469            "SELECT COUNT(*) FROM graph_relations
12470              WHERE document_unresolved_reason IS NOT NULL",
12471            [],
12472            |row| row.get::<_, i64>(0),
12473        )?;
12474        require_eq(
12475            &in_chunk_untouched,
12476            &0,
12477            "in-chunk cancellation exposed a partial document-reason update",
12478        )?;
12479        drop(in_chunk_canceled);
12480        require_eq(
12481            &store.repository_graph_generation()?,
12482            &Some(baseline_generation),
12483            "in-chunk cancellation changed the current generation",
12484        )?;
12485
12486        let mut in_chunk_retry = store.begin_index_publication("document-reason-chunks")?;
12487        populate(&mut in_chunk_retry)?;
12488        in_chunk_retry
12489            .set_document_unresolved_reasons(&reasons[..GraphLimits::MAX_ROWS as usize])?;
12490        let in_chunk_retry_count = in_chunk_retry.connection.query_row(
12491            "SELECT COUNT(*) FROM graph_relations
12492              WHERE document_unresolved_reason IS NOT NULL",
12493            [],
12494            |row| row.get::<_, i64>(0),
12495        )?;
12496        require_eq(
12497            &in_chunk_retry_count,
12498            &i64::from(GraphLimits::MAX_ROWS),
12499            "writer reuse after in-chunk cancellation did not retry cleanly",
12500        )?;
12501        drop(in_chunk_retry);
12502        require_eq(
12503            &store.repository_graph_generation()?,
12504            &Some(baseline_generation),
12505            "in-chunk retry changed the current generation before completion",
12506        )?;
12507
12508        let mut publication = store.begin_index_publication("document-reason-chunks")?;
12509        populate(&mut publication)?;
12510        publication.connection.execute_batch(
12511            "CREATE TRIGGER fail_document_reason_chunk
12512             BEFORE UPDATE OF document_unresolved_reason ON graph_relations
12513             WHEN NEW.reference_text = 'missing-10000.md'
12514             BEGIN
12515                 SELECT RAISE(ABORT, 'injected document reason failure');
12516             END;",
12517        )?;
12518        let fault = publication.set_document_unresolved_reasons(&reasons);
12519        require(
12520            matches!(fault, Err(DbError::Sqlite(_))),
12521            "fault between document-reason chunks was accepted",
12522        )?;
12523        let rolled_back = publication.connection.query_row(
12524            "SELECT COUNT(*) FROM graph_relations
12525              WHERE document_unresolved_reason IS NOT NULL",
12526            [],
12527            |row| row.get::<_, i64>(0),
12528        )?;
12529        require_eq(
12530            &rolled_back,
12531            &0,
12532            "fault between chunks exposed an earlier chunk",
12533        )?;
12534        drop(publication);
12535        require(
12536            store.repository_graph_generation()? == Some(baseline_generation),
12537            "fault between chunks replaced the current generation",
12538        )?;
12539
12540        let mut publication = store.begin_index_publication("document-reason-chunks")?;
12541        populate(&mut publication)?;
12542        let changes_before = publication.connection.total_changes();
12543        publication.set_document_unresolved_reasons(&reasons)?;
12544        let changes_after = publication.connection.total_changes();
12545        require_eq(
12546            &changes_after.saturating_sub(changes_before),
12547            &u64::try_from(total)?,
12548            "bounded reason update changed rows",
12549        )?;
12550        publication.complete()?;
12551        let (relation_count, reason_count): (i64, i64) = store.connection.query_row(
12552            "SELECT COUNT(*), COUNT(document_unresolved_reason)
12553               FROM graph_relations
12554              WHERE relation_scope = 'extended' AND relation_kind = 'documents'",
12555            [],
12556            |row| Ok((row.get(0)?, row.get(1)?)),
12557        )?;
12558        require_eq(
12559            &relation_count,
12560            &i64::try_from(total)?,
12561            "complete document relation count",
12562        )?;
12563        require_eq(
12564            &reason_count,
12565            &i64::try_from(total)?,
12566            "complete document reason count",
12567        )?;
12568        assert_query_indexes(&store)?;
12569        Ok(())
12570    }
12571
12572    #[test]
12573    fn high_fanout_document_refresh_has_bounded_sql_and_changed_rows() -> Result<(), Box<dyn Error>>
12574    {
12575        const FANOUT: usize = 256;
12576        const EXPECTED_STATEMENTS: usize = 2_083;
12577        const EXPECTED_CHANGED_ROWS: u64 = 1_031;
12578
12579        let temp = tempfile::tempdir()?;
12580        let root = temp.path().join("high-fanout-document");
12581        fs::create_dir(&root)?;
12582        let database = temp.path().join("projectatlas.db");
12583        let mut store = AtlasStore::open_for_project(&database, &root)?;
12584        let project = store
12585            .project_instance_id()?
12586            .ok_or_else(|| io::Error::other("high-fanout fixture identity is missing"))?;
12587        let document_path = RepositoryFilePath::new(Path::new("docs/high-fanout.md"))?;
12588
12589        let graph = |generation: IndexGeneration| -> DbResult<_> {
12590            let document = GraphEntity::new(
12591                project,
12592                EntitySelector::File {
12593                    path: document_path.clone(),
12594                },
12595                generation,
12596            )?;
12597            let mut entities = vec![document.clone()];
12598            let mut relations = Vec::with_capacity(FANOUT);
12599            let mut occurrences = Vec::with_capacity(FANOUT);
12600            for (index, line) in (2_u32..).take(FANOUT).enumerate() {
12601                let target = GraphEntity::new(
12602                    project,
12603                    EntitySelector::File {
12604                        path: RepositoryFilePath::new(Path::new(&format!(
12605                            "src/target_{index:04}.rs"
12606                        )))?,
12607                    },
12608                    generation,
12609                )?;
12610                let relation = LogicalRelation::new(
12611                    &document,
12612                    GraphRelationKind::Extended(ExtendedRelationKind::Documents),
12613                    RelationResolution::resolved(&target)?,
12614                    ConfidenceClass::Exact,
12615                    Completeness::Complete,
12616                    generation,
12617                )?;
12618                occurrences.push(RelationOccurrence::new(
12619                    &relation,
12620                    document_path.clone(),
12621                    SourceSpan::new(line, 0, line, 20)?,
12622                    generation,
12623                )?);
12624                entities.push(target);
12625                relations.push(relation);
12626            }
12627            Ok((entities, relations, occurrences))
12628        };
12629
12630        let mut nodes = vec![
12631            graph_node(".", NodeKind::Folder, None),
12632            graph_node("docs", NodeKind::Folder, Some(".")),
12633            graph_node("docs/high-fanout.md", NodeKind::File, Some("docs")),
12634            graph_node("src", NodeKind::Folder, Some(".")),
12635        ];
12636        nodes.extend((0..FANOUT).map(|index| {
12637            graph_node(
12638                &format!("src/target_{index:04}.rs"),
12639                NodeKind::File,
12640                Some("src"),
12641            )
12642        }));
12643        let (entities, relations, occurrences) = graph(IndexGeneration::new(1))?;
12644        {
12645            let mut publication = store.begin_index_publication("high-fanout-document")?;
12646            publication.begin_scan_replacement()?;
12647            for batch in nodes.chunks(128) {
12648                publication.upsert_scan_node_batch(batch)?;
12649            }
12650            publication.finish_scan_replacement()?;
12651            publication.replace_repository_graph(
12652                project,
12653                &entities,
12654                &relations,
12655                &occurrences,
12656                &[],
12657            )?;
12658            publication.complete()?;
12659        }
12660
12661        let (entities, relations, occurrences) = graph(IndexGeneration::new(2))?;
12662        TRACED_STATEMENTS.with(|statements| statements.borrow_mut().clear());
12663        store.connection.trace_v2(
12664            rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT,
12665            Some(record_traced_event),
12666        );
12667        let changed_before = store.connection.total_changes();
12668        let refresh = (|| -> DbResult<()> {
12669            let mut publication = store.begin_index_publication("high-fanout-document")?;
12670            publication.replace_repository_graph_for_paths(
12671                project,
12672                &["docs/high-fanout.md".to_string()],
12673                &entities,
12674                &relations,
12675                &occurrences,
12676                &[],
12677            )?;
12678            publication.complete()
12679        })();
12680        store
12681            .connection
12682            .trace_v2(rusqlite::trace::TraceEventCodes::empty(), None);
12683        refresh?;
12684        let changed_rows = store.connection.total_changes() - changed_before;
12685        let statements =
12686            TRACED_STATEMENTS.with(|statements| std::mem::take(&mut *statements.borrow_mut()));
12687
12688        require_eq(
12689            &statements.len(),
12690            &EXPECTED_STATEMENTS,
12691            "high-fanout refresh statement count",
12692        )?;
12693        require_eq(
12694            &changed_rows,
12695            &EXPECTED_CHANGED_ROWS,
12696            "high-fanout refresh changed rows",
12697        )?;
12698        let relation_count = usize::try_from(store.connection.query_row(
12699            "SELECT COUNT(*) FROM graph_relations
12700              WHERE relation_scope = 'extended' AND relation_kind = 'documents'",
12701            [],
12702            |row| row.get::<_, i64>(0),
12703        )?)?;
12704        require_eq(&relation_count, &FANOUT, "retained document relation count")?;
12705        Ok(())
12706    }
12707
12708    #[test]
12709    fn every_graph_limit_kind_round_trips_and_unknown_spelling_rolls_back()
12710    -> Result<(), Box<dyn Error>> {
12711        let temp = tempfile::tempdir()?;
12712        let root = temp.path().join("graph-limit-kinds");
12713        fs::create_dir_all(&root)?;
12714        let database = root.join("projectatlas.db");
12715        let mut store = AtlasStore::open_for_project(&database, &root)?;
12716        let project = store
12717            .project_instance_id()?
12718            .ok_or_else(|| io::Error::other("graph-limit fixture identity is missing"))?;
12719        let generation = IndexGeneration::new(1);
12720        let mut coverage = Vec::with_capacity(GraphLimitKind::ALL.len());
12721        for kind in GraphLimitKind::ALL {
12722            coverage.push(CoverageRecord::new(
12723                CoverageScope::Path {
12724                    path: RepositoryNodePath::new(Path::new(&format!(
12725                        "limits/{}.rs",
12726                        kind.as_str()
12727                    )))?,
12728                },
12729                None,
12730                CoverageState::Partial,
12731                1,
12732                1,
12733                generation,
12734                Some(GraphIdentityText::new("graph limit reached")?),
12735                Some(kind),
12736            )?);
12737        }
12738        let mut publication = store.begin_index_publication("graph-limit-kinds")?;
12739        publication.replace_repository_graph(project, &[], &[], &[], &coverage)?;
12740        publication.complete()?;
12741
12742        let verify = |store: &AtlasStore| -> Result<(), Box<dyn Error>> {
12743            for kind in GraphLimitKind::ALL {
12744                let page = store.repository_graph_coverage(
12745                    project,
12746                    &CoverageScope::Path {
12747                        path: RepositoryNodePath::new(Path::new(&format!(
12748                            "limits/{}.rs",
12749                            kind.as_str()
12750                        )))?,
12751                    },
12752                    1,
12753                )?;
12754                require_eq(&page.rows.len(), &1, "graph-limit coverage row count")?;
12755                require_eq(
12756                    &page.rows[0].reached_limit(),
12757                    &Some(kind),
12758                    "graph-limit coverage spelling",
12759                )?;
12760            }
12761            Ok(())
12762        };
12763        verify(&store)?;
12764
12765        store.connection.execute_batch("BEGIN IMMEDIATE")?;
12766        let invalid = (|| -> DbResult<()> {
12767            store.connection.execute("DELETE FROM graph_coverage", [])?;
12768            store.connection.execute(
12769                "INSERT INTO graph_coverage(
12770                    project_instance_id, scope_kind, state, total, covered, omitted,
12771                    reason, reached_limit
12772                 ) VALUES(?1, 'project', 'partial', 2, 1, 1, 'invalid limit', 'rowz')",
12773                [&project.as_bytes()[..]],
12774            )?;
12775            Ok(())
12776        })();
12777        require(
12778            matches!(invalid, Err(DbError::Sqlite(_))),
12779            "unknown graph-limit spelling bypassed the SQLite constraint",
12780        )?;
12781        store.connection.execute_batch("ROLLBACK")?;
12782        verify(&store)?;
12783        drop(store);
12784
12785        let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
12786        verify(&reopened)?;
12787        Ok(())
12788    }
12789
12790    #[test]
12791    fn typed_graph_round_trips_through_bounded_indexed_queries() -> Result<(), Box<dyn Error>> {
12792        let temp = tempfile::tempdir()?;
12793        let project_root = temp.path().join("typed-graph");
12794        let atlas_dir = project_root.join(".projectatlas");
12795        fs::create_dir_all(&atlas_dir)?;
12796        let db_path = atlas_dir.join("projectatlas.db");
12797        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
12798        let fixture = publish_fixture(&mut writer, "typed-graph")?;
12799        drop(writer);
12800        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12801
12802        for expected in &fixture.entities {
12803            require_eq(
12804                &store.repository_graph_entity(expected.key())?,
12805                &Some(expected.clone()),
12806                "stable entity lookup",
12807            )?;
12808        }
12809
12810        let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
12811        let truncated =
12812            store.repository_graph_entities_by_path(fixture.project, &source_path, 1)?;
12813        require(
12814            truncated.truncated && truncated.rows.len() == 1,
12815            "entity LIMIT + 1",
12816        )?;
12817        let path_rows =
12818            store.repository_graph_entities_by_path(fixture.project, &source_path, 10)?;
12819        require(
12820            !path_rows.truncated && path_rows.rows.len() == 2,
12821            "Unicode/case path lookup",
12822        )?;
12823        for (result, context) in [
12824            (
12825                store.repository_graph_entities_by_path(fixture.project, &source_path, 0),
12826                "zero entity page limit",
12827            ),
12828            (
12829                store.repository_graph_entities_by_path(
12830                    fixture.project,
12831                    &source_path,
12832                    GraphLimits::MAX_ROWS + 1,
12833                ),
12834                "over-ceiling entity page limit",
12835            ),
12836        ] {
12837            let error = require_db_error(result, context)?;
12838            require(
12839                matches!(error, DbError::GraphContract(_)),
12840                &format!("unexpected {context} error: {error}"),
12841            )?;
12842        }
12843
12844        let source = fixture
12845            .entities
12846            .iter()
12847            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
12848            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
12849        let outbound = store.repository_graph_relations(
12850            RepositoryGraphRelationQuery::Outbound {
12851                source: source.key().clone(),
12852            },
12853            10,
12854        )?;
12855        require_eq(&outbound.rows.len(), &4, "all resolution states")?;
12856        require(
12857            outbound.rows.iter().any(|relation| {
12858                matches!(relation.resolution(), RelationResolution::Resolved { .. })
12859            }) && outbound.rows.iter().any(|relation| {
12860                matches!(relation.resolution(), RelationResolution::Ambiguous { .. })
12861            }) && outbound.rows.iter().any(|relation| {
12862                matches!(relation.resolution(), RelationResolution::Unresolved { .. })
12863            }) && outbound.rows.iter().any(|relation| {
12864                matches!(relation.resolution(), RelationResolution::External { .. })
12865            }),
12866            "resolution variants did not round-trip",
12867        )?;
12868        let outbound_truncated = store.repository_graph_relations(
12869            RepositoryGraphRelationQuery::Outbound {
12870                source: source.key().clone(),
12871            },
12872            3,
12873        )?;
12874        require(
12875            outbound_truncated.truncated && outbound_truncated.rows.len() == 3,
12876            "relation LIMIT + 1",
12877        )?;
12878        for (limit, context) in [
12879            (0, "zero relation page limit"),
12880            (
12881                GraphLimits::MAX_ROWS + 1,
12882                "over-ceiling relation page limit",
12883            ),
12884        ] {
12885            let error = require_db_error(
12886                store.repository_graph_relations(
12887                    RepositoryGraphRelationQuery::Outbound {
12888                        source: source.key().clone(),
12889                    },
12890                    limit,
12891                ),
12892                context,
12893            )?;
12894            require(
12895                matches!(error, DbError::GraphContract(_)),
12896                &format!("unexpected {context} error: {error}"),
12897            )?;
12898        }
12899
12900        let symbol = fixture
12901            .entities
12902            .iter()
12903            .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
12904            .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
12905        let inbound = store.repository_graph_relations(
12906            RepositoryGraphRelationQuery::Inbound {
12907                target: symbol.key().clone(),
12908            },
12909            10,
12910        )?;
12911        require_eq(&inbound.rows.len(), &1, "inbound relation lookup")?;
12912        let calls = store.repository_graph_relations(
12913            RepositoryGraphRelationQuery::Family {
12914                relation: GraphRelationKind::Legacy(RelationKind::Calls),
12915            },
12916            10,
12917        )?;
12918        require_eq(&calls.rows.len(), &1, "relation-family lookup")?;
12919        let occurrence_page = store.repository_graph_occurrences(&calls.rows[0], 1)?;
12920        require(
12921            occurrence_page.truncated && occurrence_page.rows.len() == 1,
12922            "occurrence LIMIT + 1",
12923        )?;
12924        let all_occurrences = store.repository_graph_occurrences(&calls.rows[0], 10)?;
12925        require_eq(
12926            &all_occurrences.rows.len(),
12927            &2,
12928            "logical relation occurrence retention",
12929        )?;
12930        let error = require_db_error(
12931            store.repository_graph_occurrences(&calls.rows[0], 0),
12932            "zero occurrence page limit was accepted",
12933        )?;
12934        require(
12935            matches!(error, DbError::GraphContract(_)),
12936            &format!("unexpected zero occurrence-limit error: {error}"),
12937        )?;
12938        let error = require_db_error(
12939            store.repository_graph_occurrences(&calls.rows[0], GraphLimits::MAX_OCCURRENCES + 1),
12940            "over-ceiling occurrence page was accepted",
12941        )?;
12942        require(
12943            matches!(error, DbError::GraphContract(_)),
12944            &format!("unexpected occurrence-limit error: {error}"),
12945        )?;
12946
12947        let project_coverage =
12948            store.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10)?;
12949        let path_coverage = store.repository_graph_coverage(
12950            fixture.project,
12951            &CoverageScope::Path {
12952                path: RepositoryNodePath::new(Path::new("src"))?,
12953            },
12954            10,
12955        )?;
12956        require_eq(&project_coverage.rows.len(), &8, "project coverage states")?;
12957        require_eq(&path_coverage.rows.len(), &1, "path coverage state")?;
12958        require(
12959            project_coverage
12960                .rows
12961                .iter()
12962                .any(|row| row.state() == CoverageState::NoCandidates && row.total() == 0),
12963            "zero-candidate coverage did not round-trip",
12964        )?;
12965        require(
12966            project_coverage.rows.iter().any(|row| {
12967                row.state() == CoverageState::Complete
12968                    && row.total() == 0
12969                    && row.relation()
12970                        == Some(GraphRelationKind::Extended(ExtendedRelationKind::Tests))
12971            }),
12972            "non-document complete-zero coverage changed public state",
12973        )?;
12974        require(
12975            path_coverage.rows[0].state() == CoverageState::Partial,
12976            "partial coverage did not round-trip",
12977        )?;
12978        for (limit, context) in [
12979            (0, "zero coverage page limit"),
12980            (
12981                GraphLimits::MAX_ROWS + 1,
12982                "over-ceiling coverage page limit",
12983            ),
12984        ] {
12985            let error = require_db_error(
12986                store.repository_graph_coverage(fixture.project, &CoverageScope::Project, limit),
12987                context,
12988            )?;
12989            require(
12990                matches!(error, DbError::GraphContract(_)),
12991                &format!("unexpected {context} error: {error}"),
12992            )?;
12993        }
12994
12995        let source_next = GraphEntity::new(
12996            fixture.project,
12997            source.selector().clone(),
12998            IndexGeneration::new(2),
12999        )?;
13000        let symbol_next = GraphEntity::new(
13001            fixture.project,
13002            symbol.selector().clone(),
13003            IndexGeneration::new(2),
13004        )?;
13005        let next_generation_call = LogicalRelation::new(
13006            &source_next,
13007            GraphRelationKind::Legacy(RelationKind::Calls),
13008            RelationResolution::resolved(&symbol_next)?,
13009            ConfidenceClass::Exact,
13010            Completeness::Complete,
13011            IndexGeneration::new(2),
13012        )?;
13013        let error = require_db_error(
13014            store.repository_graph_occurrences(&next_generation_call, 10),
13015            "generation-mismatched occurrence request was accepted",
13016        )?;
13017        require(
13018            matches!(error, DbError::GraphContract(_)),
13019            &format!("unexpected occurrence generation error: {error}"),
13020        )?;
13021
13022        let lexical = store
13023            .load_file_text("src/Äuth.rs")?
13024            .ok_or_else(|| io::Error::other("lexical source row missing"))?;
13025        require_eq(
13026            &lexical.content,
13027            &"fn verifyToken()".to_string(),
13028            "lexical owner",
13029        )?;
13030        require_eq(
13031            &store.symbol_relation_count()?,
13032            &1,
13033            "legacy relation projection changed",
13034        )?;
13035
13036        store.finish_index_read_snapshot()?;
13037        drop(store);
13038        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
13039        let mut publication = writer.begin_index_publication("typed-graph")?;
13040        publication.replace_repository_graph_for_paths(
13041            fixture.project,
13042            &["src/unrelated.rs".to_string()],
13043            &[],
13044            &[],
13045            &[],
13046            &[],
13047        )?;
13048        publication.complete()?;
13049        drop(writer);
13050        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
13051        let reused = store
13052            .repository_graph_entity(source.key())?
13053            .ok_or_else(|| io::Error::other("unchanged entity disappeared"))?;
13054        require_eq(
13055            &reused.generation(),
13056            &IndexGeneration::new(2),
13057            "unchanged graph row generation injection",
13058        )?;
13059        for expected in &fixture.entities {
13060            let reused = store
13061                .repository_graph_entity(expected.key())?
13062                .ok_or_else(|| io::Error::other("unchanged graph entity disappeared"))?;
13063            require_eq(
13064                &reused.generation(),
13065                &IndexGeneration::new(2),
13066                "incremental graph row reuse",
13067            )?;
13068        }
13069        assert_query_indexes(&store)?;
13070        store.finish_index_read_snapshot()?;
13071        Ok(())
13072    }
13073
13074    #[test]
13075    fn batched_adjacency_uses_direction_owned_indexes_and_stable_keysets()
13076    -> Result<(), Box<dyn Error>> {
13077        let temp = tempfile::tempdir()?;
13078        let project_root = temp.path().join("batched-adjacency");
13079        let atlas_dir = project_root.join(".projectatlas");
13080        fs::create_dir_all(&atlas_dir)?;
13081        let db_path = atlas_dir.join("projectatlas.db");
13082        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
13083        let fixture = publish_fixture(&mut writer, "batched-adjacency")?;
13084        drop(writer);
13085        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
13086
13087        let source = fixture
13088            .entities
13089            .iter()
13090            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
13091            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
13092        let symbol = fixture
13093            .entities
13094            .iter()
13095            .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
13096            .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
13097        let external = fixture
13098            .entities
13099            .iter()
13100            .find(|entity| matches!(entity.selector(), EntitySelector::External { .. }))
13101            .ok_or_else(|| io::Error::other("external fixture missing"))?;
13102        let outbound_frontier = vec![source.key().clone()];
13103
13104        let first = store.repository_graph_adjacency_page(
13105            &outbound_frontier,
13106            RepositoryGraphDirection::Outbound,
13107            None,
13108            2,
13109            None,
13110        )?;
13111        require(
13112            first.truncated && first.rows.len() == 2 && first.continuation.is_some(),
13113            "outbound adjacency did not retain its LIMIT + 1 keyset",
13114        )?;
13115        require(
13116            first.rows.iter().all(|row| {
13117                row.frontier_index == 0
13118                    && row.frontier == source.key().clone()
13119                    && row.direction == RepositoryGraphDirection::Outbound
13120            }),
13121            "outbound adjacency lost its selecting frontier",
13122        )?;
13123        let continuation = first
13124            .continuation
13125            .clone()
13126            .ok_or_else(|| io::Error::other("outbound continuation missing"))?;
13127        let second = store.repository_graph_adjacency_page(
13128            &outbound_frontier,
13129            RepositoryGraphDirection::Outbound,
13130            Some(&continuation),
13131            10,
13132            None,
13133        )?;
13134        require(
13135            !second.truncated && second.rows.len() == 2 && second.continuation.is_none(),
13136            "outbound continuation did not finish the stable relation order",
13137        )?;
13138        let ordinary = store.repository_graph_relations(
13139            RepositoryGraphRelationQuery::Outbound {
13140                source: source.key().clone(),
13141            },
13142            10,
13143        )?;
13144        let combined = first
13145            .rows
13146            .into_iter()
13147            .chain(second.rows)
13148            .map(|row| row.detail.relation)
13149            .collect::<Vec<_>>();
13150        require_eq(
13151            &combined,
13152            &ordinary.rows,
13153            "adjacency keyset order versus ordinary relation order",
13154        )?;
13155        let calls = GraphRelationKind::Legacy(RelationKind::Calls);
13156        let filtered = store.repository_graph_adjacency_page_filtered(
13157            &outbound_frontier,
13158            RepositoryGraphDirection::Outbound,
13159            Some(calls),
13160            None,
13161            10,
13162            None,
13163        )?;
13164        let expected_calls = ordinary
13165            .rows
13166            .iter()
13167            .filter(|relation| relation.kind() == calls)
13168            .cloned()
13169            .collect::<Vec<_>>();
13170        require_eq(
13171            &filtered
13172                .rows
13173                .iter()
13174                .map(|row| row.detail.relation.clone())
13175                .collect::<Vec<_>>(),
13176            &expected_calls,
13177            "filtered adjacency versus ordinary family selection",
13178        )?;
13179        require(
13180            !filtered.truncated && filtered.continuation.is_none(),
13181            "filtered adjacency unexpectedly truncated its complete family",
13182        )?;
13183        let resolved = store.repository_graph_resolved_adjacency_page(
13184            &outbound_frontier,
13185            RepositoryGraphDirection::Outbound,
13186            calls,
13187            None,
13188            10,
13189            None,
13190        )?;
13191        require_eq(
13192            &resolved
13193                .rows
13194                .iter()
13195                .map(|row| row.detail.relation.clone())
13196                .collect::<Vec<_>>(),
13197            &expected_calls,
13198            "resolved adjacency versus exact local family selection",
13199        )?;
13200        let unresolved = store.repository_graph_resolved_adjacency_page(
13201            &outbound_frontier,
13202            RepositoryGraphDirection::Outbound,
13203            GraphRelationKind::Extended(ExtendedRelationKind::Configures),
13204            None,
13205            10,
13206            None,
13207        )?;
13208        require_eq(
13209            &unresolved.rows.len(),
13210            &0,
13211            "resolved adjacency retained unresolved rows",
13212        )?;
13213        let external_only = store.repository_graph_resolved_adjacency_page(
13214            &outbound_frontier,
13215            RepositoryGraphDirection::Outbound,
13216            GraphRelationKind::Legacy(RelationKind::DependsOn),
13217            None,
13218            10,
13219            None,
13220        )?;
13221        require_eq(
13222            &external_only.rows.len(),
13223            &0,
13224            "resolved adjacency retained external rows",
13225        )?;
13226        let mut resolved_cursor = continuation.clone();
13227        resolved_cursor.relation = Some(calls);
13228        resolved_cursor.resolved_only = true;
13229        let encoded = serde_json::to_string(&resolved_cursor)?;
13230        let decoded: RepositoryGraphAdjacencyContinuation = serde_json::from_str(&encoded)?;
13231        require(
13232            encoded.contains("\"resolved_only\":true") && decoded == resolved_cursor,
13233            "resolved adjacency cursor did not preserve its mode through serde",
13234        )?;
13235        let resolved_mode_mismatch = require_db_error(
13236            store.repository_graph_adjacency_page_filtered(
13237                &outbound_frontier,
13238                RepositoryGraphDirection::Outbound,
13239                Some(calls),
13240                Some(&decoded),
13241                1,
13242                None,
13243            ),
13244            "resolved adjacency cursor was accepted by ordinary adjacency",
13245        )?;
13246        require(
13247            matches!(resolved_mode_mismatch, DbError::GraphContract(_)),
13248            &format!("resolved cursor mismatch returned {resolved_mode_mismatch}"),
13249        )?;
13250
13251        let inbound_frontier = vec![symbol.key().clone(), external.key().clone()];
13252        let inbound = store.repository_graph_adjacency_page(
13253            &inbound_frontier,
13254            RepositoryGraphDirection::Inbound,
13255            None,
13256            10,
13257            None,
13258        )?;
13259        require(
13260            !inbound.truncated
13261                && inbound.rows.len() == 2
13262                && inbound.rows[0].frontier_index == 0
13263                && inbound.rows[0].frontier == symbol.key().clone()
13264                && inbound.rows[1].frontier_index == 1
13265                && inbound.rows[1].frontier == external.key().clone()
13266                && inbound
13267                    .rows
13268                    .iter()
13269                    .all(|row| row.direction == RepositoryGraphDirection::Inbound),
13270            "inbound adjacency did not preserve bounded frontier order",
13271        )?;
13272
13273        for (direction, expected_index) in [
13274            (
13275                RepositoryGraphDirection::Outbound,
13276                "idx_graph_relations_source_kind",
13277            ),
13278            (
13279                RepositoryGraphDirection::Inbound,
13280                "idx_graph_relations_target_kind",
13281            ),
13282        ] {
13283            for continuation_index in [None, Some(0)] {
13284                let sql = format!(
13285                    "EXPLAIN QUERY PLAN {}",
13286                    adjacency_relation_sql(2, direction, continuation_index, false, false, false,)
13287                );
13288                let mut statement = store.connection.prepare(&sql)?;
13289                let mut bindings = vec![Value::Blob(fixture.project.as_bytes().to_vec())];
13290                bindings.push(Value::Blob(source.key().digest_bytes()?.to_vec()));
13291                if continuation_index.is_some() {
13292                    bindings.extend([
13293                        Value::Text(String::new()),
13294                        Value::Text(String::new()),
13295                        Value::Blob(vec![0; 32]),
13296                    ]);
13297                }
13298                bindings.extend([
13299                    Value::Integer(11),
13300                    Value::Blob(symbol.key().digest_bytes()?.to_vec()),
13301                    Value::Integer(11),
13302                    Value::Integer(11),
13303                ]);
13304                let details = statement
13305                    .query_map(params_from_iter(bindings.iter()), |row| {
13306                        row.get::<_, String>(3)
13307                    })?
13308                    .collect::<Result<Vec<_>, _>>()?;
13309                require(
13310                    details.iter().any(|detail| detail.contains(expected_index))
13311                        && details
13312                            .iter()
13313                            .all(|detail| !detail.contains("SCAN relation")),
13314                    &format!(
13315                        "{direction:?} adjacency plan (continuation_index={continuation_index:?}) did not own its index: {details:?}"
13316                    ),
13317                )?;
13318            }
13319
13320            let (scope, kind) = relation_parts(calls);
13321            let filtered_sql = format!(
13322                "EXPLAIN QUERY PLAN {}",
13323                adjacency_relation_sql(2, direction, None, true, false, false)
13324            );
13325            let mut filtered_statement = store.connection.prepare(&filtered_sql)?;
13326            let filtered_bindings = [
13327                Value::Blob(fixture.project.as_bytes().to_vec()),
13328                Value::Blob(source.key().digest_bytes()?.to_vec()),
13329                Value::Text(scope.to_string()),
13330                Value::Text(kind.to_string()),
13331                Value::Integer(11),
13332                Value::Blob(symbol.key().digest_bytes()?.to_vec()),
13333                Value::Text(scope.to_string()),
13334                Value::Text(kind.to_string()),
13335                Value::Integer(11),
13336                Value::Integer(11),
13337            ];
13338            let filtered_details = filtered_statement
13339                .query_map(params_from_iter(filtered_bindings.iter()), |row| {
13340                    row.get::<_, String>(3)
13341                })?
13342                .collect::<Result<Vec<_>, _>>()?;
13343            require(
13344                filtered_details
13345                    .iter()
13346                    .any(|detail| detail.contains(expected_index))
13347                    && filtered_details
13348                        .iter()
13349                        .all(|detail| !detail.contains("SCAN relation")),
13350                &format!(
13351                    "filtered {direction:?} adjacency plan did not own its index: {filtered_details:?}"
13352                ),
13353            )?;
13354
13355            let resolved_sql = format!(
13356                "EXPLAIN QUERY PLAN {}",
13357                adjacency_relation_sql(2, direction, None, true, true, false)
13358            );
13359            let mut resolved_statement = store.connection.prepare(&resolved_sql)?;
13360            let resolved_bindings = [
13361                Value::Blob(fixture.project.as_bytes().to_vec()),
13362                Value::Text(RESOLUTION_STATUS_RESOLVED.to_string()),
13363                Value::Blob(source.key().digest_bytes()?.to_vec()),
13364                Value::Text(scope.to_string()),
13365                Value::Text(kind.to_string()),
13366                Value::Integer(11),
13367                Value::Blob(symbol.key().digest_bytes()?.to_vec()),
13368                Value::Text(scope.to_string()),
13369                Value::Text(kind.to_string()),
13370                Value::Integer(11),
13371                Value::Integer(11),
13372            ];
13373            let resolved_details = resolved_statement
13374                .query_map(params_from_iter(resolved_bindings.iter()), |row| {
13375                    row.get::<_, String>(3)
13376                })?
13377                .collect::<Result<Vec<_>, _>>()?;
13378            require(
13379                resolved_details
13380                    .iter()
13381                    .any(|detail| detail.contains(expected_index))
13382                    && resolved_details
13383                        .iter()
13384                        .all(|detail| !detail.contains("SCAN relation")),
13385                &format!(
13386                    "resolved {direction:?} adjacency plan did not own its index: {resolved_details:?}"
13387                ),
13388            )?;
13389        }
13390
13391        let duplicate_error = require_db_error(
13392            store.repository_graph_adjacency_page(
13393                &[source.key().clone(), source.key().clone()],
13394                RepositoryGraphDirection::Outbound,
13395                None,
13396                1,
13397                None,
13398            ),
13399            "duplicate adjacency frontier was accepted",
13400        )?;
13401        require(
13402            matches!(duplicate_error, DbError::GraphContract(_)),
13403            &format!("unexpected duplicate-frontier error: {duplicate_error}"),
13404        )?;
13405        let oversized = vec![source.key().clone(); MAX_REPOSITORY_GRAPH_FRONTIER + 1];
13406        let oversized_error = require_db_error(
13407            store.repository_graph_adjacency_page(
13408                &oversized,
13409                RepositoryGraphDirection::Outbound,
13410                None,
13411                1,
13412                None,
13413            ),
13414            "oversized adjacency frontier was accepted",
13415        )?;
13416        require(
13417            matches!(oversized_error, DbError::GraphContract(_)),
13418            &format!("unexpected oversized-frontier error: {oversized_error}"),
13419        )?;
13420        let foreign = GraphEntity::new(
13421            ProjectInstanceId::from_bytes([0x7f; 16])?,
13422            EntitySelector::Project,
13423            IndexGeneration::new(1),
13424        )?;
13425        let project_error = require_db_error(
13426            store.repository_graph_adjacency_page(
13427                &[source.key().clone(), foreign.key().clone()],
13428                RepositoryGraphDirection::Outbound,
13429                None,
13430                1,
13431                None,
13432            ),
13433            "mixed-project adjacency frontier was accepted",
13434        )?;
13435        require(
13436            matches!(project_error, DbError::GraphProjectIdentityMismatch { .. }),
13437            &format!("unexpected mixed-project error: {project_error}"),
13438        )?;
13439        let direction_error = require_db_error(
13440            store.repository_graph_adjacency_page(
13441                &outbound_frontier,
13442                RepositoryGraphDirection::Inbound,
13443                Some(&continuation),
13444                1,
13445                None,
13446            ),
13447            "cross-direction adjacency continuation was accepted",
13448        )?;
13449        require(
13450            matches!(direction_error, DbError::GraphContract(_)),
13451            &format!("unexpected continuation-direction error: {direction_error}"),
13452        )?;
13453        let mut filtered_continuation = continuation.clone();
13454        filtered_continuation.relation = Some(calls);
13455        let family_error = require_db_error(
13456            store.repository_graph_adjacency_page_filtered(
13457                &outbound_frontier,
13458                RepositoryGraphDirection::Outbound,
13459                Some(GraphRelationKind::Extended(
13460                    ExtendedRelationKind::Configures,
13461                )),
13462                Some(&filtered_continuation),
13463                1,
13464                None,
13465            ),
13466            "cross-family adjacency continuation was accepted",
13467        )?;
13468        require(
13469            matches!(family_error, DbError::GraphContract(_)),
13470            &format!("unexpected continuation-family error: {family_error}"),
13471        )?;
13472        let frontier_error = require_db_error(
13473            store.repository_graph_adjacency_page(
13474                &[external.key().clone()],
13475                RepositoryGraphDirection::Outbound,
13476                Some(&continuation),
13477                1,
13478                None,
13479            ),
13480            "cross-frontier adjacency continuation was accepted",
13481        )?;
13482        require(
13483            matches!(frontier_error, DbError::GraphContract(_)),
13484            &format!("unexpected continuation-frontier error: {frontier_error}"),
13485        )?;
13486
13487        let inbound_first = store.repository_graph_adjacency_page(
13488            &inbound_frontier,
13489            RepositoryGraphDirection::Inbound,
13490            None,
13491            1,
13492            None,
13493        )?;
13494        let inbound_continuation = inbound_first
13495            .continuation
13496            .ok_or_else(|| io::Error::other("inbound continuation missing"))?;
13497        require(
13498            store.repository_graph_adjacency_continuation_has_filtered_rows(
13499                &inbound_continuation,
13500                ConfidenceClass::Low,
13501                ContentSelection::UnspecifiedLegacy,
13502                None,
13503            )?,
13504            "multi-frontier continuation lost its remaining admitted relation",
13505        )?;
13506        let mut exhausted_continuation = inbound_continuation.clone();
13507        exhausted_continuation.relation = Some(GraphRelationKind::Extended(
13508            ExtendedRelationKind::Configures,
13509        ));
13510        require(
13511            !store.repository_graph_adjacency_continuation_has_filtered_rows(
13512                &exhausted_continuation,
13513                ConfidenceClass::Low,
13514                ContentSelection::UnspecifiedLegacy,
13515                None,
13516            )?,
13517            "multi-frontier continuation retained an excluded relation",
13518        )?;
13519        let reordered_frontier = vec![external.key().clone(), symbol.key().clone()];
13520        let reordered_error = require_db_error(
13521            store.repository_graph_adjacency_page(
13522                &reordered_frontier,
13523                RepositoryGraphDirection::Inbound,
13524                Some(&inbound_continuation),
13525                1,
13526                None,
13527            ),
13528            "reordered adjacency continuation frontier was accepted",
13529        )?;
13530        require(
13531            matches!(reordered_error, DbError::GraphContract(_)),
13532            &format!("unexpected reordered-frontier error: {reordered_error}"),
13533        )?;
13534        let empty_frontier_error = require_db_error(
13535            store.repository_graph_adjacency_page(
13536                &[],
13537                RepositoryGraphDirection::Inbound,
13538                Some(&inbound_continuation),
13539                1,
13540                None,
13541            ),
13542            "adjacency continuation without a frontier was accepted",
13543        )?;
13544        require(
13545            matches!(empty_frontier_error, DbError::GraphContract(_)),
13546            &format!("unexpected empty-frontier error: {empty_frontier_error}"),
13547        )?;
13548
13549        let mut foreign_project_continuation = continuation.clone();
13550        foreign_project_continuation.project = foreign.key().project();
13551        let continuation_project_error = require_db_error(
13552            store.repository_graph_adjacency_page(
13553                &outbound_frontier,
13554                RepositoryGraphDirection::Outbound,
13555                Some(&foreign_project_continuation),
13556                1,
13557                None,
13558            ),
13559            "cross-project adjacency continuation was accepted",
13560        )?;
13561        require(
13562            matches!(continuation_project_error, DbError::GraphContract(_)),
13563            &format!("unexpected continuation-project error: {continuation_project_error}"),
13564        )?;
13565        let mut stale_generation_continuation = continuation.clone();
13566        stale_generation_continuation.generation = stale_generation_continuation
13567            .generation
13568            .checked_next()
13569            .ok_or_else(|| io::Error::other("fixture generation overflowed"))?;
13570        let continuation_generation_error = require_db_error(
13571            store.repository_graph_adjacency_page(
13572                &outbound_frontier,
13573                RepositoryGraphDirection::Outbound,
13574                Some(&stale_generation_continuation),
13575                1,
13576                None,
13577            ),
13578            "cross-generation adjacency continuation was accepted",
13579        )?;
13580        require(
13581            matches!(continuation_generation_error, DbError::GraphContract(_)),
13582            &format!("unexpected continuation-generation error: {continuation_generation_error}"),
13583        )?;
13584
13585        let mut maximum_frontier = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER);
13586        for index in 0..MAX_REPOSITORY_GRAPH_FRONTIER {
13587            let entity = GraphEntity::new(
13588                fixture.project,
13589                EntitySelector::External {
13590                    external: ExternalSelector {
13591                        system: GraphIdentityText::new("work-envelope")?,
13592                        identity: GraphIdentityText::new(format!("candidate-{index}"))?,
13593                    },
13594                },
13595                continuation.generation,
13596            )?;
13597            maximum_frontier.push(entity.key().clone());
13598        }
13599        let bounded_work = store.repository_graph_adjacency_page(
13600            &maximum_frontier,
13601            RepositoryGraphDirection::Outbound,
13602            None,
13603            38,
13604            None,
13605        )?;
13606        require(
13607            bounded_work.rows.is_empty() && !bounded_work.truncated,
13608            "maximum adjacency frontier rejected work below the intermediate ceiling",
13609        )?;
13610        let excessive_work_error = require_db_error(
13611            store.repository_graph_adjacency_page(
13612                &maximum_frontier,
13613                RepositoryGraphDirection::Outbound,
13614                None,
13615                39,
13616                None,
13617            ),
13618            "adjacency intermediate work above the ceiling was accepted",
13619        )?;
13620        require(
13621            matches!(excessive_work_error, DbError::GraphContract(_)),
13622            &format!("unexpected intermediate-work error: {excessive_work_error}"),
13623        )?;
13624
13625        let successful_cancellation = projectatlas_core::IndexCancellation::new();
13626        let successful_control = IndexWorkControl::new(successful_cancellation.clone(), None);
13627        store.repository_graph_adjacency_page(
13628            &outbound_frontier,
13629            RepositoryGraphDirection::Outbound,
13630            None,
13631            10,
13632            Some(&successful_control),
13633        )?;
13634        successful_cancellation.cancel();
13635        require_eq(
13636            &store
13637                .connection
13638                .query_row("SELECT 1", [], |row| row.get::<_, i64>(0))?,
13639            &1,
13640            "cleared adjacency progress handler",
13641        )?;
13642
13643        let cancellation = projectatlas_core::IndexCancellation::new();
13644        cancellation.cancel();
13645        let control = IndexWorkControl::new(cancellation, None);
13646        let cancelled = store.repository_graph_adjacency_page(
13647            &outbound_frontier,
13648            RepositoryGraphDirection::Outbound,
13649            None,
13650            10,
13651            Some(&control),
13652        );
13653        require(
13654            matches!(
13655                cancelled,
13656                Err(DbError::IndexWork(
13657                    projectatlas_core::IndexWorkFailure::Cancelled {
13658                        stage: IndexWorkStage::RepositoryTraversal
13659                    }
13660                ))
13661            ),
13662            "adjacency cancellation was not typed",
13663        )?;
13664        store.finish_index_read_snapshot()?;
13665        Ok(())
13666    }
13667
13668    #[test]
13669    fn detailed_relation_storage_statements_are_indexed_and_batch_bounded()
13670    -> Result<(), Box<dyn Error>> {
13671        use DetailedRelationTraceStatement::{
13672            ActiveGraphGeneration, AdjacencyRelations, ProjectIdentity, PublicationMetadata,
13673            PurposeOwners, RelationEntities, RelationOccurrences, RelationsByDigest,
13674        };
13675
13676        let temp = tempfile::tempdir()?;
13677        let project_root = temp.path().join("detailed-relation-storage");
13678        let atlas_dir = project_root.join(".projectatlas");
13679        fs::create_dir_all(&atlas_dir)?;
13680        let db_path = atlas_dir.join("projectatlas.db");
13681        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
13682        let fixture =
13683            publish_detailed_relation_trace_fixture(&mut writer, "detailed-relation-storage")?;
13684        drop(writer);
13685        let mut store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
13686        let generation = store
13687            .repository_graph_generation()?
13688            .ok_or_else(|| io::Error::other("graph generation missing"))?;
13689        let budget = maximum_repository_graph_read_budget()?;
13690
13691        assert_cursor_hydration_indexes(&store)?;
13692
13693        let one_frontier = vec![fixture.source.key().clone()];
13694        let many_frontier = std::iter::once(fixture.source.key().clone())
13695            .chain(
13696                fixture
13697                    .relations
13698                    .iter()
13699                    .filter_map(|relation| relation.resolution().resolved_target().cloned())
13700                    .take(3),
13701            )
13702            .collect::<Vec<_>>();
13703        require_eq(
13704            &many_frontier.len(),
13705            &4,
13706            "multi-frontier trace fixture cardinality",
13707        )?;
13708        for (context, frontier) in [
13709            ("one-frontier adjacency", one_frontier),
13710            ("many-frontier adjacency", many_frontier),
13711        ] {
13712            let (page, statements) = trace_statements(&mut store, |store| {
13713                store.repository_graph_adjacency_page_bounded(
13714                    &frontier,
13715                    RepositoryGraphDirection::Outbound,
13716                    None,
13717                    4,
13718                    budget,
13719                    None,
13720                )
13721            })?;
13722            require(
13723                page.page.rows.len() == 4 && page.page.truncated,
13724                &format!("{context} did not exercise its bounded sentinel"),
13725            )?;
13726            require_traced_statement_multiset(
13727                &statements,
13728                &[
13729                    (ProjectIdentity, 1),
13730                    (PublicationMetadata, 1),
13731                    (ActiveGraphGeneration, 1),
13732                    (AdjacencyRelations, 1),
13733                    (RelationEntities, 1),
13734                ],
13735                context,
13736            )?;
13737        }
13738
13739        let relation_digests = fixture
13740            .relations
13741            .iter()
13742            .map(|relation| relation.key().digest_bytes())
13743            .collect::<Result<Vec<_>, _>>()?;
13744        for (context, digests, endpoint_chunks) in [
13745            ("one-relation hydration", &relation_digests[..1], 1),
13746            (
13747                "128 unique endpoint hydration",
13748                &relation_digests[..GRAPH_ENTITY_HYDRATION_CHUNK - 1],
13749                1,
13750            ),
13751            (
13752                "129 unique endpoint hydration",
13753                &relation_digests[..GRAPH_ENTITY_HYDRATION_CHUNK],
13754                2,
13755            ),
13756            ("maximum relation hydration", relation_digests.as_slice(), 3),
13757        ] {
13758            let (relations, statements) = trace_statements(&mut store, |store| {
13759                store.repository_graph_relation_rows_by_digest(
13760                    fixture.project,
13761                    generation,
13762                    digests,
13763                    budget,
13764                    None,
13765                )
13766            })?;
13767            require_eq(
13768                &relations.rows.len(),
13769                &digests.len(),
13770                &format!("{context} returned relation count"),
13771            )?;
13772            require_traced_statement_multiset(
13773                &statements,
13774                &[
13775                    (ProjectIdentity, 1),
13776                    (PublicationMetadata, 1),
13777                    (ActiveGraphGeneration, 1),
13778                    (RelationsByDigest, 1),
13779                    (RelationEntities, endpoint_chunks),
13780                ],
13781                context,
13782            )?;
13783        }
13784
13785        for (context, relations) in [
13786            ("one-relation occurrences", &fixture.relations[..1]),
13787            (
13788                "maximum relation occurrence batch",
13789                fixture.relations.as_slice(),
13790            ),
13791        ] {
13792            let (pages, statements) = trace_statements(&mut store, |store| {
13793                store.repository_graph_occurrence_pages_bounded(relations, 1, budget, None)
13794            })?;
13795            require(
13796                pages.pages.len() == relations.len()
13797                    && pages
13798                        .pages
13799                        .iter()
13800                        .all(|page| page.rows.len() == 1 && page.truncated),
13801                &format!("{context} did not retain one row plus its occurrence sentinel"),
13802            )?;
13803            require_traced_statement_multiset(
13804                &statements,
13805                &[
13806                    (ProjectIdentity, 1),
13807                    (PublicationMetadata, 1),
13808                    (ActiveGraphGeneration, 1),
13809                    (RelationOccurrences, 1),
13810                ],
13811                context,
13812            )?;
13813        }
13814        let mut oversized_occurrence_batch = fixture.relations.clone();
13815        oversized_occurrence_batch.push(fixture.relations[0].clone());
13816        let oversized_occurrences = require_db_error(
13817            store.repository_graph_occurrence_pages_bounded(
13818                &oversized_occurrence_batch,
13819                1,
13820                budget,
13821                None,
13822            ),
13823            "oversized relation occurrence batch was accepted",
13824        )?;
13825        require(
13826            matches!(oversized_occurrences, DbError::GraphContract(_)),
13827            &format!("unexpected oversized occurrence error: {oversized_occurrences}"),
13828        )?;
13829
13830        let one_purpose_path = vec![".".to_string()];
13831        let one_purpose_chunk = std::iter::once(".".to_string())
13832            .chain(
13833                (1..crate::MAX_PURPOSE_CURATION_BATCH_ROWS)
13834                    .map(|index| format!("missing/purpose-{index}.rs")),
13835            )
13836            .collect::<Vec<_>>();
13837        let two_purpose_chunks = one_purpose_chunk
13838            .iter()
13839            .cloned()
13840            .chain(std::iter::once("missing/purpose-overflow.rs".to_string()))
13841            .collect::<Vec<_>>();
13842        for (context, paths, expected) in [
13843            ("one purpose owner", one_purpose_path.as_slice(), 1),
13844            (
13845                "one full purpose-owner chunk",
13846                one_purpose_chunk.as_slice(),
13847                1,
13848            ),
13849            ("two purpose-owner chunks", two_purpose_chunks.as_slice(), 2),
13850        ] {
13851            let (_purposes, statements) = trace_statements(&mut store, |store| {
13852                store.load_purpose_owner_nodes_by_paths_controlled(
13853                    fixture.project,
13854                    generation,
13855                    paths,
13856                    budget,
13857                    None,
13858                )
13859            })?;
13860            require_traced_statement_multiset(
13861                &statements,
13862                &[
13863                    (ProjectIdentity, 1),
13864                    (PublicationMetadata, 1),
13865                    (ActiveGraphGeneration, 1),
13866                    (PurposeOwners, expected),
13867                ],
13868                context,
13869            )?;
13870        }
13871
13872        store.finish_index_read_snapshot()?;
13873        Ok(())
13874    }
13875
13876    #[test]
13877    fn cursor_hydration_is_ordered_bounded_and_fail_closed() -> Result<(), Box<dyn Error>> {
13878        let temp = tempfile::tempdir()?;
13879        let project_root = temp.path().join("cursor-hydration");
13880        let atlas_dir = project_root.join(".projectatlas");
13881        fs::create_dir_all(&atlas_dir)?;
13882        let db_path = atlas_dir.join("projectatlas.db");
13883        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
13884        let fixture = publish_fixture(&mut writer, "cursor-hydration")?;
13885        writer.connection.execute(
13886            "INSERT INTO graph_relation_occurrences(
13887                 relation_key, file_path, start_line, start_column, end_line, end_column
13888             ) VALUES(?1, 'Cargo.toml', 1, 0, 1, 1)",
13889            params![&fixture.relations[0].key().digest_bytes()?[..]],
13890        )?;
13891        writer.connection.execute(
13892            "INSERT INTO graph_coverage(
13893                 project_instance_id, scope_kind, scope_path, relation_scope,
13894                 relation_kind, state, total, covered, omitted, reason, reached_limit
13895             ) VALUES(?1, 'path', 'Cargo.toml', NULL, NULL,
13896                      'complete', 1, 1, 0, NULL, NULL)",
13897            params![&fixture.project.as_bytes()[..]],
13898        )?;
13899        drop(writer);
13900        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
13901        let generation = store
13902            .repository_graph_generation()?
13903            .ok_or_else(|| io::Error::other("graph generation missing"))?;
13904        let full_budget = RepositoryGraphReadBudget::new(
13905            MAX_REPOSITORY_GRAPH_FRONTIER as u32,
13906            MAX_REPOSITORY_GRAPH_FRONTIER as u32,
13907            RepositoryGraphReadBudget::MAX_DECODED_BYTES,
13908            RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES,
13909            RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
13910        )?;
13911
13912        let expected_entities = fixture
13913            .entities
13914            .iter()
13915            .rev()
13916            .take(3)
13917            .cloned()
13918            .collect::<Vec<_>>();
13919        let entity_digests = expected_entities
13920            .iter()
13921            .map(|entity| entity.key().digest_bytes())
13922            .collect::<Result<Vec<_>, _>>()?;
13923        let entity_batch = store.repository_graph_entities_by_digest(
13924            fixture.project,
13925            generation,
13926            &entity_digests,
13927            full_budget,
13928            None,
13929        )?;
13930        require_eq(
13931            &entity_batch.rows,
13932            &expected_entities,
13933            "ordered entity cursor hydration",
13934        )?;
13935        require_eq(
13936            &entity_batch.work,
13937            &RepositoryGraphReadWork {
13938                requested_rows: 3,
13939                returned_rows: 3,
13940                decoded_bytes: entity_batch.work.decoded_bytes,
13941                hydrated_entities: 3,
13942                hydrated_paths: 2,
13943            },
13944            "exact entity cursor work",
13945        )?;
13946        require(
13947            entity_batch.work.decoded_bytes > 0,
13948            "entity cursor decoded no SQLite payload bytes",
13949        )?;
13950
13951        let file_entity = fixture
13952            .entities
13953            .iter()
13954            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
13955            .ok_or_else(|| io::Error::other("file anchor fixture missing"))?;
13956        let file_anchor = store.repository_graph_entity_bounded(
13957            file_entity.key(),
13958            generation,
13959            full_budget,
13960            None,
13961        )?;
13962        require_eq(
13963            &file_anchor.rows,
13964            &vec![file_entity.clone()],
13965            "exact file anchor hydration",
13966        )?;
13967        require_eq(
13968            &file_anchor.work,
13969            &RepositoryGraphReadWork {
13970                requested_rows: 1,
13971                returned_rows: 1,
13972                decoded_bytes: file_anchor.work.decoded_bytes,
13973                hydrated_entities: 1,
13974                hydrated_paths: 1,
13975            },
13976            "exact file anchor work",
13977        )?;
13978        let exact_file_budget =
13979            RepositoryGraphReadBudget::new(1, 1, file_anchor.work.decoded_bytes, 1, 1)?;
13980        require_eq(
13981            &store.repository_graph_entity_bounded(
13982                file_entity.key(),
13983                generation,
13984                exact_file_budget,
13985                None,
13986            )?,
13987            &file_anchor,
13988            "exact file anchor envelope",
13989        )?;
13990        require_eq(
13991            &store.repository_graph_entity(file_entity.key())?,
13992            &Some(file_entity.clone()),
13993            "legacy file anchor wrapper compatibility",
13994        )?;
13995        let file_decode_overrun = require_db_error(
13996            store.repository_graph_entity_bounded(
13997                file_entity.key(),
13998                generation,
13999                RepositoryGraphReadBudget::new(1, 1, file_anchor.work.decoded_bytes - 1, 1, 1)?,
14000                None,
14001            ),
14002            "file anchor decoded-byte overrun was accepted",
14003        )?;
14004        require(
14005            matches!(file_decode_overrun, DbError::GraphContract(_)),
14006            &format!("unexpected file anchor envelope error: {file_decode_overrun}"),
14007        )?;
14008        let missing_file_selector = EntitySelector::File {
14009            path: RepositoryFilePath::new(Path::new("missing-anchor.rs"))?,
14010        };
14011        let missing_file_key = GraphEntityKey::new(fixture.project, &missing_file_selector);
14012        let missing_file = store.repository_graph_entity_bounded(
14013            &missing_file_key,
14014            generation,
14015            full_budget,
14016            None,
14017        )?;
14018        require(
14019            missing_file.rows.is_empty()
14020                && missing_file.work
14021                    == (RepositoryGraphReadWork {
14022                        requested_rows: 1,
14023                        returned_rows: 0,
14024                        decoded_bytes: 0,
14025                        hydrated_entities: 0,
14026                        hydrated_paths: 0,
14027                    }),
14028            "missing file anchor did not return exact empty work",
14029        )?;
14030
14031        let anchor_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
14032        let path_anchors = store.repository_graph_entities_by_path_bounded(
14033            fixture.project,
14034            generation,
14035            &anchor_path,
14036            1,
14037            full_budget,
14038            None,
14039        )?;
14040        require(
14041            path_anchors.page.rows.len() == 1
14042                && path_anchors.page.truncated
14043                && matches!(
14044                    path_anchors.page.rows[0].selector(),
14045                    EntitySelector::File { .. }
14046                )
14047                && path_anchors.work.requested_rows == 1
14048                && path_anchors.work.returned_rows == 1
14049                && path_anchors.work.decoded_bytes > 0
14050                && path_anchors.work.hydrated_entities == 2
14051                && path_anchors.work.hydrated_paths == 1,
14052            "path anchor page lost stable order or sentinel work",
14053        )?;
14054        let exact_path_anchor_budget =
14055            RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes, 2, 1)?;
14056        require_eq(
14057            &store.repository_graph_entities_by_path_bounded(
14058                fixture.project,
14059                generation,
14060                &anchor_path,
14061                1,
14062                exact_path_anchor_budget,
14063                None,
14064            )?,
14065            &path_anchors,
14066            "exact path anchor envelope",
14067        )?;
14068        require_eq(
14069            &store.repository_graph_entities_by_path(fixture.project, &anchor_path, 1)?,
14070            &path_anchors.page,
14071            "legacy path anchor wrapper compatibility",
14072        )?;
14073        for (budget, limit, context) in [
14074            (
14075                RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes - 1, 2, 1)?,
14076                1,
14077                "path anchor decoded-byte overrun was accepted",
14078            ),
14079            (
14080                RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes, 1, 1)?,
14081                1,
14082                "path anchor sentinel entity overrun was accepted",
14083            ),
14084            (
14085                exact_path_anchor_budget,
14086                2,
14087                "path anchor returned-row overrun was accepted",
14088            ),
14089        ] {
14090            let error = require_db_error(
14091                store.repository_graph_entities_by_path_bounded(
14092                    fixture.project,
14093                    generation,
14094                    &anchor_path,
14095                    limit,
14096                    budget,
14097                    None,
14098                ),
14099                context,
14100            )?;
14101            require(
14102                matches!(error, DbError::GraphContract(_)),
14103                &format!("unexpected path anchor envelope error: {error}"),
14104            )?;
14105        }
14106        let anchor_cancellation = projectatlas_core::IndexCancellation::new();
14107        anchor_cancellation.cancel();
14108        let anchor_control = IndexWorkControl::new(anchor_cancellation, None);
14109        let cancelled_anchor = store.repository_graph_entities_by_path_bounded(
14110            fixture.project,
14111            generation,
14112            &anchor_path,
14113            1,
14114            full_budget,
14115            Some(&anchor_control),
14116        );
14117        require(
14118            matches!(
14119                cancelled_anchor,
14120                Err(DbError::IndexWork(
14121                    projectatlas_core::IndexWorkFailure::Cancelled {
14122                        stage: IndexWorkStage::RepositoryTraversal
14123                    }
14124                ))
14125            ),
14126            "path anchor cancellation was not typed",
14127        )?;
14128        let expired_anchor = IndexWorkControl::with_deadline(
14129            projectatlas_core::IndexCancellation::new(),
14130            Instant::now(),
14131        );
14132        let anchor_deadline = store.repository_graph_entity_bounded(
14133            file_entity.key(),
14134            generation,
14135            full_budget,
14136            Some(&expired_anchor),
14137        );
14138        require(
14139            matches!(
14140                anchor_deadline,
14141                Err(DbError::IndexWork(
14142                    projectatlas_core::IndexWorkFailure::DeadlineExceeded {
14143                        stage: IndexWorkStage::RepositoryTraversal
14144                    }
14145                ))
14146            ),
14147            "file anchor deadline was not typed",
14148        )?;
14149
14150        let expected_relations = fixture.relations.iter().rev().cloned().collect::<Vec<_>>();
14151        let relation_digests = expected_relations
14152            .iter()
14153            .map(|relation| relation.key().digest_bytes())
14154            .collect::<Result<Vec<_>, _>>()?;
14155        let relation_rows = store.repository_graph_relation_rows_by_digest(
14156            fixture.project,
14157            generation,
14158            &relation_digests,
14159            full_budget,
14160            None,
14161        )?;
14162        require_eq(
14163            &relation_rows
14164                .rows
14165                .iter()
14166                .map(|row| row.relation.clone())
14167                .collect::<Vec<_>>(),
14168            &expected_relations,
14169            "ordered relation cursor hydration",
14170        )?;
14171        require(
14172            relation_rows.rows.iter().all(|row| {
14173                row.source.key().project() == fixture.project
14174                    && row
14175                        .target
14176                        .as_ref()
14177                        .is_none_or(|target| target.key().project() == fixture.project)
14178            }),
14179            "relation cursor hydration returned an unvalidated endpoint",
14180        )?;
14181        require_eq(
14182            &relation_rows.work,
14183            &RepositoryGraphReadWork {
14184                requested_rows: 4,
14185                returned_rows: 4,
14186                decoded_bytes: relation_rows.work.decoded_bytes,
14187                hydrated_entities: 3,
14188                hydrated_paths: 1,
14189            },
14190            "exact relation cursor work",
14191        )?;
14192        require(
14193            relation_rows.work.decoded_bytes > entity_batch.work.decoded_bytes,
14194            "relation cursor did not meter relation and endpoint payload bytes",
14195        )?;
14196
14197        let exact_entity_budget =
14198            RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes, 3, 2)?;
14199        require_eq(
14200            &store
14201                .repository_graph_entities_by_digest(
14202                    fixture.project,
14203                    generation,
14204                    &entity_digests,
14205                    exact_entity_budget,
14206                    None,
14207                )?
14208                .work,
14209            &entity_batch.work,
14210            "exact entity envelope",
14211        )?;
14212        let exact_relation_budget =
14213            RepositoryGraphReadBudget::new(4, 4, relation_rows.work.decoded_bytes, 3, 1)?;
14214        require_eq(
14215            &store
14216                .repository_graph_relation_rows_by_digest(
14217                    fixture.project,
14218                    generation,
14219                    &relation_digests,
14220                    exact_relation_budget,
14221                    None,
14222                )?
14223                .work,
14224            &relation_rows.work,
14225            "exact relation envelope",
14226        )?;
14227
14228        for (budget, context) in [
14229            (
14230                RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes - 1, 3, 2)?,
14231                "decoded-byte envelope overrun was accepted",
14232            ),
14233            (
14234                RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes, 3, 1)?,
14235                "purpose-path envelope overrun was accepted",
14236            ),
14237        ] {
14238            let error = require_db_error(
14239                store.repository_graph_entities_by_digest(
14240                    fixture.project,
14241                    generation,
14242                    &entity_digests,
14243                    budget,
14244                    None,
14245                ),
14246                context,
14247            )?;
14248            require(
14249                matches!(error, DbError::GraphContract(_)),
14250                &format!("unexpected entity envelope error: {error}"),
14251            )?;
14252        }
14253        let endpoint_budget =
14254            RepositoryGraphReadBudget::new(4, 4, relation_rows.work.decoded_bytes, 2, 1)?;
14255        let endpoint_error = require_db_error(
14256            store.repository_graph_relation_rows_by_digest(
14257                fixture.project,
14258                generation,
14259                &relation_digests,
14260                endpoint_budget,
14261                None,
14262            ),
14263            "endpoint entity envelope overrun was accepted",
14264        )?;
14265        require(
14266            matches!(endpoint_error, DbError::GraphContract(_)),
14267            &format!("unexpected endpoint envelope error: {endpoint_error}"),
14268        )?;
14269
14270        for (budget, context) in [
14271            (
14272                RepositoryGraphReadBudget::new(
14273                    2,
14274                    3,
14275                    RepositoryGraphReadBudget::MAX_DECODED_BYTES,
14276                    3,
14277                    2,
14278                )?,
14279                "requested-row envelope overrun was accepted",
14280            ),
14281            (
14282                RepositoryGraphReadBudget::new(
14283                    3,
14284                    2,
14285                    RepositoryGraphReadBudget::MAX_DECODED_BYTES,
14286                    3,
14287                    2,
14288                )?,
14289                "returned-row envelope overrun was accepted",
14290            ),
14291        ] {
14292            let error = require_db_error(
14293                store.repository_graph_entities_by_digest(
14294                    fixture.project,
14295                    generation,
14296                    &entity_digests,
14297                    budget,
14298                    None,
14299                ),
14300                context,
14301            )?;
14302            require(
14303                matches!(error, DbError::GraphContract(_)),
14304                &format!("unexpected row-envelope error: {error}"),
14305            )?;
14306        }
14307
14308        for invalid in [
14309            RepositoryGraphReadBudget::new(0, 1, 1, 1, 1),
14310            RepositoryGraphReadBudget::new(
14311                RepositoryGraphReadBudget::MAX_REQUESTED_ROWS + 1,
14312                1,
14313                1,
14314                1,
14315                1,
14316            ),
14317            RepositoryGraphReadBudget::new(
14318                1,
14319                RepositoryGraphReadBudget::MAX_RETURNED_ROWS + 1,
14320                1,
14321                1,
14322                1,
14323            ),
14324            RepositoryGraphReadBudget::new(
14325                1,
14326                1,
14327                RepositoryGraphReadBudget::MAX_DECODED_BYTES + 1,
14328                1,
14329                1,
14330            ),
14331            RepositoryGraphReadBudget::new(
14332                1,
14333                1,
14334                1,
14335                RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES + 1,
14336                1,
14337            ),
14338            RepositoryGraphReadBudget::new(
14339                1,
14340                1,
14341                1,
14342                1,
14343                RepositoryGraphReadBudget::MAX_HYDRATED_PATHS + 1,
14344            ),
14345        ] {
14346            require(
14347                matches!(invalid, Err(GraphContractError::InvalidLimits { .. })),
14348                "invalid graph read budget was accepted",
14349            )?;
14350        }
14351
14352        let empty_entities = store.repository_graph_entities_by_digest(
14353            fixture.project,
14354            generation,
14355            &[],
14356            full_budget,
14357            None,
14358        )?;
14359        let empty_relations = store.repository_graph_relation_rows_by_digest(
14360            fixture.project,
14361            generation,
14362            &[],
14363            full_budget,
14364            None,
14365        )?;
14366        require(
14367            empty_entities.rows.is_empty()
14368                && empty_relations.rows.is_empty()
14369                && empty_entities.work
14370                    == (RepositoryGraphReadWork {
14371                        requested_rows: 0,
14372                        returned_rows: 0,
14373                        decoded_bytes: 0,
14374                        hydrated_entities: 0,
14375                        hydrated_paths: 0,
14376                    })
14377                && empty_relations.work == empty_entities.work,
14378            "empty cursor hydration was not stable",
14379        )?;
14380
14381        let purpose_paths = vec![
14382            "Cargo.toml".to_string(),
14383            ".".to_string(),
14384            "src/Äuth.rs".to_string(),
14385            "src".to_string(),
14386        ];
14387        let purpose_batch = store.load_purpose_owner_nodes_by_paths_controlled(
14388            fixture.project,
14389            generation,
14390            &purpose_paths,
14391            full_budget,
14392            None,
14393        )?;
14394        require_eq(
14395            &purpose_batch
14396                .rows
14397                .iter()
14398                .map(|node| node.node.path.clone())
14399                .collect::<Vec<_>>(),
14400            &purpose_paths,
14401            "ordered purpose-owner hydration",
14402        )?;
14403        require_eq(
14404            &purpose_batch.work,
14405            &RepositoryGraphReadWork {
14406                requested_rows: 4,
14407                returned_rows: 4,
14408                decoded_bytes: purpose_batch.work.decoded_bytes,
14409                hydrated_entities: 0,
14410                hydrated_paths: 4,
14411            },
14412            "exact purpose-owner work",
14413        )?;
14414        require(
14415            purpose_batch.work.decoded_bytes > 0,
14416            "purpose-owner hydration decoded no SQLite payload bytes",
14417        )?;
14418        let exact_purpose_budget =
14419            RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes, 1, 4)?;
14420        require_eq(
14421            &store
14422                .load_purpose_owner_nodes_by_paths_controlled(
14423                    fixture.project,
14424                    generation,
14425                    &purpose_paths,
14426                    exact_purpose_budget,
14427                    None,
14428                )?
14429                .work,
14430            &purpose_batch.work,
14431            "exact purpose-owner envelope",
14432        )?;
14433        for (budget, context) in [
14434            (
14435                RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes - 1, 1, 4)?,
14436                "purpose-owner decoded-byte overrun was accepted",
14437            ),
14438            (
14439                RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes, 1, 3)?,
14440                "purpose-owner path overrun was accepted",
14441            ),
14442        ] {
14443            let error = require_db_error(
14444                store.load_purpose_owner_nodes_by_paths_controlled(
14445                    fixture.project,
14446                    generation,
14447                    &purpose_paths,
14448                    budget,
14449                    None,
14450                ),
14451                context,
14452            )?;
14453            require(
14454                matches!(error, DbError::GraphContract(_)),
14455                &format!("unexpected purpose-owner envelope error: {error}"),
14456            )?;
14457        }
14458        let mut missing_purpose_paths = purpose_paths.clone();
14459        missing_purpose_paths.push("missing/purpose-owner.rs".to_string());
14460        let missing_purpose = store.load_purpose_owner_nodes_by_paths_controlled(
14461            fixture.project,
14462            generation,
14463            &missing_purpose_paths,
14464            full_budget,
14465            None,
14466        )?;
14467        require_eq(
14468            &missing_purpose
14469                .rows
14470                .iter()
14471                .map(|node| node.node.path.clone())
14472                .collect::<Vec<_>>(),
14473            &purpose_paths,
14474            "absent purpose-owner candidate ordering",
14475        )?;
14476        require(
14477            missing_purpose.work.requested_rows == 5
14478                && missing_purpose.work.returned_rows == 4
14479                && missing_purpose.work.hydrated_paths == 4,
14480            "absent purpose-owner candidate work was not exact",
14481        )?;
14482        let duplicate_purpose = require_db_error(
14483            store.load_purpose_owner_nodes_by_paths_controlled(
14484                fixture.project,
14485                generation,
14486                &[purpose_paths[0].clone(), purpose_paths[0].clone()],
14487                full_budget,
14488                None,
14489            ),
14490            "duplicate purpose-owner paths were accepted",
14491        )?;
14492        require(
14493            matches!(duplicate_purpose, DbError::GraphContract(_)),
14494            &format!("unexpected duplicate purpose-owner error: {duplicate_purpose}"),
14495        )?;
14496        let purpose_cancellation = projectatlas_core::IndexCancellation::new();
14497        purpose_cancellation.cancel();
14498        let purpose_control = IndexWorkControl::new(purpose_cancellation, None);
14499        let cancelled_purpose = store.load_purpose_owner_nodes_by_paths_controlled(
14500            fixture.project,
14501            generation,
14502            &purpose_paths,
14503            full_budget,
14504            Some(&purpose_control),
14505        );
14506        require(
14507            matches!(
14508                cancelled_purpose,
14509                Err(DbError::IndexWork(
14510                    projectatlas_core::IndexWorkFailure::Cancelled {
14511                        stage: IndexWorkStage::RepositoryTraversal
14512                    }
14513                ))
14514            ),
14515            "purpose-owner cancellation was not typed",
14516        )?;
14517
14518        let occurrence_batch = store.repository_graph_occurrence_pages_bounded(
14519            &fixture.relations,
14520            1,
14521            full_budget,
14522            None,
14523        )?;
14524        require(
14525            occurrence_batch.pages.len() == fixture.relations.len()
14526                && occurrence_batch.pages[0].rows.len() == 1
14527                && occurrence_batch.pages[0].truncated
14528                && occurrence_batch.pages[1..]
14529                    .iter()
14530                    .all(|page| page.rows.is_empty() && !page.truncated),
14531            "batched occurrence pages lost owner order or truncation state",
14532        )?;
14533        require_eq(
14534            &occurrence_batch.work,
14535            &RepositoryGraphReadWork {
14536                requested_rows: 4,
14537                returned_rows: 1,
14538                decoded_bytes: occurrence_batch.work.decoded_bytes,
14539                hydrated_entities: 0,
14540                hydrated_paths: 2,
14541            },
14542            "exact occurrence batch work including sentinel path",
14543        )?;
14544        let exact_occurrence_budget =
14545            RepositoryGraphReadBudget::new(4, 1, occurrence_batch.work.decoded_bytes, 1, 2)?;
14546        require_eq(
14547            &store.repository_graph_occurrence_pages_bounded(
14548                &fixture.relations,
14549                1,
14550                exact_occurrence_budget,
14551                None,
14552            )?,
14553            &occurrence_batch,
14554            "exact occurrence envelope",
14555        )?;
14556        require_eq(
14557            &store.repository_graph_occurrence_pages(&fixture.relations, 1, None)?,
14558            &occurrence_batch.pages,
14559            "legacy occurrence wrapper compatibility",
14560        )?;
14561        for (budget, limit, context) in [
14562            (
14563                RepositoryGraphReadBudget::new(
14564                    4,
14565                    1,
14566                    occurrence_batch.work.decoded_bytes - 1,
14567                    1,
14568                    2,
14569                )?,
14570                1,
14571                "occurrence decoded-byte overrun was accepted",
14572            ),
14573            (
14574                RepositoryGraphReadBudget::new(4, 1, occurrence_batch.work.decoded_bytes, 1, 1)?,
14575                1,
14576                "occurrence sentinel path overrun was accepted",
14577            ),
14578            (
14579                RepositoryGraphReadBudget::new(
14580                    4,
14581                    1,
14582                    RepositoryGraphReadBudget::MAX_DECODED_BYTES,
14583                    1,
14584                    RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
14585                )?,
14586                3,
14587                "occurrence returned-row overrun was accepted",
14588            ),
14589        ] {
14590            let error = require_db_error(
14591                store.repository_graph_occurrence_pages_bounded(
14592                    &fixture.relations,
14593                    limit,
14594                    budget,
14595                    None,
14596                ),
14597                context,
14598            )?;
14599            require(
14600                matches!(error, DbError::GraphContract(_)),
14601                &format!("unexpected occurrence envelope error: {error}"),
14602            )?;
14603        }
14604        let occurrence_cancellation = projectatlas_core::IndexCancellation::new();
14605        occurrence_cancellation.cancel();
14606        let occurrence_control = IndexWorkControl::new(occurrence_cancellation, None);
14607        let cancelled_occurrences = store.repository_graph_occurrence_pages_bounded(
14608            &fixture.relations,
14609            1,
14610            full_budget,
14611            Some(&occurrence_control),
14612        );
14613        require(
14614            matches!(
14615                cancelled_occurrences,
14616                Err(DbError::IndexWork(
14617                    projectatlas_core::IndexWorkFailure::Cancelled {
14618                        stage: IndexWorkStage::RepositoryTraversal
14619                    }
14620                ))
14621            ),
14622            "occurrence batch cancellation was not typed",
14623        )?;
14624        let expired_occurrences = IndexWorkControl::with_deadline(
14625            projectatlas_core::IndexCancellation::new(),
14626            Instant::now(),
14627        );
14628        let occurrence_deadline = store.repository_graph_occurrence_pages_bounded(
14629            &fixture.relations,
14630            1,
14631            full_budget,
14632            Some(&expired_occurrences),
14633        );
14634        require(
14635            matches!(
14636                occurrence_deadline,
14637                Err(DbError::IndexWork(
14638                    projectatlas_core::IndexWorkFailure::DeadlineExceeded {
14639                        stage: IndexWorkStage::RepositoryTraversal
14640                    }
14641                ))
14642            ),
14643            "occurrence batch deadline was not typed",
14644        )?;
14645
14646        let coverage_paths = vec![
14647            RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
14648            RepositoryNodePath::new(Path::new("Cargo.toml"))?,
14649        ];
14650        let coverage_batch = store.repository_graph_path_coverage_bounded(
14651            fixture.project,
14652            generation,
14653            &coverage_paths,
14654            full_budget,
14655            None,
14656        )?;
14657        require_eq(
14658            &coverage_batch
14659                .page
14660                .rows
14661                .iter()
14662                .map(|coverage| match coverage.scope() {
14663                    CoverageScope::Path { path } => path.as_str().to_string(),
14664                    CoverageScope::Project => "project".to_string(),
14665                })
14666                .collect::<Vec<_>>(),
14667            &vec!["Cargo.toml".to_string(), "src/Äuth.rs".to_string()],
14668            "stable path coverage order",
14669        )?;
14670        require(
14671            !coverage_batch.page.truncated
14672                && coverage_batch.work
14673                    == (RepositoryGraphReadWork {
14674                        requested_rows: 2,
14675                        returned_rows: 2,
14676                        decoded_bytes: coverage_batch.work.decoded_bytes,
14677                        hydrated_entities: 0,
14678                        hydrated_paths: 2,
14679                    })
14680                && coverage_batch.work.decoded_bytes > 0,
14681            "exact coverage batch work was incomplete",
14682        )?;
14683        let exact_coverage_budget =
14684            RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes, 1, 2)?;
14685        require_eq(
14686            &store.repository_graph_path_coverage_bounded(
14687                fixture.project,
14688                generation,
14689                &coverage_paths,
14690                exact_coverage_budget,
14691                None,
14692            )?,
14693            &coverage_batch,
14694            "exact coverage envelope",
14695        )?;
14696        require_eq(
14697            &store.repository_graph_path_coverage(fixture.project, &coverage_paths, None)?,
14698            &coverage_batch.page,
14699            "legacy coverage wrapper compatibility",
14700        )?;
14701        for (budget, context) in [
14702            (
14703                RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes - 1, 1, 2)?,
14704                "coverage decoded-byte overrun was accepted",
14705            ),
14706            (
14707                RepositoryGraphReadBudget::new(2, 1, coverage_batch.work.decoded_bytes, 1, 2)?,
14708                "coverage returned-row overrun was accepted",
14709            ),
14710            (
14711                RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes, 1, 1)?,
14712                "coverage hydrated-path overrun was accepted",
14713            ),
14714        ] {
14715            let error = require_db_error(
14716                store.repository_graph_path_coverage_bounded(
14717                    fixture.project,
14718                    generation,
14719                    &coverage_paths,
14720                    budget,
14721                    None,
14722                ),
14723                context,
14724            )?;
14725            require(
14726                matches!(error, DbError::GraphContract(_)),
14727                &format!("unexpected coverage envelope error: {error}"),
14728            )?;
14729        }
14730        let coverage_cancellation = projectatlas_core::IndexCancellation::new();
14731        coverage_cancellation.cancel();
14732        let coverage_control = IndexWorkControl::new(coverage_cancellation, None);
14733        let cancelled_coverage = store.repository_graph_path_coverage_bounded(
14734            fixture.project,
14735            generation,
14736            &coverage_paths,
14737            full_budget,
14738            Some(&coverage_control),
14739        );
14740        require(
14741            matches!(
14742                cancelled_coverage,
14743                Err(DbError::IndexWork(
14744                    projectatlas_core::IndexWorkFailure::Cancelled {
14745                        stage: IndexWorkStage::RepositoryTraversal
14746                    }
14747                ))
14748            ),
14749            "coverage batch cancellation was not typed",
14750        )?;
14751        let expired_coverage = IndexWorkControl::with_deadline(
14752            projectatlas_core::IndexCancellation::new(),
14753            Instant::now(),
14754        );
14755        let coverage_deadline = store.repository_graph_path_coverage_bounded(
14756            fixture.project,
14757            generation,
14758            &coverage_paths,
14759            full_budget,
14760            Some(&expired_coverage),
14761        );
14762        require(
14763            matches!(
14764                coverage_deadline,
14765                Err(DbError::IndexWork(
14766                    projectatlas_core::IndexWorkFailure::DeadlineExceeded {
14767                        stage: IndexWorkStage::RepositoryTraversal
14768                    }
14769                ))
14770            ),
14771            "coverage batch deadline was not typed",
14772        )?;
14773
14774        let missing = [0xff; 32];
14775        let missing_entity = require_db_error(
14776            store.repository_graph_entities_by_digest(
14777                fixture.project,
14778                generation,
14779                &[entity_digests[0], missing],
14780                full_budget,
14781                None,
14782            ),
14783            "missing entity cursor key returned a partial set",
14784        )?;
14785        require(
14786            matches!(
14787                missing_entity,
14788                DbError::GraphRowShape {
14789                    table: "graph_entities",
14790                    ..
14791                }
14792            ),
14793            &format!("unexpected missing-entity error: {missing_entity}"),
14794        )?;
14795        let missing_relation = require_db_error(
14796            store.repository_graph_relation_rows_by_digest(
14797                fixture.project,
14798                generation,
14799                &[relation_digests[0], missing],
14800                full_budget,
14801                None,
14802            ),
14803            "missing relation cursor key returned a partial set",
14804        )?;
14805        require(
14806            matches!(
14807                missing_relation,
14808                DbError::GraphRowShape {
14809                    table: "graph_relations",
14810                    ..
14811                }
14812            ),
14813            &format!("unexpected missing-relation error: {missing_relation}"),
14814        )?;
14815
14816        let duplicate = require_db_error(
14817            store.repository_graph_entities_by_digest(
14818                fixture.project,
14819                generation,
14820                &[entity_digests[0], entity_digests[0]],
14821                full_budget,
14822                None,
14823            ),
14824            "duplicate entity cursor keys were accepted",
14825        )?;
14826        require(
14827            matches!(duplicate, DbError::GraphContract(_)),
14828            &format!("unexpected duplicate hydration error: {duplicate}"),
14829        )?;
14830        let oversized = vec![[0; 32]; MAX_REPOSITORY_GRAPH_FRONTIER + 1];
14831        let oversized = require_db_error(
14832            store.repository_graph_relation_rows_by_digest(
14833                fixture.project,
14834                generation,
14835                &oversized,
14836                full_budget,
14837                None,
14838            ),
14839            "oversized relation cursor key set was accepted",
14840        )?;
14841        require(
14842            matches!(oversized, DbError::GraphContract(_)),
14843            &format!("unexpected oversized hydration error: {oversized}"),
14844        )?;
14845
14846        let foreign_project = ProjectInstanceId::from_bytes([0x7f; 16])?;
14847        let foreign = require_db_error(
14848            store.repository_graph_entities_by_digest(
14849                foreign_project,
14850                generation,
14851                &entity_digests,
14852                full_budget,
14853                None,
14854            ),
14855            "cross-project entity cursor hydration was accepted",
14856        )?;
14857        require(
14858            matches!(foreign, DbError::GraphProjectIdentityMismatch { .. }),
14859            &format!("unexpected cross-project hydration error: {foreign}"),
14860        )?;
14861        let stale_generation = generation
14862            .checked_next()
14863            .ok_or_else(|| io::Error::other("fixture generation overflowed"))?;
14864        let stale = require_db_error(
14865            store.repository_graph_relation_rows_by_digest(
14866                fixture.project,
14867                stale_generation,
14868                &relation_digests,
14869                full_budget,
14870                None,
14871            ),
14872            "stale-generation relation cursor hydration was accepted",
14873        )?;
14874        require(
14875            matches!(stale, DbError::GraphContract(_)),
14876            &format!("unexpected stale-generation hydration error: {stale}"),
14877        )?;
14878        for entities in [true, false] {
14879            let cancellation = projectatlas_core::IndexCancellation::new();
14880            cancellation.cancel();
14881            let control = IndexWorkControl::new(cancellation, None);
14882            let cancelled = if entities {
14883                store
14884                    .repository_graph_entities_by_digest(
14885                        fixture.project,
14886                        generation,
14887                        &entity_digests,
14888                        full_budget,
14889                        Some(&control),
14890                    )
14891                    .map(|_| ())
14892            } else {
14893                store
14894                    .repository_graph_relation_rows_by_digest(
14895                        fixture.project,
14896                        generation,
14897                        &relation_digests,
14898                        full_budget,
14899                        Some(&control),
14900                    )
14901                    .map(|_| ())
14902            };
14903            require(
14904                matches!(
14905                    cancelled,
14906                    Err(DbError::IndexWork(
14907                        projectatlas_core::IndexWorkFailure::Cancelled {
14908                            stage: IndexWorkStage::RepositoryTraversal
14909                        }
14910                    ))
14911                ),
14912                "cursor hydration cancellation was not typed",
14913            )?;
14914        }
14915        let expired = IndexWorkControl::with_deadline(
14916            projectatlas_core::IndexCancellation::new(),
14917            Instant::now(),
14918        );
14919        let deadline = store.repository_graph_entities_by_digest(
14920            fixture.project,
14921            generation,
14922            &entity_digests,
14923            full_budget,
14924            Some(&expired),
14925        );
14926        require(
14927            matches!(
14928                deadline,
14929                Err(DbError::IndexWork(
14930                    projectatlas_core::IndexWorkFailure::DeadlineExceeded {
14931                        stage: IndexWorkStage::RepositoryTraversal
14932                    }
14933                ))
14934            ),
14935            "cursor hydration deadline was not typed",
14936        )?;
14937
14938        let source = fixture
14939            .entities
14940            .iter()
14941            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
14942            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
14943        let adjacency = store.repository_graph_adjacency_page_bounded(
14944            &[source.key().clone()],
14945            RepositoryGraphDirection::Outbound,
14946            None,
14947            1,
14948            full_budget,
14949            None,
14950        )?;
14951        require(
14952            adjacency.page.rows.len() == 1
14953                && adjacency.page.truncated
14954                && adjacency.work.requested_rows == 1
14955                && adjacency.work.returned_rows == 1
14956                && adjacency.work.decoded_bytes > 0
14957                && adjacency.work.hydrated_entities > 0
14958                && adjacency.work.hydrated_paths > 0,
14959            "bounded adjacency page omitted exact raw or endpoint work",
14960        )?;
14961        let exact_adjacency_budget = RepositoryGraphReadBudget::new(
14962            1,
14963            1,
14964            adjacency.work.decoded_bytes,
14965            adjacency.work.hydrated_entities,
14966            adjacency.work.hydrated_paths,
14967        )?;
14968        require_eq(
14969            &store.repository_graph_adjacency_page_bounded(
14970                &[source.key().clone()],
14971                RepositoryGraphDirection::Outbound,
14972                None,
14973                1,
14974                exact_adjacency_budget,
14975                None,
14976            )?,
14977            &adjacency,
14978            "exact adjacency envelope",
14979        )?;
14980        let adjacency_overrun = require_db_error(
14981            store.repository_graph_adjacency_page_bounded(
14982                &[source.key().clone()],
14983                RepositoryGraphDirection::Outbound,
14984                None,
14985                1,
14986                RepositoryGraphReadBudget::new(
14987                    1,
14988                    1,
14989                    adjacency.work.decoded_bytes - 1,
14990                    adjacency.work.hydrated_entities,
14991                    adjacency.work.hydrated_paths,
14992                )?,
14993                None,
14994            ),
14995            "adjacency decoded-byte overrun was accepted",
14996        )?;
14997        require(
14998            matches!(adjacency_overrun, DbError::GraphContract(_)),
14999            &format!("unexpected adjacency envelope error: {adjacency_overrun}"),
15000        )?;
15001        let adjacency_return_limit = require_db_error(
15002            store.repository_graph_adjacency_page_bounded(
15003                &[source.key().clone()],
15004                RepositoryGraphDirection::Outbound,
15005                None,
15006                2,
15007                exact_adjacency_budget,
15008                None,
15009            ),
15010            "adjacency page limit exceeded the return budget",
15011        )?;
15012        require(
15013            matches!(adjacency_return_limit, DbError::GraphContract(_)),
15014            &format!("unexpected adjacency return-budget error: {adjacency_return_limit}"),
15015        )?;
15016        let continuation = adjacency
15017            .page
15018            .continuation
15019            .ok_or_else(|| io::Error::other("adjacency continuation missing"))?;
15020        let encoded =
15021            serde_json::to_vec(&(RepositoryGraphDirection::Outbound, continuation.clone()))?;
15022        let decoded: (
15023            RepositoryGraphDirection,
15024            RepositoryGraphAdjacencyContinuation,
15025        ) = serde_json::from_slice(&encoded)?;
15026        require_eq(
15027            &decoded,
15028            &(RepositoryGraphDirection::Outbound, continuation),
15029            "opaque relation cursor serde round trip",
15030        )?;
15031
15032        assert_cursor_hydration_indexes(&store)?;
15033        store.finish_index_read_snapshot()?;
15034        Ok(())
15035    }
15036
15037    #[test]
15038    fn continued_adjacency_seeks_past_high_degree_prefixes() -> Result<(), Box<dyn Error>> {
15039        let temp = tempfile::tempdir()?;
15040        let project_root = temp.path().join("continued-adjacency-work");
15041        let atlas_dir = project_root.join(".projectatlas");
15042        fs::create_dir_all(&atlas_dir)?;
15043        let db_path = atlas_dir.join("projectatlas.db");
15044        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
15045        let fixture = publish_fixture(&mut writer, "continued-adjacency-work")?;
15046        let source = fixture
15047            .entities
15048            .iter()
15049            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
15050            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
15051        let external = fixture
15052            .entities
15053            .iter()
15054            .find(|entity| matches!(entity.selector(), EntitySelector::External { .. }))
15055            .ok_or_else(|| io::Error::other("external fixture missing"))?;
15056        writer.connection.execute(
15057            "WITH RECURSIVE sequence(value) AS (
15058                 VALUES(1)
15059                 UNION ALL
15060                 SELECT value + 1 FROM sequence WHERE value < 100000
15061             )
15062             INSERT INTO graph_relations(
15063                 relation_key, project_instance_id, canonical_identity,
15064                 source_entity_key, relation_scope, relation_kind,
15065                 resolution_status, target_entity_key, reference_text,
15066                 candidate_count, confidence, completeness
15067             )
15068             SELECT CAST(printf('%032d', 1000000 + value) AS BLOB),
15069                    ?1, printf('perf-%06d', value), ?2,
15070                    'legacy', 'calls', 'resolved', ?3, NULL, NULL,
15071                    'exact', 'complete'
15072               FROM sequence",
15073            params![
15074                fixture.project.as_bytes().as_slice(),
15075                source.key().digest_bytes()?.as_slice(),
15076                external.key().digest_bytes()?.as_slice(),
15077            ],
15078        )?;
15079        drop(writer);
15080
15081        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15082        let generation = store
15083            .repository_graph_generation()?
15084            .ok_or_else(|| io::Error::other("graph generation missing"))?;
15085        let source_digest = source.key().digest_bytes()?;
15086        let external_digest = external.key().digest_bytes()?;
15087        let skipped_frontier = vec![source.key().clone(), external.key().clone()];
15088        let skipped_continuation = RepositoryGraphAdjacencyContinuation {
15089            project: fixture.project,
15090            generation,
15091            direction: RepositoryGraphDirection::Outbound,
15092            relation: None,
15093            resolved_only: false,
15094            include_documents: false,
15095            frontier: vec![source_digest, external_digest],
15096            frontier_index: 1,
15097            relation_scope: String::new(),
15098            relation_kind: String::new(),
15099            relation_key: [0; 32],
15100        };
15101        let skipped_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
15102        let skipped_counter = std::sync::Arc::clone(&skipped_steps);
15103        store.connection.progress_handler(
15104            1_000,
15105            Some(move || {
15106                skipped_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
15107                false
15108            }),
15109        )?;
15110        let skipped_result = store.repository_graph_adjacency_page(
15111            &skipped_frontier,
15112            RepositoryGraphDirection::Outbound,
15113            Some(&skipped_continuation),
15114            2,
15115            None,
15116        );
15117        store.connection.progress_handler(0, None::<fn() -> bool>)?;
15118        skipped_result?;
15119        require(
15120            skipped_steps.load(std::sync::atomic::Ordering::Relaxed) < 100_000,
15121            "continued adjacency scanned a completed high-degree frontier branch",
15122        )?;
15123
15124        let last_key: [u8; 32] = format!("{:032}", 1_100_000)
15125            .into_bytes()
15126            .try_into()
15127            .map_err(|_source| io::Error::other("high-degree cursor key width changed"))?;
15128        let deep_continuation = RepositoryGraphAdjacencyContinuation {
15129            project: fixture.project,
15130            generation,
15131            direction: RepositoryGraphDirection::Outbound,
15132            relation: None,
15133            resolved_only: false,
15134            include_documents: false,
15135            frontier: vec![source_digest],
15136            frontier_index: 0,
15137            relation_scope: "legacy".to_string(),
15138            relation_kind: "calls".to_string(),
15139            relation_key: last_key,
15140        };
15141        let deep_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
15142        let deep_counter = std::sync::Arc::clone(&deep_steps);
15143        store.connection.progress_handler(
15144            1_000,
15145            Some(move || {
15146                deep_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
15147                false
15148            }),
15149        )?;
15150        let deep_result = store.repository_graph_adjacency_page(
15151            &[source.key().clone()],
15152            RepositoryGraphDirection::Outbound,
15153            Some(&deep_continuation),
15154            2,
15155            None,
15156        );
15157        store.connection.progress_handler(0, None::<fn() -> bool>)?;
15158        deep_result?;
15159        require(
15160            deep_steps.load(std::sync::atomic::Ordering::Relaxed) < 100_000,
15161            "continued adjacency rescanned a high-degree keyset prefix",
15162        )?;
15163        store.finish_index_read_snapshot()?;
15164        Ok(())
15165    }
15166
15167    #[test]
15168    fn bounded_rust_frontier_matches_indexed_recursive_cte_on_cycles_and_high_degree()
15169    -> Result<(), Box<dyn Error>> {
15170        const HIGH_DEGREE: usize = 4_096;
15171        const RECURSIVE_CTE: &str = "WITH RECURSIVE walk(entity_key) AS (
15172                VALUES(?2)
15173                UNION
15174                SELECT relation.target_entity_key
15175                  FROM graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
15176                  JOIN walk ON relation.source_entity_key = walk.entity_key
15177                 WHERE relation.project_instance_id = ?1
15178                   AND relation.relation_scope = 'legacy'
15179                   AND relation.relation_kind = 'calls'
15180                   AND relation.target_entity_key IS NOT NULL
15181            )
15182            SELECT entity_key FROM walk ORDER BY entity_key";
15183
15184        let temp = tempfile::tempdir()?;
15185        let project_root = temp.path().join("frontier-cte-comparison");
15186        let atlas_dir = project_root.join(".projectatlas");
15187        fs::create_dir_all(&atlas_dir)?;
15188        let db_path = atlas_dir.join("projectatlas.db");
15189        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
15190        let fixture = publish_fixture(&mut writer, "frontier-cte-comparison")?;
15191        let source = fixture
15192            .entities
15193            .iter()
15194            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
15195            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
15196        let generation = source.generation();
15197        let mut high_degree_entities = Vec::with_capacity(HIGH_DEGREE);
15198        let mut cyclic_relations = Vec::with_capacity(HIGH_DEGREE * 2);
15199        for index in 0..HIGH_DEGREE {
15200            let target = GraphEntity::new(
15201                fixture.project,
15202                EntitySelector::External {
15203                    external: ExternalSelector {
15204                        system: GraphIdentityText::new("frontier-measurement")?,
15205                        identity: GraphIdentityText::new(format!("node-{index:05}"))?,
15206                    },
15207                },
15208                generation,
15209            )?;
15210            cyclic_relations.push(LogicalRelation::new(
15211                source,
15212                GraphRelationKind::Legacy(RelationKind::Calls),
15213                RelationResolution::external(&target)?,
15214                ConfidenceClass::Exact,
15215                Completeness::Complete,
15216                generation,
15217            )?);
15218            cyclic_relations.push(LogicalRelation::new(
15219                &target,
15220                GraphRelationKind::Legacy(RelationKind::Calls),
15221                RelationResolution::resolved(source)?,
15222                ConfidenceClass::Exact,
15223                Completeness::Complete,
15224                generation,
15225            )?);
15226            high_degree_entities.push(target);
15227        }
15228        let transaction = writer.connection.transaction()?;
15229        insert_entities(&transaction, fixture.project, &high_degree_entities)?;
15230        insert_relations(&transaction, fixture.project, &cyclic_relations)?;
15231        transaction.commit()?;
15232        drop(writer);
15233
15234        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15235        let project = fixture.project.as_bytes();
15236        let source_key = source.key().digest_bytes()?;
15237        let plan_sql = format!("EXPLAIN QUERY PLAN {RECURSIVE_CTE}");
15238        let cte_plan = store
15239            .connection
15240            .prepare(&plan_sql)?
15241            .query_map(params![&project[..], &source_key[..]], |row| {
15242                row.get::<_, String>(3)
15243            })?
15244            .collect::<Result<Vec<_>, _>>()?;
15245        require(
15246            cte_plan
15247                .iter()
15248                .any(|detail| detail.contains("idx_graph_relations_source_kind"))
15249                && cte_plan
15250                    .iter()
15251                    .all(|detail| !detail.contains("SCAN relation")),
15252            &format!("recursive CTE did not retain the source-owned index: {cte_plan:?}"),
15253        )?;
15254
15255        let cte_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
15256        let cte_counter = std::sync::Arc::clone(&cte_steps);
15257        store.connection.progress_handler(
15258            1_000,
15259            Some(move || {
15260                cte_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
15261                false
15262            }),
15263        )?;
15264        let cte_started = Instant::now();
15265        let cte_result = store
15266            .connection
15267            .prepare(RECURSIVE_CTE)?
15268            .query_map(params![&project[..], &source_key[..]], |row| {
15269                row.get::<_, Vec<u8>>(0)
15270            })?
15271            .map(|row| fixed_bytes::<32>("recursive_cte.entity_key", row?))
15272            .collect::<DbResult<Vec<_>>>();
15273        let cte_elapsed = cte_started.elapsed();
15274        store.connection.progress_handler(0, None::<fn() -> bool>)?;
15275        let cte_nodes = cte_result?;
15276
15277        let rust_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
15278        let rust_counter = std::sync::Arc::clone(&rust_steps);
15279        store.connection.progress_handler(
15280            1_000,
15281            Some(move || {
15282                rust_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
15283                false
15284            }),
15285        )?;
15286        let rust_started = Instant::now();
15287        let rust_result = collect_bounded_outbound_calls(&store, source.key());
15288        let rust_elapsed = rust_started.elapsed();
15289        store.connection.progress_handler(0, None::<fn() -> bool>)?;
15290        let (rust_nodes, inspected_edges, peak_frontier) = rust_result?;
15291        let (repeated_nodes, repeated_edges, repeated_peak) =
15292            collect_bounded_outbound_calls(&store, source.key())?;
15293
15294        require_eq(
15295            &rust_nodes,
15296            &cte_nodes,
15297            "Rust frontier versus recursive CTE topology",
15298        )?;
15299        require_eq(
15300            &repeated_nodes,
15301            &rust_nodes,
15302            "deterministic Rust frontier order",
15303        )?;
15304        require_eq(
15305            &repeated_edges,
15306            &inspected_edges,
15307            "deterministic inspected edges",
15308        )?;
15309        require_eq(
15310            &repeated_peak,
15311            &peak_frontier,
15312            "deterministic peak frontier",
15313        )?;
15314        require_eq(
15315            &rust_nodes.len(),
15316            &(HIGH_DEGREE + 2),
15317            "cycle-safe high-degree topology",
15318        )?;
15319        require_eq(
15320            &inspected_edges,
15321            &(HIGH_DEGREE * 2 + 1),
15322            "bounded frontier inspected every call edge once",
15323        )?;
15324        require(
15325            cte_steps.load(std::sync::atomic::Ordering::Relaxed) > 0
15326                && rust_steps.load(std::sync::atomic::Ordering::Relaxed) > 0
15327                && cte_elapsed > Duration::ZERO
15328                && rust_elapsed > Duration::ZERO,
15329            "frontier comparison did not record VM work and elapsed time",
15330        )?;
15331        let retained_key_bytes = rust_nodes
15332            .len()
15333            .checked_add(peak_frontier)
15334            .and_then(|keys| keys.checked_mul(std::mem::size_of::<[u8; 32]>()))
15335            .ok_or_else(|| io::Error::other("retained key measurement overflowed"))?;
15336        let output_key_bytes = rust_nodes
15337            .len()
15338            .checked_mul(std::mem::size_of::<[u8; 32]>())
15339            .ok_or_else(|| io::Error::other("output key measurement overflowed"))?;
15340        require(
15341            retained_key_bytes <= GraphLimits::MAX_OUTPUT_BYTES as usize
15342                && output_key_bytes <= GraphLimits::MAX_OUTPUT_BYTES as usize,
15343            "bounded Rust frontier exceeded the shared compact byte ceiling",
15344        )?;
15345
15346        store.connection.progress_handler(1, Some(|| true))?;
15347        let cancelled_cte = (|| -> Result<Vec<Vec<u8>>, rusqlite::Error> {
15348            let mut statement = store.connection.prepare(RECURSIVE_CTE)?;
15349            statement
15350                .query_map(params![&project[..], &source_key[..]], |row| {
15351                    row.get::<_, Vec<u8>>(0)
15352                })?
15353                .collect()
15354        })();
15355        store.connection.progress_handler(0, None::<fn() -> bool>)?;
15356        require(
15357            matches!(
15358                cancelled_cte,
15359                Err(rusqlite::Error::SqliteFailure(ref failure, _))
15360                    if failure.code == rusqlite::ErrorCode::OperationInterrupted
15361            ),
15362            "recursive CTE did not stop through the SQLite progress handler",
15363        )?;
15364        let cancellation = projectatlas_core::IndexCancellation::new();
15365        cancellation.cancel();
15366        let control = IndexWorkControl::new(cancellation, None);
15367        let cancelled_rust = store.repository_graph_adjacency_page_filtered(
15368            &[source.key().clone()],
15369            RepositoryGraphDirection::Outbound,
15370            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
15371            None,
15372            1,
15373            Some(&control),
15374        );
15375        require(
15376            matches!(
15377                cancelled_rust,
15378                Err(DbError::IndexWork(
15379                    projectatlas_core::IndexWorkFailure::Cancelled {
15380                        stage: IndexWorkStage::RepositoryTraversal
15381                    }
15382                ))
15383            ),
15384            "bounded Rust frontier did not retain typed cancellation",
15385        )?;
15386        store.finish_index_read_snapshot()?;
15387        Ok(())
15388    }
15389
15390    #[cfg(feature = "sqlite-progress-test-observer")]
15391    #[test]
15392    fn zero_candidate_coverage_discovery_is_cancellable_at_scale() -> Result<(), Box<dyn Error>> {
15393        use crate::sqlite_progress_test_observer::{
15394            SqliteReadProgressEvent, observe_sqlite_read_progress,
15395        };
15396        use std::cell::Cell;
15397        use std::rc::Rc;
15398
15399        const POSITIVE_DOCUMENT_ROWS: usize = 100_000;
15400
15401        let temp = tempfile::tempdir()?;
15402        let project_root = temp.path().join("coverage-zero-candidate-scale");
15403        let atlas_dir = project_root.join(".projectatlas");
15404        fs::create_dir_all(&atlas_dir)?;
15405        let db_path = atlas_dir.join("projectatlas.db");
15406        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
15407        let fixture = publish_fixture(&mut writer, "coverage-zero-candidate-scale")?;
15408        writer.connection.execute(
15409            "DELETE FROM graph_coverage
15410              WHERE relation_scope = 'extended'
15411                AND relation_kind = 'documents'
15412                AND state = 'complete' AND total = 0",
15413            [],
15414        )?;
15415        writer.connection.execute(
15416            "WITH RECURSIVE sequence(value) AS (
15417                 VALUES(1)
15418                 UNION ALL
15419                 SELECT value + 1 FROM sequence WHERE value < ?2
15420             )
15421             INSERT INTO graph_coverage(
15422                 project_instance_id, scope_kind, scope_path,
15423                 relation_scope, relation_kind, state,
15424                 total, covered, omitted, reason, reached_limit
15425             )
15426             SELECT ?1, 'path', printf('src/perf-%06d.rs', value),
15427                    'extended', 'documents', 'complete',
15428                    1, 1, 0, NULL, NULL
15429               FROM sequence",
15430            params![
15431                fixture.project.as_bytes().as_slice(),
15432                i64::try_from(POSITIVE_DOCUMENT_ROWS)?,
15433            ],
15434        )?;
15435        writer.connection.execute(
15436            "INSERT INTO graph_coverage(
15437                 project_instance_id, scope_kind, scope_path,
15438                 relation_scope, relation_kind, state,
15439                 total, covered, omitted, reason, reached_limit
15440             ) VALUES (?1, 'path', 'docs/empty.md',
15441                       'extended', 'documents', 'complete',
15442                       0, 0, 0, NULL, NULL)",
15443            params![fixture.project.as_bytes().as_slice()],
15444        )?;
15445        drop(writer);
15446
15447        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15448        let cancellation = projectatlas_core::IndexCancellation::new();
15449        let control = IndexWorkControl::new(cancellation.clone(), None);
15450        let inspected_steps = Rc::new(Cell::new(0_usize));
15451        let page = observe_sqlite_read_progress(
15452            {
15453                let inspected_steps = Rc::clone(&inspected_steps);
15454                move |event| {
15455                    if matches!(
15456                        event,
15457                        SqliteReadProgressEvent::CallbackEntered {
15458                            stage: IndexWorkStage::RepositoryTraversal
15459                        }
15460                    ) {
15461                        inspected_steps.set(inspected_steps.get().saturating_add(1_000));
15462                        cancellation.cancel();
15463                    }
15464                }
15465            },
15466            || {
15467                store.repository_coverage_page_controlled(
15468                    fixture.project,
15469                    &RepositoryCoverageQuery {
15470                        start_index: 0,
15471                        limit: 1,
15472                        path_prefix: None,
15473                        parser: None,
15474                        provider: None,
15475                        relation: None,
15476                        state: Some(CoverageState::NoCandidates),
15477                        reason: None,
15478                    },
15479                    Some(&control),
15480                )
15481            },
15482        );
15483        require(
15484            matches!(
15485                page,
15486                Err(DbError::IndexWork(
15487                    projectatlas_core::IndexWorkFailure::Cancelled {
15488                        stage: IndexWorkStage::RepositoryTraversal
15489                    }
15490                ))
15491            ),
15492            "scaled zero-candidate discovery did not propagate cancellation",
15493        )?;
15494        require(
15495            inspected_steps.get() <= 1_000,
15496            "zero-candidate discovery exceeded one SQLite progress interval before cancellation",
15497        )?;
15498        store.finish_index_read_snapshot()?;
15499        Ok(())
15500    }
15501
15502    #[test]
15503    fn coverage_discovery_filters_provenance_and_fails_closed_after_reopen()
15504    -> Result<(), Box<dyn Error>> {
15505        let temp = tempfile::tempdir()?;
15506        let project_root = temp.path().join("coverage-discovery");
15507        fs::create_dir_all(&project_root)?;
15508        let db_path = project_root.join("projectatlas.db");
15509        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
15510        let fixture = publish_fixture(&mut writer, "coverage-discovery")?;
15511        let sibling_path = "src/Äuth.rs.backup";
15512        let sibling_coverage = CoverageRecord::new(
15513            CoverageScope::Path {
15514                path: RepositoryNodePath::new(Path::new(sibling_path))?,
15515            },
15516            None,
15517            CoverageState::Complete,
15518            1,
15519            0,
15520            IndexGeneration::new(2),
15521            None,
15522            None,
15523        )?;
15524        let mut publication = writer.begin_index_publication("coverage-discovery-sibling")?;
15525        publication.replace_repository_graph_for_paths(
15526            fixture.project,
15527            &[sibling_path.to_string()],
15528            &[],
15529            &[],
15530            &[],
15531            &[sibling_coverage],
15532        )?;
15533        publication.complete()?;
15534        drop(writer);
15535
15536        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15537        let project_value = Value::Blob(fixture.project.as_bytes().to_vec());
15538        assert_coverage_discovery_plan(
15539            &store.connection,
15540            "EXPLAIN QUERY PLAN
15541             SELECT coverage.id FROM graph_coverage AS coverage
15542              WHERE coverage.project_instance_id = ?
15543                AND coverage.scope_kind = 'path'
15544                AND coverage.scope_path >= ? AND coverage.scope_path < ?
15545                AND (coverage.scope_path = ? OR coverage.scope_path >= ?)
15546              ORDER BY coverage.scope_path, coverage.relation_scope, coverage.relation_kind,
15547                       coverage.state, coverage.id LIMIT 11",
15548            &[
15549                project_value.clone(),
15550                Value::Text("src/Äuth.rs".to_string()),
15551                Value::Text("src/Äuth.rs0".to_string()),
15552                Value::Text("src/Äuth.rs".to_string()),
15553                Value::Text("src/Äuth.rs/".to_string()),
15554            ],
15555            &["idx_graph_coverage_scope_order"],
15556            false,
15557            "coverage path filter",
15558        )?;
15559        assert_coverage_discovery_plan(
15560            &store.connection,
15561            "EXPLAIN QUERY PLAN
15562             SELECT coverage.id FROM graph_coverage AS coverage
15563              WHERE coverage.project_instance_id = ? AND coverage.state = ?
15564              ORDER BY coverage.state, coverage.scope_path, coverage.id LIMIT 11",
15565            &[project_value.clone(), Value::Text("failed".to_string())],
15566            &["idx_graph_coverage_discovery_state"],
15567            false,
15568            "coverage state filter",
15569        )?;
15570        assert_coverage_discovery_plan(
15571            &store.connection,
15572            "EXPLAIN QUERY PLAN
15573             SELECT coverage.id FROM graph_coverage AS coverage
15574              WHERE coverage.project_instance_id = ?
15575                AND coverage.state = 'complete' AND coverage.total = 0
15576                AND coverage.relation_scope = 'extended'
15577                AND coverage.relation_kind = 'documents'
15578              ORDER BY coverage.relation_scope, coverage.relation_kind,
15579                       coverage.state, coverage.id LIMIT 11",
15580            std::slice::from_ref(&project_value),
15581            &["idx_graph_coverage_relation_state"],
15582            false,
15583            "zero-candidate coverage filter",
15584        )?;
15585        assert_coverage_discovery_plan(
15586            &store.connection,
15587            "EXPLAIN QUERY PLAN
15588             SELECT coverage.id FROM graph_coverage AS coverage
15589              WHERE coverage.project_instance_id = ?
15590                AND coverage.state = 'complete'
15591                AND NOT (coverage.total = 0
15592                         AND coverage.relation_scope IS 'extended'
15593                         AND coverage.relation_kind IS 'documents')
15594              ORDER BY coverage.state, coverage.scope_path, coverage.id LIMIT 11",
15595            std::slice::from_ref(&project_value),
15596            &["idx_graph_coverage_discovery_state"],
15597            false,
15598            "positive-complete coverage filter",
15599        )?;
15600        assert_coverage_discovery_plan(
15601            &store.connection,
15602            "EXPLAIN QUERY PLAN
15603             SELECT coverage.id FROM graph_coverage AS coverage
15604              WHERE coverage.project_instance_id = ? AND coverage.reason = ?
15605              ORDER BY coverage.reason, coverage.scope_path, coverage.id LIMIT 11",
15606            &[
15607                project_value.clone(),
15608                Value::Text("parser failed".to_string()),
15609            ],
15610            &["idx_graph_coverage_discovery_reason"],
15611            false,
15612            "coverage reason filter",
15613        )?;
15614        for (column, index, context) in [
15615            (
15616                "source_parser",
15617                "idx_source_parse_metadata_source_parser_path",
15618                "coverage parser filter",
15619            ),
15620            (
15621                "fact_parser",
15622                "idx_source_parse_metadata_fact_parser_path",
15623                "coverage provider filter",
15624            ),
15625        ] {
15626            assert_coverage_discovery_plan(
15627                &store.connection,
15628                &format!(
15629                    "EXPLAIN QUERY PLAN
15630                     SELECT coverage.id
15631                       FROM source_parse_metadata AS metadata
15632                       CROSS JOIN graph_coverage AS coverage
15633                         ON coverage.scope_kind = 'path'
15634                        AND coverage.scope_path = metadata.path
15635                      WHERE coverage.project_instance_id = ?
15636                        AND metadata.{column} = ?
15637                      ORDER BY metadata.path, coverage.id LIMIT 11"
15638                ),
15639                &[
15640                    project_value.clone(),
15641                    Value::Text("tree-sitter".to_string()),
15642                ],
15643                &[index, "idx_graph_coverage_identity"],
15644                true,
15645                context,
15646            )?;
15647        }
15648        let all = store.repository_coverage_page(
15649            fixture.project,
15650            &RepositoryCoverageQuery {
15651                start_index: 0,
15652                limit: 2,
15653                path_prefix: None,
15654                parser: None,
15655                provider: None,
15656                relation: None,
15657                state: None,
15658                reason: None,
15659            },
15660        )?;
15661        require(
15662            all.truncated && all.rows.len() == 2,
15663            "coverage discovery did not use LIMIT + 1",
15664        )?;
15665        let all_states = store.repository_coverage_page(
15666            fixture.project,
15667            &RepositoryCoverageQuery {
15668                start_index: 0,
15669                limit: 20,
15670                path_prefix: None,
15671                parser: None,
15672                provider: None,
15673                relation: None,
15674                state: None,
15675                reason: None,
15676            },
15677        )?;
15678        for state in [
15679            CoverageState::Complete,
15680            CoverageState::NoCandidates,
15681            CoverageState::Partial,
15682            CoverageState::Failed,
15683            CoverageState::Ignored,
15684            CoverageState::Oversized,
15685            CoverageState::Quarantined,
15686            CoverageState::Stale,
15687        ] {
15688            require(
15689                all_states
15690                    .rows
15691                    .iter()
15692                    .any(|row| row.coverage.state() == state),
15693                &format!("coverage discovery omitted {state:?}"),
15694            )?;
15695        }
15696
15697        let complete = store.repository_coverage_page(
15698            fixture.project,
15699            &RepositoryCoverageQuery {
15700                start_index: 0,
15701                limit: 10,
15702                path_prefix: None,
15703                parser: None,
15704                provider: None,
15705                relation: None,
15706                state: Some(CoverageState::Complete),
15707                reason: None,
15708            },
15709        )?;
15710        let complete_contract = !complete.rows.is_empty()
15711            && complete
15712                .rows
15713                .iter()
15714                .all(|row| row.coverage.state() == CoverageState::Complete)
15715            && complete.rows.iter().any(|row| {
15716                row.coverage.total() == 0
15717                    && row.coverage.relation()
15718                        == Some(GraphRelationKind::Extended(ExtendedRelationKind::Tests))
15719            });
15720        require(
15721            complete_contract,
15722            &format!(
15723                "complete coverage filter changed a non-document zero row or admitted no-candidates: {:?}",
15724                complete.rows
15725            ),
15726        )?;
15727        let no_candidates = store.repository_coverage_page(
15728            fixture.project,
15729            &RepositoryCoverageQuery {
15730                start_index: 0,
15731                limit: 10,
15732                path_prefix: None,
15733                parser: None,
15734                provider: None,
15735                relation: None,
15736                state: Some(CoverageState::NoCandidates),
15737                reason: None,
15738            },
15739        )?;
15740        require(
15741            !no_candidates.rows.is_empty()
15742                && no_candidates.rows.iter().all(|row| {
15743                    row.coverage.state() == CoverageState::NoCandidates && row.coverage.total() == 0
15744                }),
15745            "zero-candidate coverage filter admitted a positive-complete row",
15746        )?;
15747
15748        let exact_path = store.repository_coverage_page(
15749            fixture.project,
15750            &RepositoryCoverageQuery {
15751                start_index: 0,
15752                limit: 1,
15753                path_prefix: Some("src/Äuth.rs".to_string()),
15754                parser: None,
15755                provider: None,
15756                relation: None,
15757                state: None,
15758                reason: None,
15759            },
15760        )?;
15761        require(
15762            !exact_path.truncated && exact_path.rows.len() == 1,
15763            "exact coverage path admitted a lexical sibling",
15764        )?;
15765        require_eq(
15766            exact_path.rows[0].coverage.scope(),
15767            &CoverageScope::Path {
15768                path: RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
15769            },
15770            "exact coverage path scope",
15771        )?;
15772
15773        let parsed = store.repository_coverage_page(
15774            fixture.project,
15775            &RepositoryCoverageQuery {
15776                start_index: 0,
15777                limit: 10,
15778                path_prefix: Some("src/Äuth.rs".to_string()),
15779                parser: Some(ParserKind::TreeSitter),
15780                provider: Some(ParserKind::TreeSitter),
15781                relation: None,
15782                state: Some(CoverageState::Complete),
15783                reason: None,
15784            },
15785        )?;
15786        require_eq(&parsed.rows.len(), &1, "parser/provider filtered coverage")?;
15787        require_eq(
15788            &parsed.rows[0].parser,
15789            &Some(ParserKind::TreeSitter),
15790            "source parser provenance",
15791        )?;
15792        require_eq(
15793            &parsed.rows[0].provider,
15794            &Some(ParserKind::TreeSitter),
15795            "fact provider provenance",
15796        )?;
15797
15798        let failed_calls = store.repository_coverage_page(
15799            fixture.project,
15800            &RepositoryCoverageQuery {
15801                start_index: 0,
15802                limit: 10,
15803                path_prefix: None,
15804                parser: None,
15805                provider: None,
15806                relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
15807                state: Some(CoverageState::Failed),
15808                reason: Some("parser failed".to_string()),
15809            },
15810        )?;
15811        require_eq(&failed_calls.rows.len(), &1, "combined coverage filters")?;
15812        require_eq(
15813            &failed_calls.rows[0].coverage.state(),
15814            &CoverageState::Failed,
15815            "failed coverage state",
15816        )?;
15817
15818        let absent = store.repository_coverage_page(
15819            fixture.project,
15820            &RepositoryCoverageQuery {
15821                start_index: 0,
15822                limit: 10,
15823                path_prefix: None,
15824                parser: None,
15825                provider: Some(ParserKind::Manifest),
15826                relation: None,
15827                state: None,
15828                reason: None,
15829            },
15830        )?;
15831        require(
15832            absent.rows.is_empty(),
15833            "provider filter returned a false match",
15834        )?;
15835        store.finish_index_read_snapshot()?;
15836        drop(store);
15837
15838        let writer = AtlasStore::open_for_project(&db_path, &project_root)?;
15839        writer.connection.execute(
15840            "UPDATE source_parse_metadata SET source_parser = 'corrupt-parser'
15841              WHERE path = 'src/Äuth.rs'",
15842            [],
15843        )?;
15844        drop(writer);
15845        let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15846        let error = require_db_error(
15847            store.repository_coverage_page(
15848                fixture.project,
15849                &RepositoryCoverageQuery {
15850                    start_index: 0,
15851                    limit: 10,
15852                    path_prefix: Some("src/Äuth.rs".to_string()),
15853                    parser: None,
15854                    provider: None,
15855                    relation: None,
15856                    state: None,
15857                    reason: None,
15858                },
15859            ),
15860            "corrupt parser provenance was accepted",
15861        )?;
15862        require(
15863            matches!(error, DbError::InvalidEnum { .. }),
15864            &format!("unexpected parser corruption error: {error}"),
15865        )?;
15866        Ok(())
15867    }
15868
15869    #[test]
15870    fn navigation_connections_cover_families_prefixes_truncation_and_reopen()
15871    -> Result<(), Box<dyn Error>> {
15872        let temp = tempfile::tempdir()?;
15873        let root = temp.path().join("navigation-connections");
15874        fs::create_dir_all(&root)?;
15875        let db_path = root.join("projectatlas.db");
15876        let mut store = AtlasStore::open_for_project(&db_path, &root)?;
15877        let fixture = publish_navigation_fixture(&mut store, "navigation-connections")?;
15878        let owners = vec![
15879            RepositoryNavigationNode {
15880                path: fixture.api_path.clone(),
15881                kind: NodeKind::File,
15882            },
15883            RepositoryNavigationNode {
15884                path: "src/auth".to_string(),
15885                kind: NodeKind::Folder,
15886            },
15887            RepositoryNavigationNode {
15888                path: fixture.manifest_path.clone(),
15889                kind: NodeKind::File,
15890            },
15891            RepositoryNavigationNode {
15892                path: ".".to_string(),
15893                kind: NodeKind::Folder,
15894            },
15895        ];
15896        let pages = store.repository_navigation_connections(&owners, 2, 20)?;
15897        require_eq(&pages.len(), &owners.len(), "navigation owner count")?;
15898        let api = &pages[0];
15899        let families = api
15900            .counts
15901            .iter()
15902            .map(|count| count.kind)
15903            .collect::<Vec<_>>();
15904        require_eq(
15905            &families,
15906            &NAVIGATION_CONNECTION_FAMILIES
15907                .iter()
15908                .map(|&(kind, _, _)| kind)
15909                .collect::<Vec<_>>(),
15910            "all navigation families",
15911        )?;
15912        let calls = api
15913            .counts
15914            .iter()
15915            .find(|count| count.kind == RankedConnectionKind::Call)
15916            .ok_or_else(|| io::Error::other("call navigation count is missing"))?;
15917        require_eq(&calls.count, &2, "bounded high-degree call count")?;
15918        require_eq(&calls.truncated, &true, "high-degree call truncation")?;
15919        require_eq(&api.truncated, &true, "file aggregate truncation")?;
15920        require(
15921            api.connections.iter().any(|connection| {
15922                connection.kind == RankedConnectionKind::Test
15923                    && connection.direction == RankedConnectionDirection::Inbound
15924            }),
15925            "inbound test connection was not projected",
15926        )?;
15927
15928        let folder = &pages[1];
15929        let folder_imports = folder
15930            .counts
15931            .iter()
15932            .find(|count| count.kind == RankedConnectionKind::Import)
15933            .ok_or_else(|| io::Error::other("folder import count is missing"))?;
15934        require_eq(
15935            &folder_imports.count,
15936            &1,
15937            "folder prefix import count excluding sibling authz.rs",
15938        )?;
15939        let manifest = &pages[2];
15940        require(
15941            manifest
15942                .counts
15943                .iter()
15944                .any(|count| count.kind == RankedConnectionKind::Package && count.count == 1),
15945            "manifest-owned package context is missing",
15946        )?;
15947        require(
15948            pages[3]
15949                .counts
15950                .iter()
15951                .any(|count| count.kind == RankedConnectionKind::Call),
15952            "root aggregate omitted bounded call context",
15953        )?;
15954
15955        let globally_sampled = store.repository_navigation_connections(&owners[..1], 10, 3)?;
15956        require_eq(
15957            &globally_sampled[0].connections.len(),
15958            &3,
15959            "global connection sample limit",
15960        )?;
15961        require_eq(
15962            &globally_sampled[0].counts.len(),
15963            &NAVIGATION_CONNECTION_FAMILIES.len(),
15964            "global sample retained all family counts",
15965        )?;
15966        require(
15967            globally_sampled[0]
15968                .counts
15969                .iter()
15970                .all(|count| !count.truncated),
15971            "global sample incorrectly reported family overflow",
15972        )?;
15973        require_eq(
15974            &globally_sampled[0].truncated,
15975            &true,
15976            "global sample truncation",
15977        )?;
15978
15979        drop(store);
15980        let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
15981        require_eq(
15982            &reader.project_instance_id()?,
15983            &Some(fixture.project),
15984            "reopened navigation identity",
15985        )?;
15986        let reopened = reader.repository_navigation_connections(&owners[..1], 2, 20)?;
15987        require_eq(
15988            &reopened[0].counts,
15989            &api.counts,
15990            "reopened navigation counts",
15991        )?;
15992        Ok(())
15993    }
15994
15995    #[test]
15996    fn navigation_connections_use_owned_indexes_and_fail_all_or_error_on_corruption()
15997    -> Result<(), Box<dyn Error>> {
15998        let temp = tempfile::tempdir()?;
15999        let root = temp.path().join("navigation-plan-corruption");
16000        fs::create_dir_all(&root)?;
16001        let db_path = root.join("projectatlas.db");
16002        let mut store = AtlasStore::open_for_project(&db_path, &root)?;
16003        let fixture = publish_navigation_fixture(&mut store, "navigation-plan-corruption")?;
16004        let owner = RepositoryNavigationNode {
16005            path: fixture.api_path,
16006            kind: NodeKind::File,
16007        };
16008        for (direction, expected_index) in [
16009            (
16010                RankedConnectionDirection::Outbound,
16011                "idx_graph_relations_source_kind",
16012            ),
16013            (
16014                RankedConnectionDirection::Inbound,
16015                "idx_graph_relations_target_kind",
16016            ),
16017        ] {
16018            let mut values = Vec::new();
16019            let sql = navigation_connection_branch(
16020                0,
16021                &owner,
16022                RankedConnectionKind::Call,
16023                "legacy",
16024                "calls",
16025                direction,
16026                3,
16027                &mut values,
16028            );
16029            let mut statement = store
16030                .connection
16031                .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
16032            let details = statement
16033                .query_map(params_from_iter(values.iter()), |row| {
16034                    row.get::<_, String>(3)
16035                })?
16036                .collect::<Result<Vec<_>, _>>()?;
16037            for expected in [
16038                expected_index,
16039                "idx_graph_entities_path",
16040                "idx_graph_entities_manifest_path",
16041            ] {
16042                require(
16043                    details.iter().any(|detail| detail.contains(expected)),
16044                    &format!("navigation plan missed {expected}: {details:?}"),
16045                )?;
16046            }
16047            require(
16048                details.iter().all(|detail| !detail.contains("SCAN graph_")),
16049                &format!("navigation plan scanned graph storage: {details:?}"),
16050            )?;
16051        }
16052        let folder_owner = RepositoryNavigationNode {
16053            path: "src/auth".to_string(),
16054            kind: NodeKind::Folder,
16055        };
16056        for (direction, expected_index) in [
16057            (
16058                RankedConnectionDirection::Outbound,
16059                "idx_graph_relations_source_kind",
16060            ),
16061            (
16062                RankedConnectionDirection::Inbound,
16063                "idx_graph_relations_target_kind",
16064            ),
16065        ] {
16066            let mut values = Vec::new();
16067            let sql = navigation_connection_branch(
16068                0,
16069                &folder_owner,
16070                RankedConnectionKind::Call,
16071                "legacy",
16072                "calls",
16073                direction,
16074                3,
16075                &mut values,
16076            );
16077            let details = store
16078                .connection
16079                .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?
16080                .query_map(params_from_iter(values.iter()), |row| {
16081                    row.get::<_, String>(3)
16082                })?
16083                .collect::<Result<Vec<_>, _>>()?;
16084            for expected in [
16085                expected_index,
16086                "idx_graph_entities_path",
16087                "idx_graph_entities_manifest_path",
16088            ] {
16089                require(
16090                    details.iter().any(|detail| detail.contains(expected)),
16091                    &format!("folder navigation plan missed {expected}: {details:?}"),
16092                )?;
16093            }
16094            require(
16095                details.iter().all(|detail| !detail.contains("SCAN graph_")),
16096                &format!("folder navigation plan scanned graph storage: {details:?}"),
16097            )?;
16098        }
16099        let root_owner = RepositoryNavigationNode {
16100            path: ".".to_string(),
16101            kind: NodeKind::Folder,
16102        };
16103        let mut root_values = Vec::new();
16104        let root_sql = navigation_connection_branch(
16105            0,
16106            &root_owner,
16107            RankedConnectionKind::Call,
16108            "legacy",
16109            "calls",
16110            RankedConnectionDirection::Outbound,
16111            2,
16112            &mut root_values,
16113        );
16114        let root_details = store
16115            .connection
16116            .prepare(&format!("EXPLAIN QUERY PLAN {root_sql}"))?
16117            .query_map(params_from_iter(root_values.iter()), |row| {
16118                row.get::<_, String>(3)
16119            })?
16120            .collect::<Result<Vec<_>, _>>()?;
16121        require(
16122            root_details
16123                .iter()
16124                .any(|detail| detail.contains("idx_graph_relations_kind_order")),
16125            &format!("root navigation plan missed family index: {root_details:?}"),
16126        )?;
16127        require(
16128            root_details
16129                .iter()
16130                .all(|detail| !detail.contains("SCAN graph_")),
16131            &format!("root navigation plan scanned graph storage: {root_details:?}"),
16132        )?;
16133
16134        store
16135            .connection
16136            .execute_batch("PRAGMA ignore_check_constraints = ON")?;
16137        store.connection.execute(
16138            "UPDATE graph_relations
16139                SET resolution_status = 'unresolved', reference_text = 'broken-route'
16140              WHERE relation_scope = 'extended' AND relation_kind = 'routes-to'",
16141            [],
16142        )?;
16143        let error = require_db_error(
16144            store.repository_navigation_connections(&[owner], 4, 20),
16145            "corrupt navigation relation returned a partial page",
16146        )?;
16147        require(
16148            matches!(error, DbError::GraphRowShape { .. }),
16149            &format!("corrupt navigation relation returned {error}"),
16150        )?;
16151        Ok(())
16152    }
16153
16154    #[test]
16155    fn graph_queries_fail_closed_on_corrupt_normalized_rows() -> Result<(), Box<dyn Error>> {
16156        let temp = tempfile::tempdir()?;
16157        let project_root = temp.path().join("graph-corruption");
16158        let atlas_dir = project_root.join(".projectatlas");
16159        fs::create_dir_all(&atlas_dir)?;
16160        let db_path = atlas_dir.join("projectatlas.db");
16161        let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
16162        let fixture = publish_fixture(&mut store, "graph-corruption")?;
16163        drop(store);
16164        let store = AtlasStore::open_for_project(&db_path, &project_root)?;
16165        let source = fixture
16166            .entities
16167            .iter()
16168            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
16169            .ok_or_else(|| io::Error::other("source file fixture missing"))?;
16170        let folder = fixture
16171            .entities
16172            .iter()
16173            .find(|entity| matches!(entity.selector(), EntitySelector::Folder { .. }))
16174            .ok_or_else(|| io::Error::other("folder fixture missing"))?;
16175        let symbol = fixture
16176            .entities
16177            .iter()
16178            .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
16179            .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
16180        let ambiguous = fixture
16181            .relations
16182            .iter()
16183            .find(|relation| matches!(relation.resolution(), RelationResolution::Ambiguous { .. }))
16184            .ok_or_else(|| io::Error::other("ambiguous relation fixture missing"))?;
16185        let source_digest = source.key().digest_bytes()?;
16186        let folder_digest = folder.key().digest_bytes()?;
16187        let symbol_digest = symbol.key().digest_bytes()?;
16188        let ambiguous_digest = ambiguous.key().digest_bytes()?;
16189        let source_canonical = store.connection.query_row(
16190            "SELECT canonical_identity FROM graph_entities WHERE entity_key = ?1",
16191            [&source_digest[..]],
16192            |row| row.get::<_, String>(0),
16193        )?;
16194        let symbol_canonical = store.connection.query_row(
16195            "SELECT canonical_identity FROM graph_entities WHERE entity_key = ?1",
16196            [&symbol_digest[..]],
16197            |row| row.get::<_, String>(0),
16198        )?;
16199        store
16200            .connection
16201            .execute_batch("PRAGMA ignore_check_constraints = ON")?;
16202
16203        store.connection.execute(
16204            "UPDATE graph_entities SET entity_kind = 'corrupt' WHERE entity_key = ?1",
16205            [&source_digest[..]],
16206        )?;
16207        {
16208            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16209            let error = require_db_error(
16210                reader.repository_graph_entity(source.key()),
16211                "malformed graph enum was accepted",
16212            )?;
16213            require(
16214                matches!(error, DbError::InvalidEnum { .. }),
16215                &format!("unexpected malformed-enum error: {error}"),
16216            )?;
16217            reader.finish_index_read_snapshot()?;
16218        }
16219        store.connection.execute(
16220            "UPDATE graph_entities SET entity_kind = 'file' WHERE entity_key = ?1",
16221            [&source_digest[..]],
16222        )?;
16223
16224        store.connection.execute(
16225            "UPDATE graph_relations SET candidate_count = 0 WHERE relation_key = ?1",
16226            [&ambiguous_digest[..]],
16227        )?;
16228        {
16229            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16230            let error = require_db_error(
16231                reader.repository_graph_relations(
16232                    RepositoryGraphRelationQuery::Outbound {
16233                        source: source.key().clone(),
16234                    },
16235                    10,
16236                ),
16237                "zero ambiguity count was accepted",
16238            )?;
16239            require(
16240                matches!(error, DbError::GraphRowShape { .. }),
16241                &format!("unexpected candidate-count error: {error}"),
16242            )?;
16243            let adjacency_error = require_db_error(
16244                reader.repository_graph_adjacency_page(
16245                    &[source.key().clone()],
16246                    RepositoryGraphDirection::Outbound,
16247                    None,
16248                    10,
16249                    None,
16250                ),
16251                "corrupt adjacency relation returned a partial page",
16252            )?;
16253            require(
16254                matches!(adjacency_error, DbError::GraphRowShape { .. }),
16255                &format!("unexpected adjacency row-shape error: {adjacency_error}"),
16256            )?;
16257            reader.finish_index_read_snapshot()?;
16258        }
16259        store.connection.execute(
16260            "UPDATE graph_relations SET candidate_count = 2 WHERE relation_key = ?1",
16261            [&ambiguous_digest[..]],
16262        )?;
16263
16264        store.connection.execute(
16265            "UPDATE graph_relations SET resolution_status = 'resolved'
16266              WHERE relation_key = ?1",
16267            [&ambiguous_digest[..]],
16268        )?;
16269        {
16270            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16271            let error = require_db_error(
16272                reader.repository_graph_relations(
16273                    RepositoryGraphRelationQuery::Outbound {
16274                        source: source.key().clone(),
16275                    },
16276                    10,
16277                ),
16278                "contradictory resolution columns were accepted",
16279            )?;
16280            require(
16281                matches!(error, DbError::GraphRowShape { .. }),
16282                &format!("unexpected resolution-shape error: {error}"),
16283            )?;
16284            reader.finish_index_read_snapshot()?;
16285        }
16286        store.connection.execute(
16287            "UPDATE graph_relations SET resolution_status = 'ambiguous'
16288              WHERE relation_key = ?1",
16289            [&ambiguous_digest[..]],
16290        )?;
16291
16292        store.connection.execute(
16293            "UPDATE graph_coverage SET total = 999
16294              WHERE scope_kind = 'project' AND relation_scope IS NULL",
16295            [],
16296        )?;
16297        {
16298            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16299            let error = require_db_error(
16300                reader.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10),
16301                "contradictory coverage total was accepted",
16302            )?;
16303            require(
16304                matches!(error, DbError::GraphRowShape { .. }),
16305                &format!("unexpected coverage-total error: {error}"),
16306            )?;
16307            reader.finish_index_read_snapshot()?;
16308        }
16309        store.connection.execute(
16310            "UPDATE graph_coverage SET total = covered + omitted
16311              WHERE scope_kind = 'project' AND relation_scope IS NULL",
16312            [],
16313        )?;
16314
16315        store.connection.execute(
16316            "UPDATE project_identity SET active_generation = 99 WHERE singleton = 1",
16317            [],
16318        )?;
16319        {
16320            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16321            let error = require_db_error(
16322                reader.repository_graph_entity(source.key()),
16323                "mismatched typed graph generation was accepted",
16324            )?;
16325            require(
16326                matches!(error, DbError::GraphRowShape { .. }),
16327                &format!("unexpected typed-generation error: {error}"),
16328            )?;
16329            reader.finish_index_read_snapshot()?;
16330        }
16331        store.connection.execute(
16332            "UPDATE project_identity SET active_generation = 1 WHERE singleton = 1",
16333            [],
16334        )?;
16335
16336        store.connection.execute(
16337            "UPDATE graph_entities SET canonical_identity = 'different-collision-witness'
16338              WHERE entity_key = ?1",
16339            [&source_digest[..]],
16340        )?;
16341        {
16342            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16343            let error = require_db_error(
16344                reader.repository_graph_entity(source.key()),
16345                "canonical collision witness was accepted",
16346            )?;
16347            require(
16348                matches!(error, DbError::GraphContract(_)),
16349                &format!("unexpected collision-witness error: {error}"),
16350            )?;
16351            reader.finish_index_read_snapshot()?;
16352        }
16353        store.connection.execute(
16354            "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
16355            params![source_canonical, &source_digest[..]],
16356        )?;
16357
16358        store.connection.execute(
16359            "UPDATE graph_entities SET entity_key = zeroblob(32) WHERE entity_key = ?1",
16360            [&folder_digest[..]],
16361        )?;
16362        let folder_path = RepositoryNodePath::new(Path::new("src"))?;
16363        {
16364            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16365            let error = require_db_error(
16366                reader.repository_graph_entities_by_path(fixture.project, &folder_path, 10),
16367                "invalid stable digest was accepted",
16368            )?;
16369            require(
16370                matches!(error, DbError::GraphContract(_)),
16371                &format!("unexpected stable-digest error: {error}"),
16372            )?;
16373            reader.finish_index_read_snapshot()?;
16374        }
16375        store.connection.execute(
16376            "UPDATE graph_entities SET entity_key = ?1 WHERE entity_key = zeroblob(32)",
16377            [&folder_digest[..]],
16378        )?;
16379
16380        store.connection.execute(
16381            "UPDATE graph_entities SET entity_key = X'01' WHERE entity_key = ?1",
16382            [&folder_digest[..]],
16383        )?;
16384        {
16385            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16386            let error = require_db_error(
16387                reader.repository_graph_entities_by_path(fixture.project, &folder_path, 10),
16388                "short graph key blob was accepted",
16389            )?;
16390            require(
16391                matches!(
16392                    error,
16393                    DbError::InvalidBlobLength {
16394                        field: "graph_entities.entity_key",
16395                        expected: 32,
16396                        found: 1
16397                    }
16398                ),
16399                &format!("unexpected graph-key length error: {error}"),
16400            )?;
16401            reader.finish_index_read_snapshot()?;
16402        }
16403        store.connection.execute(
16404            "UPDATE graph_entities SET entity_key = ?1 WHERE entity_key = X'01'",
16405            [&folder_digest[..]],
16406        )?;
16407
16408        store.connection.execute(
16409            "UPDATE graph_entities SET canonical_identity = X'00' WHERE entity_key = ?1",
16410            [&symbol_digest[..]],
16411        )?;
16412        let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
16413        {
16414            let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16415            let error = require_db_error(
16416                reader.repository_graph_entities_by_path(fixture.project, &source_path, 10),
16417                "later row conversion failure returned a successful partial page",
16418            )?;
16419            require(
16420                matches!(error, DbError::Sqlite(_)),
16421                &format!("unexpected later-row conversion error: {error}"),
16422            )?;
16423            reader.finish_index_read_snapshot()?;
16424        }
16425        store.connection.execute(
16426            "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
16427            params![symbol_canonical, &symbol_digest[..]],
16428        )?;
16429        store
16430            .connection
16431            .execute_batch("PRAGMA ignore_check_constraints = OFF")?;
16432        Ok(())
16433    }
16434
16435    #[test]
16436    fn identity_rejections_are_generation_atomic_indexed_and_reopenable()
16437    -> Result<(), Box<dyn Error>> {
16438        let temp = tempfile::tempdir()?;
16439        let project_root = temp.path().join("identity-rejections");
16440        let atlas_dir = project_root.join(".projectatlas");
16441        fs::create_dir_all(&atlas_dir)?;
16442        let db_path = atlas_dir.join("projectatlas.db");
16443        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
16444        let fixture = publish_fixture(&mut writer, "identity-rejections")?;
16445        let graph_v2 = graph_fixture(fixture.project, IndexGeneration::new(2))?;
16446        let graph_v3 = graph_fixture(fixture.project, IndexGeneration::new(3))?;
16447        let graph_v4 = graph_fixture(fixture.project, IndexGeneration::new(4))?;
16448        let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
16449        let first = GraphIdentityRejection {
16450            path: source_path.clone(),
16451            span: SourceSpan::new(2, 0, 2, 3)?,
16452            parser: ParserKind::TreeSitter,
16453            field: GraphIdentityField::Symbol,
16454            reason: GraphIdentityRejectionReason::Empty,
16455            fact_index: 0,
16456        };
16457        let first_distinct = GraphIdentityRejection {
16458            fact_index: 1,
16459            ..first.clone()
16460        };
16461        let mut first_rows = vec![first, first_distinct];
16462        first_rows.push(first_rows[0].clone());
16463        let expected_first = first_rows[..2].to_vec();
16464        {
16465            let mut publication = writer.begin_index_publication("identity-rejections")?;
16466            publication.replace_repository_graph(
16467                fixture.project,
16468                &graph_v2.entities,
16469                &graph_v2.relations,
16470                &graph_v2.occurrences,
16471                &graph_v2.coverage,
16472            )?;
16473            publication.replace_graph_identity_rejections(fixture.project, &first_rows)?;
16474            publication.complete()?;
16475        }
16476        let first_rows = writer.repository_graph_identity_rejections(
16477            fixture.project,
16478            std::slice::from_ref(&source_path),
16479            10,
16480            None,
16481        )?;
16482        require_eq(
16483            &first_rows,
16484            &expected_first,
16485            "first typed rejection publication",
16486        )?;
16487
16488        let query_plan = writer
16489            .connection
16490            .prepare(
16491                "EXPLAIN QUERY PLAN
16492                   SELECT file_path
16493                     FROM graph_identity_rejections
16494                    WHERE project_instance_id = ?1
16495                      AND generation = ?2
16496                      AND file_path IN (?3)",
16497            )?
16498            .query_map(
16499                params![&fixture.project.as_bytes()[..], 2_i64, source_path.as_str()],
16500                |row| row.get::<_, String>(3),
16501            )?
16502            .collect::<Result<Vec<_>, _>>()?
16503            .join(" ");
16504        require(
16505            query_plan.contains("idx_graph_identity_rejections_generation_path"),
16506            "typed rejection lookup did not use its generation/path index",
16507        )?;
16508
16509        let reopened = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16510        let reopened_rows = reopened.repository_graph_identity_rejections(
16511            fixture.project,
16512            std::slice::from_ref(&source_path),
16513            10,
16514            None,
16515        )?;
16516        require_eq(
16517            &reopened_rows,
16518            &expected_first,
16519            "typed rejection survives SQLite reopen",
16520        )?;
16521        reopened.finish_index_read_snapshot()?;
16522
16523        let second = GraphIdentityRejection {
16524            path: source_path.clone(),
16525            span: SourceSpan::new(4, 0, 4, 4)?,
16526            parser: ParserKind::TreeSitter,
16527            field: GraphIdentityField::Signature,
16528            reason: GraphIdentityRejectionReason::SurroundingWhitespace,
16529            fact_index: 0,
16530        };
16531        let expected_second = vec![second.clone()];
16532        {
16533            let mut publication = writer.begin_index_publication("identity-rejections")?;
16534            publication.replace_repository_graph_for_paths(
16535                fixture.project,
16536                &[source_path.as_str().to_string()],
16537                &graph_v3.entities,
16538                &graph_v3.relations,
16539                &graph_v3.occurrences,
16540                &graph_v3.coverage,
16541            )?;
16542            publication.replace_graph_identity_rejections(
16543                fixture.project,
16544                std::slice::from_ref(&second),
16545            )?;
16546            publication.complete()?;
16547        }
16548        let incremental_rows = writer.repository_graph_identity_rejections(
16549            fixture.project,
16550            std::slice::from_ref(&source_path),
16551            10,
16552            None,
16553        )?;
16554        require_eq(
16555            &incremental_rows,
16556            &expected_second,
16557            "incremental path replacement swaps typed rejection rows",
16558        )?;
16559        let third = GraphIdentityRejection {
16560            path: source_path.clone(),
16561            span: SourceSpan::new(6, 0, 6, 6)?,
16562            parser: ParserKind::TreeSitter,
16563            field: GraphIdentityField::Package,
16564            reason: GraphIdentityRejectionReason::Oversized,
16565            fact_index: 0,
16566        };
16567        let expected_third = vec![third.clone()];
16568        {
16569            let mut publication = writer.begin_index_publication("identity-rejections")?;
16570            publication.replace_repository_graph(
16571                fixture.project,
16572                &graph_v4.entities,
16573                &graph_v4.relations,
16574                &graph_v4.occurrences,
16575                &graph_v4.coverage,
16576            )?;
16577            publication
16578                .replace_graph_identity_rejections(fixture.project, std::slice::from_ref(&third))?;
16579            let missing = GraphEntity::new(
16580                fixture.project,
16581                EntitySelector::File {
16582                    path: RepositoryFilePath::new(Path::new("src/missing.rs"))?,
16583                },
16584                IndexGeneration::new(4),
16585            )?;
16586            let error = publication.replace_repository_graph_for_paths(
16587                fixture.project,
16588                &["src/missing.rs".to_string()],
16589                &[missing],
16590                &[],
16591                &[],
16592                &[],
16593            );
16594            require(
16595                error.is_err(),
16596                "late graph fault did not abort staged rejection",
16597            )?;
16598        }
16599        let retained_after_fault = writer.repository_graph_identity_rejections(
16600            fixture.project,
16601            std::slice::from_ref(&source_path),
16602            10,
16603            None,
16604        )?;
16605        require_eq(
16606            &retained_after_fault,
16607            &expected_second,
16608            "late publication fault retained the prior complete generation",
16609        )?;
16610
16611        {
16612            let mut publication = writer.begin_index_publication("identity-rejections")?;
16613            publication.replace_repository_graph(
16614                fixture.project,
16615                &graph_v4.entities,
16616                &graph_v4.relations,
16617                &graph_v4.occurrences,
16618                &graph_v4.coverage,
16619            )?;
16620            publication
16621                .replace_graph_identity_rejections(fixture.project, std::slice::from_ref(&third))?;
16622            publication.complete()?;
16623        }
16624        let retried = writer.repository_graph_identity_rejections(
16625            fixture.project,
16626            std::slice::from_ref(&source_path),
16627            10,
16628            None,
16629        )?;
16630        require_eq(
16631            &retried,
16632            &expected_third,
16633            "typed rejection retry replaces prior generation",
16634        )?;
16635        let retained_raw_identity_count = writer.connection.query_row(
16636            "SELECT COUNT(*)
16637               FROM graph_identity_rejections
16638              WHERE file_path LIKE '%bad%'
16639                 OR reason LIKE '%bad%'",
16640            [],
16641            |row| row.get::<_, i64>(0),
16642        )?;
16643        require_eq(
16644            &retained_raw_identity_count,
16645            &0,
16646            "typed rejection table retained no invalid raw identity material",
16647        )?;
16648        Ok(())
16649    }
16650
16651    #[test]
16652    fn incremental_rejection_ceiling_covers_preserved_and_incoming_rows()
16653    -> Result<(), Box<dyn Error>> {
16654        let temp = tempfile::tempdir()?;
16655        let root = temp.path().join("identity-rejection-ceiling");
16656        fs::create_dir_all(root.join(".projectatlas"))?;
16657        let database = root.join(".projectatlas/projectatlas.db");
16658        let mut store = AtlasStore::open_for_project(&database, &root)?;
16659        let fixture = publish_fixture(&mut store, "identity-rejection-ceiling")?;
16660        let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
16661        let preserved_count = usize::try_from(GraphLimits::MAX_ROWS)?.saturating_sub(1);
16662        let preserved = (0..preserved_count)
16663            .map(|index| {
16664                let line = u32::try_from(index.saturating_add(1))?;
16665                Ok(GraphIdentityRejection {
16666                    path: source_path.clone(),
16667                    span: SourceSpan::new(line, 0, line, 0)?,
16668                    parser: ParserKind::TreeSitter,
16669                    field: GraphIdentityField::Symbol,
16670                    reason: GraphIdentityRejectionReason::Empty,
16671                    fact_index: u64::try_from(index)?,
16672                })
16673            })
16674            .collect::<Result<Vec<_>, Box<dyn Error>>>()?;
16675        let graph_v2 = graph_fixture(fixture.project, IndexGeneration::new(2))?;
16676        {
16677            let mut publication = store.begin_index_publication("identity-rejection-ceiling")?;
16678            publication.replace_repository_graph(
16679                fixture.project,
16680                &graph_v2.entities,
16681                &graph_v2.relations,
16682                &graph_v2.occurrences,
16683                &graph_v2.coverage,
16684            )?;
16685            publication.replace_graph_identity_rejections(fixture.project, &preserved)?;
16686            publication.complete()?;
16687        }
16688        let incoming = vec![
16689            GraphIdentityRejection {
16690                path: source_path.clone(),
16691                span: SourceSpan::new(20_000, 0, 20_000, 0)?,
16692                parser: ParserKind::TreeSitter,
16693                field: GraphIdentityField::RelationTarget,
16694                reason: GraphIdentityRejectionReason::ControlCharacters,
16695                fact_index: 20_000,
16696            },
16697            GraphIdentityRejection {
16698                path: source_path.clone(),
16699                span: SourceSpan::new(20_001, 0, 20_001, 0)?,
16700                parser: ParserKind::TreeSitter,
16701                field: GraphIdentityField::RelationTarget,
16702                reason: GraphIdentityRejectionReason::ControlCharacters,
16703                fact_index: 20_001,
16704            },
16705        ];
16706        {
16707            let mut publication = store.begin_index_publication("identity-rejection-overflow")?;
16708            publication.replace_repository_graph_for_paths(
16709                fixture.project,
16710                &["Cargo.toml".to_string()],
16711                &[],
16712                &[],
16713                &[],
16714                &[],
16715            )?;
16716            let error =
16717                match publication.replace_graph_identity_rejections(fixture.project, &incoming) {
16718                    Ok(()) => {
16719                        return Err(io::Error::other(
16720                            "preserved plus incoming rows unexpectedly exceeded silently",
16721                        )
16722                        .into());
16723                    }
16724                    Err(error) => error,
16725                };
16726            require(
16727                matches!(
16728                    error,
16729                    DbError::GraphContract(GraphContractError::InvalidLimits { .. })
16730                ),
16731                "combined rejection ceiling returned an unrelated error",
16732            )?;
16733        }
16734        require_eq(
16735            &store.repository_graph_generation()?,
16736            &Some(IndexGeneration::new(2)),
16737            "combined rejection ceiling retained the prior generation",
16738        )?;
16739        require_eq(
16740            &store
16741                .repository_graph_identity_rejections(
16742                    fixture.project,
16743                    std::slice::from_ref(&source_path),
16744                    GraphLimits::MAX_ROWS,
16745                    None,
16746                )?
16747                .len(),
16748            &preserved_count,
16749            "combined rejection ceiling did not preserve complete prior rows",
16750        )?;
16751
16752        let graph_v3 = graph_fixture(fixture.project, IndexGeneration::new(3))?;
16753        {
16754            let mut publication = store.begin_index_publication("identity-rejection-retry")?;
16755            publication.replace_repository_graph_for_paths(
16756                fixture.project,
16757                std::slice::from_ref(&source_path.as_str().to_string()),
16758                &graph_v3.entities,
16759                &graph_v3.relations,
16760                &graph_v3.occurrences,
16761                &graph_v3.coverage,
16762            )?;
16763            publication.replace_graph_identity_rejections(fixture.project, &incoming)?;
16764            publication.complete()?;
16765        }
16766        let retried = store.repository_graph_identity_rejections(
16767            fixture.project,
16768            std::slice::from_ref(&source_path),
16769            GraphLimits::MAX_ROWS,
16770            None,
16771        )?;
16772        require_eq(
16773            &retried,
16774            &incoming,
16775            "rejection ceiling retry after affected replacement",
16776        )?;
16777        Ok(())
16778    }
16779
16780    #[test]
16781    fn graph_publication_failure_rolls_back_text_graph_and_generation_for_readers()
16782    -> Result<(), Box<dyn Error>> {
16783        let temp = tempfile::tempdir()?;
16784        let project_root = temp.path().join("graph-publication");
16785        let atlas_dir = project_root.join(".projectatlas");
16786        fs::create_dir_all(&atlas_dir)?;
16787        let db_path = atlas_dir.join("projectatlas.db");
16788        let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
16789        let fixture_v1 = publish_fixture(&mut writer, "graph-publication")?;
16790        let initial_index_definitions = graph_rebuildable_index_sql(&writer.connection)?;
16791        let old_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16792        require_graph_projection(
16793            &old_reader,
16794            &fixture_v1,
16795            IndexGeneration::new(1),
16796            "fn verifyToken()",
16797        )?;
16798
16799        let missing_entity = GraphEntity::new(
16800            fixture_v1.project,
16801            EntitySelector::File {
16802                path: RepositoryFilePath::new(Path::new("src/missing.rs"))?,
16803            },
16804            IndexGeneration::new(2),
16805        )?;
16806        {
16807            let mut publication = writer.begin_index_publication("graph-publication")?;
16808            let error = require_db_error(
16809                publication.replace_repository_graph(
16810                    fixture_v1.project,
16811                    std::slice::from_ref(&missing_entity),
16812                    &[],
16813                    &[],
16814                    &[],
16815                ),
16816                "full graph with an unindexed source unexpectedly succeeded",
16817            )?;
16818            require(
16819                matches!(error, DbError::Sqlite(_)),
16820                "full replacement reaches the injected late source constraint",
16821            )?;
16822            require_eq(
16823                &graph_rebuildable_index_sql(&publication.store.connection)?,
16824                &initial_index_definitions,
16825                "failed bulk replacement restores exact read-index definitions",
16826            )?;
16827        }
16828        require_graph_projection(
16829            &writer,
16830            &fixture_v1,
16831            IndexGeneration::new(1),
16832            "fn verifyToken()",
16833        )?;
16834        {
16835            let mut publication = writer.begin_index_publication("graph-publication")?;
16836            publication.replace_file_texts_for_paths(
16837                &["src/Äuth.rs".to_string()],
16838                &[IndexedFileText {
16839                    path: "src/Äuth.rs".to_string(),
16840                    content_hash: Some("hash-new".to_string()),
16841                    byte_count: "fn verifyTokenUpdated()".len(),
16842                    line_count: 1,
16843                    content: "fn verifyTokenUpdated()".to_string(),
16844                }],
16845            )?;
16846            let error = require_db_error(
16847                publication.replace_repository_graph_for_paths(
16848                    fixture_v1.project,
16849                    &["src/Äuth.rs".to_string(), "src/missing.rs".to_string()],
16850                    &[missing_entity],
16851                    &[],
16852                    &[],
16853                    &[],
16854                ),
16855                "missing-node graph publication unexpectedly succeeded",
16856            )?;
16857            require(
16858                matches!(error, DbError::Sqlite(_)),
16859                &format!("unexpected late graph publication error: {error}"),
16860            )?;
16861        }
16862
16863        require_graph_projection(
16864            &writer,
16865            &fixture_v1,
16866            IndexGeneration::new(1),
16867            "fn verifyToken()",
16868        )?;
16869        let rolled_back_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16870        require_graph_projection(
16871            &rolled_back_reader,
16872            &fixture_v1,
16873            IndexGeneration::new(1),
16874            "fn verifyToken()",
16875        )?;
16876        require_graph_projection(
16877            &old_reader,
16878            &fixture_v1,
16879            IndexGeneration::new(1),
16880            "fn verifyToken()",
16881        )?;
16882        rolled_back_reader.finish_index_read_snapshot()?;
16883
16884        let project = writer
16885            .project_instance_id()?
16886            .ok_or_else(|| io::Error::other("bound writer identity is missing"))?;
16887        let fixture_v2 = graph_fixture(project, IndexGeneration::new(2))?;
16888        {
16889            let mut publication = writer.begin_index_publication("graph-publication")?;
16890            publication.replace_file_texts_for_paths(
16891                &["src/Äuth.rs".to_string()],
16892                &[IndexedFileText {
16893                    path: "src/Äuth.rs".to_string(),
16894                    content_hash: Some("hash-new".to_string()),
16895                    byte_count: "fn verifyTokenUpdated()".len(),
16896                    line_count: 1,
16897                    content: "fn verifyTokenUpdated()".to_string(),
16898                }],
16899            )?;
16900            publication.replace_repository_graph(
16901                fixture_v2.project,
16902                &fixture_v2.entities,
16903                &fixture_v2.relations,
16904                &fixture_v2.occurrences,
16905                &fixture_v2.coverage,
16906            )?;
16907            publication.complete()?;
16908        }
16909
16910        require_eq(
16911            &graph_rebuildable_index_sql(&writer.connection)?,
16912            &initial_index_definitions,
16913            "successful bulk replacement preserves exact read-index definitions",
16914        )?;
16915
16916        require_graph_projection(
16917            &old_reader,
16918            &fixture_v1,
16919            IndexGeneration::new(1),
16920            "fn verifyToken()",
16921        )?;
16922        let new_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16923        require_graph_projection(
16924            &new_reader,
16925            &fixture_v2,
16926            IndexGeneration::new(2),
16927            "fn verifyTokenUpdated()",
16928        )?;
16929        new_reader.finish_index_read_snapshot()?;
16930        old_reader.finish_index_read_snapshot()?;
16931        Ok(())
16932    }
16933}