Skip to main content

vta_sdk/
error.rs

1//! Structured error type for VTA SDK operations.
2
3/// Errors returned by VTA SDK client operations.
4#[derive(Debug, thiserror::Error)]
5pub enum VtaError {
6    /// Network-level error (connection refused, timeout, DNS failure).
7    #[cfg(feature = "client")]
8    #[error("network error: {0}")]
9    Network(#[from] reqwest::Error),
10
11    /// Authentication failed (401) or token expired.
12    #[error("authentication failed: {0}")]
13    Auth(String),
14
15    /// Resource not found (404).
16    #[error("not found: {0}")]
17    NotFound(String),
18
19    /// Request validation error (400).
20    #[error("validation error: {0}")]
21    Validation(String),
22
23    /// Permission denied (403).
24    #[error("forbidden: {0}")]
25    Forbidden(String),
26
27    /// Conflict (409) — e.g. duplicate key ID.
28    #[error("conflict: {0}")]
29    Conflict(String),
30
31    /// Gone (410) — the resource existed but is now permanently unavailable.
32    /// Most often emitted by the bootstrap carve-out endpoint after it has
33    /// been consumed; the CLI surfaces this with a "did you mean to run
34    /// `… provision-request`" hint instead of a flat string.
35    #[error("gone: {0}")]
36    Gone(String),
37
38    /// Server error (5xx).
39    #[error("server error ({status}): {body}")]
40    Server { status: u16, body: String },
41
42    /// The operation does not support the transport the client is
43    /// configured for (e.g. calling a REST-only helper on a client built
44    /// with DIDComm-only transport, or vice versa).
45    #[error("unsupported transport: {0}")]
46    UnsupportedTransport(String),
47
48    /// DIDComm transport failure (pack/send/pickup). Network-ish —
49    /// caller may want to retry. Distinct from [`Self::Network`] which
50    /// is REST-specific and carries a `reqwest::Error`.
51    #[error("didcomm transport error: {0}")]
52    DidcommTransport(String),
53
54    /// TSP transport failure (seal/route/websocket). Network-ish — caller may
55    /// want to retry. Kept distinct from [`Self::DidcommTransport`] rather than
56    /// folded into it: the two transports fail for different reasons and have
57    /// different recovery flags, and one shared message is what R6.4 exists to
58    /// prevent.
59    #[error("tsp transport error: {0}")]
60    TspTransport(String),
61
62    /// Remote endpoint returned a DIDComm problem-report whose `code`
63    /// did not match any of the standard `e.p.msg.*` taxonomy variants
64    /// (which map to the typed REST-aligned variants above). Inspect
65    /// `code` to handle it; a typed [`Self::Conflict`] / [`Self::NotFound`]
66    /// / [`Self::Auth`] / [`Self::Validation`] / [`Self::Server`] will
67    /// already have been emitted for the standard codes.
68    #[error("didcomm remote error ({code}): {comment}")]
69    DidcommRemote { code: String, comment: String },
70
71    /// Programmer-level protocol error (response shape did not match
72    /// what the SDK expected — version mismatch or bug). Distinct from
73    /// remote-error: a peer that returned a problem-report becomes a
74    /// typed variant via [`Self::from_problem_report`], not this one.
75    #[error("protocol error: {0}")]
76    Protocol(String),
77
78    /// Serialization/deserialization error.
79    #[error("serialization error: {0}")]
80    Serialization(#[from] serde_json::Error),
81
82    // ── Runtime service-management variants (spec §4) ──────────────
83    //
84    // These are emitted by the post-setup service-management surface
85    // (`services {rest,didcomm} {enable,update,disable,rollback}`).
86    // Structured data for the variants that carry numeric fields
87    // round-trips lossless via [`TypedErrorPayload`] across both
88    // REST response bodies and DIDComm problem-report args.
89    /// The operation would leave the VTA's DID document with no
90    /// advertised transport services. Per spec §3.2, this is rejected
91    /// without a `--force` escape hatch — enable the other transport
92    /// first if a swap is intended.
93    #[error("refusing operation: would leave the VTA with no advertised services")]
94    LastServiceRefused,
95
96    /// `update`, `disable`, or a kind-specific drain action was
97    /// invoked for a service kind that isn't currently enabled.
98    #[error("service is not present (not currently enabled)")]
99    ServiceNotPresent,
100
101    /// `enable` was invoked for a service kind that's already
102    /// enabled. Use `update` to change its configuration.
103    #[error("service is already enabled")]
104    ServiceAlreadyEnabled,
105
106    /// DIDComm handshake against the candidate mediator failed
107    /// (trust-ping refused, timed out, or peer was unreachable).
108    #[error("mediator handshake failed: {reason}")]
109    MediatorHandshakeFailed { reason: String },
110
111    /// Drain TTL is outside the valid range. Bounds are
112    /// `MIN_DRAIN_TTL_OVER_DIDCOMM` (3600s, when the disable command
113    /// is itself delivered over DIDComm) and `MAX_DRAIN_TTL`
114    /// (30 days). All three fields are in seconds.
115    #[error("drain ttl {requested}s outside allowed range [{min}s, {max}s]")]
116    DrainTtlOutOfBounds { min: u64, max: u64, requested: u64 },
117
118    /// `rollback` was invoked for a service kind that has no prior
119    /// mutation in its snapshot store to fail-forward from.
120    #[error("no prior mutation to roll back from")]
121    NoPriorMutation,
122
123    /// Catch-all for other errors.
124    /// No transport protocol is advertised by **both** this party and the
125    /// counterparty, so there is no way to communicate. Carries each side's
126    /// advertised set (in preference order) so the CLI can show the operator
127    /// what each offers and which transport to enable. Determined locally by
128    /// [`crate::protocol::matching::select_protocol`] after resolving the
129    /// peer's DID document — never a server-returned wire error.
130    #[error(
131        "no transport protocol in common with {counterparty_did}: \
132         we advertise {ours:?}, they advertise {theirs:?}"
133    )]
134    NoMatchingProtocol {
135        counterparty_did: String,
136        ours: Vec<crate::protocol::matching::Protocol>,
137        theirs: Vec<crate::protocol::matching::Protocol>,
138    },
139
140    #[error("{0}")]
141    Other(String),
142}
143
144/// Wire-format companion to the typed [`VtaError`] variants emitted
145/// by the runtime service-management surface.
146///
147/// The free-form `comment` string carried by DIDComm problem-reports
148/// (and the `body` string of REST error responses) is fine for the
149/// variants whose only data is a human-readable message
150/// ([`VtaError::Conflict`], [`VtaError::NotFound`], …) but lossy for
151/// variants like [`VtaError::DrainTtlOutOfBounds`] that carry three
152/// numeric fields the CLI needs to switch on.
153///
154/// Servers serialize a `TypedErrorPayload` into the response body
155/// (REST) or problem-report `args` (DIDComm); clients deserialize
156/// it back via [`VtaError::from_typed_payload`]. The discriminator
157/// is the kebab-cased variant name in the `code` field.
158///
159/// Variants line up 1:1 with the §4 spec list — the existing
160/// [`VtaError::UnsupportedTransport`] is included so the same
161/// channel carries every typed-error wire form.
162#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
163#[serde(tag = "code", rename_all = "kebab-case")]
164pub enum TypedErrorPayload {
165    LastServiceRefused,
166    ServiceNotPresent,
167    ServiceAlreadyEnabled,
168    MediatorHandshakeFailed { reason: String },
169    DrainTtlOutOfBounds { min: u64, max: u64, requested: u64 },
170    NoPriorMutation,
171    UnsupportedTransport { detail: String },
172}
173
174impl VtaError {
175    /// Create from an HTTP response status and error body.
176    ///
177    /// Public so a downstream SDK consumer wiring its own HTTP transport
178    /// (e.g. a wasm `gloo-net` client) can produce typed `VtaError`s
179    /// from status codes without re-implementing the mapping.
180    #[cfg(feature = "client")]
181    pub fn from_http(status: reqwest::StatusCode, body: String) -> Self {
182        match status.as_u16() {
183            401 => Self::Auth(body),
184            403 => Self::Forbidden(body),
185            404 => Self::NotFound(body),
186            400 | 422 => Self::Validation(body),
187            409 => Self::Conflict(body),
188            410 => Self::Gone(body),
189            s if s >= 500 => Self::Server { status: s, body },
190            s => Self::Other(format!("{s}: {body}")),
191        }
192    }
193
194    /// Create from a DIDComm problem-report `code` + `comment`. Mirrors
195    /// the REST [`Self::from_http`] mapping so callers can `match` on the
196    /// same variants regardless of transport.
197    ///
198    /// Standard codes (`e.p.msg.unauthorized` / `bad-request` / `not-found`
199    /// / `conflict` / `internal-error`) become typed variants. Anything
200    /// else lands in [`Self::DidcommRemote`] preserving the original code.
201    pub fn from_problem_report(code: &str, comment: impl Into<String>) -> Self {
202        use crate::protocols::problem_report_codes as c;
203        let comment = comment.into();
204        match code {
205            c::CONFLICT => Self::Conflict(comment),
206            c::NOT_FOUND => Self::NotFound(comment),
207            c::UNAUTHORIZED => Self::Auth(comment),
208            c::FORBIDDEN => Self::Forbidden(comment),
209            c::BAD_REQUEST => Self::Validation(comment),
210            c::INTERNAL => Self::Server {
211                status: 500,
212                body: comment,
213            },
214            other => Self::DidcommRemote {
215                code: other.to_string(),
216                comment,
217            },
218        }
219    }
220
221    /// Reconstruct the typed [`VtaError`] variant from a wire-format
222    /// [`TypedErrorPayload`]. Used by the client when decoding REST
223    /// response bodies / DIDComm problem-report args for the runtime
224    /// service-management surface (spec §4).
225    pub fn from_typed_payload(payload: TypedErrorPayload) -> Self {
226        match payload {
227            TypedErrorPayload::LastServiceRefused => Self::LastServiceRefused,
228            TypedErrorPayload::ServiceNotPresent => Self::ServiceNotPresent,
229            TypedErrorPayload::ServiceAlreadyEnabled => Self::ServiceAlreadyEnabled,
230            TypedErrorPayload::MediatorHandshakeFailed { reason } => {
231                Self::MediatorHandshakeFailed { reason }
232            }
233            TypedErrorPayload::DrainTtlOutOfBounds {
234                min,
235                max,
236                requested,
237            } => Self::DrainTtlOutOfBounds {
238                min,
239                max,
240                requested,
241            },
242            TypedErrorPayload::NoPriorMutation => Self::NoPriorMutation,
243            TypedErrorPayload::UnsupportedTransport { detail } => {
244                Self::UnsupportedTransport(detail)
245            }
246        }
247    }
248
249    /// Project this error onto the wire-format [`TypedErrorPayload`]
250    /// when the variant is one of the runtime service-management
251    /// errors. Returns `None` for variants that don't have a
252    /// structured wire form (network errors, generic conflicts,
253    /// programmer-level protocol errors, …).
254    #[must_use]
255    pub fn to_typed_payload(&self) -> Option<TypedErrorPayload> {
256        match self {
257            Self::LastServiceRefused => Some(TypedErrorPayload::LastServiceRefused),
258            Self::ServiceNotPresent => Some(TypedErrorPayload::ServiceNotPresent),
259            Self::ServiceAlreadyEnabled => Some(TypedErrorPayload::ServiceAlreadyEnabled),
260            Self::MediatorHandshakeFailed { reason } => {
261                Some(TypedErrorPayload::MediatorHandshakeFailed {
262                    reason: reason.clone(),
263                })
264            }
265            Self::DrainTtlOutOfBounds {
266                min,
267                max,
268                requested,
269            } => Some(TypedErrorPayload::DrainTtlOutOfBounds {
270                min: *min,
271                max: *max,
272                requested: *requested,
273            }),
274            Self::NoPriorMutation => Some(TypedErrorPayload::NoPriorMutation),
275            Self::UnsupportedTransport(detail) => Some(TypedErrorPayload::UnsupportedTransport {
276                detail: detail.clone(),
277            }),
278            _ => None,
279        }
280    }
281
282    /// Returns true if the resource was permanently consumed/gone (410).
283    pub fn is_gone(&self) -> bool {
284        matches!(self, Self::Gone(_))
285    }
286
287    /// Returns true if a create/insert collided with an existing entry (409).
288    pub fn is_conflict(&self) -> bool {
289        matches!(self, Self::Conflict(_))
290    }
291
292    /// Returns true if this is an authentication/authorization error.
293    pub fn is_auth(&self) -> bool {
294        matches!(self, Self::Auth(_) | Self::Forbidden(_))
295    }
296
297    /// Returns true if this is a network-level error (retryable).
298    pub fn is_network(&self) -> bool {
299        #[cfg(feature = "client")]
300        if matches!(self, Self::Network(_)) {
301            return true;
302        }
303        false
304    }
305
306    /// Returns true if the resource was not found.
307    pub fn is_not_found(&self) -> bool {
308        matches!(self, Self::NotFound(_))
309    }
310
311    /// Operator-actionable hint matching this error variant.
312    ///
313    /// `None` for variants where no generic guidance applies (the message
314    /// itself is the hint, or the failure is a programmer error). The
315    /// CLI layer (`vta-cli-common::render::print_cli_error`) already
316    /// implements bin-aware suggestions ("`pnm acl create …`"); this
317    /// method gives **non-CLI consumers** — web UIs, GUIs, custom
318    /// dashboards — the same hint surface without needing to fork the
319    /// dispatch logic.
320    ///
321    /// Returns a `&'static str` so callers can compose it into their
322    /// own UI without lifetime juggling. The bin-specific substitution
323    /// (`pnm` vs `cnm`) is left to the CLI layer because only it
324    /// knows which binary the operator is running.
325    #[must_use]
326    pub fn suggested_fix(&self) -> Option<&'static str> {
327        match self {
328            Self::Auth(_) => Some(
329                "Token may be expired. Re-authenticate against the VTA, or check that \
330                 the `/auth` endpoint is reachable.",
331            ),
332            Self::Forbidden(_) => Some(
333                "Your role or context access doesn't permit this operation. Inspect \
334                 the ACL entry for your DID against the target context.",
335            ),
336            Self::Gone(_) => Some(
337                "The resource has been permanently consumed. The single-use bootstrap \
338                 carve-out has likely already been used; ask an existing admin to \
339                 provision-integration a new operator instead.",
340            ),
341            Self::Conflict(_) => Some(
342                "The resource already exists. Use the corresponding `update` or \
343                 `delete-then-create` flow rather than `create`.",
344            ),
345            Self::Validation(_) => Some(
346                "The request body or parameters were rejected by the VTA's schema. \
347                 Inspect the response body for the specific field that failed.",
348            ),
349            Self::Server { .. } => {
350                Some("VTA-side failure. Check the VTA's server logs or contact the operator.")
351            }
352            Self::UnsupportedTransport(_) => Some(
353                "The operation requires a specific transport (REST or DIDComm). \
354                 Check which mode the client is in and whether the endpoint supports it.",
355            ),
356            Self::DidcommTransport(_) => {
357                Some("Mediator or peer unreachable. Retry after checking mediator connectivity.")
358            }
359            Self::TspTransport(_) => Some(
360                "The VTA's TSP mediator is unreachable or rejected the frame. Retry, or \
361                 reach the VTA over another transport: `--transport didcomm` / \
362                 `--transport rest`.",
363            ),
364            #[cfg(feature = "client")]
365            Self::Network(_) => Some(
366                "Network error reaching the VTA. Confirm the URL is correct and the \
367                 host is reachable.",
368            ),
369            // Runtime service-management variants (spec §4). The CLI
370            // layer enriches these with the specific kind/command
371            // it just ran; this is the generic fallback hint for
372            // non-CLI consumers.
373            Self::LastServiceRefused => Some(
374                "This operation would leave the VTA with no advertised transport \
375                 services. Enable the other transport first (REST or DIDComm) \
376                 before disabling this one.",
377            ),
378            Self::ServiceNotPresent => Some(
379                "The service kind isn't currently enabled. Use \
380                 `services <kind> enable …` to bring it online before \
381                 updating, disabling, or rolling it back.",
382            ),
383            Self::ServiceAlreadyEnabled => Some(
384                "The service kind is already enabled. Use \
385                 `services <kind> update …` to change its configuration, \
386                 or `disable` to remove it.",
387            ),
388            Self::MediatorHandshakeFailed { .. } => Some(
389                "DIDComm handshake against the candidate mediator failed. \
390                 Confirm the mediator DID is correct and the mediator is \
391                 reachable; check the inner reason for the specific cause.",
392            ),
393            Self::DrainTtlOutOfBounds { .. } => Some(
394                "The supplied drain TTL is outside the allowed range. Pick a \
395                 value within the [min, max] interval shown in the error message.",
396            ),
397            Self::NoPriorMutation => Some(
398                "No prior mutation for this service kind to roll back from. Use \
399                 the direct `enable`/`update`/`disable` command instead.",
400            ),
401            Self::NoMatchingProtocol { .. } => Some(
402                "The two parties share no transport protocol. Enable a common \
403                 transport (TSP, DIDComm, or REST) on both sides — compare each \
404                 DID document's advertised `service` entries and add the missing one.",
405            ),
406            // No generic hint for these — the message itself is the
407            // hint, or the failure is a protocol/programmer error
408            // surface that an automated suggestion would only confuse.
409            Self::NotFound(_)
410            | Self::DidcommRemote { .. }
411            | Self::Protocol(_)
412            | Self::Serialization(_)
413            | Self::Other(_) => None,
414        }
415    }
416}
417
418impl From<crate::did_key::DidKeyError> for VtaError {
419    fn from(e: crate::did_key::DidKeyError) -> Self {
420        Self::Validation(e.to_string())
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[cfg(feature = "client")]
429    #[test]
430    fn from_http_410_maps_to_gone() {
431        let err = VtaError::from_http(reqwest::StatusCode::GONE, "carve-out closed".into());
432        assert!(err.is_gone(), "410 must map to VtaError::Gone, got {err:?}");
433    }
434
435    #[test]
436    fn problem_report_conflict_maps_to_typed_conflict() {
437        let err = VtaError::from_problem_report(
438            crate::protocols::problem_report_codes::CONFLICT,
439            "key id already exists",
440        );
441        assert!(matches!(err, VtaError::Conflict(_)), "got {err:?}");
442        assert!(err.is_conflict());
443    }
444
445    #[test]
446    fn problem_report_unknown_code_lands_in_didcomm_remote() {
447        let err = VtaError::from_problem_report("e.custom.xyz", "weird thing");
448        match err {
449            VtaError::DidcommRemote { code, comment } => {
450                assert_eq!(code, "e.custom.xyz");
451                assert_eq!(comment, "weird thing");
452            }
453            other => panic!("expected DidcommRemote, got {other:?}"),
454        }
455    }
456
457    #[test]
458    fn suggested_fix_present_for_actionable_variants() {
459        // Each "operator can do something about this" variant must have
460        // a hint string; the message-is-the-hint / programmer-error
461        // variants return None.
462        assert!(VtaError::Auth("expired".into()).suggested_fix().is_some());
463        assert!(VtaError::Forbidden("nope".into()).suggested_fix().is_some());
464        assert!(VtaError::Gone("used".into()).suggested_fix().is_some());
465        assert!(VtaError::Conflict("dup".into()).suggested_fix().is_some());
466        assert!(VtaError::Validation("bad".into()).suggested_fix().is_some());
467        assert!(
468            VtaError::Server {
469                status: 500,
470                body: "boom".into(),
471            }
472            .suggested_fix()
473            .is_some()
474        );
475        assert!(
476            VtaError::UnsupportedTransport("rest only".into())
477                .suggested_fix()
478                .is_some()
479        );
480        assert!(
481            VtaError::DidcommTransport("offline".into())
482                .suggested_fix()
483                .is_some()
484        );
485
486        // Runtime service-management variants (spec §4) all have hints.
487        assert!(VtaError::LastServiceRefused.suggested_fix().is_some());
488        assert!(VtaError::ServiceNotPresent.suggested_fix().is_some());
489        assert!(VtaError::ServiceAlreadyEnabled.suggested_fix().is_some());
490        assert!(
491            VtaError::MediatorHandshakeFailed {
492                reason: "trust-ping timeout".into()
493            }
494            .suggested_fix()
495            .is_some()
496        );
497        assert!(
498            VtaError::DrainTtlOutOfBounds {
499                min: 3600,
500                max: 2_592_000,
501                requested: 30,
502            }
503            .suggested_fix()
504            .is_some()
505        );
506        assert!(VtaError::NoPriorMutation.suggested_fix().is_some());
507
508        // Self-explanatory / programmer-error: no canned hint.
509        assert!(VtaError::NotFound("x".into()).suggested_fix().is_none());
510        assert!(VtaError::Protocol("shape".into()).suggested_fix().is_none());
511        assert!(
512            VtaError::DidcommRemote {
513                code: "e.unknown".into(),
514                comment: "x".into()
515            }
516            .suggested_fix()
517            .is_none()
518        );
519    }
520
521    /// Every typed runtime service-management variant must round-trip
522    /// through [`TypedErrorPayload`] without losing structured data.
523    /// The test cases line up 1:1 with the spec §4 list.
524    #[test]
525    fn typed_payload_round_trips_every_runtime_service_variant() {
526        let cases: Vec<VtaError> = vec![
527            VtaError::LastServiceRefused,
528            VtaError::ServiceNotPresent,
529            VtaError::ServiceAlreadyEnabled,
530            VtaError::MediatorHandshakeFailed {
531                reason: "trust-ping timeout after 10s".into(),
532            },
533            VtaError::DrainTtlOutOfBounds {
534                min: 3600,
535                max: 2_592_000,
536                requested: 30,
537            },
538            VtaError::NoPriorMutation,
539            VtaError::UnsupportedTransport("services didcomm enable is REST-only".into()),
540        ];
541
542        for original in cases {
543            let payload = original.to_typed_payload().unwrap_or_else(|| {
544                panic!("variant must project to TypedErrorPayload: {original:?}")
545            });
546
547            // Round-trip through JSON to mirror what REST and DIDComm
548            // transports actually do on the wire.
549            let json = serde_json::to_string(&payload)
550                .unwrap_or_else(|e| panic!("payload must serialize: {e}"));
551            let restored: TypedErrorPayload = serde_json::from_str(&json)
552                .unwrap_or_else(|e| panic!("payload must deserialize: {e}; raw={json}"));
553
554            assert_eq!(
555                payload, restored,
556                "TypedErrorPayload must round-trip through JSON",
557            );
558
559            // Reconstructing back to VtaError preserves the variant
560            // discriminant and any structured data.
561            let reconstructed = VtaError::from_typed_payload(restored);
562            match (&original, &reconstructed) {
563                (VtaError::LastServiceRefused, VtaError::LastServiceRefused)
564                | (VtaError::ServiceNotPresent, VtaError::ServiceNotPresent)
565                | (VtaError::ServiceAlreadyEnabled, VtaError::ServiceAlreadyEnabled)
566                | (VtaError::NoPriorMutation, VtaError::NoPriorMutation) => {}
567                (
568                    VtaError::MediatorHandshakeFailed { reason: a },
569                    VtaError::MediatorHandshakeFailed { reason: b },
570                ) => assert_eq!(a, b),
571                (
572                    VtaError::DrainTtlOutOfBounds {
573                        min: m1,
574                        max: x1,
575                        requested: r1,
576                    },
577                    VtaError::DrainTtlOutOfBounds {
578                        min: m2,
579                        max: x2,
580                        requested: r2,
581                    },
582                ) => {
583                    assert_eq!(m1, m2);
584                    assert_eq!(x1, x2);
585                    assert_eq!(r1, r2);
586                }
587                (VtaError::UnsupportedTransport(a), VtaError::UnsupportedTransport(b)) => {
588                    assert_eq!(a, b)
589                }
590                (a, b) => panic!("variant changed across round-trip: {a:?} → {b:?}"),
591            }
592        }
593    }
594
595    /// The kebab-case `code` discriminator on the wire JSON is part of
596    /// the contract for both REST and DIDComm transports — pin it
597    /// explicitly so a `serde(rename)` change doesn't silently break
598    /// existing peers.
599    #[test]
600    fn typed_payload_wire_discriminator_is_kebab_case() {
601        let payload = TypedErrorPayload::DrainTtlOutOfBounds {
602            min: 3600,
603            max: 2_592_000,
604            requested: 30,
605        };
606        let json = serde_json::to_value(&payload).unwrap();
607        assert_eq!(json["code"], "drain-ttl-out-of-bounds");
608        assert_eq!(json["min"], 3600);
609        assert_eq!(json["max"], 2_592_000);
610        assert_eq!(json["requested"], 30);
611    }
612
613    /// `to_typed_payload` returns `None` for variants outside the
614    /// runtime service-management surface — the wire-format channel
615    /// is reserved for those typed variants and shouldn't blanket
616    /// every error.
617    #[test]
618    fn typed_payload_is_none_for_non_service_management_variants() {
619        assert!(VtaError::Auth("x".into()).to_typed_payload().is_none());
620        assert!(VtaError::NotFound("x".into()).to_typed_payload().is_none());
621        assert!(VtaError::Conflict("x".into()).to_typed_payload().is_none());
622        assert!(
623            VtaError::Server {
624                status: 500,
625                body: "x".into(),
626            }
627            .to_typed_payload()
628            .is_none()
629        );
630        assert!(VtaError::Protocol("x".into()).to_typed_payload().is_none());
631        assert!(
632            VtaError::DidcommRemote {
633                code: "e.x".into(),
634                comment: "x".into()
635            }
636            .to_typed_payload()
637            .is_none()
638        );
639        assert!(VtaError::Other("x".into()).to_typed_payload().is_none());
640    }
641}