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, one coin by id, peak)
55    /// plus the push of 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.coinById` — read ONE coin record by coin id, spent or unspent.
143    WalletCoinById,
144    /// `control.wallet.peak` — read the node's current chain peak height.
145    WalletPeak,
146    /// `control.wallet.broadcast` — push an ALREADY-SIGNED spend bundle to the network.
147    WalletBroadcast,
148
149    // ---- Pairing bootstrap (OPEN — no token) ----
150    /// `pairing.request` — request a control-token pairing (returns a code to compare).
151    PairingRequest,
152    /// `pairing.poll` — poll a pairing; once the operator approves, returns the scoped token once.
153    PairingPoll,
154}
155
156impl ControlMethod {
157    /// The stable JSON-RPC wire name. Never derived from anything else — the published contract.
158    pub const fn name(self) -> &'static str {
159        match self {
160            ControlMethod::Status => "control.status",
161            ControlMethod::ConfigGet => "control.config.get",
162            ControlMethod::ConfigSetUpstream => "control.config.setUpstream",
163            ControlMethod::LogSetLevel => "control.log.setLevel",
164            ControlMethod::CacheGet => "control.cache.get",
165            ControlMethod::CacheSetCap => "control.cache.setCap",
166            ControlMethod::CacheClear => "control.cache.clear",
167            ControlMethod::HostedStoresList => "control.hostedStores.list",
168            ControlMethod::HostedStoresPin => "control.hostedStores.pin",
169            ControlMethod::HostedStoresUnpin => "control.hostedStores.unpin",
170            ControlMethod::HostedStoresStatus => "control.hostedStores.status",
171            ControlMethod::SyncStatus => "control.sync.status",
172            ControlMethod::SyncTrigger => "control.sync.trigger",
173            ControlMethod::UpdaterStatus => "control.updater.status",
174            ControlMethod::UpdaterSetChannel => "control.updater.setChannel",
175            ControlMethod::UpdaterPause => "control.updater.pause",
176            ControlMethod::UpdaterResume => "control.updater.resume",
177            ControlMethod::UpdaterCheckNow => "control.updater.checkNow",
178            ControlMethod::PairingList => "control.pairing.list",
179            ControlMethod::PairingApprove => "control.pairing.approve",
180            ControlMethod::PairingRevoke => "control.pairing.revoke",
181            ControlMethod::PeerStatus => "control.peerStatus",
182            ControlMethod::PeersConnect => "control.peers.connect",
183            ControlMethod::PeersDisconnect => "control.peers.disconnect",
184            ControlMethod::Subscribe => "control.subscribe",
185            ControlMethod::Unsubscribe => "control.unsubscribe",
186            ControlMethod::ListSubscriptions => "control.listSubscriptions",
187            ControlMethod::WalletBalance => "control.wallet.balance",
188            ControlMethod::WalletCoins => "control.wallet.coins",
189            ControlMethod::WalletCoinById => "control.wallet.coinById",
190            ControlMethod::WalletPeak => "control.wallet.peak",
191            ControlMethod::WalletBroadcast => "control.wallet.broadcast",
192            ControlMethod::PairingRequest => "pairing.request",
193            ControlMethod::PairingPoll => "pairing.poll",
194        }
195    }
196
197    /// Resolve a wire name back to its [`ControlMethod`], or `None` for an unknown name.
198    pub fn from_name(name: &str) -> Option<ControlMethod> {
199        ControlMethod::ALL
200            .iter()
201            .copied()
202            .find(|m| m.name() == name)
203    }
204
205    /// Does calling this method require the local control token?
206    ///
207    /// Three groups are reachable WITHOUT one, and they are open for two different reasons:
208    ///
209    /// - the pairing bootstrap (`pairing.request` / `pairing.poll`), so a token-less client can
210    ///   obtain a token at all;
211    /// - the wallet CHAIN READS ([`Category::Wallet`] minus the push), because each needs only
212    ///   PUBLIC chain data — an address, or a coin id on `control.wallet.coinById`; never a seed, a
213    ///   key, or a signature — and dig-node has served
214    ///   `control.wallet.balance` open since #1851. A person whose node runs as a service with an
215    ///   unreadable token file can still see their own money.
216    ///
217    /// `control.wallet.broadcast` is deliberately NOT in that second group. It puts bytes on the
218    /// network, so the token is what stands between a local process and a broadcast, and its
219    /// refusal genuinely means *unauthorized* — see [`ControlMethod::is_open_read`].
220    pub const fn requires_auth(self) -> bool {
221        !self.is_open_read()
222            && !matches!(
223                self,
224                ControlMethod::PairingRequest | ControlMethod::PairingPoll
225            )
226    }
227
228    /// Is this an OPEN chain read — served without a control token?
229    ///
230    /// Stated on the contract rather than discovered by calling, because the two refusals a client
231    /// can get here demand OPPOSITE remedies. On an open read, `UNAUTHORIZED` can only come from a
232    /// node build that predates the method and gates it generically, so the remedy is an upgrade.
233    /// On a gated method — the push — `UNAUTHORIZED` means exactly what it says, and the remedy is
234    /// the token. A client that maps the two the same way sends somebody to fix the wrong thing.
235    pub const fn is_open_read(self) -> bool {
236        matches!(
237            self,
238            ControlMethod::WalletBalance
239                | ControlMethod::WalletCoins
240                | ControlMethod::WalletCoinById
241                | ControlMethod::WalletPeak
242        )
243    }
244
245    /// Is this a PAIRING-ADMINISTRATION method that requires the MASTER control token specifically?
246    ///
247    /// A paired (scoped) token can drive ordinary `control.*` mutations but MUST NOT mint more
248    /// tokens or revoke itself — so listing/approving/revoking pairings requires the master token
249    /// (a local file read), never a paired token.
250    pub const fn is_pairing_admin(self) -> bool {
251        matches!(
252            self,
253            ControlMethod::PairingList
254                | ControlMethod::PairingApprove
255                | ControlMethod::PairingRevoke
256        )
257    }
258
259    /// How the node routes this method (shell-owned, engine-delegated, or open bootstrap).
260    pub const fn routing(self) -> Routing {
261        match self {
262            ControlMethod::PeerStatus
263            | ControlMethod::PeersConnect
264            | ControlMethod::PeersDisconnect
265            | ControlMethod::Subscribe
266            | ControlMethod::Unsubscribe
267            | ControlMethod::ListSubscriptions
268            | ControlMethod::WalletBalance
269            | ControlMethod::WalletCoins
270            | ControlMethod::WalletCoinById
271            | ControlMethod::WalletPeak
272            | ControlMethod::WalletBroadcast => Routing::Delegated,
273            ControlMethod::PairingRequest | ControlMethod::PairingPoll => Routing::OpenBootstrap,
274            _ => Routing::Owned,
275        }
276    }
277
278    /// The functional area this method belongs to.
279    pub const fn category(self) -> Category {
280        match self {
281            ControlMethod::Status => Category::Status,
282            ControlMethod::ConfigGet | ControlMethod::ConfigSetUpstream => Category::Config,
283            ControlMethod::LogSetLevel => Category::Log,
284            ControlMethod::CacheGet | ControlMethod::CacheSetCap | ControlMethod::CacheClear => {
285                Category::Cache
286            }
287            ControlMethod::HostedStoresList
288            | ControlMethod::HostedStoresPin
289            | ControlMethod::HostedStoresUnpin
290            | ControlMethod::HostedStoresStatus => Category::HostedStores,
291            ControlMethod::SyncStatus | ControlMethod::SyncTrigger => Category::Sync,
292            ControlMethod::UpdaterStatus
293            | ControlMethod::UpdaterSetChannel
294            | ControlMethod::UpdaterPause
295            | ControlMethod::UpdaterResume
296            | ControlMethod::UpdaterCheckNow => Category::Updater,
297            ControlMethod::PairingList
298            | ControlMethod::PairingApprove
299            | ControlMethod::PairingRevoke
300            | ControlMethod::PairingRequest
301            | ControlMethod::PairingPoll => Category::Pairing,
302            ControlMethod::PeerStatus
303            | ControlMethod::PeersConnect
304            | ControlMethod::PeersDisconnect => Category::Peers,
305            ControlMethod::Subscribe
306            | ControlMethod::Unsubscribe
307            | ControlMethod::ListSubscriptions => Category::Subscriptions,
308            ControlMethod::WalletBalance
309            | ControlMethod::WalletCoins
310            | ControlMethod::WalletCoinById
311            | ControlMethod::WalletPeak
312            | ControlMethod::WalletBroadcast => Category::Wallet,
313        }
314    }
315
316    /// A one-line human/agent description for the discovery catalogue.
317    pub const fn summary(self) -> &'static str {
318        match self {
319            ControlMethod::Status => "A rich node status snapshot (version, uptime, addr, cache, hosted/pinned counts, sync availability).",
320            ControlMethod::ConfigGet => "The node's effective configuration (addr/port, upstream + override, cache dir/shared, config path, sync availability).",
321            ControlMethod::ConfigSetUpstream => "Persist an upstream-RPC override; takes effect on next node start (requires_restart).",
322            ControlMethod::LogSetLevel => "Live-swap the running node's tracing EnvFilter directive (not persisted).",
323            ControlMethod::CacheGet => "The on-disk content-cache view: cap_bytes, used_bytes, dir, shared.",
324            ControlMethod::CacheSetCap => "Set the on-disk cache size cap in bytes (floored at 64 MiB).",
325            ControlMethod::CacheClear => "Delete all locally cached DIG content.",
326            ControlMethod::HostedStoresList => "Every held/pinned store, merged, with each store's cached capsules and a pinned flag.",
327            ControlMethod::HostedStoresPin => "Pin a store (storeId[:rootHash]); pre-fetches the capsule when a root is given and §21 sync is available.",
328            ControlMethod::HostedStoresUnpin => "Unpin a store and evict its cached capsules.",
329            ControlMethod::HostedStoresStatus => "Per-store status: pinned flag, cached capsules, total bytes.",
330            ControlMethod::SyncStatus => "Whether authenticated §21 whole-store sync is available, plus pinned-store cache coverage.",
331            ControlMethod::SyncTrigger => "Trigger a §21 sync for one capsule (storeId + root).",
332            ControlMethod::UpdaterStatus => "The DIG auto-update beacon's current status (proxied from dig-updater).",
333            ControlMethod::UpdaterSetChannel => "Set the beacon's update channel (\"nightly\" | \"stable\").",
334            ControlMethod::UpdaterPause => "Suspend the beacon's auto-updates (optionally until a unix time).",
335            ControlMethod::UpdaterResume => "Resume the beacon's auto-updates.",
336            ControlMethod::UpdaterCheckNow => "Force an immediate beacon update check.",
337            ControlMethod::PairingList => "List pending pairing requests and issued paired tokens (MASTER token only).",
338            ControlMethod::PairingApprove => "Approve a pending pairing, minting a scoped token (MASTER token only).",
339            ControlMethod::PairingRevoke => "Revoke an issued paired token by token_id (MASTER token only).",
340            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).",
341            ControlMethod::PeersConnect => "Dial a peer by address, or resolve an already-connected peer_id, via the live gossip pool.",
342            ControlMethod::PeersDisconnect => "Drop a pooled peer by peer_id, closing its mTLS link (idempotent).",
343            ControlMethod::Subscribe => "Subscribe the node to a store it actively watches and gap-fills.",
344            ControlMethod::Unsubscribe => "Stop watching a store.",
345            ControlMethod::ListSubscriptions => "The node's persisted subscription set + count.",
346            ControlMethod::WalletCoins => "READ-only: the spendable coin records for an address + asset, with the tier that answered and the height they reflect.",
347            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.",
348            ControlMethod::WalletPeak => "READ-only: the node's current chain peak height, independent of any address.",
349            ControlMethod::WalletBroadcast => "Push an ALREADY-SIGNED spend bundle to the network; the node never signs. TOKEN-GATED.",
350            ControlMethod::WalletBalance => "READ-only: the confirmed spendable balance for an address + asset (plus pending, sync freshness, and the peak height it reflects).",
351            ControlMethod::PairingRequest => "OPEN: request a control-token pairing; returns a pairing_id + pairing_code to compare.",
352            ControlMethod::PairingPoll => "OPEN: poll a pairing by id; once the operator approves, returns the scoped token once.",
353        }
354    }
355
356    /// Every catalogued method, in a stable order — the enumeration a machine reads to discover the
357    /// full control surface, and the anchor the conformance KATs pin against.
358    pub const ALL: &'static [ControlMethod] = &[
359        ControlMethod::Status,
360        ControlMethod::ConfigGet,
361        ControlMethod::ConfigSetUpstream,
362        ControlMethod::LogSetLevel,
363        ControlMethod::CacheGet,
364        ControlMethod::CacheSetCap,
365        ControlMethod::CacheClear,
366        ControlMethod::HostedStoresList,
367        ControlMethod::HostedStoresPin,
368        ControlMethod::HostedStoresUnpin,
369        ControlMethod::HostedStoresStatus,
370        ControlMethod::SyncStatus,
371        ControlMethod::SyncTrigger,
372        ControlMethod::UpdaterStatus,
373        ControlMethod::UpdaterSetChannel,
374        ControlMethod::UpdaterPause,
375        ControlMethod::UpdaterResume,
376        ControlMethod::UpdaterCheckNow,
377        ControlMethod::PairingList,
378        ControlMethod::PairingApprove,
379        ControlMethod::PairingRevoke,
380        ControlMethod::PeerStatus,
381        ControlMethod::PeersConnect,
382        ControlMethod::PeersDisconnect,
383        ControlMethod::Subscribe,
384        ControlMethod::Unsubscribe,
385        ControlMethod::ListSubscriptions,
386        ControlMethod::WalletBalance,
387        ControlMethod::WalletCoins,
388        ControlMethod::WalletCoinById,
389        ControlMethod::WalletPeak,
390        ControlMethod::WalletBroadcast,
391        ControlMethod::PairingRequest,
392        ControlMethod::PairingPoll,
393    ];
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use std::collections::BTreeSet;
400
401    #[test]
402    fn every_method_has_a_unique_wire_name() {
403        let names: BTreeSet<&str> = ControlMethod::ALL.iter().map(|m| m.name()).collect();
404        assert_eq!(
405            names.len(),
406            ControlMethod::ALL.len(),
407            "duplicate or missing wire names in the catalog"
408        );
409    }
410
411    #[test]
412    fn from_name_round_trips_every_method() {
413        for &m in ControlMethod::ALL {
414            assert_eq!(ControlMethod::from_name(m.name()), Some(m));
415        }
416        assert_eq!(ControlMethod::from_name("control.nope"), None);
417        assert_eq!(ControlMethod::from_name(""), None);
418    }
419
420    #[test]
421    fn the_token_less_surface_is_exactly_the_bootstrap_plus_the_chain_reads() {
422        // Written out rather than derived from `is_open_read`, so this pins the SET and not the
423        // implementation's opinion of itself. A method added to the open surface must be added
424        // here deliberately -- which is the review step a broadcast must never slip past.
425        let expected_open: BTreeSet<&str> = [
426            "pairing.request",
427            "pairing.poll",
428            "control.wallet.balance",
429            "control.wallet.coins",
430            "control.wallet.coinById",
431            "control.wallet.peak",
432        ]
433        .into_iter()
434        .collect();
435        assert_eq!(
436            expected_open.len(),
437            6,
438            "the open surface is six named methods"
439        );
440        let actual_open: BTreeSet<&str> = ControlMethod::ALL
441            .iter()
442            .filter(|m| !m.requires_auth())
443            .map(|m| m.name())
444            .collect();
445        assert_eq!(actual_open, expected_open);
446    }
447
448    /// **The push is token-gated, and no other wallet method is.** The fixture varies one thing --
449    /// which wallet method is asked -- against a category whose other three members ARE open, so an
450    /// implementation that opened the whole category (the nearest wrong one) fails here.
451    #[test]
452    fn the_push_is_the_one_wallet_method_behind_the_token() {
453        let gated: Vec<&str> = ControlMethod::ALL
454            .iter()
455            .filter(|m| m.category() == Category::Wallet && m.requires_auth())
456            .map(|m| m.name())
457            .collect();
458        assert_eq!(gated, vec!["control.wallet.broadcast"]);
459        assert!(!ControlMethod::WalletBroadcast.is_open_read());
460    }
461
462    #[test]
463    fn only_pairing_bootstrap_is_open_bootstrap_routed() {
464        for &m in ControlMethod::ALL {
465            let open_bootstrap = matches!(
466                m,
467                ControlMethod::PairingRequest | ControlMethod::PairingPoll
468            );
469            assert_eq!(
470                m.routing() == Routing::OpenBootstrap,
471                open_bootstrap,
472                "{} routing mismatch",
473                m.name()
474            );
475        }
476    }
477
478    #[test]
479    fn pairing_admin_methods_are_exactly_three() {
480        let admin: Vec<&str> = ControlMethod::ALL
481            .iter()
482            .filter(|m| m.is_pairing_admin())
483            .map(|m| m.name())
484            .collect();
485        assert_eq!(
486            admin,
487            vec![
488                "control.pairing.list",
489                "control.pairing.approve",
490                "control.pairing.revoke"
491            ]
492        );
493    }
494
495    #[test]
496    fn delegated_set_matches_the_engine_surface() {
497        let delegated: BTreeSet<&str> = ControlMethod::ALL
498            .iter()
499            .filter(|m| m.routing() == Routing::Delegated)
500            .map(|m| m.name())
501            .collect();
502        let expected: BTreeSet<&str> = [
503            "control.wallet.coins",
504            "control.wallet.coinById",
505            "control.wallet.peak",
506            "control.wallet.broadcast",
507            "control.peerStatus",
508            "control.peers.connect",
509            "control.peers.disconnect",
510            "control.subscribe",
511            "control.unsubscribe",
512            "control.listSubscriptions",
513            "control.wallet.balance",
514        ]
515        .into_iter()
516        .collect();
517        assert_eq!(delegated, expected);
518    }
519
520    #[test]
521    fn every_method_has_a_nonempty_summary() {
522        for &m in ControlMethod::ALL {
523            assert!(!m.summary().is_empty(), "{} has no summary", m.name());
524        }
525    }
526}