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    /// The VTA is temporarily unable to process this task — the standard
177    /// `unavailable` rejection (HTTP 503).
178    ///
179    /// Typed rather than folded into [`VtaError::Protocol`] because it is the
180    /// one wire rejection that means **"ask again"** rather than "this failed".
181    /// The idempotency layer returns it when a first attempt on the same key is
182    /// still running, so a retry loop that reads it as a terminal error gives up
183    /// on the one answer it was supposed to wait for.
184    ///
185    /// `retry_after` carries the server's hint verbatim when it supplied one. A
186    /// client should honour it and cap it — an unbounded wait on a
187    /// server-controlled value is a denial of service the server can trigger.
188    #[error("temporarily unavailable{}", match .retry_after {
189        Some(t) => format!(" (retry after {t})"),
190        None => String::new(),
191    })]
192    Unavailable {
193        retry_after: Option<chrono::DateTime<chrono::Utc>>,
194    },
195
196    #[error("{0}")]
197    Other(String),
198}
199
200/// Wire-format companion to the typed [`VtaError`] variants emitted
201/// by the runtime service-management surface.
202///
203/// The free-form `comment` string carried by DIDComm problem-reports
204/// (and the `body` string of REST error responses) is fine for the
205/// variants whose only data is a human-readable message
206/// ([`VtaError::Conflict`], [`VtaError::NotFound`], …) but lossy for
207/// variants like [`VtaError::DrainTtlOutOfBounds`] that carry three
208/// numeric fields the CLI needs to switch on.
209///
210/// Servers serialize a `TypedErrorPayload` into the response body
211/// (REST) or problem-report `args` (DIDComm); clients deserialize
212/// it back via [`VtaError::from_typed_payload`]. The discriminator
213/// is the kebab-cased variant name in the `code` field.
214///
215/// Variants line up 1:1 with the §4 spec list — the existing
216/// [`VtaError::UnsupportedTransport`] is included so the same
217/// channel carries every typed-error wire form.
218#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
219#[serde(tag = "code", rename_all = "kebab-case")]
220pub enum TypedErrorPayload {
221    LastServiceRefused,
222    ServiceNotPresent,
223    ServiceAlreadyEnabled,
224    MediatorHandshakeFailed { reason: String },
225    DrainTtlOutOfBounds { min: u64, max: u64, requested: u64 },
226    NoPriorMutation,
227    UnsupportedTransport { detail: String },
228}
229
230impl VtaError {
231    /// Create from an HTTP response status and error body.
232    ///
233    /// Public so a downstream SDK consumer wiring its own HTTP transport
234    /// (e.g. a wasm `gloo-net` client) can produce typed `VtaError`s
235    /// from status codes without re-implementing the mapping.
236    #[cfg(feature = "client")]
237    pub fn from_http(status: reqwest::StatusCode, body: String) -> Self {
238        match status.as_u16() {
239            401 => Self::Auth(body),
240            403 => Self::Forbidden(body),
241            404 => Self::NotFound(body),
242            400 | 422 => Self::Validation(body),
243            409 => Self::Conflict(body),
244            410 => Self::Gone(body),
245            s if s >= 500 => Self::Server { status: s, body },
246            s => Self::Other(format!("{s}: {body}")),
247        }
248    }
249
250    /// Create from a DIDComm problem-report `code` + `comment`. Mirrors
251    /// the REST [`Self::from_http`] mapping so callers can `match` on the
252    /// same variants regardless of transport.
253    ///
254    /// Standard codes (`e.p.msg.unauthorized` / `bad-request` / `not-found`
255    /// / `conflict` / `internal-error`) become typed variants. Anything
256    /// else lands in [`Self::DidcommRemote`] preserving the original code.
257    pub fn from_problem_report(code: &str, comment: impl Into<String>) -> Self {
258        use crate::protocols::problem_report_codes as c;
259        let comment = comment.into();
260        match code {
261            c::CONFLICT => Self::Conflict(comment),
262            c::NOT_FOUND => Self::NotFound(comment),
263            c::UNAUTHORIZED => Self::Auth(comment),
264            c::FORBIDDEN => Self::Forbidden(comment),
265            c::BAD_REQUEST => Self::Validation(comment),
266            c::INTERNAL => Self::Server {
267                status: 500,
268                body: comment,
269            },
270            other => Self::DidcommRemote {
271                code: other.to_string(),
272                comment,
273            },
274        }
275    }
276
277    /// Reconstruct the typed [`VtaError`] variant from a wire-format
278    /// [`TypedErrorPayload`]. Used by the client when decoding REST
279    /// response bodies / DIDComm problem-report args for the runtime
280    /// service-management surface (spec §4).
281    pub fn from_typed_payload(payload: TypedErrorPayload) -> Self {
282        match payload {
283            TypedErrorPayload::LastServiceRefused => Self::LastServiceRefused,
284            TypedErrorPayload::ServiceNotPresent => Self::ServiceNotPresent,
285            TypedErrorPayload::ServiceAlreadyEnabled => Self::ServiceAlreadyEnabled,
286            TypedErrorPayload::MediatorHandshakeFailed { reason } => {
287                Self::MediatorHandshakeFailed { reason }
288            }
289            TypedErrorPayload::DrainTtlOutOfBounds {
290                min,
291                max,
292                requested,
293            } => Self::DrainTtlOutOfBounds {
294                min,
295                max,
296                requested,
297            },
298            TypedErrorPayload::NoPriorMutation => Self::NoPriorMutation,
299            TypedErrorPayload::UnsupportedTransport { detail } => {
300                Self::UnsupportedTransport(detail)
301            }
302        }
303    }
304
305    /// Project this error onto the wire-format [`TypedErrorPayload`]
306    /// when the variant is one of the runtime service-management
307    /// errors. Returns `None` for variants that don't have a
308    /// structured wire form (network errors, generic conflicts,
309    /// programmer-level protocol errors, …).
310    #[must_use]
311    pub fn to_typed_payload(&self) -> Option<TypedErrorPayload> {
312        match self {
313            Self::LastServiceRefused => Some(TypedErrorPayload::LastServiceRefused),
314            Self::ServiceNotPresent => Some(TypedErrorPayload::ServiceNotPresent),
315            Self::ServiceAlreadyEnabled => Some(TypedErrorPayload::ServiceAlreadyEnabled),
316            Self::MediatorHandshakeFailed { reason } => {
317                Some(TypedErrorPayload::MediatorHandshakeFailed {
318                    reason: reason.clone(),
319                })
320            }
321            Self::DrainTtlOutOfBounds {
322                min,
323                max,
324                requested,
325            } => Some(TypedErrorPayload::DrainTtlOutOfBounds {
326                min: *min,
327                max: *max,
328                requested: *requested,
329            }),
330            Self::NoPriorMutation => Some(TypedErrorPayload::NoPriorMutation),
331            Self::UnsupportedTransport(detail) => Some(TypedErrorPayload::UnsupportedTransport {
332                detail: detail.clone(),
333            }),
334            _ => None,
335        }
336    }
337
338    /// Returns true if the resource was permanently consumed/gone (410).
339    pub fn is_gone(&self) -> bool {
340        matches!(self, Self::Gone(_))
341    }
342
343    /// Returns true if a create/insert collided with an existing entry (409).
344    pub fn is_conflict(&self) -> bool {
345        matches!(self, Self::Conflict(_))
346    }
347
348    /// Returns true if this is an authentication/authorization error.
349    pub fn is_auth(&self) -> bool {
350        matches!(self, Self::Auth(_) | Self::Forbidden(_))
351    }
352
353    /// Returns true if this is a network-level error (retryable).
354    pub fn is_network(&self) -> bool {
355        #[cfg(feature = "client")]
356        if matches!(self, Self::Network(_)) {
357            return true;
358        }
359        false
360    }
361
362    /// Returns true if the resource was not found.
363    pub fn is_not_found(&self) -> bool {
364        matches!(self, Self::NotFound(_))
365    }
366
367    /// Operator-actionable hint matching this error variant.
368    ///
369    /// `None` for variants where no generic guidance applies (the message
370    /// itself is the hint, or the failure is a programmer error). The
371    /// CLI layer (`vta-cli-common::render::print_cli_error`) already
372    /// implements bin-aware suggestions ("`pnm acl create …`"); this
373    /// method gives **non-CLI consumers** — web UIs, GUIs, custom
374    /// dashboards — the same hint surface without needing to fork the
375    /// dispatch logic.
376    ///
377    /// Returns a `&'static str` so callers can compose it into their
378    /// own UI without lifetime juggling. The bin-specific substitution
379    /// (`pnm` vs `cnm`) is left to the CLI layer because only it
380    /// knows which binary the operator is running.
381    #[must_use]
382    pub fn suggested_fix(&self) -> Option<&'static str> {
383        match self {
384            Self::Auth(_) => Some(
385                "Token may be expired. Re-authenticate against the VTA, or check that \
386                 the `/auth` endpoint is reachable.",
387            ),
388            Self::Forbidden(_) => Some(
389                "Your role or context access doesn't permit this operation. Inspect \
390                 the ACL entry for your DID against the target context.",
391            ),
392            Self::Gone(_) => Some(
393                "The resource was single-use or time-limited and has been consumed or has \
394                 expired — retrying will not succeed. If this was the bootstrap carve-out, \
395                 ask an existing admin to provision-integration a new operator instead.",
396            ),
397            Self::Conflict(_) => Some(
398                "The resource already exists. Use the corresponding `update` or \
399                 `delete-then-create` flow rather than `create`.",
400            ),
401            Self::Unavailable { .. } => Some(
402                "The VTA is temporarily busy — this is a wait, not a failure. If the \
403                 request carried an idempotency key, an earlier attempt on that key is \
404                 still running: retry with the same key and the original result will be \
405                 returned rather than the operation repeated.",
406            ),
407            Self::Validation(_) => Some(
408                "The request body or parameters were rejected by the VTA's schema. \
409                 Inspect the response body for the specific field that failed.",
410            ),
411            Self::Server { .. } => {
412                Some("VTA-side failure. Check the VTA's server logs or contact the operator.")
413            }
414            Self::UnsupportedTransport(_) => Some(
415                "The operation requires a specific transport (REST or DIDComm). \
416                 Check which mode the client is in and whether the endpoint supports it.",
417            ),
418            Self::DidcommTransport(_) => {
419                Some("Mediator or peer unreachable. Retry after checking mediator connectivity.")
420            }
421            Self::TspTransport(_) => Some(
422                "The VTA's TSP mediator is unreachable or rejected the frame. Retry, or \
423                 reach the VTA over another transport: `--transport didcomm` / \
424                 `--transport rest`.",
425            ),
426            #[cfg(feature = "client")]
427            Self::Network(_) => Some(
428                "Network error reaching the VTA. Confirm the URL is correct and the \
429                 host is reachable.",
430            ),
431            // Runtime service-management variants (spec §4). The CLI
432            // layer enriches these with the specific kind/command
433            // it just ran; this is the generic fallback hint for
434            // non-CLI consumers.
435            Self::LastServiceRefused => Some(
436                "This operation would leave the VTA with no advertised transport \
437                 services. Enable the other transport first (REST or DIDComm) \
438                 before disabling this one.",
439            ),
440            Self::ServiceNotPresent => Some(
441                "The service kind isn't currently enabled. Use \
442                 `services <kind> enable …` to bring it online before \
443                 updating, disabling, or rolling it back.",
444            ),
445            Self::ServiceAlreadyEnabled => Some(
446                "The service kind is already enabled. Use \
447                 `services <kind> update …` to change its configuration, \
448                 or `disable` to remove it.",
449            ),
450            Self::MediatorHandshakeFailed { .. } => Some(
451                "DIDComm handshake against the candidate mediator failed. \
452                 Confirm the mediator DID is correct and the mediator is \
453                 reachable; check the inner reason for the specific cause.",
454            ),
455            Self::DrainTtlOutOfBounds { .. } => Some(
456                "The supplied drain TTL is outside the allowed range. Pick a \
457                 value within the [min, max] interval shown in the error message.",
458            ),
459            Self::NoPriorMutation => Some(
460                "No prior mutation for this service kind to roll back from. Use \
461                 the direct `enable`/`update`/`disable` command instead.",
462            ),
463            Self::NoMatchingProtocol { .. } => Some(
464                "The two parties share no transport protocol. Enable a common \
465                 transport (TSP, DIDComm, or REST) on both sides — compare each \
466                 DID document's advertised `service` entries and add the missing one.",
467            ),
468            // No generic hint for these — the message itself is the
469            // hint, or the failure is a protocol/programmer error
470            // surface that an automated suggestion would only confuse.
471            // The hint depends on policy the message already reports — whether
472            // another device must approve, or this one may. A static string
473            // would have to guess, and guessing wrong sends the operator to the
474            // wrong screen. The CLI's consent loop says it precisely instead.
475            Self::ConsentRequired { .. } => None,
476            Self::NotFound(_)
477            | Self::DidcommRemote { .. }
478            | Self::Protocol(_)
479            | Self::Serialization(_)
480            | Self::Other(_) => None,
481        }
482    }
483}
484
485impl From<crate::did_key::DidKeyError> for VtaError {
486    fn from(e: crate::did_key::DidKeyError) -> Self {
487        Self::Validation(e.to_string())
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    #[cfg(feature = "client")]
496    #[test]
497    fn from_http_410_maps_to_gone() {
498        let err = VtaError::from_http(reqwest::StatusCode::GONE, "carve-out closed".into());
499        assert!(err.is_gone(), "410 must map to VtaError::Gone, got {err:?}");
500    }
501
502    #[test]
503    fn problem_report_conflict_maps_to_typed_conflict() {
504        let err = VtaError::from_problem_report(
505            crate::protocols::problem_report_codes::CONFLICT,
506            "key id already exists",
507        );
508        assert!(matches!(err, VtaError::Conflict(_)), "got {err:?}");
509        assert!(err.is_conflict());
510    }
511
512    #[test]
513    fn problem_report_unknown_code_lands_in_didcomm_remote() {
514        let err = VtaError::from_problem_report("e.custom.xyz", "weird thing");
515        match err {
516            VtaError::DidcommRemote { code, comment } => {
517                assert_eq!(code, "e.custom.xyz");
518                assert_eq!(comment, "weird thing");
519            }
520            other => panic!("expected DidcommRemote, got {other:?}"),
521        }
522    }
523
524    #[test]
525    fn suggested_fix_present_for_actionable_variants() {
526        // Each "operator can do something about this" variant must have
527        // a hint string; the message-is-the-hint / programmer-error
528        // variants return None.
529        assert!(VtaError::Auth("expired".into()).suggested_fix().is_some());
530        assert!(VtaError::Forbidden("nope".into()).suggested_fix().is_some());
531        assert!(VtaError::Gone("used".into()).suggested_fix().is_some());
532        assert!(VtaError::Conflict("dup".into()).suggested_fix().is_some());
533        assert!(VtaError::Validation("bad".into()).suggested_fix().is_some());
534        assert!(
535            VtaError::Server {
536                status: 500,
537                body: "boom".into(),
538            }
539            .suggested_fix()
540            .is_some()
541        );
542        assert!(
543            VtaError::UnsupportedTransport("rest only".into())
544                .suggested_fix()
545                .is_some()
546        );
547        assert!(
548            VtaError::DidcommTransport("offline".into())
549                .suggested_fix()
550                .is_some()
551        );
552
553        // Runtime service-management variants (spec §4) all have hints.
554        assert!(VtaError::LastServiceRefused.suggested_fix().is_some());
555        assert!(VtaError::ServiceNotPresent.suggested_fix().is_some());
556        assert!(VtaError::ServiceAlreadyEnabled.suggested_fix().is_some());
557        assert!(
558            VtaError::MediatorHandshakeFailed {
559                reason: "trust-ping timeout".into()
560            }
561            .suggested_fix()
562            .is_some()
563        );
564        assert!(
565            VtaError::DrainTtlOutOfBounds {
566                min: 3600,
567                max: 2_592_000,
568                requested: 30,
569            }
570            .suggested_fix()
571            .is_some()
572        );
573        assert!(VtaError::NoPriorMutation.suggested_fix().is_some());
574
575        // Self-explanatory / programmer-error: no canned hint.
576        assert!(VtaError::NotFound("x".into()).suggested_fix().is_none());
577        assert!(VtaError::Protocol("shape".into()).suggested_fix().is_none());
578        assert!(
579            VtaError::DidcommRemote {
580                code: "e.unknown".into(),
581                comment: "x".into()
582            }
583            .suggested_fix()
584            .is_none()
585        );
586    }
587
588    /// Every typed runtime service-management variant must round-trip
589    /// through [`TypedErrorPayload`] without losing structured data.
590    /// The test cases line up 1:1 with the spec §4 list.
591    #[test]
592    fn typed_payload_round_trips_every_runtime_service_variant() {
593        let cases: Vec<VtaError> = vec![
594            VtaError::LastServiceRefused,
595            VtaError::ServiceNotPresent,
596            VtaError::ServiceAlreadyEnabled,
597            VtaError::MediatorHandshakeFailed {
598                reason: "trust-ping timeout after 10s".into(),
599            },
600            VtaError::DrainTtlOutOfBounds {
601                min: 3600,
602                max: 2_592_000,
603                requested: 30,
604            },
605            VtaError::NoPriorMutation,
606            VtaError::UnsupportedTransport("services didcomm enable is REST-only".into()),
607        ];
608
609        for original in cases {
610            let payload = original.to_typed_payload().unwrap_or_else(|| {
611                panic!("variant must project to TypedErrorPayload: {original:?}")
612            });
613
614            // Round-trip through JSON to mirror what REST and DIDComm
615            // transports actually do on the wire.
616            let json = serde_json::to_string(&payload)
617                .unwrap_or_else(|e| panic!("payload must serialize: {e}"));
618            let restored: TypedErrorPayload = serde_json::from_str(&json)
619                .unwrap_or_else(|e| panic!("payload must deserialize: {e}; raw={json}"));
620
621            assert_eq!(
622                payload, restored,
623                "TypedErrorPayload must round-trip through JSON",
624            );
625
626            // Reconstructing back to VtaError preserves the variant
627            // discriminant and any structured data.
628            let reconstructed = VtaError::from_typed_payload(restored);
629            match (&original, &reconstructed) {
630                (VtaError::LastServiceRefused, VtaError::LastServiceRefused)
631                | (VtaError::ServiceNotPresent, VtaError::ServiceNotPresent)
632                | (VtaError::ServiceAlreadyEnabled, VtaError::ServiceAlreadyEnabled)
633                | (VtaError::NoPriorMutation, VtaError::NoPriorMutation) => {}
634                (
635                    VtaError::MediatorHandshakeFailed { reason: a },
636                    VtaError::MediatorHandshakeFailed { reason: b },
637                ) => assert_eq!(a, b),
638                (
639                    VtaError::DrainTtlOutOfBounds {
640                        min: m1,
641                        max: x1,
642                        requested: r1,
643                    },
644                    VtaError::DrainTtlOutOfBounds {
645                        min: m2,
646                        max: x2,
647                        requested: r2,
648                    },
649                ) => {
650                    assert_eq!(m1, m2);
651                    assert_eq!(x1, x2);
652                    assert_eq!(r1, r2);
653                }
654                (VtaError::UnsupportedTransport(a), VtaError::UnsupportedTransport(b)) => {
655                    assert_eq!(a, b)
656                }
657                (a, b) => panic!("variant changed across round-trip: {a:?} → {b:?}"),
658            }
659        }
660    }
661
662    /// The kebab-case `code` discriminator on the wire JSON is part of
663    /// the contract for both REST and DIDComm transports — pin it
664    /// explicitly so a `serde(rename)` change doesn't silently break
665    /// existing peers.
666    #[test]
667    fn typed_payload_wire_discriminator_is_kebab_case() {
668        let payload = TypedErrorPayload::DrainTtlOutOfBounds {
669            min: 3600,
670            max: 2_592_000,
671            requested: 30,
672        };
673        let json = serde_json::to_value(&payload).unwrap();
674        assert_eq!(json["code"], "drain-ttl-out-of-bounds");
675        assert_eq!(json["min"], 3600);
676        assert_eq!(json["max"], 2_592_000);
677        assert_eq!(json["requested"], 30);
678    }
679
680    /// `to_typed_payload` returns `None` for variants outside the
681    /// runtime service-management surface — the wire-format channel
682    /// is reserved for those typed variants and shouldn't blanket
683    /// every error.
684    #[test]
685    fn typed_payload_is_none_for_non_service_management_variants() {
686        assert!(VtaError::Auth("x".into()).to_typed_payload().is_none());
687        assert!(VtaError::NotFound("x".into()).to_typed_payload().is_none());
688        assert!(VtaError::Conflict("x".into()).to_typed_payload().is_none());
689        assert!(
690            VtaError::Server {
691                status: 500,
692                body: "x".into(),
693            }
694            .to_typed_payload()
695            .is_none()
696        );
697        assert!(VtaError::Protocol("x".into()).to_typed_payload().is_none());
698        assert!(
699            VtaError::DidcommRemote {
700                code: "e.x".into(),
701                comment: "x".into()
702            }
703            .to_typed_payload()
704            .is_none()
705        );
706        assert!(VtaError::Other("x".into()).to_typed_payload().is_none());
707    }
708}