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