axioval_engine/
properties.rs

1//! Exact source-neutral property-resolution host-service contracts.
2
3use axioval_ir::{Evidence, ObjectId, Property, PropertyValue};
4use std::sync::Arc;
5use thiserror::Error;
6
7use crate::session::{SnapshotBoundService, SourceSnapshot};
8
9/// Failure to resolve a property conclusively.
10#[derive(Clone, Debug, Error, PartialEq, Eq)]
11pub enum PropertyResolutionError {
12    /// The requested property reference is malformed.
13    #[error("property request is invalid")]
14    InvalidRequest,
15    /// Returned data names another object or property.
16    #[error("property response does not match its request")]
17    ResponseRequestMismatch,
18    /// A conclusive answer lacks exact, reviewable provenance.
19    #[error("property evidence is not exact and reviewable")]
20    InexactEvidence,
21    /// A conclusive answer contains a non-finite numeric value.
22    #[error("property value is not finite")]
23    InvalidValue,
24    /// The source could answer only part of the request scope.
25    #[error("property source coverage is incomplete: {0}")]
26    Incomplete(String),
27    /// Mutually incompatible exact facts were returned.
28    #[error("property evidence conflicts: {0}")]
29    Conflicting(String),
30    /// The source cannot currently provide a conclusive answer.
31    #[error("property resolution unavailable: {0}")]
32    Unavailable(String),
33}
34
35/// Request for one direct property on one source-qualified object.
36///
37/// This contract covers occurrence/type inheritance owned by the source adapter,
38/// but never traverses semantic relationships to other objects. Related-object
39/// selection requires a separately complete relationship service.
40#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
41pub struct PropertyRequest {
42    object_id: ObjectId,
43    property_set: Option<String>,
44    property: String,
45}
46impl PropertyRequest {
47    /// Creates a request. An omitted set requests an unambiguous property by name.
48    pub fn try_new(
49        object_id: ObjectId,
50        property_set: Option<String>,
51        property: impl Into<String>,
52    ) -> Result<Self, PropertyResolutionError> {
53        let property = property.into();
54        if property.trim().is_empty()
55            || property_set
56                .as_ref()
57                .is_some_and(|value| value.trim().is_empty())
58        {
59            return Err(PropertyResolutionError::InvalidRequest);
60        }
61        Ok(Self {
62            object_id,
63            property_set,
64            property,
65        })
66    }
67    /// Requested object.
68    pub fn object_id(&self) -> &ObjectId {
69        &self.object_id
70    }
71    /// Optional requested property set.
72    pub fn property_set(&self) -> Option<&str> {
73        self.property_set.as_deref()
74    }
75    /// Requested property name.
76    pub fn property(&self) -> &str {
77        &self.property
78    }
79    fn matches(&self, property: &Property) -> bool {
80        property.name == self.property
81            && self
82                .property_set
83                .as_ref()
84                .is_none_or(|set| property.property_set == *set)
85    }
86}
87
88/// Exact proof that a requested property is absent.
89#[derive(Clone, Debug, PartialEq)]
90pub struct CompletePropertyAbsenceEvidence {
91    request: PropertyRequest,
92    evidence: Evidence,
93}
94impl CompletePropertyAbsenceEvidence {
95    /// Creates request-bound exact absence evidence.
96    pub fn try_new(
97        request: PropertyRequest,
98        evidence: Evidence,
99    ) -> Result<Self, PropertyResolutionError> {
100        if !reviewable(&evidence) || evidence.source != request.object_id().source {
101            return Err(PropertyResolutionError::InexactEvidence);
102        }
103        Ok(Self { request, evidence })
104    }
105    /// Bound request.
106    pub fn request(&self) -> &PropertyRequest {
107        &self.request
108    }
109    /// Exact reviewable provenance.
110    pub fn evidence(&self) -> &Evidence {
111        &self.evidence
112    }
113}
114
115/// Exact property value bound to the request that produced it.
116#[derive(Clone, Debug, PartialEq)]
117pub struct ResolvedProperty {
118    request: PropertyRequest,
119    property: Property,
120}
121impl ResolvedProperty {
122    /// Creates an exact request-bound property value.
123    pub fn try_new(
124        request: PropertyRequest,
125        property: Property,
126    ) -> Result<Self, PropertyResolutionError> {
127        if !request.matches(&property) {
128            return Err(PropertyResolutionError::ResponseRequestMismatch);
129        }
130        if !property.evidence.as_ref().is_some_and(|evidence| {
131            reviewable(evidence) && evidence.source == request.object_id().source
132        }) {
133            return Err(PropertyResolutionError::InexactEvidence);
134        }
135        if !valid_value(&property.value) {
136            return Err(PropertyResolutionError::InvalidValue);
137        }
138        Ok(Self { request, property })
139    }
140    /// Bound request, including the source-qualified object identity.
141    pub fn request(&self) -> &PropertyRequest {
142        &self.request
143    }
144    /// Exact typed property and its reviewable provenance.
145    pub fn property(&self) -> &Property {
146        &self.property
147    }
148}
149
150/// Conclusive property result from a trusted source adapter.
151#[derive(Clone, Debug, PartialEq)]
152pub enum PropertyResolution {
153    /// The exact request-bound property value and its provenance.
154    Present(ResolvedProperty),
155    /// Exact proof that the requested property is absent.
156    Absent(CompletePropertyAbsenceEvidence),
157}
158
159/// Trusted adapter seam for property resolution.
160pub trait PropertyResolutionService: Send + Sync {
161    /// Exact source snapshots used to construct this resolver.
162    ///
163    /// The default is intentionally unbound for services used only through a
164    /// raw [`crate::ServiceRegistry`]; an [`crate::EvidenceSession`] rejects it.
165    fn source_snapshots(&self) -> &[SourceSnapshot] {
166        &[]
167    }
168    /// Resolves one request or reports why it is not conclusive.
169    fn resolve(
170        &self,
171        request: &PropertyRequest,
172    ) -> Result<PropertyResolution, PropertyResolutionError>;
173}
174
175/// Cloneable, type-erased property service registered by the host.
176#[derive(Clone)]
177pub struct PropertyResolutionServiceHandle {
178    service: Arc<dyn PropertyResolutionService>,
179}
180impl PropertyResolutionServiceHandle {
181    /// Wraps a trusted service for use outside an evidence session.
182    pub fn new(service: Arc<dyn PropertyResolutionService>) -> Self {
183        Self { service }
184    }
185    /// Resolves and validates request binding and exact provenance.
186    pub fn resolve(
187        &self,
188        request: &PropertyRequest,
189    ) -> Result<PropertyResolution, PropertyResolutionError> {
190        let resolution = self.service.resolve(request)?;
191        match &resolution {
192            PropertyResolution::Present(resolved) => {
193                if resolved.request() != request || !request.matches(resolved.property()) {
194                    return Err(PropertyResolutionError::ResponseRequestMismatch);
195                }
196                if !resolved
197                    .property()
198                    .evidence
199                    .as_ref()
200                    .is_some_and(|evidence| {
201                        reviewable(evidence) && evidence.source == request.object_id().source
202                    })
203                {
204                    return Err(PropertyResolutionError::InexactEvidence);
205                }
206                if !valid_value(&resolved.property().value) {
207                    return Err(PropertyResolutionError::InvalidValue);
208                }
209            }
210            PropertyResolution::Absent(evidence) => {
211                if evidence.request() != request {
212                    return Err(PropertyResolutionError::ResponseRequestMismatch);
213                }
214                if !reviewable(evidence.evidence())
215                    || evidence.evidence().source != request.object_id().source
216                {
217                    return Err(PropertyResolutionError::InexactEvidence);
218                }
219            }
220        }
221        Ok(resolution)
222    }
223}
224
225impl SnapshotBoundService for PropertyResolutionServiceHandle {
226    fn source_snapshots(&self) -> &[SourceSnapshot] {
227        self.service.source_snapshots()
228    }
229}
230
231fn valid_value(value: &PropertyValue) -> bool {
232    match value {
233        PropertyValue::Decimal(value) | PropertyValue::Quantity { value, .. } => value.is_finite(),
234        PropertyValue::Null
235        | PropertyValue::Boolean(_)
236        | PropertyValue::Integer(_)
237        | PropertyValue::String(_) => true,
238    }
239}
240
241fn reviewable(evidence: &Evidence) -> bool {
242    evidence.exact && !evidence.locator.trim().is_empty()
243}