axioval_ir/
lib.rs

1//! Source-neutral semantic model and declarative package contracts.
2#![forbid(unsafe_code)]
3#![allow(
4    missing_docs,
5    clippy::missing_errors_doc,
6    clippy::return_self_not_must_use
7)]
8
9use std::{collections::BTreeMap, fmt};
10
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14/// Canonical normalized package contract emitted by `axioval/schema`.
15pub mod contract;
16pub use contract::{DefinitionPackage, RuleSetPackage};
17
18/// Validation error for source-neutral contracts.
19#[derive(Debug, Error, PartialEq, Eq)]
20pub enum IrError {
21    /// An identity component was blank.
22    #[error("{kind} must not be blank")]
23    Blank { kind: &'static str },
24    /// A project contains an ambiguous identity.
25    #[error("duplicate object id: {0}")]
26    DuplicateObject(ObjectId),
27}
28
29fn required(value: impl Into<String>, kind: &'static str) -> Result<String, IrError> {
30    let value = value.into();
31    if value.trim().is_empty() {
32        Err(IrError::Blank { kind })
33    } else {
34        Ok(value)
35    }
36}
37
38/// Stable, source-qualified input identity.
39#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct SourceId {
42    pub system: String,
43    pub document: String,
44}
45impl SourceId {
46    /// Creates a source identity.
47    pub fn new(system: impl Into<String>, document: impl Into<String>) -> Result<Self, IrError> {
48        Ok(Self {
49            system: required(system, "source system")?,
50            document: required(document, "source document")?,
51        })
52    }
53}
54impl fmt::Display for SourceId {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(f, "{}:{}", self.system, self.document)
57    }
58}
59
60/// Stable identity of an object within a source.
61#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct ObjectId {
64    pub source: SourceId,
65    pub local_id: String,
66}
67impl ObjectId {
68    /// Creates a source-qualified object identity.
69    pub fn new(source: SourceId, local_id: impl Into<String>) -> Result<Self, IrError> {
70        Ok(Self {
71            source,
72            local_id: required(local_id, "object local id")?,
73        })
74    }
75}
76impl fmt::Display for ObjectId {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        write!(f, "{}/{}", self.source, self.local_id)
79    }
80}
81
82/// Physical dimension of a canonical SI quantity.
83#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum QuantityDimension {
86    Length,
87    Area,
88    Volume,
89}
90
91/// A value supplied by a source adapter.
92#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
93#[serde(tag = "type", content = "value", rename_all = "snake_case")]
94pub enum PropertyValue {
95    Null,
96    Boolean(bool),
97    Integer(i64),
98    Decimal(f64),
99    Quantity {
100        value: f64,
101        dimension: QuantityDimension,
102    },
103    String(String),
104}
105
106/// Provenance and exactness of evidence.
107#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct Evidence {
110    pub source: SourceId,
111    pub locator: String,
112    pub exact: bool,
113}
114impl Evidence {
115    /// Creates evidence asserted exact by its adapter.
116    pub fn exact(source: SourceId, locator: impl Into<String>) -> Self {
117        Self {
118            source,
119            locator: locator.into(),
120            exact: true,
121        }
122    }
123}
124
125/// A namespace/code classification.
126#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct Classification {
129    pub system: String,
130    pub code: String,
131}
132impl Classification {
133    /// Creates a classification.
134    pub fn new(system: impl Into<String>, code: impl Into<String>) -> Result<Self, IrError> {
135        Ok(Self {
136            system: required(system, "classification system")?,
137            code: required(code, "classification code")?,
138        })
139    }
140}
141
142/// A named semantic property.
143#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct Property {
146    pub property_set: String,
147    pub name: String,
148    pub value: PropertyValue,
149    pub evidence: Option<Evidence>,
150}
151impl Property {
152    /// Creates a property without provenance.
153    pub fn new(
154        property_set: impl Into<String>,
155        name: impl Into<String>,
156        value: PropertyValue,
157    ) -> Result<Self, IrError> {
158        Ok(Self {
159            property_set: required(property_set, "property set")?,
160            name: required(name, "property name")?,
161            value,
162            evidence: None,
163        })
164    }
165    /// Attaches source evidence.
166    pub fn with_evidence(mut self, evidence: Evidence) -> Self {
167        self.evidence = Some(evidence);
168        self
169    }
170    /// Returns the typed property value.
171    pub fn value(&self) -> &PropertyValue {
172        &self.value
173    }
174}
175
176/// A source-neutral object and its semantic facts.
177#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
178#[serde(deny_unknown_fields)]
179pub struct Object {
180    pub id: ObjectId,
181    pub kind: String,
182    pub properties: Vec<Property>,
183    pub classifications: Vec<Classification>,
184    pub relationships: BTreeMap<String, Vec<ObjectId>>,
185}
186impl Object {
187    /// Creates an object.
188    pub fn new(id: ObjectId, kind: impl Into<String>) -> Self {
189        Self {
190            id,
191            kind: kind.into(),
192            properties: vec![],
193            classifications: vec![],
194            relationships: BTreeMap::new(),
195        }
196    }
197    /// Adds a property.
198    pub fn with_property(mut self, property: Property) -> Self {
199        self.properties.push(property);
200        self
201    }
202    /// Adds a classification.
203    pub fn with_classification(mut self, classification: Classification) -> Self {
204        self.classifications.push(classification);
205        self
206    }
207    /// Object semantic kind.
208    pub fn kind(&self) -> &str {
209        &self.kind
210    }
211    /// Finds a property by namespace and name.
212    pub fn property(&self, set: &str, name: &str) -> Option<&Property> {
213        self.properties
214            .iter()
215            .find(|p| p.property_set == set && p.name == name)
216    }
217}
218
219/// Deterministically indexed source-neutral project graph.
220#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
221#[serde(deny_unknown_fields)]
222pub struct Project {
223    objects: BTreeMap<ObjectId, Object>,
224}
225impl Project {
226    /// Builds a project, rejecting ambiguous IDs.
227    pub fn new(objects: Vec<Object>) -> Result<Self, IrError> {
228        let mut result = Self::default();
229        for object in objects {
230            if result
231                .objects
232                .insert(object.id.clone(), object.clone())
233                .is_some()
234            {
235                return Err(IrError::DuplicateObject(object.id));
236            }
237        }
238        Ok(result)
239    }
240    /// Finds an object by source-qualified ID.
241    pub fn object(&self, id: &ObjectId) -> Option<&Object> {
242        self.objects.get(id)
243    }
244    /// Iterates objects in stable identity order.
245    pub fn objects(&self) -> impl Iterator<Item = &Object> {
246        self.objects.values()
247    }
248}
249
250/// A source-neutral object selector.
251#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct Selector {
254    pub kinds: Vec<String>,
255    pub classification: Option<Classification>,
256}
257impl Selector {
258    /// Selects a semantic kind.
259    pub fn by_kind(kind: impl Into<String>) -> Self {
260        Self {
261            kinds: vec![kind.into()],
262            classification: None,
263        }
264    }
265    /// Requires a classification.
266    pub fn with_classification(
267        mut self,
268        system: impl Into<String>,
269        code: impl Into<String>,
270    ) -> Self {
271        self.classification = Classification::new(system, code).ok();
272        self
273    }
274    /// Whether an object matches all selector terms.
275    pub fn matches(&self, object: &Object) -> bool {
276        (self.kinds.is_empty() || self.kinds.iter().any(|k| k == &object.kind))
277            && self
278                .classification
279                .as_ref()
280                .is_none_or(|c| object.classifications.contains(c))
281    }
282}
283
284/// Stable package-local rule identity.
285#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
286#[serde(transparent)]
287pub struct RuleId(String);
288impl RuleId {
289    /// Creates an ID.
290    pub fn new(value: impl Into<String>) -> Result<Self, IrError> {
291        Ok(Self(required(value, "rule id")?))
292    }
293}
294impl fmt::Display for RuleId {
295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296        self.0.fmt(f)
297    }
298}
299
300/// Severity of a validation finding.
301#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
302#[serde(rename_all = "snake_case")]
303pub enum Severity {
304    Error,
305    Warning,
306    Info,
307}
308/// A deterministic, source-qualified validation outcome.
309#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
310#[serde(deny_unknown_fields)]
311pub struct Finding {
312    pub rule_id: RuleId,
313    pub object_id: ObjectId,
314    pub severity: Severity,
315    pub message: String,
316    pub evidence: Vec<Evidence>,
317}
318/// Why an object or rule instance could not be evaluated conclusively.
319#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
320#[serde(rename_all = "snake_case")]
321pub enum NotEvaluatedReason {
322    MissingService,
323    BackendUnavailable,
324    IncompleteEvidence,
325    InvalidEvidence,
326    InvalidDeclaration,
327    ResourceLimit,
328}
329/// Explicit fail-closed evaluation outcome. This is not a compliance finding.
330#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
331#[serde(deny_unknown_fields)]
332pub struct NotEvaluated {
333    pub rule_id: RuleId,
334    pub object_id: Option<ObjectId>,
335    pub reason: NotEvaluatedReason,
336    pub message: String,
337}
338/// Ordered report from a plan execution.
339#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
340#[serde(deny_unknown_fields)]
341pub struct Report {
342    pub findings: Vec<Finding>,
343    #[serde(default)]
344    pub not_evaluated: Vec<NotEvaluated>,
345}
346impl Report {
347    /// Findings in deterministic order.
348    pub fn findings(&self) -> &[Finding] {
349        &self.findings
350    }
351    /// Fail-closed rule or object evaluations in deterministic order.
352    pub fn not_evaluated(&self) -> &[NotEvaluated] {
353        &self.not_evaluated
354    }
355}