dig-rpc-protocol 0.7.0

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
# dig-rpc-protocol — normative specification

**Status:** normative. This is the authoritative contract for the DIG-node
JSON-RPC interface. An independent reimplementation of a DIG node's RPC surface
MUST match the shapes, codes, names, and tiers defined here byte-for-byte. It
cross-references [`SYSTEM.md`](../../../SYSTEM.md) (cross-repo interaction map)
and the docs.dig.net protocol pages (the published, user-facing protocol spec);
the three MUST agree.

`dig-rpc-protocol` is the single source of truth both DIG node implementations —
the digstore `dig-node` crate and the standalone `dig-node` binary — depend on.
It defines types only: no I/O, no async, no server logic, no crypto.

---

## 1. JSON-RPC 2.0 envelope

The wire is strict [JSON-RPC 2.0](https://www.jsonrpc.org/specification).

### 1.1 Request

```json
{ "jsonrpc": "2.0", "id": <id>, "method": <string>, "params": <object|absent> }
```

- `jsonrpc` MUST be the literal string `"2.0"`. Any other value is rejected on
  decode.
- `id` is numeric, string, or `null` (notifications; DIG RPC does not use them).
  A response MUST echo the request `id` unchanged.
- `params` MAY be absent for methods that take none.

### 1.2 Response

```json
{ "jsonrpc": "2.0", "id": <id>, "result": <value> }
{ "jsonrpc": "2.0", "id": <id>, "error": <RpcError> }
```

Exactly one of `result` or `error` is present — never both, never neither. For
any well-formed request body the HTTP status is `200`; the error is carried in
the JSON envelope.

---

## 2. Error taxonomy

### 2.1 The error object

Every error is the uniform envelope:

```json
{
  "code": <int>,
  "message": <string>,
  "data": { "code": <UPPER_SNAKE_CASE>, "origin": <origin>, "redirect"?: <RedirectInfo>, ...extra }
}
```

- `code` is the numeric wire code (§2.2). It is a **published contract** and
  never changes once assigned.
- `data.code` is the stable `UPPER_SNAKE_CASE` machine identifier; it mirrors
  `code` one-to-one and is the branch key an agent keys on.
- `data.origin` is one of `node`, `peer`, `upstream`, `onion`, `control` — the
  subsystem the failure arose in.
- `data.redirect` is present ONLY on `-32008` (§2.3).
- Additional method-specific fields MAY be flattened onto `data`.

There is ONE constructor helper (`RpcError::new` / `of` / `code_only`); every
error, whether minted on a node's read path or a control gate, carries
`data.code` + `data.origin`.

### 2.2 Code set

| Code | Machine code | Origin | Meaning |
|---|---|---|---|
| `-32700` | `PARSE_ERROR` | node | request body is not valid JSON |
| `-32600` | `INVALID_REQUEST` | node | not a valid Request object |
| `-32601` | `METHOD_NOT_FOUND` | node | method not implemented, OR a non-allowlisted method called on the peer surface |
| `-32602` | `INVALID_PARAMS` | node | missing/malformed params |
| `-32603` | `INTERNAL_ERROR` | node | well-formed call failed (network profile) |
| `-32000` | `SERVER_ERROR` | node | generic server error (config write, file I/O, chain read) |
| `-32003` | `CONTENT_MISS_RATE_LIMITED` | node | content not held; miss-lookup budget exhausted for this requestor |
| `-32004` | `RESOURCE_UNAVAILABLE` | node | resource not available at the requested root (genuine infra miss; distinct from a content miss, which is a decoy and never an error) |
| `-32005` | `ROOT_NOT_ANCHORED` | node | requested/served root ≠ chain-anchored root, chain unreachable, or no confirmed generation (read-path pin failing closed) |
| `-32006` | `PEER_UNREACHABLE` | peer | no NAT-traversal strategy reached the named peer |
| `-32007` | `RANGE_NOT_SATISFIABLE` | node | `offset ≥ total_length` or the range is otherwise unsatisfiable |
| `-32008` | `CONTENT_REDIRECT` | node | content held elsewhere — `data.redirect` names the holders (§2.3) |
| `-32009` | `RANGE_METADATA_UNREPRESENTABLE` | node | the resource's own range metadata cannot fit a conforming frame, so this holder can NEVER serve the range (§6.1) |
| `-32010` | `UPSTREAM_ERROR` | upstream | an upstream/proxy fetch failed |
| `-32011` | `STAGE_INVALID_INPUT` | node | `dig.stage`: dir unreadable / walk budget exceeded |
| `-32012` | `STAGE_NO_FILES` | node | `dig.stage`: no files to compile |
| `-32013` | `STAGE_OVER_CAP` | node | `dig.stage`: input exceeds the store cap |
| `-32014` | `STAGE_COMPILE_FAILED` | node | `dig.stage`: compile / IO failure |
| `-32020` | `ONION_CIRCUIT_UNAVAILABLE` | onion | a `mode:"privacy"` read could not be served privately (fails closed) |
| `-32021` | `PRIVACY_REQUIRES_LOCAL_NODE` | onion | privacy mode requires the caller be a local originator |
| `-32022` | `ONION_HOPS_OUT_OF_RANGE` | onion | requested hop count outside `[2, 5]` |
| `-32030` | `UNAUTHORIZED` | control | control-plane call not authorized |
| `-32031` | `NOT_SUPPORTED` | control | control-plane method not supported here |
| `-32032` | `CONTROL_ERROR` | control | control-plane runtime error |

**Collision resolution (normative):** the onion codes `-32020/-32021/-32022`
are the published normative contract (docs.dig.net) and KEEP their numbers. The
control-plane errors — previously squatting those values — are renumbered to
`-32030/-32031/-32032`. This renumbering MUST land before the onion feature
ships.

`-32010 UPSTREAM_ERROR` is the dedicated code for an upstream/proxy fetch
failure. A node MAY currently fold upstream faults into `-32000`; new writers
SHOULD emit `-32010` so a client can distinguish an upstream fault from a local
one.

### 2.3 The redirect payload (`-32008`)

`error.data.redirect`:

```json
{
  "content":   { "store_id": 64hex, "root"?: 64hex, "retrieval_key"?: 64hex },
  "providers": [ { "peer_id": 64hex, "addresses": [ { "host", "port", "kind" } ] } ],
  "redirect_depth": <uint>,
  "max_redirects":  <uint = 4>
}
```

The caller re-requests the same `content` against a peer in `providers`, echoing
`redirect_depth` in its `params` so the hop budget stays monotone; it stops when
`redirect_depth` reaches `max_redirects`. A node never redirects to itself.

---

## 3. Access tiers

Every method has one PRIMARY tier:

- **`public-read`** — anonymous, self-verified content + discovery reads over
  plain HTTPS (browser) or mTLS.
- **`peer`** — the mTLS peer surface between DIG nodes; `peer_id = SHA-256(TLS
  SPKI DER)`.
- **`control`** — loopback / in-process only; NEVER reachable over the peer
  surface.

### 3.1 The peer-surface allowlist

The peer surface is an **allowlist**, not a denylist. The peer client-cert
verifier authenticates a `peer_id`; it does NOT authorize. A method not on the
allowlist answers `-32601` over the peer surface. The allowlist is exactly:

```
dig.getContent  dig.getNetworkInfo  dig.getPeers  dig.announce
dig.getAvailability  dig.listInventory  dig.fetchRange
dig.getModuleInfo  dig.fetchModuleRange
dig.getAnchoredRoot  dig.getCollection  dig.listCollectionItems
```

The three chain-anchored reads (`getAnchoredRoot`, `getCollection`,
`listCollectionItems`) are PRIMARY `public-read` yet ALSO peer-reachable.
Management/mutation methods (`cache.*`, `control.*`, `dig.stage`) are NEVER
peer-reachable.

---

## 4. Method catalogue

Names are stable. Params/results are defined field-for-field in the crate's
`types` module; the following is the authoritative summary. Optional fields are
marked `?`.

### 4.1 public-read

- **`dig.getContent`** `{ store_id, retrieval_key, root?, offset?, mode?, redirect_depth? }`
  → a [content chunk]#5-the-content-chunk-object.
- **`dig.getCapsule`** (alias **`dig.getModule`**), **`dig.getManifest`**,
  **`dig.getMetadata`**, **`dig.listCapsules`**, **`dig.getProof`**,
  **`dig.getProofStatus`** — the read/discovery subset (see docs.dig.net dig-rpc
  spec; the network profile carries the extra chunk fields in §5).
- **`dig.getAnchoredRoot`** `{ store_id }``{ store_id, root }`.
- **`dig.getCollection`** `{ launcher_ids[≤10000], did? }`  `{ did?, declared_did?, item_count, resolved_count, royalty_basis_points? }`.
- **`dig.listCollectionItems`** `{ launcher_ids[≤10000], offset?, limit?≤200 }`  `{ items[], offset, limit, total, next_offset? }`.
- **`dig.health`**`{ status, version?, network_id?, methods[] }`.
- **`dig.methods`**`{ methods[] }`.

### 4.2 peer

- **`dig.getNetworkInfo`**`{ peer_id?, network_id, listen_addr, reflexive_addr?, candidate_addresses[], reachability, relay:{url,reserved} }`.
- **`dig.getPeers`**`{ peers[] }` (each a `{ peer_id, addresses[] }`).
- **`dig.announce`** `{ peer_id, addresses[] }``{ accepted, known_peers }`.
- **`dig.getAvailability`** `{ items[≤512] }``{ items[] }` (each answer:
  `{ available, roots?, total_length?, chunk_count?, complete?, providers? }`).
- **`dig.listInventory`** `{ store_id?, limit? }`  `{ store_id, roots[] }` (with `store_id`) OR `{ stores[] }` (without).
- **`dig.fetchRange`** `{ store_id, root, retrieval_key, offset?, length, capsule?, redirect_depth?, skip_layout? }`
  → a [range frame]#6-the-range-frame. Capsule mode is not yet served
  (`-32004`).
- **`dig.getModuleInfo`** `{ store_id, root }` → a
  [module-info descriptor]#61-whole-module-pull-getmoduleinfo--fetchmodulerange
  `{ total_size, module_hash, chunk_hashes[] }`.
- **`dig.fetchModuleRange`** `{ store_id, root, offset?, length }` → a
  [range frame]#6-the-range-frame whose `bytes` carry a window of the whole
  `.dig` module blob (§6.1).

### 4.3 control (loopback / in-process only)

- **`dig.stage`** `{ dir, store_id?, salt?, metadata? }`  `{ capsule, store_id, root, module_path, size, content_address?, files[], ephemeral? }`.
- **`cache.getConfig`**`{ cap_bytes, used_bytes, cache_dir, shared }`.
- **`cache.setCapBytes`** `{ cap_bytes }``{ cap_bytes }` (floored at 64 MiB).
- **`cache.clear`**`{}`.
- **`cache.listCached`**`{ cached: [ { capsule, store_id, root, size_bytes, last_used_unix_ms } ] }`.
- **`cache.removeCached`** `{ store_id, root }``{ removed }`.
- **`cache.fetchAndCache`** `{ store_id, root }`  `{ status: "cached"|"already_cached"|"failed", size_bytes?, served_root?, message? }`.
- **`cache.stats`**  `{ cap_bytes, used_bytes, entry_count, total_bytes, evicted_count, evicted_bytes, content_cache:{ hits, misses } }`.
- **`control.peerStatus`**`{ running, peer_id?, network_id, relay:{url,reserved}, connected_peers, last_error? }`.
- **`control.subscribe`** `{ store_id }``{ subscribed: true, added, store_id }` — subscribe the node
  to a store (persisted watch + gap-fill); `store_id` echoes the canonical trimmed/lower-cased id.
- **`control.unsubscribe`** `{ store_id }``{ subscribed: false, removed, store_id }`.
- **`control.listSubscriptions`**`{ subscriptions: [store_id], count }`.
- **`control.peers.connect`** `{ peer }``{ connected: true, peer_id }` — dial a peer (address, or a
  known `peer_id`) into the connected pool.
- **`control.peers.disconnect`** `{ peer }``{ disconnected: true, peer_id }` (idempotent no-op if not
  connected).
- **`rpc.discover`** → the OpenRPC 1.2.6 document (§7).

The canonical cache-path field name is `cache_dir` everywhere.

---

## 5. The content-chunk object

One type serves both profiles. `dig.getContent` (node profile) populates:

```
ciphertext (b64), root (64hex), complete (bool),
next_offset? (uint, iff not complete),
inclusion_proof? (b64, first window only), chunk_lens? (uint[], first window only),
source? ("local"|"remote" — node profile additive tag)
```

The **network profile** (`rpc.dig.net`) additionally populates
`total_length`, `length`, `offset`, and `program_hash`. Those four fields are
network-profile-only; the node profile omits them.

`inclusion_proof` and `chunk_lens` appear on the FIRST window only (`offset == 0`).

---

## 6. The range frame

`dig.fetchRange` returns one frame:

```
offset (uint), length (uint), bytes (b64), complete (bool),
root? (64-hex), total_length? (uint), chunk_count? (uint),
chunk_index? (uint), first_chunk_index? (uint),
chunk_lens? (uint[]), chunk_lens_offset? (uint), inclusion_proof? (b64)
```

The remaining metadata splits in two by whether it scales with the resource, and
the split decides which frames MUST carry it.

**The identity set rides EVERY frame:** `root`, `total_length`, `chunk_count`,
plus `chunk_index`/`first_chunk_index` when the window begins exactly on a chunk
boundary. These are fixed-size, so carrying them everywhere costs a bounded
number of bytes, and they are what let a client fetching ranges in parallel from
many holders reject a wrong-generation or wrong-layout source the moment a frame
arrives — a client cannot check a later frame that declares no `root`, so
otherwise a bad source is detectable only after the whole resource has been paid
for in bandwidth. A frame whose window does not begin exactly on a chunk boundary
MUST omit `chunk_index`/`first_chunk_index` rather than assert an index the
caller's own alignment check would contradict.

**The resource-scaling set rides the first frame or a paged prologue, once per
range stream:** `chunk_lens` and `inclusion_proof`. A server MUST NOT repeat them
on continuation frames — they grow with the resource, and repeating them would
consume the frame budget the payload needs.

**Paged prologue.** A layout too large to state on one frame is PAGED: successive
frames each carry a slice of `chunk_lens`, stamped with the `chunk_lens_offset`
entry that slice begins at. A reader places each page at its offset and holds the
complete array once it has `chunk_count` entries — which is why `chunk_count`
rides every frame, since no single page can say how many entries the whole array
has. An absent `chunk_lens_offset` means "this frame's `chunk_lens`, if any,
begins at entry 0" — the single-frame layout every pre-0.6.0 producer emits, so
an older frame decodes with exactly its original meaning (§5.1).

`chunk_lens` is a DECRYPT input: per-chunk AEAD needs the WHOLE array, and a
reader MUST reject an array whose entries do not sum to `total_length`. A partial
prologue is therefore never usable — a reader either assembles all `chunk_count`
entries or fails the stream closed.

**Suppression (`skip_layout`).** A client that already holds the commitment for
this `root` — a resumed download, a second range of the same resource, a parallel
fetch from another holder — SHOULD set `skip_layout: true` on `dig.fetchRange`, and
a holder honouring it MUST omit the resource-scaling set entirely for that stream.
Without it every one of those streams re-pays the whole paged prologue: a
1,048,576-chunk layout costs roughly 7.3 MB, which a 64-way parallel plan pays 64
times over, so suppression is the difference between a bounded and an unbounded
read-path cost.

The identity set is **NOT** suppressed. It is what detects a wrong-generation
holder on arrival, and suppressing it would remove that check from precisely the
streams a client issues most.

`skip_layout` ABSENT and `skip_layout: false` are EQUIVALENT in meaning — both
request the layout — and preserve the pre-0.6.0 behaviour exactly, so a holder that
does not understand the field is never broken by it: it simply sends metadata the
client discards. They are nevertheless DISTINCT on the wire (absent is omitted;
`false` is emitted), so a holder MUST read the flag as "suppress only on an explicit
`true`". A holder that treated the key's PRESENCE as suppression would starve a
client that had explicitly asked for the layout, unrecoverably — the layout is a
decrypt input obtainable no other way on that stream.

**When metadata cannot be represented at all.** A resource whose `chunk_lens`
layout or `inclusion_proof` exceeds the per-frame sender bounds has no conforming
first frame, even paged. A holder MUST then answer `-32009
RANGE_METADATA_UNREPRESENTABLE` rather than stream frames a reader cannot verify.
This is a permanent property of the resource, not a transient condition, so it
MUST NOT be reported as a generic server or transport error: a client that cannot
distinguish the two would retry a holder that can never succeed.

`chunk_count` and `chunk_lens_offset` on the frame, and `skip_layout` on the
request, are OPTIONAL and were added in 0.6.0. All three are additive (§5.1): an
older reader ignores them, and a newer reader parses an older message with each
absent. `chunk_count`/`chunk_lens_offset` are **byte-identical** to
`dig_nat::mux::RangeFrame`, and `skip_layout` to
`dig_nat::mux::RangeRequest::skip_layout` (`SYSTEM.md` → "Canonical
DIG-node RPC interface"); the field names, encodings, and the population rule
above are pinned against dig-nat's own emitted bytes by
`tests/nat_wire_mirror.rs`.

### 6.1 The served window, and `range_proof` (RESERVED)

**The served window is EXACTLY the requested `[offset, length)` span.** A server
MUST NOT widen it — not to a chunk boundary, not for any other reason. A
verifying client plans its ranges and fails a frame closed on any length but the
one it planned, so a widened window is a rejected frame, not a helpful one.

```
range_proof? (b64[], RESERVED — see below),
first_chunk_index? (uint, absolute index of this frame's first chunk)
```

`range_proof` is **RESERVED and NOT currently derivable.** The generation root's
merkle leaves are per-RESOURCE — a leaf is the SHA-256 of a resource's WHOLE
ciphertext — so a single chunk has no leaf to prove and no per-chunk proof
exists to send. A server therefore MUST NOT emit `range_proof`, and a client MUST
NOT require it. Making it derivable needs a per-resource chunk-level commitment in
the store format first (tracked as `dig_ecosystem#1601`); the field stays in the
wire type, unused, so adding that commitment later is additive (§5.1).

Per-range verification today uses the whole-resource `inclusion_proof` plus the
per-frame `root`/`chunk_lens`/`total_length` metadata above.

**`root` is not a trust anchor by itself.** A client resolves the resource's root
from the URN (chain-anchored) and PINS it before fetching — the peer's declared
`root` never replaces that pinned value. What a per-frame `root` does provide is a
generation-CONSISTENCY check: a frame declaring a root other than the pinned one
is REJECTED and attributed to the offending peer (NC-9 fail-closed). So a
peer-declared `root` can only ever cause rejection; it can never move the pinned
root, and never makes an unverified frame acceptable.

These fields are OPTIONAL and were added in 0.4.0. Per §5.1 (additive-only), a
pre-0.4.0 frame carrying neither field decodes byte-identically — new readers
accept every older frame, and a frame without the fields serialises exactly as
before.

### 6.2 Whole-module pull (`getModuleInfo` / `fetchModuleRange`)

The whole-`.dig`-module pull (added in 0.5.0) transfers the COMPLETE, immutable,
content-addressed module blob for `(store, root)` — the reshare leg: a puller
that assembles + verifies the module can itself become a discoverable holder. It
delivers the whole-module transfer over the SAME ranged-fetch mechanism as
`dig.fetchRange`, so multi-source pull, resume, per-source attribution, and
DoS/permit bounding come for free — without reconstructing the container
client-side (which would risk byte-drift from the on-chain-anchored `.dig`).

**`dig.getModuleInfo`** `{ store_id, root }` — the handshake, returning the
transfer descriptor:

```
total_size   (uint)     total byte length of the whole .dig module blob
module_hash  (64hex)    SHA-256 content id of the fully-assembled blob
chunk_hashes (64hex[])  per-chunk content hashes, ascending; cover the blob in
                        fixed-size chunks (the trailing chunk may be short)
chunk_lens   (uint[])   per-chunk byte lengths, same order as chunk_hashes;
                        MUST have same length as chunk_hashes and MUST sum to
                        total_size. Enables a puller to map a fetched byte range
                        to its covering chunk hash(es) for per-source attribution.
```

**`dig.fetchModuleRange`** `{ store_id, root, offset?, length }` — one window of
the module blob, returned as a [range frame](#6-the-range-frame): `bytes` carries
the base64 window of the module blob, `total_length` echoes `total_size` on the
first frame (`offset == 0`), and `complete` ends the stream. `offset` defaults to
0 (start of blob).

**Verification (fail-closed, NC-9).** A puller checks each pulled range against
the covering `chunk_hashes` entries (per-source attribution on a multi-source
pull — a tampered range fails closed BEFORE assembly), checks the fully-assembled
blob against `module_hash`, then verifies the assembled module against its
chain-anchored root before admitting the module + announcing itself as a holder.
The descriptor hashes are integrity/attribution aids, NOT the trust root — the
chain-anchored root is.

These two methods and the `ModuleInfo` type are ADDITIVE (§5.1): they add no
fields to existing types and reuse `RangeFrame` unchanged, so every pre-0.5.0
frame decodes byte-identically.

---

## 7. OpenRPC discovery

`rpc.discover` returns an [OpenRPC 1.2.6](https://spec.open-rpc.org/) document
GENERATED from this crate's method / tier / error tables, so discovery can never
drift from the contract. Each method carries the `x-dig-tier` and
`x-dig-peer-reachable` extensions; `components.x-dig-errors` lists every code
with its numeric + machine forms; `x-dig-peer-allowlist` lists the peer-surface
methods. `dig.health` / `dig.methods` are thin summaries over the same table.

---

## 8. Shape-dispatched peer frames

The DHT and PEX wires travel over the mTLS peer transport but are dispatched on
a `type` discriminator, not a JSON-RPC `method` field.

- **DHT** (`find_node`, `find_providers`, `add_provider`, `ping`) — Kademlia
  content location; requests carry `type` + `node_id` (+ `target_id` /
  `content_key` / `provider`); responses carry `closer[]` / `providers[]` /
  `stored` / `node_id`.
- **PEX** (`pex_handshake`, `pex_snapshot`, `pex_delta`, `pex_error`) — peer
  exchange; snapshot ≤ 200 peers, delta ≤ 50 added / ≤ 50 removed.

---

## 9. Conformance

The crate ships conformance vectors (`tests/conformance.rs`) taken field-for-field
from the canonical node's emitted JSON, plus `tests/nat_wire_mirror.rs`, which
pins the `RangeFrame` wire form against the literal bytes `dig_nat::mux::RangeFrame`
emits — the byte-identity half of the contract, which a round-trip through a single
type cannot see. Both node implementations test against
these vectors; a change that breaks a vector is a wire-breaking change and MUST
bump [`INTERFACE_VERSION`](src/lib.rs).

## 10. Stability

- `ErrorCode` and `Method` are `#[non_exhaustive]`; adding a code/method is
  additive.
- `RangeFrame` and `FetchRangeParams` are `#[non_exhaustive]`: they are built with
  `RangeFrame::data` / `FetchRangeParams::resource` and the `with_*` setters, never a
  struct literal, so a future additive field on either is a PATCH for every consumer
  rather than a semver cascade.
- Numeric codes, machine codes, and method names never change once assigned.
- New optional fields are additive; removing/reshaping a field is a wire break
  (major bump).