Skip to main content

relay_knowledge/domain/operations/software/
shape.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2
3use serde::{Deserialize, Serialize};
4
5use super::{
6    SOFTWARE_ONTOLOGY_SCHEMA, SoftwareAssertionMode, SoftwareEntity, SoftwareEntityKind,
7    SoftwareFactState, SoftwarePredicate, SoftwareSourceKind, SoftwareStatement,
8    SoftwareStatementResolution,
9};
10
11/// Severity of a software ontology shape diagnostic.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum SoftwareShapeSeverity {
15    Error,
16    Warning,
17}
18
19impl SoftwareShapeSeverity {
20    pub const fn as_str(self) -> &'static str {
21        match self {
22            Self::Error => "error",
23            Self::Warning => "warning",
24        }
25    }
26
27    pub fn parse(value: &str) -> Option<Self> {
28        match value {
29            "error" => Some(Self::Error),
30            "warning" => Some(Self::Warning),
31            _ => None,
32        }
33    }
34}
35
36/// Queryable shape failure modeled after a SHACL validation result.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct SoftwareShapeDiagnostic {
39    pub diagnostic_id: String,
40    pub shape_id: String,
41    pub code: String,
42    pub severity: SoftwareShapeSeverity,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub statement_id: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub entity_key: Option<String>,
47    pub field: String,
48    pub message: String,
49}
50
51/// Validation report kept separate from the software data graph.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct SoftwareShapeReport {
54    pub conforms: bool,
55    pub diagnostics: Vec<SoftwareShapeDiagnostic>,
56}
57
58/// Predicate-specific source roles. No global source priority is implied.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct SoftwareAuthorityPolicy {
61    pub predicate: SoftwarePredicate,
62    pub declared_sources: Vec<SoftwareSourceKind>,
63    pub resolved_sources: Vec<SoftwareSourceKind>,
64    pub observed_sources: Vec<SoftwareSourceKind>,
65}
66
67/// Returns the authority contract for one predicate without choosing a winner.
68pub fn software_authority_policy(predicate: SoftwarePredicate) -> SoftwareAuthorityPolicy {
69    use SoftwareSourceKind as Source;
70    match predicate {
71        SoftwarePredicate::DependsOn => SoftwareAuthorityPolicy {
72            predicate,
73            declared_sources: vec![Source::Manifest],
74            resolved_sources: vec![Source::Lockfile, Source::Sbom, Source::BuildAttestation],
75            observed_sources: Vec::new(),
76        },
77        SoftwarePredicate::Builds | SoftwarePredicate::Produces => SoftwareAuthorityPolicy {
78            predicate,
79            declared_sources: vec![Source::BuildFile, Source::Ci],
80            resolved_sources: vec![Source::BuildAttestation],
81            observed_sources: vec![Source::BuildAttestation],
82        },
83        SoftwarePredicate::Deploys | SoftwarePredicate::RunsAs => SoftwareAuthorityPolicy {
84            predicate,
85            declared_sources: vec![Source::Iac, Source::ServiceDefinition],
86            resolved_sources: Vec::new(),
87            observed_sources: vec![Source::Runtime, Source::Connector],
88        },
89        SoftwarePredicate::ProvidesApi | SoftwarePredicate::ConsumesApi => {
90            SoftwareAuthorityPolicy {
91                predicate,
92                declared_sources: vec![Source::ApiSchema, Source::Code],
93                resolved_sources: vec![Source::ApiSchema],
94                observed_sources: vec![Source::Runtime],
95            }
96        }
97        _ => SoftwareAuthorityPolicy {
98            predicate,
99            declared_sources: vec![Source::Manifest, Source::Documentation, Source::Code],
100            resolved_sources: Vec::new(),
101            observed_sources: vec![Source::Runtime, Source::Connector],
102        },
103    }
104}
105
106/// Validates domain/range, evidence, identity, time, and provenance completeness.
107pub fn validate_software_shapes(
108    entities: &[SoftwareEntity],
109    statements: &[SoftwareStatement],
110) -> SoftwareShapeReport {
111    let mut diagnostics = Vec::new();
112    if let Err(error) = SOFTWARE_ONTOLOGY_SCHEMA.validate() {
113        diagnostics.push(SoftwareShapeDiagnostic {
114            diagnostic_id: diagnostic_id(
115                "ontology:SchemaShape",
116                "invalid_ontology_schema",
117                "software",
118                error.field,
119            ),
120            shape_id: "ontology:SchemaShape".to_owned(),
121            code: "invalid_ontology_schema".to_owned(),
122            severity: SoftwareShapeSeverity::Error,
123            statement_id: None,
124            entity_key: None,
125            field: error.field.to_owned(),
126            message: error.message,
127        });
128        return SoftwareShapeReport {
129            conforms: false,
130            diagnostics,
131        };
132    }
133    let entity_kinds = entity_kind_index(entities, &mut diagnostics);
134    validate_stable_identities(entities, &mut diagnostics);
135    for statement in statements {
136        validate_statement(statement, &entity_kinds, &mut diagnostics);
137    }
138    diagnostics.sort_by(|left, right| left.diagnostic_id.cmp(&right.diagnostic_id));
139    SoftwareShapeReport {
140        conforms: diagnostics
141            .iter()
142            .all(|diagnostic| diagnostic.severity != SoftwareShapeSeverity::Error),
143        diagnostics,
144    }
145}
146
147/// Rejects invalid candidates and marks competing objects as conflicts without overwriting either.
148pub fn reconcile_software_statements(
149    entities: &[SoftwareEntity],
150    mut statements: Vec<SoftwareStatement>,
151) -> (Vec<SoftwareStatement>, SoftwareShapeReport) {
152    let report = validate_software_shapes(entities, &statements);
153    let rejected = report
154        .diagnostics
155        .iter()
156        .filter(|diagnostic| diagnostic.severity == SoftwareShapeSeverity::Error)
157        .filter_map(|diagnostic| diagnostic.statement_id.as_deref())
158        .collect::<BTreeSet<_>>();
159    for statement in &mut statements {
160        if rejected.contains(statement.statement_id.as_str()) {
161            statement.fact_state = SoftwareFactState::Rejected;
162        } else if statement.resolution_state == SoftwareStatementResolution::Conflicting {
163            statement.fact_state = SoftwareFactState::Conflicting;
164        }
165    }
166
167    let mut competing = BTreeMap::<(String, SoftwarePredicate), BTreeSet<String>>::new();
168    for statement in statements
169        .iter()
170        .filter(|statement| statement.fact_state == SoftwareFactState::Active)
171        .filter(|statement| statement.predicate == SoftwarePredicate::Supersedes)
172    {
173        if let Some(object) = statement.object_identity() {
174            competing
175                .entry((statement.subject_id.clone(), statement.predicate))
176                .or_default()
177                .insert(object.to_owned());
178        }
179    }
180    let conflicts = competing
181        .into_iter()
182        .filter_map(|(key, objects)| (objects.len() > 1).then_some(key))
183        .collect::<BTreeSet<_>>();
184    for statement in &mut statements {
185        if statement.fact_state == SoftwareFactState::Active
186            && conflicts.contains(&(statement.subject_id.clone(), statement.predicate))
187        {
188            statement.fact_state = SoftwareFactState::Conflicting;
189            statement.resolution_state = SoftwareStatementResolution::Conflicting;
190        }
191    }
192    (statements, report)
193}
194
195#[derive(Debug)]
196struct EntityShapeIndex {
197    kind: SoftwareEntityKind,
198    source_scopes: BTreeSet<String>,
199}
200
201fn entity_kind_index(
202    entities: &[SoftwareEntity],
203    diagnostics: &mut Vec<SoftwareShapeDiagnostic>,
204) -> HashMap<String, EntityShapeIndex> {
205    let mut kinds = HashMap::new();
206    for entity in entities {
207        if entity
208            .evidence_refs
209            .iter()
210            .any(|evidence| evidence.source_scope != entity.source_scope)
211        {
212            diagnostics.push(entity_diagnostic(
213                entity,
214                "software:ProvenanceShape",
215                "cross_scope_entity_evidence",
216                "evidence_refs",
217                "entity evidence must belong to the entity occurrence source scope",
218            ));
219        }
220        match kinds.entry(entity.entity_key.clone()) {
221            std::collections::hash_map::Entry::Vacant(entry) => {
222                entry.insert(EntityShapeIndex {
223                    kind: entity.entity_kind,
224                    source_scopes: BTreeSet::from([entity.source_scope.clone()]),
225                });
226            }
227            std::collections::hash_map::Entry::Occupied(mut entry) => {
228                if entry.get().kind != entity.entity_kind {
229                    diagnostics.push(entity_diagnostic(
230                        entity,
231                        "software:StableEntityShape",
232                        "entity_kind_conflict",
233                        "entity_kind",
234                        "one stable entity key cannot identify multiple entity kinds",
235                    ));
236                }
237                entry
238                    .get_mut()
239                    .source_scopes
240                    .insert(entity.source_scope.clone());
241            }
242        }
243    }
244    kinds
245}
246
247fn validate_stable_identities(
248    entities: &[SoftwareEntity],
249    diagnostics: &mut Vec<SoftwareShapeDiagnostic>,
250) {
251    let mut stable =
252        BTreeMap::<(String, SoftwareEntityKind, String, Option<String>), String>::new();
253    for entity in entities
254        .iter()
255        .filter(|entity| !entity.entity_kind.is_occurrence_kind())
256    {
257        let identity = (
258            entity.repository_id.clone(),
259            entity.entity_kind,
260            entity.name.clone(),
261            entity.namespace.clone(),
262        );
263        if let Some(previous) = stable.insert(identity, entity.entity_key.clone())
264            && previous != entity.entity_key
265        {
266            diagnostics.push(entity_diagnostic(
267                entity,
268                "software:StableEntityShape",
269                "unstable_entity_key",
270                "entity_key",
271                "stable entity identity changed across source scopes",
272            ));
273        }
274    }
275}
276
277fn validate_statement(
278    statement: &SoftwareStatement,
279    entity_kinds: &HashMap<String, EntityShapeIndex>,
280    diagnostics: &mut Vec<SoftwareShapeDiagnostic>,
281) {
282    if statement.evidence_refs.is_empty() {
283        diagnostics.push(statement_diagnostic(
284            statement,
285            "software:ProvenanceShape",
286            "missing_evidence",
287            "evidence_refs",
288            "accepted statements require at least one evidence reference",
289        ));
290    }
291    if statement
292        .evidence_refs
293        .iter()
294        .any(|evidence| evidence.source_scope != statement.source_scope)
295    {
296        diagnostics.push(statement_diagnostic(
297            statement,
298            "software:ProvenanceShape",
299            "cross_scope_evidence",
300            "evidence_refs",
301            "statement evidence must belong to the statement source scope",
302        ));
303    }
304    if statement.extractor_id.is_empty() || statement.extractor_version.is_empty() {
305        diagnostics.push(statement_diagnostic(
306            statement,
307            "software:ProvenanceShape",
308            "missing_extractor",
309            "extractor_version",
310            "extractor id and version are required",
311        ));
312    }
313    if statement.confidence_basis_points > 10_000 {
314        diagnostics.push(statement_diagnostic(
315            statement,
316            "software:ConfidenceShape",
317            "invalid_confidence",
318            "confidence_basis_points",
319            "confidence must be between 0 and 10000 basis points",
320        ));
321    }
322    if statement
323        .valid_from
324        .zip(statement.valid_to)
325        .is_some_and(|(from, to)| from > to)
326    {
327        diagnostics.push(statement_diagnostic(
328            statement,
329            "software:ValidityShape",
330            "invalid_validity_interval",
331            "valid_to",
332            "valid_to must not precede valid_from",
333        ));
334    }
335    if statement.assertion_mode == SoftwareAssertionMode::Observed
336        && statement.observed_at.is_none()
337    {
338        diagnostics.push(statement_diagnostic(
339            statement,
340            "software:ObservationShape",
341            "missing_observed_at",
342            "observed_at",
343            "observed statements require observed_at",
344        ));
345    }
346    if statement.object_id.is_some() == statement.object_value.is_some() {
347        diagnostics.push(statement_diagnostic(
348            statement,
349            "software:ObjectShape",
350            "invalid_object_cardinality",
351            "object_id",
352            "exactly one of object_id or object_value is required",
353        ));
354    } else if statement.object_value.is_some() {
355        diagnostics.push(statement_diagnostic(
356            statement,
357            "ontology:ObjectPropertyShape",
358            "literal_object_for_object_property",
359            "object_value",
360            "software ontology object properties require an ontology entity object",
361        ));
362    }
363
364    let Some(subject) = entity_kinds.get(&statement.subject_id) else {
365        diagnostics.push(statement_diagnostic(
366            statement,
367            "software:RelationShape",
368            "unknown_subject",
369            "subject_id",
370            "subject_id does not reference an entity in this source scope",
371        ));
372        return;
373    };
374    if !subject.source_scopes.contains(&statement.source_scope) {
375        diagnostics.push(statement_diagnostic(
376            statement,
377            "software:RelationShape",
378            "cross_scope_subject",
379            "subject_id",
380            "subject_id has no occurrence in the statement source scope",
381        ));
382    }
383    let schema = &SOFTWARE_ONTOLOGY_SCHEMA;
384    if !schema.allows_subject(statement.predicate.as_str(), subject.kind.as_str()) {
385        diagnostics.push(statement_diagnostic(
386            statement,
387            "software:RelationShape",
388            "invalid_domain",
389            "subject_id",
390            "predicate domain does not allow the subject entity kind",
391        ));
392    }
393    if let Some(object_id) = statement.object_id.as_deref() {
394        let Some(object) = entity_kinds.get(object_id) else {
395            diagnostics.push(statement_diagnostic(
396                statement,
397                "software:RelationShape",
398                "unknown_object",
399                "object_id",
400                "object_id does not reference an entity in this source scope",
401            ));
402            return;
403        };
404        if !object.source_scopes.contains(&statement.source_scope) {
405            diagnostics.push(statement_diagnostic(
406                statement,
407                "software:RelationShape",
408                "cross_scope_object",
409                "object_id",
410                "object_id has no occurrence in the statement source scope",
411            ));
412        }
413        if !schema.allows_relation(
414            statement.predicate.as_str(),
415            subject.kind.as_str(),
416            object.kind.as_str(),
417        ) {
418            diagnostics.push(statement_diagnostic(
419                statement,
420                "software:RelationShape",
421                "invalid_range",
422                "object_id",
423                "predicate range does not allow the object entity kind",
424            ));
425        }
426    }
427}
428
429fn statement_diagnostic(
430    statement: &SoftwareStatement,
431    shape_id: &str,
432    code: &str,
433    field: &str,
434    message: &str,
435) -> SoftwareShapeDiagnostic {
436    SoftwareShapeDiagnostic {
437        diagnostic_id: diagnostic_id(shape_id, code, &statement.statement_id, field),
438        shape_id: shape_id.to_owned(),
439        code: code.to_owned(),
440        severity: SoftwareShapeSeverity::Error,
441        statement_id: Some(statement.statement_id.clone()),
442        entity_key: None,
443        field: field.to_owned(),
444        message: message.to_owned(),
445    }
446}
447
448fn entity_diagnostic(
449    entity: &SoftwareEntity,
450    shape_id: &str,
451    code: &str,
452    field: &str,
453    message: &str,
454) -> SoftwareShapeDiagnostic {
455    SoftwareShapeDiagnostic {
456        diagnostic_id: diagnostic_id(shape_id, code, &entity.entity_key, field),
457        shape_id: shape_id.to_owned(),
458        code: code.to_owned(),
459        severity: SoftwareShapeSeverity::Error,
460        statement_id: None,
461        entity_key: Some(entity.entity_key.clone()),
462        field: field.to_owned(),
463        message: message.to_owned(),
464    }
465}
466
467fn diagnostic_id(shape_id: &str, code: &str, focus: &str, field: &str) -> String {
468    super::validation::stable_software_id("software_diagnostic", [shape_id, code, focus, field])
469}
470
471#[cfg(test)]
472#[path = "shape_tests.rs"]
473mod tests;