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///
33/// `#[non_exhaustive]` so adding a category in a minor release is additive; downstream matches must
34/// carry a `_ => …` arm. A new method often arrives with a new area, so this enum grows on the same
35/// cadence as [`ControlMethod`] and needs the same guarantee.
36#[non_exhaustive]
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum Category {
39    /// Node status snapshot.
40    Status,
41    /// Node configuration (upstream override).
42    Config,
43    /// Live log-level control.
44    Log,
45    /// On-disk content cache.
46    Cache,
47    /// Hosted/pinned stores.
48    HostedStores,
49    /// §21 authenticated whole-store sync.
50    Sync,
51    /// The DIG auto-update beacon proxy.
52    Updater,
53    /// Control-token pairing lifecycle.
54    Pairing,
55    /// The L7 peer network: the live pool snapshot, the per-network peer counts, and dial/drop.
56    Peers,
57    /// The node's subscribed-store set.
58    Subscriptions,
59    /// Wallet chain transport: the read-only chain views (balance, coins, one coin by id, peak,
60    /// sync status) plus the push of an already-signed spend bundle.
61    Wallet,
62    /// The automated-spend AUDIT record: what this node signed WITHOUT per-transaction approval.
63    /// Read-only; nothing in this category initiates, signs or alters a spend.
64    Spends,
65    /// dig-profile BODIES: handing the node the bytes a confirmed on-chain root commits to, and
66    /// reading one back. The chain root itself is never written here -- dig-app signs and pushes
67    /// that (§908); this category moves only the bytes an already-confirmed root commits to.
68    Profile,
69    /// Mirror-collateral: this epoch's derived per-store requirement, the node's LOCAL safety
70    /// margin over it, and the per-`(store, root)` state of the bonds this node actually holds. The requirement is consensus-derived and read-only here; the margin is an
71    /// operator preference this node owns and MUST NOT let into any census or signal.
72    Collateral,
73}
74
75/// A dig-node CONTROL method.
76///
77/// `#[non_exhaustive]` so adding a method in a minor release is additive; downstream matches must
78/// carry a `_ => …` arm. Convert to/from the wire name with [`ControlMethod::name`] /
79/// [`ControlMethod::from_name`].
80#[non_exhaustive]
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82pub enum ControlMethod {
83    // ---- Status / config / log (shell-owned) ----
84    /// `control.status` — a rich node status snapshot.
85    Status,
86    /// `control.config.get` — the node's effective configuration.
87    ConfigGet,
88    /// `control.config.setUpstream` — persist an upstream-RPC override (effective on restart).
89    ConfigSetUpstream,
90    /// `control.config.setMirrorAdvertiseUrls` — override (or clear) the URLs this node
91    /// advertises in its mirror-coin memos (dig-node#562). The result states whether that took
92    /// effect immediately or needs a restart — see [`crate::results::SetMirrorAdvertiseUrlsResult`].
93    ConfigSetMirrorAdvertiseUrls,
94    /// `control.log.setLevel` — live-swap the running node's tracing level filter.
95    LogSetLevel,
96
97    // ---- Cache (shell-owned) ----
98    /// `control.cache.get` — the on-disk cache view (cap/used/dir/shared).
99    CacheGet,
100    /// `control.cache.setCap` — set the cache size cap (floored at 64 MiB).
101    CacheSetCap,
102    /// `control.cache.clear` — delete all locally cached content.
103    CacheClear,
104
105    // ---- Hosted stores (shell-owned) ----
106    /// `control.hostedStores.list` — every held/pinned store with its cached capsules.
107    HostedStoresList,
108    /// `control.hostedStores.pin` — pin a store (and pre-fetch when a root is given).
109    HostedStoresPin,
110    /// `control.hostedStores.unpin` — unpin a store and evict its cached capsules.
111    HostedStoresUnpin,
112    /// `control.hostedStores.status` — per-store pinned flag + cached capsules.
113    HostedStoresStatus,
114    /// `control.capsule.fetch` — start (or report already-cached) a P2P whole-capsule pull for
115    /// one store+root, over the recursive discover-then-dial path rather than the §21 HTTP sync.
116    CapsuleFetch,
117
118    // ---- §21 sync (shell-owned) ----
119    /// `control.sync.status` — whether authenticated whole-store sync is available + pin coverage.
120    SyncStatus,
121    /// `control.sync.trigger` — trigger a §21 sync for one capsule (storeId + root).
122    SyncTrigger,
123
124    // ---- Updater beacon proxy (shell-owned) ----
125    /// `control.updater.status` — the DIG auto-update beacon's current status.
126    UpdaterStatus,
127    /// `control.updater.setChannel` — set the beacon's update channel.
128    UpdaterSetChannel,
129    /// `control.updater.pause` — suspend auto-updates (optionally until a unix time).
130    UpdaterPause,
131    /// `control.updater.resume` — resume auto-updates.
132    UpdaterResume,
133    /// `control.updater.checkNow` — force an immediate update check.
134    UpdaterCheckNow,
135
136    // ---- Pairing administration (shell-owned, MASTER-token only) ----
137    /// `control.pairing.list` — list pending pairing requests + issued paired tokens.
138    PairingList,
139    /// `control.pairing.approve` — approve a pending pairing, minting a scoped token.
140    PairingApprove,
141    /// `control.pairing.revoke` — revoke an issued paired token.
142    PairingRevoke,
143
144    // ---- Peers (delegated to the engine) ----
145    /// `control.peerStatus` — live peer-pool + relay-reservation snapshot.
146    PeerStatus,
147    /// `control.peerCounts` — how many peers this node holds on EACH network (DIG and Chia).
148    PeerCounts,
149    /// `control.peers.connect` — dial a peer by address / resolve a connected peer_id.
150    PeersConnect,
151    /// `control.peers.disconnect` — drop a pooled peer by peer_id.
152    PeersDisconnect,
153
154    // ---- Trusted CHIA full-node peers (shell-owned) ----
155    //
156    // A DIFFERENT network from `control.peers.*` above, which are DIG gossip peers. These name
157    // Chia full nodes the wallet replica will TRUST, and trust here is a real cost: NC-12 makes
158    // dialled peers untrusted precisely so that agreement across several concurrently-queried
159    // peers is what makes a read safe. A trusted peer is exempted from that agreement, so a wrong
160    // or hostile one is believed on its own. Every surface that offers these MUST say so.
161    //
162    // Trust comes from the operator declaring a node THEIR OWN — that is the whole of the
163    // authorisation, and the wording everywhere in this crate says exactly that. It is not
164    // "a node you vouch for": the unbounded authority a trusted peer holds is justified by the
165    // operator controlling both ends, which is false of a stranger's node however well
166    // recommended. A person can be talked into vouching for an address; they cannot be talked
167    // into believing they run it.
168    /// `control.chiaPeers.add` — trust a Chia full node you RUN, bypassing corroboration for it.
169    ChiaPeersAdd,
170    /// `control.chiaPeers.list` — the trusted Chia full-node peers this node tracks.
171    ChiaPeersList,
172    /// `control.chiaPeers.remove` — stop trusting a Chia full node (optionally banning it).
173    ChiaPeersRemove,
174
175    // ---- Subscriptions (delegated to the engine) ----
176    /// `control.subscribe` — subscribe the node to a store (watch + gap-fill).
177    Subscribe,
178    /// `control.unsubscribe` — stop watching a store.
179    Unsubscribe,
180    /// `control.listSubscriptions` — the node's persisted subscription set.
181    ListSubscriptions,
182
183    // ---- Wallet chain transport (delegated to the engine) ----
184    /// `control.wallet.balance` — read an address's confirmed spendable balance for an asset.
185    WalletBalance,
186    /// `control.wallet.coins` — read an address's spendable coin records for an asset.
187    WalletCoins,
188    /// `control.wallet.coinById` — read ONE coin record by coin id, spent or unspent.
189    WalletCoinById,
190    /// `control.wallet.coinSpend` — read the SPEND that spent a coin (puzzle reveal + solution).
191    WalletCoinSpend,
192    /// `control.wallet.coinsByParent` — read the direct children a coin's spend created (one hop).
193    WalletCoinsByParent,
194    /// `control.wallet.arrivals` — read confirmed INCOMING funds since a cursor position.
195    WalletArrivals,
196    /// `control.wallet.operatorAddress` — read the address of the node's OWN machine wallet.
197    WalletOperatorAddress,
198    /// `control.wallet.peak` — read the node's current chain peak height.
199    WalletPeak,
200    /// `control.wallet.syncStatus` — read whether the wallet's chain replica is being kept current.
201    WalletSyncStatus,
202    /// `control.wallet.broadcast` — push an ALREADY-SIGNED spend bundle to the network.
203    WalletBroadcast,
204    /// `control.wallet.watch` — enrol PUBLIC keys for the node's chain replica to follow.
205    WalletWatch,
206    /// `control.wallet.unwatch` — deregister enrolled public keys, so the following stops.
207    WalletUnwatch,
208    /// `control.wallet.watched` — list the public keys currently enrolled.
209    WalletWatched,
210    /// `control.wallet.reservations.held` — read which coins are committed to in-flight spends.
211    WalletReservationsHeld,
212    /// `control.wallet.reservations.reserve` — atomically hold coins, all of them or none.
213    WalletReservationsReserve,
214    /// `control.wallet.reservations.release` — free a hold now, ahead of its TTL.
215    WalletReservationsRelease,
216    /// `control.wallet.resetCoinDb` — discard the cached coin database and re-sync from chain.
217    WalletResetCoinDb,
218
219    // ---- Automated-spend audit record (shell-owned) ----
220    /// `control.spends.list` — read the record of spends this node made WITHOUT asking.
221    SpendsList,
222
223    // ---- Mirror collateral (shell-owned) ----
224    /// `control.collateral.requirement` -- this epoch's per-store collateral requirement.
225    CollateralRequirement,
226    /// `control.collateral.margin.get` -- read the node's local safety margin, in basis points.
227    CollateralMarginGet,
228    /// `control.collateral.margin.set` -- set the node's local safety margin, in basis points.
229    CollateralMarginSet,
230    /// `control.collateral.buffer` -- the $DIG this node recommends holding, and its funding state.
231    CollateralBuffer,
232    /// `control.mirror.bondStates` -- the per-`(store, root)` mirror bond state, and the $DIG
233    /// those bonds have locked.
234    MirrorBondStates,
235    /// `control.mirror.reconcile` -- reconcile this node's mirror coins to its CURRENT advertise
236    /// URL: reclaim every coin advertising a stale URL, then recreate it advertising the URL this
237    /// node advertises today. Shared by the manual "reset mirrors" action and the same primitive
238    /// the node's DAILY detector runs (dig-node#570) -- ONE reconcile primitive, exposed two ways.
239    /// See [`crate::results::MirrorReconcileResult`] for the three outcomes a caller must be able
240    /// to tell apart, and dig_ecosystem#3203 for why `refused` must mean NOTHING was spent.
241    MirrorReconcile,
242
243    // ---- dig-profile bodies (delegated to the engine) ----
244    /// `control.profile.putBody` — hand the node the profile body a CONFIRMED chain root commits to.
245    ProfilePutBody,
246    /// `control.profile.getBody` — read back the profile body this node holds at a given root.
247    ProfileGetBody,
248
249    // ---- Pairing bootstrap (OPEN — no token) ----
250    /// `pairing.request` — request a control-token pairing (returns a code to compare).
251    PairingRequest,
252    /// `pairing.poll` — poll a pairing; once the operator approves, returns the scoped token once.
253    PairingPoll,
254}
255
256impl ControlMethod {
257    /// The stable JSON-RPC wire name. Never derived from anything else — the published contract.
258    pub const fn name(self) -> &'static str {
259        match self {
260            ControlMethod::Status => "control.status",
261            ControlMethod::ConfigGet => "control.config.get",
262            ControlMethod::ConfigSetUpstream => "control.config.setUpstream",
263            ControlMethod::ConfigSetMirrorAdvertiseUrls => "control.config.setMirrorAdvertiseUrls",
264            ControlMethod::LogSetLevel => "control.log.setLevel",
265            ControlMethod::CacheGet => "control.cache.get",
266            ControlMethod::CacheSetCap => "control.cache.setCap",
267            ControlMethod::CacheClear => "control.cache.clear",
268            ControlMethod::HostedStoresList => "control.hostedStores.list",
269            ControlMethod::HostedStoresPin => "control.hostedStores.pin",
270            ControlMethod::HostedStoresUnpin => "control.hostedStores.unpin",
271            ControlMethod::HostedStoresStatus => "control.hostedStores.status",
272            ControlMethod::CapsuleFetch => "control.capsule.fetch",
273            ControlMethod::SyncStatus => "control.sync.status",
274            ControlMethod::SyncTrigger => "control.sync.trigger",
275            ControlMethod::UpdaterStatus => "control.updater.status",
276            ControlMethod::UpdaterSetChannel => "control.updater.setChannel",
277            ControlMethod::UpdaterPause => "control.updater.pause",
278            ControlMethod::UpdaterResume => "control.updater.resume",
279            ControlMethod::UpdaterCheckNow => "control.updater.checkNow",
280            ControlMethod::PairingList => "control.pairing.list",
281            ControlMethod::PairingApprove => "control.pairing.approve",
282            ControlMethod::PairingRevoke => "control.pairing.revoke",
283            ControlMethod::PeerStatus => "control.peerStatus",
284            ControlMethod::PeerCounts => "control.peerCounts",
285            ControlMethod::PeersConnect => "control.peers.connect",
286            ControlMethod::PeersDisconnect => "control.peers.disconnect",
287            ControlMethod::ChiaPeersAdd => "control.chiaPeers.add",
288            ControlMethod::ChiaPeersList => "control.chiaPeers.list",
289            ControlMethod::ChiaPeersRemove => "control.chiaPeers.remove",
290            ControlMethod::Subscribe => "control.subscribe",
291            ControlMethod::Unsubscribe => "control.unsubscribe",
292            ControlMethod::ListSubscriptions => "control.listSubscriptions",
293            ControlMethod::WalletBalance => "control.wallet.balance",
294            ControlMethod::WalletCoins => "control.wallet.coins",
295            ControlMethod::WalletCoinById => "control.wallet.coinById",
296            ControlMethod::WalletCoinSpend => "control.wallet.coinSpend",
297            ControlMethod::WalletCoinsByParent => "control.wallet.coinsByParent",
298            ControlMethod::WalletArrivals => "control.wallet.arrivals",
299            ControlMethod::WalletOperatorAddress => "control.wallet.operatorAddress",
300            ControlMethod::WalletPeak => "control.wallet.peak",
301            ControlMethod::WalletSyncStatus => "control.wallet.syncStatus",
302            ControlMethod::WalletBroadcast => "control.wallet.broadcast",
303            ControlMethod::WalletWatch => "control.wallet.watch",
304            ControlMethod::WalletUnwatch => "control.wallet.unwatch",
305            ControlMethod::WalletWatched => "control.wallet.watched",
306            ControlMethod::WalletReservationsHeld => "control.wallet.reservations.held",
307            ControlMethod::WalletReservationsReserve => "control.wallet.reservations.reserve",
308            ControlMethod::WalletReservationsRelease => "control.wallet.reservations.release",
309            ControlMethod::WalletResetCoinDb => "control.wallet.resetCoinDb",
310            ControlMethod::SpendsList => "control.spends.list",
311            ControlMethod::CollateralRequirement => "control.collateral.requirement",
312            ControlMethod::CollateralMarginGet => "control.collateral.margin.get",
313            ControlMethod::CollateralMarginSet => "control.collateral.margin.set",
314            ControlMethod::CollateralBuffer => "control.collateral.buffer",
315            ControlMethod::MirrorBondStates => "control.mirror.bondStates",
316            ControlMethod::MirrorReconcile => "control.mirror.reconcile",
317            ControlMethod::ProfilePutBody => "control.profile.putBody",
318            ControlMethod::ProfileGetBody => "control.profile.getBody",
319            ControlMethod::PairingRequest => "pairing.request",
320            ControlMethod::PairingPoll => "pairing.poll",
321        }
322    }
323
324    /// Resolve a wire name back to its [`ControlMethod`], or `None` for an unknown name.
325    pub fn from_name(name: &str) -> Option<ControlMethod> {
326        ControlMethod::ALL
327            .iter()
328            .copied()
329            .find(|m| m.name() == name)
330    }
331
332    /// Does calling this method require the local control token?
333    ///
334    /// Three groups are reachable WITHOUT one, and they are open for two different reasons:
335    ///
336    /// - the pairing bootstrap (`pairing.request` / `pairing.poll`), so a token-less client can
337    ///   obtain a token at all;
338    /// - the PEER COUNTS (`control.peerCounts`), which disclose three integers about this node's
339    ///   own connectivity and no address, endpoint or secret;
340    /// - the wallet CALLER-ADDRESSED CHAIN READS (`control.wallet.balance` / `.coins` /
341    ///   `.coinById` / `.coinSpend` / `.coinsByParent`) and the node's own chain POSITION
342    ///   (`.peak` / `.syncStatus`), because each needs only PUBLIC chain data the CALLER already
343    ///   named — an address, or a coin id; never a seed, a key, or a signature — and dig-node has
344    ///   served `control.wallet.balance` open since #1851. A person whose node runs as a service
345    ///   with an unreadable token file can still see their own money.
346    ///
347    /// Five wallet methods are deliberately NOT in that second group:
348    ///
349    /// - `control.wallet.broadcast` puts bytes on the network, so the token is what stands between
350    ///   a local process and a broadcast — a mutation on the chain state itself;
351    /// - `control.wallet.watch` and `.unwatch` aim what this node follows, so they are mutations
352    ///   of this node's own watched-key set;
353    /// - `control.wallet.arrivals` and `.watched` take nothing from the caller and answer back
354    ///   with this node's OWN state — watched puzzle hashes and enrolled public keys respectively.
355    ///
356    /// See [`ControlMethod::is_open_read`]. On all five, `UNAUTHORIZED` genuinely means
357    /// *unauthorized*.
358    pub const fn requires_auth(self) -> bool {
359        !self.is_open_read()
360            && !matches!(
361                self,
362                ControlMethod::PairingRequest | ControlMethod::PairingPoll
363            )
364    }
365
366    /// Is this an OPEN READ — served without a control token?
367    ///
368    /// Two kinds of method qualify, and they are open for different reasons:
369    ///
370    /// - the wallet CHAIN READS (`control.wallet.balance` / `.coins` / `.coinById` / `.coinSpend` /
371    ///   `.coinsByParent` / `.peak` / `.syncStatus`), which need only PUBLIC chain data — an
372    ///   address, or a coin id; never a seed, a key, or a signature. On the first five the CALLER
373    ///   supplies the address or coin id, so the node relays a public fact and discloses no
374    ///   association with itself; the last two name the node's own chain position and no address
375    ///   at all;
376    /// - `control.peerCounts`, which is NOT a chain read: it discloses three integers about this
377    ///   node's own connectivity, and no address, endpoint, peer identity or secret. The identity
378    ///   and topology half of the same subject stays gated behind `control.peerStatus`.
379    ///
380    /// Naming both reasons matters more than it looks. The test for membership is *does this
381    /// disclose only data that is already public, or a bare count of this node's own state?* — NOT
382    /// *is it a chain read?* A future method judged against the narrower phrasing, and found to
383    /// contradict a member that was already there, invites widening the predicate by analogy rather
384    /// than against the rule.
385    ///
386    /// `control.wallet.arrivals` is the worked example, and it was briefly a member. It passes the
387    /// narrower phrasing — every field it returns is a public chain fact — and fails the rule: the
388    /// caller supplies NOTHING, so the node volunteers its OWN watched puzzle hashes together with
389    /// the full receive history behind them. The individual facts are public; the ASSOCIATION
390    /// between this node and those addresses is not, and that association is the whole answer. A
391    /// token-less caller could then feed those addresses back into the caller-addressed reads.
392    /// Membership turns on *who names the address*, never on whether the bytes are on chain.
393    ///
394    /// Stated on the contract rather than discovered by calling, because the two refusals a client
395    /// can get here demand OPPOSITE remedies. On an open read, `UNAUTHORIZED` can only come from a
396    /// node build that predates the method and gates it generically, so the remedy is an upgrade.
397    /// On a gated method — the push — `UNAUTHORIZED` means exactly what it says, and the remedy is
398    /// the token. A client that maps the two the same way sends somebody to fix the wrong thing.
399    pub const fn is_open_read(self) -> bool {
400        matches!(
401            self,
402            ControlMethod::WalletBalance
403                | ControlMethod::WalletCoins
404                | ControlMethod::WalletCoinById
405                | ControlMethod::WalletCoinSpend
406                | ControlMethod::WalletCoinsByParent
407                | ControlMethod::WalletPeak
408                | ControlMethod::WalletSyncStatus
409                | ControlMethod::PeerCounts
410        )
411    }
412
413    /// Is this a PAIRING-ADMINISTRATION method that requires the MASTER control token specifically?
414    ///
415    /// A paired (scoped) token can drive ordinary `control.*` mutations but MUST NOT mint more
416    /// tokens or revoke itself — so listing/approving/revoking pairings requires the master token
417    /// (a local file read), never a paired token.
418    ///
419    /// This names the pairing LIFECYCLE only. The predicate an auth gate consults is
420    /// [`ControlMethod::requires_master_token`], of which this is a strict subset.
421    pub const fn is_pairing_admin(self) -> bool {
422        matches!(
423            self,
424            ControlMethod::PairingList
425                | ControlMethod::PairingApprove
426                | ControlMethod::PairingRevoke
427        )
428    }
429
430    /// Does this method require the MASTER control token — the local file read — rather than any
431    /// valid token?
432    ///
433    /// **This, not [`ControlMethod::is_pairing_admin`], is the predicate an auth gate consults.**
434    /// The master tier is not "pairing administration"; it is every method whose effect OUTLIVES
435    /// the token that invoked it, and pairing administration is one instance of that shape.
436    ///
437    /// The rule, stated so a later method can be judged against it rather than by analogy: a
438    /// method belongs here when a caller holding a paired token could use it to acquire authority
439    /// it keeps AFTER that token is revoked. `pairing.revoke` is the designated remedy for a
440    /// compromised paired app, so any method that survives it has escaped the remedy.
441    ///
442    /// The two members outside the pairing lifecycle are `control.chiaPeers.add` and
443    /// `control.chiaPeers.remove`, and they are here for exactly that reason. `add` writes a
444    /// standing entry into the peer store the wallet replica reads, and a peer in that set is
445    /// believed WITHOUT corroboration — it can dictate money-bearing chain facts (peak height, and
446    /// therefore confirmation counts). Once written, the caller no longer needs the token at all,
447    /// and revoking the token does not remove the entry. A paired token must therefore not be able
448    /// to write one. `remove` is the only un-trust remedy and is gated with it, so a paired token
449    /// cannot strip the peers an operator deliberately trusts.
450    ///
451    /// `control.chiaPeers.list` deliberately stays on the ordinary token tier: it is a READ, it
452    /// grants nothing that outlives the token, and gating it would leave a paired client unable to
453    /// show the operator the trust state it is subject to. That matches `control.wallet.arrivals`,
454    /// which is gated at the ordinary tier for disclosing an association without conferring
455    /// authority.
456    ///
457    /// `control.config.setMirrorAdvertiseUrls` ALSO stays ordinary, and for the same reason as
458    /// `control.chiaPeers.list` rather than by analogy to its own persistence: "outlives the
459    /// token" is necessary but not sufficient (see this repo's #40, which argues
460    /// `control.config.setUpstream` should be promoted for exactly this gap). The persisted
461    /// override survives `pairing.revoke` just as `setUpstream`'s does, but it installs no
462    /// principal this node will thereafter believe, obey, or forward requests to — it changes only
463    /// what THIS node broadcasts about itself in its own mirror-coin memo. The node never dials the
464    /// value, never trusts bytes read FROM it, and never routes a call TO it. A caller cannot use
465    /// it to make the node trust or obey anyone new, which is the same reason `cache.setCap` and
466    /// `log.setLevel` stay ordinary despite also persisting past the call that set them.
467    pub const fn requires_master_token(self) -> bool {
468        self.is_pairing_admin()
469            || matches!(
470                self,
471                ControlMethod::ChiaPeersAdd | ControlMethod::ChiaPeersRemove
472            )
473    }
474
475    /// How the node routes this method (shell-owned, engine-delegated, or open bootstrap).
476    pub const fn routing(self) -> Routing {
477        match self {
478            ControlMethod::PeerStatus
479            | ControlMethod::PeerCounts
480            | ControlMethod::PeersConnect
481            | ControlMethod::PeersDisconnect
482            | ControlMethod::Subscribe
483            | ControlMethod::Unsubscribe
484            | ControlMethod::ListSubscriptions
485            | ControlMethod::WalletBalance
486            | ControlMethod::WalletCoins
487            | ControlMethod::WalletCoinById
488            | ControlMethod::WalletCoinSpend
489            | ControlMethod::WalletCoinsByParent
490            | ControlMethod::WalletArrivals
491            | ControlMethod::WalletPeak
492            | ControlMethod::WalletSyncStatus
493            | ControlMethod::WalletBroadcast
494            | ControlMethod::WalletWatch
495            | ControlMethod::WalletUnwatch
496            | ControlMethod::WalletWatched
497            | ControlMethod::WalletReservationsHeld
498            | ControlMethod::WalletReservationsReserve
499            | ControlMethod::WalletReservationsRelease
500            | ControlMethod::WalletResetCoinDb
501            | ControlMethod::ProfilePutBody
502            | ControlMethod::ProfileGetBody => Routing::Delegated,
503            ControlMethod::PairingRequest | ControlMethod::PairingPoll => Routing::OpenBootstrap,
504            _ => Routing::Owned,
505        }
506    }
507
508    /// The functional area this method belongs to.
509    pub const fn category(self) -> Category {
510        match self {
511            ControlMethod::Status => Category::Status,
512            ControlMethod::ConfigGet
513            | ControlMethod::ConfigSetUpstream
514            | ControlMethod::ConfigSetMirrorAdvertiseUrls => Category::Config,
515            ControlMethod::LogSetLevel => Category::Log,
516            ControlMethod::CacheGet | ControlMethod::CacheSetCap | ControlMethod::CacheClear => {
517                Category::Cache
518            }
519            ControlMethod::HostedStoresList
520            | ControlMethod::HostedStoresPin
521            | ControlMethod::HostedStoresUnpin
522            | ControlMethod::HostedStoresStatus
523            | ControlMethod::CapsuleFetch => Category::HostedStores,
524            ControlMethod::SyncStatus | ControlMethod::SyncTrigger => Category::Sync,
525            ControlMethod::UpdaterStatus
526            | ControlMethod::UpdaterSetChannel
527            | ControlMethod::UpdaterPause
528            | ControlMethod::UpdaterResume
529            | ControlMethod::UpdaterCheckNow => Category::Updater,
530            ControlMethod::PairingList
531            | ControlMethod::PairingApprove
532            | ControlMethod::PairingRevoke
533            | ControlMethod::PairingRequest
534            | ControlMethod::PairingPoll => Category::Pairing,
535            ControlMethod::PeerStatus
536            | ControlMethod::PeerCounts
537            | ControlMethod::PeersConnect
538            | ControlMethod::PeersDisconnect
539            | ControlMethod::ChiaPeersAdd
540            | ControlMethod::ChiaPeersList
541            | ControlMethod::ChiaPeersRemove => Category::Peers,
542            ControlMethod::Subscribe
543            | ControlMethod::Unsubscribe
544            | ControlMethod::ListSubscriptions => Category::Subscriptions,
545            ControlMethod::WalletBalance
546            | ControlMethod::WalletCoins
547            | ControlMethod::WalletCoinById
548            | ControlMethod::WalletCoinSpend
549            | ControlMethod::WalletCoinsByParent
550            | ControlMethod::WalletArrivals
551            | ControlMethod::WalletPeak
552            | ControlMethod::WalletSyncStatus
553            | ControlMethod::WalletOperatorAddress
554            | ControlMethod::WalletBroadcast
555            | ControlMethod::WalletWatch
556            | ControlMethod::WalletUnwatch
557            | ControlMethod::WalletWatched
558            | ControlMethod::WalletReservationsHeld
559            | ControlMethod::WalletReservationsReserve
560            | ControlMethod::WalletReservationsRelease
561            | ControlMethod::WalletResetCoinDb => Category::Wallet,
562            ControlMethod::SpendsList => Category::Spends,
563            ControlMethod::CollateralRequirement
564            | ControlMethod::CollateralMarginGet
565            | ControlMethod::CollateralMarginSet
566            | ControlMethod::CollateralBuffer
567            | ControlMethod::MirrorBondStates
568            | ControlMethod::MirrorReconcile => Category::Collateral,
569            ControlMethod::ProfilePutBody | ControlMethod::ProfileGetBody => Category::Profile,
570        }
571    }
572
573    /// A one-line human/agent description for the discovery catalogue.
574    pub const fn summary(self) -> &'static str {
575        match self {
576            ControlMethod::ChiaPeersAdd => "Trust a Chia full node by IP. A trusted peer BYPASSES CORROBORATION: this node normally believes a chain answer only when several independently-dialled peers agree, and a trusted peer is believed on its own -- so a wrong or hostile one can feed this node a false view of the chain. Add only a node you run yourself.",
577            ControlMethod::ChiaPeersList => "The Chia full-node peers this node tracks, each flagged user_managed: true where a person added it by hand and it is therefore trusted without corroboration.",
578            ControlMethod::ChiaPeersRemove => "Stop trusting a Chia full node, optionally banning it. Removing restores corroboration for that peer: chain answers must once again be agreed by independently-dialled peers.",
579            ControlMethod::Status => "A rich node status snapshot (version, uptime, addr, cache, hosted/pinned counts, sync availability).",
580            ControlMethod::ConfigGet => "The node's effective configuration (addr/port, upstream + override, cache dir/shared, config path, sync availability, mirror advertise-URL view).",
581            ControlMethod::ConfigSetUpstream => "Persist an upstream-RPC override; takes effect on next node start (requires_restart).",
582            ControlMethod::ConfigSetMirrorAdvertiseUrls => "Override (urls: a non-empty list) or clear (urls: null/absent) the URLs this node advertises in its own mirror-coin memos. The result's requires_restart says whether that took effect now or needs a node restart -- check it before telling anyone the change is live. An explicit EMPTY list is refused rather than guessed at -- it is ambiguous between advertising nothing and reverting to the derived default. Checked only for a well-formed absolute URL (scheme + host): an operator's LAN or private address is accepted on purpose, the same derived-vs-operator asymmetry dig-node#562 established.",
583            ControlMethod::LogSetLevel => "Live-swap the running node's tracing EnvFilter directive (not persisted).",
584            ControlMethod::CacheGet => "The on-disk content-cache view: cap_bytes, used_bytes, dir, shared.",
585            ControlMethod::CacheSetCap => "Set the on-disk cache size cap in bytes (floored at 64 MiB).",
586            ControlMethod::CacheClear => "Delete all locally cached DIG content.",
587            ControlMethod::HostedStoresList => "Every held/pinned store, merged, with each store's cached capsules and a pinned flag.",
588            ControlMethod::HostedStoresPin => "Pin a store (storeId[:rootHash]); pre-fetches the capsule when a root is given and §21 sync is available.",
589            ControlMethod::HostedStoresUnpin => "Unpin a store and evict its cached capsules.",
590            ControlMethod::HostedStoresStatus => "Per-store status: pinned flag, cached capsules, total bytes.",
591            ControlMethod::CapsuleFetch => "Start a P2P whole-capsule pull for one store+root over the recursive discover-then-dial path (distinct from the §21 HTTP sync `control.sync.trigger` uses). Answers `already_cached` without dialling out when the capsule is already on disk.",
592            ControlMethod::SyncStatus => "Whether authenticated §21 whole-store sync is available, plus pinned-store cache coverage.",
593            ControlMethod::SyncTrigger => "Trigger a §21 sync for one capsule (storeId + root).",
594            ControlMethod::UpdaterStatus => "The DIG auto-update beacon's current status (proxied from dig-updater).",
595            ControlMethod::UpdaterSetChannel => "Set the beacon's update channel (\"nightly\" | \"stable\").",
596            ControlMethod::UpdaterPause => "Suspend the beacon's auto-updates (optionally until a unix time).",
597            ControlMethod::UpdaterResume => "Resume the beacon's auto-updates.",
598            ControlMethod::UpdaterCheckNow => "Force an immediate beacon update check.",
599            ControlMethod::PairingList => "List pending pairing requests and issued paired tokens (MASTER token only).",
600            ControlMethod::PairingApprove => "Approve a pending pairing, minting a scoped token (MASTER token only).",
601            ControlMethod::PairingRevoke => "Revoke an issued paired token by token_id (MASTER token only).",
602            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). Its `relay.peer_count` counts peers connected to THE RELAY, not to this node, and is never the answer to \"how many peers does this node have\" -- that is control.peerCounts.",
603            ControlMethod::PeerCounts => "READ-only: how many peers this node holds on EACH network -- dig_peer_count (DIG content/gossip, port 9445) and chia_peer_count (Chia full nodes serving the wallet chain sync). Two unrelated numbers, each named for its network.",
604            ControlMethod::PeersConnect => "Dial a peer by address, or resolve an already-connected peer_id, via the live gossip pool.",
605            ControlMethod::PeersDisconnect => "Drop a pooled peer by peer_id, closing its mTLS link (idempotent).",
606            ControlMethod::Subscribe => "Subscribe the node to a store it actively watches and gap-fills.",
607            ControlMethod::Unsubscribe => "Stop watching a store.",
608            ControlMethod::ListSubscriptions => "The node's persisted subscription set + count.",
609            ControlMethod::WalletCoins => "READ-only: the spendable coin records for an address + asset, with the tier that answered and the height they reflect.",
610            ControlMethod::WalletCoinById => "READ-only: ONE coin record by coin id, spent or unspent, with no address and no asset scope; `coin: null` means the chain holds no such coin.",
611            ControlMethod::WalletCoinSpend => "READ-only: the SPEND that spent a coin -- its puzzle reveal, its solution and the coin itself -- named by the coin's own id. `spend: null` means the consulted chain shows that coin as unspent or unknown; it NEVER means the chain could not be reached, which is an error.",
612            ControlMethod::WalletCoinsByParent => "READ-only: the DIRECT children created by spending one coin, named by that parent's coin id. ONE hop, never a recursive walk: an empty list means the parent created no known children, and a caller wanting a lineage composes hops itself.",
613            ControlMethod::WalletArrivals => "READ-only: confirmed INCOMING funds recorded since a cursor position, oldest first -- the answer to `was I just paid?`, which no balance or coin list can give. Each row is a coin the node determined ARRIVED: confirmed on chain, above the wallet's arrival baseline, not previously reported, and not the wallet's own change. Resume from `cursor` (the last row you were handed), never from `latest`.",
614            ControlMethod::WalletOperatorAddress => "READ-only: the address of the node's OWN operator wallet -- the MACHINE-custody wallet that pays mirror-coin collateral, never the user's. Returns a public address and puzzle hash and NEVER any key, seed or derivation material. TOKEN-GATED, and answered by THIS node rather than forwarded upstream.",
615            ControlMethod::WalletPeak => "READ-only: the node's current chain peak height, independent of any address.",
616            ControlMethod::WalletSyncStatus => "READ-only: whether the wallet's CHAIN replica is being kept current (not_started/syncing/synced/no_wallet_enrolled/wallet_not_unlocked), the replica's own height, and its CHIA full-node peer count -- unrelated to control.sync.status (DIG stores) and to control.peerStatus (DIG peers).",
617            ControlMethod::WalletBroadcast => "Push an ALREADY-SIGNED spend bundle to the network; the node never signs. TOKEN-GATED.",
618            ControlMethod::WalletBalance => "READ-only: the confirmed spendable balance for an address + asset (plus pending, sync freshness, and the peak height it reflects).",
619            ControlMethod::WalletWatch => "Enrol PUBLIC keys (48-byte G1, lowercase 96-hex) for the node's chain replica to follow, so their addresses are synced and readable. IDEMPOTENT: re-enrolling a key already enrolled succeeds and changes nothing. Keys, never puzzle hashes -- the node derives the addresses itself, so one derivation serves every client. TOKEN-GATED.",
620            ControlMethod::WalletUnwatch => "Deregister enrolled public keys, so the node stops following their addresses. IDEMPOTENT: a key that was never enrolled is not an error. TOKEN-GATED.",
621            ControlMethod::SpendsList => "READ-only: the record of spends this node made WITHOUT per-transaction approval -- what moved, when, on whose standing authority, and whether the chain confirmed it. It NEVER initiates, signs, cancels or alters a spend, and there is no verb here that edits an entry. A failed spend is reported WITH the stage it died at, because only a signing failure means the money definitely did not move; a broadcast or confirmation failure is an UNKNOWN outcome, as is `unresolved`. A page is bounded and says so via `complete`; `unreadable_lines` reports entries the node could not parse, so an audit trail that lost rows can never read as a tidy shorter one. TOKEN-GATED although it is a read: the caller supplies no identifier, so the answer is this node's OWN state.",
622            ControlMethod::CollateralRequirement => "READ-only: this epoch's per-store mirror-collateral requirement in DIG base units, the collateral protocol version that computed it, and the census inputs behind it (advertised stores, collateralised owners, controller multiplier, small-network handicap) so a client can show WHY the figure moved rather than only that it did. A node that has not censused the epoch, or that is inside the census finality depth, answers `unknown` WITH the reason -- never a zero, which would read as a free requirement. `stores` counts qualifying (owner, store, root) advertisements and `owners` counts distinct owner puzzle hashes: neither is a node count. It NEVER returns the local safety margin, which is not a consensus value.",
623            ControlMethod::CollateralMarginGet => "Read the node's LOCAL safety margin in BASIS POINTS over the epoch requirement (`100` is +1%). The margin is an operator preference that changes only how much THIS node chooses to lock; it is never a census input and no value derived from it reaches another node. Basis points are the unit the collateral crate's own presets and rounding use, and are never converted.",
624            ControlMethod::CollateralMarginSet => "Set the node's LOCAL safety margin in BASIS POINTS (`100` is +1%), returning the margin now in force. Bounded at 10000 bp (+100%): the margin multiplies what the node locks on every store, so an unbounded value would commit the operator to an arbitrary posting. A margin above the bound is REFUSED as -32602 INVALID_PARAMS rather than clamped, so the applied value can never differ silently from the requested one. A margin gives room if the requirement rises; it does NOT guarantee a store is counted, because the requirement is re-derived every epoch and can rise by more than any margin.",
625            ControlMethod::ProfilePutBody => "Hand the node the dig-profile BODY that a chain root commits to. The node INDEPENDENTLY resolves that root on chain and REFUSES any body whose recomputed root is not the confirmed one -- the caller's `root` is a claim to be checked, never a fact to be trusted, and dig-app is a caller like any other. Bodies are capped at MAX_BODY_BYTES (4 MiB). TOKEN-GATED.",
626            ControlMethod::ProfileGetBody => "READ-only: the dig-profile body this node holds at a given store id + root, or `body: null` when it holds none. `null` NEVER means the body could not be read, which is an error. TOKEN-GATED.",
627            ControlMethod::WalletWatched => "READ-only: the public keys currently enrolled, so a client can reconcile what it asked for against what the node holds. TOKEN-GATED although it is a read -- the caller supplies nothing, so the answer is this node's OWN key set.",
628            ControlMethod::WalletReservationsHeld => "READ-only: every coin currently committed to an in-flight spend, each with the reservation holding it and the unix second that hold lapses, plus the node's own clock. `reserved: []` means NOTHING is held; a set that cannot be read is an error, never an empty list. Narrows what a caller may SELECT; never subtract these from a balance -- the coins are still the user's money. TOKEN-GATED although it is a read: the caller supplies nothing, so the answer is this node's OWN state.",
629            ControlMethod::WalletReservationsReserve => "Atomically hold coins against further selection: EVERY named coin or none. A coin already held refuses the whole call and reserves nothing, as WALLET_COINS_RESERVED -- a WAIT, never a shortfall. Reserving an empty list succeeds with a handle that releases nothing. The requested ttl_secs is clamped by the node, which returns the lifetime it actually applied. Bookkeeping only: it holds no key and authorizes nothing (§908). TOKEN-GATED.",
630            ControlMethod::WalletReservationsRelease => "Free a hold now rather than waiting out its TTL -- call it the moment a spend is known settled or known dead. A handle that names no live reservation is a SUCCESS with released: false, because a caller releasing on confirmation cannot know whether the TTL got there first. Every hold also lapses on its own, so an abandoned reservation is recoverable and never a permanent funds lockout. TOKEN-GATED.",
631            ControlMethod::WalletResetCoinDb => "DESTRUCTIVE: discard this node's cached coin database and re-sync it from chain. No key material is affected -- coins live on chain and are re-derived by the resync. Refuses with SpendInFlight while a spend is outstanding, so a reset can never race a hold. Requires params.confirm = true or refuses as INVALID_PARAMS; the on-wire acknowledgement is the confirmation, not a default. TOKEN-GATED.",
632            ControlMethod::CollateralBuffer => "READ-only: the $DIG this node recommends HOLDING, in DIG base units, and the funding state it is in -- plus the working behind the figure: the (owner, store, root) pairs THIS NODE serves, the epoch's pre-margin per-store requirement, the local margin in force (BASIS POINTS, `100` is +1%, never converted), the unreclaimed transition overlap, and the escalation headroom. Amounts are DIG base units (3 decimals, one base unit is 0.001 DIG) and never mojos, which are XCH's 1e-12 unit. The HORIZON the headroom assumed travels in the payload and is never implied: escalation is bounded at +12.5% per epoch and COMPOUNDS (x1.12 at one epoch, x1.60 at four, x4.62 at thirteen), so the same buffer over a different horizon is a different claim; `escalation_ceiling_micros` is a WORST CASE, not a forecast -- in the dead band the multiplier does not move. The FUNDING STATE is carried rather than left to each client to re-derive from thresholds, because two clients deriving it will disagree and the one that disagrees about a funding warning is the one an operator acts on; `short_now` and `dangerously_low` leave an epoch uncovered, `below_recommended_buffer` covers every epoch with no cushion and is a READOUT, never a notification. A node that cannot enumerate its served set, cannot read its reclaim state, cannot see its balance, or has no requirement to scale answers `unknown` WITH the reason -- never a zero, which here reads as NO BUFFER NEEDED and would have an operator post nothing. It is a SEPARATE method from `control.collateral.requirement` because that figure is consensus-derived while this one is local: it depends on this node's own served set, an operator preference, and a horizon this node chose. TOKEN-GATED although it is a read: the caller supplies nothing, so the answer is this node's OWN served set, preference and balance.",
633            ControlMethod::MirrorBondStates => "READ-only: the state of every mirror bond this node holds, keyed per (store, root), plus the $DIG those bonds have LOCKED. Eight states, and seven of them mean `no coin yet` for entirely different reasons: `bonded` (a coin id, epoch and the amount THAT COIN locks, read from the coin and not from today's requirement, plus `urls` -- the coin's own memo -- and `url_current` -- whether that memo still matches what this node advertises THIS PASS), `pending` (submitted, unconfirmed -- never a shortfall), `unfunded` (the ONLY genuine out-of-funds state, carrying how many DIG BASE UNITS this bond alone is short), `deferred` (the epoch requirement is unknown so no create can be priced -- the wallet may be full), `withheld` (Relayed provenance: held and deliberately never advertised), `disabled` (collateralisation is switched off node-wide), `unadvertised` (that switch ON, but no publishable advertise URL, so the node advertises nothing and a coin would bond nothing -- a fault a client MUST surface, and NOT the same as `disabled`) and `reclaiming` (a live coin whose money is STILL LOCKED until the reclaim confirms). Conflating `unfunded` with `withheld` or `disabled` produces hourly out-of-funds alarms about a healthy node, which is the defect this method removes. Amounts are DIG base units (3 decimals, one base unit is 0.001 DIG) and NEVER mojos, which are XCH's 1e-12 unit. `locked_dig_base_units` is the WHOLE-SET total including reclaiming coins, computed by the node: a client MUST NOT sum the page, which would under-report locked money by a page boundary and show unspendable funds as available. A `known` answer also carries `url_reconcile` beside `funding_wallet`: whether the daily URL-reconcile detector is armed, when it next runs, what it last observed, and how many bonds across the WHOLE set currently read `url_current: false` -- the standing `control.mirror.reconcile` exists to fix. A node that cannot enumerate its bonds, cannot read chain, cannot see its own in-flight creates, or cannot determine the provenance of what it holds answers `unknown` for the WHOLE call WITH the reason -- there is no per-row unknown and no empty-list fallback, because a truncated list and a complete one read the same. A page is bounded, ordered by ascending (store_id, root), and says via `complete` whether it is the whole set; resume from the `cursor` key you were HANDED. TOKEN-GATED although it is a read: the caller supplies nothing, so the answer is this node's OWN bond set and funding position.",
634            ControlMethod::MirrorReconcile => "Reconcile this node's mirror coins to its CURRENT advertise URL: reclaim every coin advertising a stale URL, then recreate it advertising the URL this node advertises today. Shared by the manual \"reset mirrors\" action and the same primitive the node's daily detector runs (dig-node#570) -- ONE reconcile primitive, not two implementations of a money-moving operation with different guards. `dry_run: true` prices the plan (capsules_stale, capsules_affordable, collateral reclaimed/relocked, fee) and spends nothing -- always price before executing for real. A refusal (`not_publishing`, `url_unchanged`, `no_mirror_coins`, `insufficient_funds`, `reconcile_in_progress`, `disabled`, `chain_unreadable`, `requirement_unknown`, `wallet_unavailable`, `funds_unmeasured`) means NOTHING was spent: the node validates the new URL and checks affordability BEFORE reclaiming anything, and does nothing at all rather than reclaim coins it cannot afford to recreate. There is no `completed` or `partial` outcome: a reclaim and a create are separate spend bundles, and a create's funding is a scan of CONFIRMED coins, so the node can only report `submitted` -- how many reclaims the mempool accepted/rejected and how many recreates are OWED to the ordinary create pass, never a `created` count it cannot back. The affordable prefix K is sized BEFORE any reclaim is attempted and exactly K are reclaimed, never more, so a `submitted` outcome always leaves the node's bond COUNT no worse off than before the call. TOKEN-GATED.",
635            ControlMethod::PairingRequest => "OPEN: request a control-token pairing; returns a pairing_id + pairing_code to compare.",
636            ControlMethod::PairingPoll => "OPEN: poll a pairing by id; once the operator approves, returns the scoped token once.",
637        }
638    }
639
640    /// Every catalogued method, in a stable order — the enumeration a machine reads to discover the
641    /// full control surface, and the anchor the conformance KATs pin against.
642    pub const ALL: &'static [ControlMethod] = &[
643        ControlMethod::Status,
644        ControlMethod::ConfigGet,
645        ControlMethod::ConfigSetUpstream,
646        ControlMethod::ConfigSetMirrorAdvertiseUrls,
647        ControlMethod::LogSetLevel,
648        ControlMethod::CacheGet,
649        ControlMethod::CacheSetCap,
650        ControlMethod::CacheClear,
651        ControlMethod::HostedStoresList,
652        ControlMethod::HostedStoresPin,
653        ControlMethod::HostedStoresUnpin,
654        ControlMethod::HostedStoresStatus,
655        ControlMethod::CapsuleFetch,
656        ControlMethod::SyncStatus,
657        ControlMethod::SyncTrigger,
658        ControlMethod::UpdaterStatus,
659        ControlMethod::UpdaterSetChannel,
660        ControlMethod::UpdaterPause,
661        ControlMethod::UpdaterResume,
662        ControlMethod::UpdaterCheckNow,
663        ControlMethod::PairingList,
664        ControlMethod::PairingApprove,
665        ControlMethod::PairingRevoke,
666        ControlMethod::PeerStatus,
667        ControlMethod::PeerCounts,
668        ControlMethod::PeersConnect,
669        ControlMethod::PeersDisconnect,
670        ControlMethod::ChiaPeersAdd,
671        ControlMethod::ChiaPeersList,
672        ControlMethod::ChiaPeersRemove,
673        ControlMethod::Subscribe,
674        ControlMethod::Unsubscribe,
675        ControlMethod::ListSubscriptions,
676        ControlMethod::WalletBalance,
677        ControlMethod::WalletCoins,
678        ControlMethod::WalletCoinById,
679        ControlMethod::WalletCoinSpend,
680        ControlMethod::WalletCoinsByParent,
681        ControlMethod::WalletArrivals,
682        ControlMethod::WalletPeak,
683        ControlMethod::WalletSyncStatus,
684        ControlMethod::WalletOperatorAddress,
685        ControlMethod::WalletBroadcast,
686        ControlMethod::WalletWatch,
687        ControlMethod::WalletUnwatch,
688        ControlMethod::WalletWatched,
689        ControlMethod::WalletReservationsHeld,
690        ControlMethod::WalletReservationsReserve,
691        ControlMethod::WalletReservationsRelease,
692        ControlMethod::WalletResetCoinDb,
693        ControlMethod::SpendsList,
694        ControlMethod::CollateralRequirement,
695        ControlMethod::CollateralMarginGet,
696        ControlMethod::CollateralMarginSet,
697        ControlMethod::CollateralBuffer,
698        ControlMethod::MirrorBondStates,
699        ControlMethod::MirrorReconcile,
700        ControlMethod::ProfilePutBody,
701        ControlMethod::ProfileGetBody,
702        ControlMethod::PairingRequest,
703        ControlMethod::PairingPoll,
704    ];
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use std::collections::BTreeSet;
711
712    #[test]
713    fn every_method_has_a_unique_wire_name() {
714        let names: BTreeSet<&str> = ControlMethod::ALL.iter().map(|m| m.name()).collect();
715        assert_eq!(
716            names.len(),
717            ControlMethod::ALL.len(),
718            "duplicate or missing wire names in the catalog"
719        );
720    }
721
722    #[test]
723    fn from_name_round_trips_every_method() {
724        for &m in ControlMethod::ALL {
725            assert_eq!(ControlMethod::from_name(m.name()), Some(m));
726        }
727        assert_eq!(ControlMethod::from_name("control.nope"), None);
728        assert_eq!(ControlMethod::from_name(""), None);
729    }
730
731    #[test]
732    fn the_token_less_surface_is_exactly_the_bootstrap_plus_the_chain_reads() {
733        // Written out rather than derived from `is_open_read`, so this pins the SET and not the
734        // implementation's opinion of itself. A method added to the open surface must be added
735        // here deliberately -- which is the review step a broadcast must never slip past.
736        let expected_open: BTreeSet<&str> = [
737            "pairing.request",
738            "pairing.poll",
739            "control.wallet.balance",
740            "control.wallet.coins",
741            "control.wallet.coinById",
742            "control.wallet.coinSpend",
743            "control.wallet.coinsByParent",
744            "control.wallet.peak",
745            "control.wallet.syncStatus",
746            "control.peerCounts",
747        ]
748        .into_iter()
749        .collect();
750        assert_eq!(
751            expected_open.len(),
752            10,
753            "the open surface is ten named methods"
754        );
755        let actual_open: BTreeSet<&str> = ControlMethod::ALL
756            .iter()
757            .filter(|m| !m.requires_auth())
758            .map(|m| m.name())
759            .collect();
760        assert_eq!(actual_open, expected_open);
761    }
762
763    /// **The gated wallet methods are the push, the arrival cursor, the operator address, the three
764    /// enrolment methods, the three reservation methods, and the coin-db reset.** The fixture varies one thing -- which wallet method is asked -- against a category
765    /// whose other members ARE open, so both nearest wrong implementations fail here: one that opens
766    /// the whole category (the state this crate shipped in at `1190a18`) and one that gates it
767    /// wholesale.
768    ///
769    /// Written out in catalog order rather than derived, so a method joining the gated side is a
770    /// deliberate edit here -- the review step a broadcast, or an enrolment, must never slip past.
771    #[test]
772    fn the_gated_wallet_methods_are_the_push_the_cursor_and_enrolment() {
773        let gated: Vec<&str> = ControlMethod::ALL
774            .iter()
775            .filter(|m| m.category() == Category::Wallet && m.requires_auth())
776            .map(|m| m.name())
777            .collect();
778        assert_eq!(
779            gated,
780            vec![
781                "control.wallet.arrivals",
782                // Gated for the SAME reason as `arrivals` above: the caller does not name the
783                // address, so the node volunteers its own node-to-address association. Here it is
784                // the machine wallet's, which no open read discloses.
785                "control.wallet.operatorAddress",
786                "control.wallet.broadcast",
787                "control.wallet.watch",
788                "control.wallet.unwatch",
789                "control.wallet.watched",
790                "control.wallet.reservations.held",
791                "control.wallet.reservations.reserve",
792                "control.wallet.reservations.release",
793                "control.wallet.resetCoinDb",
794            ]
795        );
796        assert!(!ControlMethod::WalletBroadcast.is_open_read());
797    }
798
799    /// **The arrival cursor is NOT an open read, and the reason is not "is it a chain read?".**
800    ///
801    /// The rule is *who names the address*. `control.wallet.arrivals` takes only a cursor, so the
802    /// node volunteers its OWN watched puzzle hashes and the receive history behind them -- the
803    /// node-to-address association, which is not public, and which a token-less caller could then
804    /// replay into the caller-addressed reads.
805    ///
806    /// The control keeps `control.wallet.coinById` in the same assertion: it is the neighbour the
807    /// analogy was drawn from, it is still open, and it stays open because its CALLER supplies the
808    /// coin id. Without that control this test would also pass on a wholesale gating of the wallet
809    /// category, which is a different (and wrong) implementation.
810    #[test]
811    fn the_arrival_cursor_is_not_an_open_read() {
812        assert!(
813            !ControlMethod::WalletArrivals.is_open_read(),
814            "control.wallet.arrivals discloses this node's OWN watched puzzle hashes to a caller \
815             that supplied nothing, so it MUST NOT be served token-less"
816        );
817        assert!(ControlMethod::WalletArrivals.requires_auth());
818        assert!(
819            ControlMethod::WalletCoinById.is_open_read(),
820            "the caller-addressed reads stay open -- the fix is the membership rule, not gating \
821             the wallet category"
822        );
823    }
824
825    /// **The control plane names every chain primitive `ChainSource` needs.**
826    ///
827    /// The list is written out rather than derived, because the property under test is a claim about
828    /// ANOTHER crate's trait (`dig-chainsource-interface`'s `ChainSource`) that no compiler here can
829    /// check. Five of its seven methods need a control method of their own. The other two need none:
830    /// `parent_spend` is a trait DEFAULT composed from `coin_record` + `coin_spend`, and
831    /// `resolve_singleton_lineage` is composed CLIENT-side from the primitives below rather than
832    /// served as a walk the node performs.
833    ///
834    /// `block_timestamp` is deliberately ABSENT from the control plane. dig-node's light client
835    /// (`chia-peer`'s `ChiaPeerProvider`) does not index block timestamps and answers `Unsupported`,
836    /// so a control method for it could only ever be refused — a surface that looks live and does
837    /// nothing. A consumer mirrors that refusal honestly; if one ever genuinely needs the value, the
838    /// method is an additive minor at that point.
839    ///
840    /// A missing name here is not a cosmetic gap: a client that cannot answer one of these cannot
841    /// implement the trait at all, which is what made a dig-profile mint structurally impossible
842    /// through the node before these two were added (dig_ecosystem#2572).
843    #[test]
844    fn the_catalog_serves_every_chain_source_primitive() {
845        for wire in [
846            "control.wallet.coinById",      // coin_record
847            "control.wallet.coins",         // coin_records_by_puzzle_hash
848            "control.wallet.peak",          // peak_height
849            "control.wallet.coinsByParent", // coin_records_by_parent
850            "control.wallet.coinSpend",     // coin_spend
851        ] {
852            assert!(
853                ControlMethod::from_name(wire).is_some(),
854                "{wire} is required to implement ChainSource over the control plane"
855            );
856        }
857    }
858
859    /// **The two chain primitives are `coinById`'s neighbours, not `arrivals`'.**
860    ///
861    /// Each takes a caller-supplied coin id and returns a deterministic public chain fact,
862    /// so the membership rule — *who names the subject* — puts them on the open side. The gated
863    /// control in the same assertion is what makes the test load-bearing: without it, a wholesale
864    /// opening of the wallet category would pass, and that is a different (and wrong) implementation.
865    #[test]
866    fn the_chain_primitives_are_caller_named_open_reads() {
867        for method in [
868            ControlMethod::WalletCoinSpend,
869            ControlMethod::WalletCoinsByParent,
870        ] {
871            assert!(
872                method.is_open_read(),
873                "{} names its subject in the request and discloses no node-to-address \
874                 association, exactly like control.wallet.coinById",
875                method.name()
876            );
877            assert!(!method.requires_auth());
878        }
879        assert!(
880            ControlMethod::WalletArrivals.requires_auth(),
881            "the caller-supplies-nothing read stays gated -- the rule is who names the subject, \
882             not whether the bytes are on chain"
883        );
884        assert!(ControlMethod::WalletBroadcast.requires_auth());
885    }
886
887    /// **All three enrolment methods are gated — including the one that only reads.**
888    ///
889    /// `control.wallet.watch` and `.unwatch` aim what the node follows, so they are mutations and the
890    /// question barely arises. `control.wallet.watched` is the one a future reader will be tempted to
891    /// open, because it returns nothing but public keys and every other wallet READ in this catalog is
892    /// open. It stays gated under the SAME rule that gates `control.wallet.arrivals`: the caller
893    /// supplies nothing, so the node volunteers its OWN enrolled keys — the node-to-key association,
894    /// which is not public, and which a token-less caller could replay straight into the
895    /// caller-addressed reads.
896    ///
897    /// The control keeps `control.wallet.coinById` open in the same assertion. Without it this test
898    /// would also pass on a wholesale gating of the wallet category, which is a different (and wrong)
899    /// implementation.
900    #[test]
901    fn the_enrolment_methods_are_gated_including_the_read() {
902        for wire in [
903            "control.wallet.watch",
904            "control.wallet.unwatch",
905            "control.wallet.watched",
906        ] {
907            let method = ControlMethod::from_name(wire)
908                .unwrap_or_else(|| panic!("{wire} must be in the catalog"));
909            assert!(
910                !method.is_open_read(),
911                "{wire} either aims this node's subscriptions or names the keys it already \
912                 follows, so it MUST NOT be served token-less"
913            );
914            assert!(method.requires_auth(), "{wire} must require the token");
915            assert_eq!(method.category(), Category::Wallet);
916            assert_eq!(method.routing(), Routing::Delegated);
917        }
918        assert!(
919            ControlMethod::WalletCoinById.is_open_read(),
920            "the caller-addressed reads stay open -- enrolment is gated by the membership rule, \
921             not by gating the wallet category"
922        );
923    }
924
925    #[test]
926    fn only_pairing_bootstrap_is_open_bootstrap_routed() {
927        for &m in ControlMethod::ALL {
928            let open_bootstrap = matches!(
929                m,
930                ControlMethod::PairingRequest | ControlMethod::PairingPoll
931            );
932            assert_eq!(
933                m.routing() == Routing::OpenBootstrap,
934                open_bootstrap,
935                "{} routing mismatch",
936                m.name()
937            );
938        }
939    }
940
941    #[test]
942    fn pairing_admin_methods_are_exactly_three() {
943        let admin: Vec<&str> = ControlMethod::ALL
944            .iter()
945            .filter(|m| m.is_pairing_admin())
946            .map(|m| m.name())
947            .collect();
948        assert_eq!(
949            admin,
950            vec![
951                "control.pairing.list",
952                "control.pairing.approve",
953                "control.pairing.revoke"
954            ]
955        );
956    }
957
958    /// **The master-token tier is the pairing lifecycle PLUS the trusted-peer mutations.**
959    ///
960    /// The set is asserted whole, because the risk is a method quietly joining or leaving it. The
961    /// two non-pairing members are here for a stated reason — `chiaPeers.add` grants authority
962    /// that SURVIVES `pairing.revoke`, so a paired token holding it escapes the very remedy for a
963    /// compromised paired app.
964    #[test]
965    fn the_master_token_tier_is_pairing_admin_plus_the_trusted_peer_mutations() {
966        let master: BTreeSet<&str> = ControlMethod::ALL
967            .iter()
968            .filter(|m| m.requires_master_token())
969            .map(|m| m.name())
970            .collect();
971        let expected: BTreeSet<&str> = [
972            "control.pairing.list",
973            "control.pairing.approve",
974            "control.pairing.revoke",
975            "control.chiaPeers.add",
976            "control.chiaPeers.remove",
977        ]
978        .into_iter()
979        .collect();
980        assert_eq!(master, expected);
981
982        // Pairing administration is a STRICT subset, not a synonym: a gate that consults
983        // `is_pairing_admin` instead of `requires_master_token` lets a paired token add a peer.
984        for &m in ControlMethod::ALL {
985            assert!(
986                !m.is_pairing_admin() || m.requires_master_token(),
987                "{} is pairing-admin but not master-tier",
988                m.name()
989            );
990        }
991        assert!(
992            master.len()
993                > ControlMethod::ALL
994                    .iter()
995                    .filter(|m| m.is_pairing_admin())
996                    .count(),
997            "the two predicates must not be interchangeable"
998        );
999
1000        // Master implies the token is required at all.
1001        for &m in ControlMethod::ALL {
1002            assert!(
1003                !m.requires_master_token() || m.requires_auth(),
1004                "{}",
1005                m.name()
1006            );
1007        }
1008    }
1009
1010    /// **The trust wording stays inside NC-12's authorisation: a node the operator RUNS.**
1011    ///
1012    /// NC-12 permits trust only from "the operator declaring it their own node". Widening that to
1013    /// vouching moves the case outside the justification for the unbounded authority the entry
1014    /// carries, and "a node you vouch for" is a phrase somebody can be talked into applying to a
1015    /// stranger's address.
1016    #[test]
1017    fn the_add_summary_authorises_only_a_node_the_operator_runs() {
1018        let summary = ControlMethod::ChiaPeersAdd.summary().to_lowercase();
1019        assert!(
1020            summary.contains("a node you run"),
1021            "add must name the operator-run scope, got: {summary}"
1022        );
1023        for widened in ["vouch", "otherwise trust", "trust yourself", "recommend"] {
1024            assert!(
1025                !summary.contains(widened),
1026                "add summary widens operator trust past NC-12 with {widened:?}: {summary}"
1027            );
1028        }
1029    }
1030
1031    #[test]
1032    fn delegated_set_matches_the_engine_surface() {
1033        let delegated: BTreeSet<&str> = ControlMethod::ALL
1034            .iter()
1035            .filter(|m| m.routing() == Routing::Delegated)
1036            .map(|m| m.name())
1037            .collect();
1038        let expected: BTreeSet<&str> = [
1039            "control.wallet.coins",
1040            "control.wallet.coinById",
1041            "control.wallet.coinSpend",
1042            "control.wallet.coinsByParent",
1043            "control.wallet.arrivals",
1044            "control.wallet.peak",
1045            "control.wallet.syncStatus",
1046            "control.wallet.broadcast",
1047            "control.wallet.watch",
1048            "control.wallet.unwatch",
1049            "control.wallet.watched",
1050            "control.wallet.reservations.held",
1051            "control.wallet.reservations.reserve",
1052            "control.wallet.reservations.release",
1053            "control.wallet.resetCoinDb",
1054            "control.profile.putBody",
1055            "control.profile.getBody",
1056            "control.peerStatus",
1057            "control.peerCounts",
1058            "control.peers.connect",
1059            "control.peers.disconnect",
1060            "control.subscribe",
1061            "control.unsubscribe",
1062            "control.listSubscriptions",
1063            "control.wallet.balance",
1064        ]
1065        .into_iter()
1066        .collect();
1067        assert_eq!(delegated, expected);
1068    }
1069
1070    /// **The trusted-Chia-peer methods are declared, gated, and say what they cost.**
1071    ///
1072    /// A trusted peer BYPASSES corroboration (NC-12: dialled peers are untrusted and agreement
1073    /// across ~5 concurrently-queried peers is what makes a read safe). The catalog is what a
1074    /// machine reads before offering the control, so the cost is stated HERE and not only in a
1075    /// doc page — a client that surfaces `summary()` surfaces the warning with it.
1076    #[test]
1077    fn the_trusted_chia_peer_methods_are_gated_and_disclose_the_corroboration_bypass() {
1078        let declared: BTreeSet<&str> = ControlMethod::ALL.iter().map(|m| m.name()).collect();
1079        for name in [
1080            "control.chiaPeers.add",
1081            "control.chiaPeers.list",
1082            "control.chiaPeers.remove",
1083        ] {
1084            assert!(declared.contains(name), "{name} is not in the catalog");
1085            let m = ControlMethod::from_name(name).expect("from_name round-trips");
1086            assert_eq!(m.category(), Category::Peers, "{name} is a peers method");
1087            assert_eq!(m.routing(), Routing::Owned, "{name} is served by the shell");
1088            assert!(m.requires_auth(), "{name} must require the control token");
1089            assert!(!m.is_open_read(), "{name} is not an open read");
1090        }
1091        // The MUTATIONS need the MASTER token; the READ deliberately does not. `add` writes
1092        // standing, corroboration-free authority that outlives the token that wrote it — a paired
1093        // token must not be able to install it, and `remove` is the only way back out.
1094        assert!(ControlMethod::ChiaPeersAdd.requires_master_token());
1095        assert!(ControlMethod::ChiaPeersRemove.requires_master_token());
1096        assert!(
1097            !ControlMethod::ChiaPeersList.requires_master_token(),
1098            "list grants nothing that outlives the token; gating it would blind a paired client \
1099             to the trust state it is subject to"
1100        );
1101        // The COST, not merely the capability: the two methods that change the trusted set must
1102        // name the bypass. A summary that only described the action would let a client offer the
1103        // control while silently withholding what it gives up.
1104        for name in ["control.chiaPeers.add", "control.chiaPeers.remove"] {
1105            let summary = ControlMethod::from_name(name).unwrap().summary();
1106            assert!(
1107                summary.to_lowercase().contains("corroboration"),
1108                "{name} summary must name the corroboration bypass, got: {summary}"
1109            );
1110        }
1111    }
1112
1113    /// **`control.config.setMirrorAdvertiseUrls` stays ORDINARY tier — it installs no principal.**
1114    ///
1115    /// It outlives the token exactly like `chiaPeers.add` and the proposed `config.setUpstream`
1116    /// promotion (this repo's #40) — the persisted override survives `pairing.revoke` just as
1117    /// theirs do. The discriminator this contract's own doc states is narrower than "outlives the
1118    /// token": whether the node will thereafter BELIEVE, OBEY, or SPEAK TO whatever was installed.
1119    /// `chiaPeers.add` makes the node trust a peer's chain answers without corroboration;
1120    /// `config.setUpstream` makes the node FORWARD calls to a third party. This method changes
1121    /// only what this node broadcasts ABOUT ITSELF in its own mirror-coin memo — the node never
1122    /// dials the value, never trusts bytes FROM it, and never forwards anything TO it. A caller
1123    /// cannot use it to make the node trust or obey anyone new, the same reason `cache.setCap` and
1124    /// `log.setLevel` stay ordinary despite also persisting.
1125    #[test]
1126    fn set_mirror_advertise_urls_is_ordinary_tier_and_config_category() {
1127        let m = ControlMethod::ConfigSetMirrorAdvertiseUrls;
1128        assert_eq!(m.name(), "control.config.setMirrorAdvertiseUrls");
1129        assert_eq!(m.category(), Category::Config);
1130        assert_eq!(m.routing(), Routing::Owned);
1131        assert!(m.requires_auth(), "a mutation on this plane is never open");
1132        assert!(!m.requires_master_token());
1133        assert!(!m.is_open_read());
1134        assert!(!m.is_pairing_admin());
1135    }
1136
1137    /// **`control.mirror.reconcile` is ORDINARY tier and Collateral category.**
1138    ///
1139    /// It moves real $DIG, but by the same discriminator
1140    /// [`ControlMethod::requires_master_token`] states: it installs no principal this node will
1141    /// thereafter believe, obey, or forward requests to. Reconciling changes only which URL THIS
1142    /// node's OWN mirror coins advertise -- it never dials a value the caller supplies, never
1143    /// trusts bytes read from anywhere new, and never routes a call to a third party. That is the
1144    /// same reasoning `control.config.setMirrorAdvertiseUrls` and `control.collateral.margin.set`
1145    /// rest on, not an exemption because this method happens to spend money.
1146    #[test]
1147    fn mirror_reconcile_is_ordinary_tier_and_collateral_category() {
1148        let m = ControlMethod::MirrorReconcile;
1149        assert_eq!(m.name(), "control.mirror.reconcile");
1150        assert_eq!(m.category(), Category::Collateral);
1151        assert_eq!(m.routing(), Routing::Owned);
1152        assert!(m.requires_auth(), "a money-moving mutation is never open");
1153        assert!(!m.requires_master_token());
1154        assert!(!m.is_open_read());
1155        assert!(!m.is_pairing_admin());
1156    }
1157
1158    #[test]
1159    fn every_method_has_a_nonempty_summary() {
1160        for &m in ControlMethod::ALL {
1161            assert!(!m.summary().is_empty(), "{} has no summary", m.name());
1162        }
1163    }
1164}