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