Skip to main content

relay_knowledge/domain/operations/software/
ontology.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::domain::core::OntologyClassIdentity;
6
7use super::super::{DomainError, GraphVersion, RepositoryCodeRange, error::required_text};
8use super::validation::{normalize_optional, stable_software_id};
9use super::vocabulary::SOFTWARE_CLASSES;
10
11const MAX_ENTITY_ATTRIBUTES: usize = 64;
12const MAX_EVIDENCE_REFS: usize = 64;
13
14/// Stable and occurrence entity kinds in the software ontology contract.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17#[repr(usize)]
18pub enum SoftwareEntityKind {
19    Domain,
20    SoftwareSystem,
21    Component,
22    Api,
23    Resource,
24    Configuration,
25    BuildDefinition,
26    DeploymentUnit,
27    RuntimeService,
28    TestCase,
29    ReleaseArtifact,
30    PackageComponent,
31    Sdk,
32    DocumentationUnit,
33    Pipeline,
34    BuildJob,
35    RepositorySnapshot,
36    FileRevision,
37    BuildRun,
38    DeploymentRevision,
39    RuntimeObservation,
40}
41
42impl SoftwareEntityKind {
43    /// Stable storage and wire value.
44    pub const fn as_str(self) -> &'static str {
45        SOFTWARE_CLASSES[self as usize].id
46    }
47
48    /// RDF local name declared by the shared OWL class vocabulary.
49    pub const fn rdf_local_name(self) -> &'static str {
50        SOFTWARE_CLASSES[self as usize].rdf_local_name
51    }
52
53    /// Parses persisted contract values without accepting unknown future kinds.
54    pub fn parse(value: &str) -> Option<Self> {
55        match value {
56            "domain" => Some(Self::Domain),
57            "software_system" => Some(Self::SoftwareSystem),
58            "component" => Some(Self::Component),
59            "api" => Some(Self::Api),
60            "resource" => Some(Self::Resource),
61            "configuration" => Some(Self::Configuration),
62            "build_definition" => Some(Self::BuildDefinition),
63            "deployment_unit" => Some(Self::DeploymentUnit),
64            "runtime_service" => Some(Self::RuntimeService),
65            "test_case" => Some(Self::TestCase),
66            "release_artifact" => Some(Self::ReleaseArtifact),
67            "package_component" => Some(Self::PackageComponent),
68            "sdk" => Some(Self::Sdk),
69            "documentation_unit" => Some(Self::DocumentationUnit),
70            "pipeline" => Some(Self::Pipeline),
71            "build_job" => Some(Self::BuildJob),
72            "repository_snapshot" => Some(Self::RepositorySnapshot),
73            "file_revision" => Some(Self::FileRevision),
74            "build_run" => Some(Self::BuildRun),
75            "deployment_revision" => Some(Self::DeploymentRevision),
76            "runtime_observation" => Some(Self::RuntimeObservation),
77            _ => None,
78        }
79    }
80
81    /// Snapshot and event instances intentionally carry source-scope identity.
82    pub const fn is_occurrence_kind(self) -> bool {
83        matches!(
84            SOFTWARE_CLASSES[self as usize].identity,
85            OntologyClassIdentity::Occurrence
86        )
87    }
88}
89
90/// Controlled provenance source categories used by authority policies.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum SoftwareSourceKind {
94    Manifest,
95    Lockfile,
96    Sbom,
97    BuildAttestation,
98    BuildFile,
99    Ci,
100    Iac,
101    ServiceDefinition,
102    ApiSchema,
103    Documentation,
104    Code,
105    Test,
106    Runtime,
107    Connector,
108}
109
110impl SoftwareSourceKind {
111    pub const fn as_str(self) -> &'static str {
112        match self {
113            Self::Manifest => "manifest",
114            Self::Lockfile => "lockfile",
115            Self::Sbom => "sbom",
116            Self::BuildAttestation => "build_attestation",
117            Self::BuildFile => "build_file",
118            Self::Ci => "ci",
119            Self::Iac => "iac",
120            Self::ServiceDefinition => "service_definition",
121            Self::ApiSchema => "api_schema",
122            Self::Documentation => "documentation",
123            Self::Code => "code",
124            Self::Test => "test",
125            Self::Runtime => "runtime",
126            Self::Connector => "connector",
127        }
128    }
129
130    pub fn parse(value: &str) -> Option<Self> {
131        match value {
132            "manifest" => Some(Self::Manifest),
133            "lockfile" => Some(Self::Lockfile),
134            "sbom" => Some(Self::Sbom),
135            "build_attestation" => Some(Self::BuildAttestation),
136            "build_file" => Some(Self::BuildFile),
137            "ci" => Some(Self::Ci),
138            "iac" => Some(Self::Iac),
139            "service_definition" => Some(Self::ServiceDefinition),
140            "api_schema" => Some(Self::ApiSchema),
141            "documentation" => Some(Self::Documentation),
142            "code" => Some(Self::Code),
143            "test" => Some(Self::Test),
144            "runtime" => Some(Self::Runtime),
145            "connector" => Some(Self::Connector),
146            _ => None,
147        }
148    }
149}
150
151/// One immutable source location supporting an entity occurrence or statement.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct SoftwareEvidenceRef {
154    pub evidence_id: String,
155    pub source_scope: String,
156    pub path: String,
157    pub line_range: RepositoryCodeRange,
158}
159
160impl SoftwareEvidenceRef {
161    /// Creates a deterministic evidence identity without reading live source bytes.
162    pub fn new(
163        source_scope: impl Into<String>,
164        path: impl Into<String>,
165        line_range: RepositoryCodeRange,
166    ) -> Result<Self, DomainError> {
167        let source_scope = required_text("source_scope", source_scope.into())?;
168        let path = required_text("evidence_path", path.into())?;
169        if line_range.start == 0 || line_range.end < line_range.start {
170            return Err(DomainError::invalid(
171                "evidence_line_range",
172                "must use positive ordered line numbers",
173            ));
174        }
175        let start = line_range.start.to_string();
176        let end = line_range.end.to_string();
177        Ok(Self {
178            evidence_id: stable_software_id(
179                "software_evidence",
180                [
181                    source_scope.as_str(),
182                    path.as_str(),
183                    start.as_str(),
184                    end.as_str(),
185                ],
186            ),
187            source_scope,
188            path,
189            line_range,
190        })
191    }
192}
193
194/// One observed occurrence of a stable software entity.
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196pub struct SoftwareEntity {
197    pub entity_key: String,
198    pub occurrence_id: String,
199    pub repository_id: String,
200    pub source_scope: String,
201    pub entity_kind: SoftwareEntityKind,
202    pub name: String,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub namespace: Option<String>,
205    pub source_kind: SoftwareSourceKind,
206    pub evidence_refs: Vec<SoftwareEvidenceRef>,
207    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
208    pub attributes: BTreeMap<String, String>,
209    pub created_graph_version: GraphVersion,
210}
211
212impl SoftwareEntity {
213    /// Separates a commit-independent entity key from its snapshot occurrence id.
214    pub fn new(input: SoftwareEntityInput) -> Result<Self, DomainError> {
215        let repository_id = required_text("repository_id", input.repository_id)?;
216        let source_scope = required_text("source_scope", input.source_scope)?;
217        let name = required_text("software_entity_name", input.name)?;
218        let namespace = normalize_optional("software_entity_namespace", input.namespace)?;
219        validate_evidence_refs(&input.evidence_refs)?;
220        validate_attributes(&input.attributes)?;
221
222        let kind = input.entity_kind.as_str();
223        let namespace_part = namespace.as_deref().unwrap_or("");
224        let entity_key = if input.entity_kind.is_occurrence_kind() {
225            stable_software_id(
226                "software_entity",
227                [
228                    repository_id.as_str(),
229                    kind,
230                    namespace_part,
231                    name.as_str(),
232                    source_scope.as_str(),
233                ],
234            )
235        } else {
236            stable_software_id(
237                "software_entity",
238                [repository_id.as_str(), kind, namespace_part, name.as_str()],
239            )
240        };
241        let mut occurrence_parts = vec![entity_key.as_str(), source_scope.as_str()];
242        occurrence_parts.extend(
243            input
244                .evidence_refs
245                .iter()
246                .map(|evidence| evidence.evidence_id.as_str()),
247        );
248        let occurrence_id = stable_software_id("software_occurrence", occurrence_parts);
249
250        Ok(Self {
251            entity_key,
252            occurrence_id,
253            repository_id,
254            source_scope,
255            entity_kind: input.entity_kind,
256            name,
257            namespace,
258            source_kind: input.source_kind,
259            evidence_refs: input.evidence_refs,
260            attributes: input.attributes,
261            created_graph_version: input.created_graph_version,
262        })
263    }
264}
265
266/// Constructor input for `SoftwareEntity`.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct SoftwareEntityInput {
269    pub repository_id: String,
270    pub source_scope: String,
271    pub entity_kind: SoftwareEntityKind,
272    pub name: String,
273    pub namespace: Option<String>,
274    pub source_kind: SoftwareSourceKind,
275    pub evidence_refs: Vec<SoftwareEvidenceRef>,
276    pub attributes: BTreeMap<String, String>,
277    pub created_graph_version: GraphVersion,
278}
279
280fn validate_evidence_refs(evidence_refs: &[SoftwareEvidenceRef]) -> Result<(), DomainError> {
281    if evidence_refs.len() > MAX_EVIDENCE_REFS {
282        return Err(DomainError::invalid(
283            "evidence_refs",
284            format!("must contain {MAX_EVIDENCE_REFS} entries or fewer"),
285        ));
286    }
287    Ok(())
288}
289
290fn validate_attributes(attributes: &BTreeMap<String, String>) -> Result<(), DomainError> {
291    if attributes.len() > MAX_ENTITY_ATTRIBUTES {
292        return Err(DomainError::invalid(
293            "attributes",
294            format!("must contain {MAX_ENTITY_ATTRIBUTES} entries or fewer"),
295        ));
296    }
297    for (key, value) in attributes {
298        required_text("software_entity_attribute_key", key.clone())?;
299        required_text("software_entity_attribute_value", value.clone())?;
300    }
301    Ok(())
302}
303
304#[cfg(test)]
305#[path = "ontology_tests.rs"]
306mod tests;