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=query` | update pull ([`crate::propagate::serve_update`]) |
32	//! | `kind=row` | notif feed ([`crate::notifsync::apply_notif_feed`]) |
33	//! | `kind=fetch` / `kind=push` | file transfer (D9-1-3) |
34	//! | untagged + `target`+`op_id` | WAN message feed ([`crate::wan::receive_wan`]) |
35	//! | untagged + `subnet`+`instance` | registry feed (D9-1-2) |
36	//! | anything else | unknown — dropped, loud |
37	//!
38	//! A family whose production wiring lands in a later slice is still
39	//! classified here (the table is complete) but routed to a typed
40	//! [`Unwired`](DispatchOutcome::Unwired) drop — loud, never silent.
41	//!
42	//! ## Worker model
43	//!
44	//! Each claimed stream gets its own worker thread with its **own** brain IPC
45	//! connection (the broker is multi-client by design; the probe-brain pattern
46	//! every E2E test uses). The peek and the serve use **separate** connections:
47	//! the peek brain subscribes from 0 to read the first line and is dropped;
48	//! the serve loop then subscribes from 0 on a fresh connection so it owns its
49	//! stream's event feed alone (no double-subscription interleave). Origin is
50	//! the table's `remote_id_hex` — handshake-proven, never payload
51	//! (REQ-HAZARD-WAN-ORIGIN-AUTH); every family's own gate then runs unchanged.
52	// [impl->REQ-NET-1]
53	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
54
55	use std::collections::HashSet;
56	use std::io;
57	use std::path::PathBuf;
58	use std::sync::atomic::{AtomicBool, Ordering};
59	use std::sync::Arc;
60	use std::thread;
61	use std::time::Duration;
62
63	use spt_net::net::notif::NotifDecoder;
64	use spt_net::net::replicate::LineDecoder as RegistryDecoder;
65	use spt_net::net::wanmsg::WanDecoder;
66	use spt_store::contextstore::ContextStore;
67	use spt_store::notif::NotifStore;
68	use spt_store::perch;
69	use spt_store::trust::TrustStore;
70
71	use crate::attach::serve_attach;
72	use crate::brain::{Brain, BrokerEvent};
73	use crate::notifsync::{apply_notif_feed, NotifPolicy};
74	use crate::propagate::serve_update;
75	use crate::registryhost::{RegistryGatePolicy, RegistryHost};
76	use crate::relcache::ReleaseCache;
77	use crate::sync::{serve_sync, SyncPolicy};
78	use crate::wan::receive_wan;
79	use crate::xfer::serve_xfer;
80
81	/// Poll cadence for the stream-table watch (the dispatcher's only busy-wait;
82	/// workers block on their stream's event feed).
83	pub const DEFAULT_DISPATCH_POLL: Duration = Duration::from_millis(100);
84
85	/// What protocol a peer-initiated stream speaks, by its first record's shape.
86	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87	pub enum StreamFamily {
88	    /// A context sync pull (`SyncRecord::Request`).
89	    Sync,
90	    /// An update pull (`UpdRecord::Query`).
91	    Update,
92	    /// A notif replication feed (`NotifRecord::Row`).
93	    Notif,
94	    /// A WAN message feed (untagged `WanMessage` lines).
95	    WanMsg,
96	    /// A registry replication feed (untagged `RegistryUpdate` lines).
97	    Registry,
98	    /// A remote-drive attach (`AttachRecord::Request`).
99	    Attach,
100	    /// A file transfer (`XferRecord::Fetch`/`Push`).
101	    Xfer,
102	    /// Unclassifiable — dropped.
103	    Unknown,
104	}
105
106	/// Classify one decoded first line by shape (module-docs demux table).
107	pub fn classify_first_line(line: &serde_json::Value) -> StreamFamily {
108	    match line.get("kind").and_then(|k| k.as_str()) {
109	        Some("request") if line.get("sync_id").is_some() => StreamFamily::Sync,
110	        Some("request") if line.get("session_id").is_some() => StreamFamily::Attach,
111	        Some("query") => StreamFamily::Update,
112	        Some("row") => StreamFamily::Notif,
113	        Some("fetch") | Some("push") => StreamFamily::Xfer,
114	        Some(_) => StreamFamily::Unknown,
115	        None => {
116	            if line.get("target").is_some() && line.get("op_id").is_some() {
117	                StreamFamily::WanMsg
118	            } else if line.get("subnet").is_some() && line.get("instance").is_some() {
119	                StreamFamily::Registry
120	            } else {
121	                StreamFamily::Unknown
122	            }
123	        }
124	    }
125	}
126
127	/// Why a dispatched stream's worker ended — loud + typed, never silent.
128	#[derive(Debug)]
129	pub enum DispatchOutcome {
130	    /// The family's serve loop ran to its own outcome (rendered for the log).
131	    Served(String),
132	    /// The first line never arrived / didn't parse — dropped.
133	    Unknown,
134	    /// The worker failed (IO against broker or store).
135	    Failed(String),
136	}
137
138	/// The SPT_HOME-anchored paths the serve arms need. One value, cloned per
139	/// worker (policies/stores re-load from the canonical paths per request —
140	/// the access-gate stance, config edits take effect without a restart).
141	#[derive(Debug, Clone)]
142	pub struct DispatchPaths {
143	    /// The perch tree (WAN message funnel target).
144	    pub owlery: PathBuf,
145	    /// The staged-release cache dir ([`ReleaseCache::open`]).
146	    pub releases: PathBuf,
147	    /// Transient bundle/artifact scratch.
148	    pub scratch: PathBuf,
149	}
150
151	impl DispatchPaths {
152	    /// The production layout under [`perch::spt_home`].
153	    pub fn production() -> DispatchPaths {
154	        let home = perch::spt_home();
155	        DispatchPaths {
156	            owlery: perch::owlery_dir(),
157	            releases: home.join("releases"),
158	            scratch: home.join("scratch").join("net"),
159	        }
160	    }
161	}
162
163	/// Watch the broker's stream table and dispatch every **peer-initiated**
164	/// stream to a family worker, until `stop` is set. Blocks the calling thread
165	/// (the daemon spawns it on its own); workers detach (each ends with its
166	/// stream).
167	pub fn run_dispatch_loop(
168	    broker_name: &str,
169	    paths: &DispatchPaths,
170	    registry: Arc<RegistryHost>,
171	    stop: &AtomicBool,
172	    poll: Duration,
173	) -> io::Result<()> {
174	    let mut brain = connect(broker_name)?;
175	    let mut claimed: HashSet<u64> = HashSet::new();
176
177	    while !stop.load(Ordering::Relaxed) {
178	        let reply = brain.net_streams()?;
179	        for s in reply.streams {
180	            // Claim only peer-initiated rows with bytes (a QUIC stream is
181	            // invisible until its first write anyway — gotcha #6 — but the
182	            // next_seq guard also skips a row whose pump hasn't appended yet).
183	            if s.initiated_locally || s.next_seq == 0 || !claimed.insert(s.stream_id) {
184	                continue;
185	            }
186	            let name = broker_name.to_string();
187	            let origin = s.remote_id_hex.clone();
188	            let stream_id = s.stream_id;
189	            let p = paths.clone();
190	            let reg = Arc::clone(&registry);
191	            thread::spawn(move || {
192	                let outcome = worker(&name, stream_id, &origin, &p, &reg);
193	                match outcome {
194	                    DispatchOutcome::Served(_) => {}
195	                    other => eprintln!("DISPATCH:{stream_id}:{other:?}"),
196	                }
197	            });
198	        }
199	        thread::sleep(poll);
200	    }
201	    Ok(())
202	}
203
204	/// Connect a fresh brain IPC client (retrying through broker startup).
205	fn connect(broker_name: &str) -> io::Result<Brain> {
206	    let mut last = io::Error::other("broker never answered");
207	    for _ in 0..300 {
208	        match Brain::cold_start(broker_name, now_ms()) {
209	            Ok(b) => return Ok(b),
210	            Err(e) => last = e,
211	        }
212	        thread::sleep(Duration::from_millis(10));
213	    }
214	    Err(last)
215	}
216
217	fn now_ms() -> u64 {
218	    std::time::SystemTime::now()
219	        .duration_since(std::time::UNIX_EPOCH)
220	        .map(|d| d.as_millis() as u64)
221	        .unwrap_or(0)
222	}
223
224	/// Peek the stream's first NDJSON line on a throwaway brain connection.
225	fn peek_first_line(broker_name: &str, stream_id: u64) -> io::Result<Option<serde_json::Value>> {
226	    let mut brain = connect(broker_name)?;
227	    brain.net_stream_subscribe(stream_id, 0)?;
228	    let mut buf: Vec<u8> = Vec::new();
229	    loop {
230	        match brain.read_event()? {
231	            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
232	                buf.extend_from_slice(&bytes);
233	                if let Some(nl) = buf.iter().position(|&b| b == b'\n') {
234	                    return Ok(serde_json::from_slice(&buf[..nl]).ok());
235	                }
236	            }
237	            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
238	                // Finished without a complete line: classify what we have
239	                // (a line without a trailing newline before EOF still counts).
240	                return Ok(serde_json::from_slice(&buf).ok());
241	            }
242	            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
243	            _ => {}
244	        }
245	    }
246	}
247
248	/// One claimed stream's worker: peek → classify → run the family's serve
249	/// loop on a fresh brain connection.
250	fn worker(
251	    broker_name: &str,
252	    stream_id: u64,
253	    origin: &str,
254	    paths: &DispatchPaths,
255	    registry: &RegistryHost,
256	) -> DispatchOutcome {
257	    let line = match peek_first_line(broker_name, stream_id) {
258	        Ok(Some(v)) => v,
259	        Ok(None) => return DispatchOutcome::Unknown,
260	        Err(e) => return DispatchOutcome::Failed(e.to_string()),
261	    };
262	    let family = classify_first_line(&line);
263
264	    let mut brain = match connect(broker_name) {
265	        Ok(b) => b,
266	        Err(e) => return DispatchOutcome::Failed(e.to_string()),
267	    };
268
269	    match family {
270	        StreamFamily::Sync => {
271	            let cs = match ContextStore::open_or_init() {
272	                Ok(cs) => cs,
273	                Err(e) => return DispatchOutcome::Failed(e.to_string()),
274	            };
275	            let policy = SyncPolicy::load();
276	            match serve_sync(&mut brain, stream_id, origin, 0, &cs, &policy, &paths.scratch) {
277	                Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
278	                Err(e) => DispatchOutcome::Failed(e.to_string()),
279	            }
280	        }
281	        StreamFamily::Update => {
282	            let cache = ReleaseCache::open(&paths.releases);
283	            let trust = TrustStore::load();
284	            match serve_update(&mut brain, stream_id, origin, 0, &cache, &trust) {
285	                Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
286	                Err(e) => DispatchOutcome::Failed(e.to_string()),
287	            }
288	        }
289	        StreamFamily::Notif => serve_notif_feed(&mut brain, stream_id, origin),
290	        StreamFamily::WanMsg => serve_wan_feed(&mut brain, stream_id, origin, paths),
291	        StreamFamily::Registry => serve_registry_feed(&mut brain, stream_id, origin, registry),
292	        StreamFamily::Attach => {
293	            // The gate subject is the session's OWNING endpoint, resolved
294	            // from the broker's table (the spawn's label) — never from wire
295	            // bytes. An unlabeled or unknown session refuses, fail-closed.
296	            let session_id = line.get("session_id").and_then(|v| v.as_u64());
297	            let endpoint = session_id.and_then(|sid| {
298	                brain.sessions().ok().and_then(|reply| {
299	                    reply
300	                        .sessions
301	                        .iter()
302	                        .find(|s| s.session_id == sid && !s.endpoint.is_empty())
303	                        .map(|s| s.endpoint.clone())
304	                })
305	            });
306	            match endpoint {
307	                Some(endpoint) => {
308	                    match serve_attach(&mut brain, stream_id, &endpoint, origin, 0) {
309	                        Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
310	                        Err(e) => DispatchOutcome::Failed(e.to_string()),
311	                    }
312	                }
313	                None => {
314	                    // Refuse politely: finish our send side, claim stays.
315	                    let _ = brain.net_stream_send(stream_id, &[], None, true);
316	                    DispatchOutcome::Served("attach refused: unlabeled/unknown session".into())
317	                }
318	            }
319	        }
320	        StreamFamily::Xfer => {
321	            // The wire names whose whitelist gates (the rule set); the origin
322	            // it is checked against stays handshake-proven. Blank refuses.
323	            let endpoint = line
324	                .get("endpoint")
325	                .and_then(|v| v.as_str())
326	                .filter(|e| !e.is_empty())
327	                .map(str::to_string);
328	            match endpoint {
329	                Some(endpoint) => {
330	                    let root = spt_store::xfer::transfers_dir();
331	                    let progress = paths.scratch.join("xfer-progress");
332	                    match serve_xfer(&mut brain, stream_id, &endpoint, origin, 0, &root, &progress)
333	                    {
334	                        Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
335	                        Err(e) => DispatchOutcome::Failed(e.to_string()),
336	                    }
337	                }
338	                None => {
339	                    let _ = brain.net_stream_send(stream_id, &[], None, true);
340	                    DispatchOutcome::Served("xfer refused: no endpoint named".into())
341	                }
342	            }
343	        }
344	        StreamFamily::Unknown => DispatchOutcome::Unknown,
345	    }
346	}
347
348	/// Registry feed arm: decode `RegistryUpdate` lines, merge each batch into
349	/// the daemon's [`RegistryHost`] under the fail-closed gate (member subnet ∧
350	/// origin trusted in that subnet), until the peer finishes.
351	// [impl->REQ-INST-7]
352	fn serve_registry_feed(
353	    brain: &mut Brain,
354	    stream_id: u64,
355	    origin: &str,
356	    registry: &RegistryHost,
357	) -> DispatchOutcome {
358	    let policy = RegistryGatePolicy::load();
359	    if let Err(e) = brain.net_stream_subscribe(stream_id, 0) {
360	        return DispatchOutcome::Failed(e.to_string());
361	    }
362	    let mut decoder = RegistryDecoder::new();
363	    let mut merged = 0usize;
364	    loop {
365	        match brain.read_event() {
366	            Ok(BrokerEvent::NetStreamData { stream_id: sid, bytes, .. }) if sid == stream_id => {
367	                let updates = decoder.push(&bytes);
368	                if updates.is_empty() {
369	                    continue;
370	                }
371	                merged += registry.apply_feed(origin, &updates, &policy).len();
372	            }
373	            Ok(BrokerEvent::NetStreamEof { stream_id: sid, .. }) if sid == stream_id => {
374	                return DispatchOutcome::Served(format!("registry feed: {merged} record(s)"));
375	            }
376	            Ok(BrokerEvent::Error { message }) => return DispatchOutcome::Failed(message),
377	            Ok(_) => {}
378	            Err(e) => return DispatchOutcome::Failed(e.to_string()),
379	        }
380	    }
381	}
382
383	/// Notif feed arm: decode rows as they arrive, apply each batch under the
384	/// fail-closed gate, until the peer finishes. The policy re-loads per feed
385	/// (not per record) — the same freshness the other arms get per request.
386	// [impl->REQ-NOTIF-1]
387	fn serve_notif_feed(brain: &mut Brain, stream_id: u64, origin: &str) -> DispatchOutcome {
388	    let store = match NotifStore::open() {
389	        Ok(s) => s,
390	        Err(e) => return DispatchOutcome::Failed(e.to_string()),
391	    };
392	    let policy = NotifPolicy::load();
393	    if let Err(e) = brain.net_stream_subscribe(stream_id, 0) {
394	        return DispatchOutcome::Failed(e.to_string());
395	    }
396	    let mut decoder = NotifDecoder::new();
397	    let mut applied = 0usize;
398	    loop {
399	        match brain.read_event() {
400	            Ok(BrokerEvent::NetStreamData { stream_id: sid, bytes, .. }) if sid == stream_id => {
401	                let records = decoder.push(&bytes);
402	                if records.is_empty() {
403	                    continue;
404	                }
405	                match apply_notif_feed(&store, origin, &records, &policy) {
406	                    Ok(verdicts) => applied += verdicts.len(),
407	                    Err(e) => return DispatchOutcome::Failed(e),
408	                }
409	            }
410	            Ok(BrokerEvent::NetStreamEof { stream_id: sid, .. }) if sid == stream_id => {
411	                return DispatchOutcome::Served(format!("notif feed: {applied} record(s)"));
412	            }
413	            Ok(BrokerEvent::Error { message }) => return DispatchOutcome::Failed(message),
414	            Ok(_) => {}
415	            Err(e) => return DispatchOutcome::Failed(e.to_string()),
416	        }
417	    }
418	}
419
420	/// WAN message arm: decode `WanMessage` lines, funnel each through the
421	/// access-gate + op-id-dedup path, until the peer finishes.
422	// [impl->REQ-NET-1]
423	fn serve_wan_feed(
424	    brain: &mut Brain,
425	    stream_id: u64,
426	    origin: &str,
427	    paths: &DispatchPaths,
428	) -> DispatchOutcome {
429	    if let Err(e) = brain.net_stream_subscribe(stream_id, 0) {
430	        return DispatchOutcome::Failed(e.to_string());
431	    }
432	    let mut decoder = WanDecoder::new();
433	    let mut handled = 0usize;
434	    loop {
435	        match brain.read_event() {
436	            Ok(BrokerEvent::NetStreamData { stream_id: sid, bytes, .. }) if sid == stream_id => {
437	                for msg in decoder.push(&bytes) {
438	                    let _ = receive_wan(&msg, origin, &paths.owlery);
439	                    handled += 1;
440	                }
441	            }
442	            Ok(BrokerEvent::NetStreamEof { stream_id: sid, .. }) if sid == stream_id => {
443	                return DispatchOutcome::Served(format!("wan feed: {handled} record(s)"));
444	            }
445	            Ok(BrokerEvent::Error { message }) => return DispatchOutcome::Failed(message),
446	            Ok(_) => {}
447	            Err(e) => return DispatchOutcome::Failed(e.to_string()),
448	        }
449	    }
450	}
451
452	/// Spawn the dispatcher on its own thread against the canonical broker
453	/// socket — the daemon-boot entry ([`crate::daemon::Daemon::run`]).
454	pub fn spawn_dispatcher(
455	    broker_name: String,
456	    registry: Arc<RegistryHost>,
457	    stop: Arc<AtomicBool>,
458	) -> thread::JoinHandle<()> {
459	    thread::spawn(move || {
460	        let paths = DispatchPaths::production();
461	        if let Err(e) =
462	            run_dispatch_loop(&broker_name, &paths, registry, &stop, DEFAULT_DISPATCH_POLL)
463	        {
464	            eprintln!("DISPATCH_LOOP_FAIL: {e}");
465	        }
466	    })
467	}
468
469	#[cfg(test)]
470	mod tests {
471	    use super::*;
472	    use serde_json::json;
473
474	    // [unit->REQ-NET-1] the demux table: every family's first record
475	    // classifies correctly, including the `request` kind collision.
476	    #[test]
477	    fn classifies_every_family_by_first_line_shape() {
478	        let sync = json!({"kind": "request", "sync_id": "pull-1", "refs": [], "have_tips": {}});
479	        assert_eq!(classify_first_line(&sync), StreamFamily::Sync);
480
481	        let attach = json!({"kind": "request", "session_id": 3, "from_seq": 0});
482	        assert_eq!(classify_first_line(&attach), StreamFamily::Attach);
483
484	        let update = json!({"kind": "query", "upd_id": "u1", "current_version": 5, "channel": "stable"});
485	        assert_eq!(classify_first_line(&update), StreamFamily::Update);
486
487	        let notif = json!({"kind": "row", "row": {}});
488	        assert_eq!(classify_first_line(&notif), StreamFamily::Notif);
489
490	        let fetch = json!({"kind": "fetch", "path": "a.txt", "xfer_id": "x1"});
491	        assert_eq!(classify_first_line(&fetch), StreamFamily::Xfer);
492	        let push = json!({"kind": "push", "path": "a.txt", "total": 4, "xfer_id": "x2"});
493	        assert_eq!(classify_first_line(&push), StreamFamily::Xfer);
494
495	        let wan = json!({"target": "ling", "from": "oak", "body": "hi", "op_id": "w1"});
496	        assert_eq!(classify_first_line(&wan), StreamFamily::WanMsg);
497
498	        let reg = json!({"subnet": "home", "endpoint_id": "ling",
499	            "instance": {"node": "n1", "status": "Active", "epoch": 4}});
500	        assert_eq!(classify_first_line(&reg), StreamFamily::Registry);
501	    }
502
503	    // [unit->REQ-NET-1] unclassifiable shapes are Unknown — dropped loud,
504	    // never routed to a gate-bearing serve loop on a guess.
505	    #[test]
506	    fn unknown_shapes_refuse_classification() {
507	        for v in [
508	            json!({"kind": "chunk", "sync_id": "s", "offset": 0, "data_b64": ""}), // mid-protocol, not an opener
509	            json!({"kind": "request"}), // request without either discriminant
510	            json!({"kind": "nonsense"}),
511	            json!({"target": "ling"}),       // wan-ish but no op_id
512	            json!({"subnet": "home"}),       // registry-ish but no instance
513	            json!({"completely": "other"}),
514	            json!(42),
515	        ] {
516	            assert_eq!(classify_first_line(&v), StreamFamily::Unknown, "for {v}");
517	        }
518	    }
519	}
520

//! 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=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::LineDecoder as RegistryDecoder;
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,
    /// 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("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 {
                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::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.
// [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 updates = decoder.push(&bytes);
                if updates.is_empty() {
                    continue;
                }
                merged += registry.apply_feed(origin, &updates, &policy).len();
            }
            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.
// [impl->REQ-NOTIF-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();
    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(),
                    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()),
        }
    }
}

/// 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);

        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 either discriminant
            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}");
        }
    }
}


Need manifest-by-id resolution for echo at daemon feed points:


crates\spt\src\api\mod.rs:225:        Some(path) => match load_manifest(path, &args.adapter) {
crates\spt\src\api\mod.rs:284:fn load_manifest(path: &PathBuf, adapter: &str) -> Result<Manifest, i32> {


180	/// Auth proof flags shared by every mutating subcommand.
181	#[derive(Args)]
182	pub struct AuthFlags {
183	    /// Capability token proving association to the target perch.
184	    #[arg(long)]
185	    token: Option<String>,
186	    /// Session id proving association (matches the perch's info.json).
187	    #[arg(long)]
188	    session_id: Option<String>,
189	}
190
191	impl AuthFlags {
192	    fn proof(&self) -> Proof {
193	        Proof::resolve(self.token.clone(), self.session_id.clone())
194	    }
195	}
196
197	/// Resolved per-call context shared by handlers.
198	pub struct Ctx {
199	    /// The `adapter_name` carried on this call.
200	    pub adapter: String,
201	    /// Parsed manifest, when `--manifest` was supplied (and valid).
202	    pub manifest: Option<Manifest>,
203	}
204
205	/// Auto-start the per-machine daemon if absent (REQ-DAEMON-3). Best-effort: a
206	/// start failure is surfaced but does not abort commands that don't need the
207	/// daemon (e.g. `capability`); the seed/listen path re-checks and fails loudly if
208	/// the daemon is genuinely unreachable.
209	// [impl->REQ-DAEMON-3]
210	fn ensure_daemon() {
211	    if let Err(e) = spt_daemon::ensure_running() {
212	        eprintln!("DAEMON_START_WARN: {e}");
213	    }
214	}
215
216	/// Parse + dispatch the `api` group. Returns the process exit code.
217	pub fn run(args: ApiArgs) -> i32 {
218	    // Any api invocation brings the daemon up if absent — the reliable anchor
219	    // every harness hook shares (REQ-DAEMON-3).
220	    ensure_daemon();
221
222	    // Resolve the manifest up front so a bad path/content fails fast and
223	    // uniformly, before any state mutation.
224	    let manifest = match &args.manifest {
225	        Some(path) => match load_manifest(path, &args.adapter) {
226	            Ok(m) => Some(m),
227	            Err(code) => return code,
228	        },
229	        None => None,
230	    };
231	    let ctx = Ctx { adapter: args.adapter, manifest };
232
233	    match args.cmd {
234	        ApiCmd::Capability => reporting::cmd_capability(&ctx),
235	        // Harness-hosted startup (T4). Seed establishes; listen consumes+binds;
236	        // bind is the post-spawn establishing call (intrinsic auth).
237	        ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),
238	        ApiCmd::Listen { id, parent_pid, once } => {
239	            startup::cmd_listen(&id, parent_pid, once, ctx.manifest.as_ref())
240	        }
241	        ApiCmd::Bind { id, bind_session, auth } => {
242	            startup::cmd_bind(&id, bind_session, auth.session_id)
243	        }
244	        // Delivery + activity (T5), gated by auth.
245	        ApiCmd::State { state, id, no_gate, auth } => {
246	            gated(&id, &auth, |id| delivery::cmd_state(id, &state, no_gate))
247	        }
248	        ApiCmd::EchoGate { action, id, auth } => {
249	            gated(&id, &auth, |id| delivery::cmd_echo_gate(id, &action))
250	        }
251	        ApiCmd::Poll { id, include_deferred, auth } => gated(&id, &auth, |id| {
252	            delivery::cmd_poll(id, include_deferred, ctx.manifest.as_ref())
253	        }),
254	        // Worker perches (T6). worker-start authenticates against the parent.
255	        ApiCmd::WorkerStart { parent, id, auth } => {
256	            gated(&parent, &auth, |parent| worker::cmd_worker_start(parent, &id))
257	        }
258	        ApiCmd::WorkerStop { id, auth } => gated(&id, &auth, worker::cmd_worker_stop),
259	        ApiCmd::WorkerPoll { id, auth } => {
260	            gated(&id, &auth, |id| worker::cmd_worker_poll(id, false))
261	        }
262	        // Reporting + boundary (T7), gated by auth.
263	        ApiCmd::Boundary { mode, id, to_session, auth } => {
264	            gated(&id, &auth, |id| reporting::cmd_boundary(id, &mode, &to_session))
265	        }
266	        ApiCmd::SessionEnd { id, erase, auth } => {
267	            gated(&id, &auth, |id| reporting::cmd_session_end(id, erase))
268	        }
269	        ApiCmd::Presence { id, auth } => gated(&id, &auth, reporting::cmd_presence),
270	        ApiCmd::DrivenBy { id, auth } => gated(&id, &auth, reporting::cmd_driven_by),
271	        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),
272	        ApiCmd::Shutdown { id, auth } => {
273	            let manifest = ctx.manifest.as_ref();
274	            gated(&id, &auth, |id| live::cmd_shutdown(id, manifest))
275	        }
276	        ApiCmd::Emit { id, r#type, auth } => {
277	            gated(&id, &auth, |id| reporting::cmd_emit(id, &r#type))
278	        }
279	    }
280	}
281
282	/// Load + validate a manifest, checking it matches the declared adapter_name.
283	/// Returns an exit code on failure (already reported to stderr).
284	fn load_manifest(path: &PathBuf, adapter: &str) -> Result<Manifest, i32> {
285	    let text = std::fs::read_to_string(path).map_err(|e| {
286	        eprintln!("MANIFEST_READ:{}: {e}", path.display());
287	        EXIT_REFUSED
288	    })?;
289	    let manifest = Manifest::from_toml_str(&text).map_err(|e| {
290	        eprintln!("MANIFEST_INVALID:{e}");
291	        EXIT_REFUSED
292	    })?;
293	    if manifest.adapter.name != adapter {
294	        eprintln!(
295	            "ADAPTER_MISMATCH: --adapter {adapter} but manifest declares {}",
296	            manifest.adapter.name
297	        );
298	        return Err(EXIT_REFUSED);
299	    }
300	    Ok(manifest)
301	}
302
303	/// Run `f` only if the proof authenticates against `target_id`; otherwise print
304	/// the refusal and return [`EXIT_REFUSED`].
305	// [impl->REQ-HAZARD-LOCAL-API-AUTH]
306	fn gated<F: FnOnce(&str) -> i32>(target_id: &str, auth: &AuthFlags, f: F) -> i32 {
307	    match authenticate(target_id, &auth.proof()) {
308	        AuthResult::Ok => f(target_id),
309	        AuthResult::NoEndpoint => {

/// Auth proof flags shared by every mutating subcommand.
#[derive(Args)]
pub struct AuthFlags {
    /// Capability token proving association to the target perch.
    #[arg(long)]
    token: Option<String>,
    /// Session id proving association (matches the perch's info.json).
    #[arg(long)]
    session_id: Option<String>,
}

impl AuthFlags {
    fn proof(&self) -> Proof {
        Proof::resolve(self.token.clone(), self.session_id.clone())
    }
}

/// Resolved per-call context shared by handlers.
pub struct Ctx {
    /// The `adapter_name` carried on this call.
    pub adapter: String,
    /// Parsed manifest, when `--manifest` was supplied (and valid).
    pub manifest: Option<Manifest>,
}

/// Auto-start the per-machine daemon if absent (REQ-DAEMON-3). Best-effort: a
/// start failure is surfaced but does not abort commands that don't need the
/// daemon (e.g. `capability`); the seed/listen path re-checks and fails loudly if
/// the daemon is genuinely unreachable.
// [impl->REQ-DAEMON-3]
fn ensure_daemon() {
    if let Err(e) = spt_daemon::ensure_running() {
        eprintln!("DAEMON_START_WARN: {e}");
    }
}

/// Parse + dispatch the `api` group. Returns the process exit code.
pub fn run(args: ApiArgs) -> i32 {
    // Any api invocation brings the daemon up if absent — the reliable anchor
    // every harness hook shares (REQ-DAEMON-3).
    ensure_daemon();

    // Resolve the manifest up front so a bad path/content fails fast and
    // uniformly, before any state mutation.
    let manifest = match &args.manifest {
        Some(path) => match load_manifest(path, &args.adapter) {
            Ok(m) => Some(m),
            Err(code) => return code,
        },
        None => None,
    };
    let ctx = Ctx { adapter: args.adapter, manifest };

    match args.cmd {
        ApiCmd::Capability => reporting::cmd_capability(&ctx),
        // Harness-hosted startup (T4). Seed establishes; listen consumes+binds;
        // bind is the post-spawn establishing call (intrinsic auth).
        ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),
        ApiCmd::Listen { id, parent_pid, once } => {
            startup::cmd_listen(&id, parent_pid, once, ctx.manifest.as_ref())
        }
        ApiCmd::Bind { id, bind_session, auth } => {
            startup::cmd_bind(&id, bind_session, auth.session_id)
        }
        // Delivery + activity (T5), gated by auth.
        ApiCmd::State { state, id, no_gate, auth } => {
            gated(&id, &auth, |id| delivery::cmd_state(id, &state, no_gate))
        }
        ApiCmd::EchoGate { action, id, auth } => {
            gated(&id, &auth, |id| delivery::cmd_echo_gate(id, &action))
        }
        ApiCmd::Poll { id, include_deferred, auth } => gated(&id, &auth, |id| {
            delivery::cmd_poll(id, include_deferred, ctx.manifest.as_ref())
        }),
        // Worker perches (T6). worker-start authenticates against the parent.
        ApiCmd::WorkerStart { parent, id, auth } => {
            gated(&parent, &auth, |parent| worker::cmd_worker_start(parent, &id))
        }
        ApiCmd::WorkerStop { id, auth } => gated(&id, &auth, worker::cmd_worker_stop),
        ApiCmd::WorkerPoll { id, auth } => {
            gated(&id, &auth, |id| worker::cmd_worker_poll(id, false))
        }
        // Reporting + boundary (T7), gated by auth.
        ApiCmd::Boundary { mode, id, to_session, auth } => {
            gated(&id, &auth, |id| reporting::cmd_boundary(id, &mode, &to_session))
        }
        ApiCmd::SessionEnd { id, erase, auth } => {
            gated(&id, &auth, |id| reporting::cmd_session_end(id, erase))
        }
        ApiCmd::Presence { id, auth } => gated(&id, &auth, reporting::cmd_presence),
        ApiCmd::DrivenBy { id, auth } => gated(&id, &auth, reporting::cmd_driven_by),
        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),
        ApiCmd::Shutdown { id, auth } => {
            let manifest = ctx.manifest.as_ref();
            gated(&id, &auth, |id| live::cmd_shutdown(id, manifest))
        }
        ApiCmd::Emit { id, r#type, auth } => {
            gated(&id, &auth, |id| reporting::cmd_emit(id, &r#type))
        }
    }
}

/// Load + validate a manifest, checking it matches the declared adapter_name.
/// Returns an exit code on failure (already reported to stderr).
fn load_manifest(path: &PathBuf, adapter: &str) -> Result<Manifest, i32> {
    let text = std::fs::read_to_string(path).map_err(|e| {
        eprintln!("MANIFEST_READ:{}: {e}", path.display());
        EXIT_REFUSED
    })?;
    let manifest = Manifest::from_toml_str(&text).map_err(|e| {
        eprintln!("MANIFEST_INVALID:{e}");
        EXIT_REFUSED
    })?;
    if manifest.adapter.name != adapter {
        eprintln!(
            "ADAPTER_MISMATCH: --adapter {adapter} but manifest declares {}",
            manifest.adapter.name
        );
        return Err(EXIT_REFUSED);
    }
    Ok(manifest)
}

/// Run `f` only if the proof authenticates against `target_id`; otherwise print
/// the refusal and return [`EXIT_REFUSED`].
// [impl->REQ-HAZARD-LOCAL-API-AUTH]
fn gated<F: FnOnce(&str) -> i32>(target_id: &str, auth: &AuthFlags, f: F) -> i32 {
    match authenticate(target_id, &auth.proof()) {
        AuthResult::Ok => f(target_id),
        AuthResult::NoEndpoint => {


D9-2-1: resting state machine + Status::Suspended + advertise-follows-state

Pure RestState/RestEvent transition core in spt-daemon/resting.rs; info.json rest_state + dormant_since_ms + auto_suspend_after_ms; DaemonConfig auto_suspend_after_ms knob; registry Status::Suspended (additive serde, routable); advertise_local maps rest_state → Status. Activate REQ-INST-3 [impl, unit]. CI green.

D9-2-2: transition effects — echo edge + wake resurface + freshness pull

Transition host applies events, persists state, fires effects: run_echo_commune on active→(dormant|suspended) edge (KH 3.3, REQ-INST-4 [impl, unit]); wake edge fires resurface_at_boundary + writes freshness-pull marker the peer pump consumes (forces due_sync). KH 7.4: if >1 agent's echo scheduled by daemon, activate REQ-HAZARD-DAEMON-SCHED-NONBLOCKING + off-scheduler echo. CI green.

D9-2-3: production feed points + spt suspend/wake CLI + INST-6 seam doc

Attach arm feeds Wake on Request / Detach on viewport end; registry inbound arm feeds AttentionShift on peer row transition-to-Active for locally hosted id; spt suspend/spt wake local CLI arms; REQ-INST-6 deferred-message gate seam documented (stays inactive). CI green; plan status updated.


Manifests are flag-passed only — daemon feed points can't run the echo directly. Check echo-gate arm API (cross-process echo trigger):


1	//! pulse heartbeat (M2b T5) — the interim per-tick driver.
2	//!
3	//! In the no-daemon interim model, the long-running `api listen` process
4	//! (T8) hosts a pulse loop. Each tick does the lightweight, recurring work:
5	//! - **poll the drop dirs** → fold any commune/signoff drops into context
6	//!   (single-writer ingest→delete — T4);
7	//! - **fire the pending echo** → if the echo-gate (`.more-done`, armed by
8	//!   `api state idle`) is set, signal that an echo is due and **clear the gate**
9	//!   so it fires *exactly once*;
10	//! - surface the **idle** flag for the caller's capsule/idle decisions.
11	//!
12	//! The pulse is deliberately thin — it decides *when* an echo is due and
13	//! guarantees once-only; the heavy echo run ([`crate::echo::run_echo_commune`])
14	//! is the caller's (T8), which holds the runtime + history + commune dir. Keep
15	//! all of this behind this one module so M3's daemon re-host (Psyche-as-loop,
16	//! daemon-owned period) is a localized swap, not a rewrite (mirrors M2a's
17	//! seed-file discipline).
18	//
19	// Interim only: the durable pulse loop + its global/per-endpoint period config
20	// belong to the M3 daemon (CONTEXT R-DAEMON-1). No dedicated REQ — the pulse is
21	// throwaway glue under REQ-SEAM-PSYCHE (see M2b-PLAN §New requirements).
22
23	use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
24	use crate::ingest::{ingest_drops, Ingested};
25	use spt_store::perch::{resolve_echo_gate_file, resolve_idle_file, ParentHint};
26	use std::path::Path;
27	use std::time::Duration;
28
29	/// Interim default pulse period. The durable, configurable knob (global +
30	/// per-endpoint override) is the M3 daemon's; this is the interim constant.
31	pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);
32
33	/// Pulse tuning. `period` paces the `api listen` loop; `protection_window_ms`
34	/// is threaded into the drop-file precedence guard (6.5).
35	#[derive(Debug, Clone, Copy)]
36	pub struct PulseConfig {
37	    pub period: Duration,
38	    pub protection_window_ms: u64,
39	}
40
41	impl Default for PulseConfig {
42	    fn default() -> Self {
43	        PulseConfig {
44	            period: DEFAULT_PULSE_PERIOD,
45	            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
46	        }
47	    }
48	}
49
50	/// What one tick observed/did. `echo_due` means the gate was armed and has now
51	/// been cleared — the caller should fire exactly one echo-commune.
52	#[derive(Debug, Clone)]
53	pub struct TickOutcome {
54	    pub ingested: Vec<Ingested>,
55	    pub echo_due: bool,
56	    pub idle: bool,
57	}
58
59	/// Run one pulse tick for `id`: ingest drops from `drop_dir` (two-tier routed
60	/// under `project_id`), then read+clear the echo-gate (once-only fire) and the
61	/// idle flag. `now_ms` stamps the precedence markers of any ingested writes.
62	pub fn tick(
63	    id: &str,
64	    project_id: &str,
65	    hint: ParentHint<'_>,
66	    drop_dir: &Path,
67	    now_ms: u64,
68	    cfg: &PulseConfig,
69	) -> std::io::Result<TickOutcome> {
70	    let ingested = ingest_drops(drop_dir, id, project_id, now_ms, cfg.protection_window_ms)?;
71	    let echo_due = take_echo_gate(id, hint.clone());
72	    let idle = resolve_idle_file(id, hint).exists();
73	    Ok(TickOutcome {
74	        ingested,
75	        echo_due,
76	        idle,
77	    })
78	}
79
80	/// Read-and-clear the echo-gate: returns whether it was armed, removing the
81	/// sentinel so the echo fires exactly once. Clearing a missing gate is a no-op.
82	pub fn take_echo_gate(id: &str, hint: ParentHint<'_>) -> bool {
83	    let gate = resolve_echo_gate_file(id, hint);
84	    if gate.exists() {
85	        // Best-effort clear; if removal fails the next tick re-fires (at-least-
86	        // once degrades safely — a duplicate echo is harmless, a lost one less so).
87	        let _ = std::fs::remove_file(&gate);
88	        true
89	    } else {
90	        false
91	    }
92	}
93
94	#[cfg(test)]
95	mod tests {
96	    use super::*;
97	    use crate::test_support::with_home;
98	    use spt_store::perch::resolve_perch_path;
99
100	    fn arm_gate(id: &str) {
101	        let perch = resolve_perch_path(id, ParentHint::Infer);
102	        std::fs::create_dir_all(&perch).unwrap();
103	        std::fs::write(resolve_echo_gate_file(id, ParentHint::Infer), "").unwrap();
104	    }
105
106	    #[test] // [unit->REQ-SEAM-PSYCHE] an armed echo-gate fires exactly once, then clears.
107	    fn echo_gate_fires_exactly_once() {
108	        with_home(|_| {
109	            let drops = tempfile::tempdir().unwrap();
110	            arm_gate("doyle");
111
112	            let t1 = tick("doyle", "proj-x", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
113	                .expect("tick");
114	            assert!(t1.echo_due, "armed gate should fire");
115
116	            // Second tick: gate cleared, no re-fire.
117	            let t2 = tick("doyle", "proj-x", ParentHint::Infer, drops.path(), 2000, &PulseConfig::default())
118	                .expect("tick");
119	            assert!(!t2.echo_due, "gate must clear after one fire");
120	        });
121	    }
122
123	    #[test] // [unit->REQ-SEAM-PSYCHE] a tick polls + ingests the drop dir.
124	    fn tick_ingests_drops() {
125	        with_home(|_| {
126	            let drops = tempfile::tempdir().unwrap();
127	            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();
128
129	            let t = tick("doyle", "proj-x", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
130	                .expect("tick");
131	            assert_eq!(t.ingested.len(), 1);
132	            assert!(!t.echo_due, "no gate armed");
133	            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
134	        });
135	    }
136
137	    #[test] // [unit->REQ-SEAM-PSYCHE] idle sentinel surfaces in the tick outcome.
138	    fn tick_reports_idle() {
139	        with_home(|_| {
140	            let drops = tempfile::tempdir().unwrap();
141	            let perch = resolve_perch_path("doyle", ParentHint::Infer);
142	            std::fs::create_dir_all(&perch).unwrap();
143	            std::fs::write(resolve_idle_file("doyle", ParentHint::Infer), "").unwrap();
144
145	            let t = tick("doyle", "proj-x", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
146	                .expect("tick");
147	            assert!(t.idle);
148	        });
149	    }
150	}
151

//! pulse heartbeat (M2b T5) — the interim per-tick driver.
//!
//! In the no-daemon interim model, the long-running `api listen` process
//! (T8) hosts a pulse loop. Each tick does the lightweight, recurring work:
//! - **poll the drop dirs** → fold any commune/signoff drops into context
//!   (single-writer ingest→delete — T4);
//! - **fire the pending echo** → if the echo-gate (`.more-done`, armed by
//!   `api state idle`) is set, signal that an echo is due and **clear the gate**
//!   so it fires *exactly once*;
//! - surface the **idle** flag for the caller's capsule/idle decisions.
//!
//! The pulse is deliberately thin — it decides *when* an echo is due and
//! guarantees once-only; the heavy echo run ([`crate::echo::run_echo_commune`])
//! is the caller's (T8), which holds the runtime + history + commune dir. Keep
//! all of this behind this one module so M3's daemon re-host (Psyche-as-loop,
//! daemon-owned period) is a localized swap, not a rewrite (mirrors M2a's
//! seed-file discipline).
//
// Interim only: the durable pulse loop + its global/per-endpoint period config
// belong to the M3 daemon (CONTEXT R-DAEMON-1). No dedicated REQ — the pulse is
// throwaway glue under REQ-SEAM-PSYCHE (see M2b-PLAN §New requirements).

use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
use crate::ingest::{ingest_drops, Ingested};
use spt_store::perch::{resolve_echo_gate_file, resolve_idle_file, ParentHint};
use std::path::Path;
use std::time::Duration;

/// Interim default pulse period. The durable, configurable knob (global +
/// per-endpoint override) is the M3 daemon's; this is the interim constant.
pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);

/// Pulse tuning. `period` paces the `api listen` loop; `protection_window_ms`
/// is threaded into the drop-file precedence guard (6.5).
#[derive(Debug, Clone, Copy)]
pub struct PulseConfig {
    pub period: Duration,
    pub protection_window_ms: u64,
}

impl Default for PulseConfig {
    fn default() -> Self {
        PulseConfig {
            period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
        }
    }
}

/// What one tick observed/did. `echo_due` means the gate was armed and has now
/// been cleared — the caller should fire exactly one echo-commune.
#[derive(Debug, Clone)]
pub struct TickOutcome {
    pub ingested: Vec<Ingested>,
    pub echo_due: bool,
    pub idle: bool,
}

/// Run one pulse tick for `id`: ingest drops from `drop_dir` (two-tier routed
/// under `project_id`), then read+clear the echo-gate (once-only fire) and the
/// idle flag. `now_ms` stamps the precedence markers of any ingested writes.
pub fn tick(
    id: &str,
    project_id: &str,
    hint: ParentHint<'_>,
    drop_dir: &Path,
    now_ms: u64,
    cfg: &PulseConfig,
) -> std::io::Result<TickOutcome> {
    let ingested = ingest_drops(drop_dir, id, project_id, now_ms, cfg.protection_window_ms)?;
    let echo_due = take_echo_gate(id, hint.clone());
    let idle = resolve_idle_file(id, hint).exists();
    Ok(TickOutcome {
        ingested,
        echo_due,
        idle,
    })
}

/// Read-and-clear the echo-gate: returns whether it was armed, removing the
/// sentinel so the echo fires exactly once. Clearing a missing gate is a no-op.
pub fn take_echo_gate(id: &str, hint: ParentHint<'_>) -> bool {
    let gate = resolve_echo_gate_file(id, hint);
    if gate.exists() {
        // Best-effort clear; if removal fails the next tick re-fires (at-least-
        // once degrades safely — a duplicate echo is harmless, a lost one less so).
        let _ = std::fs::remove_file(&gate);
        true
    } else {
        false
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::with_home;
    use spt_store::perch::resolve_perch_path;

    fn arm_gate(id: &str) {
        let perch = resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&perch).unwrap();
        std::fs::write(resolve_echo_gate_file(id, ParentHint::Infer), "").unwrap();
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] an armed echo-gate fires exactly once, then clears.
    fn echo_gate_fires_exactly_once() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            arm_gate("doyle");

            let t1 = tick("doyle", "proj-x", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
                .expect("tick");
            assert!(t1.echo_due, "armed gate should fire");

            // Second tick: gate cleared, no re-fire.
            let t2 = tick("doyle", "proj-x", ParentHint::Infer, drops.path(), 2000, &PulseConfig::default())
                .expect("tick");
            assert!(!t2.echo_due, "gate must clear after one fire");
        });
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] a tick polls + ingests the drop dir.
    fn tick_ingests_drops() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();

            let t = tick("doyle", "proj-x", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
                .expect("tick");
            assert_eq!(t.ingested.len(), 1);
            assert!(!t.echo_due, "no gate armed");
            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
        });
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] idle sentinel surfaces in the tick outcome.
    fn tick_reports_idle() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let perch = resolve_perch_path("doyle", ParentHint::Infer);
            std::fs::create_dir_all(&perch).unwrap();
            std::fs::write(resolve_idle_file("doyle", ParentHint::Infer), "").unwrap();

            let t = tick("doyle", "proj-x", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
                .expect("tick");
            assert!(t.idle);
        });
    }
}


Echo gate = the cross-process trigger. Recon CLI structure + live shutdown: