Skip to main content

axioval_engine/
integrity.rs

1//! Source integrity: facts about a source's own consistency, reported as warnings.
2//!
3//! Rule evaluation answers questions about a model; integrity answers whether
4//! the source is shaped the way its own schema says it must be. The two are
5//! kept apart on purpose. A source irregularity is not a rule finding (no rule
6//! was violated) and not a not-evaluated outcome (nothing was asked), so it
7//! gets its own channel. A host shows these as warnings next to the report,
8//! and a rule that depends on the affected data either refuses or, when it
9//! opted in, skips the instance and cites it.
10
11use std::sync::Arc;
12
13use axioval_ir::{Evidence, SourceId};
14use thiserror::Error;
15
16use crate::session::{SnapshotBoundService, SourceSnapshot};
17
18/// How much an integrity issue undermines evidence drawn from the source.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
20pub enum IntegritySeverity {
21    /// A known, bounded deviation: rules refuse by default and may opt in to
22    /// skip it, e.g. a relationship end the schema requires but real exporters
23    /// omit.
24    Warning,
25    /// The record cannot be read at all, e.g. a reference to a missing
26    /// entity. No rule can opt out of this.
27    Error,
28}
29
30/// One irregularity the source's own schema does not allow.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct IntegrityIssue {
33    /// Stable machine-readable code, e.g. `relationship.absent-required-end`.
34    pub code: String,
35    /// Whether the issue is skippable (warning) or corrupts evidence (error).
36    pub severity: IntegritySeverity,
37    /// Human-readable description of this occurrence.
38    pub message: String,
39    /// Exact locator of the offending source record.
40    pub evidence: Evidence,
41}
42
43/// Failure to produce a complete integrity report.
44#[derive(Clone, Debug, Error, PartialEq, Eq)]
45pub enum IntegrityError {
46    /// The service does not cover the requested source.
47    #[error("integrity service does not cover source `{0}`")]
48    UncoveredSource(SourceId),
49    /// An issue was reported without reviewable exact evidence.
50    #[error("integrity issue lacks reviewable exact evidence")]
51    InexactEvidence,
52    /// The service could not complete the scan.
53    #[error("integrity scan unavailable: {0}")]
54    Unavailable(String),
55}
56
57/// Adapter seam listing a source's integrity issues.
58pub trait SourceIntegrityService: Send + Sync {
59    /// Exact source snapshots the scan covers.
60    fn source_snapshots(&self) -> &[SourceSnapshot];
61    /// Every issue in one covered source, in a deterministic order.
62    fn issues(&self, source: &SourceId) -> Result<Vec<IntegrityIssue>, IntegrityError>;
63}
64
65/// Cloneable, type-erased integrity service registered by the host.
66#[derive(Clone)]
67pub struct SourceIntegrityServiceHandle(Arc<dyn SourceIntegrityService>);
68
69impl SourceIntegrityServiceHandle {
70    /// Wraps a trusted integrity service.
71    #[must_use]
72    pub fn new(service: Arc<dyn SourceIntegrityService>) -> Self {
73        Self(service)
74    }
75
76    /// Lists issues, validating coverage, evidence exactness and source binding.
77    pub fn issues(&self, source: &SourceId) -> Result<Vec<IntegrityIssue>, IntegrityError> {
78        if !self
79            .0
80            .source_snapshots()
81            .iter()
82            .any(|snapshot| snapshot.source() == source)
83        {
84            return Err(IntegrityError::UncoveredSource(source.clone()));
85        }
86        let mut issues = self.0.issues(source)?;
87        if issues.iter().any(|issue| {
88            !issue.evidence.exact
89                || issue.evidence.locator.trim().is_empty()
90                || issue.evidence.source != *source
91                || issue.code.trim().is_empty()
92        }) {
93            return Err(IntegrityError::InexactEvidence);
94        }
95        // Deterministic for hosts and diffs regardless of adapter order.
96        issues.sort_by(|left, right| {
97            (
98                &left.evidence.locator,
99                &left.code,
100                left.severity,
101                &left.message,
102            )
103                .cmp(&(
104                    &right.evidence.locator,
105                    &right.code,
106                    right.severity,
107                    &right.message,
108                ))
109        });
110        issues.dedup();
111        Ok(issues)
112    }
113}
114
115impl SnapshotBoundService for SourceIntegrityServiceHandle {
116    fn source_snapshots(&self) -> &[SourceSnapshot] {
117        self.0.source_snapshots()
118    }
119}