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