Skip to main content

termivar_scanner/decision_runner/
failures.rs

1//! Typed execution and runner failure boundaries.
2
3use super::{
4    DecisionEvidenceReceipt, DecisionExecutionFailureReceipt, DecisionExecutionRequest,
5    DecisionExecutionStage, DecisionLoopError, DecisionReasoningCommitReceipt, Deserialize,
6    EntityId, Error, KnowledgeBaseError, PayloadStrategyRef, RuntimeLimitExceeded, Serialize,
7};
8
9/// Failure reported by an isolated action executor.
10#[derive(Debug, Clone, PartialEq, Eq, Error)]
11#[error("{message}")]
12pub struct DecisionExecutorError {
13    message: String,
14    kind: DecisionExecutionFailureKind,
15    receipt: Option<Box<DecisionExecutionFailureReceipt>>,
16    runtime_limit: Option<Box<RuntimeLimitExceeded>>,
17}
18
19impl DecisionExecutorError {
20    /// Creates a generic executor failure with a stable diagnostic.
21    ///
22    /// This compatibility constructor classifies the failure as
23    /// [`DecisionExecutionFailureKind::ExecutorFailure`]. Executors with
24    /// structured failure provenance should use [`Self::with_kind`].
25    pub fn new(message: impl Into<String>) -> Self {
26        Self::with_kind(DecisionExecutionFailureKind::ExecutorFailure, message)
27    }
28
29    /// Creates an executor failure with an explicit, transport-neutral kind.
30    pub fn with_kind(kind: DecisionExecutionFailureKind, message: impl Into<String>) -> Self {
31        let message = message.into();
32        Self {
33            message: if message.trim().is_empty() {
34                "executor failed without a diagnostic".to_owned()
35            } else {
36                message
37            },
38            kind,
39            receipt: None,
40            runtime_limit: None,
41        }
42    }
43
44    pub(crate) fn from_runtime_limit(limit: RuntimeLimitExceeded) -> Self {
45        Self {
46            message: limit.to_string(),
47            kind: DecisionExecutionFailureKind::BlockedByPolicy,
48            receipt: None,
49            runtime_limit: Some(Box::new(limit)),
50        }
51    }
52
53    /// Returns the executor-supplied diagnostic.
54    pub fn message(&self) -> &str {
55        &self.message
56    }
57
58    /// Returns the structured failure classification supplied by the executor.
59    pub fn kind(&self) -> DecisionExecutionFailureKind {
60        self.kind
61    }
62
63    /// Returns runner-owned execution context when this error crossed the
64    /// [`super::DecisionRunnerAdapter`] boundary.
65    pub fn execution_failure(&self) -> Option<&DecisionExecutionFailureReceipt> {
66        self.receipt.as_deref()
67    }
68
69    /// Returns the host resource limit that refused a transport dispatch.
70    pub fn runtime_limit(&self) -> Option<&RuntimeLimitExceeded> {
71        self.runtime_limit.as_deref()
72    }
73
74    pub(super) fn with_execution_context(
75        mut self,
76        request: DecisionExecutionRequest,
77        executor_id: String,
78    ) -> Self {
79        self.receipt = Some(Box::new(DecisionExecutionFailureReceipt {
80            request,
81            executor_id,
82            diagnostic: self.message.clone(),
83            kind: self.kind,
84            runtime_limit: self.runtime_limit.as_deref().cloned(),
85        }));
86        self
87    }
88
89    fn into_execution_failure(self) -> Option<DecisionExecutionFailureReceipt> {
90        self.receipt.map(|receipt| *receipt)
91    }
92
93    fn into_runtime_limit(self) -> Option<RuntimeLimitExceeded> {
94        self.runtime_limit.map(|limit| *limit)
95    }
96}
97
98/// Transport-neutral reason an executor reported failure before evidence commit.
99///
100/// These classifications are audit facts only. They do not create verifier
101/// outcomes or directly influence Experience Store suppression policy. Route
102/// resolution, evidence provenance validation, knowledge writes, and host
103/// wall-time enforcement remain separate runner or runtime failures.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106#[non_exhaustive]
107pub enum DecisionExecutionFailureKind {
108    /// The selected action does not apply to the decision subject.
109    NotApplicable,
110    /// Host authorization or safety policy refused the execution.
111    BlockedByPolicy,
112    /// Network transport failed before evidence could be collected.
113    TransportFailure,
114    /// A host-bounded request or response-body read exceeded its deadline.
115    RequestTimeout,
116    /// The executor failed independently of target transport.
117    ExecutorFailure,
118}
119
120/// Failures raised while resolving, executing, or recording a command.
121#[derive(Debug, Error)]
122#[non_exhaustive]
123pub enum DecisionRunnerError {
124    /// A registry or route identity was empty.
125    #[error("{field} must not be empty")]
126    EmptyValue { field: &'static str },
127
128    /// An executor ID was registered twice.
129    #[error("executor identity {executor_id} is already registered")]
130    ExecutorIdentityConflict { executor_id: String },
131
132    /// One action-stage pair was routed to two different executors.
133    #[error("{stage} action {action_id} already has a different executor route")]
134    ActionRouteConflict {
135        /// Verification stage of the conflicting route.
136        stage: DecisionExecutionStage,
137        /// Action whose route was reused.
138        action_id: String,
139    },
140
141    /// An explicit or routed executor was absent.
142    #[error("decision executor {executor_id} is not registered")]
143    UnknownExecutor { executor_id: String },
144
145    /// An action-only command had no stage route.
146    #[error("{stage} action {action_id} has no executor route")]
147    MissingActionRoute {
148        /// Verification stage being resolved.
149        stage: DecisionExecutionStage,
150        /// Action lacking a route.
151        action_id: String,
152    },
153
154    /// The resolved executor cannot materialize the planner-selected strategy.
155    #[error("decision executor {executor_id} does not support payload strategy {strategy}")]
156    UnsupportedPayloadStrategy {
157        /// Resolved executor identity.
158        executor_id: String,
159        /// Exact strategy revision selected by the planner.
160        strategy: PayloadStrategyRef,
161    },
162
163    /// A non-execution command was passed to the low-level execution API.
164    #[error("command {command} does not execute an action")]
165    NonExecutionCommand { command: &'static str },
166
167    /// The supplied command does not match the outstanding session case.
168    #[error("command case {actual} does not match outstanding case {expected}")]
169    CommandCaseMismatch { expected: String, actual: String },
170
171    /// The supplied command does not match the session verification stage.
172    #[error("cannot execute {expected} evidence while decision session is {actual}")]
173    UnexpectedSessionState {
174        /// Stage required by the command.
175        expected: DecisionExecutionStage,
176        /// Stable session state name.
177        actual: &'static str,
178    },
179
180    /// An active execution receipt violated an adapter-owned invariant.
181    #[error("active execution receipt did not capture a baseline snapshot")]
182    MissingActiveBaseline,
183
184    /// A high-level continuation command was supplied without the current
185    /// host-owned suppression context.
186    #[error("command {command} requires explicit host suppression context before execution")]
187    HostPolicyContextRequired {
188        /// Stable command class rejected before any executor work.
189        command: &'static str,
190    },
191
192    /// Current host policy suppressed the outstanding action before dispatch.
193    #[error("host policy suppresses action {action_id} before execution")]
194    ActionSuppressedByHostPolicy {
195        /// Action rejected before executor work or evidence commit.
196        action_id: String,
197    },
198
199    /// Current defense enforcement suppressed the action before dispatch.
200    #[error("defense enforcement suppresses action {action_id} before execution")]
201    ActionSuppressedByDefense {
202        /// Action rejected before executor work or evidence commit.
203        action_id: String,
204    },
205
206    /// Executor evidence described another subject.
207    #[error("evidence {evidence_id} subject {actual} does not match case subject {expected}")]
208    EvidenceSubjectMismatch {
209        /// Rejected evidence identity.
210        evidence_id: String,
211        /// Case subject.
212        expected: EntityId,
213        /// Evidence subject.
214        actual: EntityId,
215    },
216
217    /// Executor evidence claimed another producing component.
218    #[error("evidence {evidence_id} source {actual} does not match executor {expected}")]
219    EvidenceSourceMismatch {
220        /// Rejected evidence identity.
221        evidence_id: String,
222        /// Resolved executor identity.
223        expected: String,
224        /// Evidence source component.
225        actual: String,
226    },
227
228    /// Executor evidence omitted or changed the verification correlation ID.
229    #[error("evidence {evidence_id} correlation does not match case {expected}")]
230    EvidenceCorrelationMismatch {
231        /// Rejected evidence identity.
232        evidence_id: String,
233        /// Required case correlation identity.
234        expected: String,
235        /// Supplied correlation identity, if any.
236        actual: Option<String>,
237    },
238
239    /// An isolated executor failed.
240    #[error("decision executor {executor_id} failed: {source}")]
241    Executor {
242        /// Executor selected for the request.
243        executor_id: String,
244        /// Isolated executor diagnostic.
245        #[source]
246        source: DecisionExecutorError,
247    },
248
249    /// Evidence committed successfully but the subsequent decision transition failed.
250    #[error("decision transition failed after evidence was committed: {source}")]
251    OutcomeAfterEvidenceCommit {
252        /// Durable append-only evidence commit token.
253        receipt: Box<DecisionEvidenceReceipt>,
254        /// Failure raised while resuming the state machine.
255        #[source]
256        source: Box<DecisionRunnerError>,
257    },
258
259    /// Atomic evidence storage failed.
260    #[error(transparent)]
261    Knowledge(#[from] KnowledgeBaseError),
262
263    /// Resuming the deterministic state machine failed.
264    #[error(transparent)]
265    Decision(#[from] DecisionLoopError),
266}
267
268impl DecisionRunnerError {
269    /// Returns the host resource limit reported by an executor, when applicable.
270    pub fn runtime_limit(&self) -> Option<&RuntimeLimitExceeded> {
271        match self {
272            Self::Executor { source, .. } => source.runtime_limit(),
273            _ => None,
274        }
275    }
276
277    /// Takes an executor-reported host resource limit without cloning it.
278    pub fn into_runtime_limit(self) -> Option<RuntimeLimitExceeded> {
279        match self {
280            Self::Executor { source, .. } => source.into_runtime_limit(),
281            _ => None,
282        }
283    }
284
285    /// Returns an executor-reported pre-commit failure receipt, when applicable.
286    pub fn execution_failure(&self) -> Option<&DecisionExecutionFailureReceipt> {
287        match self {
288            Self::Executor { source, .. } => source.execution_failure(),
289            _ => None,
290        }
291    }
292
293    /// Takes ownership of an executor-reported failure receipt without cloning it.
294    pub fn into_execution_failure(self) -> Option<DecisionExecutionFailureReceipt> {
295        match self {
296            Self::Executor { source, .. } => source.into_execution_failure(),
297            _ => None,
298        }
299    }
300
301    /// Returns evidence that was committed before this error, when applicable.
302    pub fn committed_evidence(&self) -> Option<&DecisionEvidenceReceipt> {
303        match self {
304            Self::OutcomeAfterEvidenceCommit { receipt, .. } => Some(receipt),
305            _ => None,
306        }
307    }
308
309    /// Takes ownership of evidence committed before this error without cloning it.
310    pub fn into_committed_evidence(self) -> Option<DecisionEvidenceReceipt> {
311        match self {
312            Self::OutcomeAfterEvidenceCommit { receipt, .. } => Some(*receipt),
313            _ => None,
314        }
315    }
316
317    /// Returns reasoning committed before a later planning failure, when applicable.
318    pub fn committed_reasoning(&self) -> Option<&DecisionReasoningCommitReceipt> {
319        match self {
320            Self::Decision(source) => source.committed_reasoning(),
321            Self::OutcomeAfterEvidenceCommit { source, .. } => source.committed_reasoning(),
322            _ => None,
323        }
324    }
325
326    /// Takes a post-reasoning planning receipt without cloning it.
327    pub fn into_committed_reasoning(self) -> Option<DecisionReasoningCommitReceipt> {
328        match self {
329            Self::Decision(source) => source.into_committed_reasoning(),
330            Self::OutcomeAfterEvidenceCommit { source, .. } => source.into_committed_reasoning(),
331            _ => None,
332        }
333    }
334}