1	//! Inbound net-stream dispatch (D9-1) — the daemon-side acceptor that turns
2	//! the hand-wired serve loops of D5–D8 into production behavior.
3	//!
4	//! Every E2E test so far drove the serve half manually (a probe brain calling
5	//! `serve_sync` / `serve_update` / `apply_notif_feed` / `receive_wan` against
6	//! a known stream). In production nothing did — a peer could dial and write
7	//! and the bytes sat in the broker's stream log unread. This module is the
8	//! missing consumer: a daemon-hosted loop that watches the broker's stream
9	//! table for **peer-initiated** streams, classifies each by its first NDJSON
10	//! record, and hands it to the right serve loop on its own worker thread.
11	//!
12	//! ## Direction (the gotcha-#9 fact, now first-class)
13	//!
14	//! The stream table holds locally-opened outbound streams too. The dispatcher
15	//! claims only rows with `initiated_locally == false` — serving our own
16	//! requester stream would deadlock the requester against ourselves. The flag
17	//! is broker-recorded at registration (`open_stream` vs the accept loop),
18	//! never inferred.
19	//!
20	//! ## Classification (first-record demux)
21	//!
22	//! Streams are single-purpose; the opener's first record names the protocol.
23	//! The kind-tagged families collide on variant names (`request` is both
24	//! sync's and attach's opener; `chunk`/`done`/`err` recur everywhere), so the
25	//! demux keys on the first line's **shape**, not its kind alone:
26	//!
27	//! | first line | family |
28	//! |---|---|
29	//! | `kind=request` + `sync_id` | sync pull ([`crate::sync::serve_sync`]) |
30	//! | `kind=request` + `session_id` | remote-drive attach (D9-1-3) |
31	//! | `kind=request` + `endpoint`+`event` | remote rest op ([`crate::resthost::serve_rest`]) |
32	//! | `kind=request` + `owner`+`shell_ref` | cross-node shell link ([`crate::linkhost::serve_shell_link`]) |
33	//! | `kind=query` | update pull ([`crate::propagate::serve_update`]) |
34	//! | `kind=row` | notif feed ([`crate::notifsync::apply_notif_feed`]) |
35	//! | `kind=fetch` / `kind=push` | file transfer (D9-1-3) |
36	//! | untagged + `target`+`op_id` | WAN message feed ([`crate::wan::receive_wan`]) |
37	//! | untagged + `subnet`+`instance` | registry feed (D9-1-2) |
38	//! | anything else | unknown — dropped, loud |
39	//!
40	//! A family whose production wiring lands in a later slice is still
41	//! classified here (the table is complete) but routed to a typed
42	//! [`Unwired`](DispatchOutcome::Unwired) drop — loud, never silent.
43	//!
44	//! ## Worker model
45	//!
46	//! Each claimed stream gets its own worker thread with its **own** brain IPC
47	//! connection (the broker is multi-client by design; the probe-brain pattern
48	//! every E2E test uses). The peek and the serve use **separate** connections:
49	//! the peek brain subscribes from 0 to read the first line and is dropped;
50	//! the serve loop then subscribes from 0 on a fresh connection so it owns its
51	//! stream's event feed alone (no double-subscription interleave). Origin is
52	//! the table's `remote_id_hex` — handshake-proven, never payload
53	//! (REQ-HAZARD-WAN-ORIGIN-AUTH); every family's own gate then runs unchanged.
54	// [impl->REQ-NET-1]
55	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
56
57	use std::collections::HashSet;
58	use std::io;
59	use std::path::PathBuf;
60	use std::sync::atomic::{AtomicBool, Ordering};
61	use std::sync::Arc;
62	use std::thread;
63	use std::time::Duration;
64
65	use spt_net::net::notif::NotifDecoder;
66	use spt_net::net::replicate::{
67	    FeedDecoder as RegistryDecoder, NodeLabelUpdate, RegistryFeedRecord, RegistryUpdate,
68	};
69	use spt_net::net::wanmsg::WanDecoder;
70	use spt_store::contextstore::ContextStore;
71	use spt_store::notif::NotifStore;
72	use spt_store::perch;
73	use spt_store::trust::TrustStore;
74
75	use crate::attach::serve_attach;
76	use crate::brain::{Brain, BrokerEvent};
77	use crate::notifsync::{apply_notif_feed, NotifPolicy};
78	use crate::propagate::serve_update;
79	use crate::registryhost::{RegistryGatePolicy, RegistryHost};
80	use crate::relcache::ReleaseCache;
81	use crate::sync::{serve_sync, SyncPolicy};
82	use crate::wan::receive_wan;
83	use crate::xfer::serve_xfer;
84
85	/// Poll cadence for the stream-table watch (the dispatcher's only busy-wait;
86	/// workers block on their stream's event feed).
87	pub const DEFAULT_DISPATCH_POLL: Duration = Duration::from_millis(100);
88
89	/// What protocol a peer-initiated stream speaks, by its first record's shape.
90	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91	pub enum StreamFamily {
92	    /// A context sync pull (`SyncRecord::Request`).
93	    Sync,
94	    /// An update pull (`UpdRecord::Query`).
95	    Update,
96	    /// A notif replication feed (`NotifRecord::Row`).
97	    Notif,
98	    /// A WAN message feed (untagged `WanMessage` lines).
99	    WanMsg,
100	    /// A registry replication feed (untagged `RegistryUpdate` lines).
101	    Registry,
102	    /// A remote-drive attach (`AttachRecord::Request`).
103	    Attach,
104	    /// A file transfer (`XferRecord::Fetch`/`Push`).
105	    Xfer,
106	    /// A remote rest op (`RestRecord::Request` — suspend/wake from another
107	    /// node, M5-D5b).
108	    Rest,
109	    /// A cross-node shell-link op (`ShellLinkRecord::Request` — relink/cmd
110	    /// against an owner's shell on this node, M5-D8c).
111	    ShellLink,
112	    /// A subnet-scoped liveness probe (`ServeProbeRecord::Request` — "serving
113	    /// subnet X?", the `--nodes` view's detach-aware refinement, REQ-SUBNET-5).
114	    ServeProbe,
115	    /// Unclassifiable — dropped.
116	    Unknown,
117	}
118
119	/// Classify one decoded first line by shape (module-docs demux table).
120	pub fn classify_first_line(line: &serde_json::Value) -> StreamFamily {
121	    match line.get("kind").and_then(|k| k.as_str()) {
122	        Some("request") if line.get("sync_id").is_some() => StreamFamily::Sync,
123	        Some("request") if line.get("session_id").is_some() => StreamFamily::Attach,
124	        Some("request") if line.get("endpoint").is_some() && line.get("event").is_some() => {
125	            StreamFamily::Rest
126	        }
127	        Some("request") if line.get("owner").is_some() && line.get("shell_ref").is_some() => {
128	            StreamFamily::ShellLink
129	        }
130	        Some("request") if line.get("serve_probe").is_some() => StreamFamily::ServeProbe,
131	        Some("query") => StreamFamily::Update,
132	        // The status-only convergence query rides the update family (M8
133	        // decision 19, REQ-UPD-6) — same serve loop, no artifact bytes.
134	        Some("status_query") => StreamFamily::Update,
135	        Some("row") => StreamFamily::Notif,
136	        Some("fetch") | Some("push") => StreamFamily::Xfer,
137	        Some(_) => StreamFamily::Unknown,
138	        None => {
139	            if line.get("target").is_some() && line.get("op_id").is_some() {
140	                StreamFamily::WanMsg
141	            } else if line.get("subnet").is_some() && line.get("instance").is_some() {
142	                StreamFamily::Registry
143	            } else if line.get("subnet").is_some()
144	                && line.get("node").is_some()
145	                && line.get("label").is_some()
146	            {
147	                // A node-level label feed (REQ-SUBNET-3): an endpoint-less peer
148	                // sends label records first (no instance rows), so the feed's
149	                // first line is a NodeLabelUpdate. Same serve arm, same gate.
150	                StreamFamily::Registry
151	            } else {
152	                StreamFamily::Unknown
153	            }
154	        }
155	    }
156	}
157
158	/// Why a dispatched stream's worker ended — loud + typed, never silent.
159	#[derive(Debug)]
160	pub enum DispatchOutcome {
161	    /// The family's serve loop ran to its own outcome (rendered for the log).
162	    Served(String),
163	    /// The first line never arrived / didn't parse — dropped.
164	    Unknown,
165	    /// The worker failed (IO against broker or store).
166	    Failed(String),
167	}
168
169	/// The SPT_HOME-anchored paths the serve arms need. One value, cloned per
170	/// worker (policies/stores re-load from the canonical paths per request —
171	/// the access-gate stance, config edits take effect without a restart).
172	#[derive(Debug, Clone)]
173	pub struct DispatchPaths {
174	    /// The perch tree (WAN message funnel target).
175	    pub owlery: PathBuf,
176	    /// The staged-release cache dir ([`ReleaseCache::open`]).
177	    pub releases: PathBuf,
178	    /// Transient bundle/artifact scratch.
179	    pub scratch: PathBuf,
180	}
181
182	impl DispatchPaths {
183	    /// The production layout under [`perch::spt_home`].
184	    pub fn production() -> DispatchPaths {
185	        let home = perch::spt_home();
186	        DispatchPaths {
187	            owlery: perch::owlery_dir(),
188	            releases: home.join("releases"),
189	            scratch: home.join("scratch").join("net"),
190	        }
191	    }
192	}
193
194	/// Watch the broker's stream table and dispatch every **peer-initiated**
195	/// stream to a family worker, until `stop` is set. Blocks the calling thread
196	/// (the daemon spawns it on its own); workers detach (each ends with its
197	/// stream).
198	pub fn run_dispatch_loop(
199	    broker_name: &str,
200	    paths: &DispatchPaths,
201	    registry: Arc<RegistryHost>,
202	    stop: &AtomicBool,
203	    poll: Duration,
204	) -> io::Result<()> {
205	    let mut brain = connect(broker_name)?;
206	    let mut claimed: HashSet<u64> = HashSet::new();
207
208	    while !stop.load(Ordering::Relaxed) {
209	        let reply = brain.net_streams()?;
210	        for s in reply.streams {
211	            // Claim only peer-initiated rows with bytes (a QUIC stream is
212	            // invisible until its first write anyway — gotcha #6 — but the
213	            // next_seq guard also skips a row whose pump hasn't appended yet).
214	            if s.initiated_locally || s.next_seq == 0 || !claimed.insert(s.stream_id) {
215	                continue;
216	            }
217	            let name = broker_name.to_string();
218	            let origin = s.remote_id_hex.clone();
219	            let stream_id = s.stream_id;
220	            let p = paths.clone();
221	            let reg = Arc::clone(&registry);
222	            thread::spawn(move || {
223	                let outcome = worker(&name, stream_id, &origin, &p, &reg);
224	                match outcome {
225	                    DispatchOutcome::Served(_) => {}
226	                    other => eprintln!("DISPATCH:{stream_id}:{other:?}"),
227	                }
228	            });
229	        }
230	        thread::sleep(poll);
231	    }
232	    Ok(())
233	}
234
235	/// Connect a fresh brain IPC client (retrying through broker startup).
236	fn connect(broker_name: &str) -> io::Result<Brain> {
237	    let mut last = io::Error::other("broker never answered");
238	    for _ in 0..300 {
239	        match Brain::cold_start(broker_name, now_ms()) {
240	            Ok(b) => return Ok(b),
241	            Err(e) => last = e,
242	        }
243	        thread::sleep(Duration::from_millis(10));
244	    }
245	    Err(last)
246	}
247
248	fn now_ms() -> u64 {
249	    std::time::SystemTime::now()
250	        .duration_since(std::time::UNIX_EPOCH)
251	        .map(|d| d.as_millis() as u64)
252	        .unwrap_or(0)
253	}
254
255	/// Peek the stream's first NDJSON line on a throwaway brain connection.
256	fn peek_first_line(broker_name: &str, stream_id: u64) -> io::Result<Option<serde_json::Value>> {
257	    let mut brain = connect(broker_name)?;
258	    brain.net_stream_subscribe(stream_id, 0)?;
259	    let mut buf: Vec<u8> = Vec::new();
260	    loop {
261	        match brain.read_event()? {
262	            BrokerEvent::NetStreamData {
263	                stream_id: sid,
264	                bytes,
265	                ..
266	            } if sid == stream_id => {
267	                buf.extend_from_slice(&bytes);
268	                if let Some(nl) = buf.iter().position(|&b| b == b'\n') {
269	                    return Ok(serde_json::from_slice(&buf[..nl]).ok());
270	                }
271	            }
272	            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
273	                // Finished without a complete line: classify what we have
274	                // (a line without a trailing newline before EOF still counts).
275	                return Ok(serde_json::from_slice(&buf).ok());
276	            }
277	            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
278	            _ => {}
279	        }
280	    }
281	}
282
283	/// One claimed stream's worker: peek → classify → run the family's serve
284	/// loop on a fresh brain connection.
285	fn worker(
286	    broker_name: &str,
287	    stream_id: u64,
288	    origin: &str,
289	    paths: &DispatchPaths,
290	    registry: &RegistryHost,
291	) -> DispatchOutcome {
292	    let line = match peek_first_line(broker_name, stream_id) {
293	        Ok(Some(v)) => v,
294	        Ok(None) => return DispatchOutcome::Unknown,
295	        Err(e) => return DispatchOutcome::Failed(e.to_string()),
296	    };
297	    let family = classify_first_line(&line);
298
299	    let mut brain = match connect(broker_name) {
300	        Ok(b) => b,
301	        Err(e) => return DispatchOutcome::Failed(e.to_string()),
302	    };
303
304	    match family {
305	        StreamFamily::Sync => {
306	            let cs = match ContextStore::open_or_init() {
307	                Ok(cs) => cs,
308	                Err(e) => return DispatchOutcome::Failed(e.to_string()),
309	            };
310	            let policy = SyncPolicy::load();
311	            match serve_sync(
312	                &mut brain,
313	                stream_id,
314	                origin,
315	                0,
316	                &cs,
317	                &policy,
318	                &paths.scratch,
319	            ) {
320	                Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
321	                Err(e) => DispatchOutcome::Failed(e.to_string()),
322	            }
323	        }
324	        StreamFamily::Update => {
325	            let cache = ReleaseCache::open(&paths.releases);
326	            let trust = TrustStore::load();
327	            match serve_update(&mut brain, stream_id, origin, 0, &cache, &trust) {
328	                Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
329	                Err(e) => DispatchOutcome::Failed(e.to_string()),
330	            }
331	        }
332	        StreamFamily::Notif => serve_notif_feed(&mut brain, stream_id, origin),
333	        StreamFamily::WanMsg => serve_wan_feed(&mut brain, stream_id, origin, paths),
334	        StreamFamily::Registry => serve_registry_feed(&mut brain, stream_id, origin, registry),
335	        StreamFamily::Attach => {
336	            // The gate subject is the session's OWNING endpoint, resolved
337	            // from the broker's table (the spawn's label) — never from wire
338	            // bytes. An unlabeled or unknown session refuses, fail-closed.
339	            let session_id = line.get("session_id").and_then(|v| v.as_u64());
340	            let endpoint = session_id.and_then(|sid| {
341	                brain.sessions().ok().and_then(|reply| {
342	                    reply
343	                        .sessions
344	                        .iter()
345	                        .find(|s| s.session_id == sid && !s.endpoint.is_empty())
346	                        .map(|s| s.endpoint.clone())
347	                })
348	            });
349	            match endpoint {
350	                Some(endpoint) => match serve_attach(&mut brain, stream_id, &endpoint, origin, 0) {
351	                    Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
352	                    Err(e) => DispatchOutcome::Failed(e.to_string()),
353	                },
354	                None => {
355	                    // Refuse politely: finish our send side, claim stays.
356	                    let _ = brain.net_stream_send(stream_id, &[], None, true);
357	                    DispatchOutcome::Served("attach refused: unlabeled/unknown session".into())
358	                }
359	            }
360	        }
361	        StreamFamily::Xfer => {
362	            // The wire names whose whitelist gates (the rule set); the origin
363	            // it is checked against stays handshake-proven. Blank refuses.
364	            let endpoint = line
365	                .get("endpoint")
366	                .and_then(|v| v.as_str())
367	                .filter(|e| !e.is_empty())
368	                .map(str::to_string);
369	            match endpoint {
370	                Some(endpoint) => {
371	                    let root = spt_store::xfer::transfers_dir();
372	                    let progress = paths.scratch.join("xfer-progress");
373	                    match serve_xfer(
374	                        &mut brain, stream_id, &endpoint, origin, 0, &root, &progress,
375	                    ) {
376	                        Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
377	                        Err(e) => DispatchOutcome::Failed(e.to_string()),
378	                    }
379	                }
380	                None => {
381	                    let _ = brain.net_stream_send(stream_id, &[], None, true);
382	                    DispatchOutcome::Served("xfer refused: no endpoint named".into())
383	                }
384	            }
385	        }
386	        StreamFamily::Rest => {
387	            // The wire names whose whitelist gates (the xfer posture); the
388	            // origin stays handshake-proven. The serve applies the event
389	            // through the LOCAL transition host — echo gate, wake effects,
390	            // and shell cascade run on this node, where the instance lives.
391	            // [impl->REQ-INST-6]
392	            let knob = crate::config::DaemonConfig::load().auto_suspend_after_ms;
393	            match crate::resthost::serve_rest(&mut brain, stream_id, origin, knob) {
394	                Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
395	                Err(e) => DispatchOutcome::Failed(e.to_string()),
396	            }
397	        }
398	        StreamFamily::ShellLink => {
399	            // The wire names the owner whose whitelist gates AND whose shell
400	            // tree resolves (owner exclusivity = resolution scope); the
401	            // action runs against the LOCAL owlery — perch, manifest, spool,
402	            // and binary all live here, the shell's node.
403	            // [impl->REQ-SHELL-2]
404	            match crate::linkhost::serve_shell_link(&mut brain, stream_id, origin, &paths.owlery) {
405	                Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
406	                Err(e) => DispatchOutcome::Failed(e.to_string()),
407	            }
408	        }
409	        StreamFamily::ServeProbe => {
410	            // No gate (serveprobe module docs): the answer is public
411	            // serve-state for a named subnet, read fresh from this node's
412	            // own stores — the QUIC handshake is the only subject.
413	            // [impl->REQ-SUBNET-5]
414	            match crate::serveprobe::serve_subnet_probe(&mut brain, stream_id) {
415	                Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
416	                Err(e) => DispatchOutcome::Failed(e.to_string()),
417	            }
418	        }
419	        StreamFamily::Unknown => DispatchOutcome::Unknown,
420	    }
421	}
422
423	/// Registry feed arm: decode `RegistryUpdate` lines, merge each batch into
424	/// the daemon's [`RegistryHost`] under the fail-closed gate (member subnet ∧
425	/// origin trusted in that subnet), until the peer finishes.
426	///
427	/// An admitted batch may carry **attention flips** (REQ-INST-3): a peer
428	/// instance of an id observed transitioning to `Active` — its node's daemon
429	/// just gave it a driver, so a *locally hosted* instance of the same id
430	/// rests warm (CONTEXT: driving `ling@laptop` makes `ling@desktop`
431	/// dormant). The rest edge's transition echo is gate-armed
432	/// ([`crate::resting::arm_transition_echo`]) — the endpoint's own pulse
433	/// loop runs the bounded summarizer call, never this worker (the KH 7.4
434	/// non-blocking posture: N flips in one feed are N gate files, not N LLM
435	/// calls serialized here).
436	// [impl->REQ-INST-7]
437	fn serve_registry_feed(
438	    brain: &mut Brain,
439	    stream_id: u64,
440	    origin: &str,
441	    registry: &RegistryHost,
442	) -> DispatchOutcome {
443	    let policy = RegistryGatePolicy::load();
444	    if let Err(e) = brain.net_stream_subscribe(stream_id, 0) {
445	        return DispatchOutcome::Failed(e.to_string());
446	    }
447	    let mut decoder = RegistryDecoder::new();
448	    let mut merged = 0usize;
449	    loop {
450	        match brain.read_event() {
451	            Ok(BrokerEvent::NetStreamData {
452	                stream_id: sid,
453	                bytes,
454	                ..
455	            }) if sid == stream_id => {
456	                let records = decoder.push(&bytes);
457	                if records.is_empty() {
458	                    continue;
459	                }
460	                // Split the feed into instance rows and node-level labels
461	                // (REQ-SUBNET-3) — each goes through its own gated apply path.
462	                let mut updates: Vec<RegistryUpdate> = Vec::new();
463	                let mut labels: Vec<NodeLabelUpdate> = Vec::new();
464	                for rec in records {
465	                    match rec {
466	                        RegistryFeedRecord::Instance(u) => updates.push(u),
467	                        RegistryFeedRecord::NodeLabel(n) => labels.push(n),
468	                    }
469	                }
470	                if !labels.is_empty() {
471	                    merged += registry.apply_node_labels(origin, &labels, &policy).len();
472	                }
473	                if updates.is_empty() {
474	                    continue;
475	                }
476	                let (verdicts, flips) = registry.apply_feed_flips(origin, &updates, &policy);
477	                merged += verdicts.len();
478	                // Attention shift (D9-2): only ids this node actually hosts
479	                // (a perch with a record); everything else is a no-op here.
480	                // [impl->REQ-INST-3]
481	                for id in flips {
482	                    let local = perch::resolve_perch_path(&id, spt_store::perch::ParentHint::Infer);
483	                    if local.join("info.json").exists() {
484	                        if let Err(e) = crate::resting::daemon_rest_event(
485	                            &id,
486	                            crate::resting::RestEvent::AttentionShift,
487	                            None,
488	                        ) {
489	                            eprintln!("REST_SHIFT_FAIL:{id}:{e}");
490	                        }
491	                    }
492	                }
493	            }
494	            Ok(BrokerEvent::NetStreamEof { stream_id: sid, .. }) if sid == stream_id => {
495	                return DispatchOutcome::Served(format!("registry feed: {merged} record(s)"));
496	            }
497	            Ok(BrokerEvent::Error { message }) => return DispatchOutcome::Failed(message),
498	            Ok(_) => {}
499	            Err(e) => return DispatchOutcome::Failed(e.to_string()),
500	        }
501	    }
502	}
503
504	/// Notif feed arm: decode rows as they arrive, apply each batch under the
505	/// fail-closed gate, until the peer finishes. The policy re-loads per feed
506	/// (not per record) — the same freshness the other arms get per request.
507	///
508	/// A **freshly inserted** undismissed row additionally gets a first-fire here
509	/// (REQ-PRES-1, the convergent cross-node redirect's receiving half): the
510	/// producing node skipped its local surface because the MRA winner lives
511	/// here — this node's own presence resolution surfaces it. A row this node's
512	/// MRA also calls remote stays unmarked and rides on (someone else's
513	/// feed-apply surfaces it); replays merge `Unchanged` and never re-fire.
514	// [impl->REQ-NOTIF-1]
515	// [impl->REQ-PRES-1]
516	fn serve_notif_feed(brain: &mut Brain, stream_id: u64, origin: &str) -> DispatchOutcome {
517	    let store = match NotifStore::open() {
518	        Ok(s) => s,
519	        Err(e) => return DispatchOutcome::Failed(e.to_string()),
520	    };
521	    let policy = NotifPolicy::load();
522	    // This serving node's identity for the presence resolution: the broker's
523	    // handshake identity (the same hex peers see in our registry rows).
524	    let self_hex = brain
525	        .net_status()
526	        .ok()
527	        .and_then(|s| s.node_id_hex)
528	        .unwrap_or_default();
529	    if let Err(e) = brain.net_stream_subscribe(stream_id, 0) {
530	        return DispatchOutcome::Failed(e.to_string());
531	    }
532	    let mut decoder = NotifDecoder::new();
533	    let mut applied = 0usize;
534	    loop {
535	        match brain.read_event() {
536	            Ok(BrokerEvent::NetStreamData {
537	                stream_id: sid,
538	                bytes,
539	                ..
540	            }) if sid == stream_id => {
541	                let records = decoder.push(&bytes);
542	                if records.is_empty() {
543	                    continue;
544	                }
545	                match apply_notif_feed(&store, origin, &records, &policy) {
546	                    Ok(verdicts) => {
547	                        applied += verdicts.len();
548	                        surface_fresh_rows(&store, &records, &verdicts, &self_hex);
549	                    }
550	                    Err(e) => return DispatchOutcome::Failed(e),
551	                }
552	            }
553	            Ok(BrokerEvent::NetStreamEof { stream_id: sid, .. }) if sid == stream_id => {
554	                return DispatchOutcome::Served(format!("notif feed: {applied} record(s)"));
555	            }
556	            Ok(BrokerEvent::Error { message }) => return DispatchOutcome::Failed(message),
557	            Ok(_) => {}
558	            Err(e) => return DispatchOutcome::Failed(e.to_string()),
559	        }
560	    }
561	}
562
563	/// The receiving half of the cross-node notif redirect (REQ-PRES-1): first-
564	/// fire every **freshly inserted** undismissed row from an applied feed
565	/// batch, under this node's own presence resolution. Best-effort and loud —
566	/// a failed surface costs a boundary-resurface delay, never the feed.
567	// [impl->REQ-PRES-1]
568	fn surface_fresh_rows(
569	    store: &NotifStore,
570	    records: &[spt_net::net::notif::NotifRecord],
571	    verdicts: &[crate::notifsync::NotifApplyVerdict],
572	    self_hex: &str,
573	) {
574	    use crate::notifsync::NotifApplyVerdict;
575	    use spt_store::notif::MergeOutcome;
576	    let fresh = records.iter().zip(verdicts).filter_map(|(rec, v)| {
577	        let spt_net::net::notif::NotifRecord::Row { row } = rec;
578	        (matches!(v, NotifApplyVerdict::Applied(MergeOutcome::Inserted)) && !row.dismissed)
579	            .then_some(row)
580	    });
581	    let policy = crate::notif::NotifSurfacePolicy::load();
582	    let regs = crate::presence::load_registry_snapshots(&crate::presence::registry_snapshot_dir());
583	    let owlery = perch::owlery_dir();
584	    for row in fresh {
585	        match crate::notif::first_fire_at(store, row, &policy, &owlery, self_hex, &regs, now_ms()) {
586	            Ok(_) => {}
587	            Err(e) => eprintln!("NOTIF_FEED_SURFACE_FAIL:{}: {e}", row.notif_id),
588	        }
589	    }
590	}
591
592	/// WAN message arm: decode `WanMessage` lines, funnel each through the
593	/// access-gate + op-id-dedup path, until the peer finishes.
594	// [impl->REQ-NET-1]
595	fn serve_wan_feed(
596	    brain: &mut Brain,
597	    stream_id: u64,
598	    origin: &str,
599	    paths: &DispatchPaths,
600	) -> DispatchOutcome {
601	    if let Err(e) = brain.net_stream_subscribe(stream_id, 0) {
602	        return DispatchOutcome::Failed(e.to_string());
603	    }
604	    let mut decoder = WanDecoder::new();
605	    let mut handled = 0usize;
606	    loop {
607	        match brain.read_event() {
608	            Ok(BrokerEvent::NetStreamData {
609	                stream_id: sid,
610	                bytes,
611	                ..
612	            }) if sid == stream_id => {
613	                for msg in decoder.push(&bytes) {
614	                    let _ = receive_wan(&msg, origin, &paths.owlery);
615	                    handled += 1;
616	                }
617	            }
618	            Ok(BrokerEvent::NetStreamEof { stream_id: sid, .. }) if sid == stream_id => {
619	                return DispatchOutcome::Served(format!("wan feed: {handled} record(s)"));
620	            }
621	            Ok(BrokerEvent::Error { message }) => return DispatchOutcome::Failed(message),
622	            Ok(_) => {}
623	            Err(e) => return DispatchOutcome::Failed(e.to_string()),
624	        }
625	    }
626	}
627
628	/// Spawn the dispatcher on its own thread against the canonical broker
629	/// socket — the daemon-boot entry ([`crate::daemon::Daemon::run`]).
630	pub fn spawn_dispatcher(
631	    broker_name: String,
632	    registry: Arc<RegistryHost>,
633	    stop: Arc<AtomicBool>,
634	) -> thread::JoinHandle<()> {
635	    thread::spawn(move || {
636	        let paths = DispatchPaths::production();
637	        if let Err(e) =
638	            run_dispatch_loop(&broker_name, &paths, registry, &stop, DEFAULT_DISPATCH_POLL)
639	        {
640	            eprintln!("DISPATCH_LOOP_FAIL: {e}");
641	        }
642	    })
643	}
644
645	#[cfg(test)]
646	mod tests {
647	    use super::*;
648	    use serde_json::json;
649
650	    // [unit->REQ-NET-1] the demux table: every family's first record
651	    // classifies correctly, including the `request` kind collision.
652	    #[test]
653	    fn classifies_every_family_by_first_line_shape() {
654	        let sync = json!({"kind": "request", "sync_id": "pull-1", "refs": [], "have_tips": {}});
655	        assert_eq!(classify_first_line(&sync), StreamFamily::Sync);
656
657	        let attach = json!({"kind": "request", "session_id": 3, "from_seq": 0});
658	        assert_eq!(classify_first_line(&attach), StreamFamily::Attach);
659
660	        // [unit->REQ-INST-6] the rest op's opener disambiguates by shape too.
661	        let rest =
662	            json!({"kind": "request", "endpoint": "ling", "event": "suspend", "op_id": "a:1"});
663	        assert_eq!(classify_first_line(&rest), StreamFamily::Rest);
664
665	        let update =
666	            json!({"kind": "query", "upd_id": "u1", "current_version": 5, "channel": "stable"});
667	        assert_eq!(classify_first_line(&update), StreamFamily::Update);
668
669	        let notif = json!({"kind": "row", "row": {}});
670	        assert_eq!(classify_first_line(&notif), StreamFamily::Notif);
671
672	        let fetch = json!({"kind": "fetch", "path": "a.txt", "xfer_id": "x1"});
673	        assert_eq!(classify_first_line(&fetch), StreamFamily::Xfer);
674	        let push = json!({"kind": "push", "path": "a.txt", "total": 4, "xfer_id": "x2"});
675	        assert_eq!(classify_first_line(&push), StreamFamily::Xfer);
676
677	        let wan = json!({"target": "ling", "from": "oak", "body": "hi", "op_id": "w1"});
678	        assert_eq!(classify_first_line(&wan), StreamFamily::WanMsg);
679
680	        let reg = json!({"subnet": "home", "endpoint_id": "ling",
681	            "instance": {"node": "n1", "status": "Active", "epoch": 4}});
682	        assert_eq!(classify_first_line(&reg), StreamFamily::Registry);
683	    }
684
685	    // [unit->REQ-NET-1] unclassifiable shapes are Unknown — dropped loud,
686	    // never routed to a gate-bearing serve loop on a guess.
687	    #[test]
688	    fn unknown_shapes_refuse_classification() {
689	        for v in [
690	            json!({"kind": "chunk", "sync_id": "s", "offset": 0, "data_b64": ""}), // mid-protocol, not an opener
691	            json!({"kind": "request"}), // request without any discriminant
692	            json!({"kind": "request", "endpoint": "ling"}), // rest-ish but no event
693	            json!({"kind": "nonsense"}),
694	            json!({"target": "ling"}), // wan-ish but no op_id
695	            json!({"subnet": "home"}), // registry-ish but no instance
696	            json!({"completely": "other"}),
697	            json!(42),
698	        ] {
699	            assert_eq!(classify_first_line(&v), StreamFamily::Unknown, "for {v}");
700	        }
701	    }
702	}
703

//! Inbound net-stream dispatch (D9-1) — the daemon-side acceptor that turns
//! the hand-wired serve loops of D5–D8 into production behavior.
//!
//! Every E2E test so far drove the serve half manually (a probe brain calling
//! `serve_sync` / `serve_update` / `apply_notif_feed` / `receive_wan` against
//! a known stream). In production nothing did — a peer could dial and write
//! and the bytes sat in the broker's stream log unread. This module is the
//! missing consumer: a daemon-hosted loop that watches the broker's stream
//! table for **peer-initiated** streams, classifies each by its first NDJSON
//! record, and hands it to the right serve loop on its own worker thread.
//!
//! ## Direction (the gotcha-#9 fact, now first-class)
//!
//! The stream table holds locally-opened outbound streams too. The dispatcher
//! claims only rows with `initiated_locally == false` — serving our own
//! requester stream would deadlock the requester against ourselves. The flag
//! is broker-recorded at registration (`open_stream` vs the accept loop),
//! never inferred.
//!
//! ## Classification (first-record demux)
//!
//! Streams are single-purpose; the opener's first record names the protocol.
//! The kind-tagged families collide on variant names (`request` is both
//! sync's and attach's opener; `chunk`/`done`/`err` recur everywhere), so the
//! demux keys on the first line's **shape**, not its kind alone:
//!
//! | first line | family |
//! |---|---|
//! | `kind=request` + `sync_id` | sync pull ([`crate::sync::serve_sync`]) |
//! | `kind=request` + `session_id` | remote-drive attach (D9-1-3) |
//! | `kind=request` + `endpoint`+`event` | remote rest op ([`crate::resthost::serve_rest`]) |
//! | `kind=request` + `owner`+`shell_ref` | cross-node shell link ([`crate::linkhost::serve_shell_link`]) |
//! | `kind=query` | update pull ([`crate::propagate::serve_update`]) |
//! | `kind=row` | notif feed ([`crate::notifsync::apply_notif_feed`]) |
//! | `kind=fetch` / `kind=push` | file transfer (D9-1-3) |
//! | untagged + `target`+`op_id` | WAN message feed ([`crate::wan::receive_wan`]) |
//! | untagged + `subnet`+`instance` | registry feed (D9-1-2) |
//! | anything else | unknown — dropped, loud |
//!
//! A family whose production wiring lands in a later slice is still
//! classified here (the table is complete) but routed to a typed
//! [`Unwired`](DispatchOutcome::Unwired) drop — loud, never silent.
//!
//! ## Worker model
//!
//! Each claimed stream gets its own worker thread with its **own** brain IPC
//! connection (the broker is multi-client by design; the probe-brain pattern
//! every E2E test uses). The peek and the serve use **separate** connections:
//! the peek brain subscribes from 0 to read the first line and is dropped;
//! the serve loop then subscribes from 0 on a fresh connection so it owns its
//! stream's event feed alone (no double-subscription interleave). Origin is
//! the table's `remote_id_hex` — handshake-proven, never payload
//! (REQ-HAZARD-WAN-ORIGIN-AUTH); every family's own gate then runs unchanged.
// [impl->REQ-NET-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]

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

use spt_net::net::notif::NotifDecoder;
use spt_net::net::replicate::{
    FeedDecoder as RegistryDecoder, NodeLabelUpdate, RegistryFeedRecord, RegistryUpdate,
};
use spt_net::net::wanmsg::WanDecoder;
use spt_store::contextstore::ContextStore;
use spt_store::notif::NotifStore;
use spt_store::perch;
use spt_store::trust::TrustStore;

use crate::attach::serve_attach;
use crate::brain::{Brain, BrokerEvent};
use crate::notifsync::{apply_notif_feed, NotifPolicy};
use crate::propagate::serve_update;
use crate::registryhost::{RegistryGatePolicy, RegistryHost};
use crate::relcache::ReleaseCache;
use crate::sync::{serve_sync, SyncPolicy};
use crate::wan::receive_wan;
use crate::xfer::serve_xfer;

/// Poll cadence for the stream-table watch (the dispatcher's only busy-wait;
/// workers block on their stream's event feed).
pub const DEFAULT_DISPATCH_POLL: Duration = Duration::from_millis(100);

/// What protocol a peer-initiated stream speaks, by its first record's shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamFamily {
    /// A context sync pull (`SyncRecord::Request`).
    Sync,
    /// An update pull (`UpdRecord::Query`).
    Update,
    /// A notif replication feed (`NotifRecord::Row`).
    Notif,
    /// A WAN message feed (untagged `WanMessage` lines).
    WanMsg,
    /// A registry replication feed (untagged `RegistryUpdate` lines).
    Registry,
    /// A remote-drive attach (`AttachRecord::Request`).
    Attach,
    /// A file transfer (`XferRecord::Fetch`/`Push`).
    Xfer,
    /// A remote rest op (`RestRecord::Request` — suspend/wake from another
    /// node, M5-D5b).
    Rest,
    /// A cross-node shell-link op (`ShellLinkRecord::Request` — relink/cmd
    /// against an owner's shell on this node, M5-D8c).
    ShellLink,
    /// A subnet-scoped liveness probe (`ServeProbeRecord::Request` — "serving
    /// subnet X?", the `--nodes` view's detach-aware refinement, REQ-SUBNET-5).
    ServeProbe,
    /// Unclassifiable — dropped.
    Unknown,
}

/// Classify one decoded first line by shape (module-docs demux table).
pub fn classify_first_line(line: &serde_json::Value) -> StreamFamily {
    match line.get("kind").and_then(|k| k.as_str()) {
        Some("request") if line.get("sync_id").is_some() => StreamFamily::Sync,
        Some("request") if line.get("session_id").is_some() => StreamFamily::Attach,
        Some("request") if line.get("endpoint").is_some() && line.get("event").is_some() => {
            StreamFamily::Rest
        }
        Some("request") if line.get("owner").is_some() && line.get("shell_ref").is_some() => {
            StreamFamily::ShellLink
        }
        Some("request") if line.get("serve_probe").is_some() => StreamFamily::ServeProbe,
        Some("query") => StreamFamily::Update,
        // The status-only convergence query rides the update family (M8
        // decision 19, REQ-UPD-6) — same serve loop, no artifact bytes.
        Some("status_query") => StreamFamily::Update,
        Some("row") => StreamFamily::Notif,
        Some("fetch") | Some("push") => StreamFamily::Xfer,
        Some(_) => StreamFamily::Unknown,
        None => {
            if line.get("target").is_some() && line.get("op_id").is_some() {
                StreamFamily::WanMsg
            } else if line.get("subnet").is_some() && line.get("instance").is_some() {
                StreamFamily::Registry
            } else if line.get("subnet").is_some()
                && line.get("node").is_some()
                && line.get("label").is_some()
            {
                // A node-level label feed (REQ-SUBNET-3): an endpoint-less peer
                // sends label records first (no instance rows), so the feed's
                // first line is a NodeLabelUpdate. Same serve arm, same gate.
                StreamFamily::Registry
            } else {
                StreamFamily::Unknown
            }
        }
    }
}

/// Why a dispatched stream's worker ended — loud + typed, never silent.
#[derive(Debug)]
pub enum DispatchOutcome {
    /// The family's serve loop ran to its own outcome (rendered for the log).
    Served(String),
    /// The first line never arrived / didn't parse — dropped.
    Unknown,
    /// The worker failed (IO against broker or store).
    Failed(String),
}

/// The SPT_HOME-anchored paths the serve arms need. One value, cloned per
/// worker (policies/stores re-load from the canonical paths per request —
/// the access-gate stance, config edits take effect without a restart).
#[derive(Debug, Clone)]
pub struct DispatchPaths {
    /// The perch tree (WAN message funnel target).
    pub owlery: PathBuf,
    /// The staged-release cache dir ([`ReleaseCache::open`]).
    pub releases: PathBuf,
    /// Transient bundle/artifact scratch.
    pub scratch: PathBuf,
}

impl DispatchPaths {
    /// The production layout under [`perch::spt_home`].
    pub fn production() -> DispatchPaths {
        let home = perch::spt_home();
        DispatchPaths {
            owlery: perch::owlery_dir(),
            releases: home.join("releases"),
            scratch: home.join("scratch").join("net"),
        }
    }
}

/// Watch the broker's stream table and dispatch every **peer-initiated**
/// stream to a family worker, until `stop` is set. Blocks the calling thread
/// (the daemon spawns it on its own); workers detach (each ends with its
/// stream).
pub fn run_dispatch_loop(
    broker_name: &str,
    paths: &DispatchPaths,
    registry: Arc<RegistryHost>,
    stop: &AtomicBool,
    poll: Duration,
) -> io::Result<()> {
    let mut brain = connect(broker_name)?;
    let mut claimed: HashSet<u64> = HashSet::new();

    while !stop.load(Ordering::Relaxed) {
        let reply = brain.net_streams()?;
        for s in reply.streams {
            // Claim only peer-initiated rows with bytes (a QUIC stream is
            // invisible until its first write anyway — gotcha #6 — but the
            // next_seq guard also skips a row whose pump hasn't appended yet).
            if s.initiated_locally || s.next_seq == 0 || !claimed.insert(s.stream_id) {
                continue;
            }
            let name = broker_name.to_string();
            let origin = s.remote_id_hex.clone();
            let stream_id = s.stream_id;
            let p = paths.clone();
            let reg = Arc::clone(&registry);
            thread::spawn(move || {
                let outcome = worker(&name, stream_id, &origin, &p, &reg);
                match outcome {
                    DispatchOutcome::Served(_) => {}
                    other => eprintln!("DISPATCH:{stream_id}:{other:?}"),
                }
            });
        }
        thread::sleep(poll);
    }
    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)
}

/// Peek the stream's first NDJSON line on a throwaway brain connection.
fn peek_first_line(broker_name: &str, stream_id: u64) -> io::Result<Option<serde_json::Value>> {
    let mut brain = connect(broker_name)?;
    brain.net_stream_subscribe(stream_id, 0)?;
    let mut buf: Vec<u8> = Vec::new();
    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData {
                stream_id: sid,
                bytes,
                ..
            } if sid == stream_id => {
                buf.extend_from_slice(&bytes);
                if let Some(nl) = buf.iter().position(|&b| b == b'\n') {
                    return Ok(serde_json::from_slice(&buf[..nl]).ok());
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                // Finished without a complete line: classify what we have
                // (a line without a trailing newline before EOF still counts).
                return Ok(serde_json::from_slice(&buf).ok());
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}

/// One claimed stream's worker: peek → classify → run the family's serve
/// loop on a fresh brain connection.
fn worker(
    broker_name: &str,
    stream_id: u64,
    origin: &str,
    paths: &DispatchPaths,
    registry: &RegistryHost,
) -> DispatchOutcome {
    let line = match peek_first_line(broker_name, stream_id) {
        Ok(Some(v)) => v,
        Ok(None) => return DispatchOutcome::Unknown,
        Err(e) => return DispatchOutcome::Failed(e.to_string()),
    };
    let family = classify_first_line(&line);

    let mut brain = match connect(broker_name) {
        Ok(b) => b,
        Err(e) => return DispatchOutcome::Failed(e.to_string()),
    };

    match family {
        StreamFamily::Sync => {
            let cs = match ContextStore::open_or_init() {
                Ok(cs) => cs,
                Err(e) => return DispatchOutcome::Failed(e.to_string()),
            };
            let policy = SyncPolicy::load();
            match serve_sync(
                &mut brain,
                stream_id,
                origin,
                0,
                &cs,
                &policy,
                &paths.scratch,
            ) {
                Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
                Err(e) => DispatchOutcome::Failed(e.to_string()),
            }
        }
        StreamFamily::Update => {
            let cache = ReleaseCache::open(&paths.releases);
            let trust = TrustStore::load();
            match serve_update(&mut brain, stream_id, origin, 0, &cache, &trust) {
                Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
                Err(e) => DispatchOutcome::Failed(e.to_string()),
            }
        }
        StreamFamily::Notif => serve_notif_feed(&mut brain, stream_id, origin),
        StreamFamily::WanMsg => serve_wan_feed(&mut brain, stream_id, origin, paths),
        StreamFamily::Registry => serve_registry_feed(&mut brain, stream_id, origin, registry),
        StreamFamily::Attach => {
            // The gate subject is the session's OWNING endpoint, resolved
            // from the broker's table (the spawn's label) — never from wire
            // bytes. An unlabeled or unknown session refuses, fail-closed.
            let session_id = line.get("session_id").and_then(|v| v.as_u64());
            let endpoint = session_id.and_then(|sid| {
                brain.sessions().ok().and_then(|reply| {
                    reply
                        .sessions
                        .iter()
                        .find(|s| s.session_id == sid && !s.endpoint.is_empty())
                        .map(|s| s.endpoint.clone())
                })
            });
            match endpoint {
                Some(endpoint) => match serve_attach(&mut brain, stream_id, &endpoint, origin, 0) {
                    Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
                    Err(e) => DispatchOutcome::Failed(e.to_string()),
                },
                None => {
                    // Refuse politely: finish our send side, claim stays.
                    let _ = brain.net_stream_send(stream_id, &[], None, true);
                    DispatchOutcome::Served("attach refused: unlabeled/unknown session".into())
                }
            }
        }
        StreamFamily::Xfer => {
            // The wire names whose whitelist gates (the rule set); the origin
            // it is checked against stays handshake-proven. Blank refuses.
            let endpoint = line
                .get("endpoint")
                .and_then(|v| v.as_str())
                .filter(|e| !e.is_empty())
                .map(str::to_string);
            match endpoint {
                Some(endpoint) => {
                    let root = spt_store::xfer::transfers_dir();
                    let progress = paths.scratch.join("xfer-progress");
                    match serve_xfer(
                        &mut brain, stream_id, &endpoint, origin, 0, &root, &progress,
                    ) {
                        Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
                        Err(e) => DispatchOutcome::Failed(e.to_string()),
                    }
                }
                None => {
                    let _ = brain.net_stream_send(stream_id, &[], None, true);
                    DispatchOutcome::Served("xfer refused: no endpoint named".into())
                }
            }
        }
        StreamFamily::Rest => {
            // The wire names whose whitelist gates (the xfer posture); the
            // origin stays handshake-proven. The serve applies the event
            // through the LOCAL transition host — echo gate, wake effects,
            // and shell cascade run on this node, where the instance lives.
            // [impl->REQ-INST-6]
            let knob = crate::config::DaemonConfig::load().auto_suspend_after_ms;
            match crate::resthost::serve_rest(&mut brain, stream_id, origin, knob) {
                Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
                Err(e) => DispatchOutcome::Failed(e.to_string()),
            }
        }
        StreamFamily::ShellLink => {
            // The wire names the owner whose whitelist gates AND whose shell
            // tree resolves (owner exclusivity = resolution scope); the
            // action runs against the LOCAL owlery — perch, manifest, spool,
            // and binary all live here, the shell's node.
            // [impl->REQ-SHELL-2]
            match crate::linkhost::serve_shell_link(&mut brain, stream_id, origin, &paths.owlery) {
                Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
                Err(e) => DispatchOutcome::Failed(e.to_string()),
            }
        }
        StreamFamily::ServeProbe => {
            // No gate (serveprobe module docs): the answer is public
            // serve-state for a named subnet, read fresh from this node's
            // own stores — the QUIC handshake is the only subject.
            // [impl->REQ-SUBNET-5]
            match crate::serveprobe::serve_subnet_probe(&mut brain, stream_id) {
                Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
                Err(e) => DispatchOutcome::Failed(e.to_string()),
            }
        }
        StreamFamily::Unknown => DispatchOutcome::Unknown,
    }
}

/// Registry feed arm: decode `RegistryUpdate` lines, merge each batch into
/// the daemon's [`RegistryHost`] under the fail-closed gate (member subnet ∧
/// origin trusted in that subnet), until the peer finishes.
///
/// An admitted batch may carry **attention flips** (REQ-INST-3): a peer
/// instance of an id observed transitioning to `Active` — its node's daemon
/// just gave it a driver, so a *locally hosted* instance of the same id
/// rests warm (CONTEXT: driving `ling@laptop` makes `ling@desktop`
/// dormant). The rest edge's transition echo is gate-armed
/// ([`crate::resting::arm_transition_echo`]) — the endpoint's own pulse
/// loop runs the bounded summarizer call, never this worker (the KH 7.4
/// non-blocking posture: N flips in one feed are N gate files, not N LLM
/// calls serialized here).
// [impl->REQ-INST-7]
fn serve_registry_feed(
    brain: &mut Brain,
    stream_id: u64,
    origin: &str,
    registry: &RegistryHost,
) -> DispatchOutcome {
    let policy = RegistryGatePolicy::load();
    if let Err(e) = brain.net_stream_subscribe(stream_id, 0) {
        return DispatchOutcome::Failed(e.to_string());
    }
    let mut decoder = RegistryDecoder::new();
    let mut merged = 0usize;
    loop {
        match brain.read_event() {
            Ok(BrokerEvent::NetStreamData {
                stream_id: sid,
                bytes,
                ..
            }) if sid == stream_id => {
                let records = decoder.push(&bytes);
                if records.is_empty() {
                    continue;
                }
                // Split the feed into instance rows and node-level labels
                // (REQ-SUBNET-3) — each goes through its own gated apply path.
                let mut updates: Vec<RegistryUpdate> = Vec::new();
                let mut labels: Vec<NodeLabelUpdate> = Vec::new();
                for rec in records {
                    match rec {
                        RegistryFeedRecord::Instance(u) => updates.push(u),
                        RegistryFeedRecord::NodeLabel(n) => labels.push(n),
                    }
                }
                if !labels.is_empty() {
                    merged += registry.apply_node_labels(origin, &labels, &policy).len();
                }
                if updates.is_empty() {
                    continue;
                }
                let (verdicts, flips) = registry.apply_feed_flips(origin, &updates, &policy);
                merged += verdicts.len();
                // Attention shift (D9-2): only ids this node actually hosts
                // (a perch with a record); everything else is a no-op here.
                // [impl->REQ-INST-3]
                for id in flips {
                    let local = perch::resolve_perch_path(&id, spt_store::perch::ParentHint::Infer);
                    if local.join("info.json").exists() {
                        if let Err(e) = crate::resting::daemon_rest_event(
                            &id,
                            crate::resting::RestEvent::AttentionShift,
                            None,
                        ) {
                            eprintln!("REST_SHIFT_FAIL:{id}:{e}");
                        }
                    }
                }
            }
            Ok(BrokerEvent::NetStreamEof { stream_id: sid, .. }) if sid == stream_id => {
                return DispatchOutcome::Served(format!("registry feed: {merged} record(s)"));
            }
            Ok(BrokerEvent::Error { message }) => return DispatchOutcome::Failed(message),
            Ok(_) => {}
            Err(e) => return DispatchOutcome::Failed(e.to_string()),
        }
    }
}

/// Notif feed arm: decode rows as they arrive, apply each batch under the
/// fail-closed gate, until the peer finishes. The policy re-loads per feed
/// (not per record) — the same freshness the other arms get per request.
///
/// A **freshly inserted** undismissed row additionally gets a first-fire here
/// (REQ-PRES-1, the convergent cross-node redirect's receiving half): the
/// producing node skipped its local surface because the MRA winner lives
/// here — this node's own presence resolution surfaces it. A row this node's
/// MRA also calls remote stays unmarked and rides on (someone else's
/// feed-apply surfaces it); replays merge `Unchanged` and never re-fire.
// [impl->REQ-NOTIF-1]
// [impl->REQ-PRES-1]
fn serve_notif_feed(brain: &mut Brain, stream_id: u64, origin: &str) -> DispatchOutcome {
    let store = match NotifStore::open() {
        Ok(s) => s,
        Err(e) => return DispatchOutcome::Failed(e.to_string()),
    };
    let policy = NotifPolicy::load();
    // This serving node's identity for the presence resolution: the broker's
    // handshake identity (the same hex peers see in our registry rows).
    let self_hex = brain
        .net_status()
        .ok()
        .and_then(|s| s.node_id_hex)
        .unwrap_or_default();
    if let Err(e) = brain.net_stream_subscribe(stream_id, 0) {
        return DispatchOutcome::Failed(e.to_string());
    }
    let mut decoder = NotifDecoder::new();
    let mut applied = 0usize;
    loop {
        match brain.read_event() {
            Ok(BrokerEvent::NetStreamData {
                stream_id: sid,
                bytes,
                ..
            }) if sid == stream_id => {
                let records = decoder.push(&bytes);
                if records.is_empty() {
                    continue;
                }
                match apply_notif_feed(&store, origin, &records, &policy) {
                    Ok(verdicts) => {
                        applied += verdicts.len();
                        surface_fresh_rows(&store, &records, &verdicts, &self_hex);
                    }
                    Err(e) => return DispatchOutcome::Failed(e),
                }
            }
            Ok(BrokerEvent::NetStreamEof { stream_id: sid, .. }) if sid == stream_id => {
                return DispatchOutcome::Served(format!("notif feed: {applied} record(s)"));
            }
            Ok(BrokerEvent::Error { message }) => return DispatchOutcome::Failed(message),
            Ok(_) => {}
            Err(e) => return DispatchOutcome::Failed(e.to_string()),
        }
    }
}

/// The receiving half of the cross-node notif redirect (REQ-PRES-1): first-
/// fire every **freshly inserted** undismissed row from an applied feed
/// batch, under this node's own presence resolution. Best-effort and loud —
/// a failed surface costs a boundary-resurface delay, never the feed.
// [impl->REQ-PRES-1]
fn surface_fresh_rows(
    store: &NotifStore,
    records: &[spt_net::net::notif::NotifRecord],
    verdicts: &[crate::notifsync::NotifApplyVerdict],
    self_hex: &str,
) {
    use crate::notifsync::NotifApplyVerdict;
    use spt_store::notif::MergeOutcome;
    let fresh = records.iter().zip(verdicts).filter_map(|(rec, v)| {
        let spt_net::net::notif::NotifRecord::Row { row } = rec;
        (matches!(v, NotifApplyVerdict::Applied(MergeOutcome::Inserted)) && !row.dismissed)
            .then_some(row)
    });
    let policy = crate::notif::NotifSurfacePolicy::load();
    let regs = crate::presence::load_registry_snapshots(&crate::presence::registry_snapshot_dir());
    let owlery = perch::owlery_dir();
    for row in fresh {
        match crate::notif::first_fire_at(store, row, &policy, &owlery, self_hex, &regs, now_ms()) {
            Ok(_) => {}
            Err(e) => eprintln!("NOTIF_FEED_SURFACE_FAIL:{}: {e}", row.notif_id),
        }
    }
}

/// WAN message arm: decode `WanMessage` lines, funnel each through the
/// access-gate + op-id-dedup path, until the peer finishes.
// [impl->REQ-NET-1]
fn serve_wan_feed(
    brain: &mut Brain,
    stream_id: u64,
    origin: &str,
    paths: &DispatchPaths,
) -> DispatchOutcome {
    if let Err(e) = brain.net_stream_subscribe(stream_id, 0) {
        return DispatchOutcome::Failed(e.to_string());
    }
    let mut decoder = WanDecoder::new();
    let mut handled = 0usize;
    loop {
        match brain.read_event() {
            Ok(BrokerEvent::NetStreamData {
                stream_id: sid,
                bytes,
                ..
            }) if sid == stream_id => {
                for msg in decoder.push(&bytes) {
                    let _ = receive_wan(&msg, origin, &paths.owlery);
                    handled += 1;
                }
            }
            Ok(BrokerEvent::NetStreamEof { stream_id: sid, .. }) if sid == stream_id => {
                return DispatchOutcome::Served(format!("wan feed: {handled} record(s)"));
            }
            Ok(BrokerEvent::Error { message }) => return DispatchOutcome::Failed(message),
            Ok(_) => {}
            Err(e) => return DispatchOutcome::Failed(e.to_string()),
        }
    }
}

/// Spawn the dispatcher on its own thread against the canonical broker
/// socket — the daemon-boot entry ([`crate::daemon::Daemon::run`]).
pub fn spawn_dispatcher(
    broker_name: String,
    registry: Arc<RegistryHost>,
    stop: Arc<AtomicBool>,
) -> thread::JoinHandle<()> {
    thread::spawn(move || {
        let paths = DispatchPaths::production();
        if let Err(e) =
            run_dispatch_loop(&broker_name, &paths, registry, &stop, DEFAULT_DISPATCH_POLL)
        {
            eprintln!("DISPATCH_LOOP_FAIL: {e}");
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    // [unit->REQ-NET-1] the demux table: every family's first record
    // classifies correctly, including the `request` kind collision.
    #[test]
    fn classifies_every_family_by_first_line_shape() {
        let sync = json!({"kind": "request", "sync_id": "pull-1", "refs": [], "have_tips": {}});
        assert_eq!(classify_first_line(&sync), StreamFamily::Sync);

        let attach = json!({"kind": "request", "session_id": 3, "from_seq": 0});
        assert_eq!(classify_first_line(&attach), StreamFamily::Attach);

        // [unit->REQ-INST-6] the rest op's opener disambiguates by shape too.
        let rest =
            json!({"kind": "request", "endpoint": "ling", "event": "suspend", "op_id": "a:1"});
        assert_eq!(classify_first_line(&rest), StreamFamily::Rest);

        let update =
            json!({"kind": "query", "upd_id": "u1", "current_version": 5, "channel": "stable"});
        assert_eq!(classify_first_line(&update), StreamFamily::Update);

        let notif = json!({"kind": "row", "row": {}});
        assert_eq!(classify_first_line(&notif), StreamFamily::Notif);

        let fetch = json!({"kind": "fetch", "path": "a.txt", "xfer_id": "x1"});
        assert_eq!(classify_first_line(&fetch), StreamFamily::Xfer);
        let push = json!({"kind": "push", "path": "a.txt", "total": 4, "xfer_id": "x2"});
        assert_eq!(classify_first_line(&push), StreamFamily::Xfer);

        let wan = json!({"target": "ling", "from": "oak", "body": "hi", "op_id": "w1"});
        assert_eq!(classify_first_line(&wan), StreamFamily::WanMsg);

        let reg = json!({"subnet": "home", "endpoint_id": "ling",
            "instance": {"node": "n1", "status": "Active", "epoch": 4}});
        assert_eq!(classify_first_line(&reg), StreamFamily::Registry);
    }

    // [unit->REQ-NET-1] unclassifiable shapes are Unknown — dropped loud,
    // never routed to a gate-bearing serve loop on a guess.
    #[test]
    fn unknown_shapes_refuse_classification() {
        for v in [
            json!({"kind": "chunk", "sync_id": "s", "offset": 0, "data_b64": ""}), // mid-protocol, not an opener
            json!({"kind": "request"}), // request without any discriminant
            json!({"kind": "request", "endpoint": "ling"}), // rest-ish but no event
            json!({"kind": "nonsense"}),
            json!({"target": "ling"}), // wan-ish but no op_id
            json!({"subnet": "home"}), // registry-ish but no instance
            json!({"completely": "other"}),
            json!(42),
        ] {
            assert_eq!(classify_first_line(&v), StreamFamily::Unknown, "for {v}");
        }
    }
}