Skip to main content

acdp_primitives/
error.rs

1//! Error types for the ACDP library.
2//!
3//! Error variants align with the wire vocabulary defined by
4//! `acdp-error.schema.json` and RFC-ACDP-0007 §5. The
5//! [`AcdpError::from_wire_error`] helper converts a
6//! [`crate::wire_error::WireError`] (HTTP response body shape) into a typed
7//! variant.
8
9use crate::primitives::ContentHash;
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13/// Top-level error type.
14///
15/// `#[non_exhaustive]`: the wire vocabulary (RFC-ACDP-0007 §5) keeps growing
16/// as new RFCs land — 25 wire codes now, up from 21 not long ago, with
17/// RFC-ACDP-0009 reserved and still unimplemented. Without this attribute,
18/// every new variant is a semver-breaking change for any downstream crate
19/// that matches on `AcdpError` exhaustively. Same rationale as
20/// `SsrfReason` in `crates/acdp-safe-http/src/lib.rs` ("future spec
21/// revisions may add ranges"); match with a wildcard arm.
22#[derive(Debug, Error)]
23#[non_exhaustive]
24pub enum AcdpError {
25    // ── Cryptography ─────────────────────────────────────────────────────────
26    /// JCS canonicalization failed (input not serializable).
27    #[error("JCS canonicalization failed: {0}")]
28    Canonicalization(String),
29
30    /// Stored `content_hash` did not match the recomputed value
31    /// (locally detected during signature verification).
32    #[error("content_hash mismatch\n  stored:     {stored}\n  recomputed: {recomputed}")]
33    HashMismatch {
34        /// The hash claimed by the body or request.
35        stored: ContentHash,
36        /// The hash recomputed by the verifier.
37        recomputed: ContentHash,
38    },
39
40    /// Locally detected: the registry returned a body whose `ctx_id` is not
41    /// the one that was requested (context substitution). Not a wire code —
42    /// the client detects it. Permanent; fail closed.
43    ///
44    /// Implements RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind the resolved
45    /// identity"): step 7 requires exactly this comparison — `body.ctx_id`
46    /// against the `ctx_id` used to construct the request — and permits a
47    /// consumer to surface "an equivalent typed error" in place of the
48    /// registry-side `cross_registry_resolution_failed` wire code; this
49    /// variant is that typed error. See RFC-ACDP-0008 §9.1 for the threat
50    /// this closes: without it, a registry can serve any other
51    /// validly-signed body by the same producer under the requested
52    /// context's URL, and both signature verification and `content_hash`
53    /// recomputation still pass. It does **not** close §9.1 in full: a
54    /// registry that genuinely republishes the same content under a new
55    /// `ctx_id` still passes; only serve-time substitution — a different
56    /// id claimed to be the one requested — is caught.
57    #[error("context substitution: requested {requested}, registry served {served}")]
58    ContextIdMismatch {
59        /// The `ctx_id` the caller requested.
60        requested: String,
61        /// The `ctx_id` actually present on the body the registry served.
62        served: String,
63    },
64
65    /// Locally detected: `GET /lineages/{id}` (RFC-ACDP-0013 §8.1) did not
66    /// serve a lineage the caller could accept — either it came back with
67    /// no members at all, or (when `ctx_id` is `Some`) its members did not
68    /// include the `ctx_id` a live search match named for that lineage.
69    /// Not a wire code — RFC-ACDP-0014 §10 ("No new wire error code"):
70    /// this is a locally-detected consumer-side binding failure, exactly
71    /// the same kind of thing as [`AcdpError::ContextIdMismatch`] (issue
72    /// #189) but for the lineage-walk path added for issue #226. Permanent;
73    /// fail closed — never added to [`AcdpError::is_transient`].
74    #[error(
75        "incomplete lineage {lineage_id}: registry-served members did not satisfy the \
76         expected membership (expected ctx_id: {ctx_id:?})"
77    )]
78    IncompleteLineage {
79        /// The lineage id that was walked (`GET /lineages/{id}`).
80        lineage_id: String,
81        /// The `ctx_id` a live search match named for this lineage, when
82        /// the walk was invoked with a specific member to check for.
83        /// `None` when the walk was invoked directly with no particular
84        /// member to verify (e.g. via `find_revocations_in_lineage`),
85        /// in which case only the empty-lineage case can produce this
86        /// variant.
87        ctx_id: Option<String>,
88    },
89
90    /// Locally detected: a revocation-discovery search helper
91    /// (`find_revocations`, `find_registry_attested_revocations` in
92    /// `acdp-client`) exhausted its pagination safety cap
93    /// (`MAX_SEARCH_PAGES`) with a search cursor still remaining, or
94    /// named more candidate revocation-lineage ids than its lineage-walk
95    /// safety cap (`MAX_LINEAGE_WALKS`) allows to fetch. Not a wire code
96    /// — RFC-ACDP-0014 §10 ("No new wire error code"): this is a
97    /// client-local safety-limit signal the registry cannot express, the
98    /// same rationale as [`AcdpError::ContextIdMismatch`] and
99    /// [`AcdpError::IncompleteLineage`]. Permanent for the same request
100    /// shape — never added to [`AcdpError::is_transient`]; retrying an
101    /// identical query reproduces the same truncation.
102    ///
103    /// Both caps exist as hostile-registry DoS protection
104    /// (`MAX_SEARCH_PAGES` bounds search round-trips; `MAX_LINEAGE_WALKS`
105    /// bounds the `GET /lineages/{id}` fetches a search result can
106    /// trigger, each capped at 1 MB). Search results are ordered
107    /// `created_at DESC`, so hitting a cap sheds the OLDEST — most
108    /// likely earliest-`compromised_since` — members first: a silently
109    /// truncated revocation set narrows a compromise window, which is
110    /// precisely the outcome RFC-ACDP-0014 §4 ("earliest T across a
111    /// revocation lineage is effective") forbids. A knowingly-partial
112    /// answer from these discovery helpers is strictly worse than a
113    /// loud refusal, so exhausting either cap with more results
114    /// remaining on the registry is a hard error, never a silently
115    /// partial `Vec`.
116    #[error("search truncated: {0}")]
117    SearchTruncated(String),
118
119    /// Wire code: `hash_mismatch`. The remote registry rejected a
120    /// publish request because its independent hash recomputation did
121    /// not match the producer-supplied `content_hash`. Distinct from
122    /// the local [`AcdpError::HashMismatch`] variant: this one carries
123    /// the registry's message verbatim and indicates a *producer-side*
124    /// bug (most often canonicalization divergence — see RFC-ACDP-0001
125    /// §5.7 and the `can-001` conformance fixture).
126    #[error("registry rejected hash_mismatch: {0}")]
127    RemoteHashMismatch(String),
128
129    /// Wire code: `data_ref_hash_mismatch`. A DataRef's fetched or decoded
130    /// bytes do not match the producer-declared `data_ref.content_hash`.
131    /// The body itself remains cryptographically valid — only the
132    /// referenced data has diverged. Distinct from
133    /// [`AcdpError::RemoteHashMismatch`] / [`AcdpError::HashMismatch`]
134    /// (body-level ProducerContent failure — the whole body is untrusted)
135    /// and [`AcdpError::InvalidSignature`] (a key / key-binding problem).
136    /// RFC-ACDP-0002 §6.5–6.6, RFC-ACDP-0007 §5.
137    #[error("data_ref hash mismatch: {0}")]
138    DataRefHashMismatch(String),
139
140    /// Signature verification failed or signature was malformed.
141    /// Wire code: `invalid_signature`.
142    #[error("invalid signature: {0}")]
143    InvalidSignature(String),
144
145    // ── DID / key resolution ─────────────────────────────────────────────
146    /// Wire code: `key_resolution_failed` (HTTP 400).
147    #[error("key resolution failed: {0}")]
148    KeyResolution(String),
149
150    /// Wire code: `key_resolution_unreachable` (HTTP 502) — transient, may retry.
151    #[error("key resolution unreachable (transient): {0}")]
152    KeyResolutionUnreachable(String),
153
154    /// Wire code: `key_not_authorized` (HTTP 403).
155    #[error("key not authorized: {0}")]
156    KeyNotAuthorized(String),
157
158    // ── Input validation ─────────────────────────────────────────────────
159    /// Producer body could not be parsed.
160    #[error("invalid body: {0}")]
161    InvalidBody(String),
162
163    /// A required field was missing.
164    #[error("missing required field: {0}")]
165    MissingField(&'static str),
166
167    /// Schema validation failed (string length, array uniqueness, oneOf, etc).
168    /// Wire code: `schema_violation`.
169    #[error("schema violation: {0}")]
170    SchemaViolation(String),
171
172    /// Wire code: `payload_too_large` — request body exceeds the registry limit.
173    #[error("payload too large: {0}")]
174    PayloadTooLarge(String),
175
176    /// Wire code: `embedded_too_large` — a single `DataRef.embedded.content`
177    /// exceeds the 64 KB cap.
178    #[error("embedded data reference too large: {0}")]
179    EmbeddedTooLarge(String),
180
181    /// Wire code: `unsupported_algorithm` — the producer used a signature
182    /// algorithm the registry does not accept.
183    #[error("unsupported algorithm: {0}")]
184    UnsupportedAlgorithm(String),
185
186    /// Wire code: `not_implemented` — endpoint or feature not supported by
187    /// this registry.
188    #[error("not implemented: {0}")]
189    NotImplemented(String),
190
191    // ── Retrieval / authorization ────────────────────────────────────────
192    /// Wire code: `not_found`.
193    #[error("not found: {0}")]
194    NotFound(String),
195
196    /// Wire code: `not_authorized` — the caller is not permitted to access
197    /// this resource.
198    #[error("not authorized: {0}")]
199    NotAuthorized(String),
200
201    /// Wire code: `rate_limited`.
202    #[error("rate limited: {0}")]
203    RateLimited(String),
204
205    // ── Pagination ───────────────────────────────────────────────────────
206    /// Wire code: `cursor_expired`.
207    #[error("search cursor expired")]
208    CursorExpired,
209
210    /// Wire code: `invalid_cursor`.
211    #[error("invalid cursor: {0}")]
212    InvalidCursor(String),
213
214    // ── Publication ──────────────────────────────────────────────────────
215    /// Wire code: `superseded_target`. The supersession target was rejected;
216    /// the [`SupersessionReason`] disambiguates the cause.
217    #[error("superseded target rejected ({reason:?}): {message}")]
218    SupersededTarget {
219        /// Why the target was rejected.
220        reason: SupersessionReason,
221        /// Human-readable message from the registry.
222        message: String,
223    },
224
225    /// Wire code: `duplicate_publish` — an Idempotency-Key replay produced
226    /// a different request body than the original.
227    #[error("duplicate publish: {0}")]
228    DuplicatePublish(String),
229
230    // ── Cross-registry ───────────────────────────────────────────────────
231    /// Wire code: `cross_registry_resolution_failed`.
232    #[error("cross-registry resolution failed: {0}")]
233    CrossRegistryResolutionFailed(String),
234
235    // ── Registry receipts (ACDP 0.2, RFC-ACDP-0010) ─────────────────────
236    /// Wire code: `invalid_receipt`. A `registry_receipt` failed
237    /// verification: bad signature, a cross-check mismatch (`ctx_id`,
238    /// `content_hash`, `key_fingerprint`, serving authority), a
239    /// malformed shape, or a receipt required by policy but absent.
240    /// Permanent — the receipt will not verify on retry.
241    #[error("invalid registry receipt: {0}")]
242    InvalidReceipt(String),
243
244    /// Wire code: `invalid_log_proof` (RFC-ACDP-0012 §9, §11 — 0.3.0).
245    /// A transparency-log artifact failed verification: an inclusion
246    /// proof that does not fold to the checkpoint's root, a failed
247    /// consistency proof between tree sizes, or a checkpoint whose
248    /// signature does not verify. Permanent — a bad proof will not
249    /// verify on retry (HTTP 502 on the wire: the upstream log is at
250    /// fault when a federated resolver emits it).
251    #[error("invalid transparency-log proof: {0}")]
252    InvalidLogProof(String),
253
254    /// Wire code: `invalid_witness_cosignature` (RFC-ACDP-0015 §8,
255    /// §10 — 0.4.0). A transparency-log **witness cosignature** failed
256    /// the §8 verification procedure: closed parse, the witness-key
257    /// signature, witness binding (`signature.key_id` DID ≠
258    /// `witness_id`), checkpoint binding (`witnessed_checkpoint` ≠ the
259    /// checkpoint being evaluated), or the `witnessed_at` skew check.
260    /// Deliberately **distinct** from [`AcdpError::InvalidLogProof`]:
261    /// that indicts the *log* (tree membership, history consistency,
262    /// the registry's checkpoint signature); this indicts a *witness's*
263    /// attestation — an independent verdict over an independent signer
264    /// (RFC-ACDP-0015 §10). Permanent — a bad cosignature will not
265    /// verify on retry (HTTP 502 on the wire: the cosignature came from
266    /// an upstream party — a registry aggregating on a caller's behalf
267    /// or a resolver validating a witness's cosignatures). A cosignature
268    /// that verifies but is merely *stale* is consumer freshness policy
269    /// (§8.1), never this code.
270    #[error("invalid witness cosignature: {0}")]
271    InvalidWitnessCosignature(String),
272
273    /// Wire code: `immutable_field` (RFC-ACDP-0013 §6, §10 — 0.3.0;
274    /// activated from the v0.1.0 reservation). A lifecycle (or future
275    /// mutation) endpoint request attempted to supply or alter
276    /// immutable body content. Bodies are immutable; lifecycle
277    /// endpoints mutate registry state only. Permanent (HTTP 400).
278    #[error("immutable field: {0}")]
279    ImmutableField(String),
280
281    /// Wire code: `invalid_lifecycle_transition` (RFC-ACDP-0013 §6
282    /// step 4, §10 — 0.3.0). The requested lifecycle transition
283    /// conflicts with the context's current retraction state (retract
284    /// of an already-retracted context; republish of a never-retracted
285    /// one). A state conflict like the 409 arm of `superseded_target`;
286    /// retryable only after the state changes (HTTP 409).
287    #[error("invalid lifecycle transition: {0}")]
288    InvalidLifecycleTransition(String),
289
290    // ── Wire / transport ─────────────────────────────────────────────────
291    /// Wire code: `internal_error`.
292    #[error("registry internal error: {0}")]
293    RegistryInternal(String),
294
295    /// Catch-all for `WireError` codes that have no typed variant in this
296    /// version of the library. Forward-compatible: registries may emit
297    /// reserved codes (`unsupported_embedding_model`)
298    /// that future ACDP versions add.
299    #[error("registry returned error: {0:?}")]
300    Registry(crate::wire_error::WireError),
301
302    /// JSON (de)serialization failed.
303    #[error("serialization failed: {0}")]
304    Serialization(String),
305
306    /// HTTP transport error.
307    #[error("HTTP error: {0}")]
308    Http(String),
309}
310
311/// Sub-reason for [`AcdpError::SupersededTarget`]. Mirrors the
312/// `details.reason` values defined by `acdp-error.schema.json`.
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314#[serde(rename_all = "snake_case")]
315pub enum SupersessionReason {
316    /// The supersedes target context does not exist on this registry.
317    NotFound,
318    /// The target's lineage_id differs from the new publication's lineage.
319    LineageMismatch,
320    /// The new version is not exactly `previous.version + 1`.
321    VersionMismatch,
322    /// The target has already been superseded by a different version.
323    AlreadySuperseded,
324    /// The target lives on a different registry; v0.1.0 only allows
325    /// same-registry supersession.
326    CrossRegistrySupersessionUnsupported,
327    /// The lineage walk through `supersedes` failed because an
328    /// intermediate context could not be retrieved (RFC-ACDP-0001 §5.6.1).
329    LineageWalkFailed,
330    /// A reason this version of the library does not recognize.
331    #[serde(other)]
332    Other,
333}
334
335impl AcdpError {
336    /// Whether this error is plausibly transient and worth retrying
337    /// with the same request body (and, if applicable, the same
338    /// `Idempotency-Key`).
339    ///
340    /// Returned by [`AcdpError::is_transient`] only for variants whose
341    /// wire codes the spec marks retryable: `key_resolution_unreachable`
342    /// (RFC-ACDP-0001 §5.11), `rate_limited` (RFC-ACDP-0008 §4.3),
343    /// `cross_registry_resolution_failed` (RFC-ACDP-0006 §7), and
344    /// `internal_error` (RFC-ACDP-0007 §5). Generic `Http` transport
345    /// errors are conservatively treated as transient since they
346    /// usually mean DNS or TCP-level glitches.
347    ///
348    /// All cryptographic, schema, and authorization errors are NOT
349    /// transient: a malformed body or invalid signature will not
350    /// magically validate on retry.
351    pub fn is_transient(&self) -> bool {
352        matches!(
353            self,
354            AcdpError::KeyResolutionUnreachable(_)
355                | AcdpError::RateLimited(_)
356                | AcdpError::CrossRegistryResolutionFailed(_)
357                | AcdpError::RegistryInternal(_)
358                | AcdpError::Http(_)
359        )
360    }
361
362    /// Map a wire-protocol [`crate::wire_error::WireError`] into a typed
363    /// [`AcdpError`].
364    ///
365    /// Codes the library does not yet recognize are returned as
366    /// [`AcdpError::Registry`] for forward compatibility.
367    pub fn from_wire_error(wire: crate::wire_error::WireError) -> Self {
368        let code = wire.error.code.as_str();
369        let msg = wire.error.message.clone();
370
371        match code {
372            "invalid_signature" => AcdpError::InvalidSignature(msg),
373            "hash_mismatch" => AcdpError::RemoteHashMismatch(msg),
374            "data_ref_hash_mismatch" => AcdpError::DataRefHashMismatch(msg),
375            "schema_violation" => AcdpError::SchemaViolation(msg),
376            "not_authorized" => AcdpError::NotAuthorized(msg),
377            "not_found" => AcdpError::NotFound(msg),
378            "rate_limited" => AcdpError::RateLimited(msg),
379            "payload_too_large" => AcdpError::PayloadTooLarge(msg),
380            "embedded_too_large" => AcdpError::EmbeddedTooLarge(msg),
381            "key_resolution_failed" => AcdpError::KeyResolution(msg),
382            "key_resolution_unreachable" => AcdpError::KeyResolutionUnreachable(msg),
383            "key_not_authorized" => AcdpError::KeyNotAuthorized(msg),
384            "unsupported_algorithm" => AcdpError::UnsupportedAlgorithm(msg),
385            "not_implemented" => AcdpError::NotImplemented(msg),
386            "cursor_expired" => AcdpError::CursorExpired,
387            "invalid_cursor" => AcdpError::InvalidCursor(msg),
388            "duplicate_publish" => AcdpError::DuplicatePublish(msg),
389            "cross_registry_resolution_failed" => AcdpError::CrossRegistryResolutionFailed(msg),
390            "invalid_receipt" => AcdpError::InvalidReceipt(msg),
391            "invalid_log_proof" => AcdpError::InvalidLogProof(msg),
392            "invalid_witness_cosignature" => AcdpError::InvalidWitnessCosignature(msg),
393            "immutable_field" => AcdpError::ImmutableField(msg),
394            "invalid_lifecycle_transition" => AcdpError::InvalidLifecycleTransition(msg),
395            "internal_error" => AcdpError::RegistryInternal(msg),
396            "superseded_target" => {
397                let reason = wire
398                    .error
399                    .details
400                    .as_ref()
401                    .and_then(|d| d.get("reason"))
402                    .and_then(|v| serde_json::from_value::<SupersessionReason>(v.clone()).ok())
403                    .unwrap_or(SupersessionReason::Other);
404                AcdpError::SupersededTarget {
405                    reason,
406                    message: msg,
407                }
408            }
409            // Unknown / future codes pass through as the catch-all variant
410            _ => AcdpError::Registry(wire),
411        }
412    }
413}
414
415impl From<serde_json::Error> for AcdpError {
416    fn from(e: serde_json::Error) -> Self {
417        AcdpError::Serialization(e.to_string())
418    }
419}
420
421impl From<std::io::Error> for AcdpError {
422    fn from(e: std::io::Error) -> Self {
423        AcdpError::Http(format!("io error: {e}"))
424    }
425}
426
427#[cfg(feature = "reqwest")]
428impl From<reqwest::Error> for AcdpError {
429    fn from(e: reqwest::Error) -> Self {
430        if e.is_connect() || e.is_timeout() {
431            AcdpError::Http(format!("connection failed: {e}"))
432        } else {
433            AcdpError::Http(e.to_string())
434        }
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::wire_error::{WireError, WireErrorBody};
442    use serde_json::json;
443
444    fn wire(code: &str, message: &str, details: Option<serde_json::Value>) -> WireError {
445        WireError {
446            error: WireErrorBody {
447                code: code.into(),
448                message: message.into(),
449                details,
450            },
451        }
452    }
453
454    #[test]
455    fn all_25_wire_codes_round_trip() {
456        // Test-coverage matrix entry: "All 25 error codes parse from WireError".
457        // Every code enumerated by acdp-error.schema.json's enum MUST map to a
458        // typed AcdpError variant (or, for `superseded_target` with details,
459        // produce the right SupersessionReason).
460        type Check = fn(&AcdpError) -> bool;
461        let cases: &[(&str, Check)] = &[
462            ("invalid_signature", |e| {
463                matches!(e, AcdpError::InvalidSignature(_))
464            }),
465            ("hash_mismatch", |e| {
466                matches!(e, AcdpError::RemoteHashMismatch(_))
467            }),
468            ("data_ref_hash_mismatch", |e| {
469                matches!(e, AcdpError::DataRefHashMismatch(_))
470            }),
471            ("schema_violation", |e| {
472                matches!(e, AcdpError::SchemaViolation(_))
473            }),
474            ("not_authorized", |e| {
475                matches!(e, AcdpError::NotAuthorized(_))
476            }),
477            ("not_found", |e| matches!(e, AcdpError::NotFound(_))),
478            ("superseded_target", |e| {
479                matches!(e, AcdpError::SupersededTarget { .. })
480            }),
481            ("unsupported_algorithm", |e| {
482                matches!(e, AcdpError::UnsupportedAlgorithm(_))
483            }),
484            ("rate_limited", |e| matches!(e, AcdpError::RateLimited(_))),
485            ("payload_too_large", |e| {
486                matches!(e, AcdpError::PayloadTooLarge(_))
487            }),
488            ("embedded_too_large", |e| {
489                matches!(e, AcdpError::EmbeddedTooLarge(_))
490            }),
491            ("key_resolution_failed", |e| {
492                matches!(e, AcdpError::KeyResolution(_))
493            }),
494            ("key_resolution_unreachable", |e| {
495                matches!(e, AcdpError::KeyResolutionUnreachable(_))
496            }),
497            ("key_not_authorized", |e| {
498                matches!(e, AcdpError::KeyNotAuthorized(_))
499            }),
500            ("not_implemented", |e| {
501                matches!(e, AcdpError::NotImplemented(_))
502            }),
503            ("cursor_expired", |e| matches!(e, AcdpError::CursorExpired)),
504            ("invalid_cursor", |e| {
505                matches!(e, AcdpError::InvalidCursor(_))
506            }),
507            ("duplicate_publish", |e| {
508                matches!(e, AcdpError::DuplicatePublish(_))
509            }),
510            ("cross_registry_resolution_failed", |e| {
511                matches!(e, AcdpError::CrossRegistryResolutionFailed(_))
512            }),
513            ("invalid_receipt", |e| {
514                matches!(e, AcdpError::InvalidReceipt(_))
515            }),
516            ("invalid_log_proof", |e| {
517                matches!(e, AcdpError::InvalidLogProof(_))
518            }),
519            ("invalid_witness_cosignature", |e| {
520                matches!(e, AcdpError::InvalidWitnessCosignature(_))
521            }),
522            ("immutable_field", |e| {
523                matches!(e, AcdpError::ImmutableField(_))
524            }),
525            ("invalid_lifecycle_transition", |e| {
526                matches!(e, AcdpError::InvalidLifecycleTransition(_))
527            }),
528            ("internal_error", |e| {
529                matches!(e, AcdpError::RegistryInternal(_))
530            }),
531        ];
532        // Schema enumerates exactly 25 codes (RFC-ACDP-0007 §5 + the
533        // RFC-ACDP-0010 `invalid_receipt` addition + the 0.3.0 codes:
534        // `invalid_log_proof` (RFC-0012), `immutable_field` and
535        // `invalid_lifecycle_transition` (RFC-0013) + the 0.4.0 code
536        // `invalid_witness_cosignature` (RFC-0015)).
537        assert_eq!(cases.len(), 25);
538        for (code, expected) in cases {
539            let err = AcdpError::from_wire_error(wire(code, "msg", None));
540            assert!(
541                expected(&err),
542                "code '{code}' did not map to its typed variant: got {err:?}"
543            );
544        }
545    }
546
547    #[test]
548    fn superseded_target_with_reason_details() {
549        let w = wire(
550            "superseded_target",
551            "lineage mismatch",
552            Some(json!({"reason": "lineage_mismatch"})),
553        );
554        match AcdpError::from_wire_error(w) {
555            AcdpError::SupersededTarget { reason, .. } => {
556                assert_eq!(reason, SupersessionReason::LineageMismatch);
557            }
558            other => panic!("expected SupersededTarget, got {other:?}"),
559        }
560    }
561
562    #[test]
563    fn superseded_target_without_details_falls_back_to_other() {
564        let w = wire("superseded_target", "?", None);
565        match AcdpError::from_wire_error(w) {
566            AcdpError::SupersededTarget { reason, .. } => {
567                assert_eq!(reason, SupersessionReason::Other);
568            }
569            other => panic!("got {other:?}"),
570        }
571    }
572
573    #[test]
574    fn unknown_code_passes_through_as_registry() {
575        let w = wire("unsupported_embedding_model", "reserved future code", None);
576        assert!(matches!(
577            AcdpError::from_wire_error(w),
578            AcdpError::Registry(_)
579        ));
580    }
581
582    /// T4 — `lineage_walk_failed` reason round-trips via WireError
583    /// (RFC-ACDP-0001 §5.6.1).
584    #[test]
585    fn lineage_walk_failed_reason_roundtrip() {
586        let w = wire(
587            "superseded_target",
588            "intermediate not retrievable",
589            Some(json!({
590                "reason": "lineage_walk_failed",
591                "unreachable_ctx_id":
592                    "acdp://r.example.com/12345678-1234-4321-8123-123456781234"
593            })),
594        );
595        match AcdpError::from_wire_error(w) {
596            AcdpError::SupersededTarget { reason, .. } => {
597                assert_eq!(reason, SupersessionReason::LineageWalkFailed);
598            }
599            other => panic!("got {other:?}"),
600        }
601    }
602
603    /// `is_transient` covers the wire codes the spec marks retryable.
604    #[test]
605    fn is_transient_for_known_retryables() {
606        assert!(AcdpError::KeyResolutionUnreachable("x".into()).is_transient());
607        assert!(AcdpError::RateLimited("x".into()).is_transient());
608        assert!(AcdpError::CrossRegistryResolutionFailed("x".into()).is_transient());
609        assert!(AcdpError::RegistryInternal("x".into()).is_transient());
610        assert!(AcdpError::Http("x".into()).is_transient());
611        assert!(!AcdpError::SchemaViolation("x".into()).is_transient());
612        assert!(!AcdpError::InvalidSignature("x".into()).is_transient());
613        assert!(!AcdpError::NotFound("x".into()).is_transient());
614        // BUG-02: data-ref hash mismatch is a data-integrity failure;
615        // retrying the SAME publish/fetch will return the same answer.
616        // The spec marks it permanent, not retryable.
617        assert!(!AcdpError::DataRefHashMismatch("x".into()).is_transient());
618        // RFC-ACDP-0010: a failed receipt will not verify on retry.
619        assert!(!AcdpError::InvalidReceipt("x".into()).is_transient());
620        assert!(!AcdpError::InvalidLogProof("x".into()).is_transient());
621        // RFC-ACDP-0015: a bad witness cosignature will not verify on retry.
622        assert!(!AcdpError::InvalidWitnessCosignature("x".into()).is_transient());
623        assert!(!AcdpError::ImmutableField("x".into()).is_transient());
624        assert!(!AcdpError::InvalidLifecycleTransition("x".into()).is_transient());
625        // Issue #189: context substitution is locally detected and permanent;
626        // retrying against the same misbehaving registry will not fix it.
627        assert!(!AcdpError::ContextIdMismatch {
628            requested: "a".into(),
629            served: "b".into(),
630        }
631        .is_transient());
632        // Issue #226 Phase 3: an incomplete/mismatched lineage is a
633        // locally-detected consumer-side binding failure, not a
634        // transport hiccup — retrying will not change what the
635        // registry serves for the same lineage_id.
636        assert!(!AcdpError::IncompleteLineage {
637            lineage_id: "lin:sha256:aa".into(),
638            ctx_id: Some("acdp://r.example.com/x".into()),
639        }
640        .is_transient());
641        // Issue #226 Phase 4: exhausting the search-page or lineage-walk
642        // safety cap is a client-local safety-limit signal, not a
643        // transport hiccup — retrying the identical query reproduces the
644        // same truncation.
645        assert!(!AcdpError::SearchTruncated("x".into()).is_transient());
646    }
647}