Skip to main content

dig_node_control_interface/
method.rs

1//! The canonical control-method catalog.
2//!
3//! [`ControlMethod`] enumerates every method a client can send to a running dig-node's CONTROL
4//! plane, its stable wire name, whether it requires the local control token, whether it is a
5//! pairing-administration method (which requires the MASTER token specifically), and how the node
6//! routes it (owned by the service shell, delegated to the embedded node engine, or an open
7//! pairing-bootstrap method reachable without a token).
8//!
9//! This is the SINGLE source of truth for "what can be controlled". The node dispatchers, the
10//! client SDKs (CLI `dign`, the extension, dig-app, hub), the OpenRPC/discovery surface, and the
11//! conformance KATs all read this one table, so the method set can never drift between them.
12//!
13//! Mirrors the live dig-node surface: the shell-owned methods in
14//! `dig-node-service/src/control.rs` (`CONTROL_METHODS`) plus the peer/subscription methods
15//! delegated to `dig-node-core` (`control.peerStatus` / `control.peers.*` / `control.subscribe`
16//! / `control.unsubscribe` / `control.listSubscriptions`), and the two OPEN pairing-bootstrap
17//! methods (`pairing.request` / `pairing.poll`) a token-less MV3 extension uses to obtain a
18//! scoped token after local operator approval.
19
20/// How the node resolves a control method — the routing source of truth.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum Routing {
23    /// Answered by the dig-node service shell itself (config/status/cache/pins/sync/updater/pairing-admin).
24    Owned,
25    /// Delegated to the embedded dig-node engine's own control surface (peers + subscriptions).
26    Delegated,
27    /// An OPEN bootstrap method reachable WITHOUT the control token (pairing handshake).
28    OpenBootstrap,
29}
30
31/// The functional area a control method belongs to — for grouping in UIs and docs.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub enum Category {
34    /// Node status snapshot.
35    Status,
36    /// Node configuration (upstream override).
37    Config,
38    /// Live log-level control.
39    Log,
40    /// On-disk content cache.
41    Cache,
42    /// Hosted/pinned stores.
43    HostedStores,
44    /// §21 authenticated whole-store sync.
45    Sync,
46    /// The DIG auto-update beacon proxy.
47    Updater,
48    /// Control-token pairing lifecycle.
49    Pairing,
50    /// The L7 peer network.
51    Peers,
52    /// The node's subscribed-store set.
53    Subscriptions,
54    /// Wallet chain transport: the read-only chain views (balance, coins, peak) plus the push of
55    /// an already-signed spend bundle.
56    Wallet,
57}
58
59/// A dig-node CONTROL method.
60///
61/// `#[non_exhaustive]` so adding a method in a minor release is additive; downstream matches must
62/// carry a `_ => …` arm. Convert to/from the wire name with [`ControlMethod::name`] /
63/// [`ControlMethod::from_name`].
64#[non_exhaustive]
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66pub enum ControlMethod {
67    // ---- Status / config / log (shell-owned) ----
68    /// `control.status` — a rich node status snapshot.
69    Status,
70    /// `control.config.get` — the node's effective configuration.
71    ConfigGet,
72    /// `control.config.setUpstream` — persist an upstream-RPC override (effective on restart).
73    ConfigSetUpstream,
74    /// `control.log.setLevel` — live-swap the running node's tracing level filter.
75    LogSetLevel,
76
77    // ---- Cache (shell-owned) ----
78    /// `control.cache.get` — the on-disk cache view (cap/used/dir/shared).
79    CacheGet,
80    /// `control.cache.setCap` — set the cache size cap (floored at 64 MiB).
81    CacheSetCap,
82    /// `control.cache.clear` — delete all locally cached content.
83    CacheClear,
84
85    // ---- Hosted stores (shell-owned) ----
86    /// `control.hostedStores.list` — every held/pinned store with its cached capsules.
87    HostedStoresList,
88    /// `control.hostedStores.pin` — pin a store (and pre-fetch when a root is given).
89    HostedStoresPin,
90    /// `control.hostedStores.unpin` — unpin a store and evict its cached capsules.
91    HostedStoresUnpin,
92    /// `control.hostedStores.status` — per-store pinned flag + cached capsules.
93    HostedStoresStatus,
94
95    // ---- §21 sync (shell-owned) ----
96    /// `control.sync.status` — whether authenticated whole-store sync is available + pin coverage.
97    SyncStatus,
98    /// `control.sync.trigger` — trigger a §21 sync for one capsule (storeId + root).
99    SyncTrigger,
100
101    // ---- Updater beacon proxy (shell-owned) ----
102    /// `control.updater.status` — the DIG auto-update beacon's current status.
103    UpdaterStatus,
104    /// `control.updater.setChannel` — set the beacon's update channel.
105    UpdaterSetChannel,
106    /// `control.updater.pause` — suspend auto-updates (optionally until a unix time).
107    UpdaterPause,
108    /// `control.updater.resume` — resume auto-updates.
109    UpdaterResume,
110    /// `control.updater.checkNow` — force an immediate update check.
111    UpdaterCheckNow,
112
113    // ---- Pairing administration (shell-owned, MASTER-token only) ----
114    /// `control.pairing.list` — list pending pairing requests + issued paired tokens.
115    PairingList,
116    /// `control.pairing.approve` — approve a pending pairing, minting a scoped token.
117    PairingApprove,
118    /// `control.pairing.revoke` — revoke an issued paired token.
119    PairingRevoke,
120
121    // ---- Peers (delegated to the engine) ----
122    /// `control.peerStatus` — live peer-pool + relay-reservation snapshot.
123    PeerStatus,
124    /// `control.peers.connect` — dial a peer by address / resolve a connected peer_id.
125    PeersConnect,
126    /// `control.peers.disconnect` — drop a pooled peer by peer_id.
127    PeersDisconnect,
128
129    // ---- Subscriptions (delegated to the engine) ----
130    /// `control.subscribe` — subscribe the node to a store (watch + gap-fill).
131    Subscribe,
132    /// `control.unsubscribe` — stop watching a store.
133    Unsubscribe,
134    /// `control.listSubscriptions` — the node's persisted subscription set.
135    ListSubscriptions,
136
137    // ---- Wallet chain transport (delegated to the engine) ----
138    /// `control.wallet.balance` — read an address's confirmed spendable balance for an asset.
139    WalletBalance,
140    /// `control.wallet.coins` — read an address's spendable coin records for an asset.
141    WalletCoins,
142    /// `control.wallet.peak` — read the node's current chain peak height.
143    WalletPeak,
144    /// `control.wallet.broadcast` — push an ALREADY-SIGNED spend bundle to the network.
145    WalletBroadcast,
146
147    // ---- Pairing bootstrap (OPEN — no token) ----
148    /// `pairing.request` — request a control-token pairing (returns a code to compare).
149    PairingRequest,
150    /// `pairing.poll` — poll a pairing; once the operator approves, returns the scoped token once.
151    PairingPoll,
152}
153
154impl ControlMethod {
155    /// The stable JSON-RPC wire name. Never derived from anything else — the published contract.
156    pub const fn name(self) -> &'static str {
157        match self {
158            ControlMethod::Status => "control.status",
159            ControlMethod::ConfigGet => "control.config.get",
160            ControlMethod::ConfigSetUpstream => "control.config.setUpstream",
161            ControlMethod::LogSetLevel => "control.log.setLevel",
162            ControlMethod::CacheGet => "control.cache.get",
163            ControlMethod::CacheSetCap => "control.cache.setCap",
164            ControlMethod::CacheClear => "control.cache.clear",
165            ControlMethod::HostedStoresList => "control.hostedStores.list",
166            ControlMethod::HostedStoresPin => "control.hostedStores.pin",
167            ControlMethod::HostedStoresUnpin => "control.hostedStores.unpin",
168            ControlMethod::HostedStoresStatus => "control.hostedStores.status",
169            ControlMethod::SyncStatus => "control.sync.status",
170            ControlMethod::SyncTrigger => "control.sync.trigger",
171            ControlMethod::UpdaterStatus => "control.updater.status",
172            ControlMethod::UpdaterSetChannel => "control.updater.setChannel",
173            ControlMethod::UpdaterPause => "control.updater.pause",
174            ControlMethod::UpdaterResume => "control.updater.resume",
175            ControlMethod::UpdaterCheckNow => "control.updater.checkNow",
176            ControlMethod::PairingList => "control.pairing.list",
177            ControlMethod::PairingApprove => "control.pairing.approve",
178            ControlMethod::PairingRevoke => "control.pairing.revoke",
179            ControlMethod::PeerStatus => "control.peerStatus",
180            ControlMethod::PeersConnect => "control.peers.connect",
181            ControlMethod::PeersDisconnect => "control.peers.disconnect",
182            ControlMethod::Subscribe => "control.subscribe",
183            ControlMethod::Unsubscribe => "control.unsubscribe",
184            ControlMethod::ListSubscriptions => "control.listSubscriptions",
185            ControlMethod::WalletBalance => "control.wallet.balance",
186            ControlMethod::WalletCoins => "control.wallet.coins",
187            ControlMethod::WalletPeak => "control.wallet.peak",
188            ControlMethod::WalletBroadcast => "control.wallet.broadcast",
189            ControlMethod::PairingRequest => "pairing.request",
190            ControlMethod::PairingPoll => "pairing.poll",
191        }
192    }
193
194    /// Resolve a wire name back to its [`ControlMethod`], or `None` for an unknown name.
195    pub fn from_name(name: &str) -> Option<ControlMethod> {
196        ControlMethod::ALL
197            .iter()
198            .copied()
199            .find(|m| m.name() == name)
200    }
201
202    /// Does calling this method require the local control token?
203    ///
204    /// Three groups are reachable WITHOUT one, and they are open for two different reasons:
205    ///
206    /// - the pairing bootstrap (`pairing.request` / `pairing.poll`), so a token-less client can
207    ///   obtain a token at all;
208    /// - the wallet CHAIN READS ([`Category::Wallet`] minus the push), because each needs only a
209    ///   PUBLIC address — never a seed, a key, or a signature — and dig-node has served
210    ///   `control.wallet.balance` open since #1851. A person whose node runs as a service with an
211    ///   unreadable token file can still see their own money.
212    ///
213    /// `control.wallet.broadcast` is deliberately NOT in that second group. It puts bytes on the
214    /// network, so the token is what stands between a local process and a broadcast, and its
215    /// refusal genuinely means *unauthorized* — see [`ControlMethod::is_open_read`].
216    pub const fn requires_auth(self) -> bool {
217        !self.is_open_read()
218            && !matches!(
219                self,
220                ControlMethod::PairingRequest | ControlMethod::PairingPoll
221            )
222    }
223
224    /// Is this an OPEN chain read — served without a control token?
225    ///
226    /// Stated on the contract rather than discovered by calling, because the two refusals a client
227    /// can get here demand OPPOSITE remedies. On an open read, `UNAUTHORIZED` can only come from a
228    /// node build that predates the method and gates it generically, so the remedy is an upgrade.
229    /// On a gated method — the push — `UNAUTHORIZED` means exactly what it says, and the remedy is
230    /// the token. A client that maps the two the same way sends somebody to fix the wrong thing.
231    pub const fn is_open_read(self) -> bool {
232        matches!(
233            self,
234            ControlMethod::WalletBalance | ControlMethod::WalletCoins | ControlMethod::WalletPeak
235        )
236    }
237
238    /// Is this a PAIRING-ADMINISTRATION method that requires the MASTER control token specifically?
239    ///
240    /// A paired (scoped) token can drive ordinary `control.*` mutations but MUST NOT mint more
241    /// tokens or revoke itself — so listing/approving/revoking pairings requires the master token
242    /// (a local file read), never a paired token.
243    pub const fn is_pairing_admin(self) -> bool {
244        matches!(
245            self,
246            ControlMethod::PairingList
247                | ControlMethod::PairingApprove
248                | ControlMethod::PairingRevoke
249        )
250    }
251
252    /// How the node routes this method (shell-owned, engine-delegated, or open bootstrap).
253    pub const fn routing(self) -> Routing {
254        match self {
255            ControlMethod::PeerStatus
256            | ControlMethod::PeersConnect
257            | ControlMethod::PeersDisconnect
258            | ControlMethod::Subscribe
259            | ControlMethod::Unsubscribe
260            | ControlMethod::ListSubscriptions
261            | ControlMethod::WalletBalance
262            | ControlMethod::WalletCoins
263            | ControlMethod::WalletPeak
264            | ControlMethod::WalletBroadcast => Routing::Delegated,
265            ControlMethod::PairingRequest | ControlMethod::PairingPoll => Routing::OpenBootstrap,
266            _ => Routing::Owned,
267        }
268    }
269
270    /// The functional area this method belongs to.
271    pub const fn category(self) -> Category {
272        match self {
273            ControlMethod::Status => Category::Status,
274            ControlMethod::ConfigGet | ControlMethod::ConfigSetUpstream => Category::Config,
275            ControlMethod::LogSetLevel => Category::Log,
276            ControlMethod::CacheGet | ControlMethod::CacheSetCap | ControlMethod::CacheClear => {
277                Category::Cache
278            }
279            ControlMethod::HostedStoresList
280            | ControlMethod::HostedStoresPin
281            | ControlMethod::HostedStoresUnpin
282            | ControlMethod::HostedStoresStatus => Category::HostedStores,
283            ControlMethod::SyncStatus | ControlMethod::SyncTrigger => Category::Sync,
284            ControlMethod::UpdaterStatus
285            | ControlMethod::UpdaterSetChannel
286            | ControlMethod::UpdaterPause
287            | ControlMethod::UpdaterResume
288            | ControlMethod::UpdaterCheckNow => Category::Updater,
289            ControlMethod::PairingList
290            | ControlMethod::PairingApprove
291            | ControlMethod::PairingRevoke
292            | ControlMethod::PairingRequest
293            | ControlMethod::PairingPoll => Category::Pairing,
294            ControlMethod::PeerStatus
295            | ControlMethod::PeersConnect
296            | ControlMethod::PeersDisconnect => Category::Peers,
297            ControlMethod::Subscribe
298            | ControlMethod::Unsubscribe
299            | ControlMethod::ListSubscriptions => Category::Subscriptions,
300            ControlMethod::WalletBalance
301            | ControlMethod::WalletCoins
302            | ControlMethod::WalletPeak
303            | ControlMethod::WalletBroadcast => Category::Wallet,
304        }
305    }
306
307    /// A one-line human/agent description for the discovery catalogue.
308    pub const fn summary(self) -> &'static str {
309        match self {
310            ControlMethod::Status => "A rich node status snapshot (version, uptime, addr, cache, hosted/pinned counts, sync availability).",
311            ControlMethod::ConfigGet => "The node's effective configuration (addr/port, upstream + override, cache dir/shared, config path, sync availability).",
312            ControlMethod::ConfigSetUpstream => "Persist an upstream-RPC override; takes effect on next node start (requires_restart).",
313            ControlMethod::LogSetLevel => "Live-swap the running node's tracing EnvFilter directive (not persisted).",
314            ControlMethod::CacheGet => "The on-disk content-cache view: cap_bytes, used_bytes, dir, shared.",
315            ControlMethod::CacheSetCap => "Set the on-disk cache size cap in bytes (floored at 64 MiB).",
316            ControlMethod::CacheClear => "Delete all locally cached DIG content.",
317            ControlMethod::HostedStoresList => "Every held/pinned store, merged, with each store's cached capsules and a pinned flag.",
318            ControlMethod::HostedStoresPin => "Pin a store (storeId[:rootHash]); pre-fetches the capsule when a root is given and §21 sync is available.",
319            ControlMethod::HostedStoresUnpin => "Unpin a store and evict its cached capsules.",
320            ControlMethod::HostedStoresStatus => "Per-store status: pinned flag, cached capsules, total bytes.",
321            ControlMethod::SyncStatus => "Whether authenticated §21 whole-store sync is available, plus pinned-store cache coverage.",
322            ControlMethod::SyncTrigger => "Trigger a §21 sync for one capsule (storeId + root).",
323            ControlMethod::UpdaterStatus => "The DIG auto-update beacon's current status (proxied from dig-updater).",
324            ControlMethod::UpdaterSetChannel => "Set the beacon's update channel (\"nightly\" | \"stable\").",
325            ControlMethod::UpdaterPause => "Suspend the beacon's auto-updates (optionally until a unix time).",
326            ControlMethod::UpdaterResume => "Resume the beacon's auto-updates.",
327            ControlMethod::UpdaterCheckNow => "Force an immediate beacon update check.",
328            ControlMethod::PairingList => "List pending pairing requests and issued paired tokens (MASTER token only).",
329            ControlMethod::PairingApprove => "Approve a pending pairing, minting a scoped token (MASTER token only).",
330            ControlMethod::PairingRevoke => "Revoke an issued paired token by token_id (MASTER token only).",
331            ControlMethod::PeerStatus => "Live peer-pool + relay-reservation snapshot, including the per-peer connected array; each entry carries an always-present `software` field (the peer's advertised build).",
332            ControlMethod::PeersConnect => "Dial a peer by address, or resolve an already-connected peer_id, via the live gossip pool.",
333            ControlMethod::PeersDisconnect => "Drop a pooled peer by peer_id, closing its mTLS link (idempotent).",
334            ControlMethod::Subscribe => "Subscribe the node to a store it actively watches and gap-fills.",
335            ControlMethod::Unsubscribe => "Stop watching a store.",
336            ControlMethod::ListSubscriptions => "The node's persisted subscription set + count.",
337            ControlMethod::WalletCoins => "READ-only: the spendable coin records for an address + asset, with the tier that answered and the height they reflect.",
338            ControlMethod::WalletPeak => "READ-only: the node's current chain peak height, independent of any address.",
339            ControlMethod::WalletBroadcast => "Push an ALREADY-SIGNED spend bundle to the network; the node never signs. TOKEN-GATED.",
340            ControlMethod::WalletBalance => "READ-only: the confirmed spendable balance for an address + asset (plus pending, sync freshness, and the peak height it reflects).",
341            ControlMethod::PairingRequest => "OPEN: request a control-token pairing; returns a pairing_id + pairing_code to compare.",
342            ControlMethod::PairingPoll => "OPEN: poll a pairing by id; once the operator approves, returns the scoped token once.",
343        }
344    }
345
346    /// Every catalogued method, in a stable order — the enumeration a machine reads to discover the
347    /// full control surface, and the anchor the conformance KATs pin against.
348    pub const ALL: &'static [ControlMethod] = &[
349        ControlMethod::Status,
350        ControlMethod::ConfigGet,
351        ControlMethod::ConfigSetUpstream,
352        ControlMethod::LogSetLevel,
353        ControlMethod::CacheGet,
354        ControlMethod::CacheSetCap,
355        ControlMethod::CacheClear,
356        ControlMethod::HostedStoresList,
357        ControlMethod::HostedStoresPin,
358        ControlMethod::HostedStoresUnpin,
359        ControlMethod::HostedStoresStatus,
360        ControlMethod::SyncStatus,
361        ControlMethod::SyncTrigger,
362        ControlMethod::UpdaterStatus,
363        ControlMethod::UpdaterSetChannel,
364        ControlMethod::UpdaterPause,
365        ControlMethod::UpdaterResume,
366        ControlMethod::UpdaterCheckNow,
367        ControlMethod::PairingList,
368        ControlMethod::PairingApprove,
369        ControlMethod::PairingRevoke,
370        ControlMethod::PeerStatus,
371        ControlMethod::PeersConnect,
372        ControlMethod::PeersDisconnect,
373        ControlMethod::Subscribe,
374        ControlMethod::Unsubscribe,
375        ControlMethod::ListSubscriptions,
376        ControlMethod::WalletBalance,
377        ControlMethod::WalletCoins,
378        ControlMethod::WalletPeak,
379        ControlMethod::WalletBroadcast,
380        ControlMethod::PairingRequest,
381        ControlMethod::PairingPoll,
382    ];
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use std::collections::BTreeSet;
389
390    #[test]
391    fn every_method_has_a_unique_wire_name() {
392        let names: BTreeSet<&str> = ControlMethod::ALL.iter().map(|m| m.name()).collect();
393        assert_eq!(
394            names.len(),
395            ControlMethod::ALL.len(),
396            "duplicate or missing wire names in the catalog"
397        );
398    }
399
400    #[test]
401    fn from_name_round_trips_every_method() {
402        for &m in ControlMethod::ALL {
403            assert_eq!(ControlMethod::from_name(m.name()), Some(m));
404        }
405        assert_eq!(ControlMethod::from_name("control.nope"), None);
406        assert_eq!(ControlMethod::from_name(""), None);
407    }
408
409    #[test]
410    fn the_token_less_surface_is_exactly_the_bootstrap_plus_the_chain_reads() {
411        // Written out rather than derived from `is_open_read`, so this pins the SET and not the
412        // implementation's opinion of itself. A method added to the open surface must be added
413        // here deliberately -- which is the review step a broadcast must never slip past.
414        let expected_open: BTreeSet<&str> = [
415            "pairing.request",
416            "pairing.poll",
417            "control.wallet.balance",
418            "control.wallet.coins",
419            "control.wallet.peak",
420        ]
421        .into_iter()
422        .collect();
423        let actual_open: BTreeSet<&str> = ControlMethod::ALL
424            .iter()
425            .filter(|m| !m.requires_auth())
426            .map(|m| m.name())
427            .collect();
428        assert_eq!(actual_open, expected_open);
429    }
430
431    /// **The push is token-gated, and no other wallet method is.** The fixture varies one thing --
432    /// which wallet method is asked -- against a category whose other three members ARE open, so an
433    /// implementation that opened the whole category (the nearest wrong one) fails here.
434    #[test]
435    fn the_push_is_the_one_wallet_method_behind_the_token() {
436        let gated: Vec<&str> = ControlMethod::ALL
437            .iter()
438            .filter(|m| m.category() == Category::Wallet && m.requires_auth())
439            .map(|m| m.name())
440            .collect();
441        assert_eq!(gated, vec!["control.wallet.broadcast"]);
442        assert!(!ControlMethod::WalletBroadcast.is_open_read());
443    }
444
445    #[test]
446    fn only_pairing_bootstrap_is_open_bootstrap_routed() {
447        for &m in ControlMethod::ALL {
448            let open_bootstrap = matches!(
449                m,
450                ControlMethod::PairingRequest | ControlMethod::PairingPoll
451            );
452            assert_eq!(
453                m.routing() == Routing::OpenBootstrap,
454                open_bootstrap,
455                "{} routing mismatch",
456                m.name()
457            );
458        }
459    }
460
461    #[test]
462    fn pairing_admin_methods_are_exactly_three() {
463        let admin: Vec<&str> = ControlMethod::ALL
464            .iter()
465            .filter(|m| m.is_pairing_admin())
466            .map(|m| m.name())
467            .collect();
468        assert_eq!(
469            admin,
470            vec![
471                "control.pairing.list",
472                "control.pairing.approve",
473                "control.pairing.revoke"
474            ]
475        );
476    }
477
478    #[test]
479    fn delegated_set_matches_the_engine_surface() {
480        let delegated: BTreeSet<&str> = ControlMethod::ALL
481            .iter()
482            .filter(|m| m.routing() == Routing::Delegated)
483            .map(|m| m.name())
484            .collect();
485        let expected: BTreeSet<&str> = [
486            "control.wallet.coins",
487            "control.wallet.peak",
488            "control.wallet.broadcast",
489            "control.peerStatus",
490            "control.peers.connect",
491            "control.peers.disconnect",
492            "control.subscribe",
493            "control.unsubscribe",
494            "control.listSubscriptions",
495            "control.wallet.balance",
496        ]
497        .into_iter()
498        .collect();
499        assert_eq!(delegated, expected);
500    }
501
502    #[test]
503    fn every_method_has_a_nonempty_summary() {
504        for &m in ControlMethod::ALL {
505            assert!(!m.summary().is_empty(), "{} has no summary", m.name());
506        }
507    }
508}