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}