1	//! D5b regression — remote terminal attach (REQ-INST-8): drive a real PTY
2	//! session on daemon A from daemon B, byte-stream viewport over the
3	//! broker-owned wire; compute stays on A.
4	//!
5	//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
6	//! the two daemons; a real echo child runs under A's broker PTY. The
7	//! invariants:
8	//!
9	//! - **remote drive**: keystrokes sent from B arrive at A's PTY through the
10	//!   journaled input path, and the session's output streams back to B as
11	//!   sequenced records — B sees its own marker echoed by A's child;
12	//! - **origin is transport truth** (KNOWN-HAZARDS 7.5): the serve loop gates
13	//!   on the stream table's handshake-proven `remote_id_hex`;
14	//! - **restart survival** (REQ-HAZARD-RESTART-IDEMPOTENT): A's brain dies
15	//!   mid-attach; the broker keeps the QUIC stream, the session, the output
16	//!   log, and the effect journal. The successor brain re-serves the SAME
17	//!   stream from seq 0 — the worst-case stale cursor: the replayed input
18	//!   dedups at the PTY-write journal (typed exactly once), re-transmitted
19	//!   output dedups at B's render cursor (rendered exactly once) — including
20	//!   input B sent INTO the dead window.
21
22	use std::sync::atomic::{AtomicU32, Ordering};
23	use std::sync::{Arc, OnceLock};
24	use std::thread;
25	use std::time::Duration;
26
27	use spt_daemon::attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
28	use spt_daemon::brain::{Brain, BrokerEvent};
29	use spt_daemon::msg::{decode_bytes, SpawnReq};
30	use spt_daemon::nethost::{NetConfig, NetHost};
31	use spt_daemon::Broker;
32	use spt_net::net::attach::{AttachDecoder, AttachRecord};
33	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
34	use spt_net::net::ndjson;
35	use spt_proto::identity::Identity;
36
37	/// Hold the temp home for the whole test binary so it isn't deleted mid-run.
38	/// Every test inits it: serve_attach best-effort-stamps the endpoint perch's
39	/// `driven_by` marker, and that resolution must never touch a real home.
40	static HOME: OnceLock<tempfile::TempDir> = OnceLock::new();
41
42	fn init_home() {
43	    HOME.get_or_init(|| {
44	        let dir = tempfile::TempDir::new().expect("temp dir");
45	        std::env::set_var("SPT_HOME", dir.path());
46	        dir
47	    });
48	}
49
50	static SEQ: AtomicU32 = AtomicU32::new(0);
51	fn unique_name() -> String {
52	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
53	    format!("spt-daemon-d5b-{}-{}.sock", std::process::id(), n)
54	}
55
56	fn hermetic() -> NetConfig {
57	    NetConfig {
58	        identity: Identity::generate(),
59	        relay: RelayPolicy::Disabled,
60	        discovery: LocalDiscovery::Off,
61	        scope: BindScope::Loopback,
62	        membership: None,
63	    }
64	}
65
66	fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
67	    let host = NetHost::start(hermetic()).expect("net host start");
68	    let broker =
69	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
70	    let serve = Arc::clone(&broker);
71	    thread::spawn(move || {
72	        let _ = serve.serve();
73	    });
74	    broker
75	}
76
77	fn connect_retry(name: &str) -> Brain {
78	    for _ in 0..300 {
79	        if let Ok(b) = Brain::cold_start(name, 1) {
80	            return b;
81	        }
82	        thread::sleep(Duration::from_millis(10));
83	    }
84	    panic!("brain could not connect");
85	}
86
87	fn echo_spawn_req() -> SpawnReq {
88	    #[cfg(unix)]
89	    let (program, args) = ("cat".to_string(), Vec::<String>::new());
90	    #[cfg(windows)]
91	    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
92	    SpawnReq {
93	        program,
94	        args,
95	        rows: 24,
96	        cols: 80,
97	        endpoint: String::new(),
98	    }
99	}
100
101	/// Poll the brain's `net-streams` until a stream appears; return
102	/// `(stream_id, remote_id_hex)`.
103	fn wait_for_stream(brain: &mut Brain) -> (u64, String) {
104	    for _ in 0..400 {
105	        let reply = brain.net_streams().expect("net-streams");
106	        if let Some(s) = reply.streams.first() {
107	            return (s.stream_id, s.remote_id_hex.clone());
108	        }
109	        thread::sleep(Duration::from_millis(5));
110	    }
111	    panic!("attach stream never appeared at the target's broker");
112	}
113
114	/// Operator-side viewport: consume attach-stream records, dedup `Output` by
115	/// seq, append rendered bytes to `buf`, until `needle` is rendered. Returns
116	/// the rendered-through cursor (last accepted seq + 1).
117	fn render_until(
118	    brain: &mut Brain,
119	    stream_id: u64,
120	    decoder: &mut AttachDecoder,
121	    next_seq: &mut u64,
122	    buf: &mut Vec<u8>,
123	    needle: &[u8],
124	) {
125	    let contains = |hay: &[u8], needle: &[u8]| hay.windows(needle.len()).any(|w| w == needle);
126	    while !contains(buf, needle) {
127	        match brain.read_event().expect("operator read event") {
128	            BrokerEvent::NetStreamData {
129	                stream_id: sid,
130	                bytes,
131	                ..
132	            } if sid == stream_id => {
133	                for rec in decoder.push(&bytes) {
134	                    if let AttachRecord::Output { seq, data_b64 } = rec {
135	                        if seq < *next_seq {
136	                            continue; // replay duplicate — dedup by cursor
137	                        }
138	                        assert_eq!(seq, *next_seq, "no forward gap in the viewport");
139	                        *next_seq = seq + 1;
140	                        buf.extend_from_slice(&decode_bytes(&data_b64).expect("chunk b64"));
141	                    }
142	                }
143	            }
144	            BrokerEvent::NetStreamEof { .. } => panic!("stream ended before the marker rendered"),
145	            BrokerEvent::Error { message } => panic!("operator broker error: {message}"),
146	            _ => {}
147	        }
148	    }
149	}
150
151	/// The prefix of `hay` through (and including) the first occurrence of
152	/// `needle` — the deterministic comparison window when two readers share a
153	/// "stop once the buffer contains the needle" rule but batch events
154	/// differently (each side's overshoot past the needle is chunking
155	/// nondeterminism, not a delivery difference).
156	fn prefix_through<'a>(hay: &'a [u8], needle: &[u8]) -> &'a [u8] {
157	    let pos = hay
158	        .windows(needle.len())
159	        .position(|w| w == needle)
160	        .expect("the stop rule guarantees the needle rendered");
161	    &hay[..pos + needle.len()]
162	}
163
164	/// Count non-overlapping occurrences of `needle` in `hay`.
165	fn count(hay: &[u8], needle: &[u8]) -> usize {
166	    if needle.is_empty() || hay.len() < needle.len() {
167	        return 0;
168	    }
169	    let (mut n, mut i) = (0, 0);
170	    while i + needle.len() <= hay.len() {
171	        if &hay[i..i + needle.len()] == needle {
172	            n += 1;
173	            i += needle.len();
174	        } else {
175	            i += 1;
176	        }
177	    }
178	    n
179	}
180
181	// [int->REQ-RC-1] loopback self-dial is REFUSED by the transport — pins the
182	// finding that drives `spt rc`'s LOCAL transport choice. A node cannot QUIC-dial
183	// its own node id (iroh: "Connecting to ourself is not supported"), so local
184	// attach CANNOT ride a self-dialed QUIC conn. The one-pump invariant therefore
185	// holds via the fallback transport (a broker-local loopback conn feeding the same
186	// `serve_attach` pump), NOT a self-dial — exactly the doyle-approved fallback.
187	// Cross-node attach (the prize) is unaffected: it dials a real remote peer.
188	#[test]
189	fn loopback_self_dial_is_refused_local_uses_fallback_transport() {
190	    init_home();
191	    let dir = tempfile::tempdir().expect("tempdir");
192	    let name = unique_name();
193	    let _broker = net_broker(&name, &dir.path().join("solo"));
194	    let mut brain = connect_retry(&name);
195
196	    let status = brain.net_status().expect("status");
197	    // Dialing our OWN advertised address is refused at the transport — fail-fast,
198	    // never a deadlock; local attach must not be built on a self-dial.
199	    let err = brain
200	        .net_dial(status.addr, Some(1))
201	        .expect_err("self-dial must be refused, not silently succeed");
202	    assert!(
203	        err.to_string().contains("ourself") || err.to_string().contains("our own"),
204	        "self-dial refused for the documented reason, got: {err}"
205	    );
206	}
207
208	// [int->REQ-HOST-RUN-1] [int->REQ-RC-1] spt-hosted harness bringup + cross-node
209	// attach, end to end: `harnesshost::launch_harness_brokered_in` spawns a harness's
210	// `[session.self]` into broker A's PTY (the spt-hosted bringup — the daemon owns
211	// the PTY, not an external launcher), then an operator on broker B attaches
212	// CROSS-NODE and drives the brought-up PTY: keystrokes reach it, its output
213	// streams back. This is the W1 gate proof — "bring up + attach a real harness
214	// endpoint" over the byte-identical cross-node attach pump; the session outlives
215	// the viewport (detach never kills it).
216	#[test]
217	fn spt_hosted_bringup_then_cross_node_attach_drives_the_pty() {
218	    init_home();
219	    let dir = tempfile::tempdir().expect("tempdir");
220	    let (name_a, name_b) = (unique_name(), unique_name());
221	    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
222	    let _broker_b = net_broker(&name_b, &dir.path().join("b"));
223
224	    // Bring up an spt-hosted harness on A. Its `[session.self]` is a real echo
225	    // binary standing in for a harness — the bringup spawns it into broker A's
226	    // PTY exactly as it would a `claude` command.
227	    #[cfg(unix)]
228	    let self_cmd = "cat";
229	    #[cfg(windows)]
230	    let self_cmd = "findstr .";
231	    let manifest = spt_runtime::Manifest::from_toml_str(&format!(
232	        "[adapter]\nname = \"mock\"\nkind = \"harness\"\nversion = \"1\"\n\
233	         min_spt_core_version = \"0\"\n\n[session.self]\ncommand = '{self_cmd}'\n"
234	    ))
235	    .expect("harness manifest");
236	    let spawned = spt_daemon::harnesshost::launch_harness_brokered_in(
237	        &name_a, "cc-ep", "mock", "sess-1", &manifest,
238	    )
239	    .expect("spt-hosted bringup spawns into broker A's PTY");
240
241	    // Operator on B attaches CROSS-NODE to the brought-up session.
242	    let mut target = connect_retry(&name_a);
243	    let a_addr = target.net_status().expect("a status").addr;
244	    let mut operator = connect_retry(&name_b);
245	    let b_node = operator
246	        .net_status()
247	        .expect("b status")
248	        .node_id_hex
249	        .expect("node id");
250	    let conn = operator.net_dial(a_addr, Some(1)).expect("dial A");
251	    let stream_b =
252	        request_attach(&mut operator, conn.conn_id, spawned.session_id, 0, 2).expect("request");
253	    operator
254	        .net_stream_subscribe(stream_b, 0)
255	        .expect("operator subscribe");
256
257	    // A serves the inbound attach stream (endpoint = the brought-up id).
258	    let (stream_a, origin) = wait_for_stream(&mut target);
259	    assert_eq!(origin, b_node, "serve gate subject is the handshake identity");
260	    let server = thread::spawn(move || {
261	        let outcome = serve_attach(&mut target, stream_a, "cc-ep", &origin, 0).expect("serve");
262	        (outcome, target)
263	    });
264
265	    // Drive the brought-up PTY from B; the echo streams back over the wire.
266	    send_attach_input(&mut operator, stream_b, b"HOSTED_OK\r", 1).expect("input");
267	    let mut decoder = AttachDecoder::new();
268	    let (mut cursor, mut viewport) = (0u64, Vec::new());
269	    render_until(
270	        &mut operator,
271	        stream_b,
272	        &mut decoder,
273	        &mut cursor,
274	        &mut viewport,
275	        b"HOSTED_OK",
276	    );
277
278	    // Detach: the brought-up session outlives the viewport.
279	    operator
280	        .net_stream_send(stream_b, &[], None, true)
281	        .expect("detach");
282	    let (outcome, mut target) = server.join().expect("serve thread");
283	    assert_eq!(outcome, AttachServeOutcome::Detached);
284	    target.kill_session().expect("cleanup kill");
285	}
286
287	// [unit->REQ-INST-8] drive an agent('s session) on machine A from machine B:
288	// B's keystrokes reach A's real PTY child over the wire (journaled input
289	// path), the child's output streams back as sequenced records, and B renders
290	// its own marker echoed remotely. The serve gate sees the handshake-proven
291	// origin; B detaching ends the viewport with the session still running on A.
292	#[test]
293	fn remote_attach_drives_a_real_pty_cross_daemon() {
294	    init_home();
295	    let dir = tempfile::tempdir().expect("tempdir");
296	    let (name_a, name_b) = (unique_name(), unique_name());
297	    let broker_a = net_broker(&name_a, &dir.path().join("a"));
298	    let _broker_b = net_broker(&name_b, &dir.path().join("b"));
299
300	    // Target A: spawn the echo child.
301	    let mut target = connect_retry(&name_a);
302	    let sid = target
303	        .spawn_session(echo_spawn_req())
304	        .expect("spawn echo child");
305	    let a_addr = target.net_status().expect("a status").addr;
306
307	    // Operator B: dial A, open the attach stream, request the session.
308	    let mut operator = connect_retry(&name_b);
309	    let b_node = operator
310	        .net_status()
311	        .expect("b status")
312	        .node_id_hex
313	        .expect("node id");
314	    let conn = operator.net_dial(a_addr, Some(1)).expect("dial");
315	    let stream_b = request_attach(&mut operator, conn.conn_id, sid, 0, 2).expect("request");
316	    operator
317	        .net_stream_subscribe(stream_b, 0)
318	        .expect("operator subscribe");
319
320	    // Target A: the attach stream arrives; serve it on its own thread under
321	    // the handshake-proven origin (KH 7.5 — the stream table, not payload).
322	    let (stream_a, origin) = wait_for_stream(&mut target);
323	    assert_eq!(
324	        origin, b_node,
325	        "serve gate subject IS the QUIC handshake identity"
326	    );
327	    let server = thread::spawn(move || {
328	        let outcome = serve_attach(&mut target, stream_a, "ling", &origin, 0).expect("serve");
329	        (outcome, target)
330	    });
331
332	    // B types a marker; A's child echoes it; B renders it from the wire.
333	    send_attach_input(&mut operator, stream_b, b"REMOTE_DRIVE_OK\r", 1).expect("input");
334	    let mut decoder = AttachDecoder::new();
335	    let (mut cursor, mut viewport) = (0u64, Vec::new());
336	    render_until(
337	        &mut operator,
338	        stream_b,
339	        &mut decoder,
340	        &mut cursor,
341	        &mut viewport,
342	        b"REMOTE_DRIVE_OK",
343	    );
344
345	    // Detach: B finishes its send side; the serve loop ends with the session
346	    // still alive on A (viewport over, compute stays remote).
347	    operator
348	        .net_stream_send(stream_b, &[], None, true)
349	        .expect("detach");
350	    let (outcome, mut target) = server.join().expect("serve thread");
351	    assert_eq!(outcome, AttachServeOutcome::Detached);
352	    assert_eq!(
353	        broker_a.session_count(),
354	        1,
355	        "the session outlives the viewport"
356	    );
357	    target.kill_session().expect("cleanup kill");
358	}
359
360	// [unit->REQ-HAZARD-RESTART-IDEMPOTENT] the viewport survives a target-brain
361	// crash mid-attach: the successor re-serves the SAME broker-held stream from
362	// seq 0 (worst-case stale cursor) — the replayed Request/input/output all
363	// dedup through the journals, input B sent INTO the dead window arrives, and
364	// B's viewport holds every marker exactly once.
365	// [unit->REQ-INST-8]
366	#[test]
367	fn attach_survives_target_brain_restart_exactly_once() {
368	    init_home();
369	    let dir = tempfile::tempdir().expect("tempdir");
370	    let (name_a, name_b) = (unique_name(), unique_name());
371	    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
372	    let _broker_b = net_broker(&name_b, &dir.path().join("b"));
373
374	    // Target A life 1: spawn the child; operator B dials + requests + types.
375	    let mut life1 = connect_retry(&name_a);
376	    let sid = life1
377	        .spawn_session(echo_spawn_req())
378	        .expect("spawn echo child");
379	    let a_addr = life1.net_status().expect("a status").addr;
380
381	    let mut operator = connect_retry(&name_b);
382	    let conn = operator.net_dial(a_addr, Some(1)).expect("dial");
383	    let stream_b = request_attach(&mut operator, conn.conn_id, sid, 0, 2).expect("request");
384	    operator
385	        .net_stream_subscribe(stream_b, 0)
386	        .expect("operator subscribe");
387	    send_attach_input(&mut operator, stream_b, b"MARKER_ONE\r", 10).expect("input 1");
388
389	    // Life 1 serves manually (the serve loop's exact steps, stepped so the
390	    // crash point is deterministic): handle the Request + input, pump output
391	    // records until MARKER_ONE went onto the wire — then die.
392	    let (stream_a, origin) = wait_for_stream(&mut life1);
393	    life1
394	        .net_stream_subscribe(stream_a, 0)
395	        .expect("life1 subscribe");
396	    let mut decoder = AttachDecoder::new();
397	    let mut sent: Vec<u8> = Vec::new(); // bytes life 1 pumped onto the wire
398	    'life1: loop {
399	        match life1.read_event().expect("life1 event") {
400	            BrokerEvent::NetStreamData {
401	                stream_id, bytes, ..
402	            } if stream_id == stream_a => {
403	                for rec in decoder.push(&bytes) {
404	                    match rec {
405	                        AttachRecord::Request {
406	                            session_id,
407	                            from_seq,
408	                        } => {
409	                            life1.attach(session_id, from_seq).expect("attach");
410	                        }
411	                        AttachRecord::Input { data_b64, op_id } => {
412	                            let b = decode_bytes(&data_b64).expect("b64");
413	                            life1.send_effect(op_id, &b).expect("journaled input");
414	                        }
415	                        _ => {}
416	                    }
417	                }
418	            }
419	            BrokerEvent::Output { seq, bytes, .. } => {
420	                let line = ndjson::encode_line(&AttachRecord::Output {
421	                    seq,
422	                    data_b64: spt_daemon::msg::encode_bytes(&bytes),
423	                });
424	                life1
425	                    .net_stream_send(stream_a, &line, None, false)
426	                    .expect("output send");
427	                sent.extend_from_slice(&bytes);
428	                if count(&sent, b"MARKER_ONE") >= 1 {
429	                    break 'life1; // crash with the marker on the wire
430	                }
431	            }
432	            BrokerEvent::Error { message } => panic!("life1 broker error: {message}"),
433	            _ => {}
434	        }
435	    }
436	    let state = life1.snapshot();
437	    drop(life1); // the crash — broker A, session, stream, journal all survive
438
439	    // Into the DEAD window: B types the second marker (buffered by A's broker).
440	    send_attach_input(&mut operator, stream_b, b"MARKER_TWO\r", 11).expect("input 2");
441
442	    // Life 2: handoff, then re-serve the SAME stream from seq 0 — the
443	    // worst-case replay. serve_attach re-handles the replayed Request (gate +
444	    // re-attach from 0, so every output record re-transmits — B's render
445	    // cursor drops the duplicates), replays MARKER_ONE's input (PTY-write
446	    // journal dedups op 10 — typed once), and pumps the dead window's
447	    // MARKER_TWO.
448	    let mut life2 = Brain::handoff(&name_a, state, 2).expect("handoff");
449	    let origin2 = origin.clone();
450	    let server = thread::spawn(move || {
451	        let outcome = serve_attach(&mut life2, stream_a, "ling", &origin2, 0).expect("re-serve");
452	        (outcome, life2)
453	    });
454
455	    // B renders through the dead-window marker, then detaches.
456	    let mut op_decoder = AttachDecoder::new();
457	    let (mut cursor, mut viewport) = (0u64, Vec::new());
458	    render_until(
459	        &mut operator,
460	        stream_b,
461	        &mut op_decoder,
462	        &mut cursor,
463	        &mut viewport,
464	        b"MARKER_TWO",
465	    );
466	    operator
467	        .net_stream_send(stream_b, &[], None, true)
468	        .expect("detach");
469	    let (outcome, mut life2) = server.join().expect("serve thread");
470	    assert_eq!(outcome, AttachServeOutcome::Detached);
471
472	    // Exactly-once, stated against ground truth: replay the session's OWN
473	    // output log locally from seq 0 with the same stop rule — the remote
474	    // viewport must be BYTE-IDENTICAL through the first dead-window marker
475	    // (an echoing child legitimately repeats a typed marker; what must never
476	    // differ is wire vs local). A dup or gap from the restart/replay would
477	    // break the prefix equality. Compared as prefixes-through-the-needle
478	    // because each side's *overshoot* past the marker is honest chunking
479	    // nondeterminism: both stop rules check "buffer contains needle" only
480	    // between event batches, and how many trailing records one batch carries
481	    // differs between the QUIC wire and the local IPC frames.
482	    life2.attach(sid, 0).expect("local ground-truth attach");
483	    let mut local: Vec<u8> = Vec::new();
484	    life2
485	        .read_output_until(b"MARKER_TWO", &mut local)
486	        .expect("local replay");
487	    assert_eq!(
488	        prefix_through(&viewport, b"MARKER_TWO"),
489	        prefix_through(&local, b"MARKER_TWO"),
490	        "remote viewport byte-identical to the local output log across the restart"
491	    );
492	    assert!(count(&local, b"MARKER_ONE") >= 1 && count(&local, b"MARKER_TWO") >= 1);
493
494	    life2.kill_session().expect("cleanup kill");
495	}
496
497	// [unit->REQ-REACH-1] remote-drive detection is a daemon fact: serving a
498	// net-attach stamps the endpoint perch's `driven_by` with the
499	// handshake-proven origin node (what the agent's `spt api driven-by` query
500	// reads), and the marker clears when the viewport ends — the endpoint reads
501	// locally driven again.
502	#[test]
503	fn attach_registers_remote_drive_detection() {
504	    init_home();
505	    let dir = tempfile::tempdir().expect("tempdir");
506	    let (name_a, name_b) = (unique_name(), unique_name());
507	    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
508	    let _broker_b = net_broker(&name_b, &dir.path().join("b"));
509
510	    // The driven endpoint has a real perch on A (unique id: the shared test
511	    // home outlives this test).
512	    let endpoint = format!(
513	        "ling-d5c-{}-{}",
514	        std::process::id(),
515	        SEQ.fetch_add(1, Ordering::Relaxed)
516	    );
517	    let perch_path =
518	        spt_store::perch::resolve_perch_path(&endpoint, spt_store::perch::ParentHint::Infer);
519	    std::fs::create_dir_all(&perch_path).expect("perch dir");
520	    let rec =
521	        spt_store::info::InfoJson::new(&endpoint, "now", std::process::id(), "sess", "live_agent");
522	    spt_store::info::write_info(&perch_path, &rec).expect("info.json");
523
524	    let mut target = connect_retry(&name_a);
525	    let sid = target
526	        .spawn_session(echo_spawn_req())
527	        .expect("spawn echo child");
528	    let a_addr = target.net_status().expect("a status").addr;
529
530	    let mut operator = connect_retry(&name_b);
531	    let b_node = operator
532	        .net_status()
533	        .expect("b status")
534	        .node_id_hex
535	        .expect("node id");
536	    let conn = operator.net_dial(a_addr, Some(1)).expect("dial");
537	    let stream_b = request_attach(&mut operator, conn.conn_id, sid, 0, 2).expect("request");
538	    operator
539	        .net_stream_subscribe(stream_b, 0)
540	        .expect("operator subscribe");
541
542	    let (stream_a, origin) = wait_for_stream(&mut target);
543	    let endpoint_serve = endpoint.clone();
544	    let server = thread::spawn(move || {
545	        let outcome =
546	            serve_attach(&mut target, stream_a, &endpoint_serve, &origin, 0).expect("serve");
547	        (outcome, target)
548	    });
549
550	    // Drive a marker through: once it renders at B, the serve loop has long
551	    // since processed the Request — the stamp is durably on the perch.
552	    send_attach_input(&mut operator, stream_b, b"DETECT_ME\r", 1).expect("input");
553	    let mut decoder = AttachDecoder::new();
554	    let (mut cursor, mut viewport) = (0u64, Vec::new());
555	    render_until(
556	        &mut operator,
557	        stream_b,
558	        &mut decoder,
559	        &mut cursor,
560	        &mut viewport,
561	        b"DETECT_ME",
562	    );
563	    let driven = spt_store::info::read_info(&perch_path)
564	        .expect("info")
565	        .driven_by;
566	    assert_eq!(
567	        driven.as_deref(),
568	        Some(b_node.as_str()),
569	        "the marker IS the handshake-proven origin node"
570	    );
571
572	    // Detach: the viewport ends; the endpoint reads locally driven again.
573	    operator
574	        .net_stream_send(stream_b, &[], None, true)
575	        .expect("detach");
576	    let (outcome, mut target) = server.join().expect("serve thread");
577	    assert_eq!(outcome, AttachServeOutcome::Detached);
578	    assert_eq!(
579	        spt_store::info::read_info(&perch_path)
580	            .expect("info")
581	            .driven_by,
582	        None,
583	        "viewport over → marker cleared"
584	    );
585	    target.kill_session().expect("cleanup kill");
586	}
587

//! D5b regression — remote terminal attach (REQ-INST-8): drive a real PTY
//! session on daemon A from daemon B, byte-stream viewport over the
//! broker-owned wire; compute stays on A.
//!
//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
//! the two daemons; a real echo child runs under A's broker PTY. The
//! invariants:
//!
//! - **remote drive**: keystrokes sent from B arrive at A's PTY through the
//!   journaled input path, and the session's output streams back to B as
//!   sequenced records — B sees its own marker echoed by A's child;
//! - **origin is transport truth** (KNOWN-HAZARDS 7.5): the serve loop gates
//!   on the stream table's handshake-proven `remote_id_hex`;
//! - **restart survival** (REQ-HAZARD-RESTART-IDEMPOTENT): A's brain dies
//!   mid-attach; the broker keeps the QUIC stream, the session, the output
//!   log, and the effect journal. The successor brain re-serves the SAME
//!   stream from seq 0 — the worst-case stale cursor: the replayed input
//!   dedups at the PTY-write journal (typed exactly once), re-transmitted
//!   output dedups at B's render cursor (rendered exactly once) — including
//!   input B sent INTO the dead window.

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

use spt_daemon::attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
use spt_daemon::brain::{Brain, BrokerEvent};
use spt_daemon::msg::{decode_bytes, SpawnReq};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::Broker;
use spt_net::net::attach::{AttachDecoder, AttachRecord};
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::ndjson;
use spt_proto::identity::Identity;

/// Hold the temp home for the whole test binary so it isn't deleted mid-run.
/// Every test inits it: serve_attach best-effort-stamps the endpoint perch's
/// `driven_by` marker, and that resolution must never touch a real home.
static HOME: OnceLock<tempfile::TempDir> = OnceLock::new();

fn init_home() {
    HOME.get_or_init(|| {
        let dir = tempfile::TempDir::new().expect("temp dir");
        std::env::set_var("SPT_HOME", dir.path());
        dir
    });
}

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

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

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

fn echo_spawn_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq {
        program,
        args,
        rows: 24,
        cols: 80,
        endpoint: String::new(),
    }
}

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

/// Operator-side viewport: consume attach-stream records, dedup `Output` by
/// seq, append rendered bytes to `buf`, until `needle` is rendered. Returns
/// the rendered-through cursor (last accepted seq + 1).
fn render_until(
    brain: &mut Brain,
    stream_id: u64,
    decoder: &mut AttachDecoder,
    next_seq: &mut u64,
    buf: &mut Vec<u8>,
    needle: &[u8],
) {
    let contains = |hay: &[u8], needle: &[u8]| hay.windows(needle.len()).any(|w| w == needle);
    while !contains(buf, needle) {
        match brain.read_event().expect("operator read event") {
            BrokerEvent::NetStreamData {
                stream_id: sid,
                bytes,
                ..
            } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    if let AttachRecord::Output { seq, data_b64 } = rec {
                        if seq < *next_seq {
                            continue; // replay duplicate — dedup by cursor
                        }
                        assert_eq!(seq, *next_seq, "no forward gap in the viewport");
                        *next_seq = seq + 1;
                        buf.extend_from_slice(&decode_bytes(&data_b64).expect("chunk b64"));
                    }
                }
            }
            BrokerEvent::NetStreamEof { .. } => panic!("stream ended before the marker rendered"),
            BrokerEvent::Error { message } => panic!("operator broker error: {message}"),
            _ => {}
        }
    }
}

/// The prefix of `hay` through (and including) the first occurrence of
/// `needle` — the deterministic comparison window when two readers share a
/// "stop once the buffer contains the needle" rule but batch events
/// differently (each side's overshoot past the needle is chunking
/// nondeterminism, not a delivery difference).
fn prefix_through<'a>(hay: &'a [u8], needle: &[u8]) -> &'a [u8] {
    let pos = hay
        .windows(needle.len())
        .position(|w| w == needle)
        .expect("the stop rule guarantees the needle rendered");
    &hay[..pos + needle.len()]
}

/// 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-RC-1] loopback self-dial is REFUSED by the transport — pins the
// finding that drives `spt rc`'s LOCAL transport choice. A node cannot QUIC-dial
// its own node id (iroh: "Connecting to ourself is not supported"), so local
// attach CANNOT ride a self-dialed QUIC conn. The one-pump invariant therefore
// holds via the fallback transport (a broker-local loopback conn feeding the same
// `serve_attach` pump), NOT a self-dial — exactly the doyle-approved fallback.
// Cross-node attach (the prize) is unaffected: it dials a real remote peer.
#[test]
fn loopback_self_dial_is_refused_local_uses_fallback_transport() {
    init_home();
    let dir = tempfile::tempdir().expect("tempdir");
    let name = unique_name();
    let _broker = net_broker(&name, &dir.path().join("solo"));
    let mut brain = connect_retry(&name);

    let status = brain.net_status().expect("status");
    // Dialing our OWN advertised address is refused at the transport — fail-fast,
    // never a deadlock; local attach must not be built on a self-dial.
    let err = brain
        .net_dial(status.addr, Some(1))
        .expect_err("self-dial must be refused, not silently succeed");
    assert!(
        err.to_string().contains("ourself") || err.to_string().contains("our own"),
        "self-dial refused for the documented reason, got: {err}"
    );
}

// [int->REQ-HOST-RUN-1] [int->REQ-RC-1] spt-hosted harness bringup + cross-node
// attach, end to end: `harnesshost::launch_harness_brokered_in` spawns a harness's
// `[session.self]` into broker A's PTY (the spt-hosted bringup — the daemon owns
// the PTY, not an external launcher), then an operator on broker B attaches
// CROSS-NODE and drives the brought-up PTY: keystrokes reach it, its output
// streams back. This is the W1 gate proof — "bring up + attach a real harness
// endpoint" over the byte-identical cross-node attach pump; the session outlives
// the viewport (detach never kills it).
#[test]
fn spt_hosted_bringup_then_cross_node_attach_drives_the_pty() {
    init_home();
    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"));

    // Bring up an spt-hosted harness on A. Its `[session.self]` is a real echo
    // binary standing in for a harness — the bringup spawns it into broker A's
    // PTY exactly as it would a `claude` command.
    #[cfg(unix)]
    let self_cmd = "cat";
    #[cfg(windows)]
    let self_cmd = "findstr .";
    let manifest = spt_runtime::Manifest::from_toml_str(&format!(
        "[adapter]\nname = \"mock\"\nkind = \"harness\"\nversion = \"1\"\n\
         min_spt_core_version = \"0\"\n\n[session.self]\ncommand = '{self_cmd}'\n"
    ))
    .expect("harness manifest");
    let spawned = spt_daemon::harnesshost::launch_harness_brokered_in(
        &name_a, "cc-ep", "mock", "sess-1", &manifest,
    )
    .expect("spt-hosted bringup spawns into broker A's PTY");

    // Operator on B attaches CROSS-NODE to the brought-up session.
    let mut target = connect_retry(&name_a);
    let a_addr = target.net_status().expect("a status").addr;
    let mut operator = connect_retry(&name_b);
    let b_node = operator
        .net_status()
        .expect("b status")
        .node_id_hex
        .expect("node id");
    let conn = operator.net_dial(a_addr, Some(1)).expect("dial A");
    let stream_b =
        request_attach(&mut operator, conn.conn_id, spawned.session_id, 0, 2).expect("request");
    operator
        .net_stream_subscribe(stream_b, 0)
        .expect("operator subscribe");

    // A serves the inbound attach stream (endpoint = the brought-up id).
    let (stream_a, origin) = wait_for_stream(&mut target);
    assert_eq!(origin, b_node, "serve gate subject is the handshake identity");
    let server = thread::spawn(move || {
        let outcome = serve_attach(&mut target, stream_a, "cc-ep", &origin, 0).expect("serve");
        (outcome, target)
    });

    // Drive the brought-up PTY from B; the echo streams back over the wire.
    send_attach_input(&mut operator, stream_b, b"HOSTED_OK\r", 1).expect("input");
    let mut decoder = AttachDecoder::new();
    let (mut cursor, mut viewport) = (0u64, Vec::new());
    render_until(
        &mut operator,
        stream_b,
        &mut decoder,
        &mut cursor,
        &mut viewport,
        b"HOSTED_OK",
    );

    // Detach: the brought-up session outlives the viewport.
    operator
        .net_stream_send(stream_b, &[], None, true)
        .expect("detach");
    let (outcome, mut target) = server.join().expect("serve thread");
    assert_eq!(outcome, AttachServeOutcome::Detached);
    target.kill_session().expect("cleanup kill");
}

// [unit->REQ-INST-8] drive an agent('s session) on machine A from machine B:
// B's keystrokes reach A's real PTY child over the wire (journaled input
// path), the child's output streams back as sequenced records, and B renders
// its own marker echoed remotely. The serve gate sees the handshake-proven
// origin; B detaching ends the viewport with the session still running on A.
#[test]
fn remote_attach_drives_a_real_pty_cross_daemon() {
    init_home();
    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"));

    // Target A: spawn the echo child.
    let mut target = connect_retry(&name_a);
    let sid = target
        .spawn_session(echo_spawn_req())
        .expect("spawn echo child");
    let a_addr = target.net_status().expect("a status").addr;

    // Operator B: dial A, open the attach stream, request the session.
    let mut operator = connect_retry(&name_b);
    let b_node = operator
        .net_status()
        .expect("b status")
        .node_id_hex
        .expect("node id");
    let conn = operator.net_dial(a_addr, Some(1)).expect("dial");
    let stream_b = request_attach(&mut operator, conn.conn_id, sid, 0, 2).expect("request");
    operator
        .net_stream_subscribe(stream_b, 0)
        .expect("operator subscribe");

    // Target A: the attach stream arrives; serve it on its own thread under
    // the handshake-proven origin (KH 7.5 — the stream table, not payload).
    let (stream_a, origin) = wait_for_stream(&mut target);
    assert_eq!(
        origin, b_node,
        "serve gate subject IS the QUIC handshake identity"
    );
    let server = thread::spawn(move || {
        let outcome = serve_attach(&mut target, stream_a, "ling", &origin, 0).expect("serve");
        (outcome, target)
    });

    // B types a marker; A's child echoes it; B renders it from the wire.
    send_attach_input(&mut operator, stream_b, b"REMOTE_DRIVE_OK\r", 1).expect("input");
    let mut decoder = AttachDecoder::new();
    let (mut cursor, mut viewport) = (0u64, Vec::new());
    render_until(
        &mut operator,
        stream_b,
        &mut decoder,
        &mut cursor,
        &mut viewport,
        b"REMOTE_DRIVE_OK",
    );

    // Detach: B finishes its send side; the serve loop ends with the session
    // still alive on A (viewport over, compute stays remote).
    operator
        .net_stream_send(stream_b, &[], None, true)
        .expect("detach");
    let (outcome, mut target) = server.join().expect("serve thread");
    assert_eq!(outcome, AttachServeOutcome::Detached);
    assert_eq!(
        broker_a.session_count(),
        1,
        "the session outlives the viewport"
    );
    target.kill_session().expect("cleanup kill");
}

// [unit->REQ-HAZARD-RESTART-IDEMPOTENT] the viewport survives a target-brain
// crash mid-attach: the successor re-serves the SAME broker-held stream from
// seq 0 (worst-case stale cursor) — the replayed Request/input/output all
// dedup through the journals, input B sent INTO the dead window arrives, and
// B's viewport holds every marker exactly once.
// [unit->REQ-INST-8]
#[test]
fn attach_survives_target_brain_restart_exactly_once() {
    init_home();
    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"));

    // Target A life 1: spawn the child; operator B dials + requests + types.
    let mut life1 = connect_retry(&name_a);
    let sid = life1
        .spawn_session(echo_spawn_req())
        .expect("spawn echo child");
    let a_addr = life1.net_status().expect("a status").addr;

    let mut operator = connect_retry(&name_b);
    let conn = operator.net_dial(a_addr, Some(1)).expect("dial");
    let stream_b = request_attach(&mut operator, conn.conn_id, sid, 0, 2).expect("request");
    operator
        .net_stream_subscribe(stream_b, 0)
        .expect("operator subscribe");
    send_attach_input(&mut operator, stream_b, b"MARKER_ONE\r", 10).expect("input 1");

    // Life 1 serves manually (the serve loop's exact steps, stepped so the
    // crash point is deterministic): handle the Request + input, pump output
    // records until MARKER_ONE went onto the wire — then die.
    let (stream_a, origin) = wait_for_stream(&mut life1);
    life1
        .net_stream_subscribe(stream_a, 0)
        .expect("life1 subscribe");
    let mut decoder = AttachDecoder::new();
    let mut sent: Vec<u8> = Vec::new(); // bytes life 1 pumped onto the wire
    'life1: loop {
        match life1.read_event().expect("life1 event") {
            BrokerEvent::NetStreamData {
                stream_id, bytes, ..
            } if stream_id == stream_a => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        AttachRecord::Request {
                            session_id,
                            from_seq,
                        } => {
                            life1.attach(session_id, from_seq).expect("attach");
                        }
                        AttachRecord::Input { data_b64, op_id } => {
                            let b = decode_bytes(&data_b64).expect("b64");
                            life1.send_effect(op_id, &b).expect("journaled input");
                        }
                        _ => {}
                    }
                }
            }
            BrokerEvent::Output { seq, bytes, .. } => {
                let line = ndjson::encode_line(&AttachRecord::Output {
                    seq,
                    data_b64: spt_daemon::msg::encode_bytes(&bytes),
                });
                life1
                    .net_stream_send(stream_a, &line, None, false)
                    .expect("output send");
                sent.extend_from_slice(&bytes);
                if count(&sent, b"MARKER_ONE") >= 1 {
                    break 'life1; // crash with the marker on the wire
                }
            }
            BrokerEvent::Error { message } => panic!("life1 broker error: {message}"),
            _ => {}
        }
    }
    let state = life1.snapshot();
    drop(life1); // the crash — broker A, session, stream, journal all survive

    // Into the DEAD window: B types the second marker (buffered by A's broker).
    send_attach_input(&mut operator, stream_b, b"MARKER_TWO\r", 11).expect("input 2");

    // Life 2: handoff, then re-serve the SAME stream from seq 0 — the
    // worst-case replay. serve_attach re-handles the replayed Request (gate +
    // re-attach from 0, so every output record re-transmits — B's render
    // cursor drops the duplicates), replays MARKER_ONE's input (PTY-write
    // journal dedups op 10 — typed once), and pumps the dead window's
    // MARKER_TWO.
    let mut life2 = Brain::handoff(&name_a, state, 2).expect("handoff");
    let origin2 = origin.clone();
    let server = thread::spawn(move || {
        let outcome = serve_attach(&mut life2, stream_a, "ling", &origin2, 0).expect("re-serve");
        (outcome, life2)
    });

    // B renders through the dead-window marker, then detaches.
    let mut op_decoder = AttachDecoder::new();
    let (mut cursor, mut viewport) = (0u64, Vec::new());
    render_until(
        &mut operator,
        stream_b,
        &mut op_decoder,
        &mut cursor,
        &mut viewport,
        b"MARKER_TWO",
    );
    operator
        .net_stream_send(stream_b, &[], None, true)
        .expect("detach");
    let (outcome, mut life2) = server.join().expect("serve thread");
    assert_eq!(outcome, AttachServeOutcome::Detached);

    // Exactly-once, stated against ground truth: replay the session's OWN
    // output log locally from seq 0 with the same stop rule — the remote
    // viewport must be BYTE-IDENTICAL through the first dead-window marker
    // (an echoing child legitimately repeats a typed marker; what must never
    // differ is wire vs local). A dup or gap from the restart/replay would
    // break the prefix equality. Compared as prefixes-through-the-needle
    // because each side's *overshoot* past the marker is honest chunking
    // nondeterminism: both stop rules check "buffer contains needle" only
    // between event batches, and how many trailing records one batch carries
    // differs between the QUIC wire and the local IPC frames.
    life2.attach(sid, 0).expect("local ground-truth attach");
    let mut local: Vec<u8> = Vec::new();
    life2
        .read_output_until(b"MARKER_TWO", &mut local)
        .expect("local replay");
    assert_eq!(
        prefix_through(&viewport, b"MARKER_TWO"),
        prefix_through(&local, b"MARKER_TWO"),
        "remote viewport byte-identical to the local output log across the restart"
    );
    assert!(count(&local, b"MARKER_ONE") >= 1 && count(&local, b"MARKER_TWO") >= 1);

    life2.kill_session().expect("cleanup kill");
}

// [unit->REQ-REACH-1] remote-drive detection is a daemon fact: serving a
// net-attach stamps the endpoint perch's `driven_by` with the
// handshake-proven origin node (what the agent's `spt api driven-by` query
// reads), and the marker clears when the viewport ends — the endpoint reads
// locally driven again.
#[test]
fn attach_registers_remote_drive_detection() {
    init_home();
    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"));

    // The driven endpoint has a real perch on A (unique id: the shared test
    // home outlives this test).
    let endpoint = format!(
        "ling-d5c-{}-{}",
        std::process::id(),
        SEQ.fetch_add(1, Ordering::Relaxed)
    );
    let perch_path =
        spt_store::perch::resolve_perch_path(&endpoint, spt_store::perch::ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).expect("perch dir");
    let rec =
        spt_store::info::InfoJson::new(&endpoint, "now", std::process::id(), "sess", "live_agent");
    spt_store::info::write_info(&perch_path, &rec).expect("info.json");

    let mut target = connect_retry(&name_a);
    let sid = target
        .spawn_session(echo_spawn_req())
        .expect("spawn echo child");
    let a_addr = target.net_status().expect("a status").addr;

    let mut operator = connect_retry(&name_b);
    let b_node = operator
        .net_status()
        .expect("b status")
        .node_id_hex
        .expect("node id");
    let conn = operator.net_dial(a_addr, Some(1)).expect("dial");
    let stream_b = request_attach(&mut operator, conn.conn_id, sid, 0, 2).expect("request");
    operator
        .net_stream_subscribe(stream_b, 0)
        .expect("operator subscribe");

    let (stream_a, origin) = wait_for_stream(&mut target);
    let endpoint_serve = endpoint.clone();
    let server = thread::spawn(move || {
        let outcome =
            serve_attach(&mut target, stream_a, &endpoint_serve, &origin, 0).expect("serve");
        (outcome, target)
    });

    // Drive a marker through: once it renders at B, the serve loop has long
    // since processed the Request — the stamp is durably on the perch.
    send_attach_input(&mut operator, stream_b, b"DETECT_ME\r", 1).expect("input");
    let mut decoder = AttachDecoder::new();
    let (mut cursor, mut viewport) = (0u64, Vec::new());
    render_until(
        &mut operator,
        stream_b,
        &mut decoder,
        &mut cursor,
        &mut viewport,
        b"DETECT_ME",
    );
    let driven = spt_store::info::read_info(&perch_path)
        .expect("info")
        .driven_by;
    assert_eq!(
        driven.as_deref(),
        Some(b_node.as_str()),
        "the marker IS the handshake-proven origin node"
    );

    // Detach: the viewport ends; the endpoint reads locally driven again.
    operator
        .net_stream_send(stream_b, &[], None, true)
        .expect("detach");
    let (outcome, mut target) = server.join().expect("serve thread");
    assert_eq!(outcome, AttachServeOutcome::Detached);
    assert_eq!(
        spt_store::info::read_info(&perch_path)
            .expect("info")
            .driven_by,
        None,
        "viewport over → marker cleared"
    );
    target.kill_session().expect("cleanup kill");
}


proceed with W1.5 next steps. first verify the plan with doyle


Full architecture + test harness understood. Design is crystallized. Let me record it into the plan doc (resume insurance), then implement. First read msg.rs net frame defs.


Grep

KIND_NET_DIAL|net_dialed_envelope|struct NetDialReq|struct NetDialed|struct NetStreamOpenReq|KIND_NET_STREAM_OPEN\b|pub const KIND_NET


47:pub const KIND_NET_STATUS: &str = "net-status";
49:pub const KIND_NET_STATUS_REPLY: &str = "net-status-reply";
52:pub const KIND_NET_DIAL: &str = "net-dial";
55:pub const KIND_NET_DIALED: &str = "net-dialed";
58:pub const KIND_NET_STREAM_OPEN: &str = "net-stream-open";
60:pub const KIND_NET_STREAM_OPENED: &str = "net-stream-opened";
63:pub const KIND_NET_STREAM_SEND: &str = "net-stream-send";
65:pub const KIND_NET_SENT: &str = "net-sent";
69:pub const KIND_NET_STREAMS: &str = "net-streams";
71:pub const KIND_NET_STREAMS_REPLY: &str = "net-streams-reply";
74:pub const KIND_NET_STREAM_SUBSCRIBE: &str = "net-stream-subscribe";
76:pub const KIND_NET_STREAM_DATA: &str = "net-stream-data";
79:pub const KIND_NET_STREAM_EOF: &str = "net-stream-eof";
84:pub const KIND_NET_PRESENCE_SUBSCRIBE: &str = "net-presence-subscribe";
86:pub const KIND_NET_PRESENCE_EVENT: &str = "net-presence-event";
266:pub struct NetDialReq {
319:pub struct NetDialed {
344:pub struct NetStreamOpenReq {
595:pub fn net_dialed_envelope(
603:        KIND_NET_DIALED,
659:        let env = net_dialed_envelope(3, "cafe".into(), Some(9), false, observed.clone());
660:        assert_eq!(env.kind, KIND_NET_DIALED);


258	    pub addr: serde_json::Value,
259	    /// Live connections (accepted + dialed) in the broker's conn table.
260	    #[serde(default)]
261	    pub conn_count: u64,
262	}
263
264	/// `net-dial` payload — dial a peer over the broker-owned endpoint (D4a).
265	#[derive(Debug, Clone, Serialize, Deserialize)]
266	pub struct NetDialReq {
267	    /// The peer's dialable address — a serde-serialized iroh `EndpointAddr`
268	    /// (opaque JSON here; the broker's net host decodes it).
269	    pub addr: serde_json::Value,
270	    /// The durable op id for exactly-once dialing. `Some(id)` routes the dial
271	    /// through the broker's [`crate::effect::EffectJournal`] (keyed under the
272	    /// reserved [`crate::nethost::NET_EFFECT_SESSION`] namespace): a replay of
273	    /// the same id after a brain crash is deduped and answered with the
274	    /// already-open connection instead of dialing twice. `None` dials directly.
275	    #[serde(default, skip_serializing_if = "Option::is_none")]
276	    pub op_id: Option<u64>,
277	}
278
279	/// `pair-join` payload — the two user-entered ceremony inputs (M7 D3).
280	#[derive(Debug, Clone, Serialize, Deserialize)]
281	pub struct PairJoinReq {
282	    /// The subnet to join (names the rendezvous + the seed).
283	    pub subnet: String,
284	    /// The current 6-digit TOTP code (the SPAKE2 password — consumed by the
285	    /// ceremony, never logged or journaled).
286	    pub code: String,
287	    /// Skip the rendezvous and dial THIS responder address directly (a
288	    /// serde-serialized `EndpointAddr`) — the static-`ip:port` rig seam,
289	    /// mirroring `pairing::meet::meet_at`'s explicit-address form. The
290	    /// guided CLI never sets it.
291	    #[serde(default, skip_serializing_if = "Option::is_none")]
292	    pub responder_addr: Option<serde_json::Value>,
293	}
294
295	/// `pair-joined` reply — the ceremony's verdict, classified for the CLI's
296	/// guided UX (wrong code re-prompts; rate limits surface honestly; a missing
297	/// seed-holder gets actionable hints).
298	#[derive(Debug, Clone, Serialize, Deserialize)]
299	pub struct PairJoinReply {
300	    /// Whether the ceremony confirmed and the seed landed.
301	    pub ok: bool,
302	    /// On failure: the [`crate::pairhost::JoinFail`] class tag —
303	    /// `no-seed-holder` / `wrong-code` / `rate-limited` / `unknown-subnet` /
304	    /// `clock-skew` / `other`.
305	    #[serde(default, skip_serializing_if = "Option::is_none")]
306	    pub class: Option<String>,
307	    /// Rate-limit retry window (seconds), when the class is `rate-limited`
308	    /// with a known backoff.
309	    #[serde(default, skip_serializing_if = "Option::is_none")]
310	    pub retry_after_secs: Option<u64>,
311	    /// Operator-facing detail (the rendered outcome on success, the rendered
312	    /// failure otherwise). Never carries secret material.
313	    #[serde(default)]
314	    pub detail: String,
315	}
316
317	/// `net-dialed` event — a dial durably landed (fresh or deduped).
318	#[derive(Debug, Clone, Serialize, Deserialize)]
319	pub struct NetDialed {
320	    /// The broker-minted id addressing this connection in later net commands.
321	    pub conn_id: u64,
322	    /// The remote node's stable name (lowercase-hex pubkey), proven by the QUIC
323	    /// handshake (the EndpointId *is* the key).
324	    pub remote_id_hex: String,
325	    /// Echoes the request's durable op id, when one was carried.
326	    #[serde(default, skip_serializing_if = "Option::is_none")]
327	    pub op_id: Option<u64>,
328	    /// Whether the dial ran on this delivery (`true`) or was deduped (`false`).
329	    /// Both mean "durably landed" — mirrors [`AppliedEvent::applied_now`].
330	    #[serde(default)]
331	    pub applied_now: bool,
332	    /// The peer's address as OBSERVED on the live connection (remote id +
333	    /// the connection's current path addresses) — a serde-serialized iroh
334	    /// `EndpointAddr`, opaque here. The address-seeding write-back source
335	    /// (M8 decision 20, REQ-CONV-1): what the pump persists into
336	    /// `peer-addrs.json` after a successful dial. `null` from an older
337	    /// broker or when no path is readable (additive serde-default field).
338	    #[serde(default)]
339	    pub remote_addr: serde_json::Value,
340	}
341
342	/// `net-stream-open` payload — open a bidi stream on a held connection (D4b).
343	#[derive(Debug, Clone, Serialize, Deserialize)]
344	pub struct NetStreamOpenReq {
345	    /// The connection (from [`NetDialed::conn_id`]) to open the stream on.
346	    pub conn_id: u64,
347	    /// Durable op id for an exactly-once open (shares the net op-id namespace
348	    /// with dials/sends — one durable counter across all net effects).
349	    #[serde(default, skip_serializing_if = "Option::is_none")]
350	    pub op_id: Option<u64>,
351	}
352
353	/// `net-stream-opened` event — a stream open durably landed.
354	#[derive(Debug, Clone, Serialize, Deserialize)]
355	pub struct NetStreamOpened {
356	    /// The broker-minted id addressing this stream in later commands.
357	    pub stream_id: u64,

    pub addr: serde_json::Value,
    /// Live connections (accepted + dialed) in the broker's conn table.
    #[serde(default)]
    pub conn_count: u64,
}

/// `net-dial` payload — dial a peer over the broker-owned endpoint (D4a).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetDialReq {
    /// The peer's dialable address — a serde-serialized iroh `EndpointAddr`
    /// (opaque JSON here; the broker's net host decodes it).
    pub addr: serde_json::Value,
    /// The durable op id for exactly-once dialing. `Some(id)` routes the dial
    /// through the broker's [`crate::effect::EffectJournal`] (keyed under the
    /// reserved [`crate::nethost::NET_EFFECT_SESSION`] namespace): a replay of
    /// the same id after a brain crash is deduped and answered with the
    /// already-open connection instead of dialing twice. `None` dials directly.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub op_id: Option<u64>,
}

/// `pair-join` payload — the two user-entered ceremony inputs (M7 D3).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PairJoinReq {
    /// The subnet to join (names the rendezvous + the seed).
    pub subnet: String,
    /// The current 6-digit TOTP code (the SPAKE2 password — consumed by the
    /// ceremony, never logged or journaled).
    pub code: String,
    /// Skip the rendezvous and dial THIS responder address directly (a
    /// serde-serialized `EndpointAddr`) — the static-`ip:port` rig seam,
    /// mirroring `pairing::meet::meet_at`'s explicit-address form. The
    /// guided CLI never sets it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub responder_addr: Option<serde_json::Value>,
}

/// `pair-joined` reply — the ceremony's verdict, classified for the CLI's
/// guided UX (wrong code re-prompts; rate limits surface honestly; a missing
/// seed-holder gets actionable hints).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PairJoinReply {
    /// Whether the ceremony confirmed and the seed landed.
    pub ok: bool,
    /// On failure: the [`crate::pairhost::JoinFail`] class tag —
    /// `no-seed-holder` / `wrong-code` / `rate-limited` / `unknown-subnet` /
    /// `clock-skew` / `other`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub class: Option<String>,
    /// Rate-limit retry window (seconds), when the class is `rate-limited`
    /// with a known backoff.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retry_after_secs: Option<u64>,
    /// Operator-facing detail (the rendered outcome on success, the rendered
    /// failure otherwise). Never carries secret material.
    #[serde(default)]
    pub detail: String,
}

/// `net-dialed` event — a dial durably landed (fresh or deduped).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetDialed {
    /// The broker-minted id addressing this connection in later net commands.
    pub conn_id: u64,
    /// The remote node's stable name (lowercase-hex pubkey), proven by the QUIC
    /// handshake (the EndpointId *is* the key).
    pub remote_id_hex: String,
    /// Echoes the request's durable op id, when one was carried.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub op_id: Option<u64>,
    /// Whether the dial ran on this delivery (`true`) or was deduped (`false`).
    /// Both mean "durably landed" — mirrors [`AppliedEvent::applied_now`].
    #[serde(default)]
    pub applied_now: bool,
    /// The peer's address as OBSERVED on the live connection (remote id +
    /// the connection's current path addresses) — a serde-serialized iroh
    /// `EndpointAddr`, opaque here. The address-seeding write-back source
    /// (M8 decision 20, REQ-CONV-1): what the pump persists into
    /// `peer-addrs.json` after a successful dial. `null` from an older
    /// broker or when no path is readable (additive serde-default field).
    #[serde(default)]
    pub remote_addr: serde_json::Value,
}

/// `net-stream-open` payload — open a bidi stream on a held connection (D4b).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetStreamOpenReq {
    /// The connection (from [`NetDialed::conn_id`]) to open the stream on.
    pub conn_id: u64,
    /// Durable op id for an exactly-once open (shares the net op-id namespace
    /// with dials/sends — one durable counter across all net effects).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub op_id: Option<u64>,
}

/// `net-stream-opened` event — a stream open durably landed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetStreamOpened {
    /// The broker-minted id addressing this stream in later commands.
    pub stream_id: u64,


Everything mapped. Let me record the crystallized design into the plan doc (resume insurance), then read nethost.rs imports to confirm types.


1	//! The broker-owned network host (D4a) — ADR-0004 §B made real for the wire.
2	//!
3	//! [`NetHost`] is the broker's ownership of the node's live network presence:
4	//! the iroh endpoint ([`spt_net::net::endpoint::NetEndpoint`]) and every
5	//! accepted/dialed QUIC connection. A live cross-node connection is exactly the
6	//! kind of resource ADR-0004 §B assigns to the broker — a consumer would lose
7	//! continuity on brain restart — so the endpoint is constructed *here*, in the
8	//! stable kernel, never in the brain. The brain drives net operations over the
9	//! versioned IPC ([`crate::msg::KIND_NET_STATUS`] / [`crate::msg::KIND_NET_DIAL`])
10	//! and may detach/reattach freely; connections persist in the broker's table.
11	//!
12	//! ## Runtime placement (M4-D4-PLAN locked decision)
13	//!
14	//! The broker is otherwise sync/thread-based; iroh requires tokio. So the host
15	//! owns a **dedicated multi-thread tokio runtime** on named worker threads
16	//! (`spt-broker-net`): QUIC objects live in structures only the runtime's tasks
17	//! and the host's own `block_on` bridges touch, and the runtime drops with the
18	//! host (broker teardown stops all net tasks). Spike #3's broker binary proved
19	//! exactly this shape — a broker-owned endpoint surviving brain kill/restart.
20	//!
21	//! ## Ownership closes two §B rows by construction
22	//!
23	//! `MdnsAddressLookup` and the relay session are both constructed *inside* the
24	//! iroh endpoint, so broker ownership of the endpoint closes the ADR-0004 §B
25	//! "mDNS socket / relay session" row with no extra machinery: there is no
26	//! separate socket to own.
27	// [impl->REQ-DAEMON-2]
28
29	use std::collections::{HashMap, HashSet, VecDeque};
30	use std::io;
31	use std::sync::atomic::{AtomicU64, Ordering};
32	use std::sync::{Arc, Mutex};
33
34	use spt_net::net::endpoint::{
35	    BindScope, Connection, EndpointAddr, LocalDiscovery, NetEndpoint, RecvStream, RelayPolicy,
36	    SendStream, SPT_NET_ALPN,
37	};
38	use spt_net::net::mesh::seedproof::ProofRole;
39	use spt_proto::identity::{Identity, PublicKey};
40
41	use crate::broker::SharedSend;
42	use crate::codec::write_frame;
43	use crate::seedproofx::{prove_membership, MembershipSource, RosterExchange};
44	use crate::msg::{
45	    net_presence_event_envelope, net_stream_data_envelope, net_stream_eof_envelope,
46	    NetPresenceEvent, NetStreamInfo, PRESENCE_CONNECTED, PRESENCE_DISCONNECTED,
47	};
48
49	/// The reserved [`crate::effect::EffectKey`] session namespace for net-scoped
50	/// effects (a dial has no PTY session). Broker session ids are minted from 1
51	/// (`Broker::next_id` starts at 1), so `0` can never collide with a real session.
52	pub const NET_EFFECT_SESSION: u64 = 0;
53
54	/// Everything needed to stand the broker's endpoint up: the node's identity
55	/// (the endpoint *is* the node's name — EndpointId == pubkey), the relay stance
56	/// (from `daemon.json` in production; [`RelayPolicy::Disabled`] in hermetic
57	/// tests), and whether mDNS LAN discovery rides along.
58	pub struct NetConfig {
59	    /// The node identity the endpoint binds as (seed → same ed25519 keypair).
60	    pub identity: Identity,
61	    /// Relay stance (ADR-0002): n0 default / self-host / disabled.
62	    pub relay: RelayPolicy,

//! The broker-owned network host (D4a) — ADR-0004 §B made real for the wire.
//!
//! [`NetHost`] is the broker's ownership of the node's live network presence:
//! the iroh endpoint ([`spt_net::net::endpoint::NetEndpoint`]) and every
//! accepted/dialed QUIC connection. A live cross-node connection is exactly the
//! kind of resource ADR-0004 §B assigns to the broker — a consumer would lose
//! continuity on brain restart — so the endpoint is constructed *here*, in the
//! stable kernel, never in the brain. The brain drives net operations over the
//! versioned IPC ([`crate::msg::KIND_NET_STATUS`] / [`crate::msg::KIND_NET_DIAL`])
//! and may detach/reattach freely; connections persist in the broker's table.
//!
//! ## Runtime placement (M4-D4-PLAN locked decision)
//!
//! The broker is otherwise sync/thread-based; iroh requires tokio. So the host
//! owns a **dedicated multi-thread tokio runtime** on named worker threads
//! (`spt-broker-net`): QUIC objects live in structures only the runtime's tasks
//! and the host's own `block_on` bridges touch, and the runtime drops with the
//! host (broker teardown stops all net tasks). Spike #3's broker binary proved
//! exactly this shape — a broker-owned endpoint surviving brain kill/restart.
//!
//! ## Ownership closes two §B rows by construction
//!
//! `MdnsAddressLookup` and the relay session are both constructed *inside* the
//! iroh endpoint, so broker ownership of the endpoint closes the ADR-0004 §B
//! "mDNS socket / relay session" row with no extra machinery: there is no
//! separate socket to own.
// [impl->REQ-DAEMON-2]

use std::collections::{HashMap, HashSet, VecDeque};
use std::io;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};

use spt_net::net::endpoint::{
    BindScope, Connection, EndpointAddr, LocalDiscovery, NetEndpoint, RecvStream, RelayPolicy,
    SendStream, SPT_NET_ALPN,
};
use spt_net::net::mesh::seedproof::ProofRole;
use spt_proto::identity::{Identity, PublicKey};

use crate::broker::SharedSend;
use crate::codec::write_frame;
use crate::seedproofx::{prove_membership, MembershipSource, RosterExchange};
use crate::msg::{
    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
/// (`Broker::next_id` starts at 1), so `0` can never collide with a real session.
pub const NET_EFFECT_SESSION: u64 = 0;

/// Everything needed to stand the broker's endpoint up: the node's identity
/// (the endpoint *is* the node's name — EndpointId == pubkey), the relay stance
/// (from `daemon.json` in production; [`RelayPolicy::Disabled`] in hermetic
/// tests), and whether mDNS LAN discovery rides along.
pub struct NetConfig {
    /// The node identity the endpoint binds as (seed → same ed25519 keypair).
    pub identity: Identity,
    /// Relay stance (ADR-0002): n0 default / self-host / disabled.
    pub relay: RelayPolicy,


Types confirmed (`Connection`/`SendStream`/`RecvStream` from spt_net; `local_id: PublicKey`; tokio available for `duplex`). Recording crystallized design to plan, then coding.


Edit

## Notes
- rc.rs today codes the refused self-dial → LOCAL `spt rc` errors at runtime until T1.5.3
  (expected; local is W1.5). Cross-node rc works now.
- Commit only when the operator asks (not on default branch unprompted).

## Crystallized design (2026-06-14, post-investigation — doyle GO, all 3 Qs answered)
doyle answers: (1) origin = THIS node's own id, broker-minted ONLY (never wire-derivable); access
`same-node short-circuit` already Allows (access.rs:89, documented access.rs:26-27) — NO access
change. (2) explicit new frame KIND `net-dial-loopback` (NOT sentinel addr). (3) one-pump =
by-construction via a SHARED serve_attach+pump test helper both cross-node + local int call.

**Transport seam = `tokio::io::duplex(CAP)`** — one full-duplex pair wires an entire operator↔peer
stream. Split each end (`tokio::io::split`); feed the SAME `StreamLog`/subscribe/send_stream/read-pump
machinery — only the LEAF send/recv halves become enums. That IS the one-pump invariant by construction.

- **nethost.rs**: `ConnEntry.conn` → `enum ConnKind { Quic(Connection), Loopback }`
  (`conn_remote_addr_json` Loopback→Null). `StreamEntry.send` → `enum SendHalf { Quic(SendStream),
  Loopback(WriteHalf<DuplexStream>) }` (async `write_all` + `finish`= quinn `finish()` / loopback
  `shutdown()`). Read pump takes `enum RecvHalf { Quic(RecvStream), Loopback(ReadHalf<DuplexStream>) }`
  — Quic `read_chunk`, Loopback `AsyncReadExt::read`. `register_stream` accepts SendHalf+RecvHalf.
  - `dial_loopback(&self)->(u64,String)`: SINGLETON (`loopback_conn: Mutex<Option<u64>>`), mints/reuses
    a `ConnKind::Loopback` row, `remote_id_hex = self.local_id.to_hex()`, NO closed-watcher/accept-loop.
  - `open_stream` branches on ConnKind::Loopback → `duplex(CAP)`→split→register TWO rows: operator
    (initiated_locally=true) + peer (initiated_locally=false, own hex). Returns operator stream_id.
    Peer row → dispatch loop → serve_attach with own-node origin → access Allow by construction.
- **msg.rs**: `KIND_NET_DIAL_LOOPBACK = "net-dial-loopback"`; reuse `NetDialed` reply; req payload Null
  (non-journaled — singleton is idempotent by reuse, no op needed).
- **brain.rs**: `net_dial_loopback(&mut self)->io::Result<NetDialed>`.
- **broker.rs**: dispatch arm `KIND_NET_DIAL_LOOPBACK`→`dispatch_net_dial_loopback`: `host.dial_loopback()`
  →`net_dialed_envelope(conn_id, remote, None, true, Null)`.
- **rc.rs**: swap lines 192-195 (`net_status().addr`+`net_dial`) → `net_dial_loopback()`.
- **tests/attach.rs**: extract shared helper driving request_attach→subscribe→wait_for_stream→serve_attach
  thread→input→render_until→detach→assert(Detached + session outlives). Cross-node + new local
  `local_attach_via_loopback_conn_rides_the_same_pump` BOTH call it. Tag `[int->REQ-RC-1]`.

## Notes
- rc.rs today codes the refused self-dial → LOCAL `spt rc` errors at runtime until T1.5.3
  (expected; local is W1.5). Cross-node rc works now.
- Commit only when the operator asks (not on default branch unprompted).


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

## Crystallized design (2026-06-14, post-investigation — doyle GO, all 3 Qs answered)
doyle answers: (1) origin = THIS node's own id, broker-minted ONLY (never wire-derivable); access
`same-node short-circuit` already Allows (access.rs:89, documented access.rs:26-27) — NO access
change. (2) explicit new frame KIND `net-dial-loopback` (NOT sentinel addr). (3) one-pump =
by-construction via a SHARED serve_attach+pump test helper both cross-node + local int call.

**Transport seam = `tokio::io::duplex(CAP)`** — one full-duplex pair wires an entire operator↔peer
stream. Split each end (`tokio::io::split`); feed the SAME `StreamLog`/subscribe/send_stream/read-pump
machinery — only the LEAF send/recv halves become enums. That IS the one-pump invariant by construction.

- **nethost.rs**: `ConnEntry.conn` → `enum ConnKind { Quic(Connection), Loopback }`
  (`conn_remote_addr_json` Loopback→Null). `StreamEntry.send` → `enum SendHalf { Quic(SendStream),
  Loopback(WriteHalf<DuplexStream>) }` (async `write_all` + `finish`= quinn `finish()` / loopback
  `shutdown()`). Read pump takes `enum RecvHalf { Quic(RecvStream), Loopback(ReadHalf<DuplexStream>) }`
  — Quic `read_chunk`, Loopback `AsyncReadExt::read`. `register_stream` accepts SendHalf+RecvHalf.
  - `dial_loopback(&self)->(u64,String)`: SINGLETON (`loopback_conn: Mutex<Option<u64>>`), mints/reuses
    a `ConnKind::Loopback` row, `remote_id_hex = self.local_id.to_hex()`, NO closed-watcher/accept-loop.
  - `open_stream` branches on ConnKind::Loopback → `duplex(CAP)`→split→register TWO rows: operator
    (initiated_locally=true) + peer (initiated_locally=false, own hex). Returns operator stream_id.
    Peer row → dispatch loop → serve_attach with own-node origin → access Allow by construction.
- **msg.rs**: `KIND_NET_DIAL_LOOPBACK = "net-dial-loopback"`; reuse `NetDialed` reply; req payload Null
  (non-journaled — singleton is idempotent by reuse, no op needed).
- **brain.rs**: `net_dial_loopback(&mut self)->io::Result<NetDialed>`.
- **broker.rs**: dispatch arm `KIND_NET_DIAL_LOOPBACK`→`dispatch_net_dial_loopback`: `host.dial_loopback()`
  →`net_dialed_envelope(conn_id, remote, None, true, Null)`.
- **rc.rs**: swap lines 192-195 (`net_status().addr`+`net_dial`) → `net_dial_loopback()`.
- **tests/attach.rs**: extract shared helper driving request_attach→subscribe→wait_for_stream→serve_attach
  thread→input→render_until→detach→assert(Detached + session outlives). Cross-node + new local
  `local_attach_via_loopback_conn_rides_the_same_pump` BOTH call it. Tag `[int->REQ-RC-1]`.

## Notes
- rc.rs today codes the refused self-dial → LOCAL `spt rc` errors at runtime until T1.5.3
  (expected; local is W1.5). Cross-node rc works now.
- Commit only when the operator asks (not on default branch unprompted).

# M12 Wave 1.5 — broker loopback-conn + local `spt rc` attach

> JIT plan (todlando, 2026-06-14). doyle-scoped after W1 PASS. Goal: LOCAL `spt rc <id>`
> attach rides the BYTE-IDENTICAL `serve_attach` pump as cross-node — because self-dial is
> impossible (iroh: "Connecting to ourself is not supported", pinned by
> `loopback_self_dial_is_refused_local_uses_fallback_transport`). doyle ruling = **B1**: an
> in-process broker loopback connection, NOT B2 local-direct (which would reintroduce a
> lesser-tested local branch). Sensitive broker-layer work (KNOWN-HAZARDS 6.7 zone).

## The invariant (doyle, binding)
ONE `serve_attach` pump. Transport is swappable UNDER the stream. Cross-node = QUIC conn;
local = broker loopback conn. Both surface as broker net-stream events (`NetStreamData` /
`net_stream_subscribe` / `net_stream_send`) so `serve_attach` (target) + the `rc::pump`
(operator) are unchanged. The W1.5 int test MUST assert both transports hit the same pump.

## What already exists (W1)
- Operator pump: `spt/src/rc.rs` `run_attach` + `pump` (transport-invariant — reads
  `NetStreamData`, writes via `send_attach_input`). Currently codes the now-REFUSED self-dial
  (`brain.net_dial(net_status().addr)`) — **this is the one line W1.5 swaps** to a loopback-dial.
- Target serve: `spt-daemon/src/attach.rs serve_attach`, auto-dispatched at
  `dispatch.rs:335-360 StreamFamily::Attach` (resolves session_id→endpoint via `brain.sessions()`).
- Cross-node conn: `brain.net_dial(addr,op) -> NetDialed{conn_id}` (brain.rs:800) →
  `NetHost::dial` (nethost.rs:615) → iroh connect → registers in `NetShared::conns`.

## B1 design (the loopback conn)
A broker-internal connection with NO iroh handshake whose two bidi stream ends are wired
in-process, so a stream opened by the operator side delivers its bytes as `NetStreamData` to
a subscriber on the "remote" side (which the dispatch loop serves via `serve_attach`), and
vice-versa. Local `spt rc`: loopback-dial → `conn_id` → `request_attach(conn_id, session_id)`
→ subscribe → pump — identical to cross-node from `request_attach` up.

### Investigation TODOs (read before coding — NOT yet done)
1. **`spt-daemon/src/broker.rs`**: the conn table + stream table + how `dispatch_*` routes an
   inbound stream's first frame to `StreamFamily::Attach`→`serve_attach`. How a QUIC stream's
   bytes become `NetStreamData` events (the accept/serve plumbing). Find the seam to inject a
   local pair.
2. **`spt-daemon/src/nethost.rs`** (`NetHost::dial` ~615, `NetShared::conns`): how conns get a
   `conn_id` + how streams open on a conn. Add a `dial_loopback()` / a `Conn::Loopback` variant
   that needs no iroh endpoint.
3. **Stream open + cross-wire**: `net_open_stream(conn_id)` on a loopback conn must mint a
   stream whose sends loop back to a peer stream id the dispatch loop picks up as inbound.
   Likely an in-process channel pair inside the broker, surfaced through the SAME
   `NetStreamData`/`net_stream_send`/`net_stream_subscribe` IPC the QUIC path uses.
4. **Brain-side**: a `brain.net_dial_loopback(op) -> conn_id` (or reuse `net_dial` with a
   sentinel local addr the broker recognizes). Decide: new frame KIND vs sentinel addr.
5. **Origin for the gate**: `serve_attach`'s `origin_node` is the handshake-proven id. A
   loopback conn has no handshake — use THIS node's own id as origin (access_check is
   default-open for self; confirm). Don't fabricate a foreign origin.

## Tasks
- **T1.5.1** Broker loopback conn: `Conn::Loopback` + in-process stream cross-wiring; inbound
  loopback streams dispatch to `serve_attach` exactly like QUIC. (broker.rs + nethost.rs)
- **T1.5.2** `brain.net_dial_loopback` (or sentinel) → `conn_id`; minimal Brain API.
- **T1.5.3** Swap `rc.rs` conn-acquisition: local `run_attach` loopback-dials instead of
  `net_dial(own_addr)`. (One seam; pump unchanged.)
- **T1.5.4** Int test `local_attach_via_loopback_conn_rides_the_same_pump`: spawn a session,
  loopback-dial, request_attach, drive input, render echo, detach→session outlives. PLUS the
  **one-pump assertion**: both the cross-node int and this local int exercise the same
  `serve_attach`+pump path (assert by construction / shared helper, per doyle).
- **T1.5.5** Activate REQ-RC-1 local int evidence (int already active from W1 cross-node face;
  add the `[int->REQ-RC-1]` tag on the local test). `traceable-reqs check` EXIT=0.

## Gate (doyle)
Local `spt rc` attaches a broker-held PTY via the loopback conn over the byte-identical pump;
both transports proven to hit one pump; full suite + clippy -D warnings + traceable EXIT=0.
Then **REQ-HOST-RUN-2** (project cwd via additive `SpawnReq.cwd`) before the M12 gate.

## Notes
- rc.rs today codes the refused self-dial → LOCAL `spt rc` errors at runtime until T1.5.3
  (expected; local is W1.5). Cross-node rc works now.
- Commit only when the operator asks (not on default branch unprompted).

+## Crystallized design (2026-06-14, post-investigation — doyle GO, all 3 Qs answered)

+doyle answers: (1) origin = THIS node's own id, broker-minted ONLY (never wire-derivable); access

+`same-node short-circuit` already Allows (access.rs:89, documented access.rs:26-27) — NO access

+change. (2) explicit new frame KIND `net-dial-loopback` (NOT sentinel addr). (3) one-pump =

+by-construction via a SHARED serve_attach+pump test helper both cross-node + local int call.

+**Transport seam = `tokio::io::duplex(CAP)`** — one full-duplex pair wires an entire operator↔peer

+stream. Split each end (`tokio::io::split`); feed the SAME `StreamLog`/subscribe/send_stream/read-pump

+machinery — only the LEAF send/recv halves become enums. That IS the one-pump invariant by construction.

+- **nethost.rs**: `ConnEntry.conn` → `enum ConnKind { Quic(Connection), Loopback }`

+  (`conn_remote_addr_json` Loopback→Null). `StreamEntry.send` → `enum SendHalf { Quic(SendStream),

+  Loopback(WriteHalf<DuplexStream>) }` (async `write_all` + `finish`= quinn `finish()` / loopback

+  `shutdown()`). Read pump takes `enum RecvHalf { Quic(RecvStream), Loopback(ReadHalf<DuplexStream>) }`

+  — Quic `read_chunk`, Loopback `AsyncReadExt::read`. `register_stream` accepts SendHalf+RecvHalf.

+  - `dial_loopback(&self)->(u64,String)`: SINGLETON (`loopback_conn: Mutex<Option<u64>>`), mints/reuses

+    a `ConnKind::Loopback` row, `remote_id_hex = self.local_id.to_hex()`, NO closed-watcher/accept-loop.

+  - `open_stream` branches on ConnKind::Loopback → `duplex(CAP)`→split→register TWO rows: operator

+    (initiated_locally=true) + peer (initiated_locally=false, own hex). Returns operator stream_id.

+    Peer row → dispatch loop → serve_attach with own-node origin → access Allow by construction.

+- **msg.rs**: `KIND_NET_DIAL_LOOPBACK = "net-dial-loopback"`; reuse `NetDialed` reply; req payload Null

+  (non-journaled — singleton is idempotent by reuse, no op needed).

+- **brain.rs**: `net_dial_loopback(&mut self)->io::Result<NetDialed>`.

+- **broker.rs**: dispatch arm `KIND_NET_DIAL_LOOPBACK`→`dispatch_net_dial_loopback`: `host.dial_loopback()`

+  →`net_dialed_envelope(conn_id, remote, None, true, Null)`.

+- **rc.rs**: swap lines 192-195 (`net_status().addr`+`net_dial`) → `net_dial_loopback()`.

+- **tests/attach.rs**: extract shared helper driving request_attach→subscribe→wait_for_stream→serve_attach

+  thread→input→render_until→detach→assert(Detached + session outlives). Cross-node + new local

+  `local_attach_via_loopback_conn_rides_the_same_pump` BOTH call it. Tag `[int->REQ-RC-1]`.


Now check REQ-RC-1 / REQ-HOST-RUN stages in traceable-reqs.toml, then begin coding T1.5.1 (nethost.rs).


446:id = "REQ-HOST-RUN-1"
447-[Omitted long context line]
448:required_stages = ["impl", "unit", "int"]  # activated M12-W1: impl = harnesshost::{prepare_harness_spawn, launch_harness_brokered_in} + cli cmd_endpoint_run; unit = harnesshost template-fill/fail-closed/mint; int = spt_hosted_bringup_then_cross_node_attach_drives_the_pty (bringup spawns into the broker PTY, cross-node attach drives it). cwd/project deferred to REQ-HOST-RUN-2 (W1 ships broker-inherited cwd)
449-[[requirements]]
450:id = "REQ-RC-1"
451-[Omitted long context line]
452-required_stages = ["impl", "unit", "int"]  # activated M12-W1: impl = rc::run_attach pump (single-Brain + stdin-thread + seq-dedup + ctrl-b detach) + cli Cmd::Rc; unit = detach-keybind semantics + prefix-spans-chunks + op-minter; int (CROSS-NODE face) = spt_hosted_bringup_then_cross_node_attach_drives_the_pty + loopback_self_dial_is_refused (pins the local-transport choice). LOCAL face int (loopback-conn B1) lands W1.5 with the "both transports → one pump" assertion (doyle 2026-06-14)
453-[[requirements]]
454:id = "REQ-HOST-RUN-2"
455-[Omitted long context line]
456-required_stages = []
457-
458-# ───────────────────────────── Install / migration / infra ──────────────────
459-[[requirements]]
460-id = "REQ-INSTALL-1"


REQ-RC-1 `int` already active — local test just adds same-stage evidence + tag, no toml change. Now implement T1.5.1 in nethost.rs. Start: imports + `ConnKind`.


use spt_net::net::endpoint::{
    BindScope, Connection, EndpointAddr, LocalDiscovery, NetEndpoint, RecvStream, RelayPolicy,
    SendStream, SPT_NET_ALPN,
};

use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream, ReadHalf, WriteHalf};

use spt_net::net::endpoint::{
    BindScope, Connection, EndpointAddr, LocalDiscovery, NetEndpoint, RecvStream, RelayPolicy,
    SendStream, SPT_NET_ALPN,
};


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 tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream, ReadHalf, WriteHalf};

use spt_net::net::endpoint::{
    BindScope, Connection, EndpointAddr, LocalDiscovery, NetEndpoint, RecvStream, RelayPolicy,
    SendStream, SPT_NET_ALPN,
};

+use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream, ReadHalf, WriteHalf};


258	                self.subscriber = None;
259	            }
260	        }
261	    }
262	}
263
264	/// One live QUIC stream the broker holds: the send half (brain writes ride it
265	/// over IPC) + the sequenced read log.
266	struct StreamEntry {
267	    conn_id: u64,
268	    remote_id_hex: String,
269	    /// Whether THIS node opened the stream (`open_stream`) vs the peer
270	    /// (accept loop). The inbound dispatcher (D9-1) claims only
271	    /// peer-initiated streams; without this fact the table is ambiguous —
272	    /// it holds locally-opened outbound streams too (gotcha: a dispatcher
273	    /// that served its own requester stream would deadlock on itself).
274	    initiated_locally: bool,
275	    /// The send half, serialized behind an async mutex (writes happen on the
276	    /// host's runtime via `block_on`).
277	    send: tokio::sync::Mutex<SendStream>,
278	    log: Arc<Mutex<StreamLog>>,
279	}
280
281	/// Tables shared between the host's sync surface and its runtime tasks
282	/// (accept loop, per-conn stream acceptors, per-stream read pumps).

                self.subscriber = None;
            }
        }
    }
}

/// One live QUIC stream the broker holds: the send half (brain writes ride it
/// over IPC) + the sequenced read log.
struct StreamEntry {
    conn_id: u64,
    remote_id_hex: String,
    /// Whether THIS node opened the stream (`open_stream`) vs the peer
    /// (accept loop). The inbound dispatcher (D9-1) claims only
    /// peer-initiated streams; without this fact the table is ambiguous —
    /// it holds locally-opened outbound streams too (gotcha: a dispatcher
    /// that served its own requester stream would deadlock on itself).
    initiated_locally: bool,
    /// The send half, serialized behind an async mutex (writes happen on the
    /// host's runtime via `block_on`).
    send: tokio::sync::Mutex<SendStream>,
    log: Arc<Mutex<StreamLog>>,
}

/// Tables shared between the host's sync surface and its runtime tasks
/// (accept loop, per-conn stream acceptors, per-stream read pumps).


Now edit ConnEntry → ConnKind:


/// One live QUIC connection the broker holds.
struct ConnEntry {
    /// The connection itself — kept alive by ownership here; dropping it closes
    /// the QUIC connection. Cloned (cheap handle) for stream opens.
    conn: Connection,
    /// The remote node's stable name (hex pubkey), proven by the QUIC handshake.
    remote_id_hex: String,

/// Transport backing a held connection. A real `Quic` connection (the only
/// kind an inbound handshake can produce), or a broker-minted `Loopback` —
/// an in-process connection with NO iroh handshake whose identity IS this node,
/// established BY CONSTRUCTION. A loopback conn can ONLY be minted broker-side
/// via [`NetHost::dial_loopback`]; it can never arise from an inbound handshake
/// (a remote always presents its own key and cannot claim to be us). This is
/// what lets a LOCAL `spt rc` ride the byte-identical `serve_attach` pump (M12
/// W1.5, B1) — QUIC self-dial is refused by iroh ("Connecting to ourself is not
/// supported"), so local attach rides a broker loopback conn instead.
enum ConnKind {
    /// A real QUIC connection — kept alive by ownership here; dropping it closes
    /// the connection. Cloned (cheap handle) for stream opens.
    Quic(Connection),
    /// A broker-minted in-process loopback (no iroh handle, no handshake). Its
    /// streams are minted eagerly in cross-wired pairs by [`NetHost::open_stream`];
    /// there is no accept loop and no closed-watcher (it lives for the host).
    Loopback,
}

/// One live connection the broker holds (QUIC or loopback).
struct ConnEntry {
    /// The transport backing this connection.
    kind: ConnKind,
    /// The remote node's stable name (hex pubkey). For QUIC, proven by the
    /// handshake; for loopback, THIS node's own id (the broker-minted identity).
    remote_id_hex: String,


/// One live QUIC connection the broker holds.
struct ConnEntry {
    /// The connection itself — kept alive by ownership here; dropping it closes
    /// the QUIC connection. Cloned (cheap handle) for stream opens.
    conn: Connection,
    /// The remote node's stable name (hex pubkey), proven by the QUIC handshake.
    remote_id_hex: String,

/// Transport backing a held connection. A real `Quic` connection (the only
/// kind an inbound handshake can produce), or a broker-minted `Loopback` —
/// an in-process connection with NO iroh handshake whose identity IS this node,
/// established BY CONSTRUCTION. A loopback conn can ONLY be minted broker-side
/// via [`NetHost::dial_loopback`]; it can never arise from an inbound handshake
/// (a remote always presents its own key and cannot claim to be us). This is
/// what lets a LOCAL `spt rc` ride the byte-identical `serve_attach` pump (M12
/// W1.5, B1) — QUIC self-dial is refused by iroh ("Connecting to ourself is not
/// supported"), so local attach rides a broker loopback conn instead.
enum ConnKind {
    /// A real QUIC connection — kept alive by ownership here; dropping it closes
    /// the connection. Cloned (cheap handle) for stream opens.
    Quic(Connection),
    /// A broker-minted in-process loopback (no iroh handle, no handshake). Its
    /// streams are minted eagerly in cross-wired pairs by [`NetHost::open_stream`];
    /// there is no accept loop and no closed-watcher (it lives for the host).
    Loopback,
}

/// One live connection the broker holds (QUIC or loopback).
struct ConnEntry {
    /// The transport backing this connection.
    kind: ConnKind,
    /// The remote node's stable name (hex pubkey). For QUIC, proven by the
    /// handshake; for loopback, THIS node's own id (the broker-minted identity).
    remote_id_hex: String,

/// Max bytes pulled per stream read (one ring chunk).

const STREAM_READ_CHUNK: usize = 64 * 1024;

-/// One live QUIC connection the broker holds.

+/// Transport backing a held connection. A real `Quic` connection (the only

+/// kind an inbound handshake can produce), or a broker-minted `Loopback` —

+/// an in-process connection with NO iroh handshake whose identity IS this node,

+/// established BY CONSTRUCTION. A loopback conn can ONLY be minted broker-side

+/// via [`NetHost::dial_loopback`]; it can never arise from an inbound handshake

+/// (a remote always presents its own key and cannot claim to be us). This is

+/// what lets a LOCAL `spt rc` ride the byte-identical `serve_attach` pump (M12

+/// W1.5, B1) — QUIC self-dial is refused by iroh ("Connecting to ourself is not

+/// supported"), so local attach rides a broker loopback conn instead.

+    /// A real QUIC connection — kept alive by ownership here; dropping it closes

+    /// the connection. Cloned (cheap handle) for stream opens.

+    /// A broker-minted in-process loopback (no iroh handle, no handshake). Its

+    /// streams are minted eagerly in cross-wired pairs by [`NetHost::open_stream`];

+    /// there is no accept loop and no closed-watcher (it lives for the host).

+/// One live connection the broker holds (QUIC or loopback).

-    /// The connection itself — kept alive by ownership here; dropping it closes

-    /// the QUIC connection. Cloned (cheap handle) for stream opens.

-    /// The remote node's stable name (hex pubkey), proven by the QUIC handshake.

+    /// The transport backing this connection.

+    /// The remote node's stable name (hex pubkey). For QUIC, proven by the

+    /// handshake; for loopback, THIS node's own id (the broker-minted identity).

/// The subnets the peer proved current-epoch seed-knowledge of at connect

/// (Mesh-D2, REQ-MESH-1). Empty when seed-proof is disabled (`membership:


Now StreamEntry.send → SendHalf, plus add SendHalf/RecvHalf enums before StreamEntry:


/// One live QUIC stream the broker holds: the send half (brain writes ride it
/// over IPC) + the sequenced read log.
struct StreamEntry {
    conn_id: u64,
    remote_id_hex: String,
    /// Whether THIS node opened the stream (`open_stream`) vs the peer
    /// (accept loop). The inbound dispatcher (D9-1) claims only
    /// peer-initiated streams; without this fact the table is ambiguous —
    /// it holds locally-opened outbound streams too (gotcha: a dispatcher
    /// that served its own requester stream would deadlock on itself).
    initiated_locally: bool,
    /// The send half, serialized behind an async mutex (writes happen on the
    /// host's runtime via `block_on`).
    send: tokio::sync::Mutex<SendStream>,
    log: Arc<Mutex<StreamLog>>,
}

/// A stream's send half — the same two transports as [`ConnKind`]. The
/// `serve_attach`/operator pump and the `StreamLog` read discipline are
/// IDENTICAL across both; only this leaf write differs (QUIC stream vs the
/// write end of an in-process duplex). That is the M12 W1.5 one-pump invariant
/// made structural: a loopback attach and a cross-node attach diverge at this
/// leaf and nowhere above it.
enum SendHalf {
    Quic(SendStream),
    Loopback(WriteHalf<DuplexStream>),
}

impl SendHalf {
    /// Write all `bytes` (no-op on empty). Runs on the host runtime.
    async fn write_all(&mut self, bytes: &[u8]) -> io::Result<()> {
        match self {
            SendHalf::Quic(s) => s
                .write_all(bytes)
                .await
                .map_err(|e| io::Error::other(e.to_string())),
            SendHalf::Loopback(w) => w.write_all(bytes).await,
        }
    }

    /// Close the send side so the peer's read pump sees a clean end (QUIC
    /// `finish` / duplex `shutdown`).
    async fn finish(&mut self) -> io::Result<()> {
        match self {
            SendHalf::Quic(s) => s.finish().map_err(|e| io::Error::other(e.to_string())),
            SendHalf::Loopback(w) => w.shutdown().await,
        }
    }
}

/// A stream's read half — fed into the single read pump in [`register_stream`].
/// QUIC reads via quinn's chunk API; loopback via the read end of the duplex.
/// Both append to the same [`StreamLog`], so the subscriber (and everything
/// above it) cannot tell the transports apart.
enum RecvHalf {
    Quic(RecvStream),
    Loopback(ReadHalf<DuplexStream>),
}

/// One live stream the broker holds (QUIC or loopback): the send half (brain
/// writes ride it over IPC) + the sequenced read log.
struct StreamEntry {
    conn_id: u64,
    remote_id_hex: String,
    /// Whether THIS node opened the stream (`open_stream`) vs the peer
    /// (accept loop). The inbound dispatcher (D9-1) claims only
    /// peer-initiated streams; without this fact the table is ambiguous —
    /// it holds locally-opened outbound streams too (gotcha: a dispatcher
    /// that served its own requester stream would deadlock on itself).
    initiated_locally: bool,
    /// The send half, serialized behind an async mutex (writes happen on the
    /// host's runtime via `block_on`).
    send: tokio::sync::Mutex<SendHalf>,
    log: Arc<Mutex<StreamLog>>,
}


/// One live QUIC stream the broker holds: the send half (brain writes ride it
/// over IPC) + the sequenced read log.
struct StreamEntry {
    conn_id: u64,
    remote_id_hex: String,
    /// Whether THIS node opened the stream (`open_stream`) vs the peer
    /// (accept loop). The inbound dispatcher (D9-1) claims only
    /// peer-initiated streams; without this fact the table is ambiguous —
    /// it holds locally-opened outbound streams too (gotcha: a dispatcher
    /// that served its own requester stream would deadlock on itself).
    initiated_locally: bool,
    /// The send half, serialized behind an async mutex (writes happen on the
    /// host's runtime via `block_on`).
    send: tokio::sync::Mutex<SendStream>,
    log: Arc<Mutex<StreamLog>>,
}

/// A stream's send half — the same two transports as [`ConnKind`]. The
/// `serve_attach`/operator pump and the `StreamLog` read discipline are
/// IDENTICAL across both; only this leaf write differs (QUIC stream vs the
/// write end of an in-process duplex). That is the M12 W1.5 one-pump invariant
/// made structural: a loopback attach and a cross-node attach diverge at this
/// leaf and nowhere above it.
enum SendHalf {
    Quic(SendStream),
    Loopback(WriteHalf<DuplexStream>),
}

impl SendHalf {
    /// Write all `bytes` (no-op on empty). Runs on the host runtime.
    async fn write_all(&mut self, bytes: &[u8]) -> io::Result<()> {
        match self {
            SendHalf::Quic(s) => s
                .write_all(bytes)
                .await
                .map_err(|e| io::Error::other(e.to_string())),
            SendHalf::Loopback(w) => w.write_all(bytes).await,
        }
    }

    /// Close the send side so the peer's read pump sees a clean end (QUIC
    /// `finish` / duplex `shutdown`).
    async fn finish(&mut self) -> io::Result<()> {
        match self {
            SendHalf::Quic(s) => s.finish().map_err(|e| io::Error::other(e.to_string())),
            SendHalf::Loopback(w) => w.shutdown().await,
        }
    }
}

/// A stream's read half — fed into the single read pump in [`register_stream`].
/// QUIC reads via quinn's chunk API; loopback via the read end of the duplex.
/// Both append to the same [`StreamLog`], so the subscriber (and everything
/// above it) cannot tell the transports apart.
enum RecvHalf {
    Quic(RecvStream),
    Loopback(ReadHalf<DuplexStream>),
}

/// One live stream the broker holds (QUIC or loopback): the send half (brain
/// writes ride it over IPC) + the sequenced read log.
struct StreamEntry {
    conn_id: u64,
    remote_id_hex: String,
    /// Whether THIS node opened the stream (`open_stream`) vs the peer
    /// (accept loop). The inbound dispatcher (D9-1) claims only
    /// peer-initiated streams; without this fact the table is ambiguous —
    /// it holds locally-opened outbound streams too (gotcha: a dispatcher
    /// that served its own requester stream would deadlock on itself).
    initiated_locally: bool,
    /// The send half, serialized behind an async mutex (writes happen on the
    /// host's runtime via `block_on`).
    send: tokio::sync::Mutex<SendHalf>,
    log: Arc<Mutex<StreamLog>>,
}

-/// One live QUIC stream the broker holds: the send half (brain writes ride it

-/// over IPC) + the sequenced read log.

+/// A stream's send half — the same two transports as [`ConnKind`]. The

+/// `serve_attach`/operator pump and the `StreamLog` read discipline are

+/// IDENTICAL across both; only this leaf write differs (QUIC stream vs the

+/// write end of an in-process duplex). That is the M12 W1.5 one-pump invariant

+/// made structural: a loopback attach and a cross-node attach diverge at this

+    /// Write all `bytes` (no-op on empty). Runs on the host runtime.

+    async fn write_all(&mut self, bytes: &[u8]) -> io::Result<()> {

+                .map_err(|e| io::Error::other(e.to_string())),

+            SendHalf::Loopback(w) => w.write_all(bytes).await,

+    /// Close the send side so the peer's read pump sees a clean end (QUIC

+    async fn finish(&mut self) -> io::Result<()> {

+            SendHalf::Quic(s) => s.finish().map_err(|e| io::Error::other(e.to_string())),

+            SendHalf::Loopback(w) => w.shutdown().await,

+/// A stream's read half — fed into the single read pump in [`register_stream`].

+/// QUIC reads via quinn's chunk API; loopback via the read end of the duplex.

+/// Both append to the same [`StreamLog`], so the subscriber (and everything

+/// above it) cannot tell the transports apart.

+/// One live stream the broker holds (QUIC or loopback): the send half (brain

+/// writes ride it over IPC) + the sequenced read log.

-    send: tokio::sync::Mutex<SendStream>,

+    send: tokio::sync::Mutex<SendHalf>,


Update `register_conn` ConnEntry construction + its accept-loop `register_stream` call to wrap Quic halves: