1	//! Outbound peer pump (D9-1-2) — the production trigger loops every D5–D8
2	//! slice deferred ("rides D9 with the daemon lifecycle").
3	//!
4	//! One daemon-hosted loop drives three cadenced pumps against every trusted
5	//! peer of every member subnet, each a thin call over an already-shipped,
6	//! idempotent driver — the pump owns *when*, the drivers own *how*:
7	//!
8	//! - **registry push** ([`RegistryHost::advertise_local`] → one feed stream
9	//!   per peer): the node re-advertises its own rows; peers reconverge within
10	//!   one cadence (replay-is-recovery, the D4d/D8 push posture). Own rows
11	//!   only — no transitive gossip in v1: trust is pairwise per subnet, and a
12	//!   relayed third-party row would carry an origin the receiver cannot gate
13	//!   honestly (KNOWN-HAZARDS 7.5).
14	//! - **notif push** ([`crate::notifsync::emit_notif_feed`]): the full
15	//!   semilattice spool per subnet; the receiver's join dedups.
16	//! - **sync pull** ([`crate::sync::request_sync`]): anti-entropy for the
17	//!   minds. Want-refs are the union of [`crate::sync::select_refs`] (locally
18	//!   tracked branches the policy allows toward the peer) and
19	//!   **registry-derived** `a-<id>` refs (endpoints known in the shared
20	//!   subnets — how a fresh instance bootstraps a mind it has never held),
21	//!   both gated by the same [`SyncPolicy::allows`] the responder enforces
22	//!   (symmetric scoping).
23	//!
24	//! Conflicts a pull surfaces are reported through [`PumpHooks::on_pull`] —
25	//! the default production hook logs them loud. The bounded Psyche reconcile
26	//! turn itself still runs at the activation paths (ADR-0013): the daemon
27	//! does not yet host per-agent manifest runtimes (the 7.4 fan-out), so an
28	//! in-pump turn has no runtime to ride; conflict artifacts persist safely
29	//! meanwhile (REQ-HAZARD-CONFLICT-BOTH-PRESERVED).
30	//!
31	//! ## Wire discipline (inherited from the test suites, now production)
32	//!
33	//! Every pump write is fire-and-forget (`op_id: None`) — a journaled
34	//! `net_stream_send` blocks on its ack and DISCARDS interleaved events
35	//! (gotcha #1). Stream opens for push feeds carry no op either: the feeds
36	//! are idempotent by construction. The sync pull's `open_op` IS durable
37	//! (exactly-once open identity), minted from a dedicated [`EpochSource`]
38	//! file so a daemon restart can never reuse one.
39	// [impl->REQ-INST-5]
40	// [impl->REQ-INST-7]
41	// [impl->REQ-NOTIF-1]
42
43	use std::collections::HashMap;
44	use std::io;
45	use std::path::PathBuf;
46	use std::sync::atomic::{AtomicBool, Ordering};
47	use std::sync::Arc;
48	use std::thread;
49	use std::time::{Duration, Instant};
50
51	use spt_net::net::endpoint::addr_for_node_hex;
52	use spt_store::contextstore::ContextStore;
53	use spt_store::epoch::EpochSource;
54	use spt_store::notif::NotifStore;
55	use spt_store::perch;
56	use spt_store::subnet::SubnetStore;
57	use spt_store::trust::TrustStore;
58	use spt_store::visibility::VisibilityStore;
59
60	use crate::brain::Brain;
61	use crate::config::DaemonConfig;
62	use crate::consent::{decide, most_recently_active};
63	use crate::notif::{produce_consent_notif, NotifSurfacePolicy};
64	use crate::notifsync::emit_notif_feed;
65	use crate::propagate::{request_update, UpdatePullOutcome};
66	use crate::registryhost::RegistryHost;
67	use crate::relcache::ReleaseCache;
68	use crate::release::VerifyPolicy;
69	use crate::sync::{request_sync, select_refs, SyncPolicy, SyncPullReport};
70	use crate::update::BrokerAbi;
71
72	/// The pump's tick granularity — each cadence fires when its period elapsed.
73	const TICK: Duration = Duration::from_millis(200);
74
75	/// Resolve a trusted peer's stable name (lowercase-hex pubkey) to a dialable
76	/// address (the serde JSON `net-dial` carries). Production resolution lets
77	/// discovery do the work ([`production_resolver`]); hermetic tests inject a
78	/// concrete address map (loopback endpoints run no discovery).
79	pub type PeerResolver = Box<dyn Fn(&str) -> Option<serde_json::Value> + Send>;
80
81	/// The production resolver: an id-only iroh address — mDNS on the LAN, n0
82	/// DNS on the WAN (`addr_for_node_hex`).
83	pub fn production_resolver() -> PeerResolver {
84	    Box::new(|hex| addr_for_node_hex(hex).and_then(|a| serde_json::to_value(a).ok()))
85	}
86
87	/// Where this node's pump-side stores live. Production = the canonical
88	/// SPT_HOME layout; the loopback E2E gives its in-process "node" distinct
89	/// roots so two nodes can share one test process.
90	pub struct PumpPaths {
91	    /// The perch tree (registry advertisement scan).
92	    pub owlery: PathBuf,
93	    /// The context store root ([`ContextStore::open_or_init_in`]).
94	    pub ctx_root: PathBuf,
95	    /// The notif spool DB ([`NotifStore::open_at`]).
96	    pub notif_db: PathBuf,
97	    /// Transient pull scratch.
98	    pub scratch: PathBuf,
99	    /// `subnet.json` ([`SubnetStore::load_from`]).
100	    pub subnets: PathBuf,
101	    /// `trust/peers.json` ([`TrustStore::load_from`]).
102	    pub trust: PathBuf,
103	    /// `visibility.json` ([`VisibilityStore::load_from`]).
104	    pub vis: PathBuf,
105	    /// The pump's durable open-op counter (its own file — colliding with the
106	    /// registry's advertisement epochs would break neither, but a shared
107	    /// in-memory cursor pair over one file double-mints; separate files keep
108	    /// each `EpochSource` the sole writer of its counter).
109	    pub ops: PathBuf,
110	    /// The staged-release cache dir ([`ReleaseCache::open`]).
111	    pub releases: PathBuf,
112	    /// `release-keys.json` ([`VerifyPolicy::load_from`]).
113	    pub release_keys: PathBuf,
114	    /// The wake-edge freshness-pull marker (D9-2, REQ-INST-3): a
115	    /// dormant→active transition drops it; the pump consumes it and forces a
116	    /// sync round NOW instead of waiting out `cadence.sync`
117	    /// ([`crate::resting::PULL_MARKER_FILE`]).
118	    pub pull_marker: PathBuf,
119	}
120
121	impl PumpPaths {
122	    /// The canonical SPT_HOME layout.
123	    pub fn production() -> PumpPaths {
124	        let home = perch::spt_home();
125	        let identity = perch::identity_dir();
126	        PumpPaths {
127	            owlery: perch::owlery_dir(),
128	            ctx_root: home.join("context"),
129	            notif_db: identity.join("notifs.db"),
130	            scratch: home.join("scratch").join("pump"),
131	            subnets: identity.join("subnet.json"),
132	            trust: identity.join("trust").join("peers.json"),
133	            vis: identity.join("visibility.json"),
134	            ops: identity.join("pump-ops.json"),
135	            releases: home.join("releases"),
136	            release_keys: identity.join("release-keys.json"),
137	            pull_marker: identity.join(crate::resting::PULL_MARKER_FILE),
138	        }
139	    }
140	}
141
142	/// Pump cadences (config-driven in production: [`DaemonConfig`]).
143	#[derive(Debug, Clone, Copy)]
144	pub struct PumpCadence {
145	    pub registry: Duration,
146	    pub notif: Duration,
147	    pub sync: Duration,
148	    pub update: Duration,
149	}
150
151	impl PumpCadence {
152	    /// From the daemon config knobs.
153	    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
154	        PumpCadence {
155	            registry: cfg.registry_pump_period,
156	            notif: cfg.notif_pump_period,
157	            sync: cfg.sync_pull_period,
158	            update: cfg.update_check_period,
159	        }
160	    }
161	}
162
163	/// The pump's resolved knobs: cadences + the REQ-UPD-4 consent stance.
164	#[derive(Debug, Clone, Copy)]
165	pub struct PumpConfig {
166	    pub cadence: PumpCadence,
167	    /// `true` = the explicit opt-in to prompt-free updates; default `false`
168	    /// surfaces every staged release as a consent notif.
169	    pub full_auto_update: bool,
170	}
171
172	impl PumpConfig {
173	    /// From the daemon config knobs.
174	    pub fn from_config(cfg: &DaemonConfig) -> PumpConfig {
175	        PumpConfig {
176	            cadence: PumpCadence::from_config(cfg),
177	            full_auto_update: cfg.full_auto_update,
178	        }
179	    }
180	}
181
182	/// A pull observer: `(peer_hex, report)` after every completed sync pull.
183	pub type OnPull = Box<dyn FnMut(&str, &SyncPullReport) + Send>;
184
185	/// Observation hooks. `on_pull` fires after every completed sync pull —
186	/// the conflict surface (ADR-0013) and the E2E's probe.
187	pub struct PumpHooks {
188	    pub on_pull: OnPull,
189	}
190
191	impl Default for PumpHooks {
192	    /// The production hook: conflicts are loud, never silent — the artifacts
193	    /// already persist (hazard 6.6); the reconcile turn runs at activation.
194	    fn default() -> PumpHooks {
195	        PumpHooks {
196	            on_pull: Box::new(|peer, report| {
197	                for (branch, file) in report.conflicts() {
198	                    eprintln!("SYNC_CONFLICT:{peer}:{branch}:{file}");
199	                }
200	            }),
201	        }
202	    }
203	}
204
205	/// Run the pump until `stop` is set. Owns one brain IPC connection and a
206	/// per-peer connection cache (an IO failure against a peer drops its cache
207	/// row; the next tick redials — conns are rebuilt freely, nothing leaks
208	/// because the broker's closed-watcher reaps dead conns).
209	pub fn run_peer_pump(
210	    broker_name: &str,
211	    registry: Arc<RegistryHost>,
212	    paths: &PumpPaths,
213	    resolver: PeerResolver,
214	    config: PumpConfig,
215	    mut hooks: PumpHooks,
216	    stop: &AtomicBool,
217	) -> io::Result<()> {
218	    let PumpConfig { cadence, full_auto_update } = config;
219	    let mut brain = connect(broker_name)?;
220	    let mut ops = EpochSource::load_from(&paths.ops);
221	    let mut conns: HashMap<String, u64> = HashMap::new();
222	    // Stagger from "everything due now" — the first tick primes peers.
223	    let far = Instant::now() - Duration::from_secs(86_400);
224	    let (mut last_reg, mut last_notif, mut last_sync, mut last_upd) = (far, far, far, far);
225
226	    while !stop.load(Ordering::Relaxed) {
227	        let now = Instant::now();
228	        let due_reg = now.duration_since(last_reg) >= cadence.registry;
229	        let due_notif = now.duration_since(last_notif) >= cadence.notif;
230	        // The wake edge's freshness pull (D9-2, REQ-INST-3): a dormant→active
231	        // transition dropped the marker — consume it and pull NOW, the
232	        // catch-up-on-activation latency is the point. The take is the remove,
233	        // so one wake claims exactly one forced round.
234	        // [impl->REQ-INST-3]
235	        let wake_pull = crate::resting::take_freshness_pull(&paths.pull_marker);
236	        let due_sync = wake_pull || now.duration_since(last_sync) >= cadence.sync;
237	        let due_upd = now.duration_since(last_upd) >= cadence.update;
238	        if !(due_reg || due_notif || due_sync || due_upd) {
239	            thread::sleep(TICK);
240	            continue;
241	        }
242
243	        // Re-load config-ish stores per round (the access-gate freshness
244	        // stance — edits take effect without a restart).
245	        let subnets = SubnetStore::load_from(&paths.subnets);
246	        let trust = TrustStore::load_from(&paths.trust);
247	        let policy = SyncPolicy {
248	            subnets: subnets
249	                .subnets
250	                .iter()
251	                .map(|s| (s.name.clone(), s.hide_new_endpoints))
252	                .collect(),
253	            trust: TrustStore::load_from(&paths.trust),
254	            vis: VisibilityStore::load_from(&paths.vis),
255	        };
256	        let adverts = if due_reg { registry.advertise_local(&paths.owlery) } else { Vec::new() };
257
258	        for sub in &subnets.subnets {
259	            for peer in trust.peers_in(&sub.name) {
260	                if peer.pubkey_hex == registry.node_hex() {
261	                    continue; // never pump at ourselves
262	                }
263	                let Some(conn_id) =
264	                    ensure_conn(&mut brain, &mut conns, &resolver, &peer.pubkey_hex)
265	                else {
266	                    continue; // unresolvable/undialable now — next tick retries
267	                };
268
269	                let mut peer_io = || -> io::Result<()> {
270	                    if due_reg {
271	                        let lines: Vec<u8> = adverts
272	                            .iter()
273	                            .filter(|u| u.subnet == sub.name)
274	                            .flat_map(|u| u.encode_line())
275	                            .collect();
276	                        if !lines.is_empty() {
277	                            push_feed(&mut brain, conn_id, &lines)?;
278	                        }
279	                    }
280	                    if due_notif {
281	                        let store = NotifStore::open_at(&paths.notif_db)
282	                            .map_err(|e| io::Error::other(e.to_string()))?;
283	                        let feed = emit_notif_feed(&store, &sub.name).map_err(io::Error::other)?;
284	                        if !feed.is_empty() {
285	                            push_feed(&mut brain, conn_id, &feed)?;
286	                        }
287	                    }
288	                    if due_sync {
289	                        let cs = ContextStore::open_or_init_in(&paths.ctx_root)?;
290	                        let mut want = select_refs(&policy, &cs, &peer.pubkey_hex)?;
291	                        // Registry-derived bootstrap refs: minds named in the
292	                        // shared subnet that this node never held locally.
293	                        if let Some(snap) = registry.snapshot(&sub.name) {
294	                            for id in snap.endpoint_ids() {
295	                                let r = format!("a-{id}");
296	                                if !want.contains(&r) && policy.allows(&r, &peer.pubkey_hex) {
297	                                    want.push(r);
298	                                }
299	                            }
300	                        }
301	                        if !want.is_empty() {
302	                            let open_op = ops.next_epoch()?;
303	                            let report = request_sync(
304	                                &mut brain,
305	                                conn_id,
306	                                &want,
307	                                open_op,
308	                                &cs,
309	                                &paths.scratch,
310	                            )?;
311	                            (hooks.on_pull)(&peer.pubkey_hex, &report);
312	                        }
313	                    }
314	                    if due_upd {
315	                        // The peer-propagation pull (REQ-UPD-1) under the
316	                        // PRODUCTION gate: keys + channel pin from
317	                        // release-keys.json (fail-closed when unprovisioned),
318	                        // version floor = the staged release (rollback gate),
319	                        // wall clock for expiry only.
320	                        let cache = ReleaseCache::open(&paths.releases);
321	                        let current = cache.staged_version().unwrap_or(0);
322	                        let policy =
323	                            VerifyPolicy::load_from(&paths.release_keys, current, now_ms());
324	                        if !policy.trusted_keys.is_empty() {
325	                            let open_op = ops.next_epoch()?;
326	                            match request_update(
327	                                &mut brain,
328	                                conn_id,
329	                                open_op,
330	                                &BrokerAbi::current(),
331	                                &policy,
332	                                &cache,
333	                                &paths.scratch,
334	                            )? {
335	                                UpdatePullOutcome::Updated(plan) => {
336	                                    let version = cache
337	                                        .staged_version()
338	                                        .map(|v| v.to_string())
339	                                        .unwrap_or_else(|| "?".to_string());
340	                                    eprintln!(
341	                                        "UPDATE_STAGED:{version}:{:?} (from {})",
342	                                        plan.class, peer.pubkey_hex
343	                                    );
344	                                    // REQ-UPD-4: gated on user confirmation by
345	                                    // default — the staged release surfaces as
346	                                    // a consent notif (D8's producer); apply
347	                                    // rides the existing self-update machinery
348	                                    // once confirmed. Full-auto writes nothing.
349	                                    let decision = decide(
350	                                        full_auto_update,
351	                                        most_recently_active(&paths.owlery),
352	                                    );
353	                                    let surface = NotifSurfacePolicy {
354	                                        subnets: subnets
355	                                            .subnets
356	                                            .iter()
357	                                            .map(|s| (s.name.clone(), s.hide_new_endpoints))
358	                                            .collect(),
359	                                        vis: VisibilityStore::load_from(&paths.vis),
360	                                    };
361	                                    let store = NotifStore::open_at(&paths.notif_db)
362	                                        .map_err(|e| io::Error::other(e.to_string()))?;
363	                                    if let Err(e) = produce_consent_notif(
364	                                        &decision,
365	                                        &version,
366	                                        &store,
367	                                        registry.node_hex(),
368	                                        &mut ops,
369	                                        &sub.name,
370	                                        &surface,
371	                                        &paths.owlery,
372	                                        now_ms(),
373	                                    ) {
374	                                        eprintln!("UPDATE_NOTIF_FAIL:{e}");
375	                                    }
376	                                }
377	                                UpdatePullOutcome::UpToDate => {}
378	                                UpdatePullOutcome::Rejected(reason) => {
379	                                    // Loud + typed, never silent (ADR-0004 §D).
380	                                    eprintln!(
381	                                        "UPDATE_REJECTED:{}:{reason}",
382	                                        peer.pubkey_hex
383	                                    );
384	                                }
385	                            }
386	                        }
387	                    }
388	                    Ok(())
389	                };
390	                if let Err(e) = peer_io() {
391	                    eprintln!("PUMP_PEER_FAIL:{}:{e}", peer.pubkey_hex);
392	                    conns.remove(&peer.pubkey_hex); // redial next tick
393	                }
394	            }
395	        }
396
397	        let now = Instant::now();
398	        if due_reg {
399	            last_reg = now;
400	        }
401	        if due_notif {
402	            last_notif = now;
403	        }
404	        if due_sync {
405	            last_sync = now;
406	        }
407	        if due_upd {
408	            last_upd = now;
409	        }
410	    }
411	    Ok(())
412	}
413
414	/// Dial (or reuse) the connection for a peer. `None` = unresolvable or
415	/// undialable right now — skip, don't fail the round.
416	fn ensure_conn(
417	    brain: &mut Brain,
418	    conns: &mut HashMap<String, u64>,
419	    resolver: &PeerResolver,
420	    peer_hex: &str,
421	) -> Option<u64> {
422	    if let Some(id) = conns.get(peer_hex) {
423	        return Some(*id);
424	    }
425	    let addr = resolver(peer_hex)?;
426	    match brain.net_dial(addr, None) {
427	        Ok(d) => {
428	            conns.insert(peer_hex.to_string(), d.conn_id);
429	            Some(d.conn_id)
430	        }
431	        Err(_) => None, // peer offline/unreachable — next tick retries
432	    }
433	}
434
435	/// Open a fresh stream on `conn_id`, write one whole feed fire-and-forget,
436	/// and finish it (the receiver's dispatcher classifies + applies). One
437	/// stream per feed: streams are single-purpose by the demux contract.
438	fn push_feed(brain: &mut Brain, conn_id: u64, feed: &[u8]) -> io::Result<()> {
439	    let opened = brain.net_open_stream(conn_id, None)?;
440	    brain.net_stream_send(opened.stream_id, feed, None, true)?;
441	    Ok(())
442	}
443
444	/// Connect a fresh brain IPC client (retrying through broker startup).
445	fn connect(broker_name: &str) -> io::Result<Brain> {
446	    let mut last = io::Error::other("broker never answered");
447	    for _ in 0..300 {
448	        match Brain::cold_start(broker_name, now_ms()) {
449	            Ok(b) => return Ok(b),
450	            Err(e) => last = e,
451	        }
452	        thread::sleep(Duration::from_millis(10));
453	    }
454	    Err(last)
455	}
456
457	fn now_ms() -> u64 {
458	    std::time::SystemTime::now()
459	        .duration_since(std::time::UNIX_EPOCH)
460	        .map(|d| d.as_millis() as u64)
461	        .unwrap_or(0)
462	}
463
464	/// Spawn the pump on its own thread with the production wiring — the
465	/// daemon-boot entry ([`crate::daemon::Daemon::run`]).
466	pub fn spawn_peer_pump(
467	    broker_name: String,
468	    registry: Arc<RegistryHost>,
469	    cfg: DaemonConfig,
470	    stop: Arc<AtomicBool>,
471	) -> thread::JoinHandle<()> {
472	    thread::spawn(move || {
473	        let paths = PumpPaths::production();
474	        if let Err(e) = run_peer_pump(
475	            &broker_name,
476	            registry,
477	            &paths,
478	            production_resolver(),
479	            PumpConfig::from_config(&cfg),
480	            PumpHooks::default(),
481	            &stop,
482	        ) {
483	            eprintln!("PEER_PUMP_FAIL: {e}");
484	        }
485	    })
486	}
487

//! Outbound peer pump (D9-1-2) — the production trigger loops every D5–D8
//! slice deferred ("rides D9 with the daemon lifecycle").
//!
//! One daemon-hosted loop drives three cadenced pumps against every trusted
//! peer of every member subnet, each a thin call over an already-shipped,
//! idempotent driver — the pump owns *when*, the drivers own *how*:
//!
//! - **registry push** ([`RegistryHost::advertise_local`] → one feed stream
//!   per peer): the node re-advertises its own rows; peers reconverge within
//!   one cadence (replay-is-recovery, the D4d/D8 push posture). Own rows
//!   only — no transitive gossip in v1: trust is pairwise per subnet, and a
//!   relayed third-party row would carry an origin the receiver cannot gate
//!   honestly (KNOWN-HAZARDS 7.5).
//! - **notif push** ([`crate::notifsync::emit_notif_feed`]): the full
//!   semilattice spool per subnet; the receiver's join dedups.
//! - **sync pull** ([`crate::sync::request_sync`]): anti-entropy for the
//!   minds. Want-refs are the union of [`crate::sync::select_refs`] (locally
//!   tracked branches the policy allows toward the peer) and
//!   **registry-derived** `a-<id>` refs (endpoints known in the shared
//!   subnets — how a fresh instance bootstraps a mind it has never held),
//!   both gated by the same [`SyncPolicy::allows`] the responder enforces
//!   (symmetric scoping).
//!
//! Conflicts a pull surfaces are reported through [`PumpHooks::on_pull`] —
//! the default production hook logs them loud. The bounded Psyche reconcile
//! turn itself still runs at the activation paths (ADR-0013): the daemon
//! does not yet host per-agent manifest runtimes (the 7.4 fan-out), so an
//! in-pump turn has no runtime to ride; conflict artifacts persist safely
//! meanwhile (REQ-HAZARD-CONFLICT-BOTH-PRESERVED).
//!
//! ## Wire discipline (inherited from the test suites, now production)
//!
//! Every pump write is fire-and-forget (`op_id: None`) — a journaled
//! `net_stream_send` blocks on its ack and DISCARDS interleaved events
//! (gotcha #1). Stream opens for push feeds carry no op either: the feeds
//! are idempotent by construction. The sync pull's `open_op` IS durable
//! (exactly-once open identity), minted from a dedicated [`EpochSource`]
//! file so a daemon restart can never reuse one.
// [impl->REQ-INST-5]
// [impl->REQ-INST-7]
// [impl->REQ-NOTIF-1]

use std::collections::HashMap;
use std::io;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

use spt_net::net::endpoint::addr_for_node_hex;
use spt_store::contextstore::ContextStore;
use spt_store::epoch::EpochSource;
use spt_store::notif::NotifStore;
use spt_store::perch;
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;

use crate::brain::Brain;
use crate::config::DaemonConfig;
use crate::consent::{decide, most_recently_active};
use crate::notif::{produce_consent_notif, NotifSurfacePolicy};
use crate::notifsync::emit_notif_feed;
use crate::propagate::{request_update, UpdatePullOutcome};
use crate::registryhost::RegistryHost;
use crate::relcache::ReleaseCache;
use crate::release::VerifyPolicy;
use crate::sync::{request_sync, select_refs, SyncPolicy, SyncPullReport};
use crate::update::BrokerAbi;

/// The pump's tick granularity — each cadence fires when its period elapsed.
const TICK: Duration = Duration::from_millis(200);

/// Resolve a trusted peer's stable name (lowercase-hex pubkey) to a dialable
/// address (the serde JSON `net-dial` carries). Production resolution lets
/// discovery do the work ([`production_resolver`]); hermetic tests inject a
/// concrete address map (loopback endpoints run no discovery).
pub type PeerResolver = Box<dyn Fn(&str) -> Option<serde_json::Value> + Send>;

/// The production resolver: an id-only iroh address — mDNS on the LAN, n0
/// DNS on the WAN (`addr_for_node_hex`).
pub fn production_resolver() -> PeerResolver {
    Box::new(|hex| addr_for_node_hex(hex).and_then(|a| serde_json::to_value(a).ok()))
}

/// Where this node's pump-side stores live. Production = the canonical
/// SPT_HOME layout; the loopback E2E gives its in-process "node" distinct
/// roots so two nodes can share one test process.
pub struct PumpPaths {
    /// The perch tree (registry advertisement scan).
    pub owlery: PathBuf,
    /// The context store root ([`ContextStore::open_or_init_in`]).
    pub ctx_root: PathBuf,
    /// The notif spool DB ([`NotifStore::open_at`]).
    pub notif_db: PathBuf,
    /// Transient pull scratch.
    pub scratch: PathBuf,
    /// `subnet.json` ([`SubnetStore::load_from`]).
    pub subnets: PathBuf,
    /// `trust/peers.json` ([`TrustStore::load_from`]).
    pub trust: PathBuf,
    /// `visibility.json` ([`VisibilityStore::load_from`]).
    pub vis: PathBuf,
    /// The pump's durable open-op counter (its own file — colliding with the
    /// registry's advertisement epochs would break neither, but a shared
    /// in-memory cursor pair over one file double-mints; separate files keep
    /// each `EpochSource` the sole writer of its counter).
    pub ops: PathBuf,
    /// The staged-release cache dir ([`ReleaseCache::open`]).
    pub releases: PathBuf,
    /// `release-keys.json` ([`VerifyPolicy::load_from`]).
    pub release_keys: PathBuf,
    /// The wake-edge freshness-pull marker (D9-2, REQ-INST-3): a
    /// dormant→active transition drops it; the pump consumes it and forces a
    /// sync round NOW instead of waiting out `cadence.sync`
    /// ([`crate::resting::PULL_MARKER_FILE`]).
    pub pull_marker: PathBuf,
}

impl PumpPaths {
    /// The canonical SPT_HOME layout.
    pub fn production() -> PumpPaths {
        let home = perch::spt_home();
        let identity = perch::identity_dir();
        PumpPaths {
            owlery: perch::owlery_dir(),
            ctx_root: home.join("context"),
            notif_db: identity.join("notifs.db"),
            scratch: home.join("scratch").join("pump"),
            subnets: identity.join("subnet.json"),
            trust: identity.join("trust").join("peers.json"),
            vis: identity.join("visibility.json"),
            ops: identity.join("pump-ops.json"),
            releases: home.join("releases"),
            release_keys: identity.join("release-keys.json"),
            pull_marker: identity.join(crate::resting::PULL_MARKER_FILE),
        }
    }
}

/// Pump cadences (config-driven in production: [`DaemonConfig`]).
#[derive(Debug, Clone, Copy)]
pub struct PumpCadence {
    pub registry: Duration,
    pub notif: Duration,
    pub sync: Duration,
    pub update: Duration,
}

impl PumpCadence {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
        PumpCadence {
            registry: cfg.registry_pump_period,
            notif: cfg.notif_pump_period,
            sync: cfg.sync_pull_period,
            update: cfg.update_check_period,
        }
    }
}

/// The pump's resolved knobs: cadences + the REQ-UPD-4 consent stance.
#[derive(Debug, Clone, Copy)]
pub struct PumpConfig {
    pub cadence: PumpCadence,
    /// `true` = the explicit opt-in to prompt-free updates; default `false`
    /// surfaces every staged release as a consent notif.
    pub full_auto_update: bool,
}

impl PumpConfig {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpConfig {
        PumpConfig {
            cadence: PumpCadence::from_config(cfg),
            full_auto_update: cfg.full_auto_update,
        }
    }
}

/// A pull observer: `(peer_hex, report)` after every completed sync pull.
pub type OnPull = Box<dyn FnMut(&str, &SyncPullReport) + Send>;

/// Observation hooks. `on_pull` fires after every completed sync pull —
/// the conflict surface (ADR-0013) and the E2E's probe.
pub struct PumpHooks {
    pub on_pull: OnPull,
}

impl Default for PumpHooks {
    /// The production hook: conflicts are loud, never silent — the artifacts
    /// already persist (hazard 6.6); the reconcile turn runs at activation.
    fn default() -> PumpHooks {
        PumpHooks {
            on_pull: Box::new(|peer, report| {
                for (branch, file) in report.conflicts() {
                    eprintln!("SYNC_CONFLICT:{peer}:{branch}:{file}");
                }
            }),
        }
    }
}

/// Run the pump until `stop` is set. Owns one brain IPC connection and a
/// per-peer connection cache (an IO failure against a peer drops its cache
/// row; the next tick redials — conns are rebuilt freely, nothing leaks
/// because the broker's closed-watcher reaps dead conns).
pub fn run_peer_pump(
    broker_name: &str,
    registry: Arc<RegistryHost>,
    paths: &PumpPaths,
    resolver: PeerResolver,
    config: PumpConfig,
    mut hooks: PumpHooks,
    stop: &AtomicBool,
) -> io::Result<()> {
    let PumpConfig { cadence, full_auto_update } = config;
    let mut brain = connect(broker_name)?;
    let mut ops = EpochSource::load_from(&paths.ops);
    let mut conns: HashMap<String, u64> = HashMap::new();
    // Stagger from "everything due now" — the first tick primes peers.
    let far = Instant::now() - Duration::from_secs(86_400);
    let (mut last_reg, mut last_notif, mut last_sync, mut last_upd) = (far, far, far, far);

    while !stop.load(Ordering::Relaxed) {
        let now = Instant::now();
        let due_reg = now.duration_since(last_reg) >= cadence.registry;
        let due_notif = now.duration_since(last_notif) >= cadence.notif;
        // The wake edge's freshness pull (D9-2, REQ-INST-3): a dormant→active
        // transition dropped the marker — consume it and pull NOW, the
        // catch-up-on-activation latency is the point. The take is the remove,
        // so one wake claims exactly one forced round.
        // [impl->REQ-INST-3]
        let wake_pull = crate::resting::take_freshness_pull(&paths.pull_marker);
        let due_sync = wake_pull || now.duration_since(last_sync) >= cadence.sync;
        let due_upd = now.duration_since(last_upd) >= cadence.update;
        if !(due_reg || due_notif || due_sync || due_upd) {
            thread::sleep(TICK);
            continue;
        }

        // Re-load config-ish stores per round (the access-gate freshness
        // stance — edits take effect without a restart).
        let subnets = SubnetStore::load_from(&paths.subnets);
        let trust = TrustStore::load_from(&paths.trust);
        let policy = SyncPolicy {
            subnets: subnets
                .subnets
                .iter()
                .map(|s| (s.name.clone(), s.hide_new_endpoints))
                .collect(),
            trust: TrustStore::load_from(&paths.trust),
            vis: VisibilityStore::load_from(&paths.vis),
        };
        let adverts = if due_reg { registry.advertise_local(&paths.owlery) } else { Vec::new() };

        for sub in &subnets.subnets {
            for peer in trust.peers_in(&sub.name) {
                if peer.pubkey_hex == registry.node_hex() {
                    continue; // never pump at ourselves
                }
                let Some(conn_id) =
                    ensure_conn(&mut brain, &mut conns, &resolver, &peer.pubkey_hex)
                else {
                    continue; // unresolvable/undialable now — next tick retries
                };

                let mut peer_io = || -> io::Result<()> {
                    if due_reg {
                        let lines: Vec<u8> = adverts
                            .iter()
                            .filter(|u| u.subnet == sub.name)
                            .flat_map(|u| u.encode_line())
                            .collect();
                        if !lines.is_empty() {
                            push_feed(&mut brain, conn_id, &lines)?;
                        }
                    }
                    if due_notif {
                        let store = NotifStore::open_at(&paths.notif_db)
                            .map_err(|e| io::Error::other(e.to_string()))?;
                        let feed = emit_notif_feed(&store, &sub.name).map_err(io::Error::other)?;
                        if !feed.is_empty() {
                            push_feed(&mut brain, conn_id, &feed)?;
                        }
                    }
                    if due_sync {
                        let cs = ContextStore::open_or_init_in(&paths.ctx_root)?;
                        let mut want = select_refs(&policy, &cs, &peer.pubkey_hex)?;
                        // Registry-derived bootstrap refs: minds named in the
                        // shared subnet that this node never held locally.
                        if let Some(snap) = registry.snapshot(&sub.name) {
                            for id in snap.endpoint_ids() {
                                let r = format!("a-{id}");
                                if !want.contains(&r) && policy.allows(&r, &peer.pubkey_hex) {
                                    want.push(r);
                                }
                            }
                        }
                        if !want.is_empty() {
                            let open_op = ops.next_epoch()?;
                            let report = request_sync(
                                &mut brain,
                                conn_id,
                                &want,
                                open_op,
                                &cs,
                                &paths.scratch,
                            )?;
                            (hooks.on_pull)(&peer.pubkey_hex, &report);
                        }
                    }
                    if due_upd {
                        // The peer-propagation pull (REQ-UPD-1) under the
                        // PRODUCTION gate: keys + channel pin from
                        // release-keys.json (fail-closed when unprovisioned),
                        // version floor = the staged release (rollback gate),
                        // wall clock for expiry only.
                        let cache = ReleaseCache::open(&paths.releases);
                        let current = cache.staged_version().unwrap_or(0);
                        let policy =
                            VerifyPolicy::load_from(&paths.release_keys, current, now_ms());
                        if !policy.trusted_keys.is_empty() {
                            let open_op = ops.next_epoch()?;
                            match request_update(
                                &mut brain,
                                conn_id,
                                open_op,
                                &BrokerAbi::current(),
                                &policy,
                                &cache,
                                &paths.scratch,
                            )? {
                                UpdatePullOutcome::Updated(plan) => {
                                    let version = cache
                                        .staged_version()
                                        .map(|v| v.to_string())
                                        .unwrap_or_else(|| "?".to_string());
                                    eprintln!(
                                        "UPDATE_STAGED:{version}:{:?} (from {})",
                                        plan.class, peer.pubkey_hex
                                    );
                                    // REQ-UPD-4: gated on user confirmation by
                                    // default — the staged release surfaces as
                                    // a consent notif (D8's producer); apply
                                    // rides the existing self-update machinery
                                    // once confirmed. Full-auto writes nothing.
                                    let decision = decide(
                                        full_auto_update,
                                        most_recently_active(&paths.owlery),
                                    );
                                    let surface = NotifSurfacePolicy {
                                        subnets: subnets
                                            .subnets
                                            .iter()
                                            .map(|s| (s.name.clone(), s.hide_new_endpoints))
                                            .collect(),
                                        vis: VisibilityStore::load_from(&paths.vis),
                                    };
                                    let store = NotifStore::open_at(&paths.notif_db)
                                        .map_err(|e| io::Error::other(e.to_string()))?;
                                    if let Err(e) = produce_consent_notif(
                                        &decision,
                                        &version,
                                        &store,
                                        registry.node_hex(),
                                        &mut ops,
                                        &sub.name,
                                        &surface,
                                        &paths.owlery,
                                        now_ms(),
                                    ) {
                                        eprintln!("UPDATE_NOTIF_FAIL:{e}");
                                    }
                                }
                                UpdatePullOutcome::UpToDate => {}
                                UpdatePullOutcome::Rejected(reason) => {
                                    // Loud + typed, never silent (ADR-0004 §D).
                                    eprintln!(
                                        "UPDATE_REJECTED:{}:{reason}",
                                        peer.pubkey_hex
                                    );
                                }
                            }
                        }
                    }
                    Ok(())
                };
                if let Err(e) = peer_io() {
                    eprintln!("PUMP_PEER_FAIL:{}:{e}", peer.pubkey_hex);
                    conns.remove(&peer.pubkey_hex); // redial next tick
                }
            }
        }

        let now = Instant::now();
        if due_reg {
            last_reg = now;
        }
        if due_notif {
            last_notif = now;
        }
        if due_sync {
            last_sync = now;
        }
        if due_upd {
            last_upd = now;
        }
    }
    Ok(())
}

/// Dial (or reuse) the connection for a peer. `None` = unresolvable or
/// undialable right now — skip, don't fail the round.
fn ensure_conn(
    brain: &mut Brain,
    conns: &mut HashMap<String, u64>,
    resolver: &PeerResolver,
    peer_hex: &str,
) -> Option<u64> {
    if let Some(id) = conns.get(peer_hex) {
        return Some(*id);
    }
    let addr = resolver(peer_hex)?;
    match brain.net_dial(addr, None) {
        Ok(d) => {
            conns.insert(peer_hex.to_string(), d.conn_id);
            Some(d.conn_id)
        }
        Err(_) => None, // peer offline/unreachable — next tick retries
    }
}

/// Open a fresh stream on `conn_id`, write one whole feed fire-and-forget,
/// and finish it (the receiver's dispatcher classifies + applies). One
/// stream per feed: streams are single-purpose by the demux contract.
fn push_feed(brain: &mut Brain, conn_id: u64, feed: &[u8]) -> io::Result<()> {
    let opened = brain.net_open_stream(conn_id, None)?;
    brain.net_stream_send(opened.stream_id, feed, None, true)?;
    Ok(())
}

/// Connect a fresh brain IPC client (retrying through broker startup).
fn connect(broker_name: &str) -> io::Result<Brain> {
    let mut last = io::Error::other("broker never answered");
    for _ in 0..300 {
        match Brain::cold_start(broker_name, now_ms()) {
            Ok(b) => return Ok(b),
            Err(e) => last = e,
        }
        thread::sleep(Duration::from_millis(10));
    }
    Err(last)
}

fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// Spawn the pump on its own thread with the production wiring — the
/// daemon-boot entry ([`crate::daemon::Daemon::run`]).
pub fn spawn_peer_pump(
    broker_name: String,
    registry: Arc<RegistryHost>,
    cfg: DaemonConfig,
    stop: Arc<AtomicBool>,
) -> thread::JoinHandle<()> {
    thread::spawn(move || {
        let paths = PumpPaths::production();
        if let Err(e) = run_peer_pump(
            &broker_name,
            registry,
            &paths,
            production_resolver(),
            PumpConfig::from_config(&cfg),
            PumpHooks::default(),
            &stop,
        ) {
            eprintln!("PEER_PUMP_FAIL: {e}");
        }
    })
}


Proceed with bug root cause analysis and fixes


Pump + registry clear. Now wanmsg resolution + CLI render.


1	//! WAN message records over broker QUIC streams (M4-D5a) — the REQ-MSG path,
2	//! now off-node.
3	//!
4	//! D4 gave the broker-owned streams a brain can drive across restarts; D3 gave
5	//! addressing (per-subnet registry + the refuse-ambiguous resolution policy).
6	//! This module is the thin wire seam between them and the existing local
7	//! delivery path: a minimal message record, the shared NDJSON framing
8	//! ([`crate::net::ndjson`]), and a routing helper that decides local-vs-WAN
9	//! from a [`Resolution`]. The receive-side funnel (op-id dedup → access gate →
10	//! `spt_msg::deliver`) lives in the daemon, which owns the perch/owlery
11	//! machinery.
12	//!
13	//! ## Origin is transport truth, never payload (REQ-HAZARD-WAN-ORIGIN-AUTH)
14	//!
15	//! A [`WanMessage`] deliberately carries **no origin-node field**: the access
16	//! gate's subject (ADR-0009 origin-node whitelist) is the QUIC connection's
17	//! handshake-proven remote node id (iroh `EndpointId` == the Ed25519 node
18	//! pubkey, the REQ-NET-1 identity binding) — read from the broker's stream
19	//! table, never from record bytes. A forged `origin_node`/`node` field inside
20	//! the JSON decodes as an ignored unknown field and influences nothing. The
21	//! `from` field is reply-routing metadata (`__REPLY_TO__`), not an
22	//! authorization subject.
23
24	use serde::{Deserialize, Serialize};
25
26	use crate::net::ndjson::{self, NdjsonDecoder};
27	use crate::net::registry::{Ambiguity, Resolution};
28
29	/// One WAN message record: a message for `target` (an endpoint id at the
30	/// receiving node), as sent by `from` (reply-routing metadata). `op_id` is the
31	/// sender-minted durable idempotency key — the receiver dedups on it before
32	/// delivering, so a replayed feed (sender redrive, receiver brain restart
33	/// resubscribing from an earlier cursor) delivers exactly once
34	/// (REQ-HAZARD-RESTART-IDEMPOTENT, now wire-fed).
35	// [impl->REQ-NET-1]
36	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
37	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38	pub struct WanMessage {
39	    /// The endpoint the message is for (resolved at the receiving node).
40	    pub target: String,
41	    /// The sender endpoint id — the `__REPLY_TO__` line at delivery (empty =
42	    /// anonymous). Metadata only; never an authorization subject.
43	    pub from: String,
44	    /// The message body.
45	    pub body: String,
46	    /// Sender-minted durable idempotency key, unique per logical send (e.g.
47	    /// `<sender-node-hex>:<epoch>` from the node's own `EpochSource`).
48	    pub op_id: String,
49	}
50
51	impl WanMessage {
52	    /// Encode as one NDJSON line (the JSON document + `\n`).
53	    pub fn encode_line(&self) -> Vec<u8> {
54	        ndjson::encode_line(self)
55	    }
56	}
57
58	/// The WAN message feed's NDJSON reassembler — the shared
59	/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
60	/// [`WanMessage`] records. One decoder per stream.
61	pub type WanDecoder = NdjsonDecoder<WanMessage>;
62
63	/// Where a send should go, decided from a registry [`Resolution`].
64	#[derive(Debug, Clone, PartialEq, Eq)]
65	pub enum WanRoute {
66	    /// A live instance on this node — the existing local path
67	    /// (`spt_msg::deliver`), no wire.
68	    Local,
69	    /// A live instance on another node — dial and send a [`WanMessage`].
70	    /// `subnet` is set when resolution crossed a per-subnet registry.
71	    Remote { subnet: Option<String>, node: String },
72	    /// Resolution refused — force qualification (D3c policy: never guess).
73	    Refused(Ambiguity),
74	    /// No live (routable) instance anywhere.
75	    NotFound,
76	}
77
78	/// Map a [`Resolution`] to a [`WanRoute`] against this node's own id. The
79	/// resolution policy (local-first, refuse-ambiguous, `@node` pin, visibility)
80	/// already ran inside `resolve_across_visible`; this only splits its hit into
81	/// the local and wire legs.
82	// [impl->REQ-NET-1]
83	pub fn route_wan(resolution: Resolution, local_node: &str) -> WanRoute {
84	    match resolution {
85	        Resolution::Resolved { instance, .. } if instance.node == local_node => WanRoute::Local,
86	        Resolution::Resolved { subnet, instance } => {
87	            WanRoute::Remote { subnet, node: instance.node }
88	        }
89	        Resolution::Ambiguous(why) => WanRoute::Refused(why),
90	        Resolution::NotFound => WanRoute::NotFound,
91	    }
92	}
93
94	#[cfg(test)]
95	mod tests {
96	    use super::*;
97	    use crate::net::registry::{Instance, Status};
98
99	    fn resolved(node: &str) -> Resolution {
100	        Resolution::Resolved {
101	            subnet: Some("home".into()),
102	            instance: Instance { node: node.into(), status: Status::Active, epoch: 1, resources: None, last_active_ms: None, shell_adapters: Vec::new(), node_label: None },
103	        }
104	    }
105
106	    // [unit->REQ-NET-1] a record round-trips through the NDJSON line encoding,
107	    // and an unknown future field decodes (wire forward-compat).
108	    #[test]
109	    fn message_round_trips_and_tolerates_unknown_field() {
110	        let m = WanMessage {
111	            target: "ling".into(),
112	            from: "doyle".into(),
113	            body: "hello across the wire".into(),
114	            op_id: "cafe:42".into(),
115	        };
116	        let line = m.encode_line();
117	        assert_eq!(line.last(), Some(&b'\n'));
118	        let back: WanMessage = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
119	        assert_eq!(back, m);
120
121	        let wire = br#"{"target":"ling","from":"doyle","body":"x","op_id":"n:1","added_at_d6":true}"#;
122	        let future: WanMessage = serde_json::from_slice(wire).unwrap();
123	        assert_eq!(future.op_id, "n:1");
124	    }
125
126	    // [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] the record has no origin-node field:
127	    // a forged `origin_node` (or `node`) inside the JSON decodes as an ignored
128	    // unknown field — nothing in the decoded record can impersonate a node.
129	    // The gate's subject comes from the QUIC handshake (the broker's stream
130	    // table), asserted end-to-end in the daemon's wanmsg int test.
131	    #[test]
132	    fn forged_origin_field_is_inert() {
133	        let wire = br#"{"target":"ling","from":"doyle","body":"x","op_id":"n:1","origin_node":"evil","node":"evil"}"#;
134	        let decoded: WanMessage = serde_json::from_slice(wire).unwrap();
135	        // Every field the record actually has is accounted for — there is no
136	        // place for the forged origin to land.
137	        assert_eq!(
138	            decoded,
139	            WanMessage {
140	                target: "ling".into(),
141	                from: "doyle".into(),
142	                body: "x".into(),
143	                op_id: "n:1".into()
144	            }
145	        );
146	    }
147
148	    // [unit->REQ-NET-1] route_wan splits a resolution into the local and wire
149	    // legs and passes refusals through (never guess past an ambiguity).
150	    #[test]
151	    fn route_splits_local_and_remote_and_passes_refusals() {
152	        assert_eq!(route_wan(resolved("here"), "here"), WanRoute::Local);
153	        assert_eq!(
154	            route_wan(resolved("there"), "here"),
155	            WanRoute::Remote { subnet: Some("home".into()), node: "there".into() }
156	        );
157	        let amb = Resolution::Ambiguous(Ambiguity::AcrossNodes(vec!["a".into(), "b".into()]));
158	        assert_eq!(
159	            route_wan(amb, "here"),
160	            WanRoute::Refused(Ambiguity::AcrossNodes(vec!["a".into(), "b".into()]))
161	        );
162	        assert_eq!(route_wan(Resolution::NotFound, "here"), WanRoute::NotFound);
163	    }
164	}
165

//! WAN message records over broker QUIC streams (M4-D5a) — the REQ-MSG path,
//! now off-node.
//!
//! D4 gave the broker-owned streams a brain can drive across restarts; D3 gave
//! addressing (per-subnet registry + the refuse-ambiguous resolution policy).
//! This module is the thin wire seam between them and the existing local
//! delivery path: a minimal message record, the shared NDJSON framing
//! ([`crate::net::ndjson`]), and a routing helper that decides local-vs-WAN
//! from a [`Resolution`]. The receive-side funnel (op-id dedup → access gate →
//! `spt_msg::deliver`) lives in the daemon, which owns the perch/owlery
//! machinery.
//!
//! ## Origin is transport truth, never payload (REQ-HAZARD-WAN-ORIGIN-AUTH)
//!
//! A [`WanMessage`] deliberately carries **no origin-node field**: the access
//! gate's subject (ADR-0009 origin-node whitelist) is the QUIC connection's
//! handshake-proven remote node id (iroh `EndpointId` == the Ed25519 node
//! pubkey, the REQ-NET-1 identity binding) — read from the broker's stream
//! table, never from record bytes. A forged `origin_node`/`node` field inside
//! the JSON decodes as an ignored unknown field and influences nothing. The
//! `from` field is reply-routing metadata (`__REPLY_TO__`), not an
//! authorization subject.

use serde::{Deserialize, Serialize};

use crate::net::ndjson::{self, NdjsonDecoder};
use crate::net::registry::{Ambiguity, Resolution};

/// One WAN message record: a message for `target` (an endpoint id at the
/// receiving node), as sent by `from` (reply-routing metadata). `op_id` is the
/// sender-minted durable idempotency key — the receiver dedups on it before
/// delivering, so a replayed feed (sender redrive, receiver brain restart
/// resubscribing from an earlier cursor) delivers exactly once
/// (REQ-HAZARD-RESTART-IDEMPOTENT, now wire-fed).
// [impl->REQ-NET-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WanMessage {
    /// The endpoint the message is for (resolved at the receiving node).
    pub target: String,
    /// The sender endpoint id — the `__REPLY_TO__` line at delivery (empty =
    /// anonymous). Metadata only; never an authorization subject.
    pub from: String,
    /// The message body.
    pub body: String,
    /// Sender-minted durable idempotency key, unique per logical send (e.g.
    /// `<sender-node-hex>:<epoch>` from the node's own `EpochSource`).
    pub op_id: String,
}

impl WanMessage {
    /// Encode as one NDJSON line (the JSON document + `\n`).
    pub fn encode_line(&self) -> Vec<u8> {
        ndjson::encode_line(self)
    }
}

/// The WAN message feed's NDJSON reassembler — the shared
/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
/// [`WanMessage`] records. One decoder per stream.
pub type WanDecoder = NdjsonDecoder<WanMessage>;

/// Where a send should go, decided from a registry [`Resolution`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WanRoute {
    /// A live instance on this node — the existing local path
    /// (`spt_msg::deliver`), no wire.
    Local,
    /// A live instance on another node — dial and send a [`WanMessage`].
    /// `subnet` is set when resolution crossed a per-subnet registry.
    Remote { subnet: Option<String>, node: String },
    /// Resolution refused — force qualification (D3c policy: never guess).
    Refused(Ambiguity),
    /// No live (routable) instance anywhere.
    NotFound,
}

/// Map a [`Resolution`] to a [`WanRoute`] against this node's own id. The
/// resolution policy (local-first, refuse-ambiguous, `@node` pin, visibility)
/// already ran inside `resolve_across_visible`; this only splits its hit into
/// the local and wire legs.
// [impl->REQ-NET-1]
pub fn route_wan(resolution: Resolution, local_node: &str) -> WanRoute {
    match resolution {
        Resolution::Resolved { instance, .. } if instance.node == local_node => WanRoute::Local,
        Resolution::Resolved { subnet, instance } => {
            WanRoute::Remote { subnet, node: instance.node }
        }
        Resolution::Ambiguous(why) => WanRoute::Refused(why),
        Resolution::NotFound => WanRoute::NotFound,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::net::registry::{Instance, Status};

    fn resolved(node: &str) -> Resolution {
        Resolution::Resolved {
            subnet: Some("home".into()),
            instance: Instance { node: node.into(), status: Status::Active, epoch: 1, resources: None, last_active_ms: None, shell_adapters: Vec::new(), node_label: None },
        }
    }

    // [unit->REQ-NET-1] a record round-trips through the NDJSON line encoding,
    // and an unknown future field decodes (wire forward-compat).
    #[test]
    fn message_round_trips_and_tolerates_unknown_field() {
        let m = WanMessage {
            target: "ling".into(),
            from: "doyle".into(),
            body: "hello across the wire".into(),
            op_id: "cafe:42".into(),
        };
        let line = m.encode_line();
        assert_eq!(line.last(), Some(&b'\n'));
        let back: WanMessage = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
        assert_eq!(back, m);

        let wire = br#"{"target":"ling","from":"doyle","body":"x","op_id":"n:1","added_at_d6":true}"#;
        let future: WanMessage = serde_json::from_slice(wire).unwrap();
        assert_eq!(future.op_id, "n:1");
    }

    // [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] the record has no origin-node field:
    // a forged `origin_node` (or `node`) inside the JSON decodes as an ignored
    // unknown field — nothing in the decoded record can impersonate a node.
    // The gate's subject comes from the QUIC handshake (the broker's stream
    // table), asserted end-to-end in the daemon's wanmsg int test.
    #[test]
    fn forged_origin_field_is_inert() {
        let wire = br#"{"target":"ling","from":"doyle","body":"x","op_id":"n:1","origin_node":"evil","node":"evil"}"#;
        let decoded: WanMessage = serde_json::from_slice(wire).unwrap();
        // Every field the record actually has is accounted for — there is no
        // place for the forged origin to land.
        assert_eq!(
            decoded,
            WanMessage {
                target: "ling".into(),
                from: "doyle".into(),
                body: "x".into(),
                op_id: "n:1".into()
            }
        );
    }

    // [unit->REQ-NET-1] route_wan splits a resolution into the local and wire
    // legs and passes refusals through (never guess past an ambiguity).
    #[test]
    fn route_splits_local_and_remote_and_passes_refusals() {
        assert_eq!(route_wan(resolved("here"), "here"), WanRoute::Local);
        assert_eq!(
            route_wan(resolved("there"), "here"),
            WanRoute::Remote { subnet: Some("home".into()), node: "there".into() }
        );
        let amb = Resolution::Ambiguous(Ambiguity::AcrossNodes(vec!["a".into(), "b".into()]));
        assert_eq!(
            route_wan(amb, "here"),
            WanRoute::Refused(Ambiguity::AcrossNodes(vec!["a".into(), "b".into()]))
        );
        assert_eq!(route_wan(Resolution::NotFound, "here"), WanRoute::NotFound);
    }
}


crates\spt\src\cli.rs:965:                eprintln!("AMBIGUOUS:{id} — qualify the address: {a}");
crates\spt\src\cli.rs:1067:                    "AMBIGUOUS_SUBNET: this node holds several — pass --subnet <name> (no guessing)"
crates\spt\src\cli.rs:1238:        Ok(spt_daemon::ApplyStagedOutcome::RefusedClass(class)) => {
crates\spt\src\cli.rs:1323:                    eprintln!("AMBIGUOUS:{target} — qualify with subnet: or @node ({why})");
crates\spt\src\cli.rs:1576:            // Refused fetch: no code, and we do NOT persist a `--create-new` mint —
crates\spt\src\cli.rs:1589:                "AMBIGUOUS_SUBNET: this node holds {} subnets ({}) — name one: `spt subnet show-code <name>`",
crates\spt\src\cli.rs:1655:            // Refused mint: nothing persists, nothing leaks (the seed-reveal
crates\spt\src\cli.rs:2820:                            GrantDecision::RefusedReserved => {
crates\spt\src\cli.rs:3185:            eprintln!("AMBIGUOUS:{target} — qualify the address: {a}");
crates\spt\src\cli.rs:4919:        assert!(amb.msg.contains("AMBIGUOUS_SUBNET"));
crates\spt\src\api\mod.rs:380:        AuthResult::Refused => {
crates\spt\src\api\auth.rs:66:/// Outcome of an auth check. `NoEndpoint` and `Refused` are both refusals; they
crates\spt\src\api\auth.rs:74:    Refused,
crates\spt\src\api\auth.rs:93:    AuthResult::Refused
crates\spt\src\api\auth.rs:159:        assert_eq!(authenticate("alice", &proof), AuthResult::Refused);
crates\spt\src\api\auth.rs:167:        assert_eq!(authenticate("alice", &proof), AuthResult::Refused);


Fix 1: Non-admin daemon spawn (both OSes)

Daemon must always run unelevated in invoking user's universe. Windows: elevated command spawns elevated daemon → pipes deny unelevated clients → later spt spawn attempts get bind Access-denied. Linux: sudo'd command spawns root daemon/root state in user home. Fix: de-elevated spawn (Windows: unelevated shell token; Linux: drop to SUDO_UID) and/or pipe SDDL granting user session.

Fix 2: Ghost registry row eviction

Dead node identity rows persist forever in registry snapshots (no TTL/eviction; only per-(endpoint,node) epoch lease supersedes). Poisons bare-id resolution with phantom AcrossNodes ambiguity. Fix: decay rows unrefreshed for N pump cadences (reuse heard-map from D2); consider trust-prune verb.

Ride-along: AMBIGUOUS render as copy-paste targets

Resolution refusals should print valid qualified targets (sergey@gravity-linux, sergey@03854a12), labels preferred — not AcrossNodes([..hex..]) debug dump. Render fix at wansend/CLI boundary.


900	            let mut shown = 0usize;
901	            for name in names {
902	                let Some(reg) = regs.get(&name) else { continue };
903	                let hide_new = subnets.find(&name).map(|s| s.hide_new_endpoints).unwrap_or(false);
904	                // The same exclusion the resolver enforces — the projection
905	                // can never list what resolution would refuse to route to.
906	                let rows = resource_projection(reg, |id| vis.hidden(id, &name, hide_new));
907	                for row in rows {
908	                    println!(
909	                        "{}\t{}\t{}\t{:?}\t{}",
910	                        name,
911	                        row.endpoint_id,
912	                        row.node,
913	                        row.status,
914	                        row.resources.as_deref().unwrap_or("-")
915	                    );
916	                    shown += 1;
917	                }
918	            }
919	            eprintln!("RESOURCES:{shown}");
920	            0
921	        }
922	    }
923	}
924
925	/// `spt suspend <id>` / `spt wake <id>` — the manual resting-state edges
926	/// (REQ-INST-3): apply the event against the local endpoint's durable record
927	/// through the daemon-side transition host (echo gate-armed for the
928	/// endpoint's own pulse loop; wake fires the boundary resurface + freshness
929	/// pull). A qualified `[subnet:]id@node` rides the remote rest arm (M5-D5b,
930	/// REQ-INST-6): resolve the peer from the registry snapshots, dial, and let
931	/// the TARGET node's transition host run the edge — exit codes mirror local
932	/// (edge / NO_EDGE = 0, refusal or failure = 1).
933	// [impl->REQ-INST-3]
934	// [impl->REQ-INST-6]
935	fn cmd_rest(id: &str, event: spt_daemon::RestEvent, verb: &str) -> i32 {
936	    if id.contains('@') || id.contains(':') {
937	        let tag = match event {
938	            spt_daemon::RestEvent::Suspend => spt_net::net::rest::REST_EVENT_SUSPEND,
939	            spt_daemon::RestEvent::Wake => spt_net::net::rest::REST_EVENT_WAKE,
940	            // Only the manual verbs route here; the other events are
941	            // daemon-internal feeds with no CLI surface.
942	            _ => {
943	                eprintln!("{verb}_FAIL:{id}: event has no remote arm");
944	                return 1;
945	            }
946	        };
947	        return match crate::wansend::wan_rest(id, tag) {
948	            crate::wansend::WanRestOutcome::Edge { node, detail } => {
949	                eprintln!("{verb}:{id} ({detail}) at {node}");
950	                0
951	            }
952	            crate::wansend::WanRestOutcome::NoEdge { node } => {
953	                eprintln!("NO_EDGE:{id} (already in the target state) at {node}");
954	                0
955	            }
956	            crate::wansend::WanRestOutcome::NoReply { node } => {
957	                eprintln!("{verb}_REFUSED:{id}: {node} closed the stream without a reply (access gate)");
958	                1
959	            }
960	            crate::wansend::WanRestOutcome::RemoteFail { node, detail } => {
961	                eprintln!("{verb}_FAIL:{id} at {node}: {detail}");
962	                1
963	            }
964	            crate::wansend::WanRestOutcome::Ambiguous(a) => {
965	                eprintln!("AMBIGUOUS:{id} — qualify the address: {a}");
966	                1
967	            }
968	            crate::wansend::WanRestOutcome::NotFound => {
969	                eprintln!("NO_ENDPOINT:{id}: no routable remote instance in any visible subnet");
970	                1
971	            }
972	            crate::wansend::WanRestOutcome::Failed(e) => {
973	                eprintln!("{verb}_FAIL:{id}: {e}");
974	                1
975	            }
976	        };
977	    }
978	    let knob = spt_daemon::DaemonConfig::load().auto_suspend_after_ms;
979	    match spt_daemon::daemon_rest_event(id, event, knob) {
980	        Ok(Some(report)) => {
981	            eprintln!("{verb}:{id} ({:?} -> {:?})", report.from, report.to);
982	            0
983	        }
984	        Ok(None) => {
985	            // Idempotent UX: the machine reports no edge (already there).
986	            eprintln!("NO_EDGE:{id} (already in the target state)");
987	            0
988	        }
989	        Err(e) => {
990	            eprintln!("{verb}_FAIL:{id}: {e}");
991	            1
992	        }
993	    }
994	}
995
996	/// `spt shutdown [id]` — the agent's graceful self-suspend verb (M5-D4d,
997	/// CONTEXT §Shell model: "an agent gracefully shuts down *its own* endpoint
998	/// → suspended; fires the transition echo-commune; cascades its persistent
999	/// shells offline"). Two legs through existing machinery: (1) soft-stop the
1000	/// listener (drop the ready marker — the session-over half `spt suspend`
1001	/// does not do); (2) the suspend edge via the daemon-side transition host —
1002	/// which arms the echo gate and runs the D4a shell cascade. Idempotent like
1003	/// every rest verb.
1004	// [impl->REQ-SHELL-2]
1005	fn cmd_shutdown(id: Option<String>) -> i32 {
1006	    let Some(id) = id.or_else(roster::detect_self_id) else {
1007	        eprintln!("NO_SELF: shutdown needs an endpoint (spt shutdown <id> or $OWL_SESSION_ID)");
1008	        return 1;
1009	    };
1010	    if id.contains('@') || id.contains(':') {
1011	        // Deliberately NOT lifted with the D5b remote rest arm: an agent
1012	        // shuts down *its own* endpoint — remote shutdown is not a surface;
1013	        // `spt suspend <id@node>` covers the remote need.
1014	        eprintln!("LOCAL_ONLY:{id} — shutdown is an endpoint's own verb; use `spt suspend {id}` remotely");
1015	        return 1;
1016	    }
1017	    // Leg 1: soft-stop — the listener marker goes first so nothing new is
1018	    // accepted while the edge runs.
1019	    let _ = std::fs::remove_file(perch::resolve_ready_file(&id, ParentHint::Infer));
1020	    // Leg 2: the suspend edge (echo gate + shell cascade live in the host).
1021	    cmd_rest(&id, spt_daemon::RestEvent::Suspend, "SHUTDOWN")
1022	}
1023
1024	/// Run the per-machine daemon (the hidden `spt daemon` entry point). Blocks
1025	/// serving the broker + seed-control channels until killed; a bind contention
1026	/// (another daemon already owns this home) surfaces as a non-zero exit.
1027	// [impl->REQ-DAEMON-3]
1028	fn cmd_daemon() -> i32 {
1029	    match spt_daemon::Daemon::run() {

            let mut shown = 0usize;
            for name in names {
                let Some(reg) = regs.get(&name) else { continue };
                let hide_new = subnets.find(&name).map(|s| s.hide_new_endpoints).unwrap_or(false);
                // The same exclusion the resolver enforces — the projection
                // can never list what resolution would refuse to route to.
                let rows = resource_projection(reg, |id| vis.hidden(id, &name, hide_new));
                for row in rows {
                    println!(
                        "{}\t{}\t{}\t{:?}\t{}",
                        name,
                        row.endpoint_id,
                        row.node,
                        row.status,
                        row.resources.as_deref().unwrap_or("-")
                    );
                    shown += 1;
                }
            }
            eprintln!("RESOURCES:{shown}");
            0
        }
    }
}

/// `spt suspend <id>` / `spt wake <id>` — the manual resting-state edges
/// (REQ-INST-3): apply the event against the local endpoint's durable record
/// through the daemon-side transition host (echo gate-armed for the
/// endpoint's own pulse loop; wake fires the boundary resurface + freshness
/// pull). A qualified `[subnet:]id@node` rides the remote rest arm (M5-D5b,
/// REQ-INST-6): resolve the peer from the registry snapshots, dial, and let
/// the TARGET node's transition host run the edge — exit codes mirror local
/// (edge / NO_EDGE = 0, refusal or failure = 1).
// [impl->REQ-INST-3]
// [impl->REQ-INST-6]
fn cmd_rest(id: &str, event: spt_daemon::RestEvent, verb: &str) -> i32 {
    if id.contains('@') || id.contains(':') {
        let tag = match event {
            spt_daemon::RestEvent::Suspend => spt_net::net::rest::REST_EVENT_SUSPEND,
            spt_daemon::RestEvent::Wake => spt_net::net::rest::REST_EVENT_WAKE,
            // Only the manual verbs route here; the other events are
            // daemon-internal feeds with no CLI surface.
            _ => {
                eprintln!("{verb}_FAIL:{id}: event has no remote arm");
                return 1;
            }
        };
        return match crate::wansend::wan_rest(id, tag) {
            crate::wansend::WanRestOutcome::Edge { node, detail } => {
                eprintln!("{verb}:{id} ({detail}) at {node}");
                0
            }
            crate::wansend::WanRestOutcome::NoEdge { node } => {
                eprintln!("NO_EDGE:{id} (already in the target state) at {node}");
                0
            }
            crate::wansend::WanRestOutcome::NoReply { node } => {
                eprintln!("{verb}_REFUSED:{id}: {node} closed the stream without a reply (access gate)");
                1
            }
            crate::wansend::WanRestOutcome::RemoteFail { node, detail } => {
                eprintln!("{verb}_FAIL:{id} at {node}: {detail}");
                1
            }
            crate::wansend::WanRestOutcome::Ambiguous(a) => {
                eprintln!("AMBIGUOUS:{id} — qualify the address: {a}");
                1
            }
            crate::wansend::WanRestOutcome::NotFound => {
                eprintln!("NO_ENDPOINT:{id}: no routable remote instance in any visible subnet");
                1
            }
            crate::wansend::WanRestOutcome::Failed(e) => {
                eprintln!("{verb}_FAIL:{id}: {e}");
                1
            }
        };
    }
    let knob = spt_daemon::DaemonConfig::load().auto_suspend_after_ms;
    match spt_daemon::daemon_rest_event(id, event, knob) {
        Ok(Some(report)) => {
            eprintln!("{verb}:{id} ({:?} -> {:?})", report.from, report.to);
            0
        }
        Ok(None) => {
            // Idempotent UX: the machine reports no edge (already there).
            eprintln!("NO_EDGE:{id} (already in the target state)");
            0
        }
        Err(e) => {
            eprintln!("{verb}_FAIL:{id}: {e}");
            1
        }
    }
}

/// `spt shutdown [id]` — the agent's graceful self-suspend verb (M5-D4d,
/// CONTEXT §Shell model: "an agent gracefully shuts down *its own* endpoint
/// → suspended; fires the transition echo-commune; cascades its persistent
/// shells offline"). Two legs through existing machinery: (1) soft-stop the
/// listener (drop the ready marker — the session-over half `spt suspend`
/// does not do); (2) the suspend edge via the daemon-side transition host —
/// which arms the echo gate and runs the D4a shell cascade. Idempotent like
/// every rest verb.
// [impl->REQ-SHELL-2]
fn cmd_shutdown(id: Option<String>) -> i32 {
    let Some(id) = id.or_else(roster::detect_self_id) else {
        eprintln!("NO_SELF: shutdown needs an endpoint (spt shutdown <id> or $OWL_SESSION_ID)");
        return 1;
    };
    if id.contains('@') || id.contains(':') {
        // Deliberately NOT lifted with the D5b remote rest arm: an agent
        // shuts down *its own* endpoint — remote shutdown is not a surface;
        // `spt suspend <id@node>` covers the remote need.
        eprintln!("LOCAL_ONLY:{id} — shutdown is an endpoint's own verb; use `spt suspend {id}` remotely");
        return 1;
    }
    // Leg 1: soft-stop — the listener marker goes first so nothing new is
    // accepted while the edge runs.
    let _ = std::fs::remove_file(perch::resolve_ready_file(&id, ParentHint::Infer));
    // Leg 2: the suspend edge (echo gate + shell cascade live in the host).
    cmd_rest(&id, spt_daemon::RestEvent::Suspend, "SHUTDOWN")
}

/// Run the per-machine daemon (the hidden `spt daemon` entry point). Blocks
/// serving the broker + seed-control channels until killed; a bind contention
/// (another daemon already owns this home) surfaces as a non-zero exit.
// [impl->REQ-DAEMON-3]
fn cmd_daemon() -> i32 {
    match spt_daemon::Daemon::run() {


1280	    let outcome = if deferred {
1281	        deliver::send_deferred(&target, &from, &body)
1282	    } else {
1283	        deliver::send(&target, &from, &body, &owlery)
1284	    };
1285
1286	    match outcome {
1287	        SendOutcome::Sent => {
1288	            eprintln!("{}:{}", if is_reply { "REPLIED" } else { "SENT" }, target);
1289	            0
1290	        }
1291	        SendOutcome::Queued => {
1292	            let label = if deferred {
1293	                "DEFERRED"
1294	            } else if is_reply {
1295	                "REPLIED(QUEUED)"
1296	            } else {
1297	                "QUEUED"
1298	            };
1299	            eprintln!("{}:{}", label, target);
1300	            0
1301	        }
1302	        SendOutcome::NoPerch => {
1303	            if deferred {
1304	                // The wire record carries no deferred class (v1) — a remote
1305	                // deferred send is a documented seam, not a silent reclass.
1306	                eprintln!("NO_PERCH:{} is not listening (deferred stays local-only)", target);
1307	                return 1;
1308	            }
1309	            // The WAN leg (D9-1-4): resolve against the registry snapshots
1310	            // and ship to the instance's node; its daemon delivers/spools.
1311	            // [impl->REQ-NET-1]
1312	            match crate::wansend::wan_send(&target, &from, &body) {
1313	                crate::wansend::WanSendOutcome::Sent { node } => {
1314	                    eprintln!(
1315	                        "{}:{}@{}",
1316	                        if is_reply { "REPLIED(WAN)" } else { "SENT(WAN)" },
1317	                        target,
1318	                        node
1319	                    );
1320	                    0
1321	                }
1322	                crate::wansend::WanSendOutcome::Ambiguous(why) => {
1323	                    eprintln!("AMBIGUOUS:{target} — qualify with subnet: or @node ({why})");
1324	                    1
1325	                }
1326	                crate::wansend::WanSendOutcome::NotFound => {
1327	                    eprintln!("NO_PERCH:{} is not listening", target);
1328	                    1
1329	                }
1330	                crate::wansend::WanSendOutcome::Failed(e) => {
1331	                    eprintln!("WAN_FAIL:{target} — {e}");
1332	                    1
1333	                }
1334	            }
1335	        }
1336	        SendOutcome::Empty => {
1337	            eprintln!("EMPTY_MSG: refusing to send empty message to {}", target);
1338	            1
1339	        }
1340	    }
1341	}
1342
1343	fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
1344	    let from = resolve_from(from);
1345	    if from.is_empty() {
1346	        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
1347	        return 1;
1348	    }
1349	    let body = read_stdin();
1350	    let owlery = perch::owlery_dir();
1351
1352	    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
1353	        RingOutcome::Replied(reply) => {
1354	            print!("{}", reply);
1355	            let _ = std::io::stdout().flush();
1356	            0
1357	        }
1358	        RingOutcome::TimedOut => {
1359	            eprintln!("TIMEOUT:{} did not reply within {}s", target, timeout);

    let outcome = if deferred {
        deliver::send_deferred(&target, &from, &body)
    } else {
        deliver::send(&target, &from, &body, &owlery)
    };

    match outcome {
        SendOutcome::Sent => {
            eprintln!("{}:{}", if is_reply { "REPLIED" } else { "SENT" }, target);
            0
        }
        SendOutcome::Queued => {
            let label = if deferred {
                "DEFERRED"
            } else if is_reply {
                "REPLIED(QUEUED)"
            } else {
                "QUEUED"
            };
            eprintln!("{}:{}", label, target);
            0
        }
        SendOutcome::NoPerch => {
            if deferred {
                // The wire record carries no deferred class (v1) — a remote
                // deferred send is a documented seam, not a silent reclass.
                eprintln!("NO_PERCH:{} is not listening (deferred stays local-only)", target);
                return 1;
            }
            // The WAN leg (D9-1-4): resolve against the registry snapshots
            // and ship to the instance's node; its daemon delivers/spools.
            // [impl->REQ-NET-1]
            match crate::wansend::wan_send(&target, &from, &body) {
                crate::wansend::WanSendOutcome::Sent { node } => {
                    eprintln!(
                        "{}:{}@{}",
                        if is_reply { "REPLIED(WAN)" } else { "SENT(WAN)" },
                        target,
                        node
                    );
                    0
                }
                crate::wansend::WanSendOutcome::Ambiguous(why) => {
                    eprintln!("AMBIGUOUS:{target} — qualify with subnet: or @node ({why})");
                    1
                }
                crate::wansend::WanSendOutcome::NotFound => {
                    eprintln!("NO_PERCH:{} is not listening", target);
                    1
                }
                crate::wansend::WanSendOutcome::Failed(e) => {
                    eprintln!("WAN_FAIL:{target} — {e}");
                    1
                }
            }
        }
        SendOutcome::Empty => {
            eprintln!("EMPTY_MSG: refusing to send empty message to {}", target);
            1
        }
    }
}

fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
    let from = resolve_from(from);
    if from.is_empty() {
        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
        return 1;
    }
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
        RingOutcome::Replied(reply) => {
            print!("{}", reply);
            let _ = std::io::stdout().flush();
            0
        }
        RingOutcome::TimedOut => {
            eprintln!("TIMEOUT:{} did not reply within {}s", target, timeout);