Skip to main content

aion_server/
error.rs

1//! `ServerError` taxonomy for server library modules.
2
3use std::borrow::Cow;
4use std::net::SocketAddr;
5use std::path::PathBuf;
6
7use aion::EngineError;
8use aion_core::{ActivityId, WorkflowId};
9use aion_proto::WireError;
10use aion_store::StoreError;
11use thiserror::Error;
12
13#[path = "error_engine.rs"]
14mod engine;
15#[path = "error_process_exit.rs"]
16mod process_exit;
17
18/// Server-library error taxonomy.
19#[derive(Debug, Error)]
20pub enum ServerError {
21    /// Operator configuration could not be loaded or validated.
22    #[error("configuration error: {message}")]
23    Config {
24        /// Redacted, operator-facing failure message.
25        message: String,
26    },
27
28    /// A path-ambient store backend was configured beneath a renameable directory.
29    #[error(
30        "unsafe store.data_dir `{}`: ancestor `{}` is not owner-controlled: {reason}; \
31         leave store.data_dir unset so it defaults beneath the private Aion home \
32         (`$AION_HOME`, default `$HOME/.aion`), or set a path whose ancestor chain is \
33         owner-only (a leading `~` expands against $HOME; a relative path resolves \
34         against the server's working directory)",
35        .data_root.display(),
36        .component.display()
37    )]
38    UnsafeDataRootAncestor {
39        /// Descriptor-resolved data root that the backend would use by pathname.
40        data_root: PathBuf,
41        /// First unsafe component in the resolved root's ancestor chain.
42        component: PathBuf,
43        /// Ownership, mode, or inspection failure that made the component unsafe.
44        reason: String,
45    },
46
47    /// A transport listener could not bind or start.
48    #[error("{transport} transport failed at {address}: {message}")]
49    TransportBind {
50        /// Transport name.
51        transport: &'static str,
52        /// Configured listener address.
53        address: SocketAddr,
54        /// Redacted, operator-facing failure message.
55        message: String,
56    },
57
58    /// A running transport task aborted: it panicked or was cancelled.
59    #[error("{transport} transport task failed: {message}")]
60    Transport {
61        /// Transport name.
62        transport: &'static str,
63        /// Redacted, operator-facing failure message.
64        message: String,
65    },
66
67    /// A termination-signal listener could not be installed or failed.
68    #[error("{listener} listener failed: {message}")]
69    SignalListener {
70        /// Listener name (`SIGTERM`, `SIGINT`, or the portable fallback).
71        listener: &'static str,
72        /// Redacted, operator-facing failure message.
73        message: String,
74    },
75
76    /// The death note — the durable record of what killed the process — could
77    /// not be armed at boot.
78    #[error("death note error: {message}")]
79    DeathNote {
80        /// Redacted, operator-facing failure message.
81        message: String,
82    },
83
84    /// Namespace validation or authorization failed.
85    #[error("namespace error: {message}")]
86    Namespace {
87        /// Redacted namespace failure message.
88        message: String,
89    },
90
91    /// Engine call failed.
92    #[error("engine call failed: {source}")]
93    EngineCall {
94        /// Typed engine error returned by the embedded engine.
95        #[from]
96        source: EngineError,
97    },
98
99    /// Store backend call failed before an engine handle was available.
100    #[error("store backend failed: {source}")]
101    StoreBackend {
102        /// Typed store error returned by the configured backend.
103        #[from]
104        source: StoreError,
105    },
106
107    /// Streaming failure.
108    #[error("stream failure: {failure}")]
109    Stream {
110        /// Stream failure class.
111        failure: StreamFailure,
112    },
113
114    /// A scheduled activity could not be pushed to a worker.
115    #[error(
116        "worker dispatch failed for namespace {namespace}, activity type {activity_type}: {reason}"
117    )]
118    WorkerDispatch {
119        /// Namespace scoped before dispatch.
120        namespace: String,
121        /// Activity type requested by the engine.
122        activity_type: String,
123        /// Redacted dispatch failure reason.
124        reason: String,
125    },
126
127    /// The worker connection chosen for a dispatch was lost mid-flight: the
128    /// connection was already gone at push time, or it closed before the worker
129    /// sent its correlated push reply.
130    ///
131    /// This is DISTINCT from [`Self::WorkerDispatch`]: a `WorkerDispatch` covers a
132    /// genuine reply timeout (the worker is alive but slow), a no-worker-available
133    /// selection failure, or any other dispatch fault, all of which keep the
134    /// outbox's normal exponential backoff. A `WorkerConnectionLost` instead means
135    /// the chosen worker is gone (and has already been deregistered by liminal's
136    /// `on_worker_unregistered`), so the row can be re-armed for IMMEDIATE re-claim
137    /// to fail over to a live worker without waiting out the backoff. The outbox
138    /// dispatcher keys its fast-failover decision on this variant.
139    #[error("worker connection lost during dispatch on {channel}: {detail}")]
140    WorkerConnectionLost {
141        /// Row-derived dispatch channel for operator diagnostics.
142        channel: String,
143        /// Redacted, operator-facing description of how the connection was lost.
144        detail: String,
145    },
146
147    /// The worker connection chosen for a dispatch refused admission because it
148    /// already holds its pending-push cap — the worker is ALIVE and WORKING,
149    /// its connection is simply full of held dispatches.
150    ///
151    /// DISTINCT from both [`Self::WorkerDispatch`] (which consumes a retry
152    /// attempt on backoff) and [`Self::WorkerConnectionLost`] (immediate
153    /// attempt-consuming failover): a busy worker is neither slow to reply nor
154    /// gone, so the outbox re-arms the row ATTEMPT-NEUTRALLY after a short
155    /// backoff — capacity pressure must never spend the retry budget that
156    /// prices genuine delivery failures, and must never dead-letter work.
157    #[error("worker connection busy during dispatch on {channel}: {detail}")]
158    WorkerBusy {
159        /// Row-derived dispatch channel for operator diagnostics.
160        channel: String,
161        /// Redacted, operator-facing description of the admission refusal.
162        detail: String,
163    },
164
165    /// A second dispatcher tried to install a responder for an execution site
166    /// whose prior attempt is still held live.
167    #[error(
168        "pending activity collision for workflow {workflow_id}, activity {activity_id}: \
169         a live responder already owns this execution site"
170    )]
171    PendingActivityCollision {
172        /// Workflow whose responder slot is already occupied.
173        workflow_id: WorkflowId,
174        /// Activity site whose responder slot is already occupied.
175        activity_id: ActivityId,
176    },
177
178    /// A worker result did not prove ownership of the current execution
179    /// generation and was refused before reaching workflow state.
180    #[error(
181        "activity completion rejected for workflow {workflow_id}, activity {activity_id}: {reason}"
182    )]
183    ActivityCompletionRejected {
184        /// Workflow named by the submitted completion.
185        workflow_id: WorkflowId,
186        /// Activity site named by the submitted completion.
187        activity_id: ActivityId,
188        /// Typed reason the completion token did not authorize this write.
189        reason: CompletionRejectionReason,
190    },
191
192    /// A second server-executed declared body was started for an execution site
193    /// whose prior attempt is still running here.
194    ///
195    /// Distinct from [`Self::PendingActivityCollision`], which is about the
196    /// responder slot a dispatch installs: this is about the PROCESS. The
197    /// registry that makes a running declared command cancellable holds one
198    /// cancellation handle per attempt, so admitting a second execution would
199    /// replace the first command's handle — and a replaced handle is a command
200    /// running on the operator's machine that nothing can stop. Refusing the
201    /// second is the only outcome that leaves both attempts accounted for.
202    #[error(
203        "declared command for workflow {workflow_id}, activity {activity_id} attempt {attempt} \
204         is already executing at this server"
205    )]
206    DeclaredAttemptCollision {
207        /// Workflow whose declared body is already executing.
208        workflow_id: WorkflowId,
209        /// Activity site whose declared body is already executing.
210        activity_id: ActivityId,
211        /// Attempt whose declared body is already executing.
212        attempt: u32,
213    },
214
215    /// A lock was poisoned and the protected state cannot be trusted.
216    #[error("{resource} lock was poisoned")]
217    LockPoisoned {
218        /// Protected resource name.
219        resource: &'static str,
220    },
221
222    /// A failure already translated into the public wire taxonomy.
223    #[error("wire error: {wire}")]
224    Wire {
225        /// Stable wire error.
226        wire: WireError,
227    },
228}
229
230/// Typed completion-fence refusal classes.
231#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
232pub enum CompletionRejectionReason {
233    /// A worker from before completion-token support omitted the required token.
234    #[error("completion token is missing (worker registration era is incompatible)")]
235    MissingCompletionToken,
236    /// No generation is currently authorized for this activity execution.
237    #[error("no execution generation is currently accepting completion")]
238    NoCurrentGeneration,
239    /// A later dispatch superseded the submitted generation.
240    #[error("completion token belongs to a stale execution generation")]
241    StaleGeneration,
242}
243
244/// Bounded-stream and connection failure classes.
245#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
246pub enum StreamFailure {
247    /// Bounded per-connection buffer overflowed because the consumer lagged.
248    #[error("consumer lagged behind bounded buffer")]
249    Lagged,
250    /// Subscriber closed the connection.
251    #[error("subscriber connection closed")]
252    Closed,
253    /// Upstream engine event stream ended unexpectedly.
254    #[error("engine event stream closed")]
255    UpstreamClosed,
256}
257
258impl From<WireError> for ServerError {
259    fn from(wire: WireError) -> Self {
260        Self::Wire { wire }
261    }
262}
263
264impl ServerError {
265    /// Convert a server error that crosses a transport boundary into the stable
266    /// public wire taxonomy.
267    #[must_use]
268    pub fn to_wire_error(&self) -> WireError {
269        match self {
270            Self::Config { .. }
271            | Self::UnsafeDataRootAncestor { .. }
272            | Self::TransportBind { .. }
273            | Self::Transport { .. }
274            | Self::SignalListener { .. }
275            | Self::DeathNote { .. }
276            | Self::LockPoisoned { .. } => WireError::backend("server backend failure"),
277            Self::ActivityCompletionRejected { .. } => {
278                WireError::backend("stale activity completion rejected")
279            }
280            Self::PendingActivityCollision { .. } => {
281                WireError::backend("pending activity collision")
282            }
283            Self::DeclaredAttemptCollision { .. } => {
284                WireError::backend("declared command attempt collision")
285            }
286            Self::WorkerDispatch { .. } => WireError::backend("worker dispatch failed"),
287            Self::WorkerConnectionLost { .. } => {
288                WireError::backend("worker connection lost during dispatch")
289            }
290            Self::WorkerBusy { .. } => WireError::backend("worker connection busy during dispatch"),
291            Self::Namespace { message } => WireError::namespace_denied(message.clone()),
292            Self::EngineCall { source } => wire_from_engine(source),
293            Self::StoreBackend { source } => wire_from_store(source),
294            Self::Stream { failure } => match failure {
295                StreamFailure::Lagged => WireError::lagged("subscriber lagged behind"),
296                StreamFailure::Closed | StreamFailure::UpstreamClosed => {
297                    WireError::backend("event stream closed")
298                }
299            },
300            Self::Wire { wire } => wire.clone(),
301        }
302    }
303
304    /// Return true when this is an operator configuration failure.
305    #[must_use]
306    pub const fn is_config(&self) -> bool {
307        matches!(
308            self,
309            Self::Config { .. } | Self::UnsafeDataRootAncestor { .. }
310        )
311    }
312
313    /// Construct a namespace-denied error without embedding authorization logic.
314    #[must_use]
315    pub fn namespace_denied(message: impl Into<String>) -> Self {
316        Self::Namespace {
317            message: message.into(),
318        }
319    }
320
321    /// Construct the loud, whole-registration rejection when a worker's advertised
322    /// `node` violates a `Pinned{L}` namespace's placement (Control-Plane Phase 2,
323    /// P2-I1). Names the offending namespace, the worker's advertised node (or
324    /// "none"), and the required label set, so the operator sees exactly why the
325    /// registration was refused. Carried on the namespace-denied wire code — a
326    /// registration refused on isolation grounds is a namespace-authorization
327    /// failure, not a transient dispatch error.
328    #[must_use]
329    pub fn placement_admission_denied(
330        namespace: &str,
331        worker_node: Option<&str>,
332        required: &std::collections::BTreeSet<String>,
333    ) -> Self {
334        let node = worker_node.unwrap_or("none");
335        let required = required
336            .iter()
337            .map(String::as_str)
338            .collect::<Vec<_>>()
339            .join(", ");
340        Self::namespace_denied(format!(
341            "worker registration rejected: namespace {namespace} is Pinned to node label(s) \
342             [{required}] but the worker advertises node {node}, which is not in the required set"
343        ))
344    }
345
346    /// Construct a deploy-authorization denial carried on the dedicated
347    /// `deploy_denied` wire code (deploy is not a namespace operation).
348    #[must_use]
349    pub fn deploy_denied(message: impl Into<String>) -> Self {
350        Self::Wire {
351            wire: WireError::deploy_denied(message),
352        }
353    }
354
355    /// Construct a lagged-stream error.
356    #[must_use]
357    pub const fn lagged_stream() -> Self {
358        Self::Stream {
359            failure: StreamFailure::Lagged,
360        }
361    }
362
363    /// Construct a worker-dispatch error.
364    #[must_use]
365    pub fn worker_dispatch(
366        namespace: impl Into<String>,
367        activity_type: impl Into<String>,
368        reason: impl Into<String>,
369    ) -> Self {
370        Self::WorkerDispatch {
371            namespace: namespace.into(),
372            activity_type: activity_type.into(),
373            reason: reason.into(),
374        }
375    }
376
377    /// Construct a worker-connection-lost error for a dispatch whose chosen
378    /// worker connection was gone at push time or closed before replying.
379    #[must_use]
380    pub fn worker_connection_lost(channel: impl Into<String>, detail: impl Into<String>) -> Self {
381        Self::WorkerConnectionLost {
382            channel: channel.into(),
383            detail: detail.into(),
384        }
385    }
386
387    /// Return true when this is a lost-worker-connection dispatch failure.
388    ///
389    /// The outbox dispatcher keys its fast cross-node failover on this: a lost
390    /// connection means the worker is gone (already deregistered), so the row is
391    /// re-armed for immediate re-claim instead of waiting out the retry backoff.
392    #[must_use]
393    pub const fn is_worker_connection_lost(&self) -> bool {
394        matches!(self, Self::WorkerConnectionLost { .. })
395    }
396
397    /// Construct a worker-busy error for a dispatch refused at push admission
398    /// because the worker's connection already holds its pending-push cap.
399    #[must_use]
400    pub fn worker_busy(channel: impl Into<String>, detail: impl Into<String>) -> Self {
401        Self::WorkerBusy {
402            channel: channel.into(),
403            detail: detail.into(),
404        }
405    }
406
407    /// Return true when this is a busy-worker admission refusal.
408    ///
409    /// The outbox dispatcher keys its ATTEMPT-NEUTRAL re-arm on this: a full
410    /// connection means the worker is alive and holding earlier dispatches, so
411    /// the row waits for capacity without spending the retry budget that prices
412    /// genuine delivery failures.
413    #[must_use]
414    pub const fn is_worker_busy(&self) -> bool {
415        matches!(self, Self::WorkerBusy { .. })
416    }
417
418    /// Construct a lock-poison error at the lock boundary.
419    #[must_use]
420    pub const fn lock_poisoned(resource: &'static str) -> Self {
421        Self::LockPoisoned { resource }
422    }
423}
424
425/// Stable structured error metadata for tracing events.
426#[derive(Clone)]
427pub struct ErrorTraceFields<'a> {
428    /// Outer error type recorded in the `error_type` tracing field.
429    pub error_type: Cow<'a, str>,
430    /// Optional inner store error type for `StoreError` records.
431    pub store_error_type: Option<&'static str>,
432    /// Human-readable reason safe for operator logs.
433    pub reason: &'a dyn std::fmt::Display,
434}
435
436impl ServerError {
437    /// Return stable typed fields for structured error logging.
438    #[must_use]
439    pub fn trace_fields(&self) -> ErrorTraceFields<'_> {
440        match self {
441            Self::Config { message } => ErrorTraceFields {
442                error_type: Cow::Borrowed("Config"),
443                store_error_type: None,
444                reason: message,
445            },
446            Self::UnsafeDataRootAncestor { reason, .. } => ErrorTraceFields {
447                error_type: Cow::Borrowed("UnsafeDataRootAncestor"),
448                store_error_type: None,
449                reason,
450            },
451            Self::TransportBind { message, .. } => ErrorTraceFields {
452                error_type: Cow::Borrowed("TransportBind"),
453                store_error_type: None,
454                reason: message,
455            },
456            Self::Transport { message, .. } => ErrorTraceFields {
457                error_type: Cow::Borrowed("Transport"),
458                store_error_type: None,
459                reason: message,
460            },
461            Self::SignalListener { message, .. } => ErrorTraceFields {
462                error_type: Cow::Borrowed("SignalListener"),
463                store_error_type: None,
464                reason: message,
465            },
466            Self::DeathNote { message } => ErrorTraceFields {
467                error_type: Cow::Borrowed("DeathNote"),
468                store_error_type: None,
469                reason: message,
470            },
471            Self::Namespace { message } => ErrorTraceFields {
472                error_type: Cow::Borrowed("Namespace"),
473                store_error_type: None,
474                reason: message,
475            },
476            Self::EngineCall { source } => engine_trace_fields(source),
477            Self::StoreBackend { source } => store_trace_fields(source),
478            Self::Stream { failure } => ErrorTraceFields {
479                error_type: Cow::Borrowed("Stream"),
480                store_error_type: None,
481                reason: failure,
482            },
483            Self::WorkerDispatch { reason, .. } => ErrorTraceFields {
484                error_type: Cow::Borrowed("WorkerDispatch"),
485                store_error_type: None,
486                reason,
487            },
488            Self::WorkerConnectionLost { detail, .. } => ErrorTraceFields {
489                error_type: Cow::Borrowed("WorkerConnectionLost"),
490                store_error_type: None,
491                reason: detail,
492            },
493            Self::WorkerBusy { detail, .. } => ErrorTraceFields {
494                error_type: Cow::Borrowed("WorkerBusy"),
495                store_error_type: None,
496                reason: detail,
497            },
498            Self::PendingActivityCollision { activity_id, .. } => ErrorTraceFields {
499                error_type: Cow::Borrowed("PendingActivityCollision"),
500                store_error_type: None,
501                reason: activity_id,
502            },
503            Self::DeclaredAttemptCollision { activity_id, .. } => ErrorTraceFields {
504                error_type: Cow::Borrowed("DeclaredAttemptCollision"),
505                store_error_type: None,
506                reason: activity_id,
507            },
508            Self::ActivityCompletionRejected { reason, .. } => ErrorTraceFields {
509                error_type: Cow::Borrowed("ActivityCompletionRejected"),
510                store_error_type: None,
511                reason,
512            },
513            Self::LockPoisoned { resource } => ErrorTraceFields {
514                error_type: Cow::Borrowed("LockPoisoned"),
515                store_error_type: None,
516                reason: resource,
517            },
518            Self::Wire { wire } => ErrorTraceFields {
519                error_type: wire
520                    .error_type
521                    .as_deref()
522                    .map_or_else(|| Cow::Borrowed(wire.code.as_str()), Cow::Borrowed),
523                store_error_type: None,
524                reason: wire,
525            },
526        }
527    }
528}
529
530/// The wire type name for each #117(c) never-alive cancellation refusal.
531///
532/// Each keeps its OWN name rather than collapsing into one: an operator told
533/// "not found" for a run whose history they are reading learns nothing, and
534/// telling them "this workflow already has a writer" versus "your package now
535/// loads again" is the difference between a dead end and the next action.
536///
537/// Total without a panic. A variant that should never reach here gets the
538/// family label — an admittedly vague answer, which is the right failure mode
539/// for a function whose only job is to name an error someone is already
540/// receiving.
541fn never_alive_error_type(source: &EngineError) -> &'static str {
542    match source {
543        EngineError::TerminalWriterUnavailable { .. } => "TerminalWriterUnavailable",
544        EngineError::TerminalWriterHeld { .. } => "TerminalWriterHeld",
545        EngineError::RunIsRecoverable { .. } => "RunIsRecoverable",
546        EngineError::NoResidencyVerdict { .. } => "NoResidencyVerdict",
547        _ => "EngineError",
548    }
549}
550
551/// A durability failure that is really a STORE failure keeps the store's own
552/// trace fields; the rest are the engine's.
553///
554/// Extracted from [`engine_trace_fields`] because a nested match is a different
555/// question from the flat dispatch around it, not to satisfy a line count.
556fn durability_trace_fields<'a>(
557    durability: &'a aion::durability::DurabilityError,
558    source: &'a EngineError,
559) -> ErrorTraceFields<'a> {
560    match durability {
561        aion::durability::DurabilityError::Store(store) => store_trace_fields(store),
562        aion::durability::DurabilityError::NonDeterminism(_)
563        | aion::durability::DurabilityError::HistoryShape { .. }
564        | aion::durability::DurabilityError::SearchAttribute(_) => {
565            simple_engine_fields("Durability", source)
566        }
567        // Its own label rather than the generic `Durability`, and the SAME
568        // label the engine-level variant gets below, so an operator searching
569        // traces for "did this engine begin closing" finds every seam that
570        // refused for that reason under one name instead of one name and a
571        // generic one.
572        aion::durability::DurabilityError::EngineTaskEpochClosed { .. } => {
573            simple_engine_fields("EngineTaskEpochClosed", source)
574        }
575    }
576}
577
578fn engine_trace_fields(source: &EngineError) -> ErrorTraceFields<'_> {
579    match source {
580        EngineError::WorkflowNotFound { .. } => simple_engine_fields("WorkflowNotFound", source),
581        // #117(c): the never-alive cancellation path's four refusals.
582        EngineError::TerminalWriterUnavailable { .. }
583        | EngineError::TerminalWriterHeld { .. }
584        | EngineError::RunIsRecoverable { .. }
585        | EngineError::NoResidencyVerdict { .. } => {
586            simple_engine_fields(never_alive_error_type(source), source)
587        }
588        EngineError::InvalidState { .. } => simple_engine_fields("InvalidState", source),
589        EngineError::ScheduleNotFound { .. } => simple_engine_fields("ScheduleNotFound", source),
590        EngineError::ShuttingDown => simple_engine_fields("ShuttingDown", source),
591        EngineError::EngineTaskEpochClosed { .. } => {
592            simple_engine_fields("EngineTaskEpochClosed", source)
593        }
594        EngineError::Store(store) => store_trace_fields(store),
595        EngineError::Durability(durability) => durability_trace_fields(durability, source),
596        EngineError::MissingStore => simple_engine_fields("MissingStore", source),
597        EngineError::MissingVisibilityStore => {
598            simple_engine_fields("MissingVisibilityStore", source)
599        }
600        EngineError::ConflictingEventPublisher => {
601            simple_engine_fields("ConflictingEventPublisher", source)
602        }
603        EngineError::EventStreaming(_) => simple_engine_fields("EventStreaming", source),
604        EngineError::Load { .. } => simple_engine_fields("Load", source),
605        EngineError::UnenforceableContract { .. } => {
606            simple_engine_fields("UnenforceableContract", source)
607        }
608        EngineError::UnknownVersion { .. } => simple_engine_fields("UnknownVersion", source),
609        EngineError::VersionPinned { .. } => simple_engine_fields("VersionPinned", source),
610        EngineError::RouteActive { .. } => simple_engine_fields("RouteActive", source),
611        EngineError::ManifestMismatch { .. } => simple_engine_fields("ManifestMismatch", source),
612        EngineError::Package(_) => simple_engine_fields("Package", source),
613        EngineError::ContractIdentity { .. } => simple_engine_fields("ContractIdentity", source),
614        EngineError::NoQueueDeclaration { .. } => {
615            simple_engine_fields("NoQueueDeclaration", source)
616        }
617        EngineError::StartInputRefused { .. } => simple_engine_fields("StartInputRefused", source),
618        EngineError::SignalRefused { .. } => simple_engine_fields("SignalRefused", source),
619        EngineError::Schedule { .. } => simple_engine_fields("Schedule", source),
620        EngineError::Runtime { .. } => simple_engine_fields("Runtime", source),
621        EngineError::Gate3BifReplacementMissing { .. } => {
622            simple_engine_fields("Gate3BifReplacementMissing", source)
623        }
624        EngineError::StartupRecoveryNotDeferred => {
625            simple_engine_fields("StartupRecoveryNotDeferred", source)
626        }
627        EngineError::StartupRecoveryAlreadyRan => {
628            simple_engine_fields("StartupRecoveryAlreadyRan", source)
629        }
630        EngineError::StartupCatchupBeforeWorkflowRecovery => {
631            simple_engine_fields("StartupCatchupBeforeWorkflowRecovery", source)
632        }
633        EngineError::StartupRecoverySlotPoisoned => {
634            simple_engine_fields("StartupRecoverySlotPoisoned", source)
635        }
636        EngineError::CleanupExecutorPoisoned => {
637            simple_engine_fields("CleanupExecutorPoisoned", source)
638        }
639        EngineError::CleanupExecutorShutdownTimedOut { .. } => {
640            simple_engine_fields("CleanupExecutorShutdownTimedOut", source)
641        }
642        EngineError::RunNotInHistory { .. } => simple_engine_fields("RunNotInHistory", source),
643        EngineError::ProcessExitRegistryPoisoned => {
644            simple_engine_fields("ProcessExitRegistryPoisoned", source)
645        }
646        EngineError::ProcessExitOwnershipPoisoned { .. } => {
647            simple_engine_fields("ProcessExitOwnershipPoisoned", source)
648        }
649        EngineError::ProcessExitStatePoisoned { .. }
650        | EngineError::ProcessExitSubscriptionUnavailable
651        | EngineError::ProcessExitDrainerSpawn { .. }
652        | EngineError::ProcessExitDrainerPoisoned
653        | EngineError::ProcessExitOutcomeMissingAfterEvent { .. }
654        | EngineError::ProcessExitEventStreamDisconnected
655        | EngineError::ProcessExitDrainerShutdownTimedOut { .. }
656        | EngineError::ProcessExitDrainerPanicked => process_exit::drainer_trace(source),
657        EngineError::ProcessExitCallbackDispatcherPoisoned
658        | EngineError::ProcessExitCallbackDispatcherUnavailable
659        | EngineError::ProcessExitCallbackDispatcherShutdownTimedOut { .. } => {
660            process_exit::callback_trace(source)
661        }
662        EngineError::ProcessExitAlreadyTerminal { .. } => {
663            simple_engine_fields("ProcessExitAlreadyTerminal", source)
664        }
665        EngineError::ActivityDeliveryPoisoned { .. } => {
666            simple_engine_fields("ActivityDeliveryPoisoned", source)
667        }
668        EngineError::RegistryPoisoned => simple_engine_fields("RegistryPoisoned", source),
669        EngineError::CatalogPoisoned => simple_engine_fields("CatalogPoisoned", source),
670        EngineError::NifRegistration { .. } => simple_engine_fields("NifRegistration", source),
671        EngineError::SignalRouter(_) => simple_engine_fields("SignalRouter", source),
672        EngineError::Query(query) => simple_engine_fields(engine::query_error_type(query), source),
673    }
674}
675
676fn simple_engine_fields<'a>(
677    error_type: &'static str,
678    source: &'a EngineError,
679) -> ErrorTraceFields<'a> {
680    ErrorTraceFields {
681        error_type: Cow::Borrowed(error_type),
682        store_error_type: None,
683        reason: source,
684    }
685}
686
687fn store_trace_fields(source: &StoreError) -> ErrorTraceFields<'_> {
688    ErrorTraceFields {
689        error_type: Cow::Borrowed("StoreError"),
690        store_error_type: Some(engine::store_error_type(source)),
691        reason: source,
692    }
693}
694
695fn wire_from_engine(source: &EngineError) -> WireError {
696    use EngineError as E;
697    use engine::backend_wire as backend;
698
699    match source {
700        EngineError::WorkflowNotFound { .. } => {
701            WireError::not_found_with_type("WorkflowNotFound", source.to_string())
702        }
703        // Reopen preconditions use failed-precondition/HTTP 409, not NotFound.
704        EngineError::InvalidState { reason } => engine::invalid_state_wire(reason),
705        // #117(c). All four are PRECONDITION failures, never NotFound: in every
706        // one of them the run exists and its history is readable, and what has
707        // failed is a condition on cancelling it here and now. Mapping any of
708        // them to 404 would reproduce, one layer out, the exact lie this path
709        // exists to stop telling.
710        E::TerminalWriterUnavailable { .. }
711        | E::TerminalWriterHeld { .. }
712        | E::RunIsRecoverable { .. }
713        | E::NoResidencyVerdict { .. } => engine::invalid_state_wire(&source.to_string())
714            .with_error_type(never_alive_error_type(source)),
715        EngineError::ScheduleNotFound { .. } => {
716            WireError::not_found_with_type("ScheduleNotFound", source.to_string())
717        }
718        EngineError::ShuttingDown => {
719            WireError::not_running_with_type("ShuttingDown", source.to_string())
720        }
721        // The engine-task epoch closed before this run's terminal could be
722        // appended, so this process is no longer the run's single writer. The
723        // work was refused because the engine is going away, not because the
724        // request was wrong. Reachable only from an internal completion path,
725        // never from a request — but a total match is what keeps a new variant
726        // a COMPILE failure here rather than a silent default.
727        //
728        // 🔴 `backend`, NOT `not_running`, and an earlier revision of this arm
729        // had it wrong. The variant's own doc (`crates/aion/src/error.rs:389`)
730        // states the opposite of what `not_running` claims: "in both cases THE
731        // RUN STAYS `Running`", and a startup sweep re-installs a monitor. A
732        // caller told `not_running` is then sent to the CLI hint for that class
733        // (`aion-cli/src/render.rs:157-160`) — "the target run is no longer
734        // running; `aion list --status running` shows runs that can still serve
735        // queries" — which is false about this run twice over, and would have
736        // the operator hunting for a terminal that never landed. `backend`
737        // makes no claim about the run's state at all and carries the variant's
738        // own `Display`, which says exactly what happened; it is also the class
739        // this table already gives every other engine-internal refusal
740        // (`MissingStore`, `ConflictingEventPublisher`, `EventStreaming`).
741        E::EngineTaskEpochClosed { .. } => backend("EngineTaskEpochClosed", source),
742        EngineError::Store(store) => wire_from_store(store),
743        EngineError::Durability(durability) => engine::durability_wire(durability, source),
744        E::MissingStore => backend("MissingStore", source),
745        E::MissingVisibilityStore => backend("MissingVisibilityStore", source),
746        E::ConflictingEventPublisher => backend("ConflictingEventPublisher", source),
747        E::EventStreaming(_) => backend("EventStreaming", source),
748        E::Load { .. } => backend("Load", source),
749        // The archive is well-formed and the defect is in what it DECLARES, so
750        // this is the operator's own input to correct — not a backend fault.
751        EngineError::UnenforceableContract { .. } => {
752            WireError::invalid_input(source.to_string()).with_error_type("UnenforceableContract")
753        }
754        // Deploy refusals preserve not-found, state-conflict, and input classes.
755        EngineError::UnknownVersion { .. } => {
756            WireError::not_found_with_type("UnknownVersion", source.to_string())
757        }
758        EngineError::VersionPinned { .. } => {
759            WireError::version_pinned(source.to_string()).with_error_type("VersionPinned")
760        }
761        EngineError::RouteActive { .. } => {
762            WireError::version_pinned(source.to_string()).with_error_type("RouteActive")
763        }
764        EngineError::ManifestMismatch { .. } => {
765            WireError::invalid_input(source.to_string()).with_error_type("ManifestMismatch")
766        }
767        E::Package(_) => backend("Package", source),
768        E::ContractIdentity { .. } => engine::contract_refusal_wire("ContractIdentity", source),
769        E::NoQueueDeclaration { .. } => engine::contract_refusal_wire("NoQueueDeclaration", source),
770        E::StartInputRefused { .. } => engine::declared_contract_wire("StartInputRefused", source),
771        E::SignalRefused { .. } => engine::declared_contract_wire("SignalRefused", source),
772        EngineError::Schedule { .. } => backend("Schedule", source),
773        E::Runtime { .. } => backend("Runtime", source),
774        E::Gate3BifReplacementMissing { .. } => backend("Gate3BifReplacementMissing", source),
775        // Boot-sequencing invariants (#266): misuse of the one-shot deferred
776        // recovery slot is a server construction bug, never a caller's fault.
777        E::StartupRecoveryNotDeferred => backend("StartupRecoveryNotDeferred", source),
778        E::StartupRecoveryAlreadyRan => backend("StartupRecoveryAlreadyRan", source),
779        E::StartupCatchupBeforeWorkflowRecovery => {
780            backend("StartupCatchupBeforeWorkflowRecovery", source)
781        }
782        E::StartupRecoverySlotPoisoned => backend("StartupRecoverySlotPoisoned", source),
783        E::CleanupExecutorPoisoned => backend("CleanupExecutorPoisoned", source),
784        E::CleanupExecutorShutdownTimedOut { .. } => {
785            backend("CleanupExecutorShutdownTimedOut", source)
786        }
787        // aion#94: the registry holds a run that the history it was reconciled
788        // against does not contain. Nothing about the caller's request is wrong
789        // — it is an engine-internal disagreement between two of the engine's
790        // own stores — so `backend`, which makes no claim about the run's state
791        // and carries the variant's own Display.
792        E::RunNotInHistory { .. } => backend("RunNotInHistory", source),
793        E::ProcessExitRegistryPoisoned => backend("ProcessExitRegistryPoisoned", source),
794        E::ProcessExitOwnershipPoisoned { .. } => backend("ProcessExitOwnershipPoisoned", source),
795        EngineError::ProcessExitStatePoisoned { .. }
796        | EngineError::ProcessExitSubscriptionUnavailable
797        | EngineError::ProcessExitDrainerSpawn { .. }
798        | EngineError::ProcessExitDrainerPoisoned
799        | EngineError::ProcessExitOutcomeMissingAfterEvent { .. }
800        | EngineError::ProcessExitEventStreamDisconnected
801        | EngineError::ProcessExitDrainerShutdownTimedOut { .. }
802        | EngineError::ProcessExitDrainerPanicked => process_exit::drainer_wire(source),
803        EngineError::ProcessExitCallbackDispatcherPoisoned
804        | EngineError::ProcessExitCallbackDispatcherUnavailable
805        | EngineError::ProcessExitCallbackDispatcherShutdownTimedOut { .. } => {
806            process_exit::callback_wire(source)
807        }
808        E::ProcessExitAlreadyTerminal { .. } => backend("ProcessExitAlreadyTerminal", source),
809        E::ActivityDeliveryPoisoned { .. } => backend("ActivityDeliveryPoisoned", source),
810        E::CatalogPoisoned => backend("CatalogPoisoned", source),
811        E::RegistryPoisoned => backend("RegistryPoisoned", source),
812        E::NifRegistration { .. } => backend("NifRegistration", source),
813        E::SignalRouter(_) => backend("SignalRouter", source),
814        EngineError::Query(query) => engine::query_wire(query, source),
815    }
816}
817
818fn wire_from_store(source: &StoreError) -> WireError {
819    match source {
820        StoreError::SequenceConflict { .. } => WireError::new_with_type(
821            aion_proto::WireErrorCode::SequenceConflict,
822            "SequenceConflict",
823            source.to_string(),
824        ),
825        StoreError::NotFound { .. } => {
826            WireError::not_found_with_type("NotFound", source.to_string())
827        }
828        StoreError::NotOwner { .. } => {
829            WireError::not_owner(source.to_string()).with_error_type("NotOwner")
830        }
831        StoreError::Backend(_) => WireError::backend_with_type("Backend", source.to_string()),
832        StoreError::Serialization(_) => {
833            WireError::backend_with_type("Serialization", source.to_string())
834        }
835    }
836}
837
838#[cfg(test)]
839#[path = "error_tests.rs"]
840mod tests;