1	//! The brain (B2) — the restartable logic half that attaches to the broker.
2	//!
3	//! The brain holds all the volatile logic; it may be killed and restarted at
4	//! any moment (a routine self-update swaps it; a crash kills it). It connects to
5	//! the stable [`crate::broker::Broker`] over the versioned IPC, drives sessions
6	//! (spawn / input / resize / kill), and consumes their output. Two invariants
7	//! this type encodes:
8	//!
9	//! - **`gen_start = now()` on cold-start *and* handoff (KNOWN-HAZARDS 2.4).**
10	//!   The wall-clock generation stamp is taken fresh on every start. A handed-off
11	//!   brain rehydrates its resume cursor and generation *counter* from prior
12	//!   state, but its `gen_start` is `now()`, **never** the stale value from that
13	//!   state — a rehydrated `gen_start` would fire time-based discriminators on
14	//!   the new generation. [`Brain::handoff`] takes the prior state by value yet
15	//!   ignores its `gen_start_ms`.
16	//! - **Gapless, exactly-once resume.** The brain tracks `next_seq` — the next
17	//!   output sequence it still needs. On handoff it `subscribe`s from that cursor;
18	//!   the broker replays buffered output from there. [`Brain::read_event`] accepts
19	//!   only the contiguous next `seq` (advancing the cursor), silently drops an
20	//!   already-seen `seq` (a replay duplicate), and treats a forward gap as a hard
21	//!   error — so a lost chunk can never pass silently.
22	// [impl->REQ-HAZARD-GEN-START-NOW]
23	// [impl->REQ-DAEMON-2]
24
25	use std::io;
26	use std::sync::atomic::{AtomicBool, Ordering};
27	use std::time::{SystemTime, UNIX_EPOCH};
28
29	use interprocess::local_socket::Stream;
30
31	use crate::codec::{read_frame, write_frame};
32	use crate::digest::{ingest_chunk, DigestEngine};
33	use crate::digesthub::DigestHub;
34	use crate::frame::{Envelope, Role};
35	use crate::msg::{
36	    decode_bytes, encode_bytes, AppliedEvent, ErrorEvent, ExitEvent, InputReq, KillReq, NetDialReq,
37	    NetDialed, NetSent, NetStatusReply, NetStreamData, NetStreamEof, NetStreamOpenReq,
38	    NetStreamOpened, NetStreamSendReq, NetStreamSubscribeReq, NetStreamsReply, OutputEvent,
39	    ResizeReq, SpawnReq, Spawned, SubscribeReq, KIND_APPLIED, KIND_ERROR, KIND_EXIT, KIND_INPUT,
40	    KIND_KILL, KIND_NET_DIAL, KIND_NET_DIALED, KIND_NET_SENT, KIND_NET_STATUS,
41	    KIND_NET_STATUS_REPLY, KIND_NET_STREAMS, KIND_NET_STREAMS_REPLY, KIND_NET_STREAM_DATA,
42	    KIND_NET_STREAM_EOF, KIND_NET_STREAM_OPEN, KIND_NET_STREAM_OPENED, KIND_NET_STREAM_SEND,
43	    KIND_NET_STREAM_SUBSCRIBE, KIND_OUTPUT, KIND_RESIZE, KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
44	};
45	use crate::transport::{send_hello, LocalSocketTransport};
46
47	/// Wall-clock now in epoch milliseconds — the source for `gen_start` (matches
48	/// the epoch-ms stamping used elsewhere in the tree; no date dependency).
49	pub fn now_ms() -> u64 {
50	    SystemTime::now()
51	        .duration_since(UNIX_EPOCH)
52	        .map(|d| d.as_millis() as u64)
53	        .unwrap_or(0)
54	}
55
56	/// The brain's durable state across a handoff: enough to re-attach gaplessly and
57	/// advance the generation. `gen_start_ms` is recorded for observability but is
58	/// **deliberately not authoritative on restart** — [`Brain::handoff`] sets a
59	/// fresh `gen_start` from `now()` (2.4).
60	#[derive(Debug, Clone)]
61	pub struct BrainState {
62	    /// The session the brain was driving.
63	    pub session_id: u64,
64	    /// The generation counter (incremented on each handoff).
65	    pub generation: u64,
66	    /// The next output sequence still needed (the resume cursor).
67	    pub next_seq: u64,
68	    /// The prior generation's wall-clock start (ignored as authoritative on
69	    /// handoff — kept only to demonstrate it is *not* carried forward).
70	    pub gen_start_ms: u64,
71	}
72
73	/// A demuxed broker→brain event.
74	#[derive(Debug)]
75	pub enum BrokerEvent {
76	    /// A contiguous chunk of a session's output (the cursor has advanced past it).
77	    Output { session_id: u64, seq: u64, bytes: Vec<u8> },
78	    /// A spawn succeeded.
79	    Spawned { session_id: u64, pid: Option<u32> },
80	    /// A session's child exited.
81	    Exit { session_id: u64, code: Option<i32> },
82	    /// An idempotent effect is durably applied (or was deduped — `applied_now`
83	    /// distinguishes). The exactly-once ack for a [`Brain::send_effect`] (B5).
84	    Applied { session_id: u64, op_id: u64, applied_now: bool },
85	    /// The broker-owned net endpoint's status (a `net-status` reply, D4a).
86	    NetStatus(NetStatusReply),
87	    /// A net dial durably landed — fresh or deduped (D4a).
88	    NetDialed(NetDialed),
89	    /// A stream open durably landed — fresh or deduped (D4b).
90	    NetStreamOpened(NetStreamOpened),
91	    /// A journaled stream send durably landed — fresh or deduped (D4b).
92	    NetSent(NetSent),
93	    /// The broker's held-streams listing (D4b).
94	    NetStreams(NetStreamsReply),
95	    /// A contiguous chunk of a stream's reads (the cursor has advanced past it).
96	    NetStreamData { stream_id: u64, seq: u64, bytes: Vec<u8> },
97	    /// A stream's read side finished cleanly; `next_seq` is the completeness
98	    /// check (the subscriber's cursor must equal it to have missed nothing).
99	    NetStreamEof { stream_id: u64, next_seq: u64 },
100	    /// The broker reported a command error.
101	    Error { message: String },
102	    /// A frame of an unrecognized kind (forward-compat: tolerated, surfaced).
103	    Other(Envelope),
104	}
105
106	/// A live brain attached to a broker.
107	pub struct Brain {
108	    conn: Stream,
109	    generation: u64,
110	    gen_start_ms: u64,
111	    session_id: Option<u64>,
112	    /// The next output sequence still needed (resume cursor / dedup floor).
113	    next_seq: u64,
114	    /// Per-net-stream resume cursors (D4b) — the stream-read siblings of
115	    /// `next_seq`, set by [`Brain::net_stream_subscribe`] and advanced by
116	    /// [`Brain::read_event`] under the same accept/dedup/gap discipline.
117	    /// Durability across a handoff is the logic layer's job (a re-attaching
118	    /// brain queries `net-streams` and re-subscribes from its durable cursors).
119	    net_cursors: std::collections::HashMap<u64, u64>,
120	}
121
122	impl Brain {
123	    /// Cold start: connect, handshake, fresh generation 0, `gen_start = now`.
124	    pub fn cold_start(name: &str, now_ms: u64) -> io::Result<Self> {
125	        let mut conn = LocalSocketTransport::connect(name)?;
126	        send_hello(&mut conn, Role::Brain)?;
127	        Ok(Brain {
128	            conn,
129	            generation: 0,
130	            gen_start_ms: now_ms,
131	            session_id: None,
132	            next_seq: 0,
133	            net_cursors: std::collections::HashMap::new(),
134	        })
135	    }
136
137	    /// Handoff: connect, handshake, rehydrate the resume cursor + generation
138	    /// from `prior`, but stamp `gen_start = now()` (NOT `prior.gen_start_ms` —
139	    /// the 2.4 invariant), and re-subscribe to the session from the cursor for a
140	    /// gapless resume.
141	    pub fn handoff(name: &str, prior: BrainState, now_ms: u64) -> io::Result<Self> {
142	        let mut conn = LocalSocketTransport::connect(name)?;
143	        send_hello(&mut conn, Role::Brain)?;
144	        let mut brain = Brain {
145	            conn,
146	            generation: prior.generation + 1,
147	            gen_start_ms: now_ms, // fresh — prior.gen_start_ms is intentionally discarded
148	            session_id: Some(prior.session_id),
149	            next_seq: prior.next_seq,
150	            net_cursors: std::collections::HashMap::new(),
151	        };
152	        brain.subscribe(prior.session_id, prior.next_seq)?;
153	        Ok(brain)
154	    }
155
156	    /// The wall-clock generation start (epoch ms) — always this generation's own
157	    /// start time, never a rehydrated value.
158	    pub fn gen_start_ms(&self) -> u64 {
159	        self.gen_start_ms
160	    }
161
162	    /// The generation counter (0 on cold start, +1 per handoff).
163	    pub fn generation(&self) -> u64 {
164	        self.generation
165	    }
166
167	    /// The next output sequence still needed (the resume cursor).
168	    pub fn next_seq(&self) -> u64 {
169	        self.next_seq
170	    }
171
172	    /// The session id the brain is driving, if any.
173	    pub fn session_id(&self) -> Option<u64> {
174	        self.session_id
175	    }
176
177	    /// Snapshot durable state for a future handoff.
178	    pub fn snapshot(&self) -> BrainState {
179	        BrainState {
180	            session_id: self.session_id.expect("snapshot before a session exists"),
181	            generation: self.generation,
182	            next_seq: self.next_seq,
183	            gen_start_ms: self.gen_start_ms,
184	        }
185	    }
186
187	    /// Spawn a session under a broker PTY; the broker auto-subscribes this
188	    /// connection. Reads until the `spawned` reply (consuming any output that
189	    /// races ahead).
190	    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
191	        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
192	        loop {
193	            match self.read_event()? {
194	                BrokerEvent::Spawned { session_id, .. } => {
195	                    self.session_id = Some(session_id);
196	                    return Ok(session_id);
197	                }
198	                BrokerEvent::Error { message } => {
199	                    return Err(io::Error::other(message));
200	                }
201	                _ => continue,
202	            }
203	        }
204	    }
205
206	    /// Inject input bytes into the current session (send-keys), the **legacy
207	    /// direct** path: no durable id, no dedup, no ack. For exactly-once delivery
208	    /// of an effect that originates from a durable source (a spool row, a
209	    /// sequenced injection), use [`Brain::send_effect`].
210	    pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
211	        let sid = self.require_session()?;
212	        self.send(
213	            KIND_INPUT,
214	            serde_json::to_value(InputReq { session_id: sid, data_b64: encode_bytes(bytes), op_id: None })
215	                .expect("InputReq serializes"),
216	        )
217	    }
218
219	    /// Inject input **idempotently**: the write carries the durable `op_id` so a
220	    /// replay after a brain crash is deduped at the broker (exactly-once, B5).
221	    /// `op_id` must come from a durable source and be reused verbatim on replay —
222	    /// the same logical effect must always carry the same id, so the broker's
223	    /// applied-set recognizes the re-drive. The broker answers with a
224	    /// [`BrokerEvent::Applied`] for `(session, op_id)`.
225	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
226	    pub fn send_effect(&mut self, op_id: u64, bytes: &[u8]) -> io::Result<()> {
227	        let sid = self.require_session()?;
228	        self.send(
229	            KIND_INPUT,
230	            serde_json::to_value(InputReq {
231	                session_id: sid,
232	                data_b64: encode_bytes(bytes),
233	                op_id: Some(op_id),
234	            })
235	            .expect("InputReq serializes"),
236	        )
237	    }
238
239	    /// Resize the current session's PTY.
240	    pub fn resize(&mut self, rows: u16, cols: u16) -> io::Result<()> {
241	        let sid = self.require_session()?;
242	        self.send(
243	            KIND_RESIZE,
244	            serde_json::to_value(ResizeReq { session_id: sid, rows, cols })
245	                .expect("ResizeReq serializes"),
246	        )
247	    }
248
249	    /// Terminate the current session's child.
250	    pub fn kill_session(&mut self) -> io::Result<()> {
251	        let sid = self.require_session()?;
252	        self.send(
253	            KIND_KILL,
254	            serde_json::to_value(KillReq { session_id: sid }).expect("KillReq serializes"),
255	        )
256	    }
257
258	    /// Read output events until the accumulated bytes contain `needle`,
259	    /// appending all received output to `buf`. Returns the highest seq seen.
260	    /// A session `Exit` before the needle is found is an error (the child died).
261	    pub fn read_output_until(&mut self, needle: &[u8], buf: &mut Vec<u8>) -> io::Result<u64> {
262	        loop {
263	            if contains(buf, needle) {
264	                return Ok(self.next_seq.saturating_sub(1));
265	            }
266	            match self.read_event()? {
267	                BrokerEvent::Output { bytes, .. } => buf.extend_from_slice(&bytes),
268	                BrokerEvent::Exit { .. } => {
269	                    return Err(io::Error::new(
270	                        io::ErrorKind::UnexpectedEof,
271	                        "session exited before the expected output arrived",
272	                    ));
273	                }
274	                BrokerEvent::Error { message } => {
275	                    return Err(io::Error::other(message));
276	                }
277	                _ => {}
278	            }
279	        }
280	    }
281
282	    /// Read and demux one broker event, enforcing exactly-once contiguous output:
283	    /// accept the next `seq` (advancing the cursor), drop an already-seen `seq`
284	    /// (replay duplicate), and reject a forward gap (lost chunk).
285	    pub fn read_event(&mut self) -> io::Result<BrokerEvent> {
286	        loop {
287	            let env = read_frame(&mut self.conn)?;
288	            match env.kind.as_str() {
289	                KIND_OUTPUT => {
290	                    let ev: OutputEvent = serde_json::from_value(env.payload)
291	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
292	                    if ev.seq < self.next_seq {
293	                        continue; // already processed — a replay duplicate; dedup
294	                    }
295	                    if ev.seq > self.next_seq {
296	                        return Err(io::Error::new(
297	                            io::ErrorKind::InvalidData,
298	                            format!("output gap: got seq {} want {}", ev.seq, self.next_seq),
299	                        ));
300	                    }
301	                    self.next_seq = ev.seq + 1;
302	                    let bytes = decode_bytes(&ev.data_b64)
303	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
304	                    return Ok(BrokerEvent::Output { session_id: ev.session_id, seq: ev.seq, bytes });
305	                }
306	                KIND_SPAWNED => {
307	                    let ev: Spawned = serde_json::from_value(env.payload)
308	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
309	                    return Ok(BrokerEvent::Spawned { session_id: ev.session_id, pid: ev.pid });
310	                }
311	                KIND_EXIT => {
312	                    let ev: ExitEvent = serde_json::from_value(env.payload)
313	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
314	                    return Ok(BrokerEvent::Exit { session_id: ev.session_id, code: ev.code });
315	                }
316	                KIND_APPLIED => {
317	                    let ev: AppliedEvent = serde_json::from_value(env.payload)
318	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
319	                    return Ok(BrokerEvent::Applied {
320	                        session_id: ev.session_id,
321	                        op_id: ev.op_id,
322	                        applied_now: ev.applied_now,
323	                    });
324	                }
325	                KIND_NET_STATUS_REPLY => {
326	                    let ev: NetStatusReply = serde_json::from_value(env.payload)
327	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
328	                    return Ok(BrokerEvent::NetStatus(ev));
329	                }
330	                KIND_NET_DIALED => {
331	                    let ev: NetDialed = serde_json::from_value(env.payload)
332	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
333	                    return Ok(BrokerEvent::NetDialed(ev));
334	                }
335	                KIND_NET_STREAM_OPENED => {
336	                    let ev: NetStreamOpened = serde_json::from_value(env.payload)
337	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
338	                    return Ok(BrokerEvent::NetStreamOpened(ev));
339	                }
340	                KIND_NET_SENT => {
341	                    let ev: NetSent = serde_json::from_value(env.payload)
342	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
343	                    return Ok(BrokerEvent::NetSent(ev));
344	                }
345	                KIND_NET_STREAMS_REPLY => {
346	                    let ev: NetStreamsReply = serde_json::from_value(env.payload)
347	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
348	                    return Ok(BrokerEvent::NetStreams(ev));
349	                }
350	                KIND_NET_STREAM_DATA => {
351	                    let ev: NetStreamData = serde_json::from_value(env.payload)
352	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
353	                    // Same exactly-once discipline as PTY output, per stream:
354	                    // accept the contiguous next seq, drop a replay duplicate,
355	                    // reject a forward gap (a lost chunk must not pass silently).
356	                    let cursor = self.net_cursors.entry(ev.stream_id).or_insert(ev.seq);
357	                    if ev.seq < *cursor {
358	                        continue; // replay duplicate — dedup
359	                    }
360	                    if ev.seq > *cursor {
361	                        return Err(io::Error::new(
362	                            io::ErrorKind::InvalidData,
363	                            format!(
364	                                "stream {} gap: got seq {} want {}",
365	                                ev.stream_id, ev.seq, *cursor
366	                            ),
367	                        ));
368	                    }
369	                    *cursor = ev.seq + 1;
370	                    let bytes = decode_bytes(&ev.data_b64)
371	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
372	                    return Ok(BrokerEvent::NetStreamData {
373	                        stream_id: ev.stream_id,
374	                        seq: ev.seq,
375	                        bytes,
376	                    });
377	                }
378	                KIND_NET_STREAM_EOF => {
379	                    let ev: NetStreamEof = serde_json::from_value(env.payload)
380	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
381	                    return Ok(BrokerEvent::NetStreamEof {
382	                        stream_id: ev.stream_id,
383	                        next_seq: ev.next_seq,
384	                    });
385	                }
386	                KIND_ERROR => {
387	                    let ev: ErrorEvent = serde_json::from_value(env.payload)
388	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
389	                    return Ok(BrokerEvent::Error { message: ev.message });
390	                }
391	                _ => return Ok(BrokerEvent::Other(env)),
392	            }
393	        }
394	    }
395
396	    /// Attach this (freshly-connected) brain to an **existing** broker session's
397	    /// output from `from_seq`, e.g. a dedicated digest-feed subscriber that did
398	    /// not spawn the session itself. Sets the resume cursor + session, then
399	    /// subscribes (the broker replays from `from_seq`, then streams live). Unlike
400	    /// [`Brain::handoff`] this does not touch the generation — it is a *second*
401	    /// reader of a session another brain drives, not a logic handoff.
402	    pub fn attach(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {
403	        self.session_id = Some(session_id);
404	        self.next_seq = from_seq;
405	        self.subscribe(session_id, from_seq)
406	    }
407
408	    /// Run the **PTY-digest feed** for `endpoint_id` (B8): read this session's
409	    /// output events and fold each chunk through `engine`, publishing every change
410	    /// to `hub` (and persisting completed turns when the engine opted in) via
411	    /// [`ingest_chunk`]. This is the brain-side digest projection — the parse is
412	    /// logic, so it lives here, never in the broker (ADR-0004 §B). Returns when
413	    /// the session exits, `stop` is set (checked between events), or the broker
414	    /// connection drops. A non-output event other than `exit` is ignored (the
415	    /// feed only cares about the byte stream).
416	    // [impl->REQ-TERM-4]
417	    pub fn run_digest_feed(
418	        &mut self,
419	        engine: &mut DigestEngine,
420	        hub: &DigestHub,
421	        endpoint_id: &str,
422	        stop: &AtomicBool,
423	    ) -> io::Result<()> {
424	        loop {
425	            if stop.load(Ordering::Relaxed) {
426	                return Ok(());
427	            }
428	            match self.read_event()? {
429	                BrokerEvent::Output { bytes, .. } => {
430	                    ingest_chunk(engine, hub, endpoint_id, &bytes)
431	                }
432	                BrokerEvent::Exit { .. } => return Ok(()),
433	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
434	                _ => {}
435	            }
436	        }
437	    }
438
439	    /// Query the broker-owned net endpoint's status (D4a): node id, dialable
440	    /// address, conn count — or `enabled: false` on a net-less broker. Reads
441	    /// until the reply (consuming interleaved events like [`Brain::spawn_session`]).
442	    // [impl->REQ-DAEMON-2]
443	    pub fn net_status(&mut self) -> io::Result<NetStatusReply> {
444	        self.send(KIND_NET_STATUS, serde_json::Value::Null)?;
445	        loop {
446	            match self.read_event()? {
447	                BrokerEvent::NetStatus(s) => return Ok(s),
448	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
449	                _ => continue,
450	            }
451	        }
452	    }
453
454	    /// Dial a peer over the broker-owned endpoint (D4a). `addr` is the peer's
455	    /// serialized `EndpointAddr` (as another node's `net-status` reply carries).
456	    /// With `op_id` the dial is exactly-once: a replay of the same durable id
457	    /// after a brain crash is deduped at the broker and answered with the
458	    /// already-open connection ([`NetDialed::applied_now`] distinguishes).
459	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
460	    pub fn net_dial(
461	        &mut self,
462	        addr: serde_json::Value,
463	        op_id: Option<u64>,
464	    ) -> io::Result<NetDialed> {
465	        self.send(
466	            KIND_NET_DIAL,
467	            serde_json::to_value(NetDialReq { addr, op_id }).expect("NetDialReq serializes"),
468	        )?;
469	        loop {
470	            match self.read_event()? {
471	                BrokerEvent::NetDialed(d) => return Ok(d),
472	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
473	                _ => continue,
474	            }
475	        }
476	    }
477
478	    /// Open a bidi stream on a held connection (D4b). With `op_id` the open is
479	    /// exactly-once (a replay is answered with the original stream). NOTE all
480	    /// net op ids — dials, opens, sends — share ONE durable namespace; mint
481	    /// them from a single durable counter.
482	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
483	    pub fn net_open_stream(
484	        &mut self,
485	        conn_id: u64,
486	        op_id: Option<u64>,
487	    ) -> io::Result<NetStreamOpened> {
488	        self.send(
489	            KIND_NET_STREAM_OPEN,
490	            serde_json::to_value(NetStreamOpenReq { conn_id, op_id })
491	                .expect("NetStreamOpenReq serializes"),
492	        )?;
493	        loop {
494	            match self.read_event()? {
495	                BrokerEvent::NetStreamOpened(ev) => return Ok(ev),
496	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
497	                _ => continue,
498	            }
499	        }
500	    }
501
502	    /// Write bytes to a held stream over the broker (D4b). With `op_id` the
503	    /// write is exactly-once and this blocks for the `net-sent` ack (returned);
504	    /// without one it is a direct fire-and-forget write (`None` returned).
505	    /// `finish` closes the send side after the write.
506	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
507	    pub fn net_stream_send(
508	        &mut self,
509	        stream_id: u64,
510	        bytes: &[u8],
511	        op_id: Option<u64>,
512	        finish: bool,
513	    ) -> io::Result<Option<NetSent>> {
514	        self.send(
515	            KIND_NET_STREAM_SEND,
516	            serde_json::to_value(NetStreamSendReq {
517	                stream_id,
518	                data_b64: encode_bytes(bytes),
519	                op_id,
520	                finish,
521	            })
522	            .expect("NetStreamSendReq serializes"),
523	        )?;
524	        if op_id.is_none() {
525	            return Ok(None);
526	        }
527	        loop {
528	            match self.read_event()? {
529	                BrokerEvent::NetSent(ev) if Some(ev.op_id) == op_id => return Ok(Some(ev)),
530	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
531	                _ => continue,
532	            }
533	        }
534	    }
535
536	    /// List the streams the broker holds (D4b) — how a re-attaching brain
537	    /// finds streams (incl. peer-initiated ones) that arrived while it was down.
538	    pub fn net_streams(&mut self) -> io::Result<NetStreamsReply> {
539	        self.send(KIND_NET_STREAMS, serde_json::Value::Null)?;
540	        loop {
541	            match self.read_event()? {
542	                BrokerEvent::NetStreams(ev) => return Ok(ev),
543	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
544	                _ => continue,
545	            }
546	        }
547	    }
548
549	    /// (Re)subscribe to a stream's buffered reads from `from_seq`, setting the
550	    /// local cursor (gapless, exactly-once resume — the PTY `subscribe`
551	    /// discipline on a QUIC stream, D4b).
552	    // [impl->REQ-DAEMON-2]
553	    pub fn net_stream_subscribe(&mut self, stream_id: u64, from_seq: u64) -> io::Result<()> {
554	        self.net_cursors.insert(stream_id, from_seq);
555	        self.send(
556	            KIND_NET_STREAM_SUBSCRIBE,
557	            serde_json::to_value(NetStreamSubscribeReq { stream_id, from_seq })
558	                .expect("NetStreamSubscribeReq serializes"),
559	        )
560	    }
561
562	    /// The next stream-read sequence still needed for `stream_id` (the durable
563	    /// state a handed-off brain carries to resubscribe gaplessly).
564	    pub fn net_cursor(&self, stream_id: u64) -> Option<u64> {
565	        self.net_cursors.get(&stream_id).copied()
566	    }
567
568	    /// Re-attach to a session's output from `from_seq` (handoff resume).
569	    fn subscribe(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {
570	        self.send(
571	            KIND_SUBSCRIBE,
572	            serde_json::to_value(SubscribeReq { session_id, from_seq })
573	                .expect("SubscribeReq serializes"),
574	        )
575	    }
576
577	    fn send(&mut self, kind: &str, payload: serde_json::Value) -> io::Result<()> {
578	        write_frame(&mut self.conn, &Envelope::new(kind, payload))
579	    }
580
581	    fn require_session(&self) -> io::Result<u64> {
582	        self.session_id
583	            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no active session"))
584	    }
585	}
586
587	/// Whether `haystack` contains the contiguous `needle`.
588	fn contains(haystack: &[u8], needle: &[u8]) -> bool {
589	    if needle.is_empty() {
590	        return true;
591	    }
592	    haystack.windows(needle.len()).any(|w| w == needle)
593	}
594

//! The brain (B2) — the restartable logic half that attaches to the broker.
//!
//! The brain holds all the volatile logic; it may be killed and restarted at
//! any moment (a routine self-update swaps it; a crash kills it). It connects to
//! the stable [`crate::broker::Broker`] over the versioned IPC, drives sessions
//! (spawn / input / resize / kill), and consumes their output. Two invariants
//! this type encodes:
//!
//! - **`gen_start = now()` on cold-start *and* handoff (KNOWN-HAZARDS 2.4).**
//!   The wall-clock generation stamp is taken fresh on every start. A handed-off
//!   brain rehydrates its resume cursor and generation *counter* from prior
//!   state, but its `gen_start` is `now()`, **never** the stale value from that
//!   state — a rehydrated `gen_start` would fire time-based discriminators on
//!   the new generation. [`Brain::handoff`] takes the prior state by value yet
//!   ignores its `gen_start_ms`.
//! - **Gapless, exactly-once resume.** The brain tracks `next_seq` — the next
//!   output sequence it still needs. On handoff it `subscribe`s from that cursor;
//!   the broker replays buffered output from there. [`Brain::read_event`] accepts
//!   only the contiguous next `seq` (advancing the cursor), silently drops an
//!   already-seen `seq` (a replay duplicate), and treats a forward gap as a hard
//!   error — so a lost chunk can never pass silently.
// [impl->REQ-HAZARD-GEN-START-NOW]
// [impl->REQ-DAEMON-2]

use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

use interprocess::local_socket::Stream;

use crate::codec::{read_frame, write_frame};
use crate::digest::{ingest_chunk, DigestEngine};
use crate::digesthub::DigestHub;
use crate::frame::{Envelope, Role};
use crate::msg::{
    decode_bytes, encode_bytes, AppliedEvent, ErrorEvent, ExitEvent, InputReq, KillReq, NetDialReq,
    NetDialed, NetSent, NetStatusReply, NetStreamData, NetStreamEof, NetStreamOpenReq,
    NetStreamOpened, NetStreamSendReq, NetStreamSubscribeReq, NetStreamsReply, OutputEvent,
    ResizeReq, SpawnReq, Spawned, SubscribeReq, KIND_APPLIED, KIND_ERROR, KIND_EXIT, KIND_INPUT,
    KIND_KILL, KIND_NET_DIAL, KIND_NET_DIALED, KIND_NET_SENT, KIND_NET_STATUS,
    KIND_NET_STATUS_REPLY, KIND_NET_STREAMS, KIND_NET_STREAMS_REPLY, KIND_NET_STREAM_DATA,
    KIND_NET_STREAM_EOF, KIND_NET_STREAM_OPEN, KIND_NET_STREAM_OPENED, KIND_NET_STREAM_SEND,
    KIND_NET_STREAM_SUBSCRIBE, KIND_OUTPUT, KIND_RESIZE, KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
};
use crate::transport::{send_hello, LocalSocketTransport};

/// Wall-clock now in epoch milliseconds — the source for `gen_start` (matches
/// the epoch-ms stamping used elsewhere in the tree; no date dependency).
pub fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// The brain's durable state across a handoff: enough to re-attach gaplessly and
/// advance the generation. `gen_start_ms` is recorded for observability but is
/// **deliberately not authoritative on restart** — [`Brain::handoff`] sets a
/// fresh `gen_start` from `now()` (2.4).
#[derive(Debug, Clone)]
pub struct BrainState {
    /// The session the brain was driving.
    pub session_id: u64,
    /// The generation counter (incremented on each handoff).
    pub generation: u64,
    /// The next output sequence still needed (the resume cursor).
    pub next_seq: u64,
    /// The prior generation's wall-clock start (ignored as authoritative on
    /// handoff — kept only to demonstrate it is *not* carried forward).
    pub gen_start_ms: u64,
}

/// A demuxed broker→brain event.
#[derive(Debug)]
pub enum BrokerEvent {
    /// A contiguous chunk of a session's output (the cursor has advanced past it).
    Output { session_id: u64, seq: u64, bytes: Vec<u8> },
    /// A spawn succeeded.
    Spawned { session_id: u64, pid: Option<u32> },
    /// A session's child exited.
    Exit { session_id: u64, code: Option<i32> },
    /// An idempotent effect is durably applied (or was deduped — `applied_now`
    /// distinguishes). The exactly-once ack for a [`Brain::send_effect`] (B5).
    Applied { session_id: u64, op_id: u64, applied_now: bool },
    /// The broker-owned net endpoint's status (a `net-status` reply, D4a).
    NetStatus(NetStatusReply),
    /// A net dial durably landed — fresh or deduped (D4a).
    NetDialed(NetDialed),
    /// A stream open durably landed — fresh or deduped (D4b).
    NetStreamOpened(NetStreamOpened),
    /// A journaled stream send durably landed — fresh or deduped (D4b).
    NetSent(NetSent),
    /// The broker's held-streams listing (D4b).
    NetStreams(NetStreamsReply),
    /// A contiguous chunk of a stream's reads (the cursor has advanced past it).
    NetStreamData { stream_id: u64, seq: u64, bytes: Vec<u8> },
    /// A stream's read side finished cleanly; `next_seq` is the completeness
    /// check (the subscriber's cursor must equal it to have missed nothing).
    NetStreamEof { stream_id: u64, next_seq: u64 },
    /// The broker reported a command error.
    Error { message: String },
    /// A frame of an unrecognized kind (forward-compat: tolerated, surfaced).
    Other(Envelope),
}

/// A live brain attached to a broker.
pub struct Brain {
    conn: Stream,
    generation: u64,
    gen_start_ms: u64,
    session_id: Option<u64>,
    /// The next output sequence still needed (resume cursor / dedup floor).
    next_seq: u64,
    /// Per-net-stream resume cursors (D4b) — the stream-read siblings of
    /// `next_seq`, set by [`Brain::net_stream_subscribe`] and advanced by
    /// [`Brain::read_event`] under the same accept/dedup/gap discipline.
    /// Durability across a handoff is the logic layer's job (a re-attaching
    /// brain queries `net-streams` and re-subscribes from its durable cursors).
    net_cursors: std::collections::HashMap<u64, u64>,
}

impl Brain {
    /// Cold start: connect, handshake, fresh generation 0, `gen_start = now`.
    pub fn cold_start(name: &str, now_ms: u64) -> io::Result<Self> {
        let mut conn = LocalSocketTransport::connect(name)?;
        send_hello(&mut conn, Role::Brain)?;
        Ok(Brain {
            conn,
            generation: 0,
            gen_start_ms: now_ms,
            session_id: None,
            next_seq: 0,
            net_cursors: std::collections::HashMap::new(),
        })
    }

    /// Handoff: connect, handshake, rehydrate the resume cursor + generation
    /// from `prior`, but stamp `gen_start = now()` (NOT `prior.gen_start_ms` —
    /// the 2.4 invariant), and re-subscribe to the session from the cursor for a
    /// gapless resume.
    pub fn handoff(name: &str, prior: BrainState, now_ms: u64) -> io::Result<Self> {
        let mut conn = LocalSocketTransport::connect(name)?;
        send_hello(&mut conn, Role::Brain)?;
        let mut brain = Brain {
            conn,
            generation: prior.generation + 1,
            gen_start_ms: now_ms, // fresh — prior.gen_start_ms is intentionally discarded
            session_id: Some(prior.session_id),
            next_seq: prior.next_seq,
            net_cursors: std::collections::HashMap::new(),
        };
        brain.subscribe(prior.session_id, prior.next_seq)?;
        Ok(brain)
    }

    /// The wall-clock generation start (epoch ms) — always this generation's own
    /// start time, never a rehydrated value.
    pub fn gen_start_ms(&self) -> u64 {
        self.gen_start_ms
    }

    /// The generation counter (0 on cold start, +1 per handoff).
    pub fn generation(&self) -> u64 {
        self.generation
    }

    /// The next output sequence still needed (the resume cursor).
    pub fn next_seq(&self) -> u64 {
        self.next_seq
    }

    /// The session id the brain is driving, if any.
    pub fn session_id(&self) -> Option<u64> {
        self.session_id
    }

    /// Snapshot durable state for a future handoff.
    pub fn snapshot(&self) -> BrainState {
        BrainState {
            session_id: self.session_id.expect("snapshot before a session exists"),
            generation: self.generation,
            next_seq: self.next_seq,
            gen_start_ms: self.gen_start_ms,
        }
    }

    /// Spawn a session under a broker PTY; the broker auto-subscribes this
    /// connection. Reads until the `spawned` reply (consuming any output that
    /// races ahead).
    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
        loop {
            match self.read_event()? {
                BrokerEvent::Spawned { session_id, .. } => {
                    self.session_id = Some(session_id);
                    return Ok(session_id);
                }
                BrokerEvent::Error { message } => {
                    return Err(io::Error::other(message));
                }
                _ => continue,
            }
        }
    }

    /// Inject input bytes into the current session (send-keys), the **legacy
    /// direct** path: no durable id, no dedup, no ack. For exactly-once delivery
    /// of an effect that originates from a durable source (a spool row, a
    /// sequenced injection), use [`Brain::send_effect`].
    pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_INPUT,
            serde_json::to_value(InputReq { session_id: sid, data_b64: encode_bytes(bytes), op_id: None })
                .expect("InputReq serializes"),
        )
    }

    /// Inject input **idempotently**: the write carries the durable `op_id` so a
    /// replay after a brain crash is deduped at the broker (exactly-once, B5).
    /// `op_id` must come from a durable source and be reused verbatim on replay —
    /// the same logical effect must always carry the same id, so the broker's
    /// applied-set recognizes the re-drive. The broker answers with a
    /// [`BrokerEvent::Applied`] for `(session, op_id)`.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    pub fn send_effect(&mut self, op_id: u64, bytes: &[u8]) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_INPUT,
            serde_json::to_value(InputReq {
                session_id: sid,
                data_b64: encode_bytes(bytes),
                op_id: Some(op_id),
            })
            .expect("InputReq serializes"),
        )
    }

    /// Resize the current session's PTY.
    pub fn resize(&mut self, rows: u16, cols: u16) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_RESIZE,
            serde_json::to_value(ResizeReq { session_id: sid, rows, cols })
                .expect("ResizeReq serializes"),
        )
    }

    /// Terminate the current session's child.
    pub fn kill_session(&mut self) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_KILL,
            serde_json::to_value(KillReq { session_id: sid }).expect("KillReq serializes"),
        )
    }

    /// Read output events until the accumulated bytes contain `needle`,
    /// appending all received output to `buf`. Returns the highest seq seen.
    /// A session `Exit` before the needle is found is an error (the child died).
    pub fn read_output_until(&mut self, needle: &[u8], buf: &mut Vec<u8>) -> io::Result<u64> {
        loop {
            if contains(buf, needle) {
                return Ok(self.next_seq.saturating_sub(1));
            }
            match self.read_event()? {
                BrokerEvent::Output { bytes, .. } => buf.extend_from_slice(&bytes),
                BrokerEvent::Exit { .. } => {
                    return Err(io::Error::new(
                        io::ErrorKind::UnexpectedEof,
                        "session exited before the expected output arrived",
                    ));
                }
                BrokerEvent::Error { message } => {
                    return Err(io::Error::other(message));
                }
                _ => {}
            }
        }
    }

    /// Read and demux one broker event, enforcing exactly-once contiguous output:
    /// accept the next `seq` (advancing the cursor), drop an already-seen `seq`
    /// (replay duplicate), and reject a forward gap (lost chunk).
    pub fn read_event(&mut self) -> io::Result<BrokerEvent> {
        loop {
            let env = read_frame(&mut self.conn)?;
            match env.kind.as_str() {
                KIND_OUTPUT => {
                    let ev: OutputEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    if ev.seq < self.next_seq {
                        continue; // already processed — a replay duplicate; dedup
                    }
                    if ev.seq > self.next_seq {
                        return Err(io::Error::new(
                            io::ErrorKind::InvalidData,
                            format!("output gap: got seq {} want {}", ev.seq, self.next_seq),
                        ));
                    }
                    self.next_seq = ev.seq + 1;
                    let bytes = decode_bytes(&ev.data_b64)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Output { session_id: ev.session_id, seq: ev.seq, bytes });
                }
                KIND_SPAWNED => {
                    let ev: Spawned = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Spawned { session_id: ev.session_id, pid: ev.pid });
                }
                KIND_EXIT => {
                    let ev: ExitEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Exit { session_id: ev.session_id, code: ev.code });
                }
                KIND_APPLIED => {
                    let ev: AppliedEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Applied {
                        session_id: ev.session_id,
                        op_id: ev.op_id,
                        applied_now: ev.applied_now,
                    });
                }
                KIND_NET_STATUS_REPLY => {
                    let ev: NetStatusReply = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::NetStatus(ev));
                }
                KIND_NET_DIALED => {
                    let ev: NetDialed = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::NetDialed(ev));
                }
                KIND_NET_STREAM_OPENED => {
                    let ev: NetStreamOpened = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::NetStreamOpened(ev));
                }
                KIND_NET_SENT => {
                    let ev: NetSent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::NetSent(ev));
                }
                KIND_NET_STREAMS_REPLY => {
                    let ev: NetStreamsReply = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::NetStreams(ev));
                }
                KIND_NET_STREAM_DATA => {
                    let ev: NetStreamData = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    // Same exactly-once discipline as PTY output, per stream:
                    // accept the contiguous next seq, drop a replay duplicate,
                    // reject a forward gap (a lost chunk must not pass silently).
                    let cursor = self.net_cursors.entry(ev.stream_id).or_insert(ev.seq);
                    if ev.seq < *cursor {
                        continue; // replay duplicate — dedup
                    }
                    if ev.seq > *cursor {
                        return Err(io::Error::new(
                            io::ErrorKind::InvalidData,
                            format!(
                                "stream {} gap: got seq {} want {}",
                                ev.stream_id, ev.seq, *cursor
                            ),
                        ));
                    }
                    *cursor = ev.seq + 1;
                    let bytes = decode_bytes(&ev.data_b64)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::NetStreamData {
                        stream_id: ev.stream_id,
                        seq: ev.seq,
                        bytes,
                    });
                }
                KIND_NET_STREAM_EOF => {
                    let ev: NetStreamEof = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::NetStreamEof {
                        stream_id: ev.stream_id,
                        next_seq: ev.next_seq,
                    });
                }
                KIND_ERROR => {
                    let ev: ErrorEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Error { message: ev.message });
                }
                _ => return Ok(BrokerEvent::Other(env)),
            }
        }
    }

    /// Attach this (freshly-connected) brain to an **existing** broker session's
    /// output from `from_seq`, e.g. a dedicated digest-feed subscriber that did
    /// not spawn the session itself. Sets the resume cursor + session, then
    /// subscribes (the broker replays from `from_seq`, then streams live). Unlike
    /// [`Brain::handoff`] this does not touch the generation — it is a *second*
    /// reader of a session another brain drives, not a logic handoff.
    pub fn attach(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {
        self.session_id = Some(session_id);
        self.next_seq = from_seq;
        self.subscribe(session_id, from_seq)
    }

    /// Run the **PTY-digest feed** for `endpoint_id` (B8): read this session's
    /// output events and fold each chunk through `engine`, publishing every change
    /// to `hub` (and persisting completed turns when the engine opted in) via
    /// [`ingest_chunk`]. This is the brain-side digest projection — the parse is
    /// logic, so it lives here, never in the broker (ADR-0004 §B). Returns when
    /// the session exits, `stop` is set (checked between events), or the broker
    /// connection drops. A non-output event other than `exit` is ignored (the
    /// feed only cares about the byte stream).
    // [impl->REQ-TERM-4]
    pub fn run_digest_feed(
        &mut self,
        engine: &mut DigestEngine,
        hub: &DigestHub,
        endpoint_id: &str,
        stop: &AtomicBool,
    ) -> io::Result<()> {
        loop {
            if stop.load(Ordering::Relaxed) {
                return Ok(());
            }
            match self.read_event()? {
                BrokerEvent::Output { bytes, .. } => {
                    ingest_chunk(engine, hub, endpoint_id, &bytes)
                }
                BrokerEvent::Exit { .. } => return Ok(()),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => {}
            }
        }
    }

    /// Query the broker-owned net endpoint's status (D4a): node id, dialable
    /// address, conn count — or `enabled: false` on a net-less broker. Reads
    /// until the reply (consuming interleaved events like [`Brain::spawn_session`]).
    // [impl->REQ-DAEMON-2]
    pub fn net_status(&mut self) -> io::Result<NetStatusReply> {
        self.send(KIND_NET_STATUS, serde_json::Value::Null)?;
        loop {
            match self.read_event()? {
                BrokerEvent::NetStatus(s) => return Ok(s),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => continue,
            }
        }
    }

    /// Dial a peer over the broker-owned endpoint (D4a). `addr` is the peer's
    /// serialized `EndpointAddr` (as another node's `net-status` reply carries).
    /// With `op_id` the dial is exactly-once: a replay of the same durable id
    /// after a brain crash is deduped at the broker and answered with the
    /// already-open connection ([`NetDialed::applied_now`] distinguishes).
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    pub fn net_dial(
        &mut self,
        addr: serde_json::Value,
        op_id: Option<u64>,
    ) -> io::Result<NetDialed> {
        self.send(
            KIND_NET_DIAL,
            serde_json::to_value(NetDialReq { addr, op_id }).expect("NetDialReq serializes"),
        )?;
        loop {
            match self.read_event()? {
                BrokerEvent::NetDialed(d) => return Ok(d),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => continue,
            }
        }
    }

    /// Open a bidi stream on a held connection (D4b). With `op_id` the open is
    /// exactly-once (a replay is answered with the original stream). NOTE all
    /// net op ids — dials, opens, sends — share ONE durable namespace; mint
    /// them from a single durable counter.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    pub fn net_open_stream(
        &mut self,
        conn_id: u64,
        op_id: Option<u64>,
    ) -> io::Result<NetStreamOpened> {
        self.send(
            KIND_NET_STREAM_OPEN,
            serde_json::to_value(NetStreamOpenReq { conn_id, op_id })
                .expect("NetStreamOpenReq serializes"),
        )?;
        loop {
            match self.read_event()? {
                BrokerEvent::NetStreamOpened(ev) => return Ok(ev),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => continue,
            }
        }
    }

    /// Write bytes to a held stream over the broker (D4b). With `op_id` the
    /// write is exactly-once and this blocks for the `net-sent` ack (returned);
    /// without one it is a direct fire-and-forget write (`None` returned).
    /// `finish` closes the send side after the write.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    pub fn net_stream_send(
        &mut self,
        stream_id: u64,
        bytes: &[u8],
        op_id: Option<u64>,
        finish: bool,
    ) -> io::Result<Option<NetSent>> {
        self.send(
            KIND_NET_STREAM_SEND,
            serde_json::to_value(NetStreamSendReq {
                stream_id,
                data_b64: encode_bytes(bytes),
                op_id,
                finish,
            })
            .expect("NetStreamSendReq serializes"),
        )?;
        if op_id.is_none() {
            return Ok(None);
        }
        loop {
            match self.read_event()? {
                BrokerEvent::NetSent(ev) if Some(ev.op_id) == op_id => return Ok(Some(ev)),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => continue,
            }
        }
    }

    /// List the streams the broker holds (D4b) — how a re-attaching brain
    /// finds streams (incl. peer-initiated ones) that arrived while it was down.
    pub fn net_streams(&mut self) -> io::Result<NetStreamsReply> {
        self.send(KIND_NET_STREAMS, serde_json::Value::Null)?;
        loop {
            match self.read_event()? {
                BrokerEvent::NetStreams(ev) => return Ok(ev),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => continue,
            }
        }
    }

    /// (Re)subscribe to a stream's buffered reads from `from_seq`, setting the
    /// local cursor (gapless, exactly-once resume — the PTY `subscribe`
    /// discipline on a QUIC stream, D4b).
    // [impl->REQ-DAEMON-2]
    pub fn net_stream_subscribe(&mut self, stream_id: u64, from_seq: u64) -> io::Result<()> {
        self.net_cursors.insert(stream_id, from_seq);
        self.send(
            KIND_NET_STREAM_SUBSCRIBE,
            serde_json::to_value(NetStreamSubscribeReq { stream_id, from_seq })
                .expect("NetStreamSubscribeReq serializes"),
        )
    }

    /// The next stream-read sequence still needed for `stream_id` (the durable
    /// state a handed-off brain carries to resubscribe gaplessly).
    pub fn net_cursor(&self, stream_id: u64) -> Option<u64> {
        self.net_cursors.get(&stream_id).copied()
    }

    /// Re-attach to a session's output from `from_seq` (handoff resume).
    fn subscribe(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {
        self.send(
            KIND_SUBSCRIBE,
            serde_json::to_value(SubscribeReq { session_id, from_seq })
                .expect("SubscribeReq serializes"),
        )
    }

    fn send(&mut self, kind: &str, payload: serde_json::Value) -> io::Result<()> {
        write_frame(&mut self.conn, &Envelope::new(kind, payload))
    }

    fn require_session(&self) -> io::Result<u64> {
        self.session_id
            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no active session"))
    }
}

/// Whether `haystack` contains the contiguous `needle`.
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
    if needle.is_empty() {
        return true;
    }
    haystack.windows(needle.len()).any(|w| w == needle)
}


1	//! D4b regression — gapless, exactly-once QUIC stream survival across brain
2	//! restarts (ADR-0004 §B; Spike #3 invariants A–D as real tests).
3	//!
4	//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
5	//! the two daemons. The invariants:
6	//!
7	//! - **A** — the QUIC connection + stream survive a brain kill on either side
8	//!   (chunks keep flowing past the kill).
9	//! - **B** — the receiving brain's chunk sequence is contiguous (no gap): the
10	//!   broker's ring buffers chunks produced while no brain was attached and
11	//!   replays them from the resume cursor.
12	//! - **C** — a post-restart brain drives the broker-owned QUIC sender (the
13	//!   replayed sends below go through the original broker-held stream).
14	//! - **D** — every chunk is delivered exactly once (no duplicate seq): the
15	//!   receive side dedups by cursor; the send side dedups replayed `op_id`s at
16	//!   the broker-owned journal.
17
18	use std::sync::atomic::{AtomicU32, Ordering};
19	use std::sync::Arc;
20	use std::thread;
21	use std::time::Duration;
22
23	use spt_daemon::brain::{Brain, BrokerEvent};
24	use spt_daemon::nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
25	use spt_daemon::Broker;
26	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
27	use spt_proto::identity::Identity;
28
29	static SEQ: AtomicU32 = AtomicU32::new(0);
30	fn unique_name() -> String {
31	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
32	    format!("spt-daemon-d4b-{}-{}.sock", std::process::id(), n)
33	}
34
35	fn hermetic() -> NetConfig {
36	    NetConfig {
37	        identity: Identity::generate(),
38	        relay: RelayPolicy::Disabled,
39	        discovery: LocalDiscovery::Off,
40	        scope: BindScope::Loopback,
41	    }
42	}
43
44	fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
45	    let host = NetHost::start(hermetic()).expect("net host start");
46	    let broker =
47	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
48	    let serve = Arc::clone(&broker);
49	    thread::spawn(move || {
50	        let _ = serve.serve();
51	    });
52	    broker
53	}
54
55	fn connect_retry(name: &str) -> Brain {
56	    for _ in 0..300 {
57	        if let Ok(b) = Brain::cold_start(name, 1) {
58	            return b;
59	        }
60	        thread::sleep(Duration::from_millis(10));
61	    }
62	    panic!("brain could not connect");
63	}
64
65	/// The chunk for op `n` — distinct, fixed-size, non-substring payloads.
66	fn chunk_for(n: u64) -> Vec<u8> {
67	    format!("<CHUNK-{n:04}>").into_bytes()
68	}
69
70	/// Poll the receiving brain's `net-streams` until a stream appears.
71	fn wait_for_stream(brain: &mut Brain) -> u64 {
72	    for _ in 0..400 {
73	        let reply = brain.net_streams().expect("net-streams");
74	        if let Some(s) = reply.streams.first() {
75	            return s.stream_id;
76	        }
77	        thread::sleep(Duration::from_millis(5));
78	    }
79	    panic!("stream never appeared at the receiver's broker");
80	}
81
82	/// Read stream chunks into `buf` until the cursor reaches `until_seq`.
83	/// Returns on eof too (asserting the cursor reached the target).
84	fn read_until(brain: &mut Brain, stream_id: u64, until_seq: u64, buf: &mut Vec<u8>) {
85	    while brain.net_cursor(stream_id).expect("cursor set") < until_seq {
86	        match brain.read_event().expect("read event") {
87	            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
88	                buf.extend_from_slice(&bytes);
89	            }
90	            BrokerEvent::NetStreamEof { stream_id: sid, next_seq } if sid == stream_id => {
91	                assert_eq!(
92	                    next_seq,
93	                    until_seq,
94	                    "eof before the expected chunk count — a gap the ring should have covered"
95	                );
96	                return;
97	            }
98	            BrokerEvent::Error { message } => panic!("broker error: {message}"),
99	            _ => {}
100	        }
101	    }
102	}
103
104	/// Count non-overlapping occurrences of `needle` in `hay`.
105	fn count(hay: &[u8], needle: &[u8]) -> usize {
106	    if needle.is_empty() || hay.len() < needle.len() {
107	        return 0;
108	    }
109	    let (mut n, mut i) = (0, 0);
110	    while i + needle.len() <= hay.len() {
111	        if &hay[i..i + needle.len()] == needle {
112	            n += 1;
113	            i += needle.len();
114	        } else {
115	            i += 1;
116	        }
117	    }
118	    n
119	}
120
121	// [int->REQ-DAEMON-2] RECEIVER survival (invariants A, B, D receive-side): the
122	// receiving brain is killed mid-stream while the sender keeps writing; the
123	// broker-owned stream + ring buffer the in-flight chunks, and a fresh brain
124	// resumes from its cursor with no gap and no duplicate. The stream finishes
125	// with a clean eof whose final cursor proves completeness.
126	#[test]
127	fn receiver_brain_restart_is_gapless_and_exactly_once() {
128	    const TOTAL: u64 = 20;
129	    const KILL_AT: u64 = 5; // receiver dies after reading this many chunks
130
131	    let dir = tempfile::tempdir().expect("tempdir");
132	    let (name_a, name_b) = (unique_name(), unique_name());
133	    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
134	    let _broker_b = net_broker(&name_b, &dir.path().join("b"));
135
136	    // Sender brain: dial B, open a stream, deliver the first half (journaled).
137	    let mut sender = connect_retry(&name_a);
138	    let mut b_probe = connect_retry(&name_b);
139	    let addr = b_probe.net_status().expect("b status").addr;
140	    let conn = sender.net_dial(addr, Some(1)).expect("dial");
141	    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");
142	    for n in 0..KILL_AT {
143	        sender
144	            .net_stream_send(opened.stream_id, &chunk_for(n), Some(100 + n), false)
145	            .expect("send");
146	    }
147
148	    // Receiver life 1: find the stream, subscribe from 0, read KILL_AT chunks,
149	    // then crash (connection drop). Carry the durable cursor forward.
150	    let b_stream = wait_for_stream(&mut b_probe);
151	    b_probe.net_stream_subscribe(b_stream, 0).expect("subscribe");
152	    let mut buf: Vec<u8> = Vec::new();
153	    read_until(&mut b_probe, b_stream, KILL_AT, &mut buf);
154	    let cursor = b_probe.net_cursor(b_stream).expect("cursor");
155	    assert_eq!(cursor, KILL_AT);
156	    drop(b_probe); // the crash — broker B and its stream survive
157
158	    // Invariant A: the sender keeps delivering INTO the dead window; the
159	    // broker-owned conn/stream are untouched by the receiver's brain death.
160	    for n in KILL_AT..TOTAL {
161	        sender
162	            .net_stream_send(opened.stream_id, &chunk_for(n), Some(100 + n), false)
163	            .expect("send into the dead window");
164	    }
165	    sender
166	        .net_stream_send(opened.stream_id, &[], Some(100 + TOTAL), true)
167	        .expect("finish");
168
169	    // Receiver life 2: re-attach, resubscribe from the durable cursor — the
170	    // ring replays everything missed, then eof confirms the final cursor.
171	    let mut receiver = connect_retry(&name_b);
172	    let streams = receiver.net_streams().expect("net-streams").streams;
173	    assert_eq!(streams.len(), 1, "the stream survived the brain restart");
174	    assert_eq!(streams[0].stream_id, b_stream);
175	    receiver.net_stream_subscribe(b_stream, cursor).expect("resubscribe");
176	    read_until(&mut receiver, b_stream, TOTAL, &mut buf);
177
178	    // Invariants B + D: every chunk exactly once, in order, no gap.
179	    let mut expect: Vec<u8> = Vec::new();
180	    for n in 0..TOTAL {
181	        expect.extend_from_slice(&chunk_for(n));
182	    }
183	    assert_eq!(
184	        buf, expect,
185	        "byte-exact: all chunks exactly once, in order, across the restart"
186	    );
187	}
188
189	// [int->REQ-HAZARD-RESTART-IDEMPOTENT] SENDER survival (invariants A, C, D
190	// send-side): the sending brain crashes mid-delivery and a fresh brain
191	// re-drives the whole durable op sequence — replayed dial/open/send ops are
192	// deduped at the broker-owned journal (no second connection, no second stream,
193	// no duplicate chunk on the wire) while un-applied ops run. The post-restart
194	// brain is driving the ORIGINAL broker-held stream (invariant C).
195	#[test]
196	fn sender_brain_restart_redrive_is_exactly_once() {
197	    const TOTAL: u64 = 8;
198	    const CRASH_AFTER: u64 = 3; // acked sends before the crash
199
200	    let dir = tempfile::tempdir().expect("tempdir");
201	    let (name_a, name_b) = (unique_name(), unique_name());
202	    let broker_a = net_broker(&name_a, &dir.path().join("a"));
203	    let _broker_b = net_broker(&name_b, &dir.path().join("b"));
204
205	    let mut b_probe = connect_retry(&name_b);
206	    let addr = b_probe.net_status().expect("b status").addr;
207
208	    // Sender life 1: dial(1) + open(2) + sends 100..100+CRASH_AFTER, then crash.
209	    let mut life1 = connect_retry(&name_a);
210	    let conn = life1.net_dial(addr.clone(), Some(1)).expect("dial");
211	    let opened1 = life1.net_open_stream(conn.conn_id, Some(2)).expect("open");
212	    for n in 0..CRASH_AFTER {
213	        life1
214	            .net_stream_send(opened1.stream_id, &chunk_for(n), Some(100 + n), false)
215	            .expect("send");
216	    }
217	    drop(life1); // crash before retiring anything from the durable source
218
219	    // Sender life 2: re-drive the WHOLE durable sequence with the same op ids.
220	    let mut life2 = connect_retry(&name_a);
221	    let conn2 = life2.net_dial(addr, Some(1)).expect("redial");
222	    assert!(!conn2.applied_now, "dial replay deduped");
223	    assert_eq!(conn2.conn_id, conn.conn_id, "same broker-held connection (C)");
224	    let opened2 = life2.net_open_stream(conn2.conn_id, Some(2)).expect("reopen");
225	    assert!(!opened2.applied_now, "open replay deduped");
226	    assert_eq!(opened2.stream_id, opened1.stream_id, "same broker-held stream (C)");
227	    let mut deduped = 0;
228	    for n in 0..TOTAL {
229	        let ack = life2
230	            .net_stream_send(opened2.stream_id, &chunk_for(n), Some(100 + n), false)
231	            .expect("re-driven send")
232	            .expect("acked");
233	        if !ack.applied_now {
234	            deduped += 1;
235	        }
236	    }
237	    assert_eq!(deduped as u64, CRASH_AFTER, "exactly the pre-crash sends dedup");
238	    life2
239	        .net_stream_send(opened2.stream_id, &[], Some(100 + TOTAL), true)
240	        .expect("finish");
241
242	    // The journal holds each net op exactly once.
243	    for op in [1u64, 2] {
244	        assert!(broker_a.journal().is_applied((NET_EFFECT_SESSION, op)));
245	    }
246	    for n in 0..=TOTAL {
247	        assert!(broker_a.journal().is_applied((NET_EFFECT_SESSION, 100 + n)));
248	    }
249
250	    // Receiver: read to eof; every chunk arrived exactly once (invariant D on
251	    // the wire, not just in the journal).
252	    let stream = wait_for_stream(&mut b_probe);
253	    b_probe.net_stream_subscribe(stream, 0).expect("subscribe");
254	    let mut buf: Vec<u8> = Vec::new();
255	    read_until(&mut b_probe, stream, TOTAL, &mut buf);
256	    for n in 0..TOTAL {
257	        assert_eq!(
258	            count(&buf, &chunk_for(n)),
259	            1,
260	            "chunk {n} must cross the wire exactly once\nbuf={:?}",
261	            String::from_utf8_lossy(&buf)
262	        );
263	    }
264	}
265

//! D4b regression — gapless, exactly-once QUIC stream survival across brain
//! restarts (ADR-0004 §B; Spike #3 invariants A–D as real tests).
//!
//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
//! the two daemons. The invariants:
//!
//! - **A** — the QUIC connection + stream survive a brain kill on either side
//!   (chunks keep flowing past the kill).
//! - **B** — the receiving brain's chunk sequence is contiguous (no gap): the
//!   broker's ring buffers chunks produced while no brain was attached and
//!   replays them from the resume cursor.
//! - **C** — a post-restart brain drives the broker-owned QUIC sender (the
//!   replayed sends below go through the original broker-held stream).
//! - **D** — every chunk is delivered exactly once (no duplicate seq): the
//!   receive side dedups by cursor; the send side dedups replayed `op_id`s at
//!   the broker-owned journal.

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_daemon::brain::{Brain, BrokerEvent};
use spt_daemon::nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
use spt_daemon::Broker;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_proto::identity::Identity;

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-d4b-{}-{}.sock", std::process::id(), n)
}

fn hermetic() -> NetConfig {
    NetConfig {
        identity: Identity::generate(),
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
    }
}

fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic()).expect("net host start");
    let broker =
        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

fn connect_retry(name: &str) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain could not connect");
}

/// The chunk for op `n` — distinct, fixed-size, non-substring payloads.
fn chunk_for(n: u64) -> Vec<u8> {
    format!("<CHUNK-{n:04}>").into_bytes()
}

/// Poll the receiving brain's `net-streams` until a stream appears.
fn wait_for_stream(brain: &mut Brain) -> u64 {
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.first() {
            return s.stream_id;
        }
        thread::sleep(Duration::from_millis(5));
    }
    panic!("stream never appeared at the receiver's broker");
}

/// Read stream chunks into `buf` until the cursor reaches `until_seq`.
/// Returns on eof too (asserting the cursor reached the target).
fn read_until(brain: &mut Brain, stream_id: u64, until_seq: u64, buf: &mut Vec<u8>) {
    while brain.net_cursor(stream_id).expect("cursor set") < until_seq {
        match brain.read_event().expect("read event") {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                buf.extend_from_slice(&bytes);
            }
            BrokerEvent::NetStreamEof { stream_id: sid, next_seq } if sid == stream_id => {
                assert_eq!(
                    next_seq,
                    until_seq,
                    "eof before the expected chunk count — a gap the ring should have covered"
                );
                return;
            }
            BrokerEvent::Error { message } => panic!("broker error: {message}"),
            _ => {}
        }
    }
}

/// Count non-overlapping occurrences of `needle` in `hay`.
fn count(hay: &[u8], needle: &[u8]) -> usize {
    if needle.is_empty() || hay.len() < needle.len() {
        return 0;
    }
    let (mut n, mut i) = (0, 0);
    while i + needle.len() <= hay.len() {
        if &hay[i..i + needle.len()] == needle {
            n += 1;
            i += needle.len();
        } else {
            i += 1;
        }
    }
    n
}

// [int->REQ-DAEMON-2] RECEIVER survival (invariants A, B, D receive-side): the
// receiving brain is killed mid-stream while the sender keeps writing; the
// broker-owned stream + ring buffer the in-flight chunks, and a fresh brain
// resumes from its cursor with no gap and no duplicate. The stream finishes
// with a clean eof whose final cursor proves completeness.
#[test]
fn receiver_brain_restart_is_gapless_and_exactly_once() {
    const TOTAL: u64 = 20;
    const KILL_AT: u64 = 5; // receiver dies after reading this many chunks

    let dir = tempfile::tempdir().expect("tempdir");
    let (name_a, name_b) = (unique_name(), unique_name());
    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
    let _broker_b = net_broker(&name_b, &dir.path().join("b"));

    // Sender brain: dial B, open a stream, deliver the first half (journaled).
    let mut sender = connect_retry(&name_a);
    let mut b_probe = connect_retry(&name_b);
    let addr = b_probe.net_status().expect("b status").addr;
    let conn = sender.net_dial(addr, Some(1)).expect("dial");
    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");
    for n in 0..KILL_AT {
        sender
            .net_stream_send(opened.stream_id, &chunk_for(n), Some(100 + n), false)
            .expect("send");
    }

    // Receiver life 1: find the stream, subscribe from 0, read KILL_AT chunks,
    // then crash (connection drop). Carry the durable cursor forward.
    let b_stream = wait_for_stream(&mut b_probe);
    b_probe.net_stream_subscribe(b_stream, 0).expect("subscribe");
    let mut buf: Vec<u8> = Vec::new();
    read_until(&mut b_probe, b_stream, KILL_AT, &mut buf);
    let cursor = b_probe.net_cursor(b_stream).expect("cursor");
    assert_eq!(cursor, KILL_AT);
    drop(b_probe); // the crash — broker B and its stream survive

    // Invariant A: the sender keeps delivering INTO the dead window; the
    // broker-owned conn/stream are untouched by the receiver's brain death.
    for n in KILL_AT..TOTAL {
        sender
            .net_stream_send(opened.stream_id, &chunk_for(n), Some(100 + n), false)
            .expect("send into the dead window");
    }
    sender
        .net_stream_send(opened.stream_id, &[], Some(100 + TOTAL), true)
        .expect("finish");

    // Receiver life 2: re-attach, resubscribe from the durable cursor — the
    // ring replays everything missed, then eof confirms the final cursor.
    let mut receiver = connect_retry(&name_b);
    let streams = receiver.net_streams().expect("net-streams").streams;
    assert_eq!(streams.len(), 1, "the stream survived the brain restart");
    assert_eq!(streams[0].stream_id, b_stream);
    receiver.net_stream_subscribe(b_stream, cursor).expect("resubscribe");
    read_until(&mut receiver, b_stream, TOTAL, &mut buf);

    // Invariants B + D: every chunk exactly once, in order, no gap.
    let mut expect: Vec<u8> = Vec::new();
    for n in 0..TOTAL {
        expect.extend_from_slice(&chunk_for(n));
    }
    assert_eq!(
        buf, expect,
        "byte-exact: all chunks exactly once, in order, across the restart"
    );
}

// [int->REQ-HAZARD-RESTART-IDEMPOTENT] SENDER survival (invariants A, C, D
// send-side): the sending brain crashes mid-delivery and a fresh brain
// re-drives the whole durable op sequence — replayed dial/open/send ops are
// deduped at the broker-owned journal (no second connection, no second stream,
// no duplicate chunk on the wire) while un-applied ops run. The post-restart
// brain is driving the ORIGINAL broker-held stream (invariant C).
#[test]
fn sender_brain_restart_redrive_is_exactly_once() {
    const TOTAL: u64 = 8;
    const CRASH_AFTER: u64 = 3; // acked sends before the crash

    let dir = tempfile::tempdir().expect("tempdir");
    let (name_a, name_b) = (unique_name(), unique_name());
    let broker_a = net_broker(&name_a, &dir.path().join("a"));
    let _broker_b = net_broker(&name_b, &dir.path().join("b"));

    let mut b_probe = connect_retry(&name_b);
    let addr = b_probe.net_status().expect("b status").addr;

    // Sender life 1: dial(1) + open(2) + sends 100..100+CRASH_AFTER, then crash.
    let mut life1 = connect_retry(&name_a);
    let conn = life1.net_dial(addr.clone(), Some(1)).expect("dial");
    let opened1 = life1.net_open_stream(conn.conn_id, Some(2)).expect("open");
    for n in 0..CRASH_AFTER {
        life1
            .net_stream_send(opened1.stream_id, &chunk_for(n), Some(100 + n), false)
            .expect("send");
    }
    drop(life1); // crash before retiring anything from the durable source

    // Sender life 2: re-drive the WHOLE durable sequence with the same op ids.
    let mut life2 = connect_retry(&name_a);
    let conn2 = life2.net_dial(addr, Some(1)).expect("redial");
    assert!(!conn2.applied_now, "dial replay deduped");
    assert_eq!(conn2.conn_id, conn.conn_id, "same broker-held connection (C)");
    let opened2 = life2.net_open_stream(conn2.conn_id, Some(2)).expect("reopen");
    assert!(!opened2.applied_now, "open replay deduped");
    assert_eq!(opened2.stream_id, opened1.stream_id, "same broker-held stream (C)");
    let mut deduped = 0;
    for n in 0..TOTAL {
        let ack = life2
            .net_stream_send(opened2.stream_id, &chunk_for(n), Some(100 + n), false)
            .expect("re-driven send")
            .expect("acked");
        if !ack.applied_now {
            deduped += 1;
        }
    }
    assert_eq!(deduped as u64, CRASH_AFTER, "exactly the pre-crash sends dedup");
    life2
        .net_stream_send(opened2.stream_id, &[], Some(100 + TOTAL), true)
        .expect("finish");

    // The journal holds each net op exactly once.
    for op in [1u64, 2] {
        assert!(broker_a.journal().is_applied((NET_EFFECT_SESSION, op)));
    }
    for n in 0..=TOTAL {
        assert!(broker_a.journal().is_applied((NET_EFFECT_SESSION, 100 + n)));
    }

    // Receiver: read to eof; every chunk arrived exactly once (invariant D on
    // the wire, not just in the journal).
    let stream = wait_for_stream(&mut b_probe);
    b_probe.net_stream_subscribe(stream, 0).expect("subscribe");
    let mut buf: Vec<u8> = Vec::new();
    read_until(&mut b_probe, stream, TOTAL, &mut buf);
    for n in 0..TOTAL {
        assert_eq!(
            count(&buf, &chunk_for(n)),
            1,
            "chunk {n} must cross the wire exactly once\nbuf={:?}",
            String::from_utf8_lossy(&buf)
        );
    }
}


Read net endpoint wrapper — need `Connection::closed()` availability.


Grep

closed|pub struct Connection|impl Connection|pub fn


208:    pub fn node_id(&self) -> PublicKey {
214:    pub fn node_id_hex(&self) -> String {
220:    pub fn addr(&self) -> EndpointAddr {
232:    /// `None` once the endpoint is closed.
269:    pub fn iroh(&self) -> &Endpoint {
308:            conn.closed().await;


180	                .map_err(|e| NetError::Bind(e.to_string()))?
181	                .portmapper_config(PortmapperConfig::Disabled),
182	        };
183	        // The N0 preset already wires the n0 relay default; only override it when the
184	        // policy asks for self-host or disabled.
185	        let builder = match relay {
186	            RelayPolicy::N0Default => builder,
187	            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
188	            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
189	        };
190	        // mDNS rides alongside the preset's n0 DNS discovery as an extra address
191	        // lookup, giving LAN-local advertise + resolve-by-id.
192	        let builder = match discovery {
193	            LocalDiscovery::Mdns => {
194	                builder.address_lookup(MdnsAddressLookup::builder().advertise(true))
195	            }
196	            LocalDiscovery::Off => builder,
197	        };
198	        let inner = builder
199	            .bind()
200	            .await
201	            .map_err(|e| NetError::Bind(e.to_string()))?;
202	        Ok(NetEndpoint { inner })
203	    }
204
205	    /// This node's public key — its stable subnet name, derived from the live
206	    /// endpoint id. Always a valid ed25519 point (iroh guarantees it).
207	    // [impl->REQ-NET-1]
208	    pub fn node_id(&self) -> PublicKey {
209	        PublicKey::from_bytes(self.inner.id().as_bytes())
210	            .expect("an iroh EndpointId is always a valid ed25519 public key")
211	    }
212
213	    /// The node's stable name as lowercase hex (the `CONTEXT.md` SptNode string).
214	    pub fn node_id_hex(&self) -> String {
215	        self.node_id().to_hex()
216	    }
217
218	    /// This endpoint's dialable address (id + currently-known direct/relay paths).
219	    /// Serialise it (serde) to hand a peer a connect ticket.
220	    pub fn addr(&self) -> EndpointAddr {
221	        self.inner.addr()
222	    }
223
224	    /// Wait until the endpoint has registered with its relays — needed before the
225	    /// [`addr`](Self::addr) ticket is dialable from off-LAN. Not required for a
226	    /// direct/loopback dial where the peer already has a direct address.
227	    pub async fn online(&self) {
228	        self.inner.online().await;
229	    }
230
231	    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
232	    /// `None` once the endpoint is closed.
233	    pub async fn accept(&self) -> Option<Incoming> {
234	        self.inner.accept().await
235	    }
236
237	    /// Dial a peer by address over [`SPT_NET_ALPN`].
238	    // [impl->REQ-NET-1]
239	    pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {
240	        self.connect_with_alpn(addr, SPT_NET_ALPN).await
241	    }
242
243	    /// Dial a peer over an explicit ALPN (the pairing driver dials `SPT_PAIR_ALPN`).
244	    // [impl->REQ-NET-1]
245	    pub async fn connect_with_alpn(
246	        &self,
247	        addr: EndpointAddr,
248	        alpn: &[u8],
249	    ) -> Result<Connection, NetError> {
250	        self.inner
251	            .connect(addr, alpn)
252	            .await
253	            .map_err(|e| NetError::Connect(e.to_string()))
254	    }
255
256	    /// Dial a peer by its node id (public key) alone, letting discovery (mDNS on the
257	    /// LAN, n0 DNS on the WAN) resolve the address. This is the "no ticket needed"
258	    /// path: with [`LocalDiscovery::Mdns`] enabled, two machines on one LAN connect
259	    /// knowing only each other's stable name.
260	    // [impl->REQ-NET-1]
261	    pub async fn connect_by_id(&self, peer: PublicKey) -> Result<Connection, NetError> {
262	        let id = EndpointId::from_bytes(&peer.to_bytes())
263	            .map_err(|e| NetError::Connect(format!("invalid peer id: {e}")))?;
264	        self.connect(EndpointAddr::new(id)).await
265	    }
266
267	    /// Borrow the underlying iroh endpoint — the seam the D4 broker uses to own the
268	    /// endpoint + its live QUIC streams across brain restarts (ADR-0004 §B).
269	    pub fn iroh(&self) -> &Endpoint {
270	        &self.inner
271	    }
272	}
273
274	#[cfg(test)]
275	mod tests {
276	    use super::*;
277
278	    /// Bind a fully hermetic endpoint: relays disabled, no discovery, loopback
279	    /// sockets only (no real interface touched, no Windows firewall prompt).
280	    async fn bind_hermetic(id: &Identity) -> NetEndpoint {
281	        NetEndpoint::bind_scoped(
282	            id,
283	            RelayPolicy::Disabled,
284	            LocalDiscovery::Off,
285	            vec![SPT_NET_ALPN.to_vec()],
286	            BindScope::Loopback,
287	        )
288	        .await
289	        .expect("hermetic bind")
290	    }
291
292	    /// Accept exactly one connection on `server` and echo a single bidi request as
293	    /// `echo:<req>`. Shared by the loopback + mDNS tests.
294	    fn spawn_echo_once(server: NetEndpoint) -> tokio::task::JoinHandle<()> {
295	        tokio::spawn(async move {
296	            let incoming = server.accept().await.expect("incoming");
297	            let conn = incoming
298	                .accept()
299	                .expect("accept")
300	                .await
301	                .expect("connecting");
302	            let (mut send, mut recv) = conn.accept_bi().await.expect("accept_bi");
303	            let req = recv.read_to_end(1024).await.expect("read req");
304	            let mut echo = b"echo:".to_vec();
305	            echo.extend_from_slice(&req);
306	            send.write_all(&echo).await.expect("write echo");
307	            send.finish().expect("finish");
308	            conn.closed().await;
309	        })
310	    }
311
312	    /// Drive one ping→`echo:ping` round-trip over an established connection.
313	    async fn ping_echo(conn: &Connection) {
314	        let (mut send, mut recv) = conn.open_bi().await.expect("open_bi");
315	        send.write_all(b"ping").await.expect("write ping");
316	        send.finish().expect("finish");
317	        let resp = recv.read_to_end(1024).await.expect("read resp");
318	        assert_eq!(resp, b"echo:ping", "bidi echo over QUIC");
319	    }
320
321	    // [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the
322	    // iroh EndpointId equals the node's public key (same seed → same keypair).
323	    #[tokio::test]
324	    async fn endpoint_id_binds_to_identity() {
325	        let id = Identity::generate();
326	        let ep = bind_hermetic(&id).await;
327	        assert_eq!(
328	            ep.node_id(),
329	            id.public_key(),
330	            "endpoint id must equal node pubkey"
331	        );
332	        assert_eq!(ep.node_id_hex(), id.public_key().to_hex());
333	    }
334
335	    // [unit->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
336	    // QUIC with relays DISABLED, no discovery, and loopback-only sockets — fully
337	    // hermetic (no network), proving the ALPN + connect/accept path by direct
338	    // address. (The cross-node WAN `int` stage is the two-host E2E at D9; this is
339	    // the component-level evidence.)

                .map_err(|e| NetError::Bind(e.to_string()))?
                .portmapper_config(PortmapperConfig::Disabled),
        };
        // The N0 preset already wires the n0 relay default; only override it when the
        // policy asks for self-host or disabled.
        let builder = match relay {
            RelayPolicy::N0Default => builder,
            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
        };
        // mDNS rides alongside the preset's n0 DNS discovery as an extra address
        // lookup, giving LAN-local advertise + resolve-by-id.
        let builder = match discovery {
            LocalDiscovery::Mdns => {
                builder.address_lookup(MdnsAddressLookup::builder().advertise(true))
            }
            LocalDiscovery::Off => builder,
        };
        let inner = builder
            .bind()
            .await
            .map_err(|e| NetError::Bind(e.to_string()))?;
        Ok(NetEndpoint { inner })
    }

    /// This node's public key — its stable subnet name, derived from the live
    /// endpoint id. Always a valid ed25519 point (iroh guarantees it).
    // [impl->REQ-NET-1]
    pub fn node_id(&self) -> PublicKey {
        PublicKey::from_bytes(self.inner.id().as_bytes())
            .expect("an iroh EndpointId is always a valid ed25519 public key")
    }

    /// The node's stable name as lowercase hex (the `CONTEXT.md` SptNode string).
    pub fn node_id_hex(&self) -> String {
        self.node_id().to_hex()
    }

    /// This endpoint's dialable address (id + currently-known direct/relay paths).
    /// Serialise it (serde) to hand a peer a connect ticket.
    pub fn addr(&self) -> EndpointAddr {
        self.inner.addr()
    }

    /// Wait until the endpoint has registered with its relays — needed before the
    /// [`addr`](Self::addr) ticket is dialable from off-LAN. Not required for a
    /// direct/loopback dial where the peer already has a direct address.
    pub async fn online(&self) {
        self.inner.online().await;
    }

    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
    /// `None` once the endpoint is closed.
    pub async fn accept(&self) -> Option<Incoming> {
        self.inner.accept().await
    }

    /// Dial a peer by address over [`SPT_NET_ALPN`].
    // [impl->REQ-NET-1]
    pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {
        self.connect_with_alpn(addr, SPT_NET_ALPN).await
    }

    /// Dial a peer over an explicit ALPN (the pairing driver dials `SPT_PAIR_ALPN`).
    // [impl->REQ-NET-1]
    pub async fn connect_with_alpn(
        &self,
        addr: EndpointAddr,
        alpn: &[u8],
    ) -> Result<Connection, NetError> {
        self.inner
            .connect(addr, alpn)
            .await
            .map_err(|e| NetError::Connect(e.to_string()))
    }

    /// Dial a peer by its node id (public key) alone, letting discovery (mDNS on the
    /// LAN, n0 DNS on the WAN) resolve the address. This is the "no ticket needed"
    /// path: with [`LocalDiscovery::Mdns`] enabled, two machines on one LAN connect
    /// knowing only each other's stable name.
    // [impl->REQ-NET-1]
    pub async fn connect_by_id(&self, peer: PublicKey) -> Result<Connection, NetError> {
        let id = EndpointId::from_bytes(&peer.to_bytes())
            .map_err(|e| NetError::Connect(format!("invalid peer id: {e}")))?;
        self.connect(EndpointAddr::new(id)).await
    }

    /// Borrow the underlying iroh endpoint — the seam the D4 broker uses to own the
    /// endpoint + its live QUIC streams across brain restarts (ADR-0004 §B).
    pub fn iroh(&self) -> &Endpoint {
        &self.inner
    }
}

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

    /// Bind a fully hermetic endpoint: relays disabled, no discovery, loopback
    /// sockets only (no real interface touched, no Windows firewall prompt).
    async fn bind_hermetic(id: &Identity) -> NetEndpoint {
        NetEndpoint::bind_scoped(
            id,
            RelayPolicy::Disabled,
            LocalDiscovery::Off,
            vec![SPT_NET_ALPN.to_vec()],
            BindScope::Loopback,
        )
        .await
        .expect("hermetic bind")
    }

    /// Accept exactly one connection on `server` and echo a single bidi request as
    /// `echo:<req>`. Shared by the loopback + mDNS tests.
    fn spawn_echo_once(server: NetEndpoint) -> tokio::task::JoinHandle<()> {
        tokio::spawn(async move {
            let incoming = server.accept().await.expect("incoming");
            let conn = incoming
                .accept()
                .expect("accept")
                .await
                .expect("connecting");
            let (mut send, mut recv) = conn.accept_bi().await.expect("accept_bi");
            let req = recv.read_to_end(1024).await.expect("read req");
            let mut echo = b"echo:".to_vec();
            echo.extend_from_slice(&req);
            send.write_all(&echo).await.expect("write echo");
            send.finish().expect("finish");
            conn.closed().await;
        })
    }

    /// Drive one ping→`echo:ping` round-trip over an established connection.
    async fn ping_echo(conn: &Connection) {
        let (mut send, mut recv) = conn.open_bi().await.expect("open_bi");
        send.write_all(b"ping").await.expect("write ping");
        send.finish().expect("finish");
        let resp = recv.read_to_end(1024).await.expect("read resp");
        assert_eq!(resp, b"echo:ping", "bidi echo over QUIC");
    }

    // [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the
    // iroh EndpointId equals the node's public key (same seed → same keypair).
    #[tokio::test]
    async fn endpoint_id_binds_to_identity() {
        let id = Identity::generate();
        let ep = bind_hermetic(&id).await;
        assert_eq!(
            ep.node_id(),
            id.public_key(),
            "endpoint id must equal node pubkey"
        );
        assert_eq!(ep.node_id_hex(), id.public_key().to_hex());
    }

    // [unit->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
    // QUIC with relays DISABLED, no discovery, and loopback-only sockets — fully
    // hermetic (no network), proving the ALPN + connect/accept path by direct
    // address. (The cross-node WAN `int` stage is the two-host E2E at D9; this is
    // the component-level evidence.)


Plan clear. Track tasks, then implement.


ToolSearch

Question: select:TaskCreate,TaskUpdate


Question: select:TaskCreate,TaskUpdate


25:iroh = { version = "=0.98.2", optional = true, features = ["address-lookup-mdns"] }


TaskCreate

D4c: activate REQ-EP-4 + PresenceLog/closed-watcher in nethost

traceable-reqs.toml REQ-EP-4 required_stages=["impl","unit"]; nethost.rs: PresenceLog (seq'd ring mirroring StreamLog), connected event on register_conn, closed-watcher task (conn.closed() → remove conn row → disconnected event), subscribe/detach API, NetHost::close() + NetEndpoint::close()


Task #1 created successfully: D4c: activate REQ-EP-4 + PresenceLog/closed-watcher in nethost


D4c: presence IPC frames + broker dispatch + brain cursor

msg.rs: KIND_NET_PRESENCE_SUBSCRIBE/KIND_NET_PRESENCE_EVENT + payloads + envelope helper; broker.rs dispatch + disconnect detach; brain.rs presence cursor discipline (accept contiguous/drop dup/gap error) + net_presence_subscribe

Wiring presence frames through broker/brain


Task #2 created successfully: D4c: presence IPC frames + broker dispatch + brain cursor


D4c: presence test + traceable-reqs check + CI green

tests/presence.rs: broker B + standalone droppable NetHost A; A dials → connected; close/drop A → disconnected + conn row removed; brain-restart resume from cursor. Run tests, clippy, traceable-reqs check from repo root, commit, verify CI conclusion via gh run list


Task #3 created successfully: D4c: presence test + traceable-reqs check + CI green


Updated task #1 status


Edit

id = "REQ-EP-4"
title = "PresenceChannel broker endpoint (seam day-one)"
required_stages = []

id = "REQ-EP-4"
title = "PresenceChannel broker endpoint (seam day-one)"
required_stages = ["impl", "unit"]  # activated M4-D4c: the broker-owned PresenceLog — a seq'd ring of {connected|disconnected, conn_id, remote_id_hex} events mirroring the StreamLog discipline. register_conn appends connected + spawns a closed-watcher (conn.closed() → conn row removed → disconnected appended), so the conn table finally reflects liveness; the brain subscribes via net-presence-subscribe {from_seq} and consumes net-presence-event under the same contiguous/dedup/gap cursor discipline as streams (brain-restart cursor-resume). The full PresenceChannel *endpoint* (agent-visible channel) builds on this broker seam later; int = two-host at D9.


The file C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

id = "REQ-EP-4"
title = "PresenceChannel broker endpoint (seam day-one)"
required_stages = []

id = "REQ-EP-4"
title = "PresenceChannel broker endpoint (seam day-one)"
required_stages = ["impl", "unit"]  # activated M4-D4c: the broker-owned PresenceLog — a seq'd ring of {connected|disconnected, conn_id, remote_id_hex} events mirroring the StreamLog discipline. register_conn appends connected + spawns a closed-watcher (conn.closed() → conn row removed → disconnected appended), so the conn table finally reflects liveness; the brain subscribes via net-presence-subscribe {from_seq} and consumes net-presence-event under the same contiguous/dedup/gap cursor discipline as streams (brain-restart cursor-resume). The full PresenceChannel *endpoint* (agent-visible channel) builds on this broker seam later; int = two-host at D9.

+required_stages = ["impl", "unit"]  # activated M4-D4c: the broker-owned PresenceLog — a seq'd ring of {connected|disconnected, conn_id, remote_id_hex} events mirroring the StreamLog discipline. register_conn appends connected + spawns a closed-watcher (conn.closed() → conn row removed → disconnected appended), so the conn table finally reflects liveness; the brain subscribes via net-presence-subscribe {from_seq} and consumes net-presence-event under the same contiguous/dedup/gap cursor discipline as streams (brain-restart cursor-resume). The full PresenceChannel *endpoint* (agent-visible channel) builds on this broker seam later; int = two-host at D9.

title = "Concrete shell instantiation model: spawn-mints-instance (vs relink/online), registered-on-node permission + broadcast-is-discovery, per-shell require_approval gate, max_instances_per_owner + over_cap, instance aliasing, discovery scope"


Add `NetEndpoint::close`:


    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
    /// `None` once the endpoint is closed.
    pub async fn accept(&self) -> Option<Incoming> {
        self.inner.accept().await
    }

    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
    /// `None` once the endpoint is closed.
    pub async fn accept(&self) -> Option<Incoming> {
        self.inner.accept().await
    }

    /// Gracefully close the endpoint: every held connection sends its QUIC
    /// CLOSE frame so peers observe the disconnect promptly (a bare drop may
    /// tear the runtime down before the close ever flushes, leaving peers to
    /// an idle timeout). The D4c presence seam relies on this for prompt
    /// disconnected events.
    // [impl->REQ-NET-1]
    pub async fn close(&self) {
        self.inner.close().await;
    }


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\endpoint.rs has been updated successfully. (file state is current in your context — no need to Read it back)

    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
    /// `None` once the endpoint is closed.
    pub async fn accept(&self) -> Option<Incoming> {
        self.inner.accept().await
    }

    /// Gracefully close the endpoint: every held connection sends its QUIC
    /// CLOSE frame so peers observe the disconnect promptly (a bare drop may
    /// tear the runtime down before the close ever flushes, leaving peers to
    /// an idle timeout). The D4c presence seam relies on this for prompt
    /// disconnected events.
    // [impl->REQ-NET-1]
    pub async fn close(&self) {
        self.inner.close().await;
    }

+    /// Gracefully close the endpoint: every held connection sends its QUIC

+    /// CLOSE frame so peers observe the disconnect promptly (a bare drop may

+    /// tear the runtime down before the close ever flushes, leaving peers to

+    /// an idle timeout). The D4c presence seam relies on this for prompt

/// Dial a peer by address over [`SPT_NET_ALPN`].

pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {


Now nethost.rs — PresenceLog + closed-watcher:


use crate::broker::SharedSend;
use crate::codec::write_frame;
use crate::msg::{net_stream_data_envelope, net_stream_eof_envelope, NetStreamInfo};

use crate::broker::SharedSend;
use crate::codec::write_frame;
use crate::msg::{
    net_presence_event_envelope, net_stream_data_envelope, net_stream_eof_envelope,
    NetPresenceEvent, NetStreamInfo, PRESENCE_CONNECTED, PRESENCE_DISCONNECTED,
};


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\nethost.rs has been updated successfully. (file state is current in your context — no need to Read it back)

use crate::broker::SharedSend;
use crate::codec::write_frame;
use crate::msg::{net_stream_data_envelope, net_stream_eof_envelope, NetStreamInfo};

use crate::broker::SharedSend;
use crate::codec::write_frame;
use crate::msg::{
    net_presence_event_envelope, net_stream_data_envelope, net_stream_eof_envelope,
    NetPresenceEvent, NetStreamInfo, PRESENCE_CONNECTED, PRESENCE_DISCONNECTED,
};

-use crate::msg::{net_stream_data_envelope, net_stream_eof_envelope, NetStreamInfo};

+    net_presence_event_envelope, net_stream_data_envelope, net_stream_eof_envelope,

+    NetPresenceEvent, NetStreamInfo, PRESENCE_CONNECTED, PRESENCE_DISCONNECTED,

/// The reserved [`crate::effect::EffectKey`] session namespace for net-scoped

/// effects (a dial has no PTY session). Broker session ids are minted from 1