Skip to main content

axioval_engine/
session.rs

1use std::{
2    any::Any,
3    collections::{BTreeMap, BTreeSet},
4    sync::Arc,
5};
6
7use axioval_ir::{Project, SourceId};
8use thiserror::Error;
9
10use crate::{ServiceRegistry, ServiceRegistryError};
11
12/// Trusted service that declares the immutable source snapshots it can resolve.
13///
14/// Session registration validates these identities against the session before
15/// exposing the service to evaluation. A service may cover a subset of a
16/// multi-source session, but every declared binding must match exactly.
17pub trait SnapshotBoundService: Any + Send + Sync {
18    /// Exact source snapshots used to construct this service.
19    fn source_snapshots(&self) -> &[SourceSnapshot];
20}
21
22/// Immutable identity of one source revision in an evidence session.
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct SourceSnapshot {
25    source: SourceId,
26    revision: Arc<str>,
27    fingerprint: Arc<str>,
28    schema: Option<Arc<str>>,
29}
30
31impl SourceSnapshot {
32    /// Creates an exact source snapshot identity.
33    pub fn try_new(
34        source: SourceId,
35        revision: impl Into<Arc<str>>,
36        fingerprint: impl Into<Arc<str>>,
37    ) -> Result<Self, EvidenceSessionError> {
38        let revision = revision.into();
39        let fingerprint = fingerprint.into();
40        if revision.trim().is_empty() || fingerprint.trim().is_empty() {
41            return Err(EvidenceSessionError::InvalidSnapshotIdentity);
42        }
43        Ok(Self {
44            source,
45            revision,
46            fingerprint,
47            schema: None,
48        })
49    }
50    /// Binds a source-declared semantic schema to the immutable snapshot.
51    pub fn with_schema(
52        mut self,
53        schema: impl Into<Arc<str>>,
54    ) -> Result<Self, EvidenceSessionError> {
55        let schema = schema.into();
56        if schema.trim().is_empty() {
57            return Err(EvidenceSessionError::InvalidSnapshotIdentity);
58        }
59        self.schema = Some(schema);
60        Ok(self)
61    }
62    /// Stable source identity.
63    pub fn source(&self) -> &SourceId {
64        &self.source
65    }
66    /// Adapter-defined immutable revision.
67    pub fn revision(&self) -> &str {
68        &self.revision
69    }
70    /// Content fingerprint, including its algorithm when applicable.
71    pub fn fingerprint(&self) -> &str {
72        &self.fingerprint
73    }
74    /// Source-declared semantic schema, when the adapter has one.
75    pub fn schema(&self) -> Option<&str> {
76        self.schema.as_deref()
77    }
78}
79
80/// Invalid project/source snapshot binding.
81#[derive(Clone, Debug, Error, Eq, PartialEq)]
82pub enum EvidenceSessionError {
83    /// Snapshot revision or fingerprint is empty.
84    #[error("source snapshot revision and fingerprint must be non-empty")]
85    InvalidSnapshotIdentity,
86    /// Two snapshot declarations name the same source.
87    #[error("duplicate source snapshot: {0}")]
88    DuplicateSource(SourceId),
89    /// A project source has no immutable snapshot declaration.
90    #[error("project source has no snapshot declaration: {0}")]
91    MissingSource(SourceId),
92    /// A snapshot does not correspond to any project object.
93    #[error("snapshot source is not present in the project: {0}")]
94    UnexpectedSource(SourceId),
95    /// A service declared no immutable source binding.
96    #[error("evidence service has no source snapshot binding")]
97    UnboundService,
98    /// A service declared the same source binding more than once.
99    #[error("evidence service has duplicate source snapshot binding: {0}")]
100    DuplicateServiceSource(SourceId),
101    /// A service source is absent from the session or has a different identity.
102    #[error("evidence service snapshot does not match the session: {0}")]
103    ServiceSnapshotMismatch(SourceId),
104    /// Typed service registration failed.
105    #[error(transparent)]
106    ServiceRegistry(#[from] ServiceRegistryError),
107}
108
109/// Immutable project snapshot bound to the exact host services that produced
110/// and can resolve its evidence.
111///
112/// Adapters build a session once per source snapshot. Runtime evaluation then
113/// consumes the project and services as one unit, preventing accidental use of
114/// a resolver from a different model revision.
115pub struct EvidenceSession {
116    project: Arc<Project>,
117    snapshots: BTreeMap<SourceId, SourceSnapshot>,
118    services: ServiceRegistry,
119}
120
121impl EvidenceSession {
122    /// Starts a session after proving every project source has exactly one snapshot.
123    pub fn try_new(
124        project: Project,
125        snapshots: impl IntoIterator<Item = SourceSnapshot>,
126    ) -> Result<Self, EvidenceSessionError> {
127        let project_sources = project
128            .objects()
129            .map(|object| object.id.source.clone())
130            .collect::<BTreeSet<_>>();
131        let mut indexed = BTreeMap::new();
132        for snapshot in snapshots {
133            let source = snapshot.source.clone();
134            if indexed.insert(source.clone(), snapshot).is_some() {
135                return Err(EvidenceSessionError::DuplicateSource(source));
136            }
137        }
138        if let Some(source) = project_sources
139            .difference(&indexed.keys().cloned().collect())
140            .next()
141        {
142            return Err(EvidenceSessionError::MissingSource(source.clone()));
143        }
144        if let Some(source) = indexed
145            .keys()
146            .find(|source| !project_sources.contains(*source))
147        {
148            return Err(EvidenceSessionError::UnexpectedSource((*source).clone()));
149        }
150        Ok(Self {
151            project: Arc::new(project),
152            snapshots: indexed,
153            services: ServiceRegistry::new(),
154        })
155    }
156
157    /// Registers one non-replaceable typed evidence service.
158    pub fn with_service<T: SnapshotBoundService>(
159        mut self,
160        service: T,
161    ) -> Result<Self, EvidenceSessionError> {
162        let bindings = service.source_snapshots();
163        if bindings.is_empty() {
164            return Err(EvidenceSessionError::UnboundService);
165        }
166        let mut sources = BTreeSet::new();
167        for binding in bindings {
168            if !sources.insert(binding.source.clone()) {
169                return Err(EvidenceSessionError::DuplicateServiceSource(
170                    binding.source.clone(),
171                ));
172            }
173            if self.snapshots.get(&binding.source) != Some(binding) {
174                return Err(EvidenceSessionError::ServiceSnapshotMismatch(
175                    binding.source.clone(),
176                ));
177            }
178        }
179        self.services.register(service)?;
180        Ok(self)
181    }
182
183    /// Returns the immutable project snapshot.
184    #[must_use]
185    pub fn project(&self) -> &Project {
186        &self.project
187    }
188
189    /// Returns all immutable source identities bound to the project.
190    pub fn snapshots(&self) -> impl ExactSizeIterator<Item = &SourceSnapshot> {
191        self.snapshots.values()
192    }
193
194    /// Returns one source snapshot identity.
195    #[must_use]
196    pub fn snapshot(&self, source: &SourceId) -> Option<&SourceSnapshot> {
197        self.snapshots.get(source)
198    }
199
200    /// Returns all services bound to this snapshot.
201    #[must_use]
202    pub fn services(&self) -> &ServiceRegistry {
203        &self.services
204    }
205
206    /// Returns one typed service bound to this snapshot.
207    #[must_use]
208    pub fn service<T: Any + Send + Sync>(&self) -> Option<&T> {
209        self.services.get::<T>()
210    }
211}