Skip to main content

aion_proto/
error.rs

1//! `WireError` taxonomy and mapping.
2//!
3//! `WireErrorCode` is the only client-branchable failure contract. The
4//! associated message is informational and may change without notice.
5//!
6//! Authoritative mapping table for adapters that can see engine/store types:
7//! - `aion_store::StoreError::SequenceConflict` -> `SequenceConflict`.
8//! - `aion_store::StoreError::NotFound` -> `NotFound`.
9//! - `aion_store::StoreError::Backend | Serialization` -> `Backend`.
10//! - `aion::EngineError::WorkflowNotFound` -> `NotFound`.
11//! - `aion::EngineError::Store | Durability(StoreError)` -> store mapping above.
12//! - Other operational engine failures -> `Backend`.
13//! - Query unknown/timeout/not-running/unknown-workflow ->
14//!   `UnknownQuery`/`QueryTimeout`/`NotRunning`/`NotFound`.
15//! - Query handler ran and reported an application-level failure ->
16//!   `QueryFailed`. Query reply dropped because the workflow ended first ->
17//!   `NotRunning`.
18//! - Signal terminal/unknown target -> `NotRunning`/`NotFound`.
19//! - Namespace authorization failure -> `NamespaceDenied`.
20//! - Missing grant word on a granted surface -> `GrantDenied` (the deploy
21//!   surface keeps its own `DeployDenied`).
22//! - Bounded subscriber overflow -> `Lagged`.
23//!
24//! This crate intentionally does not depend on `aion` or `aion-store` to keep
25//! the proto crate leaf-safe; server-side adapters apply this documented table
26//! where those concrete error types are reachable.
27
28use std::fmt;
29
30use serde::{Deserialize, Serialize};
31
32/// Stable, closed, client-branchable wire error codes.
33///
34/// The JSON representation is the `snake_case` code returned by
35/// [`WireErrorCode::as_str`] — the documented stable contract every SDK wire
36/// map branches on. `rename_all = "snake_case"` keeps Serialize/Deserialize
37/// byte-identical to `as_str()` for every variant; the
38/// `json_codes_match_as_str_and_round_trip` pin test enforces this.
39#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
40#[serde(rename_all = "snake_case")]
41pub enum WireErrorCode {
42    /// The requested workflow, run, activity, timer, or history item was not found.
43    NotFound,
44    /// The caller is not authorized to operate in the requested namespace.
45    NamespaceDenied,
46    /// A durable write lost an optimistic sequence-position race.
47    SequenceConflict,
48    /// The requested workflow query name is not registered.
49    UnknownQuery,
50    /// A workflow query exceeded its configured timeout/window.
51    QueryTimeout,
52    /// The target workflow is terminal or otherwise not running.
53    NotRunning,
54    /// A bounded stream consumer fell behind and was disconnected.
55    Lagged,
56    /// A request body, identifier, or envelope is malformed or semantically invalid.
57    InvalidInput,
58    /// Backend storage, serialization, runtime, or other internal failure.
59    Backend,
60    /// The workflow's query handler ran and reported an application-level failure.
61    QueryFailed,
62    /// The caller is not authorized to use the operator deploy surface.
63    DeployDenied,
64    /// The caller does not hold the grant a surface requires. The refusal
65    /// names the word and the knob that carries it. Distinct from
66    /// [`WireErrorCode::DeployDenied`], which names the deploy surface
67    /// specifically: a grant refusal that spelled `deploy_denied` would be
68    /// telling the caller to go looking at a surface it never touched.
69    GrantDenied,
70    /// A deploy unload/route was refused because the version is route-active
71    /// or pinned by live state.
72    VersionPinned,
73    /// The targeted shard is owned by a different cluster node; the request was
74    /// fenced. A retryable routing signal: the caller (or the request-routing
75    /// edge) should re-resolve the shard owner and retry or forward.
76    NotOwner,
77    /// A precondition on the target's current state was not met (e.g. a reopen
78    /// of a run that is not a reopenable terminal). Distinct from `NotFound`
79    /// (absent) and `Backend` (internal failure): the target exists but is in
80    /// the wrong state. Maps to gRPC `FailedPrecondition` / HTTP 409 Conflict.
81    InvalidState,
82}
83
84impl WireErrorCode {
85    /// Returns the stable string code SDKs may branch on.
86    #[must_use]
87    pub const fn as_str(self) -> &'static str {
88        match self {
89            Self::NotFound => "not_found",
90            Self::NamespaceDenied => "namespace_denied",
91            Self::SequenceConflict => "sequence_conflict",
92            Self::UnknownQuery => "unknown_query",
93            Self::QueryTimeout => "query_timeout",
94            Self::NotRunning => "not_running",
95            Self::Lagged => "lagged",
96            Self::InvalidInput => "invalid_input",
97            Self::Backend => "backend",
98            Self::QueryFailed => "query_failed",
99            Self::DeployDenied => "deploy_denied",
100            Self::GrantDenied => "grant_denied",
101            Self::VersionPinned => "version_pinned",
102            Self::NotOwner => "not_owner",
103            Self::InvalidState => "invalid_state",
104        }
105    }
106}
107
108impl fmt::Display for WireErrorCode {
109    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
110        formatter.write_str(self.as_str())
111    }
112}
113
114/// Wire-safe error value. `code` is stable; `message` is informational only.
115#[derive(thiserror::Error, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
116#[error("{code}: {message}")]
117pub struct WireError {
118    /// Stable client-branchable error code.
119    pub code: WireErrorCode,
120    /// Human-readable informational message.
121    pub message: String,
122    /// Concrete typed error variant, when the server can expose one safely.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub error_type: Option<String>,
125}
126
127impl WireError {
128    /// Creates a wire error with the supplied stable code and informational message.
129    #[must_use]
130    pub fn new(code: WireErrorCode, message: impl Into<String>) -> Self {
131        Self {
132            code,
133            message: message.into(),
134            error_type: None,
135        }
136    }
137
138    /// Attach a concrete typed error variant name to this wire error.
139    #[must_use]
140    pub fn with_error_type(mut self, error_type: impl Into<String>) -> Self {
141        self.error_type = Some(error_type.into());
142        self
143    }
144
145    /// Attach an optional concrete typed error variant name to this wire error.
146    #[must_use]
147    pub fn with_optional_error_type(mut self, error_type: Option<String>) -> Self {
148        self.error_type = error_type;
149        self
150    }
151
152    /// Creates a wire error with a concrete typed error variant name.
153    #[must_use]
154    pub fn new_with_type(
155        code: WireErrorCode,
156        error_type: impl Into<String>,
157        message: impl Into<String>,
158    ) -> Self {
159        Self::new(code, message).with_error_type(error_type)
160    }
161
162    /// Not-found failure.
163    #[must_use]
164    pub fn not_found(message: impl Into<String>) -> Self {
165        Self::new(WireErrorCode::NotFound, message)
166    }
167
168    /// Namespace authorization failure.
169    #[must_use]
170    pub fn namespace_denied(message: impl Into<String>) -> Self {
171        Self::new(WireErrorCode::NamespaceDenied, message)
172    }
173
174    /// Durable sequence conflict failure.
175    #[must_use]
176    pub fn sequence_conflict(message: impl Into<String>) -> Self {
177        Self::new(WireErrorCode::SequenceConflict, message)
178    }
179
180    /// Unknown workflow query failure.
181    #[must_use]
182    pub fn unknown_query(message: impl Into<String>) -> Self {
183        Self::new(WireErrorCode::UnknownQuery, message)
184    }
185
186    /// Query timeout failure.
187    #[must_use]
188    pub fn query_timeout(message: impl Into<String>) -> Self {
189        Self::new(WireErrorCode::QueryTimeout, message)
190    }
191
192    /// Workflow not-running failure.
193    #[must_use]
194    pub fn not_running(message: impl Into<String>) -> Self {
195        Self::new(WireErrorCode::NotRunning, message)
196    }
197
198    /// Lagged stream failure.
199    #[must_use]
200    pub fn lagged(message: impl Into<String>) -> Self {
201        Self::new(WireErrorCode::Lagged, message)
202    }
203
204    /// Invalid input failure.
205    #[must_use]
206    pub fn invalid_input(message: impl Into<String>) -> Self {
207        Self::new(WireErrorCode::InvalidInput, message)
208    }
209
210    /// Backend/internal failure.
211    #[must_use]
212    pub fn backend(message: impl Into<String>) -> Self {
213        Self::new(WireErrorCode::Backend, message)
214    }
215
216    /// Query-handler application-level failure.
217    #[must_use]
218    pub fn query_failed(message: impl Into<String>) -> Self {
219        Self::new(WireErrorCode::QueryFailed, message)
220    }
221
222    /// Deploy authorization failure.
223    #[must_use]
224    pub fn deploy_denied(message: impl Into<String>) -> Self {
225        Self::new(WireErrorCode::DeployDenied, message)
226    }
227
228    /// Grant authorization failure: the caller does not hold the grant word
229    /// the surface requires.
230    #[must_use]
231    pub fn grant_denied(message: impl Into<String>) -> Self {
232        Self::new(WireErrorCode::GrantDenied, message)
233    }
234
235    /// Deploy version-pinned refusal.
236    #[must_use]
237    pub fn version_pinned(message: impl Into<String>) -> Self {
238        Self::new(WireErrorCode::VersionPinned, message)
239    }
240
241    /// Wrong-shard-owner (fenced) failure — retryable routing signal.
242    #[must_use]
243    pub fn not_owner(message: impl Into<String>) -> Self {
244        Self::new(WireErrorCode::NotOwner, message)
245    }
246
247    /// Invalid-state precondition failure.
248    #[must_use]
249    pub fn invalid_state(message: impl Into<String>) -> Self {
250        Self::new(WireErrorCode::InvalidState, message)
251    }
252
253    /// Invalid-state precondition failure with a concrete typed error variant name.
254    #[must_use]
255    pub fn invalid_state_with_type(
256        error_type: impl Into<String>,
257        message: impl Into<String>,
258    ) -> Self {
259        Self::new_with_type(WireErrorCode::InvalidState, error_type, message)
260    }
261
262    /// Not-found failure with a concrete typed error variant name.
263    #[must_use]
264    pub fn not_found_with_type(error_type: impl Into<String>, message: impl Into<String>) -> Self {
265        Self::new_with_type(WireErrorCode::NotFound, error_type, message)
266    }
267
268    /// Not-running failure with a concrete typed error variant name.
269    #[must_use]
270    pub fn not_running_with_type(
271        error_type: impl Into<String>,
272        message: impl Into<String>,
273    ) -> Self {
274        Self::new_with_type(WireErrorCode::NotRunning, error_type, message)
275    }
276
277    /// Backend/internal failure with a concrete typed error variant name.
278    #[must_use]
279    pub fn backend_with_type(error_type: impl Into<String>, message: impl Into<String>) -> Self {
280        Self::new_with_type(WireErrorCode::Backend, error_type, message)
281    }
282}
283
284/// Proto representation of [`WireErrorCode`]. Zero is invalid on decode.
285#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, prost::Enumeration)]
286#[repr(i32)]
287pub enum ProtoWireErrorCode {
288    /// Missing/invalid code.
289    Unspecified = 0,
290    /// See [`WireErrorCode::NotFound`].
291    NotFound = 1,
292    /// See [`WireErrorCode::NamespaceDenied`].
293    NamespaceDenied = 2,
294    /// See [`WireErrorCode::SequenceConflict`].
295    SequenceConflict = 3,
296    /// See [`WireErrorCode::UnknownQuery`].
297    UnknownQuery = 4,
298    /// See [`WireErrorCode::QueryTimeout`].
299    QueryTimeout = 5,
300    /// See [`WireErrorCode::NotRunning`].
301    NotRunning = 6,
302    /// See [`WireErrorCode::Lagged`].
303    Lagged = 7,
304    /// See [`WireErrorCode::InvalidInput`].
305    InvalidInput = 8,
306    /// See [`WireErrorCode::Backend`].
307    Backend = 9,
308    /// See [`WireErrorCode::QueryFailed`].
309    QueryFailed = 10,
310    /// See [`WireErrorCode::DeployDenied`].
311    DeployDenied = 11,
312    /// See [`WireErrorCode::VersionPinned`].
313    VersionPinned = 12,
314    /// See [`WireErrorCode::NotOwner`].
315    NotOwner = 13,
316    /// See [`WireErrorCode::InvalidState`].
317    InvalidState = 14,
318    /// See [`WireErrorCode::GrantDenied`].
319    GrantDenied = 15,
320}
321
322/// Proto representation of [`WireError`].
323#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, prost::Message)]
324pub struct ProtoWireError {
325    /// Stable client-branchable code.
326    #[prost(enumeration = "ProtoWireErrorCode", tag = "1")]
327    pub code: i32,
328    /// Informational message.
329    #[prost(string, tag = "2")]
330    pub message: String,
331    /// Concrete typed error variant, when known.
332    #[prost(string, optional, tag = "3")]
333    pub error_type: Option<String>,
334}
335
336impl From<WireErrorCode> for ProtoWireErrorCode {
337    fn from(value: WireErrorCode) -> Self {
338        match value {
339            WireErrorCode::NotFound => Self::NotFound,
340            WireErrorCode::NamespaceDenied => Self::NamespaceDenied,
341            WireErrorCode::SequenceConflict => Self::SequenceConflict,
342            WireErrorCode::UnknownQuery => Self::UnknownQuery,
343            WireErrorCode::QueryTimeout => Self::QueryTimeout,
344            WireErrorCode::NotRunning => Self::NotRunning,
345            WireErrorCode::Lagged => Self::Lagged,
346            WireErrorCode::InvalidInput => Self::InvalidInput,
347            WireErrorCode::Backend => Self::Backend,
348            WireErrorCode::QueryFailed => Self::QueryFailed,
349            WireErrorCode::DeployDenied => Self::DeployDenied,
350            WireErrorCode::GrantDenied => Self::GrantDenied,
351            WireErrorCode::VersionPinned => Self::VersionPinned,
352            WireErrorCode::NotOwner => Self::NotOwner,
353            WireErrorCode::InvalidState => Self::InvalidState,
354        }
355    }
356}
357
358impl TryFrom<ProtoWireErrorCode> for WireErrorCode {
359    type Error = WireError;
360
361    fn try_from(value: ProtoWireErrorCode) -> Result<Self, Self::Error> {
362        match value {
363            ProtoWireErrorCode::Unspecified => {
364                Err(WireError::backend("wire error code is missing"))
365            }
366            ProtoWireErrorCode::NotFound => Ok(Self::NotFound),
367            ProtoWireErrorCode::NamespaceDenied => Ok(Self::NamespaceDenied),
368            ProtoWireErrorCode::SequenceConflict => Ok(Self::SequenceConflict),
369            ProtoWireErrorCode::UnknownQuery => Ok(Self::UnknownQuery),
370            ProtoWireErrorCode::QueryTimeout => Ok(Self::QueryTimeout),
371            ProtoWireErrorCode::NotRunning => Ok(Self::NotRunning),
372            ProtoWireErrorCode::Lagged => Ok(Self::Lagged),
373            ProtoWireErrorCode::InvalidInput => Ok(Self::InvalidInput),
374            ProtoWireErrorCode::Backend => Ok(Self::Backend),
375            ProtoWireErrorCode::QueryFailed => Ok(Self::QueryFailed),
376            ProtoWireErrorCode::DeployDenied => Ok(Self::DeployDenied),
377            ProtoWireErrorCode::GrantDenied => Ok(Self::GrantDenied),
378            ProtoWireErrorCode::VersionPinned => Ok(Self::VersionPinned),
379            ProtoWireErrorCode::NotOwner => Ok(Self::NotOwner),
380            ProtoWireErrorCode::InvalidState => Ok(Self::InvalidState),
381        }
382    }
383}
384
385impl From<WireError> for ProtoWireError {
386    fn from(value: WireError) -> Self {
387        let code = ProtoWireErrorCode::from(value.code) as i32;
388        Self {
389            code,
390            message: value.message,
391            error_type: value.error_type,
392        }
393    }
394}
395
396impl TryFrom<ProtoWireError> for WireError {
397    type Error = WireError;
398
399    fn try_from(value: ProtoWireError) -> Result<Self, Self::Error> {
400        let code = ProtoWireErrorCode::try_from(value.code)
401            .map_err(|_| WireError::backend("wire error code is unknown"))?;
402        Ok(Self::new(WireErrorCode::try_from(code)?, value.message)
403            .with_optional_error_type(value.error_type))
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::{ProtoWireError, ProtoWireErrorCode, WireError, WireErrorCode};
410
411    fn assert_send_sync<T: Send + Sync>() {}
412
413    /// Exhaustive successor chain over [`WireErrorCode`]. Adding a variant
414    /// makes this match non-exhaustive, so the build breaks until the new
415    /// variant is threaded into the chain and therefore into every test that
416    /// iterates [`all_codes`]. This is deliberately not a hand-maintained
417    /// list.
418    const fn next_code(code: WireErrorCode) -> Option<WireErrorCode> {
419        match code {
420            WireErrorCode::NotFound => Some(WireErrorCode::NamespaceDenied),
421            WireErrorCode::NamespaceDenied => Some(WireErrorCode::SequenceConflict),
422            WireErrorCode::SequenceConflict => Some(WireErrorCode::UnknownQuery),
423            WireErrorCode::UnknownQuery => Some(WireErrorCode::QueryTimeout),
424            WireErrorCode::QueryTimeout => Some(WireErrorCode::NotRunning),
425            WireErrorCode::NotRunning => Some(WireErrorCode::Lagged),
426            WireErrorCode::Lagged => Some(WireErrorCode::InvalidInput),
427            WireErrorCode::InvalidInput => Some(WireErrorCode::Backend),
428            WireErrorCode::Backend => Some(WireErrorCode::QueryFailed),
429            WireErrorCode::QueryFailed => Some(WireErrorCode::DeployDenied),
430            WireErrorCode::DeployDenied => Some(WireErrorCode::GrantDenied),
431            WireErrorCode::GrantDenied => Some(WireErrorCode::VersionPinned),
432            WireErrorCode::VersionPinned => Some(WireErrorCode::NotOwner),
433            WireErrorCode::NotOwner => Some(WireErrorCode::InvalidState),
434            WireErrorCode::InvalidState => None,
435        }
436    }
437
438    /// Every wire error code, derived from the compile-breaking chain above.
439    fn all_codes() -> Vec<WireErrorCode> {
440        let mut codes = vec![WireErrorCode::NotFound];
441        while let Some(&last) = codes.last() {
442            match next_code(last) {
443                Some(next) => codes.push(next),
444                None => break,
445            }
446        }
447        codes
448    }
449
450    #[test]
451    fn wire_error_is_send_sync() {
452        assert_send_sync::<WireError>();
453    }
454
455    /// The numeric proto enum values are the cross-SDK wire contract: every
456    /// generated decoder (Python, TypeScript, gRPC stubs) branches on these
457    /// exact integers, so each variant's number is pinned explicitly.
458    #[test]
459    fn proto_numeric_values_are_pinned() {
460        let expected: &[(WireErrorCode, i32)] = &[
461            (WireErrorCode::NotFound, 1),
462            (WireErrorCode::NamespaceDenied, 2),
463            (WireErrorCode::SequenceConflict, 3),
464            (WireErrorCode::UnknownQuery, 4),
465            (WireErrorCode::QueryTimeout, 5),
466            (WireErrorCode::NotRunning, 6),
467            (WireErrorCode::Lagged, 7),
468            (WireErrorCode::InvalidInput, 8),
469            (WireErrorCode::Backend, 9),
470            (WireErrorCode::QueryFailed, 10),
471            (WireErrorCode::DeployDenied, 11),
472            (WireErrorCode::GrantDenied, 15),
473            (WireErrorCode::VersionPinned, 12),
474            (WireErrorCode::NotOwner, 13),
475            (WireErrorCode::InvalidState, 14),
476        ];
477        assert_eq!(
478            expected.len(),
479            all_codes().len(),
480            "every WireErrorCode variant must have a pinned numeric value"
481        );
482        for &(code, number) in expected {
483            assert_eq!(
484                ProtoWireErrorCode::from(code) as i32,
485                number,
486                "{code:?} must keep proto enum value {number}",
487            );
488        }
489    }
490
491    /// The `snake_case` string codes are the JSON wire contract every SDK
492    /// branches on; each one is pinned explicitly.
493    #[test]
494    fn string_codes_are_pinned() {
495        let expected: &[(WireErrorCode, &str)] = &[
496            (WireErrorCode::NotFound, "not_found"),
497            (WireErrorCode::NamespaceDenied, "namespace_denied"),
498            (WireErrorCode::SequenceConflict, "sequence_conflict"),
499            (WireErrorCode::UnknownQuery, "unknown_query"),
500            (WireErrorCode::QueryTimeout, "query_timeout"),
501            (WireErrorCode::NotRunning, "not_running"),
502            (WireErrorCode::Lagged, "lagged"),
503            (WireErrorCode::InvalidInput, "invalid_input"),
504            (WireErrorCode::Backend, "backend"),
505            (WireErrorCode::QueryFailed, "query_failed"),
506            (WireErrorCode::DeployDenied, "deploy_denied"),
507            (WireErrorCode::GrantDenied, "grant_denied"),
508            (WireErrorCode::VersionPinned, "version_pinned"),
509            (WireErrorCode::NotOwner, "not_owner"),
510            (WireErrorCode::InvalidState, "invalid_state"),
511        ];
512        assert_eq!(
513            expected.len(),
514            all_codes().len(),
515            "every WireErrorCode variant must have a pinned string code"
516        );
517        for &(code, string) in expected {
518            assert_eq!(code.as_str(), string, "{code:?} must keep code {string}");
519        }
520    }
521
522    #[test]
523    fn json_codes_match_as_str_and_round_trip() -> Result<(), serde_json::Error> {
524        for code in all_codes() {
525            let serialized = serde_json::to_value(code)?;
526            assert_eq!(
527                serialized,
528                serde_json::Value::String(code.as_str().to_owned()),
529                "JSON serialization of {code:?} must equal as_str()",
530            );
531            let deserialized: WireErrorCode =
532                serde_json::from_value(serde_json::Value::String(code.as_str().to_owned()))?;
533            assert_eq!(deserialized, code, "{code:?} must round-trip through JSON");
534
535            let error = WireError::new(code, format!("message for {}", code.as_str()));
536            let body = serde_json::to_value(&error)?;
537            assert_eq!(
538                body.get("code"),
539                Some(&serde_json::Value::String(code.as_str().to_owned())),
540                "WireError JSON body must carry the snake_case code for {code:?}",
541            );
542            let decoded: WireError = serde_json::from_value(body)?;
543            assert_eq!(decoded, error);
544        }
545        Ok(())
546    }
547
548    #[test]
549    fn proto_round_trips_every_code() -> Result<(), WireError> {
550        for code in all_codes() {
551            let error = WireError::new_with_type(
552                code,
553                format!("{}Variant", code.as_str()),
554                format!("message for {}", code.as_str()),
555            );
556            let proto = ProtoWireError::from(error.clone());
557            let decoded = WireError::try_from(proto)?;
558            assert_eq!(decoded, error);
559        }
560
561        Ok(())
562    }
563
564    /// A grant refusal must never spell `deploy_denied`. The deploy code
565    /// literally names the deploy surface; a caller refused for
566    /// `assistant.sessions` and told `deploy_denied` would go looking at a
567    /// surface it never touched, and an operator reading the audit line would
568    /// grant the wrong word.
569    #[test]
570    fn a_grant_refusal_is_not_a_deploy_refusal() {
571        assert_ne!(WireErrorCode::GrantDenied, WireErrorCode::DeployDenied);
572        assert_ne!(
573            WireErrorCode::GrantDenied.as_str(),
574            WireErrorCode::DeployDenied.as_str()
575        );
576        assert_ne!(
577            ProtoWireErrorCode::from(WireErrorCode::GrantDenied) as i32,
578            ProtoWireErrorCode::from(WireErrorCode::DeployDenied) as i32
579        );
580    }
581
582    /// Two codes sharing one string would make the branchable contract
583    /// ambiguous for every SDK that branches on the string form.
584    #[test]
585    fn every_code_has_a_distinct_string() {
586        let mut strings: Vec<&str> = all_codes().iter().map(|code| code.as_str()).collect();
587        let total = strings.len();
588        strings.sort_unstable();
589        strings.dedup();
590        assert_eq!(strings.len(), total, "two wire codes share one string code");
591    }
592
593    #[test]
594    fn rejects_unspecified_proto_code() {
595        let proto = ProtoWireError {
596            code: 0,
597            message: String::from("missing"),
598            error_type: None,
599        };
600
601        let result = WireError::try_from(proto);
602        assert_eq!(
603            result,
604            Err(WireError::backend("wire error code is missing"))
605        );
606    }
607
608    #[test]
609    fn representative_documented_mappings_use_stable_codes() {
610        let engine_unknown_workflow = WireError::not_found("workflow was not found");
611        let store_sequence_conflict = WireError::sequence_conflict("event sequence conflicted");
612
613        assert_eq!(engine_unknown_workflow.code, WireErrorCode::NotFound);
614        assert_eq!(
615            store_sequence_conflict.code,
616            WireErrorCode::SequenceConflict
617        );
618        assert_eq!(
619            WireError::namespace_denied("denied").code,
620            WireErrorCode::NamespaceDenied
621        );
622        assert_eq!(
623            WireError::query_timeout("timeout").code,
624            WireErrorCode::QueryTimeout
625        );
626        assert_eq!(
627            WireError::unknown_query("unknown").code,
628            WireErrorCode::UnknownQuery
629        );
630        assert_eq!(
631            WireError::not_running("terminal").code,
632            WireErrorCode::NotRunning
633        );
634        assert_eq!(
635            WireError::invalid_input("malformed").code,
636            WireErrorCode::InvalidInput
637        );
638        assert_eq!(
639            WireError::query_failed("handler raised").code,
640            WireErrorCode::QueryFailed
641        );
642        assert_eq!(
643            WireError::deploy_denied("no deploy grant").code,
644            WireErrorCode::DeployDenied
645        );
646        assert_eq!(
647            WireError::version_pinned("pinned by live run").code,
648            WireErrorCode::VersionPinned
649        );
650        assert_eq!(
651            WireError::grant_denied("no assistant.sessions grant").code,
652            WireErrorCode::GrantDenied
653        );
654        assert_eq!(
655            WireError::not_owner("wrong shard owner").code,
656            WireErrorCode::NotOwner
657        );
658        assert_eq!(
659            WireError::invalid_state("run is not reopenable").code,
660            WireErrorCode::InvalidState
661        );
662    }
663}