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