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}