# 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` 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
| `-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:
| `-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`:
| `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:
| `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.
| `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:
| 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),
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.