dig-rpc-protocol 0.10.1

Canonical DIG-node JSON-RPC protocol: request/response types, the method enum + tier classification, the error-code taxonomy, and an OpenRPC 1.2.6 document generator. The single source of truth both DIG node implementations depend on. Pure types — no I/O, no async, no server logic. (Formerly dig-rpc-types.)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
//! The canonical DIG-node RPC error taxonomy.
//!
//! This module is the **single definition point** for every error code the DIG
//! node RPC surface emits, the canonical [`RpcError`] envelope
//! (`{code, message, data:{code, origin}}`), and the one constructor helper both
//! node implementations call so every error carries a machine-branchable
//! `data.code` and `data.origin`.
//!
//! # The code set
//!
//! Standard JSON-RPC 2.0 codes plus the DIG protocol-specific codes. The numeric
//! values are a **published wire contract** and never change once assigned.
//!
//! | Code | Variant | Origin | Meaning |
//! |---|---|---|---|
//! | `-32700` | [`ParseError`](ErrorCode::ParseError) | Node | request body is not valid JSON |
//! | `-32600` | [`InvalidRequest`](ErrorCode::InvalidRequest) | Node | not a valid Request object |
//! | `-32601` | [`MethodNotFound`](ErrorCode::MethodNotFound) | Node | method not implemented |
//! | `-32602` | [`InvalidParams`](ErrorCode::InvalidParams) | Node | missing/malformed params |
//! | `-32603` | [`InternalError`](ErrorCode::InternalError) | Node | well-formed call failed |
//! | `-32000` | [`ServerError`](ErrorCode::ServerError) | Node | generic server error |
//! | `-32003` | [`ContentMissRateLimited`](ErrorCode::ContentMissRateLimited) | Node | content not held; miss-lookup budget exhausted for this requestor |
//! | `-32004` | [`ResourceUnavailable`](ErrorCode::ResourceUnavailable) | Node | resource not available at the requested root (genuine infra miss) |
//! | `-32005` | [`RootNotAnchored`](ErrorCode::RootNotAnchored) | Node | requested/served root is not the chain-anchored root (fail-closed pin) |
//! | `-32006` | [`PeerUnreachable`](ErrorCode::PeerUnreachable) | Node | no NAT-traversal strategy reached the peer |
//! | `-32007` | [`RangeNotSatisfiable`](ErrorCode::RangeNotSatisfiable) | Node | byte range lies outside the resource |
//! | `-32008` | [`ContentRedirect`](ErrorCode::ContentRedirect) | Node | content held elsewhere — `data.redirect` names holders |
//! | `-32009` | [`RangeMetadataUnrepresentable`](ErrorCode::RangeMetadataUnrepresentable) | Node | the resource's own range metadata cannot fit a conforming frame, so this holder can NEVER serve the range |
//! | `-32010` | [`UpstreamError`](ErrorCode::UpstreamError) | Upstream | an upstream/proxy fetch failed |
//! | `-32011` | [`StageInvalidInput`](ErrorCode::StageInvalidInput) | Node | `dig.stage`: dir unreadable / walk budget exceeded |
//! | `-32012` | [`StageNoFiles`](ErrorCode::StageNoFiles) | Node | `dig.stage`: no files to compile |
//! | `-32013` | [`StageOverCap`](ErrorCode::StageOverCap) | Node | `dig.stage`: input exceeds the store cap |
//! | `-32014` | [`StageCompileFailed`](ErrorCode::StageCompileFailed) | Node | `dig.stage`: compile / IO failure |
//! | `-32015` | [`MetadataTooLarge`](ErrorCode::MetadataTooLarge) | Node | `dig.getMetadata`: the metadata section is refused as too large/complex (bounded error) |
//! | `-32016` | [`PushPendingLimited`](ErrorCode::PushPendingLimited) | Node | `cache.pushCapsule`: refused, the pending-push budget is exhausted |
//! | `-32017` | [`ContentMissInconclusive`](ErrorCode::ContentMissInconclusive) | Peer | absence was NOT established — a hop timed out, was unreachable, or refused uninformatively |
//! | `-32020` | [`OnionCircuitUnavailable`](ErrorCode::OnionCircuitUnavailable) | Onion | private read could not build/keep a circuit |
//! | `-32021` | [`PrivacyRequiresLocalNode`](ErrorCode::PrivacyRequiresLocalNode) | Onion | privacy mode requires the caller be a local originator |
//! | `-32022` | [`OnionHopsOutOfRange`](ErrorCode::OnionHopsOutOfRange) | Onion | requested hop count outside `[2, 5]` |
//! | `-32030` | [`Unauthorized`](ErrorCode::Unauthorized) | Control | control-plane call is not authorized |
//! | `-32031` | [`NotSupported`](ErrorCode::NotSupported) | Control | control-plane method not supported here |
//! | `-32032` | [`ControlError`](ErrorCode::ControlError) | Control | control-plane runtime error |
//! | `-32050` | [`NoIdentity`](ErrorCode::NoIdentity) | Node | no persistent identity key, so the node cannot seal as sender |
//! | `-32051` | [`NoPeerNetwork`](ErrorCode::NoPeerNetwork) | Peer | no gossip pool, so a directed send has no transport |
//! | `-32052` | [`SendFailed`](ErrorCode::SendFailed) | Peer | sealing or sending the directed message failed |
//!
//! ## The `-32020..-32022` collision, resolved
//!
//! The published normative protocol (docs.dig.net) assigns `-32020/-32021/-32022`
//! to the **onion** (private-retrieval) failures. Those keep their numbers. The
//! control-plane errors that previously squatted the same values are renumbered
//! to `-32030/-32031/-32032`.
//!
//! ## The canonical space includes consumer-held ranges
//!
//! A number is **free only if it is unoccupied ECOSYSTEM-WIDE**, not merely absent
//! from [`ErrorCode::ALL`]. Consumers hold undeclared bands inside this same space:
//! `-32015` `METADATA_TOO_LARGE` and `-32016` `PUSH_PENDING_LIMITED` were released,
//! catalogued on docs.dig.net, and emitted by dig-node while being invisible here,
//! so `ContentMissInconclusive` was assigned `-32015` as "the next free code" and
//! collided with a live wire contract. That was the second such collision on one
//! variant (`-32009` was the first).
//!
//! Those five consumer-held codes are now DECLARED here with their existing numbers
//! and meanings unchanged — the canonical side adapts, because a released code is a
//! published branch key and renumbering one breaks clients (§5.1 is additive-only).
//! The band map, so the next assignment has somewhere to look:
//!
//! | Band | Owner |
//! |---|---|
//! | `-32000..-32019` | node read/serve + staging |
//! | `-32020..-32029` | onion / private retrieval |
//! | `-32030..-32039` | loopback control plane |
//! | `-32040..-32049` | control-plane wallet reads (consumer-held, not yet absorbed) |
//! | `-32050..-32059` | directed messaging — sealed sender-to-recipient sends |
//!
//! `-32050..-32052` get a band of their own rather than joining the control band:
//! they are served on the node's ORDINARY JSON-RPC surface (dispatched from
//! `seams::dig_rpc`, reusing the standard `-32602` for bad params), so they are
//! neither control-plane nor "the node's private application range" — a directed
//! send is a peer-network operation and is banded as one.
//!
//! `tests/error_code_registry.rs` holds the measured occupancy as data and asserts
//! no declaration contradicts it. See that file for what the list can and cannot
//! prove.

use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};

/// A canonical DIG-node RPC error code.
///
/// Serializes as a bare integer (via `serde_repr`), spec-compliant for
/// `error.code`. `#[non_exhaustive]` so adding a code in a minor release is
/// additive; downstream matches must use `_ => …`.
#[repr(i32)]
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq, Hash)]
pub enum ErrorCode {
    // ---- Standard JSON-RPC 2.0 ----
    /// Invalid JSON was received by the server.
    ParseError = -32700,
    /// The JSON sent is not a valid Request object.
    InvalidRequest = -32600,
    /// The method does not exist / is not available.
    MethodNotFound = -32601,
    /// Invalid method parameter(s).
    InvalidParams = -32602,
    /// The node failed to satisfy a well-formed call (network profile).
    InternalError = -32603,

    // ---- DIG protocol-specific (implementation-defined server range) ----
    /// Generic server error (config write failure, file I/O, chain read failure).
    ServerError = -32000,
    /// This node does not hold the content, and the requestor has exhausted its
    /// miss-lookup budget. Unlike an ordinary content miss (an indistinguishable
    /// decoy that is never an error), this is an explicit rate-limit signal: the
    /// requestor has spent its budget of miss lookups and should back off rather
    /// than keep probing.
    ContentMissRateLimited = -32003,
    /// Resource not available at the requested root — a genuine infrastructure
    /// miss (absent module, bad magic, oversize, a trap, an undecodable
    /// envelope). Distinct from a content miss, which is an indistinguishable
    /// decoy and is never an error.
    ResourceUnavailable = -32004,
    /// The requested or served generation is not the store's current on-chain
    /// root. The read path pins to the CHIP-0035 singleton's on-chain root and
    /// fails closed rather than serving an unverified generation.
    RootNotAnchored = -32005,
    /// No connection to the named peer could be established — every
    /// NAT-traversal strategy failed, or the peer is not on this network.
    PeerUnreachable = -32006,
    /// The requested byte range lies outside the resource (`offset >=
    /// total_length`) or is otherwise unsatisfiable.
    RangeNotSatisfiable = -32007,
    /// This node does not hold the content but located peers that do.
    /// `data.redirect` names the holders + the redirect budget.
    ContentRedirect = -32008,
    /// `dig.fetchRange`: the resource's own range metadata cannot be represented
    /// in a conforming frame at all, so this holder can NEVER serve the range.
    ///
    /// A resource whose `chunk_lens` layout or `inclusion_proof` exceeds the
    /// per-frame bounds has no conforming first frame, even with a paged prologue.
    /// That is a permanent property of the resource, not a transient condition, so
    /// it needs its own code: a client that could not tell it from an ordinary
    /// transport failure would keep retrying a holder that cannot succeed, and would
    /// retry every other holder of the same resource for the same reason.
    RangeMetadataUnrepresentable = -32009,
    /// An upstream/proxy fetch (e.g. `rpc.dig.net`) failed. Distinct from the
    /// generic [`ServerError`](ErrorCode::ServerError) so a client can tell an
    /// upstream fault from a local one.
    UpstreamError = -32010,
    /// `dig.stage`: the input directory is unreadable, or the bounded walk
    /// exceeded its byte / file-count / depth budget.
    StageInvalidInput = -32011,
    /// `dig.stage`: the input directory contained no files to compile.
    StageNoFiles = -32012,
    /// `dig.stage`: the input exceeds the per-store size cap.
    StageOverCap = -32013,
    /// `dig.stage`: compiling the capsule failed (CLVM/IO error).
    StageCompileFailed = -32014,

    /// `dig.getMetadata`: the capsule's publisher metadata section is refused
    /// because it renders larger than the bounded response ceiling, or is too
    /// complex (more custom entries than the cap allows).
    ///
    /// The section is returned WHOLE — it cannot be paged like content — and its
    /// `links`/`custom` fields are publisher-controlled, so an oversized section is
    /// refused with this bounded error rather than returned as one very large
    /// response. A normal store's metadata is well inside the ceiling.
    ///
    /// Declared here because dig-node has emitted it, and docs.dig.net has
    /// catalogued it, since before this taxonomy existed.
    MetadataTooLarge = -32015,
    /// `cache.pushCapsule`: this window is refused because accepting it would
    /// exceed an in-flight reassembly bound — the per-requestor concurrent-push
    /// cap, the global concurrent-push cap, or the global pending-bytes budget.
    ///
    /// Retriable: the budget frees as in-flight pushes complete or an abandoned
    /// partial is reaped. Distinct from
    /// [`MetadataTooLarge`](ErrorCode::MetadataTooLarge), which is a bound on ONE
    /// response, and from
    /// [`ContentMissRateLimited`](ErrorCode::ContentMissRateLimited), which is a
    /// per-requestor lookup budget rather than a memory bound.
    ///
    /// Declared here for the same reason as `-32015`: it was already released.
    PushPendingLimited = -32016,

    /// `dig.getAvailability`: this node could not ESTABLISH that nobody holds the
    /// content. A recursive ask reached a hop that timed out, was unreachable, or
    /// refused in a way that carries no information about the content, so the
    /// subtree behind that hop was never actually consulted.
    ///
    /// It is deliberately NOT a miss. A plain miss (`available: false` with
    /// [`absence_established`](crate::types::AvailabilityAnswer::absence_established)
    /// true) is a positive assertion: the responder looked and nobody it can reach
    /// holds this. This code is the absence of that assertion.
    ///
    /// A client MUST NOT treat it as absence — give up the search and report
    /// not-found — and MUST NOT treat it as holder-fatal. Keep looking, and this
    /// holder stays eligible for a later ask.
    ///
    /// # Why `-32017`, after two collisions
    ///
    /// [`RangeMetadataUnrepresentable`](ErrorCode::RangeMetadataUnrepresentable) is
    /// holder-FATAL: that holder can never serve that range, so a client should
    /// never ask it again. This code is the opposite instruction on both axes —
    /// nothing is established and nothing is exhausted. A client that could not tell
    /// them apart would either permanently blacklist a merely-uncertain holder or
    /// keep re-asking one that can never serve.
    ///
    /// It was then written as `-32015`, verified free against THIS crate's list —
    /// where it was free — while `-32015` is dig-node's released
    /// [`MetadataTooLarge`](ErrorCode::MetadataTooLarge). `-32017` was chosen only
    /// after measuring zero occurrences ecosystem-wide.
    ContentMissInconclusive = -32017,

    // ---- Onion / private retrieval (published normative — KEEP) ----
    /// A `mode:"privacy"` read could not be served privately (no circuit could
    /// be built, or one died mid-fetch). The node fails closed rather than
    /// downgrading — a silent downgrade would deanonymize the reader.
    OnionCircuitUnavailable = -32020,
    /// `mode:"privacy"` was requested but the caller is not the node's own
    /// trusted local originator. Privacy requires a local DIG node.
    PrivacyRequiresLocalNode = -32021,
    /// The requested `privacy.hops` (circuit length) is outside `[2, 5]`.
    OnionHopsOutOfRange = -32022,

    // ---- Control plane (loopback-only; renumbered off the onion codes) ----
    /// The control-plane call is not authorized (loopback / token gate failed).
    Unauthorized = -32030,
    /// The control-plane method is recognized but not supported on this node.
    NotSupported = -32031,
    /// A control-plane runtime error (pin registry, sync trigger, config write).
    ControlError = -32032,

    // ---- Directed messaging (sealed sender-to-recipient sends) ----
    /// A directed send was refused because this node has no persistent identity
    /// key, so it cannot seal as sender.
    ///
    /// NC-1 requires a directed message be sealed to the recipient BY the sender's
    /// identity; without a persistent key there is no sender to seal as, and the
    /// node refuses rather than sending something the recipient cannot attribute.
    NoIdentity = -32050,
    /// A directed send was refused because this node has no gossip pool — there is
    /// no peer transport to carry the message to the recipient.
    NoPeerNetwork = -32051,
    /// Sealing or sending the directed message failed.
    SendFailed = -32052,
}

impl ErrorCode {
    /// The raw integer wire code.
    pub const fn code(self) -> i32 {
        self as i32
    }

    /// The stable `UPPER_SNAKE_CASE` machine identifier carried in `data.code`.
    ///
    /// This is the branch key an agent keys on; it never changes once assigned.
    pub const fn machine_code(self) -> &'static str {
        match self {
            ErrorCode::ParseError => "PARSE_ERROR",
            ErrorCode::InvalidRequest => "INVALID_REQUEST",
            ErrorCode::MethodNotFound => "METHOD_NOT_FOUND",
            ErrorCode::InvalidParams => "INVALID_PARAMS",
            ErrorCode::InternalError => "INTERNAL_ERROR",
            ErrorCode::ServerError => "SERVER_ERROR",
            ErrorCode::ContentMissRateLimited => "CONTENT_MISS_RATE_LIMITED",
            ErrorCode::ResourceUnavailable => "RESOURCE_UNAVAILABLE",
            ErrorCode::RootNotAnchored => "ROOT_NOT_ANCHORED",
            ErrorCode::PeerUnreachable => "PEER_UNREACHABLE",
            ErrorCode::RangeNotSatisfiable => "RANGE_NOT_SATISFIABLE",
            ErrorCode::ContentRedirect => "CONTENT_REDIRECT",
            ErrorCode::RangeMetadataUnrepresentable => "RANGE_METADATA_UNREPRESENTABLE",
            ErrorCode::UpstreamError => "UPSTREAM_ERROR",
            ErrorCode::StageInvalidInput => "STAGE_INVALID_INPUT",
            ErrorCode::StageNoFiles => "STAGE_NO_FILES",
            ErrorCode::StageOverCap => "STAGE_OVER_CAP",
            ErrorCode::StageCompileFailed => "STAGE_COMPILE_FAILED",
            ErrorCode::MetadataTooLarge => "METADATA_TOO_LARGE",
            ErrorCode::PushPendingLimited => "PUSH_PENDING_LIMITED",
            ErrorCode::ContentMissInconclusive => "CONTENT_MISS_INCONCLUSIVE",
            ErrorCode::OnionCircuitUnavailable => "ONION_CIRCUIT_UNAVAILABLE",
            ErrorCode::PrivacyRequiresLocalNode => "PRIVACY_REQUIRES_LOCAL_NODE",
            ErrorCode::OnionHopsOutOfRange => "ONION_HOPS_OUT_OF_RANGE",
            ErrorCode::Unauthorized => "UNAUTHORIZED",
            ErrorCode::NotSupported => "NOT_SUPPORTED",
            ErrorCode::ControlError => "CONTROL_ERROR",
            ErrorCode::NoIdentity => "NO_IDENTITY",
            ErrorCode::NoPeerNetwork => "NO_PEER_NETWORK",
            ErrorCode::SendFailed => "SEND_FAILED",
        }
    }

    /// The default human-readable summary for this code (used when a caller does
    /// not supply a more specific message).
    pub const fn default_message(self) -> &'static str {
        match self {
            ErrorCode::ParseError => "Parse error",
            ErrorCode::InvalidRequest => "Invalid request",
            ErrorCode::MethodNotFound => "Method not found",
            ErrorCode::InvalidParams => "Invalid params",
            ErrorCode::InternalError => "Internal error",
            ErrorCode::ServerError => "Server error",
            ErrorCode::ContentMissRateLimited => {
                "Content not held; miss-lookup budget exhausted for this requestor"
            }
            ErrorCode::ResourceUnavailable => "Resource not available at the requested root",
            ErrorCode::RootNotAnchored => "Root not chain-anchored",
            ErrorCode::PeerUnreachable => "Peer unreachable",
            ErrorCode::RangeNotSatisfiable => "Range not satisfiable",
            ErrorCode::ContentRedirect => "Content held elsewhere — redirect",
            ErrorCode::RangeMetadataUnrepresentable => {
                "Range metadata cannot be represented in a conforming frame"
            }
            ErrorCode::UpstreamError => "Upstream error",
            ErrorCode::StageInvalidInput => "Stage input directory not readable",
            ErrorCode::StageNoFiles => "Stage input contained no files",
            ErrorCode::StageOverCap => "Stage input over the store cap",
            ErrorCode::StageCompileFailed => "Stage compile failed",
            ErrorCode::MetadataTooLarge => "Metadata section too large to return",
            ErrorCode::PushPendingLimited => "Push pending-reassembly limit reached",
            ErrorCode::ContentMissInconclusive => {
                "Absence not established — a hop could not be consulted"
            }
            ErrorCode::OnionCircuitUnavailable => "Onion circuit unavailable",
            ErrorCode::PrivacyRequiresLocalNode => "Privacy requires a local node",
            ErrorCode::OnionHopsOutOfRange => "Onion hop count out of range",
            ErrorCode::Unauthorized => "Unauthorized",
            ErrorCode::NotSupported => "Not supported",
            ErrorCode::ControlError => "Control-plane error",
            ErrorCode::NoIdentity => "No node identity key — cannot seal as sender",
            ErrorCode::NoPeerNetwork => "No peer network — a directed send has no transport",
            ErrorCode::SendFailed => "Seal or send failed",
        }
    }

    /// The natural [`ErrorOrigin`] for this code (which subsystem the failure
    /// arose in). A caller may override it — a `ResourceUnavailable` bubbled up
    /// from an upstream proxy can be tagged [`ErrorOrigin::Upstream`].
    pub const fn default_origin(self) -> ErrorOrigin {
        match self {
            ErrorCode::UpstreamError => ErrorOrigin::Upstream,
            ErrorCode::OnionCircuitUnavailable
            | ErrorCode::PrivacyRequiresLocalNode
            | ErrorCode::OnionHopsOutOfRange => ErrorOrigin::Onion,
            ErrorCode::Unauthorized | ErrorCode::NotSupported | ErrorCode::ControlError => {
                ErrorOrigin::Control
            }
            ErrorCode::PeerUnreachable
            | ErrorCode::ContentMissInconclusive
            | ErrorCode::NoPeerNetwork
            | ErrorCode::SendFailed => ErrorOrigin::Peer,
            _ => ErrorOrigin::Node,
        }
    }

    /// Whether this variant is in the JSON-RPC-reserved range
    /// (`-32768..=-32000`).
    pub const fn is_jsonrpc_reserved(self) -> bool {
        let c = self.code();
        c >= -32768 && c <= -32000
    }

    /// Every code, in wire order. Drives the OpenRPC error catalogue and the
    /// exhaustiveness conformance test — a new variant must be added here.
    pub const ALL: &'static [ErrorCode] = &[
        ErrorCode::ParseError,
        ErrorCode::InvalidRequest,
        ErrorCode::MethodNotFound,
        ErrorCode::InvalidParams,
        ErrorCode::InternalError,
        ErrorCode::ServerError,
        ErrorCode::ContentMissRateLimited,
        ErrorCode::ResourceUnavailable,
        ErrorCode::RootNotAnchored,
        ErrorCode::PeerUnreachable,
        ErrorCode::RangeNotSatisfiable,
        ErrorCode::ContentRedirect,
        ErrorCode::RangeMetadataUnrepresentable,
        ErrorCode::UpstreamError,
        ErrorCode::StageInvalidInput,
        ErrorCode::StageNoFiles,
        ErrorCode::StageOverCap,
        ErrorCode::StageCompileFailed,
        ErrorCode::MetadataTooLarge,
        ErrorCode::PushPendingLimited,
        ErrorCode::ContentMissInconclusive,
        ErrorCode::OnionCircuitUnavailable,
        ErrorCode::PrivacyRequiresLocalNode,
        ErrorCode::OnionHopsOutOfRange,
        ErrorCode::Unauthorized,
        ErrorCode::NotSupported,
        ErrorCode::ControlError,
        ErrorCode::NoIdentity,
        ErrorCode::NoPeerNetwork,
        ErrorCode::SendFailed,
    ];
}

/// The subsystem a failure arose in — carried in `data.origin` so a client can
/// route the error (retry upstream, rebuild a circuit, re-auth the control
/// plane) without parsing the message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ErrorOrigin {
    /// The node's own read/serve path.
    Node,
    /// The peer-network layer (discovery / availability / range serving).
    Peer,
    /// An upstream fetch (`rpc.dig.net` proxy / whole-store sync).
    Upstream,
    /// The onion (private-retrieval) layer.
    Onion,
    /// The loopback control plane.
    Control,
}

/// Structured error context carried in `error.data`.
///
/// The canonical DIG envelope always carries `data.code` (the
/// [`UPPER_SNAKE_CASE`](ErrorCode::machine_code) machine identifier) and
/// `data.origin`. `redirect` is present only on
/// [`ContentRedirect`](ErrorCode::ContentRedirect); `extra` carries any
/// method-specific fields verbatim (flattened onto `data`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ErrorData {
    /// The stable `UPPER_SNAKE_CASE` machine code (mirrors the numeric `code`).
    pub code: String,
    /// The subsystem the failure arose in.
    pub origin: ErrorOrigin,
    /// The redirect payload — present only on `CONTENT_REDIRECT`.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub redirect: Option<crate::types::RedirectInfo>,
    /// Any additional method-specific fields, flattened onto `data`.
    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
    pub extra: serde_json::Map<String, serde_json::Value>,
}

/// The canonical DIG-node RPC error object: `{code, message, data:{code, origin}}`.
///
/// Build one with [`RpcError::new`] (or the code-specific helpers) so the numeric
/// code, the `data.code` machine string, and the origin can never drift apart.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RpcError {
    /// The numeric wire code.
    pub code: ErrorCode,
    /// A human-readable message. May be refined over time; `data.code` is the
    /// stable branch key.
    pub message: String,
    /// Structured, machine-branchable context.
    pub data: ErrorData,
}

impl RpcError {
    /// The single constructor: mint the canonical envelope from a code, a
    /// message, and an origin. `data.code` is derived from `code` so the two
    /// can never disagree.
    pub fn new(code: ErrorCode, message: impl Into<String>, origin: ErrorOrigin) -> Self {
        Self {
            code,
            message: message.into(),
            data: ErrorData {
                code: code.machine_code().to_string(),
                origin,
                redirect: None,
                extra: serde_json::Map::new(),
            },
        }
    }

    /// Mint an error using the code's [default origin](ErrorCode::default_origin).
    pub fn of(code: ErrorCode, message: impl Into<String>) -> Self {
        Self::new(code, message, code.default_origin())
    }

    /// Mint an error using both the code's default origin and its
    /// [default message](ErrorCode::default_message).
    pub fn code_only(code: ErrorCode) -> Self {
        Self::new(code, code.default_message(), code.default_origin())
    }

    /// Attach a [`RedirectInfo`](crate::types::RedirectInfo) payload (for
    /// [`ContentRedirect`](ErrorCode::ContentRedirect)).
    pub fn with_redirect(mut self, redirect: crate::types::RedirectInfo) -> Self {
        self.data.redirect = Some(redirect);
        self
    }

    /// Attach one extra `data` field (flattened onto `data`).
    pub fn with_extra(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
        self.data.extra.insert(key.into(), value);
        self
    }
}

impl std::fmt::Display for RpcError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "[{}] {}", self.code.machine_code(), self.message)
    }
}

impl std::error::Error for RpcError {}

#[cfg(test)]
mod tests {
    use super::*;

    /// **Proves:** every numeric code is pinned to its published wire value.
    /// **Catches:** a reorder/renumber, or a lost `#[repr(i32)]`.
    #[test]
    fn numeric_values_pinned() {
        assert_eq!(ErrorCode::ParseError.code(), -32700);
        assert_eq!(ErrorCode::InvalidRequest.code(), -32600);
        assert_eq!(ErrorCode::MethodNotFound.code(), -32601);
        assert_eq!(ErrorCode::InvalidParams.code(), -32602);
        assert_eq!(ErrorCode::InternalError.code(), -32603);
        assert_eq!(ErrorCode::ServerError.code(), -32000);
        assert_eq!(ErrorCode::ContentMissRateLimited.code(), -32003);
        assert_eq!(ErrorCode::ResourceUnavailable.code(), -32004);
        assert_eq!(ErrorCode::RootNotAnchored.code(), -32005);
        assert_eq!(ErrorCode::PeerUnreachable.code(), -32006);
        assert_eq!(ErrorCode::RangeNotSatisfiable.code(), -32007);
        assert_eq!(ErrorCode::ContentRedirect.code(), -32008);
        assert_eq!(ErrorCode::UpstreamError.code(), -32010);
        assert_eq!(ErrorCode::StageInvalidInput.code(), -32011);
        assert_eq!(ErrorCode::StageNoFiles.code(), -32012);
        assert_eq!(ErrorCode::StageOverCap.code(), -32013);
        assert_eq!(ErrorCode::StageCompileFailed.code(), -32014);
        assert_eq!(ErrorCode::MetadataTooLarge.code(), -32015);
        assert_eq!(ErrorCode::PushPendingLimited.code(), -32016);
        assert_eq!(ErrorCode::ContentMissInconclusive.code(), -32017);
        assert_eq!(ErrorCode::OnionCircuitUnavailable.code(), -32020);
        assert_eq!(ErrorCode::PrivacyRequiresLocalNode.code(), -32021);
        assert_eq!(ErrorCode::OnionHopsOutOfRange.code(), -32022);
        assert_eq!(ErrorCode::Unauthorized.code(), -32030);
        assert_eq!(ErrorCode::NotSupported.code(), -32031);
        assert_eq!(ErrorCode::ControlError.code(), -32032);
        assert_eq!(ErrorCode::NoIdentity.code(), -32050);
        assert_eq!(ErrorCode::NoPeerNetwork.code(), -32051);
        assert_eq!(ErrorCode::SendFailed.code(), -32052);
    }

    /// **Proves:** the onion codes keep `-32020..-32022` and the control codes
    /// are renumbered clear of them.
    /// **Catches:** a regression that reintroduces the historical collision.
    #[test]
    fn onion_control_collision_resolved() {
        assert_eq!(ErrorCode::OnionCircuitUnavailable.code(), -32020);
        assert_eq!(ErrorCode::Unauthorized.code(), -32030);
        assert_ne!(
            ErrorCode::OnionCircuitUnavailable.code(),
            ErrorCode::Unauthorized.code()
        );
    }

    /// **Proves:** a code serializes as a bare integer, never a tagged object.
    /// **Catches:** a swap of `Serialize_repr` for plain `Serialize`.
    #[test]
    fn code_serialises_as_integer() {
        assert_eq!(
            serde_json::to_string(&ErrorCode::MethodNotFound).unwrap(),
            "-32601"
        );
        assert_eq!(
            serde_json::to_string(&ErrorCode::ContentRedirect).unwrap(),
            "-32008"
        );
    }

    /// **Proves:** `ALL` lists exactly the distinct codes, with unique numbers
    /// and unique machine strings.
    /// **Catches:** a new variant left out of `ALL`, or a duplicated
    /// code/machine string.
    #[test]
    fn all_codes_unique_and_complete() {
        use std::collections::HashSet;
        let nums: HashSet<i32> = ErrorCode::ALL.iter().map(|c| c.code()).collect();
        let strs: HashSet<&str> = ErrorCode::ALL.iter().map(|c| c.machine_code()).collect();
        assert_eq!(nums.len(), ErrorCode::ALL.len(), "duplicate numeric code");
        assert_eq!(strs.len(), ErrorCode::ALL.len(), "duplicate machine code");
        assert_eq!(ErrorCode::ALL.len(), 30);
    }

    /// **Proves:** the constructor mints the full `{code, message, data:{code,
    /// origin}}` envelope and `data.code` mirrors the numeric code.
    /// **Catches:** a drift between the numeric code and `data.code`.
    #[test]
    fn envelope_shape_and_data_code() {
        let e = RpcError::new(
            ErrorCode::ResourceUnavailable,
            "not here",
            ErrorOrigin::Node,
        );
        let v = serde_json::to_value(&e).unwrap();
        assert_eq!(v["code"], -32004);
        assert_eq!(v["message"], "not here");
        assert_eq!(v["data"]["code"], "RESOURCE_UNAVAILABLE");
        assert_eq!(v["data"]["origin"], "node");
        // Round-trips.
        let back: RpcError = serde_json::from_value(v).unwrap();
        assert_eq!(back, e);
    }

    /// **Proves:** `UpstreamError` defaults to the `upstream` origin.
    #[test]
    fn upstream_default_origin() {
        let e = RpcError::of(ErrorCode::UpstreamError, "upstream: boom");
        assert_eq!(e.data.origin, ErrorOrigin::Upstream);
        assert_eq!(
            serde_json::to_value(&e).unwrap()["data"]["origin"],
            "upstream"
        );
    }

    /// **Proves:** `code_only` uses the default message + origin.
    #[test]
    fn code_only_defaults() {
        let e = RpcError::code_only(ErrorCode::PeerUnreachable);
        assert_eq!(e.message, "Peer unreachable");
        assert_eq!(e.data.origin, ErrorOrigin::Peer);
    }
}