dig-rpc-protocol 0.13.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
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
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
# 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).
- **`dig.getPayeeRewardClaimStatus`**`{ subject: "payee", claim_log, claim_loop }` — the answering
  node's own claim-side posture as a payee: what its claim **log** holds, and what its claim **loop**
  is doing. `subject` is a required literal: the party an answer is about is stated on the wire, never
  inferred from which endpoint a caller believes it hit. `claim_log` is either
  `{ outcome: "consulted", observed_at, claims_submitted_count }` or
  `{ outcome: "not_consulted", observed_at }`; `claim_loop` is either
  `{ outcome: "consulted", observed_at, distributors_known, distributors_claimable, state }` or
  `{ outcome: "not_consulted", observed_at }`, where `state` is one of the seven closed
  `{ kind: "idle" | "chain_source_unavailable" | "persisted_state_corrupt" | "cadence_not_elapsed" |
  "nominal" }`, `{ kind: "faulted", cycles }` or `{ kind: "claimable_but_not_claiming", claimable,
  submitted }`. Every count lives **inside** a `consulted` arm, so a node that could not read its log
  or its loop has no field to put a zero in: "I looked and found none" and "I never looked" are
  different payloads, each dated, and neither is a bare or a freshly dated zero. The payload carries
  **no monetary amount and no payout puzzle hash**, and a conforming responder MUST NOT add either.
  The full object, its units, the seven states and the anti-silence rule are §4.4.
- **`rpc.discover`** → the OpenRPC 1.2.6 document (§7).

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

### 4.4 The payee claim-status object (`dig.getPayeeRewardClaimStatus`)

Rust type `types::PayeeClaimStatus`, built from `types::PayeeSubject`, `types::ClaimLogObservation`,
`types::ClaimLoopObservation` and `types::ClaimLoopState`. This section is the whole contract for the
object; §4.3's catalogue line is its summary. This is the wire shape from **0.13.0**; the reshape's
admissibility is §10.1.

#### 4.4.1 Shape

A complete answer, every field present:

```json
{
  "subject": "payee",
  "claim_log": {
    "outcome": "consulted",
    "observed_at": 1758130000,
    "claims_submitted_count": 3
  },
  "claim_loop": {
    "outcome": "consulted",
    "observed_at": 1758129900,
    "distributors_known": 4,
    "distributors_claimable": 2,
    "state": { "kind": "claimable_but_not_claiming", "claimable": 2, "submitted": 0 }
  }
}
```

The arms and their exact key sets:

| Object | Arm | Keys (all REQUIRED) |
|---|---|---|
| `PayeeClaimStatus` || `subject`, `claim_log`, `claim_loop` |
| `claim_log` | `outcome: "consulted"` | `outcome`, `observed_at`, `claims_submitted_count` |
| `claim_log` | `outcome: "not_consulted"` | `outcome`, `observed_at` |
| `claim_loop` | `outcome: "consulted"` | `outcome`, `observed_at`, `distributors_known`, `distributors_claimable`, `state` |
| `claim_loop` | `outcome: "not_consulted"` | `outcome`, `observed_at` |
| `state` | `kind: "idle"` / `"chain_source_unavailable"` / `"persisted_state_corrupt"` / `"cadence_not_elapsed"` / `"nominal"` | `kind` |
| `state` | `kind: "faulted"` | `kind`, `cycles` |
| `state` | `kind: "claimable_but_not_claiming"` | `kind`, `claimable`, `submitted` |

Rules that bind both directions:

- **Every key is required; nothing defaults, nothing is skipped.** No field of any of these types
  carries `#[serde(default)]` or `skip_serializing_if`, none derives `Default`, and no enum has a
  `#[serde(other)]` catch-all. A producer cannot omit a field and have a consumer invent it; a
  missing key is a parse error.
- **Fail-closed on unknown keys.** `PayeeClaimStatus`, `ClaimLogObservation`, `ClaimLoopObservation`
  and `ClaimLoopState` all carry `deny_unknown_fields`. An unknown key beside `subject`, beside a
  `consulted`/`not_consulted` arm, or beside a `faulted`/`claimable_but_not_claiming` payload is a
  parse error. In particular `{ "outcome": "not_consulted", "observed_at": …, "distributors_known": 0 }`
  and `{ "outcome": "not_consulted", "observed_at": …, "state": … }` do not parse: a count or a
  verdict cannot ride an arm that says nothing was read.
- **One stated vacuity.** serde does not apply `deny_unknown_fields` to the *payload-less* members
  of an internally tagged enum, so `{ "kind": "idle", "claimable": 5 }` parses as `idle` with the
  stray key dropped. A conforming producer emits exactly `{ "kind": … }` for the five payload-less
  states, and a consumer MUST NOT read any key but `kind` from them. The property this crate does
  enforce is the one that carries weight: the counts a reader acts on (`distributors_known`,
  `distributors_claimable`) live at the observation level, where unknown and missing keys ARE
  rejected.
- **Tags are strings, states are objects.** `outcome` and `kind` are snake_case string tags.
  `state` is always an object, even for a payload-less kind: `"state": "idle"` is a parse error.
  `subject` is the literal `"payee"` and nothing else parses (§4.3).
- **An unknown `kind` is a parse error**, never a coerced `idle`. A consumer that meets one is
  talking to a node on a later contract and MUST fail the read, not render a default.

The serde spelling this shape requires, for an independent reimplementation: `PayeeClaimStatus` is
a plain struct with `#[serde(deny_unknown_fields)]`; `ClaimLoopObservation` is
`#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]` with arms
`Consulted { observed_at, distributors_known, distributors_claimable, state }` and
`NotConsulted { observed_at }`; `ClaimLoopState` is
`#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]` with the seven members of
§4.4.3. The tag key is `kind`, not `state`, because the field holding the object is already named
`state`: `"state": { "state": … }` would put two meanings under one key in adjacent positions.

#### 4.4.2 Units and meanings

- `observed_at` — Unix seconds, in every arm. It dates the **consultation**, never the assembly of
  the answer.
  - In `claim_log.consulted` it is when the log was read; in `claim_log.not_consulted`, when the
    responder established it could not read it (unchanged from 0.12.0).
  - In `claim_loop.consulted` it is when the claim loop **last wrote** the status this arm reports —
    stamped by the loop in the cycle that produced these numbers, or, before any cycle, when the loop
    published its initial `idle`. A responder MUST NOT stamp it with the clock of the RPC handler
    that read the status. This is the clause that makes a dead loop visible: a loop that ran cycles
    and then hung or died leaves its last `state` behind, and only an `observed_at` that stops
    advancing tells a reader that `nominal` is the loop's *last* word rather than its *current*
    one. Re-dating the snapshot at read time manufactures freshness for a number nobody produced
    just now — the reassuring zero with a timestamp on it, one layer up.
  - In `claim_loop.not_consulted` it is when the responder established that the loop's status could
    not be read: the loop is not constructed (claiming disabled or not started) or its status was
    unreadable. It is **not** "the chain is unreachable" (that is a `state`) and **not** "no
    distributors" (that is a count).
  - Staleness is the reader's to derive from `observed_at` and its own clock (§2.4's rule); nothing
    here pre-computes it.
- `distributors_known` — how many reward distributors this node's claim loop has discovered as
  candidates it might hold an entry in. A **count of distributors**, never an amount.
- `distributors_claimable` — how many of those the loop judged claimable in the cycle dated by
  `observed_at`: an own entry over the distributor's payout threshold at a fee the loop will pay, by
  the loop's own rule. A **count of distributors**, never an amount, and a per-cycle reading, not a
  lifetime total.
- `claims_submitted_count` — a lifetime count of claim *attempts* this node has made, not of money
  and not of successes (unchanged from 0.12.0).
- `state.cycles` (in `faulted`) — the number of consecutive cycles, **including the current one**,
  on which the loop has observed a cycle-wide fault. From a conforming producer it is ≥ 1; the type
  does not reject `0`.
- `state.claimable` and `state.submitted` (in `claimable_but_not_claiming`) — this cycle's
  shortfall: `submitted` distributors were claimed against, of `claimable` that should have been.
  Both are **counts of distributors**. From a conforming producer `submitted < claimable`; the type
  does not reject the contrary, and a reader that meets it MUST still take the state as the verdict.
  `state.claimable` is **at least** `distributors_claimable` and MAY exceed it: the producer folds
  refusals it never counted as claimable (a distributor whose entry named another payout hash) into
  the shortfall it reports. The predicate and the fold are owned by the producer —
  `ClaimStatus::compute_state` in dig-node's `dig-node-service` crate — and are not restated here. A
  reader MUST NOT conclude `state.claimable == distributors_claimable`, and MUST NOT rebuild the
  state from the observation-level counts.
- All five counts are `u64` on the wire. The producer's own counters are narrower; it widens them
  losslessly. None is money, none is a success count, and none is denominated in $DIG or mojos.

#### 4.4.3 The seven states — a closed set

`ClaimLoopState` mirrors the producer's own enumeration one-to-one, payloads included. A lossy
mirror — a wire type with fewer states than the loop can be in — is a new instance of the defect
this method exists to remove: a type that cannot say something went wrong.

| `kind` | Payload | Meaning |
|---|---|---|
| `idle` || No cycle has been attempted yet. |
| `chain_source_unavailable` || The chain seam reported itself unavailable; no cycle ran. The true state, not a silent no-op. |
| `persisted_state_corrupt` || The loop's persisted claim state was unreadable, unparsable, over its own budget or future-dated; the loop treats its spend window as exhausted and submits nothing this cycle. A refusal made visible, not a freeze that reads as `nominal`. |
| `cadence_not_elapsed` || The claim cadence has not elapsed since the last completed cycle: a deliberate skip, named as its own condition rather than left as the absence of one. |
| `faulted` | `cycles` | A chain call this cycle failed with a real error, distinct from "no chain at all"; `cycles` says for how many consecutive cycles, so a blip and a wedged loop read differently. |
| `claimable_but_not_claiming` | `claimable`, `submitted` | Fewer distributors were claimed against this cycle than should have been, and no fault is live. **The anti-silence signal**: the loop is running and paying nobody, or not everybody. |
| `nominal` || A cycle completed and none of the above is true. |

The set is pinned to exactly these seven members, the way `types::ProverState` is pinned to
dig-rewards-coin SPEC §2.3's nine: adding a member is a SPEC amendment and a breaking release for
every consumer, on purpose. Consumers MUST match the set exhaustively — the Rust type is
deliberately **not** `#[non_exhaustive]`, because a wildcard arm in a consumer is `#[serde(other)]`
relocated to the render path: it decides, at compile time, that a state nobody has heard of renders
as something bland. Fail closed instead: a new state is a compile error in every consumer until that
consumer says what it means.

Precedence among the states is the producer's to compute, not a consumer's: the wire carries one
`kind`, and it is the one the loop chose. A reader MAY NOT reorder them.

#### 4.4.4 The anti-silence rule

The whole point of `claim_loop` is that **"loop running, zero claims, nothing wrong reported" is
unrepresentable**. Normatively:

1. Whenever, in the cycle dated by `observed_at`, claims existed that the loop did not make —
   `submitted < claimable` by the producer's predicate — and no cycle-wide fault is live, the
   responder MUST report `state.kind = "claimable_but_not_claiming"` with both numbers. It MUST NOT
   report `nominal`, and MUST NOT report `idle`.
2. "Nothing to do" is `nominal` (or `idle` before any cycle) **together with**
   `distributors_claimable = 0` (or `distributors_known = 0`). `nominal` with
   `distributors_claimable > 0` is legal and means every claimable distributor was claimed against
   this cycle; a reader MUST NOT read a positive `distributors_claimable` as "unclaimed".
3. A cycle-wide fault MUST be reported as `faulted` (or `chain_source_unavailable`), never absorbed
   into `nominal` for lack of anywhere else to go, and never into `claimable_but_not_claiming`,
   whose meaning is "no fault, still not claiming".
4. **The state is the verdict; the counts are the facts.** A consumer MUST render `state.kind` (and
   its payload) as the loop's condition. It MAY show `distributors_known` and
   `distributors_claimable` beside it so an operator can check the verdict against the numbers. It
   MUST NOT compute its own verdict from the counts and show that in place of the state — the
   producer's predicate folds in facts the counts do not carry (§4.4.2).
5. A loop that was never read has no counts and no state: that is `claim_loop.not_consulted`, and
   it is a different, dated payload from `consulted` + `idle` + zeros.

How the readings differ, for a consumer that reads only the JSON:

| Reading | `claim_loop.outcome` | `state.kind` | `distributors_known` | `distributors_claimable` |
|---|---|---|---|---|
| The loop's status could not be read | `not_consulted` | absent | absent | absent |
| Loop constructed, no cycle yet | `consulted` | `idle` | `0` | `0` |
| No chain | `consulted` | `chain_source_unavailable` | last reading | last reading |
| Refusing to spend on corrupt state | `consulted` | `persisted_state_corrupt` | last reading | last reading |
| Deliberate skip inside the cadence | `consulted` | `cadence_not_elapsed` | last reading | last reading |
| Broken for `cycles` cycles | `consulted` | `faulted` | last reading | last reading |
| Running, not paying everybody | `consulted` | `claimable_but_not_claiming` | `n` | `c` (≤ `state.claimable`) |
| Healthy, nothing claimable | `consulted` | `nominal` | `n` | `0` |
| Healthy, everything claimable claimed | `consulted` | `nominal` | `n` | `c > 0` |

"Last reading" means the counts are whatever the last cycle that got far enough to count left
behind, dated by `observed_at`; under those four states they are context, not a verdict.

What a reader may NOT conclude:

- from `distributors_known = 0`, that the network has no distributors — only that this node's loop
  has discovered none as of `observed_at`;
- from `distributors_claimable = 0`, that nothing is owed — under `chain_source_unavailable`,
  `faulted` or `persisted_state_corrupt` the loop may not have evaluated anything this cycle;
- from `nominal`, that the loop is alive *now* — only that its last completed cycle was clean; a
  stale `observed_at` is the liveness signal (§4.4.2);
- from any field here, an amount, a balance, a payout, or a success rate.

#### 4.4.5 The money rule

The payload carries **no monetary amount and no payout puzzle hash** — not optional, not nullable,
absent — and a conforming responder MUST NOT add either, at any level, in any arm, under any
`kind`. Every number here is a count of distributors or of attempts. A payee reading a
distributor-scoped total as its own earnings is the failure the 0.12.0 shape exists to make
unrepresentable, and a count is the largest thing this object will ever say about money. A
consumer MUST NOT render any field of this object as earnings, a balance or a pending payout, and
MUST NOT derive one by multiplying a count by anything. The payout puzzle hash is the payee's
payment identity; nothing here needs it.

#### 4.4.6 Construction, strictness, and how a wrong version fails

- **`PayeeClaimStatus` is built by struct literal and is deliberately not `#[non_exhaustive]`.**
  §10's `RangeFrame` pattern buys a PATCH-level path for a future *optional* field, and this type
  has no optional fields by rule: every field is required and non-defaulting, so any future field is
  a wire break under §10.1 whatever the Rust attribute says, and a `new(claim_log, claim_loop)`
  constructor's signature breaks for that field exactly as a literal does. The attribute would only
  forbid consumers from destructuring the struct and imply an evolution path the type does not have.
  A producer cannot forget `subject` — the literal is a compile error without it — and cannot get it
  wrong — `PayeeSubject` has one member.
- **`PayeeClaimStatus` carries `deny_unknown_fields`** (new in 0.13.0). With every field required,
  version skew fails loudly in both directions rather than silently in one: a consumer on this
  contract fed a payload from a later one fails the parse instead of reading the subset it knows
  and presenting it as the whole answer.
- **How a wrong version fails.** A 0.12.0 consumer fed a 0.13.0 payload ignores `claim_loop`
  (0.12.0's struct did not deny unknown keys) and sees a 0.12.0 answer — which is why §10.1
  establishes that no such consumer exists. A 0.13.0 consumer fed a 0.12.0 payload fails the parse:
  `claim_loop` is required. A 0.13.0 consumer fed a `kind` this section does not list fails the
  parse. No mixed reading exists.

---

## 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).

### 10.1 A wire break in a minor is earned by "no producer", and that expires

0.12.0 reshaped `ListRewardDistributorsResult`, `GetRewardProverStatusResult` and
`PayeeClaimStatus` — a wire break by the rule above — inside a minor release. It
was admissible for exactly one reason, which is **not** "0.x minors may break":

> No implementation produced any of those three results. No 0.11 JSON was ever
> emitted, so no parser anywhere — including one this crate cannot see — could be
> bound to the old shape.

Two consequences bind every later change:

- **The test is a shipped PRODUCER, not a shipped consumer.** A consumer we know
  about can be migrated; a producer's emitted JSON is what an unknown third-party
  parser binds to, and once that JSON exists the break is real whether or not any
  consumer in this ecosystem breaks.
- **The exemption expires on the first producer.** From the release in which any
  node serves one of these methods, the same reshape is a MAJOR bump. A published
  crate version is immutable, which is why doing this at 0.12.0 was free and why
  doing it later is not.

A future breaking change MUST re-earn this exemption on its own facts. Citing
0.12.0 as precedent — "it's only a minor, we did it before" — does not discharge
it.

**Re-earned for 0.13.0 (2026-09-17).** 0.13.0 adds the required `claim_loop` field to
`PayeeClaimStatus` and puts `deny_unknown_fields` on the struct (§4.4) — a reshape of a 0.12.0
result, and therefore a wire break by §10's rule. It is admissible on these measured facts, not on
0.12.0's precedent:

- The only producer of `PayeeClaimStatus` JSON is dig-node's
  `crates/dig-node-core/src/seams/dig_rpc/dispatch.rs` (PR #609, merge `24cde574`). That commit is
  on dig-node's `origin/develop` and is **not an ancestor of `v0.259.0`**, dig-node's latest
  release. No released binary has ever emitted a `PayeeClaimStatus`, so no parser anywhere can be
  bound to the 0.12.0 shape.
- dig-node pins `dig-rpc-protocol = "0.12"` (`crates/dig-node-core/Cargo.toml:201`). Publishing
  0.13.0 alters no live build; adoption is a deliberate bump in dig-node that fills `claim_loop`
  from the loop that produces it, and that bump is the first release that serves the method.
- No other crate in the organisation consumes the type (dig-peer names it in a `Cargo.toml` comment
  only).

The exemption expires again on that first serving release: from the dig-node release that ships
`dig.getPayeeRewardClaimStatus` on 0.13.0, any further reshape of `PayeeClaimStatus`,
`ClaimLoopObservation` or `ClaimLoopState` — an eighth `kind`, a new field, a renamed key — is a
MAJOR bump, and its author re-earns on new facts or takes the major.