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