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
# 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 |
| `-32015` | `METADATA_TOO_LARGE` | node | `dig.getMetadata`: the publisher metadata section renders larger than the bounded response ceiling, or carries more custom entries than the cap allows; the section cannot be paged, so it is refused whole |
| `-32016` | `PUSH_PENDING_LIMITED` | node | `cache.pushCapsule`: this window is refused because accepting it would exceed an in-flight reassembly bound (per-requestor cap, global concurrent-push cap, or global pending-bytes budget); retriable |
| `-32017` | `CONTENT_MISS_INCONCLUSIVE` | peer | absence was NOT established — a hop timed out, was unreachable, or refused uninformatively, so the subtree behind it was never consulted (§2.5) |
| `-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 |
| `-32050` | `NO_IDENTITY` | node | a directed send is refused: this node holds no persistent identity key, so it cannot seal as sender |
| `-32051` | `NO_PEER_NETWORK` | peer | a directed send is refused: no gossip pool, so there is no transport to the recipient |
| `-32052` | `SEND_FAILED` | peer | sealing or sending the directed message failed |

**The canonical space includes consumer-held ranges (normative).** A number is
available for assignment only if it is unoccupied ECOSYSTEM-WIDE. Absence from the
table above does NOT make a number free: consumers hold undeclared bands inside
this same space, and an implementation MUST measure occupancy across every DIG
repository — not against this table alone — before assigning a new code. `-32015`
and `-32016` were released by dig-node and catalogued on docs.dig.net while absent
from this table, and `CONTENT_MISS_INCONCLUSIVE` was consequently assigned `-32015`
and collided with a live wire contract; `-32009` had already been assigned the same
way. Both are now reconciled in favour of the RELEASED meaning: a published code is
a permanent branch key, so the canonical taxonomy adapts and the new code moves.

The bands, so an assignment has somewhere to look:

| Band | Owner |
|---|---|
| `-32000`..`-32019` | node read/serve, staging, metadata, push bounds |
| `-32020`..`-32029` | onion / private retrieval |
| `-32030`..`-32039` | loopback control plane |
| `-32040`..`-32049` | control-plane wallet reads (consumer-held; not yet declared here) |
| `-32050`..`-32059` | directed messaging — sealed sender-to-recipient sends |

`-32050`..`-32052` form their own band rather than joining the control band: they
are served on the node's ORDINARY JSON-RPC surface and reuse the standard `-32602`
for bad params, so they are neither control-plane nor a private application range.
A directed send is a peer-network operation and is banded as one.

A consumer MUST NOT declare a DIG RPC error code locally. A code emitted as a bare
integer is invisible to this taxonomy and to the OpenRPC catalogue, which is how
`-32015` came to be handed out twice.

**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
and does NOT forward when `redirect_depth` has reached `max_redirects`. A node
never redirects to itself.

`redirect_depth` is the number of hops ALREADY CONSUMED, counted UP from zero
— never a remaining allowance counted down. The params types that carry it
(`dig.getContent`, `dig.fetchRange`, `dig.getAvailability`) all carry the SAME
field, under the same key, with the same meaning; an absent value means zero.

### 2.4 The hop budget on `dig.getAvailability`

`dig.getAvailability` answers a miss with the holders it located, in each
answer's `providers` — the same enrichment `-32008` carries. A responder that
cannot answer from what it holds MAY ask its own peers, so one caller's question
can walk several hops.

A node that asks onward on a caller's behalf MUST send `redirect_depth + 1`
(treating an absent value as zero; the increment is saturating at the type maximum
so a received value at or near the maximum cannot reset the budget), and MUST NOT
ask onward when that would reach `max_redirects`. A node MUST NOT decrease the
value it received. `providers` returned by a peer are that peer's CLAIM, never
this node's assertion: they are candidates for the fetch path, where the merkle
bind makes a wrong candidate merely wasted.

### 2.5 The recursive availability ask

A responder MAY answer a miss by asking its own peers (§2.4). Three additional
`params` fields and one additional answer field make that walk terminate, bound its
cost in TIME as well as hops, and keep a failure to look distinguishable from a
finding of absence. All four are OPTIONAL and additive: a request with none of them
present, and an answer omitting `absence_established`, are exactly the pre-0.9 wire.

#### 2.5.1 `params.budget_ms` (`uint`, optional)

The wall-clock time, in milliseconds, this ask may still spend.

`redirect_depth` (§2.4) counts hops UP from zero toward a ceiling; `budget_ms`
counts milliseconds DOWN toward zero. They are separate axes moving in opposite
directions, so they MUST be separate fields — a single integer cannot carry both,
and folding them together would make "one more hop" and "more time" the same
request.

- **Absent means UNBUDGETED**, not zero. A responder receiving no budget applies its
  own policy. This is why the field has no scalar default: zero would refuse every
  older caller's ask, and any positive default would impose one node's patience on
  another node's question.
- **Present and zero means EXHAUSTED.** A responder with a zero budget MUST NOT ask
  onward.
- A responder that asks onward MUST pass a value DECREMENTED by the time it has
  itself already spent, and MUST NOT increase a received value.
- A responder MUST NOT grant a child less time than the work it asks that child to
  do. A responder asking `n` peers SEQUENTIALLY MUST divide its remaining budget
  between them; one asking them CONCURRENTLY MAY grant each the full remainder.
- A responder whose remaining budget is smaller than one round trip MUST answer
  `-32017` `CONTENT_MISS_INCONCLUSIVE` rather than ask onward and time out.

The failure this bounds is measurable rather than hypothetical: a FIXED per-ask
timeout with sequential asks and a fan-out greater than one guarantees the second
hop times out, and a responder that reads its own timeout as a miss reports a
confident not-found for content it never looked for.

#### 2.5.2 `params.ask_id` (32 lowercase hex chars, optional)

An opaque identity for one ask, for cross-path dedup.

A recursive ask walks a graph, not a tree: two disjoint paths can arrive at the same
responder, and without a shared identity a diamond in the peer graph does not
terminate. A responder that has already seen an `ask_id` MUST answer from what it
already knows and MUST NOT ask onward again for it.

- **16 unpredictable random bytes**, hex-encoded lowercase, drawn freshly by the
  ORIGINATOR and copied VERBATIM by every relaying hop. A hop MUST NOT rewrite it.
- It MUST be unpredictable. A guessable value lets an attacker pre-poison a
  responder's dedup memo and thereby suppress an ask that has not yet been made.
- A responder MUST NOT derive anything from its value beyond EQUALITY. It carries no
  structure, no origin, no timestamp and no ordering.
- It is **NOT** the JSON-RPC `id`. That field correlates one request with one
  response on one connection, is chosen per-connection, and is commonly a small
  constant; reusing it for dedup either collides every unrelated ask together or
  dedups nothing.
- Absent means the caller opted OUT of dedup. A responder MAY then apply its own
  loop protection, and `redirect_depth` still bounds the walk.
- Dedup memo state is bounded by the responder and MUST NOT grow without limit; an
  entry MAY be evicted, in which case the responder simply loses the dedup benefit.

#### 2.5.3 `answer.absence_established` (`bool`, optional)

Whether the responder actually ESTABLISHED that nobody it can reach holds the item.
Meaningful only beside `available: false`.

Three distinct states, and ABSENT IS NOT `false`:

| Value | Claim | Client obligation |
|---|---|---|
| `true` | the responder looked, reached everything it meant to reach, and asserts absence | MAY stop searching |
| `false` | the responder looked and could NOT establish absence | MUST keep looking |
| absent | the responder predates this field and makes NO claim | MUST NOT infer either |

A client MUST NOT default an absent value. Defaulting it to `true` turns an unknown
into an assertion of absence; defaulting it to `false` reports a definite absence as
permanently uncertain. `false` is a responder telling you its search was incomplete;
absent is a responder that cannot describe its search at all.

A responder MUST serialize the unknown state by OMITTING the key, never as `null`
and never as `false`.

#### 2.5.4 `-32017` `CONTENT_MISS_INCONCLUSIVE`

The out-of-band form of `absence_established: false`, for a call that could not
answer at all. `absence_established` carries the same fact PER ITEM, for a batch in
which only some items were inconclusive and the call itself therefore succeeded.

A client MUST NOT treat `-32017` as absence, and MUST NOT treat it as holder-fatal:
the holder stays eligible for a later ask. This is precisely why it is NOT `-32009`
`RANGE_METADATA_UNREPRESENTABLE`, which is holder-FATAL — the two demand opposite
behaviour on both axes, so a shared number would make a client either permanently
blacklist a merely-uncertain holder or keep re-asking one that can never serve.

---

## 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], redirect_depth?, budget_ms?, ask_id? }``{ items[] }`
  (each answer: `{ available, roots?, total_length?, chunk_count?, complete?, providers?,
  absence_established? }`). See §2.5 for the recursive-ask fields.
- **`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`, `FetchRangeParams` and `GetAvailabilityParams` are
  `#[non_exhaustive]`: they are built with `RangeFrame::data` /
  `FetchRangeParams::resource` / `GetAvailabilityParams::new` and the `with_*`
  setters, never a struct literal, so a future additive field on any of them 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).