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    /// Wire code: `hash_mismatch`. The remote registry rejected a
66    /// publish request because its independent hash recomputation did
67    /// not match the producer-supplied `content_hash`. Distinct from
68    /// the local [`AcdpError::HashMismatch`] variant: this one carries
69    /// the registry's message verbatim and indicates a *producer-side*
70    /// bug (most often canonicalization divergence — see RFC-ACDP-0001
71    /// §5.7 and the `can-001` conformance fixture).
72    #[error("registry rejected hash_mismatch: {0}")]
73    RemoteHashMismatch(String),
74
75    /// Wire code: `data_ref_hash_mismatch`. A DataRef's fetched or decoded
76    /// bytes do not match the producer-declared `data_ref.content_hash`.
77    /// The body itself remains cryptographically valid — only the
78    /// referenced data has diverged. Distinct from
79    /// [`AcdpError::RemoteHashMismatch`] / [`AcdpError::HashMismatch`]
80    /// (body-level ProducerContent failure — the whole body is untrusted)
81    /// and [`AcdpError::InvalidSignature`] (a key / key-binding problem).
82    /// RFC-ACDP-0002 §6.5–6.6, RFC-ACDP-0007 §5.
83    #[error("data_ref hash mismatch: {0}")]
84    DataRefHashMismatch(String),
85
86    /// Signature verification failed or signature was malformed.
87    /// Wire code: `invalid_signature`.
88    #[error("invalid signature: {0}")]
89    InvalidSignature(String),
90
91    // ── DID / key resolution ─────────────────────────────────────────────
92    /// Wire code: `key_resolution_failed` (HTTP 400).
93    #[error("key resolution failed: {0}")]
94    KeyResolution(String),
95
96    /// Wire code: `key_resolution_unreachable` (HTTP 502) — transient, may retry.
97    #[error("key resolution unreachable (transient): {0}")]
98    KeyResolutionUnreachable(String),
99
100    /// Wire code: `key_not_authorized` (HTTP 403).
101    #[error("key not authorized: {0}")]
102    KeyNotAuthorized(String),
103
104    // ── Input validation ─────────────────────────────────────────────────
105    /// Producer body could not be parsed.
106    #[error("invalid body: {0}")]
107    InvalidBody(String),
108
109    /// A required field was missing.
110    #[error("missing required field: {0}")]
111    MissingField(&'static str),
112
113    /// Schema validation failed (string length, array uniqueness, oneOf, etc).
114    /// Wire code: `schema_violation`.
115    #[error("schema violation: {0}")]
116    SchemaViolation(String),
117
118    /// Wire code: `payload_too_large` — request body exceeds the registry limit.
119    #[error("payload too large: {0}")]
120    PayloadTooLarge(String),
121
122    /// Wire code: `embedded_too_large` — a single `DataRef.embedded.content`
123    /// exceeds the 64 KB cap.
124    #[error("embedded data reference too large: {0}")]
125    EmbeddedTooLarge(String),
126
127    /// Wire code: `unsupported_algorithm` — the producer used a signature
128    /// algorithm the registry does not accept.
129    #[error("unsupported algorithm: {0}")]
130    UnsupportedAlgorithm(String),
131
132    /// Wire code: `not_implemented` — endpoint or feature not supported by
133    /// this registry.
134    #[error("not implemented: {0}")]
135    NotImplemented(String),
136
137    // ── Retrieval / authorization ────────────────────────────────────────
138    /// Wire code: `not_found`.
139    #[error("not found: {0}")]
140    NotFound(String),
141
142    /// Wire code: `not_authorized` — the caller is not permitted to access
143    /// this resource.
144    #[error("not authorized: {0}")]
145    NotAuthorized(String),
146
147    /// Wire code: `rate_limited`.
148    #[error("rate limited: {0}")]
149    RateLimited(String),
150
151    // ── Pagination ───────────────────────────────────────────────────────
152    /// Wire code: `cursor_expired`.
153    #[error("search cursor expired")]
154    CursorExpired,
155
156    /// Wire code: `invalid_cursor`.
157    #[error("invalid cursor: {0}")]
158    InvalidCursor(String),
159
160    // ── Publication ──────────────────────────────────────────────────────
161    /// Wire code: `superseded_target`. The supersession target was rejected;
162    /// the [`SupersessionReason`] disambiguates the cause.
163    #[error("superseded target rejected ({reason:?}): {message}")]
164    SupersededTarget {
165        /// Why the target was rejected.
166        reason: SupersessionReason,
167        /// Human-readable message from the registry.
168        message: String,
169    },
170
171    /// Wire code: `duplicate_publish` — an Idempotency-Key replay produced
172    /// a different request body than the original.
173    #[error("duplicate publish: {0}")]
174    DuplicatePublish(String),
175
176    // ── Cross-registry ───────────────────────────────────────────────────
177    /// Wire code: `cross_registry_resolution_failed`.
178    #[error("cross-registry resolution failed: {0}")]
179    CrossRegistryResolutionFailed(String),
180
181    // ── Registry receipts (ACDP 0.2, RFC-ACDP-0010) ─────────────────────
182    /// Wire code: `invalid_receipt`. A `registry_receipt` failed
183    /// verification: bad signature, a cross-check mismatch (`ctx_id`,
184    /// `content_hash`, `key_fingerprint`, serving authority), a
185    /// malformed shape, or a receipt required by policy but absent.
186    /// Permanent — the receipt will not verify on retry.
187    #[error("invalid registry receipt: {0}")]
188    InvalidReceipt(String),
189
190    /// Wire code: `invalid_log_proof` (RFC-ACDP-0012 §9, §11 — 0.3.0).
191    /// A transparency-log artifact failed verification: an inclusion
192    /// proof that does not fold to the checkpoint's root, a failed
193    /// consistency proof between tree sizes, or a checkpoint whose
194    /// signature does not verify. Permanent — a bad proof will not
195    /// verify on retry (HTTP 502 on the wire: the upstream log is at
196    /// fault when a federated resolver emits it).
197    #[error("invalid transparency-log proof: {0}")]
198    InvalidLogProof(String),
199
200    /// Wire code: `invalid_witness_cosignature` (RFC-ACDP-0015 §8,
201    /// §10 — 0.4.0). A transparency-log **witness cosignature** failed
202    /// the §8 verification procedure: closed parse, the witness-key
203    /// signature, witness binding (`signature.key_id` DID ≠
204    /// `witness_id`), checkpoint binding (`witnessed_checkpoint` ≠ the
205    /// checkpoint being evaluated), or the `witnessed_at` skew check.
206    /// Deliberately **distinct** from [`AcdpError::InvalidLogProof`]:
207    /// that indicts the *log* (tree membership, history consistency,
208    /// the registry's checkpoint signature); this indicts a *witness's*
209    /// attestation — an independent verdict over an independent signer
210    /// (RFC-ACDP-0015 §10). Permanent — a bad cosignature will not
211    /// verify on retry (HTTP 502 on the wire: the cosignature came from
212    /// an upstream party — a registry aggregating on a caller's behalf
213    /// or a resolver validating a witness's cosignatures). A cosignature
214    /// that verifies but is merely *stale* is consumer freshness policy
215    /// (§8.1), never this code.
216    #[error("invalid witness cosignature: {0}")]
217    InvalidWitnessCosignature(String),
218
219    /// Wire code: `immutable_field` (RFC-ACDP-0013 §6, §10 — 0.3.0;
220    /// activated from the v0.1.0 reservation). A lifecycle (or future
221    /// mutation) endpoint request attempted to supply or alter
222    /// immutable body content. Bodies are immutable; lifecycle
223    /// endpoints mutate registry state only. Permanent (HTTP 400).
224    #[error("immutable field: {0}")]
225    ImmutableField(String),
226
227    /// Wire code: `invalid_lifecycle_transition` (RFC-ACDP-0013 §6
228    /// step 4, §10 — 0.3.0). The requested lifecycle transition
229    /// conflicts with the context's current retraction state (retract
230    /// of an already-retracted context; republish of a never-retracted
231    /// one). A state conflict like the 409 arm of `superseded_target`;
232    /// retryable only after the state changes (HTTP 409).
233    #[error("invalid lifecycle transition: {0}")]
234    InvalidLifecycleTransition(String),
235
236    // ── Wire / transport ─────────────────────────────────────────────────
237    /// Wire code: `internal_error`.
238    #[error("registry internal error: {0}")]
239    RegistryInternal(String),
240
241    /// Catch-all for `WireError` codes that have no typed variant in this
242    /// version of the library. Forward-compatible: registries may emit
243    /// reserved codes (`unsupported_embedding_model`)
244    /// that future ACDP versions add.
245    #[error("registry returned error: {0:?}")]
246    Registry(crate::wire_error::WireError),
247
248    /// JSON (de)serialization failed.
249    #[error("serialization failed: {0}")]
250    Serialization(String),
251
252    /// HTTP transport error.
253    #[error("HTTP error: {0}")]
254    Http(String),
255}
256
257/// Sub-reason for [`AcdpError::SupersededTarget`]. Mirrors the
258/// `details.reason` values defined by `acdp-error.schema.json`.
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "snake_case")]
261pub enum SupersessionReason {
262    /// The supersedes target context does not exist on this registry.
263    NotFound,
264    /// The target's lineage_id differs from the new publication's lineage.
265    LineageMismatch,
266    /// The new version is not exactly `previous.version + 1`.
267    VersionMismatch,
268    /// The target has already been superseded by a different version.
269    AlreadySuperseded,
270    /// The target lives on a different registry; v0.1.0 only allows
271    /// same-registry supersession.
272    CrossRegistrySupersessionUnsupported,
273    /// The lineage walk through `supersedes` failed because an
274    /// intermediate context could not be retrieved (RFC-ACDP-0001 §5.6.1).
275    LineageWalkFailed,
276    /// A reason this version of the library does not recognize.
277    #[serde(other)]
278    Other,
279}
280
281impl AcdpError {
282    /// Whether this error is plausibly transient and worth retrying
283    /// with the same request body (and, if applicable, the same
284    /// `Idempotency-Key`).
285    ///
286    /// Returned by [`AcdpError::is_transient`] only for variants whose
287    /// wire codes the spec marks retryable: `key_resolution_unreachable`
288    /// (RFC-ACDP-0001 §5.11), `rate_limited` (RFC-ACDP-0008 §4.3),
289    /// `cross_registry_resolution_failed` (RFC-ACDP-0006 §7), and
290    /// `internal_error` (RFC-ACDP-0007 §5). Generic `Http` transport
291    /// errors are conservatively treated as transient since they
292    /// usually mean DNS or TCP-level glitches.
293    ///
294    /// All cryptographic, schema, and authorization errors are NOT
295    /// transient: a malformed body or invalid signature will not
296    /// magically validate on retry.
297    pub fn is_transient(&self) -> bool {
298        matches!(
299            self,
300            AcdpError::KeyResolutionUnreachable(_)
301                | AcdpError::RateLimited(_)
302                | AcdpError::CrossRegistryResolutionFailed(_)
303                | AcdpError::RegistryInternal(_)
304                | AcdpError::Http(_)
305        )
306    }
307
308    /// Map a wire-protocol [`crate::wire_error::WireError`] into a typed
309    /// [`AcdpError`].
310    ///
311    /// Codes the library does not yet recognize are returned as
312    /// [`AcdpError::Registry`] for forward compatibility.
313    pub fn from_wire_error(wire: crate::wire_error::WireError) -> Self {
314        let code = wire.error.code.as_str();
315        let msg = wire.error.message.clone();
316
317        match code {
318            "invalid_signature" => AcdpError::InvalidSignature(msg),
319            "hash_mismatch" => AcdpError::RemoteHashMismatch(msg),
320            "data_ref_hash_mismatch" => AcdpError::DataRefHashMismatch(msg),
321            "schema_violation" => AcdpError::SchemaViolation(msg),
322            "not_authorized" => AcdpError::NotAuthorized(msg),
323            "not_found" => AcdpError::NotFound(msg),
324            "rate_limited" => AcdpError::RateLimited(msg),
325            "payload_too_large" => AcdpError::PayloadTooLarge(msg),
326            "embedded_too_large" => AcdpError::EmbeddedTooLarge(msg),
327            "key_resolution_failed" => AcdpError::KeyResolution(msg),
328            "key_resolution_unreachable" => AcdpError::KeyResolutionUnreachable(msg),
329            "key_not_authorized" => AcdpError::KeyNotAuthorized(msg),
330            "unsupported_algorithm" => AcdpError::UnsupportedAlgorithm(msg),
331            "not_implemented" => AcdpError::NotImplemented(msg),
332            "cursor_expired" => AcdpError::CursorExpired,
333            "invalid_cursor" => AcdpError::InvalidCursor(msg),
334            "duplicate_publish" => AcdpError::DuplicatePublish(msg),
335            "cross_registry_resolution_failed" => AcdpError::CrossRegistryResolutionFailed(msg),
336            "invalid_receipt" => AcdpError::InvalidReceipt(msg),
337            "invalid_log_proof" => AcdpError::InvalidLogProof(msg),
338            "invalid_witness_cosignature" => AcdpError::InvalidWitnessCosignature(msg),
339            "immutable_field" => AcdpError::ImmutableField(msg),
340            "invalid_lifecycle_transition" => AcdpError::InvalidLifecycleTransition(msg),
341            "internal_error" => AcdpError::RegistryInternal(msg),
342            "superseded_target" => {
343                let reason = wire
344                    .error
345                    .details
346                    .as_ref()
347                    .and_then(|d| d.get("reason"))
348                    .and_then(|v| serde_json::from_value::<SupersessionReason>(v.clone()).ok())
349                    .unwrap_or(SupersessionReason::Other);
350                AcdpError::SupersededTarget {
351                    reason,
352                    message: msg,
353                }
354            }
355            // Unknown / future codes pass through as the catch-all variant
356            _ => AcdpError::Registry(wire),
357        }
358    }
359}
360
361impl From<serde_json::Error> for AcdpError {
362    fn from(e: serde_json::Error) -> Self {
363        AcdpError::Serialization(e.to_string())
364    }
365}
366
367impl From<std::io::Error> for AcdpError {
368    fn from(e: std::io::Error) -> Self {
369        AcdpError::Http(format!("io error: {e}"))
370    }
371}
372
373#[cfg(feature = "reqwest")]
374impl From<reqwest::Error> for AcdpError {
375    fn from(e: reqwest::Error) -> Self {
376        if e.is_connect() || e.is_timeout() {
377            AcdpError::Http(format!("connection failed: {e}"))
378        } else {
379            AcdpError::Http(e.to_string())
380        }
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::wire_error::{WireError, WireErrorBody};
388    use serde_json::json;
389
390    fn wire(code: &str, message: &str, details: Option<serde_json::Value>) -> WireError {
391        WireError {
392            error: WireErrorBody {
393                code: code.into(),
394                message: message.into(),
395                details,
396            },
397        }
398    }
399
400    #[test]
401    fn all_25_wire_codes_round_trip() {
402        // Test-coverage matrix entry: "All 25 error codes parse from WireError".
403        // Every code enumerated by acdp-error.schema.json's enum MUST map to a
404        // typed AcdpError variant (or, for `superseded_target` with details,
405        // produce the right SupersessionReason).
406        type Check = fn(&AcdpError) -> bool;
407        let cases: &[(&str, Check)] = &[
408            ("invalid_signature", |e| {
409                matches!(e, AcdpError::InvalidSignature(_))
410            }),
411            ("hash_mismatch", |e| {
412                matches!(e, AcdpError::RemoteHashMismatch(_))
413            }),
414            ("data_ref_hash_mismatch", |e| {
415                matches!(e, AcdpError::DataRefHashMismatch(_))
416            }),
417            ("schema_violation", |e| {
418                matches!(e, AcdpError::SchemaViolation(_))
419            }),
420            ("not_authorized", |e| {
421                matches!(e, AcdpError::NotAuthorized(_))
422            }),
423            ("not_found", |e| matches!(e, AcdpError::NotFound(_))),
424            ("superseded_target", |e| {
425                matches!(e, AcdpError::SupersededTarget { .. })
426            }),
427            ("unsupported_algorithm", |e| {
428                matches!(e, AcdpError::UnsupportedAlgorithm(_))
429            }),
430            ("rate_limited", |e| matches!(e, AcdpError::RateLimited(_))),
431            ("payload_too_large", |e| {
432                matches!(e, AcdpError::PayloadTooLarge(_))
433            }),
434            ("embedded_too_large", |e| {
435                matches!(e, AcdpError::EmbeddedTooLarge(_))
436            }),
437            ("key_resolution_failed", |e| {
438                matches!(e, AcdpError::KeyResolution(_))
439            }),
440            ("key_resolution_unreachable", |e| {
441                matches!(e, AcdpError::KeyResolutionUnreachable(_))
442            }),
443            ("key_not_authorized", |e| {
444                matches!(e, AcdpError::KeyNotAuthorized(_))
445            }),
446            ("not_implemented", |e| {
447                matches!(e, AcdpError::NotImplemented(_))
448            }),
449            ("cursor_expired", |e| matches!(e, AcdpError::CursorExpired)),
450            ("invalid_cursor", |e| {
451                matches!(e, AcdpError::InvalidCursor(_))
452            }),
453            ("duplicate_publish", |e| {
454                matches!(e, AcdpError::DuplicatePublish(_))
455            }),
456            ("cross_registry_resolution_failed", |e| {
457                matches!(e, AcdpError::CrossRegistryResolutionFailed(_))
458            }),
459            ("invalid_receipt", |e| {
460                matches!(e, AcdpError::InvalidReceipt(_))
461            }),
462            ("invalid_log_proof", |e| {
463                matches!(e, AcdpError::InvalidLogProof(_))
464            }),
465            ("invalid_witness_cosignature", |e| {
466                matches!(e, AcdpError::InvalidWitnessCosignature(_))
467            }),
468            ("immutable_field", |e| {
469                matches!(e, AcdpError::ImmutableField(_))
470            }),
471            ("invalid_lifecycle_transition", |e| {
472                matches!(e, AcdpError::InvalidLifecycleTransition(_))
473            }),
474            ("internal_error", |e| {
475                matches!(e, AcdpError::RegistryInternal(_))
476            }),
477        ];
478        // Schema enumerates exactly 25 codes (RFC-ACDP-0007 §5 + the
479        // RFC-ACDP-0010 `invalid_receipt` addition + the 0.3.0 codes:
480        // `invalid_log_proof` (RFC-0012), `immutable_field` and
481        // `invalid_lifecycle_transition` (RFC-0013) + the 0.4.0 code
482        // `invalid_witness_cosignature` (RFC-0015)).
483        assert_eq!(cases.len(), 25);
484        for (code, expected) in cases {
485            let err = AcdpError::from_wire_error(wire(code, "msg", None));
486            assert!(
487                expected(&err),
488                "code '{code}' did not map to its typed variant: got {err:?}"
489            );
490        }
491    }
492
493    #[test]
494    fn superseded_target_with_reason_details() {
495        let w = wire(
496            "superseded_target",
497            "lineage mismatch",
498            Some(json!({"reason": "lineage_mismatch"})),
499        );
500        match AcdpError::from_wire_error(w) {
501            AcdpError::SupersededTarget { reason, .. } => {
502                assert_eq!(reason, SupersessionReason::LineageMismatch);
503            }
504            other => panic!("expected SupersededTarget, got {other:?}"),
505        }
506    }
507
508    #[test]
509    fn superseded_target_without_details_falls_back_to_other() {
510        let w = wire("superseded_target", "?", None);
511        match AcdpError::from_wire_error(w) {
512            AcdpError::SupersededTarget { reason, .. } => {
513                assert_eq!(reason, SupersessionReason::Other);
514            }
515            other => panic!("got {other:?}"),
516        }
517    }
518
519    #[test]
520    fn unknown_code_passes_through_as_registry() {
521        let w = wire("unsupported_embedding_model", "reserved future code", None);
522        assert!(matches!(
523            AcdpError::from_wire_error(w),
524            AcdpError::Registry(_)
525        ));
526    }
527
528    /// T4 — `lineage_walk_failed` reason round-trips via WireError
529    /// (RFC-ACDP-0001 §5.6.1).
530    #[test]
531    fn lineage_walk_failed_reason_roundtrip() {
532        let w = wire(
533            "superseded_target",
534            "intermediate not retrievable",
535            Some(json!({
536                "reason": "lineage_walk_failed",
537                "unreachable_ctx_id":
538                    "acdp://r.example.com/12345678-1234-4321-8123-123456781234"
539            })),
540        );
541        match AcdpError::from_wire_error(w) {
542            AcdpError::SupersededTarget { reason, .. } => {
543                assert_eq!(reason, SupersessionReason::LineageWalkFailed);
544            }
545            other => panic!("got {other:?}"),
546        }
547    }
548
549    /// `is_transient` covers the wire codes the spec marks retryable.
550    #[test]
551    fn is_transient_for_known_retryables() {
552        assert!(AcdpError::KeyResolutionUnreachable("x".into()).is_transient());
553        assert!(AcdpError::RateLimited("x".into()).is_transient());
554        assert!(AcdpError::CrossRegistryResolutionFailed("x".into()).is_transient());
555        assert!(AcdpError::RegistryInternal("x".into()).is_transient());
556        assert!(AcdpError::Http("x".into()).is_transient());
557        assert!(!AcdpError::SchemaViolation("x".into()).is_transient());
558        assert!(!AcdpError::InvalidSignature("x".into()).is_transient());
559        assert!(!AcdpError::NotFound("x".into()).is_transient());
560        // BUG-02: data-ref hash mismatch is a data-integrity failure;
561        // retrying the SAME publish/fetch will return the same answer.
562        // The spec marks it permanent, not retryable.
563        assert!(!AcdpError::DataRefHashMismatch("x".into()).is_transient());
564        // RFC-ACDP-0010: a failed receipt will not verify on retry.
565        assert!(!AcdpError::InvalidReceipt("x".into()).is_transient());
566        assert!(!AcdpError::InvalidLogProof("x".into()).is_transient());
567        // RFC-ACDP-0015: a bad witness cosignature will not verify on retry.
568        assert!(!AcdpError::InvalidWitnessCosignature("x".into()).is_transient());
569        assert!(!AcdpError::ImmutableField("x".into()).is_transient());
570        assert!(!AcdpError::InvalidLifecycleTransition("x".into()).is_transient());
571        // Issue #189: context substitution is locally detected and permanent;
572        // retrying against the same misbehaving registry will not fix it.
573        assert!(!AcdpError::ContextIdMismatch {
574            requested: "a".into(),
575            served: "b".into(),
576        }
577        .is_transient());
578    }
579}