projectatlas_core/
graph.rs

1//! Typed, project-qualified repository graph contracts.
2
3use crate::symbols::{RelationKind, SymbolKind};
4use crate::{CoreError, IndexGeneration, validated_repo_file_key, validated_repo_node_key};
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use std::num::NonZeroU32;
8use std::path::Path;
9use thiserror::Error;
10
11/// Canonical identity namespace for repository graph entities.
12const ENTITY_KEY_DOMAIN: &str = "projectatlas.graph.entity.v1";
13/// Canonical identity namespace for logical repository relationships.
14const RELATION_KEY_DOMAIN: &str = "projectatlas.graph.relation.v1";
15/// Canonical identity namespace for repository resolution keys.
16const RESOLUTION_KEY_DOMAIN: &str = "projectatlas.graph.resolution.v1";
17/// Largest accepted identity component in bytes.
18const MAX_IDENTITY_BYTES: usize = 4_096;
19/// Maximum portable canonical resolver material retained in a derived snapshot.
20const MAX_PORTABLE_RESOLUTION_IDENTITY_BYTES: usize = 32 * 1_024;
21
22/// Failure while constructing or reconciling typed graph contracts.
23#[derive(Debug, Error)]
24pub enum GraphContractError {
25    /// A project instance identifier was malformed or used the zero sentinel.
26    #[error("invalid project instance identifier: {reason}")]
27    InvalidProjectInstanceId {
28        /// Stable explanation suitable for diagnostics.
29        reason: &'static str,
30    },
31    /// An identity component was blank, padded, too large, or contained control data.
32    #[error("invalid graph identity text: {reason}")]
33    InvalidIdentityText {
34        /// Stable explanation suitable for diagnostics.
35        reason: &'static str,
36    },
37    /// An existing repository path validator rejected a graph path.
38    #[error(transparent)]
39    InvalidRepositoryPath(#[from] CoreError),
40    /// A persisted stable key digest did not match its canonical identity.
41    #[error("stable graph key digest does not match its canonical identity")]
42    InvalidStableKeyDigest,
43    /// A persisted canonical resolution-key domain was not supported.
44    #[error("unsupported canonical resolution-key domain")]
45    InvalidResolutionKeyDomain,
46    /// A portable canonical resolution identity was malformed or oversized.
47    #[error("invalid portable canonical resolution-key identity")]
48    InvalidResolutionKeyIdentity,
49    /// Two distinct canonical identities claimed the same compact key.
50    #[error("stable graph key collision for digest {digest}")]
51    StableKeyCollision {
52        /// Compact digest that mapped to conflicting canonical material.
53        digest: String,
54    },
55    /// A persisted entity key did not retain its project-qualified prefix.
56    #[error("stable entity key is not qualified by its declared project")]
57    ProjectQualificationMismatch,
58    /// A canonical resolution key and its graph owner belonged to different projects.
59    #[error("canonical resolution key belongs to a different project than its graph owner")]
60    ResolutionKeyOwnerMismatch,
61    /// A resolved relationship crossed project identity without federation.
62    #[error("resolved graph relation target belongs to another project")]
63    CrossProjectRelation,
64    /// Related graph records did not belong to one complete publication.
65    #[error("graph generation mismatch for {context}")]
66    GenerationMismatch {
67        /// Record relationship whose generations disagreed.
68        context: &'static str,
69    },
70    /// A derived graph record claimed the pre-publication zero generation.
71    #[error("graph records require a complete nonzero publication generation")]
72    InvalidGeneration,
73    /// A resolution retained a target or selector that did not identify one entity.
74    #[error("invalid graph relation resolution: {reason}")]
75    InvalidResolution {
76        /// Stable explanation suitable for diagnostics.
77        reason: &'static str,
78    },
79    /// A source span used an invalid line or ordering.
80    #[error("invalid source span: {reason}")]
81    InvalidSourceSpan {
82        /// Stable explanation suitable for diagnostics.
83        reason: &'static str,
84    },
85    /// Coverage state and counts disagreed.
86    #[error("invalid graph coverage: {reason}")]
87    InvalidCoverage {
88        /// Stable explanation suitable for diagnostics.
89        reason: &'static str,
90    },
91    /// A query budget was zero or exceeded the absolute product ceiling.
92    #[error("invalid graph limits: {reason}")]
93    InvalidLimits {
94        /// Stable explanation suitable for diagnostics.
95        reason: &'static str,
96    },
97}
98
99/// Stable identity of one `ProjectAtlas` index across supported moves and upgrades.
100#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
101pub struct ProjectInstanceId([u8; 16]);
102
103impl ProjectInstanceId {
104    /// Construct a project identity from its durable 16-byte representation.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error for the all-zero sentinel.
109    pub fn from_bytes(bytes: [u8; 16]) -> Result<Self, GraphContractError> {
110        if bytes == [0; 16] {
111            return Err(GraphContractError::InvalidProjectInstanceId {
112                reason: "the zero identifier is reserved",
113            });
114        }
115        Ok(Self(bytes))
116    }
117
118    /// Return the durable binary representation.
119    #[must_use]
120    pub const fn as_bytes(self) -> [u8; 16] {
121        self.0
122    }
123
124    /// Return the canonical lowercase hexadecimal representation.
125    #[must_use]
126    pub fn as_hex(self) -> String {
127        encode_hex(&self.0)
128    }
129}
130
131impl fmt::Display for ProjectInstanceId {
132    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133        formatter.write_str(&self.as_hex())
134    }
135}
136
137impl TryFrom<&str> for ProjectInstanceId {
138    type Error = GraphContractError;
139
140    fn try_from(value: &str) -> Result<Self, Self::Error> {
141        let compact = match value.len() {
142            32 => value.to_string(),
143            36 if value.as_bytes().get(8) == Some(&b'-')
144                && value.as_bytes().get(13) == Some(&b'-')
145                && value.as_bytes().get(18) == Some(&b'-')
146                && value.as_bytes().get(23) == Some(&b'-') =>
147            {
148                value
149                    .chars()
150                    .filter(|character| *character != '-')
151                    .collect()
152            }
153            _ => {
154                return Err(GraphContractError::InvalidProjectInstanceId {
155                    reason: "expected 32 hexadecimal digits or a hyphenated UUID",
156                });
157            }
158        };
159        if compact.len() != 32 {
160            return Err(GraphContractError::InvalidProjectInstanceId {
161                reason: "hyphenated identifier must contain exactly 32 hexadecimal digits",
162            });
163        }
164        let mut bytes = [0_u8; 16];
165        for (index, pair) in compact.as_bytes().chunks_exact(2).enumerate() {
166            let high = decode_hex(pair[0]).ok_or(GraphContractError::InvalidProjectInstanceId {
167                reason: "identifier contains a non-hexadecimal digit",
168            })?;
169            let low = decode_hex(pair[1]).ok_or(GraphContractError::InvalidProjectInstanceId {
170                reason: "identifier contains a non-hexadecimal digit",
171            })?;
172            bytes[index] = (high << 4) | low;
173        }
174        Self::from_bytes(bytes)
175    }
176}
177
178impl TryFrom<String> for ProjectInstanceId {
179    type Error = GraphContractError;
180
181    fn try_from(value: String) -> Result<Self, Self::Error> {
182        Self::try_from(value.as_str())
183    }
184}
185
186impl Serialize for ProjectInstanceId {
187    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
188    where
189        S: serde::Serializer,
190    {
191        serializer.serialize_str(&self.as_hex())
192    }
193}
194
195impl<'de> Deserialize<'de> for ProjectInstanceId {
196    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
197    where
198        D: serde::Deserializer<'de>,
199    {
200        let value = String::deserialize(deserializer)?;
201        Self::try_from(value).map_err(serde::de::Error::custom)
202    }
203}
204
205/// Validated non-empty, unpadded identity component used by graph selectors.
206#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
207#[serde(try_from = "String", into = "String")]
208pub struct GraphIdentityText(String);
209
210impl GraphIdentityText {
211    /// Validate one graph identity component.
212    ///
213    /// # Errors
214    ///
215    /// Returns an error for blank, padded, oversized, or control-bearing text.
216    pub fn new(value: impl Into<String>) -> Result<Self, GraphContractError> {
217        let value = value.into();
218        if value.trim().is_empty() {
219            return Err(GraphContractError::InvalidIdentityText {
220                reason: "identity text must not be empty",
221            });
222        }
223        if value.trim() != value {
224            return Err(GraphContractError::InvalidIdentityText {
225                reason: "identity text must not contain surrounding whitespace",
226            });
227        }
228        if value.len() > MAX_IDENTITY_BYTES {
229            return Err(GraphContractError::InvalidIdentityText {
230                reason: "identity text exceeds the byte limit",
231            });
232        }
233        if value.chars().any(char::is_control) {
234            return Err(GraphContractError::InvalidIdentityText {
235                reason: "identity text contains control characters",
236            });
237        }
238        Ok(Self(value))
239    }
240
241    /// Borrow the validated value.
242    #[must_use]
243    pub fn as_str(&self) -> &str {
244        &self.0
245    }
246}
247
248impl fmt::Display for GraphIdentityText {
249    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
250        formatter.write_str(self.as_str())
251    }
252}
253
254impl TryFrom<String> for GraphIdentityText {
255    type Error = GraphContractError;
256
257    fn try_from(value: String) -> Result<Self, Self::Error> {
258        Self::new(value)
259    }
260}
261
262impl TryFrom<&str> for GraphIdentityText {
263    type Error = GraphContractError;
264
265    fn try_from(value: &str) -> Result<Self, Self::Error> {
266        Self::new(value)
267    }
268}
269
270impl From<GraphIdentityText> for String {
271    fn from(value: GraphIdentityText) -> Self {
272        value.0
273    }
274}
275
276/// Normalized repository-relative node path, including the project root `.`.
277#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
278#[serde(try_from = "String", into = "String")]
279pub struct RepositoryNodePath(String);
280
281impl RepositoryNodePath {
282    /// Validate and normalize a repository node path through the shared validator.
283    ///
284    /// # Errors
285    ///
286    /// Returns an error for absolute, parent-traversing, empty, or non-UTF-8 paths.
287    pub fn new(path: &Path) -> Result<Self, GraphContractError> {
288        Ok(Self(validated_repo_node_key(path)?))
289    }
290
291    /// Borrow the normalized slash-separated path.
292    #[must_use]
293    pub fn as_str(&self) -> &str {
294        &self.0
295    }
296}
297
298impl TryFrom<String> for RepositoryNodePath {
299    type Error = GraphContractError;
300
301    fn try_from(value: String) -> Result<Self, Self::Error> {
302        Self::new(Path::new(&value))
303    }
304}
305
306impl From<RepositoryNodePath> for String {
307    fn from(value: RepositoryNodePath) -> Self {
308        value.0
309    }
310}
311
312/// Normalized repository-relative file path.
313#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
314#[serde(try_from = "String", into = "String")]
315pub struct RepositoryFilePath(String);
316
317impl RepositoryFilePath {
318    /// Validate and normalize a repository file path through the shared validator.
319    ///
320    /// # Errors
321    ///
322    /// Returns an error for root, absolute, parent-traversing, empty, or non-UTF-8 paths.
323    pub fn new(path: &Path) -> Result<Self, GraphContractError> {
324        Ok(Self(validated_repo_file_key(path)?))
325    }
326
327    /// Borrow the normalized slash-separated path.
328    #[must_use]
329    pub fn as_str(&self) -> &str {
330        &self.0
331    }
332}
333
334impl TryFrom<String> for RepositoryFilePath {
335    type Error = GraphContractError;
336
337    fn try_from(value: String) -> Result<Self, Self::Error> {
338        Self::new(Path::new(&value))
339    }
340}
341
342impl From<RepositoryFilePath> for String {
343    fn from(value: RepositoryFilePath) -> Self {
344        value.0
345    }
346}
347
348/// Package identity scoped by manager, name, and owning manifest.
349#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
350pub struct PackageSelector {
351    /// Package ecosystem or manifest family.
352    pub manager: GraphIdentityText,
353    /// Package name as declared by its manifest.
354    pub name: GraphIdentityText,
355    /// Repository-local manifest that owns the package.
356    pub manifest: RepositoryFilePath,
357}
358
359/// Declaration identity stable across source line movement.
360#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
361pub struct SymbolSelector {
362    /// Repository-local source file containing the declaration.
363    pub file: RepositoryFilePath,
364    /// Declaration name.
365    pub name: GraphIdentityText,
366    /// Existing `ProjectAtlas` symbol kind.
367    pub kind: SymbolKind,
368    /// Optional containing symbol or namespace.
369    pub parent: Option<GraphIdentityText>,
370    /// Normalized declaration signature that distinguishes overloads.
371    pub signature: GraphIdentityText,
372}
373
374/// External identity retained without fabricating a local target.
375#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
376pub struct ExternalSelector {
377    /// External namespace such as a package ecosystem or protocol.
378    pub system: GraphIdentityText,
379    /// Identity within the external namespace.
380    pub identity: GraphIdentityText,
381}
382
383/// Closed entity selectors owned by the repository graph domain.
384#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
385#[serde(tag = "kind", rename_all = "snake_case")]
386pub enum EntitySelector {
387    /// The selected project instance itself.
388    Project,
389    /// One repository folder.
390    Folder {
391        /// Normalized repository-relative folder path.
392        path: RepositoryNodePath,
393    },
394    /// One repository file.
395    File {
396        /// Normalized repository-relative file path.
397        path: RepositoryFilePath,
398    },
399    /// One package owned by a manifest.
400    Package {
401        /// Typed package identity.
402        package: PackageSelector,
403    },
404    /// One declaration stable across line movement.
405    Symbol {
406        /// Typed declaration identity.
407        symbol: SymbolSelector,
408    },
409    /// One target outside the selected local project.
410    External {
411        /// Typed external identity.
412        external: ExternalSelector,
413    },
414}
415
416/// Persisted digest plus the canonical material needed to detect collisions.
417#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
418#[serde(try_from = "StableKeyWire", into = "StableKeyWire")]
419struct StableKey {
420    /// Lowercase BLAKE3 digest.
421    digest: String,
422    /// Unambiguous canonical identity that produced the digest.
423    canonical_identity: String,
424}
425
426impl StableKey {
427    /// Derive a compact key while retaining its canonical collision witness.
428    fn new(canonical_identity: String) -> Self {
429        let digest = blake3::hash(canonical_identity.as_bytes())
430            .to_hex()
431            .as_str()
432            .to_owned();
433        Self {
434            digest,
435            canonical_identity,
436        }
437    }
438
439    /// Validate persisted key material.
440    fn from_persisted(
441        digest: &str,
442        canonical_identity: String,
443    ) -> Result<Self, GraphContractError> {
444        let key = Self::new(canonical_identity);
445        if key.digest != digest {
446            return Err(GraphContractError::InvalidStableKeyDigest);
447        }
448        Ok(key)
449    }
450
451    /// Compare compact keys without silently accepting a digest collision.
452    fn reconcile(&self, other: &Self) -> Result<bool, GraphContractError> {
453        if self.digest != other.digest {
454            return Ok(false);
455        }
456        if self.canonical_identity != other.canonical_identity {
457            return Err(GraphContractError::StableKeyCollision {
458                digest: self.digest.clone(),
459            });
460        }
461        Ok(true)
462    }
463
464    /// Decode the validated digest into its compact binary representation.
465    fn digest_bytes(&self) -> Result<[u8; 32], GraphContractError> {
466        let Ok(digest) = blake3::Hash::from_hex(&self.digest) else {
467            return Err(GraphContractError::InvalidStableKeyDigest);
468        };
469        Ok(*digest.as_bytes())
470    }
471}
472
473/// Serializable stable-key representation with validation on input.
474#[derive(Deserialize, Serialize)]
475struct StableKeyWire {
476    /// Lowercase BLAKE3 digest.
477    digest: String,
478    /// Canonical collision witness.
479    canonical_identity: String,
480}
481
482impl TryFrom<StableKeyWire> for StableKey {
483    type Error = GraphContractError;
484
485    fn try_from(value: StableKeyWire) -> Result<Self, Self::Error> {
486        Self::from_persisted(&value.digest, value.canonical_identity)
487    }
488}
489
490impl From<StableKey> for StableKeyWire {
491    fn from(value: StableKey) -> Self {
492        Self {
493            digest: value.digest,
494            canonical_identity: value.canonical_identity,
495        }
496    }
497}
498
499/// Stable project-qualified key for one graph entity.
500#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
501#[serde(try_from = "GraphEntityKeyWire", into = "GraphEntityKeyWire")]
502pub struct GraphEntityKey {
503    /// Project instance that owns this key.
504    project: ProjectInstanceId,
505    /// Compact digest and retained canonical identity.
506    stable: StableKey,
507}
508
509impl GraphEntityKey {
510    /// Derive a stable key from a project and typed selector.
511    #[must_use]
512    pub fn new(project: ProjectInstanceId, selector: &EntitySelector) -> Self {
513        let canonical_identity = entity_canonical_identity(project, selector);
514        Self {
515            project,
516            stable: StableKey::new(canonical_identity),
517        }
518    }
519
520    /// Return the owning project instance.
521    #[must_use]
522    pub const fn project(&self) -> ProjectInstanceId {
523        self.project
524    }
525
526    /// Borrow the lowercase compact digest.
527    #[must_use]
528    pub fn digest(&self) -> &str {
529        &self.stable.digest
530    }
531
532    /// Return the compact binary digest used by normalized persistence.
533    ///
534    /// # Errors
535    ///
536    /// Returns [`GraphContractError::InvalidStableKeyDigest`] if the private key
537    /// invariant was violated by incompatible persisted input.
538    pub fn digest_bytes(&self) -> Result<[u8; 32], GraphContractError> {
539        self.stable.digest_bytes()
540    }
541
542    /// Borrow the canonical identity retained for collision detection.
543    #[must_use]
544    pub fn canonical_identity(&self) -> &str {
545        &self.stable.canonical_identity
546    }
547
548    /// Determine whether two keys identify the same entity, failing on collision.
549    ///
550    /// # Errors
551    ///
552    /// Returns [`GraphContractError::StableKeyCollision`] when equal digests retain
553    /// different canonical identities.
554    pub fn reconcile(&self, other: &Self) -> Result<bool, GraphContractError> {
555        if self.project != other.project {
556            return Ok(false);
557        }
558        self.stable.reconcile(&other.stable)
559    }
560}
561
562/// Validated serialized entity-key representation.
563#[derive(Deserialize, Serialize)]
564struct GraphEntityKeyWire {
565    /// Owning project instance.
566    project: ProjectInstanceId,
567    /// Stable key material.
568    stable: StableKey,
569}
570
571impl TryFrom<GraphEntityKeyWire> for GraphEntityKey {
572    type Error = GraphContractError;
573
574    fn try_from(value: GraphEntityKeyWire) -> Result<Self, Self::Error> {
575        let prefix = entity_project_prefix(value.project);
576        if !has_canonical_prefix(&value.stable.canonical_identity, &prefix) {
577            return Err(GraphContractError::ProjectQualificationMismatch);
578        }
579        Ok(Self {
580            project: value.project,
581            stable: value.stable,
582        })
583    }
584}
585
586impl From<GraphEntityKey> for GraphEntityKeyWire {
587    fn from(value: GraphEntityKey) -> Self {
588        Self {
589            project: value.project,
590            stable: value.stable,
591        }
592    }
593}
594
595/// One typed graph entity at a complete index generation.
596#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
597pub struct GraphEntity {
598    /// Stable project-qualified key.
599    key: GraphEntityKey,
600    /// Typed identity material used to derive the key.
601    selector: EntitySelector,
602    /// Complete generation containing this entity.
603    generation: IndexGeneration,
604}
605
606impl GraphEntity {
607    /// Construct an entity and derive its stable key.
608    ///
609    /// # Errors
610    ///
611    /// Returns an error for the pre-publication zero generation.
612    pub fn new(
613        project: ProjectInstanceId,
614        selector: EntitySelector,
615        generation: IndexGeneration,
616    ) -> Result<Self, GraphContractError> {
617        if generation == IndexGeneration::ZERO {
618            return Err(GraphContractError::InvalidGeneration);
619        }
620        let key = GraphEntityKey::new(project, &selector);
621        Ok(Self {
622            key,
623            selector,
624            generation,
625        })
626    }
627
628    /// Borrow the stable project-qualified key.
629    #[must_use]
630    pub const fn key(&self) -> &GraphEntityKey {
631        &self.key
632    }
633
634    /// Borrow the typed identity selector.
635    #[must_use]
636    pub const fn selector(&self) -> &EntitySelector {
637        &self.selector
638    }
639
640    /// Return the complete generation containing this entity.
641    #[must_use]
642    pub const fn generation(&self) -> IndexGeneration {
643        self.generation
644    }
645}
646
647/// Additive graph-only relation families.
648#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
649#[serde(rename_all = "snake_case")]
650pub enum ExtendedRelationKind {
651    /// A source references a declaration or resource.
652    References,
653    /// A test exercises a source target.
654    Tests,
655    /// A route or protocol entry reaches a handler.
656    RoutesTo,
657    /// Configuration selects or controls a target.
658    Configures,
659    /// Deployment or infrastructure configuration provisions a target.
660    Deploys,
661    /// A source performs a bounded static read.
662    Reads,
663    /// A source performs a bounded static write.
664    Writes,
665}
666
667/// Relation family that preserves the existing exhaustive legacy enum.
668#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
669#[serde(tag = "scope", content = "value", rename_all = "snake_case")]
670pub enum GraphRelationKind {
671    /// Existing `ProjectAtlas` relation value and payload spelling.
672    Legacy(RelationKind),
673    /// Additive repository-wide graph relation.
674    Extended(ExtendedRelationKind),
675}
676
677impl GraphRelationKind {
678    /// Complete stable persisted relation-kind set.
679    pub const ALL: [Self; 11] = [
680        Self::Legacy(RelationKind::Contains),
681        Self::Legacy(RelationKind::Imports),
682        Self::Legacy(RelationKind::Calls),
683        Self::Legacy(RelationKind::DependsOn),
684        Self::Extended(ExtendedRelationKind::References),
685        Self::Extended(ExtendedRelationKind::Tests),
686        Self::Extended(ExtendedRelationKind::RoutesTo),
687        Self::Extended(ExtendedRelationKind::Configures),
688        Self::Extended(ExtendedRelationKind::Deploys),
689        Self::Extended(ExtendedRelationKind::Reads),
690        Self::Extended(ExtendedRelationKind::Writes),
691    ];
692
693    /// Wrap one legacy relation without changing its enum.
694    #[must_use]
695    pub const fn from_legacy(kind: RelationKind) -> Self {
696        Self::Legacy(kind)
697    }
698
699    /// Return the old projection when this is an existing relation family.
700    #[must_use]
701    pub const fn legacy_kind(self) -> Option<RelationKind> {
702        match self {
703            Self::Legacy(kind) => Some(kind),
704            Self::Extended(_) => None,
705        }
706    }
707
708    /// Return the stable canonical relation spelling.
709    #[must_use]
710    pub const fn as_str(self) -> &'static str {
711        match self {
712            Self::Legacy(RelationKind::Contains) => "legacy:contains",
713            Self::Legacy(RelationKind::Imports) => "legacy:imports",
714            Self::Legacy(RelationKind::Calls) => "legacy:calls",
715            Self::Legacy(RelationKind::DependsOn) => "legacy:depends-on",
716            Self::Extended(ExtendedRelationKind::References) => "extended:references",
717            Self::Extended(ExtendedRelationKind::Tests) => "extended:tests",
718            Self::Extended(ExtendedRelationKind::RoutesTo) => "extended:routes-to",
719            Self::Extended(ExtendedRelationKind::Configures) => "extended:configures",
720            Self::Extended(ExtendedRelationKind::Deploys) => "extended:deploys",
721            Self::Extended(ExtendedRelationKind::Reads) => "extended:reads",
722            Self::Extended(ExtendedRelationKind::Writes) => "extended:writes",
723        }
724    }
725}
726
727/// One exact source range using one-based lines and zero-based columns.
728#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
729#[serde(try_from = "SourceSpanWire", into = "SourceSpanWire")]
730pub struct SourceSpan {
731    /// First one-based source line.
732    start_line: u32,
733    /// First zero-based source column.
734    start_column: u32,
735    /// Last one-based source line.
736    end_line: u32,
737    /// Exclusive zero-based end column.
738    end_column: u32,
739}
740
741impl SourceSpan {
742    /// Validate a non-reversed source range.
743    ///
744    /// # Errors
745    ///
746    /// Returns an error for zero lines or an end before the start.
747    pub fn new(
748        start_line: u32,
749        start_column: u32,
750        end_line: u32,
751        end_column: u32,
752    ) -> Result<Self, GraphContractError> {
753        if start_line == 0 || end_line == 0 {
754            return Err(GraphContractError::InvalidSourceSpan {
755                reason: "source lines are one-based",
756            });
757        }
758        if (end_line, end_column) < (start_line, start_column) {
759            return Err(GraphContractError::InvalidSourceSpan {
760                reason: "source span end precedes its start",
761            });
762        }
763        Ok(Self {
764            start_line,
765            start_column,
766            end_line,
767            end_column,
768        })
769    }
770
771    /// Return the first one-based line.
772    #[must_use]
773    pub const fn start_line(self) -> u32 {
774        self.start_line
775    }
776
777    /// Return the first zero-based column.
778    #[must_use]
779    pub const fn start_column(self) -> u32 {
780        self.start_column
781    }
782
783    /// Return the last one-based line.
784    #[must_use]
785    pub const fn end_line(self) -> u32 {
786        self.end_line
787    }
788
789    /// Return the exclusive zero-based end column.
790    #[must_use]
791    pub const fn end_column(self) -> u32 {
792        self.end_column
793    }
794}
795
796/// Source-span wire shape validated during deserialization.
797#[derive(Deserialize, Serialize)]
798struct SourceSpanWire {
799    /// First one-based line.
800    start_line: u32,
801    /// First zero-based column.
802    start_column: u32,
803    /// Last one-based line.
804    end_line: u32,
805    /// Exclusive zero-based end column.
806    end_column: u32,
807}
808
809impl TryFrom<SourceSpanWire> for SourceSpan {
810    type Error = GraphContractError;
811
812    fn try_from(value: SourceSpanWire) -> Result<Self, Self::Error> {
813        Self::new(
814            value.start_line,
815            value.start_column,
816            value.end_line,
817            value.end_column,
818        )
819    }
820}
821
822impl From<SourceSpan> for SourceSpanWire {
823    fn from(value: SourceSpan) -> Self {
824        Self {
825            start_line: value.start_line,
826            start_column: value.start_column,
827            end_line: value.end_line,
828            end_column: value.end_column,
829        }
830    }
831}
832
833/// Exact local selectors reusable by summary, relation, and source-slice adapters.
834#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
835#[serde(tag = "kind", rename_all = "snake_case")]
836pub enum ReusableTargetSelector {
837    /// Select one repository folder.
838    Folder {
839        /// Exact normalized folder path.
840        folder: RepositoryNodePath,
841    },
842    /// Select one repository file.
843    File {
844        /// Exact normalized file path.
845        file: RepositoryFilePath,
846    },
847    /// Select one package through its exact manifest-owned identity.
848    Package {
849        /// Exact package identity whose manifest is directly reusable by file calls.
850        package: PackageSelector,
851    },
852    /// Select one declaration without depending on its current line.
853    Symbol {
854        /// Stable declaration identity.
855        symbol: SymbolSelector,
856    },
857}
858
859impl ReusableTargetSelector {
860    /// Derive the exact reusable selector for one navigable local entity.
861    fn for_entity(target: &GraphEntity) -> Result<Self, GraphContractError> {
862        match &target.selector {
863            EntitySelector::Project => Err(GraphContractError::InvalidResolution {
864                reason: "the project aggregate is not a direct source target",
865            }),
866            EntitySelector::Folder { path } => Ok(Self::Folder {
867                folder: path.clone(),
868            }),
869            EntitySelector::File { path } => Ok(Self::File { file: path.clone() }),
870            EntitySelector::Package { package } => Ok(Self::Package {
871                package: package.clone(),
872            }),
873            EntitySelector::Symbol { symbol } => Ok(Self::Symbol {
874                symbol: symbol.clone(),
875            }),
876            EntitySelector::External { .. } => Err(GraphContractError::InvalidResolution {
877                reason: "external entities do not have a local reusable selector",
878            }),
879        }
880    }
881
882    /// Return the entity identity selected by this navigation target.
883    fn entity_selector(&self) -> EntitySelector {
884        match self {
885            Self::Folder { folder } => EntitySelector::Folder {
886                path: folder.clone(),
887            },
888            Self::File { file } => EntitySelector::File { path: file.clone() },
889            Self::Package { package } => EntitySelector::Package {
890                package: package.clone(),
891            },
892            Self::Symbol { symbol } => EntitySelector::Symbol {
893                symbol: symbol.clone(),
894            },
895        }
896    }
897}
898
899/// Relationship resolution state and optional exact local jump target.
900#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
901#[serde(tag = "status", rename_all = "snake_case")]
902pub enum RelationResolution {
903    /// Exactly one local entity was resolved.
904    Resolved {
905        /// Stable target entity.
906        target: GraphEntityKey,
907        /// Exact selector accepted by a later summary, relation, or slice adapter.
908        selector: ReusableTargetSelector,
909        /// Complete generation containing the resolved target.
910        generation: IndexGeneration,
911    },
912    /// More than one statically valid target remains.
913    Ambiguous {
914        /// Original normalized reference text.
915        reference: GraphIdentityText,
916        /// Number of retained valid candidates before result limits.
917        candidates: NonZeroU32,
918    },
919    /// No supported static target was found.
920    Unresolved {
921        /// Original normalized reference text.
922        reference: GraphIdentityText,
923    },
924    /// The target is intentionally outside the selected local project.
925    External {
926        /// Project-qualified external entity key.
927        target: GraphEntityKey,
928        /// Typed external identity retained for fail-closed validation.
929        external: ExternalSelector,
930        /// Complete generation containing the external entity record.
931        generation: IndexGeneration,
932    },
933}
934
935impl RelationResolution {
936    /// Construct one exact local resolution from a graph entity.
937    ///
938    /// # Errors
939    ///
940    /// Returns an error when the target is not a navigable local entity.
941    pub fn resolved(target: &GraphEntity) -> Result<Self, GraphContractError> {
942        Ok(Self::Resolved {
943            target: target.key.clone(),
944            selector: ReusableTargetSelector::for_entity(target)?,
945            generation: target.generation,
946        })
947    }
948
949    /// Construct one external resolution from a graph entity record.
950    ///
951    /// # Errors
952    ///
953    /// Returns an error when the target is not an external entity record.
954    pub fn external(target: &GraphEntity) -> Result<Self, GraphContractError> {
955        let EntitySelector::External { external } = &target.selector else {
956            return Err(GraphContractError::InvalidResolution {
957                reason: "external resolution requires an external entity",
958            });
959        };
960        Ok(Self::External {
961            target: target.key.clone(),
962            external: external.clone(),
963            generation: target.generation,
964        })
965    }
966
967    /// Return the exact traversable local target, if one exists.
968    #[must_use]
969    pub const fn resolved_target(&self) -> Option<&GraphEntityKey> {
970        match self {
971            Self::Resolved { target, .. } => Some(target),
972            Self::Ambiguous { .. } | Self::Unresolved { .. } | Self::External { .. } => None,
973        }
974    }
975
976    /// Return any project-qualified target key retained by the resolution.
977    const fn target_key(&self) -> Option<&GraphEntityKey> {
978        match self {
979            Self::Resolved { target, .. } | Self::External { target, .. } => Some(target),
980            Self::Ambiguous { .. } | Self::Unresolved { .. } => None,
981        }
982    }
983
984    /// Return the generation of a retained target entity, when one exists.
985    const fn target_generation(&self) -> Option<IndexGeneration> {
986        match self {
987            Self::Resolved { generation, .. } | Self::External { generation, .. } => {
988                Some(*generation)
989            }
990            Self::Ambiguous { .. } | Self::Unresolved { .. } => None,
991        }
992    }
993
994    /// Append stable resolution material to a canonical relation identity.
995    fn append_canonical(&self, canonical: &mut String) {
996        match self {
997            Self::Resolved { target, .. } => {
998                append_canonical_field(canonical, "resolved");
999                append_canonical_field(canonical, target.canonical_identity());
1000            }
1001            Self::Ambiguous { reference, .. } => {
1002                append_canonical_field(canonical, "ambiguous");
1003                append_canonical_field(canonical, reference.as_str());
1004            }
1005            Self::Unresolved { reference } => {
1006                append_canonical_field(canonical, "unresolved");
1007                append_canonical_field(canonical, reference.as_str());
1008            }
1009            Self::External { target, .. } => {
1010                append_canonical_field(canonical, "external");
1011                append_canonical_field(canonical, target.canonical_identity());
1012            }
1013        }
1014    }
1015}
1016
1017/// Coarse confidence that avoids unsupported numeric precision.
1018#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1019#[serde(rename_all = "snake_case")]
1020pub enum ConfidenceClass {
1021    /// Direct language or manifest semantics establish the fact.
1022    Exact,
1023    /// Strong deterministic evidence supports the fact.
1024    High,
1025    /// Multiple conservative signals support the fact.
1026    Medium,
1027    /// The fact is useful only as a weak candidate.
1028    Low,
1029}
1030
1031/// Whether the producer observed all supported facts for its bounded scope.
1032#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1033#[serde(rename_all = "snake_case")]
1034pub enum Completeness {
1035    /// Every supported fact in the scope was considered.
1036    Complete,
1037    /// A declared limit or unsupported region omitted some facts.
1038    Partial,
1039}
1040
1041/// Stable project-qualified key for a deduplicated logical relation.
1042#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1043#[serde(try_from = "LogicalRelationKeyWire", into = "LogicalRelationKeyWire")]
1044pub struct LogicalRelationKey {
1045    /// Project instance that owns this relation.
1046    project: ProjectInstanceId,
1047    /// Compact digest and retained canonical identity.
1048    stable: StableKey,
1049}
1050
1051/// Validated serialized logical-relation key representation.
1052#[derive(Deserialize, Serialize)]
1053struct LogicalRelationKeyWire {
1054    /// Owning project instance.
1055    project: ProjectInstanceId,
1056    /// Stable relation-key material.
1057    stable: StableKey,
1058}
1059
1060impl TryFrom<LogicalRelationKeyWire> for LogicalRelationKey {
1061    type Error = GraphContractError;
1062
1063    fn try_from(value: LogicalRelationKeyWire) -> Result<Self, Self::Error> {
1064        let prefix = relation_project_prefix(value.project);
1065        if !has_canonical_prefix(&value.stable.canonical_identity, &prefix) {
1066            return Err(GraphContractError::ProjectQualificationMismatch);
1067        }
1068        Ok(Self {
1069            project: value.project,
1070            stable: value.stable,
1071        })
1072    }
1073}
1074
1075impl From<LogicalRelationKey> for LogicalRelationKeyWire {
1076    fn from(value: LogicalRelationKey) -> Self {
1077        Self {
1078            project: value.project,
1079            stable: value.stable,
1080        }
1081    }
1082}
1083
1084impl LogicalRelationKey {
1085    /// Return the owning project instance.
1086    #[must_use]
1087    pub const fn project(&self) -> ProjectInstanceId {
1088        self.project
1089    }
1090
1091    /// Borrow the lowercase compact digest.
1092    #[must_use]
1093    pub fn digest(&self) -> &str {
1094        &self.stable.digest
1095    }
1096
1097    /// Return the compact binary digest used by normalized persistence.
1098    ///
1099    /// # Errors
1100    ///
1101    /// Returns [`GraphContractError::InvalidStableKeyDigest`] if the private key
1102    /// invariant was violated by incompatible persisted input.
1103    pub fn digest_bytes(&self) -> Result<[u8; 32], GraphContractError> {
1104        self.stable.digest_bytes()
1105    }
1106
1107    /// Borrow the canonical identity retained for collision detection.
1108    #[must_use]
1109    pub fn canonical_identity(&self) -> &str {
1110        &self.stable.canonical_identity
1111    }
1112
1113    /// Determine whether two keys identify the same logical relation.
1114    ///
1115    /// # Errors
1116    ///
1117    /// Returns [`GraphContractError::StableKeyCollision`] for conflicting
1118    /// canonical identities with the same digest.
1119    pub fn reconcile(&self, other: &Self) -> Result<bool, GraphContractError> {
1120        if self.project != other.project {
1121            return Ok(false);
1122        }
1123        self.stable.reconcile(&other.stable)
1124    }
1125}
1126
1127/// Closed target-identity families used by canonical resolution keys.
1128#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
1129#[serde(rename_all = "snake_case")]
1130pub enum ResolutionKeyDomain {
1131    /// A declaration, value, type, or other named source symbol.
1132    Declaration,
1133    /// A source module, namespace, or importable file identity.
1134    Module,
1135    /// A package or manifest dependency identity.
1136    Package,
1137}
1138
1139impl ResolutionKeyDomain {
1140    /// Return the stable `SQLite` and wire representation.
1141    #[must_use]
1142    pub const fn as_str(self) -> &'static str {
1143        match self {
1144            Self::Declaration => "declaration",
1145            Self::Module => "module",
1146            Self::Package => "package",
1147        }
1148    }
1149}
1150
1151impl TryFrom<&str> for ResolutionKeyDomain {
1152    type Error = GraphContractError;
1153
1154    fn try_from(value: &str) -> Result<Self, Self::Error> {
1155        match value {
1156            "declaration" => Ok(Self::Declaration),
1157            "module" => Ok(Self::Module),
1158            "package" => Ok(Self::Package),
1159            _ => Err(GraphContractError::InvalidResolutionKeyDomain),
1160        }
1161    }
1162}
1163
1164/// Project-qualified canonical identity used for export and dependency resolution.
1165///
1166/// The fixed digest is the indexed hot-path value. The canonical identity remains
1167/// alongside it as the collision witness and includes every identity-affecting
1168/// provider, language, package, scope, relation-family, and target field.
1169#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
1170#[serde(
1171    try_from = "CanonicalResolutionKeyWire",
1172    into = "CanonicalResolutionKeyWire"
1173)]
1174pub struct CanonicalResolutionKey {
1175    /// Project instance whose resolver namespace owns this key.
1176    project: ProjectInstanceId,
1177    /// Closed resolver family.
1178    domain: ResolutionKeyDomain,
1179    /// Fixed compact key used by `SQLite` indexes.
1180    digest: [u8; 32],
1181    /// Canonical collision witness.
1182    canonical_identity: String,
1183}
1184
1185impl CanonicalResolutionKey {
1186    /// Construct a deterministic canonical resolution key.
1187    #[allow(clippy::too_many_arguments)]
1188    #[must_use]
1189    pub fn new(
1190        project: ProjectInstanceId,
1191        domain: ResolutionKeyDomain,
1192        provider: &GraphIdentityText,
1193        language: &GraphIdentityText,
1194        package: Option<&GraphIdentityText>,
1195        scope: Option<&GraphIdentityText>,
1196        relation: Option<GraphRelationKind>,
1197        identity: &GraphIdentityText,
1198    ) -> Self {
1199        let mut canonical_identity = resolution_project_prefix(project, domain);
1200        append_canonical_field(&mut canonical_identity, provider.as_str());
1201        append_canonical_field(&mut canonical_identity, language.as_str());
1202        append_optional_canonical_field(&mut canonical_identity, package);
1203        append_optional_canonical_field(&mut canonical_identity, scope);
1204        append_optional_raw_canonical_field(
1205            &mut canonical_identity,
1206            relation.map(GraphRelationKind::as_str),
1207        );
1208        append_canonical_field(&mut canonical_identity, identity.as_str());
1209        let digest = *blake3::hash(canonical_identity.as_bytes()).as_bytes();
1210        Self {
1211            project,
1212            domain,
1213            digest,
1214            canonical_identity,
1215        }
1216    }
1217
1218    /// Reconstruct and validate persisted canonical key material.
1219    ///
1220    /// # Errors
1221    ///
1222    /// Returns an error when the witness is not qualified by the declared project
1223    /// and domain or its digest does not match.
1224    pub fn from_persisted(
1225        project: ProjectInstanceId,
1226        domain: ResolutionKeyDomain,
1227        digest: [u8; 32],
1228        canonical_identity: String,
1229    ) -> Result<Self, GraphContractError> {
1230        let prefix = resolution_project_prefix(project, domain);
1231        if !has_canonical_prefix(&canonical_identity, &prefix) {
1232            return Err(GraphContractError::ProjectQualificationMismatch);
1233        }
1234        if *blake3::hash(canonical_identity.as_bytes()).as_bytes() != digest {
1235            return Err(GraphContractError::InvalidStableKeyDigest);
1236        }
1237        Ok(Self {
1238            project,
1239            domain,
1240            digest,
1241            canonical_identity,
1242        })
1243    }
1244
1245    /// Return the owning project instance.
1246    #[must_use]
1247    pub const fn project(&self) -> ProjectInstanceId {
1248        self.project
1249    }
1250
1251    /// Return the closed resolver domain.
1252    #[must_use]
1253    pub const fn domain(&self) -> ResolutionKeyDomain {
1254        self.domain
1255    }
1256
1257    /// Return the fixed compact digest used by normalized persistence.
1258    #[must_use]
1259    pub const fn digest_bytes(&self) -> [u8; 32] {
1260        self.digest
1261    }
1262
1263    /// Borrow the canonical collision witness.
1264    #[must_use]
1265    pub fn canonical_identity(&self) -> &str {
1266        &self.canonical_identity
1267    }
1268
1269    /// Remove project qualification while retaining the exact canonical
1270    /// resolver material needed to rebind a derived graph snapshot.
1271    ///
1272    /// # Errors
1273    ///
1274    /// Returns an error if the private canonical-key invariant was violated.
1275    pub fn portable(&self) -> Result<PortableResolutionKey, GraphContractError> {
1276        let prefix = resolution_project_prefix(self.project, self.domain);
1277        let Some(canonical_identity) = self.canonical_identity.strip_prefix(&prefix) else {
1278            return Err(GraphContractError::ProjectQualificationMismatch);
1279        };
1280        PortableResolutionKey::new(self.domain, canonical_identity.to_string())
1281    }
1282
1283    /// Compare compact keys without silently accepting a digest collision.
1284    ///
1285    /// # Errors
1286    ///
1287    /// Returns [`GraphContractError::StableKeyCollision`] when equal compact keys
1288    /// retain different canonical material.
1289    pub fn reconcile(&self, other: &Self) -> Result<bool, GraphContractError> {
1290        if self.project != other.project || self.domain != other.domain {
1291            return Ok(false);
1292        }
1293        if self.digest != other.digest {
1294            return Ok(false);
1295        }
1296        if self.canonical_identity != other.canonical_identity {
1297            return Err(GraphContractError::StableKeyCollision {
1298                digest: encode_hex(&self.digest),
1299            });
1300        }
1301        Ok(true)
1302    }
1303}
1304
1305/// Project-independent canonical resolution identity carried by a derived
1306/// graph snapshot.
1307#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
1308#[serde(
1309    try_from = "PortableResolutionKeyWire",
1310    into = "PortableResolutionKeyWire"
1311)]
1312pub struct PortableResolutionKey {
1313    /// Closed resolver family.
1314    domain: ResolutionKeyDomain,
1315    /// Exact length-prefixed canonical fields after project qualification.
1316    canonical_identity: String,
1317}
1318
1319impl PortableResolutionKey {
1320    /// Validate one portable resolver identity.
1321    ///
1322    /// # Errors
1323    ///
1324    /// Returns an error for oversized, malformed, or incomplete canonical
1325    /// field material.
1326    pub fn new(
1327        domain: ResolutionKeyDomain,
1328        canonical_identity: String,
1329    ) -> Result<Self, GraphContractError> {
1330        if canonical_identity.len() > MAX_PORTABLE_RESOLUTION_IDENTITY_BYTES
1331            || !valid_canonical_field_sequence(&canonical_identity)
1332        {
1333            return Err(GraphContractError::InvalidResolutionKeyIdentity);
1334        }
1335        Ok(Self {
1336            domain,
1337            canonical_identity,
1338        })
1339    }
1340
1341    /// Return the closed resolver family.
1342    #[must_use]
1343    pub const fn domain(&self) -> ResolutionKeyDomain {
1344        self.domain
1345    }
1346
1347    /// Borrow the project-independent canonical identity.
1348    #[must_use]
1349    pub fn canonical_identity(&self) -> &str {
1350        &self.canonical_identity
1351    }
1352
1353    /// Bind this portable identity to one destination project.
1354    #[must_use]
1355    pub fn bind(&self, project: ProjectInstanceId) -> CanonicalResolutionKey {
1356        let mut canonical_identity = resolution_project_prefix(project, self.domain);
1357        canonical_identity.push_str(&self.canonical_identity);
1358        let digest = *blake3::hash(canonical_identity.as_bytes()).as_bytes();
1359        CanonicalResolutionKey {
1360            project,
1361            domain: self.domain,
1362            digest,
1363            canonical_identity,
1364        }
1365    }
1366}
1367
1368/// Validated serialized portable resolution-key representation.
1369#[derive(Deserialize, Serialize)]
1370struct PortableResolutionKeyWire {
1371    /// Closed resolver family.
1372    domain: ResolutionKeyDomain,
1373    /// Exact project-independent canonical fields.
1374    canonical_identity: String,
1375}
1376
1377impl TryFrom<PortableResolutionKeyWire> for PortableResolutionKey {
1378    type Error = GraphContractError;
1379
1380    fn try_from(value: PortableResolutionKeyWire) -> Result<Self, Self::Error> {
1381        Self::new(value.domain, value.canonical_identity)
1382    }
1383}
1384
1385impl From<PortableResolutionKey> for PortableResolutionKeyWire {
1386    fn from(value: PortableResolutionKey) -> Self {
1387        Self {
1388            domain: value.domain,
1389            canonical_identity: value.canonical_identity,
1390        }
1391    }
1392}
1393
1394/// Validated serialized canonical resolution-key representation.
1395#[derive(Deserialize, Serialize)]
1396struct CanonicalResolutionKeyWire {
1397    /// Owning project instance.
1398    project: ProjectInstanceId,
1399    /// Closed resolver family.
1400    domain: ResolutionKeyDomain,
1401    /// Fixed compact digest.
1402    digest: [u8; 32],
1403    /// Canonical collision witness.
1404    canonical_identity: String,
1405}
1406
1407impl TryFrom<CanonicalResolutionKeyWire> for CanonicalResolutionKey {
1408    type Error = GraphContractError;
1409
1410    fn try_from(value: CanonicalResolutionKeyWire) -> Result<Self, Self::Error> {
1411        Self::from_persisted(
1412            value.project,
1413            value.domain,
1414            value.digest,
1415            value.canonical_identity,
1416        )
1417    }
1418}
1419
1420impl From<CanonicalResolutionKey> for CanonicalResolutionKeyWire {
1421    fn from(value: CanonicalResolutionKey) -> Self {
1422        Self {
1423            project: value.project,
1424            domain: value.domain,
1425            digest: value.digest,
1426            canonical_identity: value.canonical_identity,
1427        }
1428    }
1429}
1430
1431/// One exported canonical key bound to its owning graph entity.
1432#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1433pub struct EntityResolutionKey {
1434    /// Entity that exports the canonical identity.
1435    entity: GraphEntityKey,
1436    /// Canonical resolver identity exported by the entity.
1437    key: CanonicalResolutionKey,
1438}
1439
1440impl EntityResolutionKey {
1441    /// Bind one canonical key to its exported entity.
1442    ///
1443    /// # Errors
1444    ///
1445    /// Returns an error when the entity and key belong to different projects.
1446    pub fn new(
1447        entity: GraphEntityKey,
1448        key: CanonicalResolutionKey,
1449    ) -> Result<Self, GraphContractError> {
1450        if entity.project() != key.project() {
1451            return Err(GraphContractError::ResolutionKeyOwnerMismatch);
1452        }
1453        Ok(Self { entity, key })
1454    }
1455
1456    /// Borrow the owning entity key.
1457    #[must_use]
1458    pub const fn entity(&self) -> &GraphEntityKey {
1459        &self.entity
1460    }
1461
1462    /// Borrow the canonical resolver key.
1463    #[must_use]
1464    pub const fn key(&self) -> &CanonicalResolutionKey {
1465        &self.key
1466    }
1467}
1468
1469/// One canonical dependency identity bound to its owning logical relation.
1470#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1471pub struct RelationDependencyKey {
1472    /// Logical relation that depends on the canonical identity.
1473    relation: LogicalRelationKey,
1474    /// Canonical identity used to select candidate exports.
1475    key: CanonicalResolutionKey,
1476}
1477
1478impl RelationDependencyKey {
1479    /// Bind one dependency identity to its logical relation.
1480    ///
1481    /// # Errors
1482    ///
1483    /// Returns an error when the relation and key belong to different projects.
1484    pub fn new(
1485        relation: LogicalRelationKey,
1486        key: CanonicalResolutionKey,
1487    ) -> Result<Self, GraphContractError> {
1488        if relation.project() != key.project() {
1489            return Err(GraphContractError::ResolutionKeyOwnerMismatch);
1490        }
1491        Ok(Self { relation, key })
1492    }
1493
1494    /// Borrow the owning logical-relation key.
1495    #[must_use]
1496    pub const fn relation(&self) -> &LogicalRelationKey {
1497        &self.relation
1498    }
1499
1500    /// Borrow the canonical resolver key.
1501    #[must_use]
1502    pub const fn key(&self) -> &CanonicalResolutionKey {
1503        &self.key
1504    }
1505}
1506
1507/// One deduplicated source-kind-target relationship at a complete generation.
1508#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1509pub struct LogicalRelation {
1510    /// Stable key independent of individual source occurrences.
1511    key: LogicalRelationKey,
1512    /// Source entity.
1513    source: GraphEntityKey,
1514    /// Typed legacy or additive family.
1515    kind: GraphRelationKind,
1516    /// Resolution state and optional exact local target.
1517    resolution: RelationResolution,
1518    /// Coarse trust class.
1519    confidence: ConfidenceClass,
1520    /// Producer completeness for this relation scope.
1521    completeness: Completeness,
1522    /// Complete generation containing the relation.
1523    generation: IndexGeneration,
1524}
1525
1526impl LogicalRelation {
1527    /// Construct one logical relation and derive its stable key.
1528    ///
1529    /// # Errors
1530    ///
1531    /// Returns an error when the source or target belongs to another generation,
1532    /// a retained target belongs to another project, a reusable selector names a
1533    /// different entity, or the relation claims the zero generation.
1534    pub fn new(
1535        source: &GraphEntity,
1536        kind: GraphRelationKind,
1537        resolution: RelationResolution,
1538        confidence: ConfidenceClass,
1539        completeness: Completeness,
1540        generation: IndexGeneration,
1541    ) -> Result<Self, GraphContractError> {
1542        if generation == IndexGeneration::ZERO {
1543            return Err(GraphContractError::InvalidGeneration);
1544        }
1545        if source.generation != generation {
1546            return Err(GraphContractError::GenerationMismatch {
1547                context: "logical relation source",
1548            });
1549        }
1550        if resolution
1551            .target_key()
1552            .is_some_and(|target| target.project() != source.key.project())
1553        {
1554            return Err(GraphContractError::CrossProjectRelation);
1555        }
1556        if resolution
1557            .target_generation()
1558            .is_some_and(|target_generation| target_generation != generation)
1559        {
1560            return Err(GraphContractError::GenerationMismatch {
1561                context: "logical relation target",
1562            });
1563        }
1564        let selected = match &resolution {
1565            RelationResolution::Resolved {
1566                target, selector, ..
1567            } => Some((target, selector.entity_selector())),
1568            RelationResolution::External {
1569                target, external, ..
1570            } => Some((
1571                target,
1572                EntitySelector::External {
1573                    external: external.clone(),
1574                },
1575            )),
1576            RelationResolution::Ambiguous { .. } | RelationResolution::Unresolved { .. } => None,
1577        };
1578        if let Some((target, selector)) = selected {
1579            let selected = GraphEntityKey::new(target.project(), &selector);
1580            if !target.reconcile(&selected)? {
1581                return Err(GraphContractError::InvalidResolution {
1582                    reason: "resolution selector does not identify its retained target",
1583                });
1584            }
1585        }
1586        let mut canonical = relation_project_prefix(source.key.project());
1587        append_canonical_field(&mut canonical, source.key.canonical_identity());
1588        append_canonical_field(&mut canonical, kind.as_str());
1589        resolution.append_canonical(&mut canonical);
1590        let key = LogicalRelationKey {
1591            project: source.key.project(),
1592            stable: StableKey::new(canonical),
1593        };
1594        Ok(Self {
1595            key,
1596            source: source.key.clone(),
1597            kind,
1598            resolution,
1599            confidence,
1600            completeness,
1601            generation,
1602        })
1603    }
1604
1605    /// Borrow the stable logical-relation key.
1606    #[must_use]
1607    pub const fn key(&self) -> &LogicalRelationKey {
1608        &self.key
1609    }
1610
1611    /// Borrow the source entity key.
1612    #[must_use]
1613    pub const fn source(&self) -> &GraphEntityKey {
1614        &self.source
1615    }
1616
1617    /// Return the typed relation family.
1618    #[must_use]
1619    pub const fn kind(&self) -> GraphRelationKind {
1620        self.kind
1621    }
1622
1623    /// Borrow the relation resolution state.
1624    #[must_use]
1625    pub const fn resolution(&self) -> &RelationResolution {
1626        &self.resolution
1627    }
1628
1629    /// Return the coarse relation confidence.
1630    #[must_use]
1631    pub const fn confidence(&self) -> ConfidenceClass {
1632        self.confidence
1633    }
1634
1635    /// Return the producer completeness for this relation.
1636    #[must_use]
1637    pub const fn completeness(&self) -> Completeness {
1638        self.completeness
1639    }
1640
1641    /// Return the complete generation containing this relation.
1642    #[must_use]
1643    pub const fn generation(&self) -> IndexGeneration {
1644        self.generation
1645    }
1646}
1647
1648/// One exact source occurrence supporting a logical relationship.
1649#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1650pub struct RelationOccurrence {
1651    /// Deduplicated logical relation supported by this occurrence.
1652    relation: LogicalRelationKey,
1653    /// Repository-local file containing the evidence.
1654    file: RepositoryFilePath,
1655    /// Exact supporting source range.
1656    span: SourceSpan,
1657    /// Complete generation containing the occurrence.
1658    generation: IndexGeneration,
1659}
1660
1661impl RelationOccurrence {
1662    /// Construct one source occurrence for a logical relation.
1663    ///
1664    /// # Errors
1665    ///
1666    /// Returns an error when the occurrence and relation generations differ.
1667    pub fn new(
1668        relation: &LogicalRelation,
1669        file: RepositoryFilePath,
1670        span: SourceSpan,
1671        generation: IndexGeneration,
1672    ) -> Result<Self, GraphContractError> {
1673        if relation.generation != generation {
1674            return Err(GraphContractError::GenerationMismatch {
1675                context: "relation occurrence",
1676            });
1677        }
1678        Ok(Self {
1679            relation: relation.key.clone(),
1680            file,
1681            span,
1682            generation,
1683        })
1684    }
1685
1686    /// Borrow the supported logical-relation key.
1687    #[must_use]
1688    pub const fn relation(&self) -> &LogicalRelationKey {
1689        &self.relation
1690    }
1691
1692    /// Borrow the repository-local source file.
1693    #[must_use]
1694    pub const fn file(&self) -> &RepositoryFilePath {
1695        &self.file
1696    }
1697
1698    /// Return the exact supporting source span.
1699    #[must_use]
1700    pub const fn span(&self) -> SourceSpan {
1701        self.span
1702    }
1703
1704    /// Return the complete generation containing this occurrence.
1705    #[must_use]
1706    pub const fn generation(&self) -> IndexGeneration {
1707        self.generation
1708    }
1709}
1710
1711/// Coverage lifecycle state for one indexed graph scope.
1712#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1713#[serde(rename_all = "snake_case")]
1714pub enum CoverageState {
1715    /// Every supported item was covered.
1716    Complete,
1717    /// Some supported items were covered and some were omitted.
1718    Partial,
1719    /// Extraction failed for the whole scope.
1720    Failed,
1721    /// Configuration intentionally excluded the scope.
1722    Ignored,
1723    /// A declared size or work limit excluded the scope.
1724    Oversized,
1725    /// Trust policy isolated the scope.
1726    Quarantined,
1727    /// Previously derived coverage no longer matches current source state.
1728    Stale,
1729}
1730
1731/// Indexed scope whose graph coverage is being reported.
1732#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1733#[serde(tag = "kind", rename_all = "snake_case")]
1734pub enum CoverageScope {
1735    /// The selected project as a whole.
1736    Project,
1737    /// One repository path.
1738    Path {
1739        /// Normalized repository-relative path.
1740        path: RepositoryNodePath,
1741    },
1742}
1743
1744/// Absolute product limit that a coverage or query row reached.
1745#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1746#[serde(rename_all = "snake_case")]
1747pub enum GraphLimitKind {
1748    /// Result-row limit.
1749    Rows,
1750    /// Unique or active node limit.
1751    Nodes,
1752    /// Inspected logical-edge limit.
1753    Edges,
1754    /// Per-relation source-occurrence limit.
1755    Occurrences,
1756    /// Node-simple visited-state limit.
1757    Visited,
1758    /// Decoded or retained intermediate-memory byte limit.
1759    IntermediateBytes,
1760    /// Elapsed request deadline.
1761    Deadline,
1762    /// Traversal-depth limit.
1763    Depth,
1764    /// Encoded-output byte limit.
1765    OutputBytes,
1766}
1767
1768/// Coverage report with counts consistent with its lifecycle state.
1769#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1770pub struct CoverageRecord {
1771    /// Covered project or path.
1772    scope: CoverageScope,
1773    /// Optional relation family when the row is relation-specific.
1774    relation: Option<GraphRelationKind>,
1775    /// Coverage lifecycle state.
1776    state: CoverageState,
1777    /// Supported items in the scope.
1778    total: u64,
1779    /// Successfully covered items.
1780    covered: u64,
1781    /// Omitted, failed, or untrusted items.
1782    omitted: u64,
1783    /// Complete generation associated with this row.
1784    generation: IndexGeneration,
1785    /// Actionable non-complete explanation.
1786    reason: Option<GraphIdentityText>,
1787    /// Product limit reached, when applicable.
1788    reached_limit: Option<GraphLimitKind>,
1789}
1790
1791impl CoverageRecord {
1792    /// Construct a coverage row and enforce count/state consistency.
1793    ///
1794    /// # Errors
1795    ///
1796    /// Returns an error when counts overflow, contradict the selected state, omit
1797    /// a required reason, or claim the pre-publication zero generation.
1798    pub fn new(
1799        scope: CoverageScope,
1800        relation: Option<GraphRelationKind>,
1801        state: CoverageState,
1802        covered: u64,
1803        omitted: u64,
1804        generation: IndexGeneration,
1805        reason: Option<GraphIdentityText>,
1806        reached_limit: Option<GraphLimitKind>,
1807    ) -> Result<Self, GraphContractError> {
1808        if generation == IndexGeneration::ZERO {
1809            return Err(GraphContractError::InvalidGeneration);
1810        }
1811        let total = covered
1812            .checked_add(omitted)
1813            .ok_or(GraphContractError::InvalidCoverage {
1814                reason: "coverage counts overflow",
1815            })?;
1816        let valid_counts = match state {
1817            CoverageState::Complete => omitted == 0,
1818            CoverageState::Partial => covered > 0 && omitted > 0,
1819            CoverageState::Failed
1820            | CoverageState::Ignored
1821            | CoverageState::Oversized
1822            | CoverageState::Quarantined
1823            | CoverageState::Stale => covered == 0 && omitted > 0,
1824        };
1825        if !valid_counts {
1826            return Err(GraphContractError::InvalidCoverage {
1827                reason: "coverage state contradicts covered and omitted counts",
1828            });
1829        }
1830        if state == CoverageState::Complete && (reason.is_some() || reached_limit.is_some()) {
1831            return Err(GraphContractError::InvalidCoverage {
1832                reason: "complete coverage cannot report an omission reason or limit",
1833            });
1834        }
1835        if state != CoverageState::Complete && reason.is_none() {
1836            return Err(GraphContractError::InvalidCoverage {
1837                reason: "non-complete coverage requires an actionable reason",
1838            });
1839        }
1840        Ok(Self {
1841            scope,
1842            relation,
1843            state,
1844            total,
1845            covered,
1846            omitted,
1847            generation,
1848            reason,
1849            reached_limit,
1850        })
1851    }
1852
1853    /// Borrow the covered project or path scope.
1854    #[must_use]
1855    pub const fn scope(&self) -> &CoverageScope {
1856        &self.scope
1857    }
1858
1859    /// Return the optional relation family covered by this row.
1860    #[must_use]
1861    pub const fn relation(&self) -> Option<GraphRelationKind> {
1862        self.relation
1863    }
1864
1865    /// Return the coverage lifecycle state.
1866    #[must_use]
1867    pub const fn state(&self) -> CoverageState {
1868        self.state
1869    }
1870
1871    /// Return the total number of supported items in scope.
1872    #[must_use]
1873    pub const fn total(&self) -> u64 {
1874        self.total
1875    }
1876
1877    /// Return the number of successfully covered items.
1878    #[must_use]
1879    pub const fn covered(&self) -> u64 {
1880        self.covered
1881    }
1882
1883    /// Return the number of omitted, failed, or untrusted items.
1884    #[must_use]
1885    pub const fn omitted(&self) -> u64 {
1886        self.omitted
1887    }
1888
1889    /// Return the complete generation associated with this row.
1890    #[must_use]
1891    pub const fn generation(&self) -> IndexGeneration {
1892        self.generation
1893    }
1894
1895    /// Borrow the actionable non-complete explanation, when present.
1896    #[must_use]
1897    pub const fn reason(&self) -> Option<&GraphIdentityText> {
1898        self.reason.as_ref()
1899    }
1900
1901    /// Return the product limit reached, when applicable.
1902    #[must_use]
1903    pub const fn reached_limit(&self) -> Option<GraphLimitKind> {
1904        self.reached_limit
1905    }
1906}
1907
1908/// Hard bounded retrieval limits accepted by repository graph consumers.
1909#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1910#[serde(try_from = "GraphLimitsWire", into = "GraphLimitsWire")]
1911pub struct GraphLimits {
1912    /// Maximum returned logical rows.
1913    rows: NonZeroU32,
1914    /// Maximum returned occurrences for one logical relation.
1915    occurrences: NonZeroU32,
1916    /// Maximum traversal depth.
1917    depth: NonZeroU32,
1918    /// Maximum encoded result bytes.
1919    output_bytes: NonZeroU32,
1920}
1921
1922impl GraphLimits {
1923    /// Absolute row ceiling for any one graph request.
1924    pub const MAX_ROWS: u32 = 10_000;
1925    /// Absolute occurrence ceiling for any one logical relation.
1926    pub const MAX_OCCURRENCES: u32 = 1_024;
1927    /// Absolute traversal-depth ceiling.
1928    pub const MAX_DEPTH: u32 = 64;
1929    /// Absolute encoded-output ceiling.
1930    pub const MAX_OUTPUT_BYTES: u32 = 16 * 1_024 * 1_024;
1931
1932    /// Validate result-defining hard limits.
1933    ///
1934    /// # Errors
1935    ///
1936    /// Returns an error for zero values or values above absolute ceilings.
1937    pub fn new(
1938        rows: u32,
1939        occurrences: u32,
1940        depth: u32,
1941        output_bytes: u32,
1942    ) -> Result<Self, GraphContractError> {
1943        if rows == 0 || rows > Self::MAX_ROWS {
1944            return Err(GraphContractError::InvalidLimits {
1945                reason: "row limit is zero or above the absolute ceiling",
1946            });
1947        }
1948        if occurrences == 0 || occurrences > Self::MAX_OCCURRENCES {
1949            return Err(GraphContractError::InvalidLimits {
1950                reason: "occurrence limit is zero or above the absolute ceiling",
1951            });
1952        }
1953        if depth == 0 || depth > Self::MAX_DEPTH {
1954            return Err(GraphContractError::InvalidLimits {
1955                reason: "depth limit is zero or above the absolute ceiling",
1956            });
1957        }
1958        if output_bytes == 0 || output_bytes > Self::MAX_OUTPUT_BYTES {
1959            return Err(GraphContractError::InvalidLimits {
1960                reason: "output limit is zero or above the absolute ceiling",
1961            });
1962        }
1963        Ok(Self {
1964            rows: NonZeroU32::new(rows).ok_or(GraphContractError::InvalidLimits {
1965                reason: "row limit must be nonzero",
1966            })?,
1967            occurrences: NonZeroU32::new(occurrences).ok_or(GraphContractError::InvalidLimits {
1968                reason: "occurrence limit must be nonzero",
1969            })?,
1970            depth: NonZeroU32::new(depth).ok_or(GraphContractError::InvalidLimits {
1971                reason: "depth limit must be nonzero",
1972            })?,
1973            output_bytes: NonZeroU32::new(output_bytes).ok_or(
1974                GraphContractError::InvalidLimits {
1975                    reason: "output limit must be nonzero",
1976                },
1977            )?,
1978        })
1979    }
1980
1981    /// Return the logical-row limit.
1982    #[must_use]
1983    pub const fn rows(self) -> u32 {
1984        self.rows.get()
1985    }
1986
1987    /// Return the per-relation occurrence limit.
1988    #[must_use]
1989    pub const fn occurrences(self) -> u32 {
1990        self.occurrences.get()
1991    }
1992
1993    /// Return the traversal-depth limit.
1994    #[must_use]
1995    pub const fn depth(self) -> u32 {
1996        self.depth.get()
1997    }
1998
1999    /// Return the encoded-output byte limit.
2000    #[must_use]
2001    pub const fn output_bytes(self) -> u32 {
2002        self.output_bytes.get()
2003    }
2004}
2005
2006/// Graph-limit wire shape validated during deserialization.
2007#[derive(Deserialize, Serialize)]
2008struct GraphLimitsWire {
2009    /// Logical-row limit.
2010    rows: u32,
2011    /// Per-relation occurrence limit.
2012    occurrences: u32,
2013    /// Traversal-depth limit.
2014    depth: u32,
2015    /// Encoded-output byte limit.
2016    output_bytes: u32,
2017}
2018
2019impl TryFrom<GraphLimitsWire> for GraphLimits {
2020    type Error = GraphContractError;
2021
2022    fn try_from(value: GraphLimitsWire) -> Result<Self, Self::Error> {
2023        Self::new(
2024            value.rows,
2025            value.occurrences,
2026            value.depth,
2027            value.output_bytes,
2028        )
2029    }
2030}
2031
2032impl From<GraphLimits> for GraphLimitsWire {
2033    fn from(value: GraphLimits) -> Self {
2034        Self {
2035            rows: value.rows(),
2036            occurrences: value.occurrences(),
2037            depth: value.depth(),
2038            output_bytes: value.output_bytes(),
2039        }
2040    }
2041}
2042
2043/// Start a canonical identity with a stable domain and project prefix.
2044fn project_canonical_prefix(domain: &str, project: ProjectInstanceId) -> String {
2045    let mut canonical = domain.to_string();
2046    append_canonical_field(&mut canonical, &project.as_hex());
2047    canonical
2048}
2049
2050/// Return the canonical prefix required for one entity key.
2051fn entity_project_prefix(project: ProjectInstanceId) -> String {
2052    project_canonical_prefix(ENTITY_KEY_DOMAIN, project)
2053}
2054
2055/// Return the canonical prefix required for one relation key.
2056fn relation_project_prefix(project: ProjectInstanceId) -> String {
2057    project_canonical_prefix(RELATION_KEY_DOMAIN, project)
2058}
2059
2060/// Return the canonical prefix required for one resolution key.
2061fn resolution_project_prefix(project: ProjectInstanceId, domain: ResolutionKeyDomain) -> String {
2062    let mut canonical = project_canonical_prefix(RESOLUTION_KEY_DOMAIN, project);
2063    append_canonical_field(&mut canonical, domain.as_str());
2064    canonical
2065}
2066
2067/// Return whether canonical material continues after one complete field prefix.
2068fn has_canonical_prefix(canonical: &str, prefix: &str) -> bool {
2069    canonical
2070        .strip_prefix(prefix)
2071        .is_some_and(|remainder| remainder.starts_with('|'))
2072}
2073
2074/// Encode a typed selector into unambiguous project-qualified material.
2075fn entity_canonical_identity(project: ProjectInstanceId, selector: &EntitySelector) -> String {
2076    let mut canonical = entity_project_prefix(project);
2077    match selector {
2078        EntitySelector::Project => append_canonical_field(&mut canonical, "project"),
2079        EntitySelector::Folder { path } => {
2080            append_canonical_field(&mut canonical, "folder");
2081            append_canonical_field(&mut canonical, path.as_str());
2082        }
2083        EntitySelector::File { path } => {
2084            append_canonical_field(&mut canonical, "file");
2085            append_canonical_field(&mut canonical, path.as_str());
2086        }
2087        EntitySelector::Package { package } => {
2088            append_canonical_field(&mut canonical, "package");
2089            append_canonical_field(&mut canonical, package.manager.as_str());
2090            append_canonical_field(&mut canonical, package.name.as_str());
2091            append_canonical_field(&mut canonical, package.manifest.as_str());
2092        }
2093        EntitySelector::Symbol { symbol } => {
2094            append_canonical_field(&mut canonical, "symbol");
2095            append_canonical_field(&mut canonical, symbol.file.as_str());
2096            append_canonical_field(&mut canonical, symbol.name.as_str());
2097            append_canonical_field(&mut canonical, &symbol.kind.to_string());
2098            append_optional_canonical_field(&mut canonical, symbol.parent.as_ref());
2099            append_canonical_field(&mut canonical, symbol.signature.as_str());
2100        }
2101        EntitySelector::External { external } => {
2102            append_canonical_field(&mut canonical, "external");
2103            append_canonical_field(&mut canonical, external.system.as_str());
2104            append_canonical_field(&mut canonical, external.identity.as_str());
2105        }
2106    }
2107    canonical
2108}
2109
2110/// Append one byte-length-prefixed field to canonical identity material.
2111fn append_canonical_field(canonical: &mut String, value: &str) {
2112    canonical.push('|');
2113    canonical.push_str(&value.len().to_string());
2114    canonical.push(':');
2115    canonical.push_str(value);
2116}
2117
2118/// Validate the exact project-independent field sequence produced by
2119/// [`CanonicalResolutionKey::new`].
2120fn valid_canonical_field_sequence(canonical: &str) -> bool {
2121    let Some(fields) = decode_canonical_fields(canonical) else {
2122        return false;
2123    };
2124    let mut index = 0;
2125    for _required in 0..2 {
2126        let Some(field) = fields.get(index) else {
2127            return false;
2128        };
2129        if !valid_canonical_identity_field(field) {
2130            return false;
2131        }
2132        index += 1;
2133    }
2134    for _optional in 0..3 {
2135        let Some(marker) = fields.get(index) else {
2136            return false;
2137        };
2138        index += 1;
2139        match *marker {
2140            "none" => {}
2141            "some" => {
2142                let Some(field) = fields.get(index) else {
2143                    return false;
2144                };
2145                if !valid_canonical_identity_field(field) {
2146                    return false;
2147                }
2148                index += 1;
2149            }
2150            _ => return false,
2151        }
2152    }
2153    fields
2154        .get(index)
2155        .is_some_and(|field| valid_canonical_identity_field(field))
2156        && index + 1 == fields.len()
2157}
2158
2159/// Decode one complete sequence of byte-length-prefixed canonical fields.
2160fn decode_canonical_fields(mut canonical: &str) -> Option<Vec<&str>> {
2161    let mut fields = Vec::new();
2162    while !canonical.is_empty() {
2163        canonical = canonical.strip_prefix('|')?;
2164        let separator = canonical.find(':')?;
2165        let length_text = canonical.get(..separator)?;
2166        let length = length_text.parse::<usize>().ok()?;
2167        if length_text != length.to_string() {
2168            return None;
2169        }
2170        canonical = canonical.get(separator + 1..)?;
2171        if length == 0
2172            || length > MAX_IDENTITY_BYTES
2173            || length > canonical.len()
2174            || !canonical.is_char_boundary(length)
2175        {
2176            return None;
2177        }
2178        let (field, remainder) = canonical.split_at(length);
2179        fields.push(field);
2180        canonical = remainder;
2181    }
2182    Some(fields)
2183}
2184
2185/// Apply the original identity-field constraints to portable canonical data.
2186fn valid_canonical_identity_field(value: &str) -> bool {
2187    !value.is_empty() && value.len() <= MAX_IDENTITY_BYTES && !value.chars().any(char::is_control)
2188}
2189
2190/// Append an optional identity field without conflating absent and empty values.
2191fn append_optional_canonical_field(canonical: &mut String, value: Option<&GraphIdentityText>) {
2192    match value {
2193        Some(value) => {
2194            append_canonical_field(canonical, "some");
2195            append_canonical_field(canonical, value.as_str());
2196        }
2197        None => append_canonical_field(canonical, "none"),
2198    }
2199}
2200
2201/// Append optional validated contract text without conflating absent and empty values.
2202fn append_optional_raw_canonical_field(canonical: &mut String, value: Option<&str>) {
2203    match value {
2204        Some(value) => {
2205            append_canonical_field(canonical, "some");
2206            append_canonical_field(canonical, value);
2207        }
2208        None => append_canonical_field(canonical, "none"),
2209    }
2210}
2211
2212/// Encode bytes as lowercase hexadecimal without another dependency.
2213fn encode_hex(bytes: &[u8]) -> String {
2214    const HEX: &[u8; 16] = b"0123456789abcdef";
2215    let mut encoded = String::with_capacity(bytes.len() * 2);
2216    for byte in bytes {
2217        encoded.push(char::from(HEX[usize::from(byte >> 4)]));
2218        encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
2219    }
2220    encoded
2221}
2222
2223/// Decode one ASCII hexadecimal digit.
2224const fn decode_hex(value: u8) -> Option<u8> {
2225    match value {
2226        b'0'..=b'9' => Some(value - b'0'),
2227        b'a'..=b'f' => Some(value - b'a' + 10),
2228        b'A'..=b'F' => Some(value - b'A' + 10),
2229        _ => None,
2230    }
2231}
2232
2233#[cfg(test)]
2234mod tests {
2235    use super::{
2236        CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
2237        CoverageState, EntityResolutionKey, EntitySelector, ExtendedRelationKind, ExternalSelector,
2238        GraphContractError, GraphEntity, GraphEntityKey, GraphIdentityText, GraphLimitKind,
2239        GraphLimits, GraphRelationKind, LogicalRelation, PackageSelector, PortableResolutionKey,
2240        ProjectInstanceId, RelationDependencyKey, RelationOccurrence, RelationResolution,
2241        RepositoryFilePath, RepositoryNodePath, ResolutionKeyDomain, ReusableTargetSelector,
2242        SourceSpan, StableKey, SymbolSelector,
2243    };
2244    use crate::IndexGeneration;
2245    use crate::symbols::{RelationKind, SymbolKind};
2246    use std::io;
2247    use std::num::NonZeroU32;
2248    use std::path::Path;
2249
2250    /// Return a stable nonzero project identity for unit tests.
2251    fn project() -> Result<ProjectInstanceId, GraphContractError> {
2252        ProjectInstanceId::try_from("00112233445566778899aabbccddeeff")
2253    }
2254
2255    /// Return one stable function selector for unit tests.
2256    fn symbol_selector(path: &str) -> Result<SymbolSelector, GraphContractError> {
2257        Ok(SymbolSelector {
2258            file: RepositoryFilePath::new(Path::new(path))?,
2259            name: GraphIdentityText::new("répond")?,
2260            kind: SymbolKind::Function,
2261            parent: Some(GraphIdentityText::new("Service")?),
2262            signature: GraphIdentityText::new("fn répond(input: &str)")?,
2263        })
2264    }
2265
2266    /// Return an ordinary test error when a behavior condition is false.
2267    fn require(condition: bool, message: &'static str) -> Result<(), Box<dyn std::error::Error>> {
2268        if condition {
2269            Ok(())
2270        } else {
2271            Err(io::Error::other(message).into())
2272        }
2273    }
2274
2275    #[test]
2276    fn project_identity_validates_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
2277        let id = ProjectInstanceId::try_from("00112233-4455-6677-8899-AABBCCDDEEFF")?;
2278        require(
2279            id.as_hex() == "00112233445566778899aabbccddeeff",
2280            "project identity did not normalize to lowercase hexadecimal",
2281        )?;
2282        let encoded = serde_json::to_string(&id)?;
2283        require(
2284            encoded == "\"00112233445566778899aabbccddeeff\"",
2285            "project identity serialized with an unstable shape",
2286        )?;
2287        require(
2288            serde_json::from_str::<ProjectInstanceId>(&encoded)? == id,
2289            "project identity did not round-trip",
2290        )?;
2291        require(
2292            ProjectInstanceId::try_from("00000000000000000000000000000000").is_err(),
2293            "zero project identity was accepted",
2294        )?;
2295        require(
2296            ProjectInstanceId::try_from("not-an-id").is_err(),
2297            "malformed project identity was accepted",
2298        )?;
2299        require(
2300            ProjectInstanceId::try_from("00112233-4455-6677-8899-AABBCCDDEEF-").is_err(),
2301            "hyphenated project identity with missing hexadecimal data was accepted",
2302        )?;
2303        Ok(())
2304    }
2305
2306    #[test]
2307    fn entity_keys_are_stable_line_independent_and_project_scoped()
2308    -> Result<(), Box<dyn std::error::Error>> {
2309        let selector = EntitySelector::Symbol {
2310            symbol: symbol_selector("src\\service.rs")?,
2311        };
2312        let first = GraphEntity::new(project()?, selector.clone(), IndexGeneration::new(4))?;
2313        let rescanned = GraphEntity::new(project()?, selector, IndexGeneration::new(5))?;
2314        require(
2315            first.key().reconcile(rescanned.key())?,
2316            "unchanged selector did not retain identity",
2317        )?;
2318        require(
2319            first.key().digest() == rescanned.key().digest(),
2320            "unchanged selector changed its compact key",
2321        )?;
2322        require(
2323            first.key().digest_bytes()?
2324                == *blake3::hash(first.key().canonical_identity().as_bytes()).as_bytes(),
2325            "binary persistence key did not match the validated digest",
2326        )?;
2327
2328        let other_project = ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?;
2329        let other = GraphEntityKey::new(other_project, rescanned.selector());
2330        require(
2331            !first.key().reconcile(&other)?,
2332            "independent projects shared an entity identity",
2333        )?;
2334        require(
2335            first.key().digest() != other.digest(),
2336            "independent projects shared a compact entity key",
2337        )?;
2338        Ok(())
2339    }
2340
2341    #[test]
2342    fn stable_key_collisions_fail_closed() -> Result<(), Box<dyn std::error::Error>> {
2343        let selector = EntitySelector::File {
2344            path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
2345        };
2346        let first = GraphEntityKey::new(project()?, &selector);
2347        let conflicting = GraphEntityKey {
2348            project: first.project,
2349            stable: StableKey {
2350                digest: first.stable.digest.clone(),
2351                canonical_identity: "different canonical identity".to_string(),
2352            },
2353        };
2354        require(
2355            matches!(
2356                first.reconcile(&conflicting),
2357                Err(GraphContractError::StableKeyCollision { .. })
2358            ),
2359            "conflicting canonical identity did not fail closed",
2360        )?;
2361
2362        let mut serialized = serde_json::to_value(&first)?;
2363        serialized["stable"]["canonical_identity"] = serde_json::json!("tampered");
2364        require(
2365            serde_json::from_value::<GraphEntityKey>(serialized).is_err(),
2366            "tampered stable-key material was accepted",
2367        )?;
2368        Ok(())
2369    }
2370
2371    #[test]
2372    fn canonical_resolution_keys_are_stable_qualified_and_collision_checked()
2373    -> Result<(), Box<dyn std::error::Error>> {
2374        let provider = GraphIdentityText::new("tree-sitter")?;
2375        let language = GraphIdentityText::new("rust")?;
2376        let package = GraphIdentityText::new("auth")?;
2377        let scope = GraphIdentityText::new("crate")?;
2378        let identity = GraphIdentityText::new("répond")?;
2379        let key = CanonicalResolutionKey::new(
2380            project()?,
2381            ResolutionKeyDomain::Declaration,
2382            &provider,
2383            &language,
2384            Some(&package),
2385            Some(&scope),
2386            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2387            &identity,
2388        );
2389        let repeated = CanonicalResolutionKey::new(
2390            project()?,
2391            ResolutionKeyDomain::Declaration,
2392            &provider,
2393            &language,
2394            Some(&package),
2395            Some(&scope),
2396            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2397            &identity,
2398        );
2399        require(
2400            key.reconcile(&repeated)?,
2401            "equal canonical resolver input changed identity",
2402        )?;
2403        require(
2404            key.digest_bytes() == *blake3::hash(key.canonical_identity().as_bytes()).as_bytes(),
2405            "fixed resolver digest did not match its collision witness",
2406        )?;
2407        let persisted = CanonicalResolutionKey::from_persisted(
2408            key.project(),
2409            key.domain(),
2410            key.digest_bytes(),
2411            key.canonical_identity().to_string(),
2412        )?;
2413        require(
2414            key.reconcile(&persisted)?,
2415            "validated persisted resolver key changed identity",
2416        )?;
2417
2418        let other_project = ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?;
2419        let case_distinct = GraphIdentityText::new("Répond")?;
2420        let variants = [
2421            CanonicalResolutionKey::new(
2422                other_project,
2423                ResolutionKeyDomain::Declaration,
2424                &provider,
2425                &language,
2426                Some(&package),
2427                Some(&scope),
2428                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2429                &identity,
2430            ),
2431            CanonicalResolutionKey::new(
2432                project()?,
2433                ResolutionKeyDomain::Module,
2434                &provider,
2435                &language,
2436                Some(&package),
2437                Some(&scope),
2438                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2439                &identity,
2440            ),
2441            CanonicalResolutionKey::new(
2442                project()?,
2443                ResolutionKeyDomain::Declaration,
2444                &GraphIdentityText::new("manifest")?,
2445                &language,
2446                Some(&package),
2447                Some(&scope),
2448                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2449                &identity,
2450            ),
2451            CanonicalResolutionKey::new(
2452                project()?,
2453                ResolutionKeyDomain::Declaration,
2454                &provider,
2455                &GraphIdentityText::new("typescript")?,
2456                Some(&package),
2457                Some(&scope),
2458                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2459                &identity,
2460            ),
2461            CanonicalResolutionKey::new(
2462                project()?,
2463                ResolutionKeyDomain::Declaration,
2464                &provider,
2465                &language,
2466                Some(&GraphIdentityText::new("billing")?),
2467                Some(&scope),
2468                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2469                &identity,
2470            ),
2471            CanonicalResolutionKey::new(
2472                project()?,
2473                ResolutionKeyDomain::Declaration,
2474                &provider,
2475                &language,
2476                Some(&package),
2477                Some(&GraphIdentityText::new("module")?),
2478                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2479                &identity,
2480            ),
2481            CanonicalResolutionKey::new(
2482                project()?,
2483                ResolutionKeyDomain::Declaration,
2484                &provider,
2485                &language,
2486                Some(&package),
2487                Some(&scope),
2488                Some(GraphRelationKind::Legacy(RelationKind::Imports)),
2489                &identity,
2490            ),
2491            CanonicalResolutionKey::new(
2492                project()?,
2493                ResolutionKeyDomain::Declaration,
2494                &provider,
2495                &language,
2496                Some(&package),
2497                Some(&scope),
2498                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2499                &case_distinct,
2500            ),
2501        ];
2502        for variant in variants {
2503            require(
2504                !key.reconcile(&variant)?,
2505                "identity-affecting resolver dimension was ignored",
2506            )?;
2507        }
2508        require(
2509            matches!(
2510                ResolutionKeyDomain::try_from("declaration"),
2511                Ok(ResolutionKeyDomain::Declaration)
2512            ) && ResolutionKeyDomain::try_from("unknown").is_err(),
2513            "closed resolver-domain persistence accepted an unsupported value",
2514        )?;
2515
2516        let conflicting = CanonicalResolutionKey {
2517            canonical_identity: "different canonical identity".to_string(),
2518            ..key
2519        };
2520        require(
2521            matches!(
2522                key.reconcile(&conflicting),
2523                Err(GraphContractError::StableKeyCollision { .. })
2524            ),
2525            "equal resolver digest with a different witness did not fail closed",
2526        )?;
2527        let mut serialized = serde_json::to_value(&key)?;
2528        serialized["canonical_identity"] = serde_json::json!("tampered");
2529        require(
2530            serde_json::from_value::<CanonicalResolutionKey>(serialized).is_err(),
2531            "tampered persisted resolver key was accepted",
2532        )?;
2533        Ok(())
2534    }
2535
2536    #[test]
2537    fn portable_resolution_keys_rebind_without_project_identity()
2538    -> Result<(), Box<dyn std::error::Error>> {
2539        let provider = GraphIdentityText::new("tree-sitter")?;
2540        let language = GraphIdentityText::new("rust")?;
2541        let package = GraphIdentityText::new("auth")?;
2542        let scope = GraphIdentityText::new("crate")?;
2543        let identity = GraphIdentityText::new("répond")?;
2544        let source = CanonicalResolutionKey::new(
2545            project()?,
2546            ResolutionKeyDomain::Declaration,
2547            &provider,
2548            &language,
2549            Some(&package),
2550            Some(&scope),
2551            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2552            &identity,
2553        );
2554        let portable = source.portable()?;
2555        let encoded = serde_json::to_string(&portable)?;
2556        require(
2557            !encoded.contains(&project()?.as_hex()),
2558            "portable resolver key leaked its source project identity",
2559        )?;
2560        let decoded = serde_json::from_str::<PortableResolutionKey>(&encoded)?;
2561        let destination = ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?;
2562        let rebound = decoded.bind(destination);
2563        let expected = CanonicalResolutionKey::new(
2564            destination,
2565            ResolutionKeyDomain::Declaration,
2566            &provider,
2567            &language,
2568            Some(&package),
2569            Some(&scope),
2570            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2571            &identity,
2572        );
2573        require(
2574            rebound.reconcile(&expected)?,
2575            "portable resolver key did not rebind to the destination project",
2576        )?;
2577
2578        let mut invalid = serde_json::to_value(&portable)?;
2579        invalid["canonical_identity"] = serde_json::json!("|04:rust");
2580        require(
2581            serde_json::from_value::<PortableResolutionKey>(invalid).is_err(),
2582            "non-canonical portable resolver material was accepted",
2583        )?;
2584        Ok(())
2585    }
2586
2587    #[test]
2588    fn resolution_key_bindings_preserve_dependency_identity_across_states()
2589    -> Result<(), Box<dyn std::error::Error>> {
2590        let generation = IndexGeneration::new(7);
2591        let source = GraphEntity::new(
2592            project()?,
2593            EntitySelector::Symbol {
2594                symbol: symbol_selector("src/caller.rs")?,
2595            },
2596            generation,
2597        )?;
2598        let target = GraphEntity::new(
2599            project()?,
2600            EntitySelector::Symbol {
2601                symbol: symbol_selector("src/service.rs")?,
2602            },
2603            generation,
2604        )?;
2605        let dependency = CanonicalResolutionKey::new(
2606            project()?,
2607            ResolutionKeyDomain::Declaration,
2608            &GraphIdentityText::new("tree-sitter")?,
2609            &GraphIdentityText::new("rust")?,
2610            None,
2611            None,
2612            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2613            &GraphIdentityText::new("répond")?,
2614        );
2615        let export = EntityResolutionKey::new(target.key().clone(), dependency.clone())?;
2616        require(
2617            export.key().reconcile(&dependency)?,
2618            "export binding changed its canonical resolver identity",
2619        )?;
2620
2621        let relations = [
2622            LogicalRelation::new(
2623                &source,
2624                GraphRelationKind::Legacy(RelationKind::Calls),
2625                RelationResolution::resolved(&target)?,
2626                ConfidenceClass::Exact,
2627                Completeness::Complete,
2628                generation,
2629            )?,
2630            LogicalRelation::new(
2631                &source,
2632                GraphRelationKind::Legacy(RelationKind::Calls),
2633                RelationResolution::Ambiguous {
2634                    reference: GraphIdentityText::new("répond")?,
2635                    candidates: NonZeroU32::new(2).ok_or("nonzero candidate fixture")?,
2636                },
2637                ConfidenceClass::High,
2638                Completeness::Complete,
2639                generation,
2640            )?,
2641            LogicalRelation::new(
2642                &source,
2643                GraphRelationKind::Legacy(RelationKind::Calls),
2644                RelationResolution::Unresolved {
2645                    reference: GraphIdentityText::new("répond")?,
2646                },
2647                ConfidenceClass::Low,
2648                Completeness::Complete,
2649                generation,
2650            )?,
2651        ];
2652        for relation in relations {
2653            let binding = RelationDependencyKey::new(relation.key().clone(), dependency.clone())?;
2654            require(
2655                binding.key().reconcile(&dependency)?,
2656                "resolution state changed the retained dependency identity",
2657            )?;
2658        }
2659
2660        let foreign = CanonicalResolutionKey::new(
2661            ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?,
2662            ResolutionKeyDomain::Declaration,
2663            &GraphIdentityText::new("tree-sitter")?,
2664            &GraphIdentityText::new("rust")?,
2665            None,
2666            None,
2667            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2668            &GraphIdentityText::new("répond")?,
2669        );
2670        require(
2671            matches!(
2672                EntityResolutionKey::new(target.key().clone(), foreign),
2673                Err(GraphContractError::ResolutionKeyOwnerMismatch)
2674            ),
2675            "cross-project resolver binding was accepted",
2676        )?;
2677        Ok(())
2678    }
2679
2680    #[test]
2681    fn selectors_normalize_slashes_and_preserve_unicode_and_case()
2682    -> Result<(), Box<dyn std::error::Error>> {
2683        let upper = EntitySelector::Symbol {
2684            symbol: symbol_selector("Src\\Über.rs")?,
2685        };
2686        let lower = EntitySelector::Symbol {
2687            symbol: symbol_selector("src/Über.rs")?,
2688        };
2689        let encoded = serde_json::to_string(&upper)?;
2690        let decoded: EntitySelector = serde_json::from_str(&encoded)?;
2691        require(decoded == upper, "Unicode selector did not round-trip")?;
2692        require(
2693            encoded.contains("Src/Über.rs"),
2694            "selector changed Unicode or path case",
2695        )?;
2696        require(
2697            GraphEntityKey::new(project()?, &upper) != GraphEntityKey::new(project()?, &lower),
2698            "case-distinct repository paths collapsed",
2699        )?;
2700
2701        let slash_variant = EntitySelector::File {
2702            path: RepositoryFilePath::new(Path::new("src\\lib.rs"))?,
2703        };
2704        let normalized = EntitySelector::File {
2705            path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
2706        };
2707        require(
2708            GraphEntityKey::new(project()?, &slash_variant)
2709                == GraphEntityKey::new(project()?, &normalized),
2710            "slash variants did not normalize to one entity",
2711        )?;
2712        Ok(())
2713    }
2714
2715    #[test]
2716    fn invalid_selector_inputs_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
2717        require(
2718            RepositoryFilePath::new(Path::new("../secret.rs")).is_err(),
2719            "parent traversal was accepted",
2720        )?;
2721        require(
2722            RepositoryFilePath::new(Path::new(".")).is_err(),
2723            "project root was accepted as a file",
2724        )?;
2725        require(
2726            RepositoryNodePath::new(Path::new("C:/repo")).is_err(),
2727            "absolute Windows path was accepted",
2728        )?;
2729        require(
2730            GraphIdentityText::new(" ").is_err(),
2731            "blank identity text was accepted",
2732        )?;
2733        require(
2734            GraphIdentityText::new(" padded").is_err()
2735                && GraphIdentityText::new("padded ").is_err(),
2736            "surrounding identity whitespace was accepted",
2737        )?;
2738        require(
2739            GraphIdentityText::new("bad\nidentity").is_err(),
2740            "control-bearing identity text was accepted",
2741        )?;
2742        require(
2743            GraphIdentityText::new("x".repeat(super::MAX_IDENTITY_BYTES + 1)).is_err(),
2744            "oversized identity text was accepted",
2745        )?;
2746        require(
2747            SourceSpan::new(0, 0, 1, 0).is_err(),
2748            "zero source line was accepted",
2749        )?;
2750        require(
2751            SourceSpan::new(4, 2, 3, 9).is_err(),
2752            "reversed source span was accepted",
2753        )?;
2754        require(
2755            GraphEntity::new(
2756                project()?,
2757                EntitySelector::File {
2758                    path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
2759                },
2760                IndexGeneration::ZERO,
2761            )
2762            .is_err(),
2763            "pre-publication generation was accepted for a graph entity",
2764        )?;
2765
2766        let package = EntitySelector::Package {
2767            package: PackageSelector {
2768                manager: GraphIdentityText::new("cargo")?,
2769                name: GraphIdentityText::new("projectatlas-core")?,
2770                manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
2771            },
2772        };
2773        require(
2774            package == serde_json::from_str(&serde_json::to_string(&package)?)?,
2775            "package selector did not round-trip",
2776        )?;
2777        Ok(())
2778    }
2779
2780    #[test]
2781    fn logical_relations_deduplicate_distinct_source_occurrences()
2782    -> Result<(), Box<dyn std::error::Error>> {
2783        let source = GraphEntity::new(
2784            project()?,
2785            EntitySelector::Symbol {
2786                symbol: symbol_selector("src/caller.rs")?,
2787            },
2788            IndexGeneration::new(7),
2789        )?;
2790        let target_symbol = symbol_selector("src/target.rs")?;
2791        let target = GraphEntity::new(
2792            project()?,
2793            EntitySelector::Symbol {
2794                symbol: target_symbol,
2795            },
2796            IndexGeneration::new(7),
2797        )?;
2798        let relation = LogicalRelation::new(
2799            &source,
2800            GraphRelationKind::from_legacy(RelationKind::Calls),
2801            RelationResolution::resolved(&target)?,
2802            ConfidenceClass::Exact,
2803            Completeness::Complete,
2804            IndexGeneration::new(7),
2805        )?;
2806        let first = RelationOccurrence::new(
2807            &relation,
2808            RepositoryFilePath::new(Path::new("src/caller.rs"))?,
2809            SourceSpan::new(10, 4, 10, 18)?,
2810            IndexGeneration::new(7),
2811        )?;
2812        let second = RelationOccurrence::new(
2813            &relation,
2814            RepositoryFilePath::new(Path::new("src/caller.rs"))?,
2815            SourceSpan::new(20, 4, 20, 18)?,
2816            IndexGeneration::new(7),
2817        )?;
2818        require(
2819            first.relation() == second.relation(),
2820            "occurrences did not retain one logical relation",
2821        )?;
2822        require(
2823            first.span() != second.span(),
2824            "distinct relation occurrences lost their source spans",
2825        )?;
2826        Ok(())
2827    }
2828
2829    #[test]
2830    fn relation_records_reject_inconsistent_identity_resolution_and_generation()
2831    -> Result<(), Box<dyn std::error::Error>> {
2832        let source = GraphEntity::new(
2833            project()?,
2834            EntitySelector::Symbol {
2835                symbol: symbol_selector("src/caller.rs")?,
2836            },
2837            IndexGeneration::new(3),
2838        )?;
2839        let target_symbol = symbol_selector("src/target.rs")?;
2840        let target = GraphEntity::new(
2841            project()?,
2842            EntitySelector::Symbol {
2843                symbol: target_symbol.clone(),
2844            },
2845            IndexGeneration::new(4),
2846        )?;
2847        require(
2848            matches!(
2849                LogicalRelation::new(
2850                    &source,
2851                    GraphRelationKind::from_legacy(RelationKind::Calls),
2852                    RelationResolution::Unresolved {
2853                        reference: GraphIdentityText::new("missing")?,
2854                    },
2855                    ConfidenceClass::Low,
2856                    Completeness::Complete,
2857                    IndexGeneration::new(4),
2858                ),
2859                Err(GraphContractError::GenerationMismatch { .. })
2860            ),
2861            "mixed source generation was accepted",
2862        )?;
2863        require(
2864            matches!(
2865                LogicalRelation::new(
2866                    &source,
2867                    GraphRelationKind::from_legacy(RelationKind::Calls),
2868                    RelationResolution::resolved(&target)?,
2869                    ConfidenceClass::Exact,
2870                    Completeness::Complete,
2871                    IndexGeneration::new(3),
2872                ),
2873                Err(GraphContractError::GenerationMismatch { .. })
2874            ),
2875            "mixed target generation was accepted",
2876        )?;
2877        let target = GraphEntity::new(
2878            project()?,
2879            EntitySelector::Symbol {
2880                symbol: target_symbol,
2881            },
2882            IndexGeneration::new(3),
2883        )?;
2884        let relation = LogicalRelation::new(
2885            &source,
2886            GraphRelationKind::from_legacy(RelationKind::Calls),
2887            RelationResolution::resolved(&target)?,
2888            ConfidenceClass::Exact,
2889            Completeness::Complete,
2890            IndexGeneration::new(3),
2891        )?;
2892        let encoded = serde_json::to_string(relation.key())?;
2893        let decoded = serde_json::from_str::<super::LogicalRelationKey>(&encoded)?;
2894        require(
2895            relation.key().reconcile(&decoded)?,
2896            "validated logical relation key did not round-trip",
2897        )?;
2898        require(
2899            matches!(
2900                RelationOccurrence::new(
2901                    &relation,
2902                    RepositoryFilePath::new(Path::new("src/caller.rs"))?,
2903                    SourceSpan::new(2, 0, 2, 8)?,
2904                    IndexGeneration::new(4),
2905                ),
2906                Err(GraphContractError::GenerationMismatch { .. })
2907            ),
2908            "mixed occurrence generation was accepted",
2909        )?;
2910        require(
2911            matches!(
2912                LogicalRelation::new(
2913                    &source,
2914                    GraphRelationKind::from_legacy(RelationKind::Calls),
2915                    RelationResolution::Resolved {
2916                        target: target.key().clone(),
2917                        selector: ReusableTargetSelector::Symbol {
2918                            symbol: symbol_selector("src/wrong.rs")?,
2919                        },
2920                        generation: IndexGeneration::new(3),
2921                    },
2922                    ConfidenceClass::Exact,
2923                    Completeness::Complete,
2924                    IndexGeneration::new(3),
2925                ),
2926                Err(GraphContractError::InvalidResolution { .. })
2927            ),
2928            "selector for an unrelated entity was accepted",
2929        )?;
2930
2931        let other_project_target = GraphEntity::new(
2932            ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?,
2933            EntitySelector::Symbol {
2934                symbol: symbol_selector("src/target.rs")?,
2935            },
2936            IndexGeneration::new(3),
2937        )?;
2938        require(
2939            matches!(
2940                LogicalRelation::new(
2941                    &source,
2942                    GraphRelationKind::from_legacy(RelationKind::Calls),
2943                    RelationResolution::resolved(&other_project_target)?,
2944                    ConfidenceClass::Exact,
2945                    Completeness::Complete,
2946                    IndexGeneration::new(3),
2947                ),
2948                Err(GraphContractError::CrossProjectRelation)
2949            ),
2950            "cross-project relation was accepted without federation",
2951        )?;
2952
2953        let external_selector = ExternalSelector {
2954            system: GraphIdentityText::new("cargo")?,
2955            identity: GraphIdentityText::new("serde")?,
2956        };
2957        let external_target = GraphEntity::new(
2958            project()?,
2959            EntitySelector::External {
2960                external: external_selector.clone(),
2961            },
2962            IndexGeneration::new(3),
2963        )?;
2964        LogicalRelation::new(
2965            &source,
2966            GraphRelationKind::from_legacy(RelationKind::DependsOn),
2967            RelationResolution::external(&external_target)?,
2968            ConfidenceClass::Exact,
2969            Completeness::Complete,
2970            IndexGeneration::new(3),
2971        )?;
2972        require(
2973            matches!(
2974                LogicalRelation::new(
2975                    &source,
2976                    GraphRelationKind::from_legacy(RelationKind::DependsOn),
2977                    RelationResolution::External {
2978                        target: external_target.key().clone(),
2979                        external: ExternalSelector {
2980                            system: external_selector.system,
2981                            identity: GraphIdentityText::new("different")?,
2982                        },
2983                        generation: IndexGeneration::new(3),
2984                    },
2985                    ConfidenceClass::Exact,
2986                    Completeness::Complete,
2987                    IndexGeneration::new(3),
2988                ),
2989                Err(GraphContractError::InvalidResolution { .. })
2990            ),
2991            "mismatched external identity was accepted",
2992        )?;
2993
2994        let mut serialized = serde_json::to_value(relation.key())?;
2995        serialized["stable"]["canonical_identity"] =
2996            serde_json::json!(source.key().canonical_identity());
2997        serialized["stable"]["digest"] = serde_json::json!(
2998            blake3::hash(source.key().canonical_identity().as_bytes())
2999                .to_hex()
3000                .as_str()
3001        );
3002        require(
3003            serde_json::from_value::<super::LogicalRelationKey>(serialized).is_err(),
3004            "entity namespace was accepted as a relation key",
3005        )?;
3006
3007        let file_key = GraphEntityKey::new(
3008            project()?,
3009            &EntitySelector::File {
3010                path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3011            },
3012        );
3013        let mut serialized = serde_json::to_value(&file_key)?;
3014        let malformed = format!("{}suffix", super::entity_project_prefix(project()?));
3015        serialized["stable"]["canonical_identity"] = serde_json::json!(&malformed);
3016        serialized["stable"]["digest"] =
3017            serde_json::json!(blake3::hash(malformed.as_bytes()).to_hex().as_str());
3018        require(
3019            serde_json::from_value::<GraphEntityKey>(serialized).is_err(),
3020            "partial project prefix was accepted as typed entity identity",
3021        )?;
3022        Ok(())
3023    }
3024
3025    #[test]
3026    fn ambiguous_candidate_counts_do_not_change_logical_relation_identity()
3027    -> Result<(), Box<dyn std::error::Error>> {
3028        let source = GraphEntity::new(
3029            project()?,
3030            EntitySelector::Symbol {
3031                symbol: symbol_selector("src/caller.rs")?,
3032            },
3033            IndexGeneration::new(11),
3034        )?;
3035        let relation = |candidates| {
3036            LogicalRelation::new(
3037                &source,
3038                GraphRelationKind::Extended(ExtendedRelationKind::References),
3039                RelationResolution::Ambiguous {
3040                    reference: GraphIdentityText::new("handler")?,
3041                    candidates: NonZeroU32::new(candidates).ok_or(
3042                        GraphContractError::InvalidCoverage {
3043                            reason: "test candidate count must be nonzero",
3044                        },
3045                    )?,
3046                },
3047                ConfidenceClass::High,
3048                Completeness::Complete,
3049                IndexGeneration::new(11),
3050            )
3051        };
3052        let first = relation(2)?;
3053        let second = relation(3)?;
3054        require(
3055            first.key().reconcile(second.key())?,
3056            "candidate-count metadata changed logical relation identity",
3057        )?;
3058        Ok(())
3059    }
3060
3061    #[test]
3062    fn relation_resolution_and_target_selectors_round_trip()
3063    -> Result<(), Box<dyn std::error::Error>> {
3064        let selector = ReusableTargetSelector::Symbol {
3065            symbol: symbol_selector("src/lib.rs")?,
3066        };
3067        let encoded = serde_json::to_string(&selector)?;
3068        require(
3069            serde_json::from_str::<ReusableTargetSelector>(&encoded)? == selector,
3070            "exact target selector did not round-trip",
3071        )?;
3072
3073        let cases = [
3074            (
3075                EntitySelector::Folder {
3076                    path: RepositoryNodePath::new(Path::new("src"))?,
3077                },
3078                ReusableTargetSelector::Folder {
3079                    folder: RepositoryNodePath::new(Path::new("src"))?,
3080                },
3081            ),
3082            (
3083                EntitySelector::File {
3084                    path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3085                },
3086                ReusableTargetSelector::File {
3087                    file: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3088                },
3089            ),
3090            (
3091                EntitySelector::Package {
3092                    package: PackageSelector {
3093                        manager: GraphIdentityText::new("cargo")?,
3094                        name: GraphIdentityText::new("projectatlas-core")?,
3095                        manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
3096                    },
3097                },
3098                ReusableTargetSelector::Package {
3099                    package: PackageSelector {
3100                        manager: GraphIdentityText::new("cargo")?,
3101                        name: GraphIdentityText::new("projectatlas-core")?,
3102                        manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
3103                    },
3104                },
3105            ),
3106            (
3107                EntitySelector::Symbol {
3108                    symbol: symbol_selector("src/lib.rs")?,
3109                },
3110                selector,
3111            ),
3112        ];
3113        for (entity_selector, expected) in cases {
3114            let entity = GraphEntity::new(project()?, entity_selector, IndexGeneration::new(5))?;
3115            require(
3116                ReusableTargetSelector::for_entity(&entity)? == expected,
3117                "entity-derived reusable selector drifted",
3118            )?;
3119        }
3120
3121        let project_entity =
3122            GraphEntity::new(project()?, EntitySelector::Project, IndexGeneration::new(5))?;
3123        require(
3124            RelationResolution::resolved(&project_entity).is_err(),
3125            "project aggregate was exposed as a direct source target",
3126        )?;
3127        let external_entity = GraphEntity::new(
3128            project()?,
3129            EntitySelector::External {
3130                external: ExternalSelector {
3131                    system: GraphIdentityText::new("cargo")?,
3132                    identity: GraphIdentityText::new("serde")?,
3133                },
3134            },
3135            IndexGeneration::new(5),
3136        )?;
3137        let external = RelationResolution::external(&external_entity)?;
3138        require(
3139            external.resolved_target().is_none(),
3140            "external resolution exposed a traversable local target",
3141        )?;
3142        require(
3143            RelationResolution::external(&project_entity).is_err(),
3144            "non-external entity was accepted as an external resolution",
3145        )?;
3146
3147        let unresolved = RelationResolution::Unresolved {
3148            reference: GraphIdentityText::new("missing::target")?,
3149        };
3150        require(
3151            unresolved.resolved_target().is_none(),
3152            "unresolved relation exposed a traversable target",
3153        )?;
3154        let ambiguous = RelationResolution::Ambiguous {
3155            reference: GraphIdentityText::new("handler")?,
3156            candidates: NonZeroU32::new(2).ok_or("candidate count must be nonzero")?,
3157        };
3158        require(
3159            ambiguous.resolved_target().is_none(),
3160            "ambiguous relation exposed a traversable target",
3161        )?;
3162        Ok(())
3163    }
3164
3165    #[test]
3166    fn legacy_relation_projection_remains_compatible() {
3167        for kind in [
3168            RelationKind::Contains,
3169            RelationKind::Imports,
3170            RelationKind::Calls,
3171            RelationKind::DependsOn,
3172        ] {
3173            assert_eq!(
3174                GraphRelationKind::from_legacy(kind).legacy_kind(),
3175                Some(kind)
3176            );
3177        }
3178        assert_eq!(
3179            GraphRelationKind::Extended(ExtendedRelationKind::Tests).legacy_kind(),
3180            None
3181        );
3182    }
3183
3184    #[test]
3185    fn coverage_state_enforces_consistent_counts_and_reasons()
3186    -> Result<(), Box<dyn std::error::Error>> {
3187        let complete = CoverageRecord::new(
3188            CoverageScope::Project,
3189            None,
3190            CoverageState::Complete,
3191            8,
3192            0,
3193            IndexGeneration::new(9),
3194            None,
3195            None,
3196        )?;
3197        require(complete.total() == 8, "complete coverage total drifted")?;
3198
3199        let partial = CoverageRecord::new(
3200            CoverageScope::Path {
3201                path: RepositoryNodePath::new(Path::new("src"))?,
3202            },
3203            Some(GraphRelationKind::Extended(
3204                ExtendedRelationKind::References,
3205            )),
3206            CoverageState::Partial,
3207            5,
3208            3,
3209            IndexGeneration::new(9),
3210            Some(GraphIdentityText::new("parser limit reached")?),
3211            Some(GraphLimitKind::Rows),
3212        )?;
3213        require(
3214            (partial.covered(), partial.omitted(), partial.total()) == (5, 3, 8),
3215            "partial coverage counts are inconsistent",
3216        )?;
3217
3218        require(
3219            CoverageRecord::new(
3220                CoverageScope::Project,
3221                None,
3222                CoverageState::Complete,
3223                7,
3224                1,
3225                IndexGeneration::new(9),
3226                None,
3227                None,
3228            )
3229            .is_err(),
3230            "complete coverage accepted omitted rows",
3231        )?;
3232        require(
3233            CoverageRecord::new(
3234                CoverageScope::Project,
3235                None,
3236                CoverageState::Failed,
3237                0,
3238                8,
3239                IndexGeneration::new(9),
3240                None,
3241                None,
3242            )
3243            .is_err(),
3244            "failed coverage accepted a missing reason",
3245        )?;
3246        require(
3247            CoverageRecord::new(
3248                CoverageScope::Project,
3249                None,
3250                CoverageState::Complete,
3251                1,
3252                0,
3253                IndexGeneration::ZERO,
3254                None,
3255                None,
3256            )
3257            .is_err(),
3258            "pre-publication coverage generation was accepted",
3259        )?;
3260        Ok(())
3261    }
3262
3263    #[test]
3264    fn graph_limits_are_nonzero_bounded_and_validated_on_input()
3265    -> Result<(), Box<dyn std::error::Error>> {
3266        let limits = GraphLimits::new(100, 20, 4, 64 * 1_024)?;
3267        let encoded = serde_json::to_string(&limits)?;
3268        require(
3269            serde_json::from_str::<GraphLimits>(&encoded)? == limits,
3270            "graph limits did not round-trip",
3271        )?;
3272        require(
3273            GraphLimits::new(0, 20, 4, 1024).is_err(),
3274            "zero graph row limit was accepted",
3275        )?;
3276        require(
3277            GraphLimits::new(GraphLimits::MAX_ROWS + 1, 20, 4, 1024).is_err(),
3278            "graph row limit exceeded its hard ceiling",
3279        )?;
3280        require(
3281            serde_json::from_str::<GraphLimits>(
3282                r#"{"rows":1,"occurrences":1,"depth":65,"output_bytes":1}"#,
3283            )
3284            .is_err(),
3285            "deserialization bypassed graph depth limits",
3286        )?;
3287        Ok(())
3288    }
3289}