dig-node-control-interface 0.12.0

Canonical client <-> dig-node CONTROL interface contract: the method catalog for controlling/querying a running dig-node (config, status, peers, subscriptions, cache, wallet), transport-agnostic. SSOT so client and node can't drift.
Documentation
# dig-node-control-interface

The canonical **client ⇄ dig-node CONTROL interface** contract. One ecosystem definition of the
management/query surface a client (the CLI `dign`, the browser extension, dig-app, hub) uses to
control and query a running dig-node — status, config, cache, hosted/pinned stores, §21 sync, the peer
network, subscriptions, the auto-update beacon, live log level, and control-token pairing — so the
client side and the node side can never silently drift. Transport-agnostic: rides the
`dig-ipc-protocol` local session, or loopback-mTLS + a signed control token over HTTP/WebSocket for
clients that aren't on the local pipe.

- **License:** Apache-2.0 OR MIT
- **Spec:** [`SPEC.md`](./SPEC.md) (normative) — this README is the at-a-glance interface reference.

```toml
[dependencies]
dig-node-control-interface = "0.2"
```

## Usage

Client side — build a typed request, carry it over your transport, parse the typed result:

```rust
use dig_node_control_interface::{
    params::SetCapParams,
    traits::{build_request, parse_response},
    envelope::JsonRpcResponse,
};

let call = SetCapParams { cap_bytes: 128 * 1024 * 1024 };
let request = build_request(1.into(), &call);          // → control.cache.setCap envelope
// ... send `request` over dig-ipc / loopback-mTLS, receive `response` ...
# let response = JsonRpcResponse::success(1.into(), serde_json::json!({ "cap_bytes": 134217728 }));
let result = parse_response::<SetCapParams>(response)?; // → SetCapResult { cap_bytes }
# Ok::<(), dig_node_control_interface::ControlError>(())
```

Node side — implement `ControlHandler` (one typed method per control method); the provided `dispatch`
routes a raw request to the right method and builds the response.

## Authorization

Every `control.*` method is token-gated unless the tables below mark it `—`: present the node's local
control token as the `X-Dig-Control-Token` header (preferred) or a `params._control_token` field.
Open today are `pairing.request` / `pairing.poll` (a token-less client uses them to obtain a scoped
token after local operator approval), the wallet CHAIN READS (they need only public chain data — an
address or a coin id, never a key), and `control.peerCounts` (two integers about this node's own
connectivity). The three `control.pairing.*` admin methods require the MASTER token specifically.

`UNAUTHORIZED` from an OPEN method means the node predates it — upgrade the node. From
`control.wallet.broadcast` it means what it says — find the token. See `SPEC.md` §4.2.

## Full interface reference

Every control method, for LLM/agent lookup without reading code. `Auth`: **T** = requires the control
token, **M** = requires the MASTER token, **—** = open. `Route`: how the node resolves it — `own`
(service shell), `del` (engine), `open` (bootstrap).

### Status / config / log

| Method | Auth | Route | Params | Result |
|---|---|---|---|---|
| `control.status` | T | own | — | `{running, service, version, commit, protocol, uptime_secs, addr, upstream, cache:{cap_bytes,used_bytes,dir,shared}, hosted_store_count, cached_capsule_count, pinned_store_count, sync:{available}}` |
| `control.config.get` | T | own | — | `{addr, port, upstream, upstream_override:string\|null, cache_dir, cache_shared, config_path, sync_available}` |
| `control.config.setUpstream` | T | own | `{upstream:string}` | `{upstream, requires_restart:true}` |
| `control.log.setLevel` | T | own | `{filter:string}` (an EnvFilter directive) | `{filter}` |

### Cache

| Method | Auth | Route | Params | Result |
|---|---|---|---|---|
| `control.cache.get` | T | own | — | `{cap_bytes, used_bytes, dir, shared}` |
| `control.cache.setCap` | T | own | `{cap_bytes:u64}` | `{cap_bytes}` (floored at 64 MiB) |
| `control.cache.clear` | T | own | — | `{cleared:true}` |

### Hosted / pinned stores

| Method | Auth | Route | Params | Result |
|---|---|---|---|---|
| `control.hostedStores.list` | T | own | — | `{stores:[{store_id, pinned, capsule_count, total_bytes, capsules:[{capsule,root,size_bytes,last_used_unix_ms}]}]}` |
| `control.hostedStores.pin` | T | own | `{store:"storeId[:root]"}` | `{store_id, root:string\|null, pinned:true, fetch:{status,…}}` |
| `control.hostedStores.unpin` | T | own | `{store}` | `{store_id, unpinned:bool, evicted_capsules:u64}` |
| `control.hostedStores.status` | T | own | `{store}` | `{store_id, pinned, capsule_count, total_bytes, capsules}` |

### §21 whole-store sync

| Method | Auth | Route | Params | Result |
|---|---|---|---|---|
| `control.sync.status` | T | own | — | `{available, method, pinned_total, pinned_synced, whole_store_trigger_supported}` |
| `control.sync.trigger` | T | own | `{store:"storeId:root"}` | `{store_id, root, status:"synced", size_bytes, served_root}` |

### Auto-update beacon (proxied to dig-updater)

| Method | Auth | Route | Params | Result |
|---|---|---|---|---|
| `control.updater.status` | T | own | — | (proxied beacon status) |
| `control.updater.setChannel` | T | own | `{channel:"nightly"\|"stable"}` | (proxied) |
| `control.updater.pause` | T | own | `{until?:u64}` (unix secs; omit = indefinite) | (proxied) |
| `control.updater.resume` | T | own | — | (proxied) |
| `control.updater.checkNow` | T | own | — | (proxied) |

### Pairing (control-token lifecycle)

| Method | Auth | Route | Params | Result |
|---|---|---|---|---|
| `control.pairing.list` | M | own | — | (pending requests + issued tokens) |
| `control.pairing.approve` | M | own | `{pairing_id:string}` | `{approved:true, client_name, token_id}` |
| `control.pairing.revoke` | M | own | `{token_id:string}` | `{revoked:bool, token_id}` |
| `pairing.request` | — | open | `{client_name:string}` | `{pairing_id, pairing_code, expires_ms}` |
| `pairing.poll` | — | open | `{pairing_id:string}` | `{status, token?}` (token present once, after approval) |

### Peers (delegated to the engine)

| Method | Auth | Route | Params | Result |
|---|---|---|---|---|
| `control.peerStatus` | T | del | — | (peer-pool + relay-reservation snapshot, incl. per-peer `peers[]`); its `relay.peer_count` counts THE RELAY's peers, not this node's |
| `control.peerCounts` | — | del | — | `{dig_peer_count:u32\|null, chia_peer_count:u32\|null}` — DIG content/gossip peers and CHIA full-node peers; `0` is observed, `null` unobservable |
| `control.peers.connect` | T | del | `{peer:string}` (address or peer_id) | `{connected:true, peer_id}` |
| `control.peers.disconnect` | T | del | `{peer:string}` (peer_id) | `{disconnected:true, peer_id}` |

### Wallet chain transport (delegated to the engine)

The caller-addressed reads and the chain-position reads are OPEN; the arrival cursor and the push
are token-gated. The node never signs — it reads chain state
and pushes bytes somebody else signed.

| Method | Auth | Route | Params | Result |
|---|---|---|---|---|
| `control.wallet.balance` | — | del | `{address:string, asset:"xch"\|"dig"}` | `{balance, pending, source, synced, peak_height}` |
| `control.wallet.coins` | — | del | `{address:string, asset:"xch"\|"dig"}` | `{coins:[WalletCoinRecord], source, synced, peak_height}` |
| `control.wallet.coinById` | — | del | `{coin_id:string}` (64-hex, `0x` accepted) | `{coin:WalletCoinRecord\|null, source, synced, peak_height}`; `coin:null` = the chain holds no such coin |
| `control.wallet.coinSpend` | — | del | `{coin_id:string}` (64-hex, `0x` accepted) | `{spend:{coin,puzzle_reveal,solution}\|null, source, synced, peak_height}`; `spend:null` = the chain holds no spend of that coin (unspent, or unknown) — NEVER "could not answer", which is an error. `puzzle_reveal` MUST tree-hash to the coin's `puzzle_hash` |
| `control.wallet.coinsByParent` | — | del | `{parent_coin_id:string, after_coin_id?:string, limit?:u32}` (ids 64-hex, `0x` accepted; `limit` 1..=1000, default 100, REFUSED out of range — never clamped) | `{coins:[WalletCoinRecord], complete:bool, cursor:string\|null, source, synced, peak_height}`; ONE PAGE of the parent's DIRECT children, ascending by `coin_id`, ONE hop, never a recursive walk. `complete:false` = TRUNCATED, resume from `cursor` — never infer completeness from the page length. `coins:[]` = no known children, never an unreachable chain |
| `control.wallet.arrivals` | T | del | `{after_seq:u64=0, limit?:u32}` | `{arrivals:[WalletArrivalRecord], cursor, latest}`; TOKEN-GATED: the caller supplies only a cursor, so the answer names this node's own watched puzzle hashes. CONFIRMED incoming funds only, never the wallet's own change. Resume from `cursor`, never `latest` |
| `control.wallet.peak` | — | del | — | `{peak_height:u32\|null, synced:bool}`; `synced` = catch-up COMPLETED, weaker than `syncStatus`'s |
| `control.wallet.syncStatus` | — | del | — | `{phase:"not_started"\|"syncing"\|"synced"\|"no_wallet_enrolled"\|"wallet_not_unlocked", peak_height:u32\|null, chia_peer_count:u32\|null, watched_addresses:u32\|null}`; `synced` also requires a LIVE Chia peer. `no_wallet_enrolled` = nothing to watch and that is correct; `wallet_not_unlocked` = a wallet EXISTS and nothing is watching it — NEVER render it as settled. An UNKNOWN token parses to `Unrecognized(token)`, never an error |
| `control.wallet.broadcast` | T | del | `{signed_bundle_hex:string}` | `{accepted, transaction_id, rejection}`; `accepted` = mempool admission, NOT confirmation |

### Subscriptions (delegated to the engine)

| Method | Auth | Route | Params | Result |
|---|---|---|---|---|
| `control.subscribe` | T | del | `{store_id:string}` | `{subscribed:true, added:bool, store_id}` |
| `control.unsubscribe` | T | del | `{store_id:string}` | `{subscribed:false, removed:bool, store_id}` |
| `control.listSubscriptions` | T | del | — | `{subscriptions:[string], count}` |

### Error codes

Every control error is `{code:int, message:string, data:{code:SYMBOL, origin:string}}`. Branch on
`data.code` (the stable symbol), never the message.

| Code | Symbol | Origin | Meaning |
|---|---|---|---|
| `-32700` | `PARSE_ERROR` | shell | request body was not valid JSON |
| `-32600` | `INVALID_REQUEST` | shell | not a single JSON-RPC object |
| `-32601` | `METHOD_NOT_FOUND` | boundary | control method is not resolved |
| `-32602` | `INVALID_PARAMS` | node | missing/malformed params |
| `-32000` | `DISPATCH_FAILED` | shell | the node failed to dispatch a well-formed call |
| `-32030` | `UNAUTHORIZED` | shell | `control.*` called without a valid token |
| `-32031` | `NOT_SUPPORTED` | shell | control op unsupported on this build |
| `-32032` | `CONTROL_ERROR` | shell | control op failed at runtime |

## Boundary

- `dig-rpc-protocol` — node ⇄ node peer wire.
- `dig-ipc-protocol` — app ⇄ node local session/signing envelope (the transport this catalog rides
  over, for local clients).
- `dig-node-control-interface` (this crate) — the control METHOD CATALOG carried inside that
  authenticated channel, or over loopback-mTLS for remote-transport clients.

See `SPEC.md` for the normative contract and the superproject `SYSTEM.md` for the wider cross-repo map.

## Consumers

dig-node (server-side dispatch — implements `ControlHandler`), the CLI `dign`, dig-chrome-extension,
dig-app, hub — all consuming this one control contract per epic #1110 (dig-node adoption + the
dig-rpc Control-tier removal land in later tasks of the cascade).