Skip to main content

termivar_scanner/api_observation/
model.rs

1use serde::Serialize;
2use std::fmt;
3use termivar_core::{EntityId, EvidenceId, RelationId};
4use thiserror::Error;
5
6use crate::{
7    knowledge::{KnowledgeBaseError, KnowledgeWrite},
8    rules::{RuleApplication, RuleEngineError},
9};
10
11/// Receipt for an observation pair committed to one [`crate::KnowledgeBase`] instance.
12///
13/// Evidence and its resource-scope relation are committed atomically. Rule
14/// application happens afterwards and is deliberately not part of that write
15/// transaction. This receipt does not imply that the in-memory knowledge base
16/// has been persisted by its host.
17#[derive(Clone, PartialEq, Eq, Serialize)]
18pub struct ApiObservationCommitReceipt {
19    pub(super) comparison_subject: EntityId,
20    pub(super) resource_scope: EntityId,
21    pub(super) evidence_id: EvidenceId,
22    pub(super) relation_id: RelationId,
23    pub(super) evidence_write: KnowledgeWrite,
24    pub(super) relation_write: KnowledgeWrite,
25}
26
27impl fmt::Debug for ApiObservationCommitReceipt {
28    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
29        formatter
30            .debug_struct("ApiObservationCommitReceipt")
31            .field("comparison_subject", &"<redacted>")
32            .field("resource_scope", &"<redacted>")
33            .field("evidence_id", &"<redacted>")
34            .field("relation_id", &"<redacted>")
35            .field("evidence_write", &self.evidence_write)
36            .field("relation_write", &self.relation_write)
37            .finish()
38    }
39}
40
41impl ApiObservationCommitReceipt {
42    /// Returns the isolated subject on which comparison reasoning runs.
43    pub fn comparison_subject(&self) -> &EntityId {
44        &self.comparison_subject
45    }
46
47    /// Returns the host-declared logical resource that was compared.
48    pub fn resource_scope(&self) -> &EntityId {
49        &self.resource_scope
50    }
51
52    /// Returns the immutable comparison evidence identity.
53    pub fn evidence_id(&self) -> &EvidenceId {
54        &self.evidence_id
55    }
56
57    /// Returns the evidence-backed resource relation identity.
58    pub fn relation_id(&self) -> &RelationId {
59        &self.relation_id
60    }
61
62    /// Returns whether evidence was inserted or replayed unchanged.
63    pub const fn evidence_write(&self) -> KnowledgeWrite {
64        self.evidence_write
65    }
66
67    /// Returns whether the resource relation was inserted or replayed unchanged.
68    pub const fn relation_write(&self) -> KnowledgeWrite {
69        self.relation_write
70    }
71}
72
73/// Complete successful observation and reasoning receipt.
74#[derive(Clone, PartialEq, Eq, Serialize)]
75pub struct ApiObservationReceipt {
76    pub(super) commit: ApiObservationCommitReceipt,
77    pub(super) applications: Vec<RuleApplication>,
78}
79
80impl fmt::Debug for ApiObservationReceipt {
81    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82        formatter
83            .debug_struct("ApiObservationReceipt")
84            .field("commit", &self.commit)
85            .field("application_count", &self.applications.len())
86            .finish()
87    }
88}
89
90impl ApiObservationReceipt {
91    /// Returns the observation commit for the supplied knowledge-base instance.
92    pub fn commit(&self) -> &ApiObservationCommitReceipt {
93        &self.commit
94    }
95
96    /// Returns rule evaluations and hypothesis writes in stable rule-ID order.
97    ///
98    /// Materialized hypotheses retain their evaluation timestamps, so complete
99    /// receipt bytes are not guaranteed to be identical across exact replays.
100    /// Each application carries the evaluated snapshot candidate and its write
101    /// status, not a fresh clone of committed state. If terminal-state
102    /// preservation matters, re-read the hypothesis from the knowledge base.
103    pub fn applications(&self) -> &[RuleApplication] {
104        &self.applications
105    }
106
107    /// Splits the receipt into its observation commit and reasoning applications.
108    pub fn into_parts(self) -> (ApiObservationCommitReceipt, Vec<RuleApplication>) {
109        (self.commit, self.applications)
110    }
111}
112
113/// Failure while accepting or reasoning over an API visibility observation.
114#[derive(Error)]
115#[non_exhaustive]
116pub enum ApiObservationError {
117    /// The observation described a resource outside the host-selected scope.
118    #[error("API visibility observation resource does not match expected resource")]
119    ResourceMismatch {
120        /// Resource authorized by the caller.
121        expected: EntityId,
122        /// Resource declared by the observation.
123        actual: EntityId,
124    },
125
126    /// A review query cannot make progress with an empty scan window.
127    #[error("API visibility review scan limit must be greater than zero")]
128    ZeroReviewScanLimit,
129
130    /// A review query exceeded the compiled per-page scan ceiling.
131    #[error("API visibility review scan limit {actual} exceeds hard ceiling {maximum}")]
132    ReviewScanLimitExceeded {
133        /// Rejected requested relation count.
134        actual: u16,
135        /// Inclusive compiled ceiling.
136        maximum: u16,
137    },
138
139    /// A review cursor exceeded the relation-store identifier ceiling.
140    #[error("API visibility review cursor is {actual} bytes, above hard ceiling {maximum}")]
141    ReviewCursorTooLong {
142        /// Rejected cursor byte length.
143        actual: usize,
144        /// Inclusive relation identifier ceiling.
145        maximum: usize,
146    },
147
148    /// A serialized resource-bound review cursor exceeded its compiled ceiling.
149    #[error("API visibility resource-bound review cursor is {actual} bytes, above hard ceiling {maximum}")]
150    ResourceBoundReviewCursorTooLong {
151        /// Rejected serialized cursor byte length.
152        actual: usize,
153        /// Inclusive compiled cursor ceiling.
154        maximum: usize,
155    },
156
157    /// A serialized resource-bound review cursor was not canonical v2 syntax.
158    #[error("invalid API visibility resource-bound review cursor: {reason}")]
159    InvalidResourceBoundReviewCursor {
160        /// Stable parse reason that never contains cursor input.
161        reason: &'static str,
162    },
163
164    /// A resource-bound review cursor used an unsupported wire version.
165    #[error("unsupported API visibility resource-bound review cursor version")]
166    UnsupportedResourceBoundReviewCursorVersion,
167
168    /// A resource-bound review cursor was replayed against another resource.
169    #[error("API visibility resource-bound review cursor does not match requested resource")]
170    ResourceBoundReviewCursorMismatch,
171
172    /// An observation field exceeded the review model's storage ceiling.
173    #[error("API visibility observation {field} size {actual} exceeds hard ceiling {maximum}")]
174    ObservationLimitExceeded {
175        /// Stable field name (`source.component`).
176        field: &'static str,
177        /// Rejected UTF-8 byte count.
178        actual: usize,
179        /// Inclusive compiled ceiling.
180        maximum: usize,
181    },
182
183    /// The atomic evidence and relation write failed before anything committed.
184    #[error(transparent)]
185    Knowledge(#[from] KnowledgeBaseError),
186
187    /// Reasoning failed after the observation pair had committed.
188    #[error("API visibility observation committed before reasoning failed: {source}")]
189    ReasoningAfterCommit {
190        /// Committed observation that must not be retried as if no write occurred.
191        commit: Box<ApiObservationCommitReceipt>,
192        /// Rule evaluation or hypothesis-write failure.
193        #[source]
194        source: RuleEngineError,
195    },
196}
197
198impl fmt::Debug for ApiObservationError {
199    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
200        match self {
201            Self::ResourceMismatch { .. } => formatter
202                .debug_struct("ResourceMismatch")
203                .field("expected", &"<redacted>")
204                .field("actual", &"<redacted>")
205                .finish(),
206            Self::ZeroReviewScanLimit => formatter.write_str("ZeroReviewScanLimit"),
207            Self::ReviewScanLimitExceeded { actual, maximum } => formatter
208                .debug_struct("ReviewScanLimitExceeded")
209                .field("actual", actual)
210                .field("maximum", maximum)
211                .finish(),
212            Self::ReviewCursorTooLong { actual, maximum } => formatter
213                .debug_struct("ReviewCursorTooLong")
214                .field("actual", actual)
215                .field("maximum", maximum)
216                .finish(),
217            Self::ResourceBoundReviewCursorTooLong { actual, maximum } => formatter
218                .debug_struct("ResourceBoundReviewCursorTooLong")
219                .field("actual", actual)
220                .field("maximum", maximum)
221                .finish(),
222            Self::InvalidResourceBoundReviewCursor { reason } => formatter
223                .debug_struct("InvalidResourceBoundReviewCursor")
224                .field("reason", reason)
225                .finish(),
226            Self::UnsupportedResourceBoundReviewCursorVersion => {
227                formatter.write_str("UnsupportedResourceBoundReviewCursorVersion")
228            },
229            Self::ResourceBoundReviewCursorMismatch => {
230                formatter.write_str("ResourceBoundReviewCursorMismatch")
231            },
232            Self::ObservationLimitExceeded {
233                field,
234                actual,
235                maximum,
236            } => formatter
237                .debug_struct("ObservationLimitExceeded")
238                .field("field", field)
239                .field("actual", actual)
240                .field("maximum", maximum)
241                .finish(),
242            Self::Knowledge(source) => formatter.debug_tuple("Knowledge").field(source).finish(),
243            Self::ReasoningAfterCommit { commit, source } => formatter
244                .debug_struct("ReasoningAfterCommit")
245                .field("commit", commit)
246                .field("source", source)
247                .finish(),
248        }
249    }
250}