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;
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	static SEQ: AtomicU32 = AtomicU32::new(0);
38	fn unique_name() -> String {
39	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
40	    format!("spt-daemon-d5b-{}-{}.sock", std::process::id(), n)
41	}
42
43	fn hermetic() -> NetConfig {
44	    NetConfig {
45	        identity: Identity::generate(),
46	        relay: RelayPolicy::Disabled,
47	        discovery: LocalDiscovery::Off,
48	        scope: BindScope::Loopback,
49	    }
50	}
51
52	fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
53	    let host = NetHost::start(hermetic()).expect("net host start");
54	    let broker =
55	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
56	    let serve = Arc::clone(&broker);
57	    thread::spawn(move || {
58	        let _ = serve.serve();
59	    });
60	    broker
61	}
62
63	fn connect_retry(name: &str) -> Brain {
64	    for _ in 0..300 {
65	        if let Ok(b) = Brain::cold_start(name, 1) {
66	            return b;
67	        }
68	        thread::sleep(Duration::from_millis(10));
69	    }
70	    panic!("brain could not connect");
71	}
72
73	fn echo_spawn_req() -> SpawnReq {
74	    #[cfg(unix)]
75	    let (program, args) = ("cat".to_string(), Vec::<String>::new());
76	    #[cfg(windows)]
77	    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
78	    SpawnReq { program, args, rows: 24, cols: 80 }
79	}
80
81	/// Poll the brain's `net-streams` until a stream appears; return
82	/// `(stream_id, remote_id_hex)`.
83	fn wait_for_stream(brain: &mut Brain) -> (u64, String) {
84	    for _ in 0..400 {
85	        let reply = brain.net_streams().expect("net-streams");
86	        if let Some(s) = reply.streams.first() {
87	            return (s.stream_id, s.remote_id_hex.clone());
88	        }
89	        thread::sleep(Duration::from_millis(5));
90	    }
91	    panic!("attach stream never appeared at the target's broker");
92	}
93
94	/// Operator-side viewport: consume attach-stream records, dedup `Output` by
95	/// seq, append rendered bytes to `buf`, until `needle` is rendered. Returns
96	/// the rendered-through cursor (last accepted seq + 1).
97	fn render_until(
98	    brain: &mut Brain,
99	    stream_id: u64,
100	    decoder: &mut AttachDecoder,
101	    next_seq: &mut u64,
102	    buf: &mut Vec<u8>,
103	    needle: &[u8],
104	) {
105	    let contains = |hay: &[u8], needle: &[u8]| {
106	        hay.windows(needle.len()).any(|w| w == needle)
107	    };
108	    while !contains(buf, needle) {
109	        match brain.read_event().expect("operator read event") {
110	            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
111	                for rec in decoder.push(&bytes) {
112	                    if let AttachRecord::Output { seq, data_b64 } = rec {
113	                        if seq < *next_seq {
114	                            continue; // replay duplicate — dedup by cursor
115	                        }
116	                        assert_eq!(seq, *next_seq, "no forward gap in the viewport");
117	                        *next_seq = seq + 1;
118	                        buf.extend_from_slice(&decode_bytes(&data_b64).expect("chunk b64"));
119	                    }
120	                }
121	            }
122	            BrokerEvent::NetStreamEof { .. } => panic!("stream ended before the marker rendered"),
123	            BrokerEvent::Error { message } => panic!("operator broker error: {message}"),
124	            _ => {}
125	        }
126	    }
127	}
128
129	/// Count non-overlapping occurrences of `needle` in `hay`.
130	fn count(hay: &[u8], needle: &[u8]) -> usize {
131	    if needle.is_empty() || hay.len() < needle.len() {
132	        return 0;
133	    }
134	    let (mut n, mut i) = (0, 0);
135	    while i + needle.len() <= hay.len() {
136	        if &hay[i..i + needle.len()] == needle {
137	            n += 1;
138	            i += needle.len();
139	        } else {
140	            i += 1;
141	        }
142	    }
143	    n
144	}
145
146	// [unit->REQ-INST-8] drive an agent('s session) on machine A from machine B:
147	// B's keystrokes reach A's real PTY child over the wire (journaled input
148	// path), the child's output streams back as sequenced records, and B renders
149	// its own marker echoed remotely. The serve gate sees the handshake-proven
150	// origin; B detaching ends the viewport with the session still running on A.
151	#[test]
152	fn remote_attach_drives_a_real_pty_cross_daemon() {
153	    let dir = tempfile::tempdir().expect("tempdir");
154	    let (name_a, name_b) = (unique_name(), unique_name());
155	    let broker_a = net_broker(&name_a, &dir.path().join("a"));
156	    let _broker_b = net_broker(&name_b, &dir.path().join("b"));
157
158	    // Target A: spawn the echo child.
159	    let mut target = connect_retry(&name_a);
160	    let sid = target.spawn_session(echo_spawn_req()).expect("spawn echo child");
161	    let a_addr = target.net_status().expect("a status").addr;
162
163	    // Operator B: dial A, open the attach stream, request the session.
164	    let mut operator = connect_retry(&name_b);
165	    let b_node = operator.net_status().expect("b status").node_id_hex.expect("node id");
166	    let conn = operator.net_dial(a_addr, Some(1)).expect("dial");
167	    let stream_b = request_attach(&mut operator, conn.conn_id, sid, 0, 2).expect("request");
168	    operator.net_stream_subscribe(stream_b, 0).expect("operator subscribe");
169
170	    // Target A: the attach stream arrives; serve it on its own thread under
171	    // the handshake-proven origin (KH 7.5 — the stream table, not payload).
172	    let (stream_a, origin) = wait_for_stream(&mut target);
173	    assert_eq!(origin, b_node, "serve gate subject IS the QUIC handshake identity");
174	    let server = thread::spawn(move || {
175	        let outcome = serve_attach(&mut target, stream_a, "ling", &origin, 0).expect("serve");
176	        (outcome, target)
177	    });
178
179	    // B types a marker; A's child echoes it; B renders it from the wire.
180	    send_attach_input(&mut operator, stream_b, b"REMOTE_DRIVE_OK\r", 1).expect("input");
181	    let mut decoder = AttachDecoder::new();
182	    let (mut cursor, mut viewport) = (0u64, Vec::new());
183	    render_until(&mut operator, stream_b, &mut decoder, &mut cursor, &mut viewport, b"REMOTE_DRIVE_OK");
184
185	    // Detach: B finishes its send side; the serve loop ends with the session
186	    // still alive on A (viewport over, compute stays remote).
187	    operator.net_stream_send(stream_b, &[], None, true).expect("detach");
188	    let (outcome, mut target) = server.join().expect("serve thread");
189	    assert_eq!(outcome, AttachServeOutcome::Detached);
190	    assert_eq!(broker_a.session_count(), 1, "the session outlives the viewport");
191	    target.kill_session().expect("cleanup kill");
192	}
193
194	// [unit->REQ-HAZARD-RESTART-IDEMPOTENT] the viewport survives a target-brain
195	// crash mid-attach: the successor re-serves the SAME broker-held stream from
196	// seq 0 (worst-case stale cursor) — the replayed Request/input/output all
197	// dedup through the journals, input B sent INTO the dead window arrives, and
198	// B's viewport holds every marker exactly once.
199	// [unit->REQ-INST-8]
200	#[test]
201	fn attach_survives_target_brain_restart_exactly_once() {
202	    let dir = tempfile::tempdir().expect("tempdir");
203	    let (name_a, name_b) = (unique_name(), unique_name());
204	    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
205	    let _broker_b = net_broker(&name_b, &dir.path().join("b"));
206
207	    // Target A life 1: spawn the child; operator B dials + requests + types.
208	    let mut life1 = connect_retry(&name_a);
209	    let sid = life1.spawn_session(echo_spawn_req()).expect("spawn echo child");
210	    let a_addr = life1.net_status().expect("a status").addr;
211
212	    let mut operator = connect_retry(&name_b);
213	    let conn = operator.net_dial(a_addr, Some(1)).expect("dial");
214	    let stream_b = request_attach(&mut operator, conn.conn_id, sid, 0, 2).expect("request");
215	    operator.net_stream_subscribe(stream_b, 0).expect("operator subscribe");
216	    send_attach_input(&mut operator, stream_b, b"MARKER_ONE\r", 10).expect("input 1");
217
218	    // Life 1 serves manually (the serve loop's exact steps, stepped so the
219	    // crash point is deterministic): handle the Request + input, pump output
220	    // records until MARKER_ONE went onto the wire — then die.
221	    let (stream_a, origin) = wait_for_stream(&mut life1);
222	    life1.net_stream_subscribe(stream_a, 0).expect("life1 subscribe");
223	    let mut decoder = AttachDecoder::new();
224	    let mut sent: Vec<u8> = Vec::new(); // bytes life 1 pumped onto the wire
225	    'life1: loop {
226	        match life1.read_event().expect("life1 event") {
227	            BrokerEvent::NetStreamData { stream_id, bytes, .. } if stream_id == stream_a => {
228	                for rec in decoder.push(&bytes) {
229	                    match rec {
230	                        AttachRecord::Request { session_id, from_seq } => {
231	                            life1.attach(session_id, from_seq).expect("attach");
232	                        }
233	                        AttachRecord::Input { data_b64, op_id } => {
234	                            let b = decode_bytes(&data_b64).expect("b64");
235	                            life1.send_effect(op_id, &b).expect("journaled input");
236	                        }
237	                        _ => {}
238	                    }
239	                }
240	            }
241	            BrokerEvent::Output { seq, bytes, .. } => {
242	                let line = ndjson::encode_line(&AttachRecord::Output {
243	                    seq,
244	                    data_b64: spt_daemon::msg::encode_bytes(&bytes),
245	                });
246	                life1
247	                    .net_stream_send(stream_a, &line, None, false)
248	                    .expect("output send");
249	                sent.extend_from_slice(&bytes);
250	                if count(&sent, b"MARKER_ONE") >= 1 {
251	                    break 'life1; // crash with the marker on the wire
252	                }
253	            }
254	            BrokerEvent::Error { message } => panic!("life1 broker error: {message}"),
255	            _ => {}
256	        }
257	    }
258	    let state = life1.snapshot();
259	    drop(life1); // the crash — broker A, session, stream, journal all survive
260
261	    // Into the DEAD window: B types the second marker (buffered by A's broker).
262	    send_attach_input(&mut operator, stream_b, b"MARKER_TWO\r", 11).expect("input 2");
263
264	    // Life 2: handoff, then re-serve the SAME stream from seq 0 — the
265	    // worst-case replay. serve_attach re-handles the replayed Request (gate +
266	    // re-attach from 0, so every output record re-transmits — B's render
267	    // cursor drops the duplicates), replays MARKER_ONE's input (PTY-write
268	    // journal dedups op 10 — typed once), and pumps the dead window's
269	    // MARKER_TWO.
270	    let mut life2 = Brain::handoff(&name_a, state, 2).expect("handoff");
271	    let origin2 = origin.clone();
272	    let server = thread::spawn(move || {
273	        let outcome =
274	            serve_attach(&mut life2, stream_a, "ling", &origin2, 0).expect("re-serve");
275	        (outcome, life2)
276	    });
277
278	    // B renders through the dead-window marker, then detaches.
279	    let mut op_decoder = AttachDecoder::new();
280	    let (mut cursor, mut viewport) = (0u64, Vec::new());
281	    render_until(&mut operator, stream_b, &mut op_decoder, &mut cursor, &mut viewport, b"MARKER_TWO");
282	    operator.net_stream_send(stream_b, &[], None, true).expect("detach");
283	    let (outcome, mut life2) = server.join().expect("serve thread");
284	    assert_eq!(outcome, AttachServeOutcome::Detached);
285
286	    // Exactly-once, stated against ground truth: replay the session's OWN
287	    // output log locally from seq 0 with the same stop rule — the remote
288	    // viewport must be BYTE-IDENTICAL (an echoing child legitimately repeats
289	    // a typed marker; what must never differ is wire vs local). A dup or gap
290	    // from the restart/replay would break the equality.
291	    life2.attach(sid, 0).expect("local ground-truth attach");
292	    let mut local: Vec<u8> = Vec::new();
293	    life2.read_output_until(b"MARKER_TWO", &mut local).expect("local replay");
294	    assert_eq!(
295	        viewport, local,
296	        "remote viewport byte-identical to the local output log across the restart"
297	    );
298	    assert!(count(&local, b"MARKER_ONE") >= 1 && count(&local, b"MARKER_TWO") >= 1);
299
300	    life2.kill_session().expect("cleanup kill");
301	}
302

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

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,
    }
}

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 }
}

/// 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}"),
            _ => {}
        }
    }
}

/// 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
}

// [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() {
    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() {
    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 (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 equality.
    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!(
        viewport, local,
        "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");
}


NOTE FROM THE USER: proceed with next steps.


Picture clear. Check wanmsg test home-isolation pattern + access.rs, then build.


1	//! D5a regression — WAN message delivery over the broker-owned wire: a message
2	//! sent from daemon A lands in B's perch spool **exactly once**, gated on the
3	//! handshake-proven origin.
4	//!
5	//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
6	//! the two daemons. The invariants:
7	//!
8	//! - **delivery**: a `WanMessage` NDJSON feed over a broker QUIC stream funnels
9	//!   into the existing local spool path (`receive_wan` → `spool_wan_message_at`)
10	//!   — and survives real QUIC chunking (a record split across two sends);
11	//! - **idempotency** (REQ-HAZARD-RESTART-IDEMPOTENT, wire-fed): a replayed
12	//!   record (same wire `op_id`) is a no-op — including across a receiver brain
13	//!   restart that resubscribes from a stale cursor and re-presents the whole
14	//!   feed;
15	//! - **origin is transport truth** (REQ-HAZARD-WAN-ORIGIN-AUTH): the access
16	//!   gate's subject is the stream table's `remote_id_hex` (the QUIC handshake
17	//!   identity) — a record carrying a forged `origin_node` field delivers under
18	//!   the REAL origin, the forgery inert.
19
20	use std::sync::atomic::{AtomicU32, Ordering};
21	use std::sync::{Arc, OnceLock};
22	use std::thread;
23	use std::time::Duration;
24
25	use spt_daemon::brain::{Brain, BrokerEvent};
26	use spt_daemon::nethost::{NetConfig, NetHost};
27	use spt_daemon::wan::{receive_wan, WanOutcome};
28	use spt_daemon::Broker;
29	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
30	use spt_net::net::wanmsg::{WanDecoder, WanMessage};
31	use spt_proto::identity::Identity;
32	use spt_store::info::{self, InfoJson};
33	use spt_store::perch::{self, ParentHint};
34	use spt_store::spool;
35	use tempfile::TempDir;
36
37	/// Hold the temp owlery for the whole test binary so it isn't deleted mid-run.
38	static HOME: OnceLock<TempDir> = OnceLock::new();
39
40	/// Point SPT_HOME at an isolated temp dir (once per test process).
41	fn init_home() {
42	    HOME.get_or_init(|| {
43	        let dir = TempDir::new().expect("temp dir");
44	        std::env::set_var("SPT_HOME", dir.path());
45	        dir
46	    });
47	}
48
49	static SEQ: AtomicU32 = AtomicU32::new(0);
50	fn unique_name() -> String {
51	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
52	    format!("spt-daemon-d5a-{}-{}.sock", std::process::id(), n)
53	}
54
55	fn unique_id(prefix: &str) -> String {
56	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
57	    format!("{}-{}-{}", prefix, std::process::id(), n)
58	}
59
60	fn hermetic() -> NetConfig {
61	    NetConfig {
62	        identity: Identity::generate(),
63	        relay: RelayPolicy::Disabled,
64	        discovery: LocalDiscovery::Off,
65	        scope: BindScope::Loopback,
66	    }
67	}
68
69	fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
70	    let host = NetHost::start(hermetic()).expect("net host start");
71	    let broker =
72	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
73	    let serve = Arc::clone(&broker);
74	    thread::spawn(move || {
75	        let _ = serve.serve();
76	    });
77	    broker
78	}
79
80	fn connect_retry(name: &str) -> Brain {
81	    for _ in 0..300 {
82	        if let Ok(b) = Brain::cold_start(name, 1) {
83	            return b;
84	        }
85	        thread::sleep(Duration::from_millis(10));
86	    }
87	    panic!("brain could not connect");
88	}
89
90	/// Poll the receiving brain's `net-streams` until a stream appears; return
91	/// `(stream_id, remote_id_hex)` — the handshake-proven origin the funnel uses.
92	fn wait_for_stream(brain: &mut Brain) -> (u64, String) {
93	    for _ in 0..400 {
94	        let reply = brain.net_streams().expect("net-streams");
95	        if let Some(s) = reply.streams.first() {
96	            return (s.stream_id, s.remote_id_hex.clone());
97	        }
98	        thread::sleep(Duration::from_millis(5));
99	    }
100	    panic!("stream never appeared at the receiver's broker");
101	}
102
103	/// Create an offline perch (dir + info.json, no listener) for `id` so the
104	/// funnel takes the atomic spool path.
105	fn offline_perch(id: &str) -> std::path::PathBuf {
106	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
107	    std::fs::create_dir_all(&perch_path).expect("perch dir");
108	    let rec = InfoJson::new(id, "now", std::process::id(), "sess", "ready_agent");
109	    info::write_info(&perch_path, &rec).expect("info.json");
110	    perch_path
111	}
112
113	fn msg(target: &str, body: &str, op_id: &str) -> WanMessage {
114	    WanMessage { target: target.into(), from: "doyle".into(), body: body.into(), op_id: op_id.into() }
115	}
116
117	/// Drive the subscribed stream's events through the decoder + funnel until
118	/// `eof`, returning every funnel outcome in feed order.
119	fn funnel_until_eof(
120	    brain: &mut Brain,
121	    stream_id: u64,
122	    origin: &str,
123	    owlery: &std::path::Path,
124	) -> Vec<WanOutcome> {
125	    let mut decoder = WanDecoder::new();
126	    let mut outcomes = Vec::new();
127	    loop {
128	        match brain.read_event().expect("read event") {
129	            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
130	                for rec in decoder.push(&bytes) {
131	                    outcomes.push(receive_wan(&rec, origin, owlery));
132	                }
133	            }
134	            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => break,
135	            BrokerEvent::Error { message } => panic!("broker error: {message}"),
136	            _ => {}
137	        }
138	    }
139	    outcomes
140	}
141
142	// [unit->REQ-NET-1] a WAN message from daemon A lands in B's perch spool
143	// exactly once: NDJSON over a broker QUIC stream (one record split mid-record
144	// across two sends — QUIC chunking is not record framing), funneled through
145	// receive_wan into the atomic op-id+spool insert; a replayed record (same wire
146	// op_id) is a no-op.
147	// [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] and the origin the gate sees is the QUIC
148	// handshake identity from the broker's stream table — a forged `origin_node`
149	// field inside a record is inert (the record still delivers, under the REAL
150	// origin; nothing in payload bytes can impersonate a node).
151	#[test]
152	fn wan_message_lands_exactly_once_under_transport_origin() {
153	    init_home();
154	    let dir = tempfile::tempdir().expect("tempdir");
155	    let (name_a, name_b) = (unique_name(), unique_name());
156	    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
157	    let _broker_b = net_broker(&name_b, &dir.path().join("b"));
158
159	    let target = unique_id("ling");
160	    let perch_path = offline_perch(&target);
161	    let owlery = perch::owlery_dir();
162
163	    // ── Node A: dial B, open a stream, write the feed. ──────────────────────
164	    let mut sender = connect_retry(&name_a);
165	    let a_node = sender.net_status().expect("a status").node_id_hex.expect("node id");
166	    let mut b_probe = connect_retry(&name_b);
167	    let addr = b_probe.net_status().expect("b status").addr;
168	    let conn = sender.net_dial(addr, Some(1)).expect("dial");
169	    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");
170
171	    // Record 1, split MID-RECORD across two separate sends.
172	    let m1 = msg(&target, "hello across the wire", "a:1");
173	    let line = m1.encode_line();
174	    let split = line.len() / 2;
175	    sender.net_stream_send(opened.stream_id, &line[..split], Some(100), false).expect("send");
176	    sender.net_stream_send(opened.stream_id, &line[split..], Some(101), false).expect("send");
177	    // Record 2: a FORGED origin_node riding inside the JSON (the anti-spoof
178	    // negative: it must decode as an ignored unknown field).
179	    let forged = format!(
180	        "{{\"target\":\"{target}\",\"from\":\"mallory\",\"body\":\"spoofed?\",\"op_id\":\"a:2\",\"origin_node\":\"evil\"}}\n"
181	    );
182	    sender.net_stream_send(opened.stream_id, forged.as_bytes(), Some(102), false).expect("send");
183	    // Record 3: record 1 REPLAYED (same wire op_id — a lagging duplicate).
184	    sender.net_stream_send(opened.stream_id, &m1.encode_line(), Some(103), true).expect("send");
185
186	    // ── Node B: subscribe, decode, funnel — under the handshake origin. ─────
187	    let (stream, remote) = wait_for_stream(&mut b_probe);
188	    assert_eq!(remote, a_node, "the stream table's origin IS the QUIC handshake identity");
189	    b_probe.net_stream_subscribe(stream, 0).expect("subscribe");
190	    let outcomes = funnel_until_eof(&mut b_probe, stream, &remote, &owlery);
191
192	    assert_eq!(
193	        outcomes,
194	        vec![WanOutcome::Spooled, WanOutcome::Spooled, WanOutcome::Duplicate],
195	        "two distinct ops land, the replay dedups"
196	    );
197	    let pending = spool::peek_all_at(&perch_path).expect("peek");
198	    assert_eq!(pending.len(), 2, "exactly two spool rows");
199	    assert_eq!(pending[0].2, "hello across the wire");
200	    assert_eq!(pending[1].2, "spoofed?", "forged-origin record delivered (gate saw the REAL origin)");
201	    assert_eq!(pending[1].1, "mallory", "from is reply metadata, not an authorization subject");
202	}
203
204	// [unit->REQ-HAZARD-RESTART-IDEMPOTENT] receiver brain restart: life 2
205	// resubscribes from a STALE cursor (0) and re-presents the whole feed — the
206	// durable op-id claims dedup everything life 1 already delivered, so the spool
207	// holds each message exactly once.
208	#[test]
209	fn receiver_restart_replays_feed_without_double_delivery() {
210	    init_home();
211	    let dir = tempfile::tempdir().expect("tempdir");
212	    let (name_a, name_b) = (unique_name(), unique_name());
213	    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
214	    let _broker_b = net_broker(&name_b, &dir.path().join("b"));
215
216	    let target = unique_id("ling");
217	    let perch_path = offline_perch(&target);
218	    let owlery = perch::owlery_dir();
219
220	    let mut sender = connect_retry(&name_a);
221	    let mut b_probe = connect_retry(&name_b);
222	    let addr = b_probe.net_status().expect("b status").addr;
223	    let conn = sender.net_dial(addr, Some(1)).expect("dial");
224	    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");
225	    for n in 0..3u64 {
226	        let m = msg(&target, &format!("msg-{n}"), &format!("a:{n}"));
227	        sender
228	            .net_stream_send(opened.stream_id, &m.encode_line(), Some(100 + n), n == 2)
229	            .expect("send");
230	    }
231
232	    // Life 1: funnel the first record only, then crash.
233	    let (stream, remote) = wait_for_stream(&mut b_probe);
234	    b_probe.net_stream_subscribe(stream, 0).expect("subscribe");
235	    let mut decoder = WanDecoder::new();
236	    let mut delivered_first = false;
237	    while !delivered_first {
238	        match b_probe.read_event().expect("read event") {
239	            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream => {
240	                // Deliver the FIRST record only, then crash.
241	                if let Some(rec) = decoder.push(&bytes).into_iter().next() {
242	                    assert_eq!(receive_wan(&rec, &remote, &owlery), WanOutcome::Spooled);
243	                    delivered_first = true;
244	                }
245	            }
246	            BrokerEvent::NetStreamEof { .. } => panic!("eof before the first delivery"),
247	            BrokerEvent::Error { message } => panic!("broker error: {message}"),
248	            _ => {}
249	        }
250	    }
251	    drop(b_probe); // the crash — broker B, its stream, and the ring survive
252
253	    // Life 2: resubscribe from 0 — the WORST-case stale cursor, replaying the
254	    // entire feed. The op-id claims absorb the replay.
255	    let mut receiver = connect_retry(&name_b);
256	    receiver.net_stream_subscribe(stream, 0).expect("resubscribe");
257	    let outcomes = funnel_until_eof(&mut receiver, stream, &remote, &owlery);
258	    assert_eq!(
259	        outcomes,
260	        vec![WanOutcome::Duplicate, WanOutcome::Spooled, WanOutcome::Spooled],
261	        "the already-delivered op dedups; the rest land"
262	    );
263	    assert_eq!(spool::pending_count_at(&perch_path).expect("count"), 3, "each message exactly once");
264	}
265

//! D5a regression — WAN message delivery over the broker-owned wire: a message
//! sent from daemon A lands in B's perch spool **exactly once**, gated on the
//! handshake-proven origin.
//!
//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
//! the two daemons. The invariants:
//!
//! - **delivery**: a `WanMessage` NDJSON feed over a broker QUIC stream funnels
//!   into the existing local spool path (`receive_wan` → `spool_wan_message_at`)
//!   — and survives real QUIC chunking (a record split across two sends);
//! - **idempotency** (REQ-HAZARD-RESTART-IDEMPOTENT, wire-fed): a replayed
//!   record (same wire `op_id`) is a no-op — including across a receiver brain
//!   restart that resubscribes from a stale cursor and re-presents the whole
//!   feed;
//! - **origin is transport truth** (REQ-HAZARD-WAN-ORIGIN-AUTH): the access
//!   gate's subject is the stream table's `remote_id_hex` (the QUIC handshake
//!   identity) — a record carrying a forged `origin_node` field delivers under
//!   the REAL origin, the forgery inert.

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

use spt_daemon::brain::{Brain, BrokerEvent};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::wan::{receive_wan, WanOutcome};
use spt_daemon::Broker;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::wanmsg::{WanDecoder, WanMessage};
use spt_proto::identity::Identity;
use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;
use tempfile::TempDir;

/// Hold the temp owlery for the whole test binary so it isn't deleted mid-run.
static HOME: OnceLock<TempDir> = OnceLock::new();

/// Point SPT_HOME at an isolated temp dir (once per test process).
fn init_home() {
    HOME.get_or_init(|| {
        let dir = 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-d5a-{}-{}.sock", std::process::id(), n)
}

fn unique_id(prefix: &str) -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("{}-{}-{}", prefix, std::process::id(), n)
}

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

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

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

/// Poll the receiving brain's `net-streams` until a stream appears; return
/// `(stream_id, remote_id_hex)` — the handshake-proven origin the funnel uses.
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!("stream never appeared at the receiver's broker");
}

/// Create an offline perch (dir + info.json, no listener) for `id` so the
/// funnel takes the atomic spool path.
fn offline_perch(id: &str) -> std::path::PathBuf {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).expect("perch dir");
    let rec = InfoJson::new(id, "now", std::process::id(), "sess", "ready_agent");
    info::write_info(&perch_path, &rec).expect("info.json");
    perch_path
}

fn msg(target: &str, body: &str, op_id: &str) -> WanMessage {
    WanMessage { target: target.into(), from: "doyle".into(), body: body.into(), op_id: op_id.into() }
}

/// Drive the subscribed stream's events through the decoder + funnel until
/// `eof`, returning every funnel outcome in feed order.
fn funnel_until_eof(
    brain: &mut Brain,
    stream_id: u64,
    origin: &str,
    owlery: &std::path::Path,
) -> Vec<WanOutcome> {
    let mut decoder = WanDecoder::new();
    let mut outcomes = Vec::new();
    loop {
        match brain.read_event().expect("read event") {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    outcomes.push(receive_wan(&rec, origin, owlery));
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => break,
            BrokerEvent::Error { message } => panic!("broker error: {message}"),
            _ => {}
        }
    }
    outcomes
}

// [unit->REQ-NET-1] a WAN message from daemon A lands in B's perch spool
// exactly once: NDJSON over a broker QUIC stream (one record split mid-record
// across two sends — QUIC chunking is not record framing), funneled through
// receive_wan into the atomic op-id+spool insert; a replayed record (same wire
// op_id) is a no-op.
// [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] and the origin the gate sees is the QUIC
// handshake identity from the broker's stream table — a forged `origin_node`
// field inside a record is inert (the record still delivers, under the REAL
// origin; nothing in payload bytes can impersonate a node).
#[test]
fn wan_message_lands_exactly_once_under_transport_origin() {
    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"));

    let target = unique_id("ling");
    let perch_path = offline_perch(&target);
    let owlery = perch::owlery_dir();

    // ── Node A: dial B, open a stream, write the feed. ──────────────────────
    let mut sender = connect_retry(&name_a);
    let a_node = sender.net_status().expect("a status").node_id_hex.expect("node id");
    let mut b_probe = connect_retry(&name_b);
    let addr = b_probe.net_status().expect("b status").addr;
    let conn = sender.net_dial(addr, Some(1)).expect("dial");
    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");

    // Record 1, split MID-RECORD across two separate sends.
    let m1 = msg(&target, "hello across the wire", "a:1");
    let line = m1.encode_line();
    let split = line.len() / 2;
    sender.net_stream_send(opened.stream_id, &line[..split], Some(100), false).expect("send");
    sender.net_stream_send(opened.stream_id, &line[split..], Some(101), false).expect("send");
    // Record 2: a FORGED origin_node riding inside the JSON (the anti-spoof
    // negative: it must decode as an ignored unknown field).
    let forged = format!(
        "{{\"target\":\"{target}\",\"from\":\"mallory\",\"body\":\"spoofed?\",\"op_id\":\"a:2\",\"origin_node\":\"evil\"}}\n"
    );
    sender.net_stream_send(opened.stream_id, forged.as_bytes(), Some(102), false).expect("send");
    // Record 3: record 1 REPLAYED (same wire op_id — a lagging duplicate).
    sender.net_stream_send(opened.stream_id, &m1.encode_line(), Some(103), true).expect("send");

    // ── Node B: subscribe, decode, funnel — under the handshake origin. ─────
    let (stream, remote) = wait_for_stream(&mut b_probe);
    assert_eq!(remote, a_node, "the stream table's origin IS the QUIC handshake identity");
    b_probe.net_stream_subscribe(stream, 0).expect("subscribe");
    let outcomes = funnel_until_eof(&mut b_probe, stream, &remote, &owlery);

    assert_eq!(
        outcomes,
        vec![WanOutcome::Spooled, WanOutcome::Spooled, WanOutcome::Duplicate],
        "two distinct ops land, the replay dedups"
    );
    let pending = spool::peek_all_at(&perch_path).expect("peek");
    assert_eq!(pending.len(), 2, "exactly two spool rows");
    assert_eq!(pending[0].2, "hello across the wire");
    assert_eq!(pending[1].2, "spoofed?", "forged-origin record delivered (gate saw the REAL origin)");
    assert_eq!(pending[1].1, "mallory", "from is reply metadata, not an authorization subject");
}

// [unit->REQ-HAZARD-RESTART-IDEMPOTENT] receiver brain restart: life 2
// resubscribes from a STALE cursor (0) and re-presents the whole feed — the
// durable op-id claims dedup everything life 1 already delivered, so the spool
// holds each message exactly once.
#[test]
fn receiver_restart_replays_feed_without_double_delivery() {
    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"));

    let target = unique_id("ling");
    let perch_path = offline_perch(&target);
    let owlery = perch::owlery_dir();

    let mut sender = connect_retry(&name_a);
    let mut b_probe = connect_retry(&name_b);
    let addr = b_probe.net_status().expect("b status").addr;
    let conn = sender.net_dial(addr, Some(1)).expect("dial");
    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");
    for n in 0..3u64 {
        let m = msg(&target, &format!("msg-{n}"), &format!("a:{n}"));
        sender
            .net_stream_send(opened.stream_id, &m.encode_line(), Some(100 + n), n == 2)
            .expect("send");
    }

    // Life 1: funnel the first record only, then crash.
    let (stream, remote) = wait_for_stream(&mut b_probe);
    b_probe.net_stream_subscribe(stream, 0).expect("subscribe");
    let mut decoder = WanDecoder::new();
    let mut delivered_first = false;
    while !delivered_first {
        match b_probe.read_event().expect("read event") {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream => {
                // Deliver the FIRST record only, then crash.
                if let Some(rec) = decoder.push(&bytes).into_iter().next() {
                    assert_eq!(receive_wan(&rec, &remote, &owlery), WanOutcome::Spooled);
                    delivered_first = true;
                }
            }
            BrokerEvent::NetStreamEof { .. } => panic!("eof before the first delivery"),
            BrokerEvent::Error { message } => panic!("broker error: {message}"),
            _ => {}
        }
    }
    drop(b_probe); // the crash — broker B, its stream, and the ring survive

    // Life 2: resubscribe from 0 — the WORST-case stale cursor, replaying the
    // entire feed. The op-id claims absorb the replay.
    let mut receiver = connect_retry(&name_b);
    receiver.net_stream_subscribe(stream, 0).expect("resubscribe");
    let outcomes = funnel_until_eof(&mut receiver, stream, &remote, &owlery);
    assert_eq!(
        outcomes,
        vec![WanOutcome::Duplicate, WanOutcome::Spooled, WanOutcome::Spooled],
        "the already-delivered op dedups; the rest land"
    );
    assert_eq!(spool::pending_count_at(&perch_path).expect("count"), 3, "each message exactly once");
}


1	//! Endpoint access control — the ADR-0009 middle gate (M4-D5 seam; enforcement
2	//! lands at D5d, REQ-SEC-1).
3	//!
4	//! ADR-0009 models access as **three orthogonal, nested gates**, evaluated in
5	//! order: subnet *visibility* (is the endpoint routable here at all? —
6	//! `spt_store::visibility`, applied inside registry resolution) → the
7	//! **endpoint access whitelist** (may *your node* remotely reach it? — THIS
8	//! module) → *capability grants* (may this agent do this high-risk thing? —
9	//! [`crate::consent`], default-deny). The whitelist is the outer gate before
10	//! grants: an origin the whitelist refuses never reaches a grant check.
11	//!
12	//! ## Whitelist semantics (stateful firewall, not a blanket block)
13	//!
14	//! - **Origin-node gate:** the subject is the node the inbound interaction
15	//!   *originates from* (where the operator is acting) — never the sender
16	//!   endpoint's identity or home. The subject value MUST be the QUIC
17	//!   handshake-proven remote node id from the broker's conn/stream table,
18	//!   never a payload field (REQ-HAZARD-WAN-ORIGIN-AUTH).
19	//! - **Outbound** → any visible node: the whitelist never restricts who an
20	//!   endpoint talks *to*.
21	//! - **Inbound reply** correlated to the endpoint's own prior outbound
22	//!   (`__REPLY_TO__`) → allowed from any visible node ("established/related").
23	//! - **Unsolicited/direct inbound** → only from a whitelisted node.
24	//! - **Same-node operation is always allowed** — the gate sits on the
25	//!   net-inbound path only, so the local delivery path never sees it.
26	//! - **Default empty = open**; setting a whitelist restricts. Node-tier ships
27	//!   M4 (Ed25519 node pubkeys); the user tier is an inert schema reservation
28	//!   until the cross-user model (ADR-0006 seam).
29	//!
30	//! D5a lands this as the **call seam** on the net-inbound dispatch (every
31	//! D5b/c inbound path is born gated); D5d fills in the `AccessStore` (security
32	//! material near the trust store), the reply-correlation tracking, and the
33	//! `spt access` surface.
34
35	/// How an inbound interaction reached this node, for the stateful-firewall
36	/// split. Classification (reply-correlation against the endpoint's own prior
37	/// outbound) lands with D5d; until then every wire inbound is `Unsolicited` —
38	/// the conservative class.
39	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40	pub enum InboundClass {
41	    /// A new/direct control or message — gated by the whitelist.
42	    Unsolicited,
43	    /// Correlated to the target endpoint's own prior outbound (`__REPLY_TO__`)
44	    /// — allowed from any visible node.
45	    Reply,
46	}
47
48	/// The gate's verdict.
49	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50	pub enum AccessDecision {
51	    /// Pass to the next gate (grants, for gated capabilities) / deliver.
52	    Allow,
53	    /// Refused at the whitelist — drop before any grant check.
54	    Deny,
55	}
56
57	/// The ADR-0009 access-whitelist gate for one wire-inbound interaction against
58	/// `endpoint`, originating from handshake-proven `origin_node`. **D5a stub:**
59	/// no `AccessStore` exists yet, and ADR-0009's default is *empty = open*, so
60	/// every check allows — but every net-inbound dispatch path already routes
61	/// through here, so D5d only swaps the body for the store lookup +
62	/// reply-correlation without touching callers.
63	pub fn access_check(
64	    _endpoint: &str,
65	    _origin_node: &str,
66	    _class: InboundClass,
67	) -> AccessDecision {
68	    // Default empty = open (ADR-0009): no whitelist store exists until D5d.
69	    AccessDecision::Allow
70	}
71

//! Endpoint access control — the ADR-0009 middle gate (M4-D5 seam; enforcement
//! lands at D5d, REQ-SEC-1).
//!
//! ADR-0009 models access as **three orthogonal, nested gates**, evaluated in
//! order: subnet *visibility* (is the endpoint routable here at all? —
//! `spt_store::visibility`, applied inside registry resolution) → the
//! **endpoint access whitelist** (may *your node* remotely reach it? — THIS
//! module) → *capability grants* (may this agent do this high-risk thing? —
//! [`crate::consent`], default-deny). The whitelist is the outer gate before
//! grants: an origin the whitelist refuses never reaches a grant check.
//!
//! ## Whitelist semantics (stateful firewall, not a blanket block)
//!
//! - **Origin-node gate:** the subject is the node the inbound interaction
//!   *originates from* (where the operator is acting) — never the sender
//!   endpoint's identity or home. The subject value MUST be the QUIC
//!   handshake-proven remote node id from the broker's conn/stream table,
//!   never a payload field (REQ-HAZARD-WAN-ORIGIN-AUTH).
//! - **Outbound** → any visible node: the whitelist never restricts who an
//!   endpoint talks *to*.
//! - **Inbound reply** correlated to the endpoint's own prior outbound
//!   (`__REPLY_TO__`) → allowed from any visible node ("established/related").
//! - **Unsolicited/direct inbound** → only from a whitelisted node.
//! - **Same-node operation is always allowed** — the gate sits on the
//!   net-inbound path only, so the local delivery path never sees it.
//! - **Default empty = open**; setting a whitelist restricts. Node-tier ships
//!   M4 (Ed25519 node pubkeys); the user tier is an inert schema reservation
//!   until the cross-user model (ADR-0006 seam).
//!
//! D5a lands this as the **call seam** on the net-inbound dispatch (every
//! D5b/c inbound path is born gated); D5d fills in the `AccessStore` (security
//! material near the trust store), the reply-correlation tracking, and the
//! `spt access` surface.

/// How an inbound interaction reached this node, for the stateful-firewall
/// split. Classification (reply-correlation against the endpoint's own prior
/// outbound) lands with D5d; until then every wire inbound is `Unsolicited` —
/// the conservative class.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InboundClass {
    /// A new/direct control or message — gated by the whitelist.
    Unsolicited,
    /// Correlated to the target endpoint's own prior outbound (`__REPLY_TO__`)
    /// — allowed from any visible node.
    Reply,
}

/// The gate's verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessDecision {
    /// Pass to the next gate (grants, for gated capabilities) / deliver.
    Allow,
    /// Refused at the whitelist — drop before any grant check.
    Deny,
}

/// The ADR-0009 access-whitelist gate for one wire-inbound interaction against
/// `endpoint`, originating from handshake-proven `origin_node`. **D5a stub:**
/// no `AccessStore` exists yet, and ADR-0009's default is *empty = open*, so
/// every check allows — but every net-inbound dispatch path already routes
/// through here, so D5d only swaps the body for the store lookup +
/// reply-correlation without touching callers.
pub fn access_check(
    _endpoint: &str,
    _origin_node: &str,
    _class: InboundClass,
) -> AccessDecision {
    // Default empty = open (ADR-0009): no whitelist store exists until D5d.
    AccessDecision::Allow
}


Bash

sed -n '95,116p' crates/spt/src/api/reporting.rs && echo ==== && grep -n "pub fn" crates/spt-store/src/atomic.rs && echo ==== && tail -30 docs/FAULT-MATRIX.md

Presence cmd, atomic helpers, fault-matrix tail


fn presence_path() -> PathBuf {
    perch::spt_home().join("presence.json")
}

/// `api presence <id>` — record that the user interacted at `id` (endpoint +
/// timestamp; no content — privacy-preserving).
// [impl->REQ-API-2]
pub fn cmd_presence(id: &str) -> i32 {
    let datum = format!("{{\"last_active_endpoint\":\"{id}\",\"ts\":\"{}\"}}", now_stamp());
    if let Err(e) = std::fs::write(presence_path(), datum) {
        eprintln!("PRESENCE_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    eprintln!("PRESENCE:{id}");
    0
}

/// Append a normalized record to the perch's Path-B native history store,
/// returning bytes written. Thin wrapper over the store-layer primitive
/// ([`spt_store::history::append_record`]) — shared with the M3b digest's opt-in
/// persistence so the append behavior lives in exactly one place.
pub fn append_history(id: &str, record: &str) -> std::io::Result<usize> {
====
36:pub fn to_forward_slash(path: &Path) -> String {
49:pub fn atomic_write_string(path: &Path, body: &str) -> io::Result<()> {
56:pub fn atomic_write_bytes(path: &Path, body: &[u8]) -> io::Result<()> {
====
| 1 | **Brain killed mid-PTY-stream** (logic crash / routine self-update) | Logic halts until restart | Broker, PTY child (pid stable), output log | New brain re-subscribes from cursor; gapless + exactly-once | `spt-daemon/tests/` B2/B9 handoff suite; `tests/idempotent.rs` (crash before-intent / before-effect / after-effect) |
| 2 | **Brain killed mid-QUIC-stream, receive side** | Logic halts | Broker-owned endpoint, conn, stream, read ring | Resubscribe from durable cursor; ring replays the dead window | `tests/netstream.rs::receiver_brain_restart_is_gapless_and_exactly_once` |
| 3 | **Brain killed mid-QUIC-stream, send side** | Logic halts | Broker-owned conn/stream + effect journal | Re-drive whole durable op sequence; journal dedups replays (no dup on the wire) | `tests/netstream.rs::sender_brain_restart_redrive_is_exactly_once` |
| 4 | **Brain dead across peer connect/disconnect** | Presence consumption pauses | PresenceLog ring buffers liveness transitions | Resubscribe from presence cursor; missed transitions replay | `tests/presence.rs` (both tests) |
| 5 | **Peer node vanishes (graceful close)** | That conn's streams end | Local broker, other conns, brain | Closed-watcher removes conn row + emits `disconnected`; dial fresh when needed | `tests/presence.rs`; `nethost.rs` closed-watcher |
| 6 | **Registry feed: stale/replayed update over the wire** | None — update dropped | Stored registry state (newer epoch holds) | None needed (epoch lease absorbs it) | `tests/replicate.rs::registries_converge_over_the_wire_and_the_lease_holds`; `replicate.rs` unit tests |
| 7 | **Registry feed: corrupt record in the stream** | That record only — skipped | The rest of the feed (decoder does not wedge) | Lease makes loss safe; next update supersedes | `replicate.rs::decoder_survives_chunk_splits_and_corrupt_lines` |
| 8 | **Net disabled / endpoint bind fails at boot** | No WAN; net frames answer `enabled:false` / typed error | PTY hosting (daemon degrades to net-less broker) | Fix config, restart daemon | `broker.rs::dispatch_net_status` (None arm); `Daemon::run` degrade path |
| 9 | **Broker process restart (journal survives, conns don't)** | Live conns/streams lost | Effect journal (durable) | Deduped op whose resource is gone → typed "retry with a fresh op_id" error; brain re-dials | `broker.rs::dispatch_net_dial` / `dispatch_net_stream_open` restart arms |
| 10 | **WAN msg feed replayed** (sender redrive, or receiver brain restart resubscribing from a stale cursor) | None — replayed records dedup | Spool state (each op exactly one row); already-delivered ops | Durable `wan_seen` op-id claims absorb the replay; spool path claims atomically with the row | `tests/wanmsg.rs::receiver_restart_replays_feed_without_double_delivery`; `spool.rs` wan unit tests |
| 11 | **WAN msg record forges its origin** (payload `origin_node`/`node` field) | None — forgery inert (unknown field) | Access-gate subject (handshake-proven `remote_id_hex`) | None needed (records carry no origin field by design — KH 7.5) | `tests/wanmsg.rs::wan_message_lands_exactly_once_under_transport_origin`; `wanmsg.rs::forged_origin_field_is_inert` |
| 12 | **Target brain killed mid-remote-attach** (operator typing into the dead window) | Pumping pauses until handoff | Broker-held QUIC stream, session + child, output log, effect journal; the operator's viewport | Successor re-serves the same stream from seq 0 (worst case): replayed input dedups at the PTY-write journal; re-transmitted output dedups at the operator's render cursor — every byte exactly once | `tests/attach.rs::attach_survives_target_brain_restart_exactly_once` |

## Planned rows (accepted debt — no injection evidence yet)

| # | Fault to inject | Expected blast radius | Expected survival | Target |
|---|---|---|---|---|
| P1 | **Iroh endpoint task hangs** (not crash — a wedged accept loop) | New inbound conns stall; existing conns + PTY hosting unaffected | PTY side fully isolated (separate threads/runtime) | D9 chaos pass |
| P2 | **mDNS discovery dies** | LAN dial-by-id fails; direct-addr + relay dials unaffected | Endpoint, conns, PTY | D9 (needs real LAN) |
| P3 | **Relay unreachable** (n0 outage / air-gap) | WAN dials needing relay fail; LAN/direct unaffected | Everything local | D9 two-host |
| P4 | **PTY broker drain thread dies** | That session's output stalls | Other sessions, net side | with terminal-wrapper hardening |
| P5 | **Manifest invocation hangs** (adapter subprocess) | That endpoint's lifecycle op | Daemon scheduler (KH 5.3 timeouts + 7.4 non-blocking) | when daemon hosts N agent loops |
| P6 | **Registry feed flood** (malicious/buggy peer spams updates) | Bounded by ring caps + lease; CPU cost unmeasured | — | D9 + REQ-SEC-1 outer gate |
| P7 | **Closed-watcher starvation** (net runtime saturated) | Presence lags behind reality | Conn table eventually consistent | D9 chaos pass |
| P8 | **Receiver crash between a live-TCP WAN delivery and its op-id claim** | That one in-flight op re-delivers on replay (at-least-once window — chosen over claim-first, whose crash gap silently *drops*; see `wan.rs` module docs) | Spool path (claim+row atomic); all other ops | live-listener durable receipt, if the window ever bites |

**Maintenance rule:** when a planned row gains a real injection test, move it up with
its evidence path. When new subsystems land (D5 WAN delivery, D6 Psyche sync), add
their fault rows *in the same change* — a subsystem without a row here is a review
finding.


TaskCreate

D5c: wire records — spt-net xfer.rs (NDJSON, no origin field)

XferRecord {Fetch|Push|Chunk(offset)|Done|Err} + XferDecoder, unknown-kind skip, unit tests (round-trip, unknown-field tolerance, no-origin-field hazard test)


Task #1 created successfully: D5c: wire records — spt-net xfer.rs (NDJSON, no origin field)


D5c: progress store + driven_by field — spt-store

xfer progress files {bytes_done,bytes_total,state} per (xfer_id, role) under spt_home/transfers, atomic writes, read helper; InfoJson.driven_by + set_driven_by; unit tests


Task #2 created successfully: D5c: progress store + driven_by field — spt-store


D5c: daemon pumps — serve_xfer/fetch_file/push_file + driven_by stamping in serve_attach

spt-daemon/src/xfer.rs: target serve (gated, offset-idempotent writes, temp+rename, progress per chunk, fire-and-forget sends only); operator fetch/push; serve_attach stamps/clears driven_by on endpoint perch


Task #3 created successfully: D5c: daemon pumps — serve_xfer/fetch_file/push_file + driven_by stamping in serve_attach


D5c: api expose driven_by

Query surface so agent can detect "driven by node X" via spt api


Task #4 created successfully: D5c: api expose driven_by

D5c: api expose driven_by


D5c: int tests — fetch cross-daemon + push survives brain restart + detection

tests/xfer.rs (fetch w/ mid-flight progress; crash mid-push → re-serve seq 0 → byte-identical); attach.rs detection test (driven_by set on attach, cleared on detach)


Task #5 created successfully: D5c: int tests — fetch cross-daemon + push survives brain restart + detection


D5c: docs + activation — handoff seam shape, FAULT-MATRIX row, REQ-REACH-1 [impl,unit]

Handoff seam (git pull + fresh-with-preload, remote dormant) doc'd as D6-consumed shape; FAULT-MATRIX row 13; traceable-reqs.toml activation; traceable-reqs check green; push + CI watch


Task #6 created successfully: D5c: docs + activation — handoff seam shape, FAULT-MATRIX row, REQ-REACH-1 [impl,unit]


1	//! Remote terminal attach records (M4-D5b, REQ-INST-8) — the byte-stream
2	//! viewport onto an instance running on another node.
3	//!
4	//! Remote-control is a **distinct mode from local operation** (CONTEXT
5	//! §Remote-control vs local operation): compute + files stay on the target
6	//! node; the operator's node is a viewport — a Shell-like driven surface,
7	//! daemon-to-daemon over the broker-owned QUIC streams (D4). The records here
8	//! are the wire shape of that viewport: the operator node opens a bidi stream,
9	//! sends [`AttachRecord::Request`], and the target node pumps its session's
10	//! `OutputLog` chunks back as [`AttachRecord::Output`] while injecting
11	//! [`AttachRecord::Input`] into the PTY through the journaled exactly-once
12	//! input path.
13	//!
14	//! Framing is the shared NDJSON ([`crate::net::ndjson`]); chunk bytes ride
15	//! base64 inside the JSON (the same posture as the IPC catalog's
16	//! `data_b64` fields — no binary fork of the codec). `Output.seq` is the
17	//! target session's `OutputLog` sequence verbatim, so the operator side dedups
18	//! replays (a target-brain restart redrives journaled sends) by cursor, and a
19	//! restarted operator brain resubscribes the QUIC stream and skips what it
20	//! already rendered — gapless exactly-once on both ends, composed entirely
21	//! from the D4/B5 machinery.
22	//!
23	//! Like [`WanMessage`](crate::net::wanmsg::WanMessage), attach records carry
24	//! **no origin field** — the access gate's subject is the QUIC handshake
25	//! identity from the broker's stream table (KNOWN-HAZARDS 7.5,
26	//! REQ-HAZARD-WAN-ORIGIN-AUTH).
27
28	use serde::{Deserialize, Serialize};
29
30	use crate::net::ndjson::{self, NdjsonDecoder};
31
32	/// One attach-protocol record. Tagged (`kind`) so both directions share one
33	/// stream decoder; an unknown future kind fails the line's decode and is
34	/// skipped (degrade, don't die — same posture as every wire feed).
35	// [impl->REQ-INST-8]
36	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37	#[serde(tag = "kind", rename_all = "snake_case")]
38	pub enum AttachRecord {
39	    /// Operator → target, first record on the stream: attach to `session_id`,
40	    /// replaying output from `from_seq` (the operator's resume cursor — 0 for
41	    /// a fresh attach, the last-rendered seq + 1 on re-attach).
42	    Request { session_id: u64, from_seq: u64 },
43	    /// Target → operator: one session output chunk. `seq` is the target
44	    /// session's `OutputLog` sequence verbatim (the operator's dedup cursor).
45	    Output { seq: u64, data_b64: String },
46	    /// Operator → target: input bytes for the session's PTY. `op_id` is the
47	    /// operator-minted durable id the target feeds into its journaled
48	    /// exactly-once input path (a replayed record cannot double-type).
49	    Input { data_b64: String, op_id: u64 },
50	    /// Target → operator: the session's child exited; the viewport is done.
51	    Exit { code: Option<i32> },
52	}
53
54	impl AttachRecord {
55	    /// Encode as one NDJSON line (the JSON document + `\n`).
56	    pub fn encode_line(&self) -> Vec<u8> {
57	        ndjson::encode_line(self)
58	    }
59	}
60
61	/// The attach stream's NDJSON reassembler — the shared
62	/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
63	/// [`AttachRecord`]s. One decoder per stream side.
64	pub type AttachDecoder = NdjsonDecoder<AttachRecord>;
65
66	#[cfg(test)]
67	mod tests {
68	    use super::*;
69
70	    // [unit->REQ-INST-8] every record kind round-trips through the NDJSON line
71	    // encoding, and the decoder skips an unknown future kind without wedging
72	    // the stream (forward compat: degrade, don't die).
73	    #[test]
74	    fn records_round_trip_and_unknown_kind_is_skipped() {
75	        let records = vec![
76	            AttachRecord::Request { session_id: 7, from_seq: 0 },
77	            AttachRecord::Output { seq: 3, data_b64: "aGk=".into() },
78	            AttachRecord::Input { data_b64: "bHM=".into(), op_id: 42 },
79	            AttachRecord::Exit { code: Some(0) },
80	        ];
81	        let mut wire = Vec::new();
82	        for r in &records {
83	            wire.extend_from_slice(&r.encode_line());
84	        }
85	        // An unknown future kind in the middle of the feed is skipped.
86	        let mut with_junk = records[0].encode_line();
87	        with_junk.extend_from_slice(b"{\"kind\":\"added_at_d9\",\"x\":1}\n");
88	        with_junk.extend_from_slice(&records[3].encode_line());
89
90	        let mut dec = AttachDecoder::new();
91	        assert_eq!(dec.push(&wire), records, "all kinds round-trip in order");
92
93	        let mut dec = AttachDecoder::new();
94	        assert_eq!(
95	            dec.push(&with_junk),
96	            vec![records[0].clone(), records[3].clone()],
97	            "unknown kind skipped, feed not wedged"
98	        );
99	    }
100
101	    // [unit->REQ-INST-8] chunk-split safety rides the shared decoder: a record
102	    // split mid-bytes across pushes reassembles (QUIC chunking is not record
103	    // framing).
104	    #[test]
105	    fn record_split_across_chunks_reassembles() {
106	        let r = AttachRecord::Output { seq: 9, data_b64: "c3BsaXQtbWU=".into() };
107	        let line = r.encode_line();
108	        let mut dec = AttachDecoder::new();
109	        assert!(dec.push(&line[..line.len() / 2]).is_empty());
110	        assert_eq!(dec.push(&line[line.len() / 2..]), vec![r]);
111	    }
112	}
113

//! Remote terminal attach records (M4-D5b, REQ-INST-8) — the byte-stream
//! viewport onto an instance running on another node.
//!
//! Remote-control is a **distinct mode from local operation** (CONTEXT
//! §Remote-control vs local operation): compute + files stay on the target
//! node; the operator's node is a viewport — a Shell-like driven surface,
//! daemon-to-daemon over the broker-owned QUIC streams (D4). The records here
//! are the wire shape of that viewport: the operator node opens a bidi stream,
//! sends [`AttachRecord::Request`], and the target node pumps its session's
//! `OutputLog` chunks back as [`AttachRecord::Output`] while injecting
//! [`AttachRecord::Input`] into the PTY through the journaled exactly-once
//! input path.
//!
//! Framing is the shared NDJSON ([`crate::net::ndjson`]); chunk bytes ride
//! base64 inside the JSON (the same posture as the IPC catalog's
//! `data_b64` fields — no binary fork of the codec). `Output.seq` is the
//! target session's `OutputLog` sequence verbatim, so the operator side dedups
//! replays (a target-brain restart redrives journaled sends) by cursor, and a
//! restarted operator brain resubscribes the QUIC stream and skips what it
//! already rendered — gapless exactly-once on both ends, composed entirely
//! from the D4/B5 machinery.
//!
//! Like [`WanMessage`](crate::net::wanmsg::WanMessage), attach records carry
//! **no origin field** — the access gate's subject is the QUIC handshake
//! identity from the broker's stream table (KNOWN-HAZARDS 7.5,
//! REQ-HAZARD-WAN-ORIGIN-AUTH).

use serde::{Deserialize, Serialize};

use crate::net::ndjson::{self, NdjsonDecoder};

/// One attach-protocol record. Tagged (`kind`) so both directions share one
/// stream decoder; an unknown future kind fails the line's decode and is
/// skipped (degrade, don't die — same posture as every wire feed).
// [impl->REQ-INST-8]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AttachRecord {
    /// Operator → target, first record on the stream: attach to `session_id`,
    /// replaying output from `from_seq` (the operator's resume cursor — 0 for
    /// a fresh attach, the last-rendered seq + 1 on re-attach).
    Request { session_id: u64, from_seq: u64 },
    /// Target → operator: one session output chunk. `seq` is the target
    /// session's `OutputLog` sequence verbatim (the operator's dedup cursor).
    Output { seq: u64, data_b64: String },
    /// Operator → target: input bytes for the session's PTY. `op_id` is the
    /// operator-minted durable id the target feeds into its journaled
    /// exactly-once input path (a replayed record cannot double-type).
    Input { data_b64: String, op_id: u64 },
    /// Target → operator: the session's child exited; the viewport is done.
    Exit { code: Option<i32> },
}

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

/// The attach stream's NDJSON reassembler — the shared
/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
/// [`AttachRecord`]s. One decoder per stream side.
pub type AttachDecoder = NdjsonDecoder<AttachRecord>;

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

    // [unit->REQ-INST-8] every record kind round-trips through the NDJSON line
    // encoding, and the decoder skips an unknown future kind without wedging
    // the stream (forward compat: degrade, don't die).
    #[test]
    fn records_round_trip_and_unknown_kind_is_skipped() {
        let records = vec![
            AttachRecord::Request { session_id: 7, from_seq: 0 },
            AttachRecord::Output { seq: 3, data_b64: "aGk=".into() },
            AttachRecord::Input { data_b64: "bHM=".into(), op_id: 42 },
            AttachRecord::Exit { code: Some(0) },
        ];
        let mut wire = Vec::new();
        for r in &records {
            wire.extend_from_slice(&r.encode_line());
        }
        // An unknown future kind in the middle of the feed is skipped.
        let mut with_junk = records[0].encode_line();
        with_junk.extend_from_slice(b"{\"kind\":\"added_at_d9\",\"x\":1}\n");
        with_junk.extend_from_slice(&records[3].encode_line());

        let mut dec = AttachDecoder::new();
        assert_eq!(dec.push(&wire), records, "all kinds round-trip in order");

        let mut dec = AttachDecoder::new();
        assert_eq!(
            dec.push(&with_junk),
            vec![records[0].clone(), records[3].clone()],
            "unknown kind skipped, feed not wedged"
        );
    }

    // [unit->REQ-INST-8] chunk-split safety rides the shared decoder: a record
    // split mid-bytes across pushes reassembles (QUIC chunking is not record
    // framing).
    #[test]
    fn record_split_across_chunks_reassembles() {
        let r = AttachRecord::Output { seq: 9, data_b64: "c3BsaXQtbWU=".into() };
        let line = r.encode_line();
        let mut dec = AttachDecoder::new();
        assert!(dec.push(&line[..line.len() / 2]).is_empty());
        assert_eq!(dec.push(&line[line.len() / 2..]), vec![r]);
    }
}


Bash

grep -n "ApiCmd\|enum\|Poll\|Presence" crates/spt/src/cli.rs | head -30 && echo ==== && grep -rn "driven\|status" crates/spt/src/api/delivery.rs crates/spt/src/api/live.rs | head

Find ApiCmd enum location


45:enum Cmd {
77:    Poll {
135:enum PairCmd {
158:        Cmd::Poll { id, once } => run_listen(&id, once),
387:    let perches = roster::enumerate();
435:enum Resolved {
629:        assert!(matches!(parse(&["spt", "poll", "bob", "--once"]).unwrap().cmd, Cmd::Poll { once: true, .. }));
====
crates/spt/src/api/live.rs:4://! M2b hosted the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest +


crates\spt\src\api\mod.rs:52:pub enum ApiCmd {
crates\spt\src\api\mod.rs-53-    \ Harness-hosted startup: record an ephemeral seed keyed by parent pid (T4).
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-54-    Seed {
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-55-        #[arg(long)]
crates\spt\src\api\mod.rs-56-        pid: u32,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-57-        #[arg(long)]
crates\spt\src\api\mod.rs-58-        session_id: String,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-59-    },
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-60-    /// Consume a seed and hold the perch + relay loop (interim long-runner, T4).
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-61-    Listen {
crates\spt\src\api\mod.rs-62-        id: String,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-63-        /// Override the parent-pid anchor (defaults to the self-discovered PPID).
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-64-        #[arg(long)]
crates\spt\src\api\mod.rs-65-        parent_pid: Option<u32>,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-66-        /// Drain backlog + one receive cycle, then exit (testability).
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-67-        #[arg(long)]
crates\spt\src\api\mod.rs-68-        once: bool,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-69-    },
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-70-    /// Post-spawn bind of a session to its perch (T4).
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-71-    Bind {
crates\spt\src\api\mod.rs-72-        id: String,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-73-        /// The session id discovered post-spawn, written into the perch record.
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-74-        #[arg(long = "set-session-id")]
crates\spt\src\api\mod.rs-75-        bind_session: Option<String>,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-76-        #[command(flatten)]
crates\spt\src\api\mod.rs-77-        auth: AuthFlags,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-78-    },
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-79-    /// Set activity state busy|idle (+ echo-gate sentinel) (T5).
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-80-    State {
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-81-        #[arg(value_parser = ["busy", "idle"])]
crates\spt\src\api\mod.rs-82-        state: String,
crates\spt\src\api\mod.rs-83-        id: String,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-84-        #[arg(long)]
crates\spt\src\api\mod.rs-85-        no_gate: bool,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-86-        #[command(flatten)]
crates\spt\src\api\mod.rs-87-        auth: AuthFlags,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-88-    },
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-89-    /// Manage the echo-gate sentinel directly (T5).
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-90-    EchoGate {
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-91-        #[arg(value_parser = ["set", "clear"])]
crates\spt\src\api\mod.rs-92-        action: String,
crates\spt\src\api\mod.rs-93-        id: String,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-94-        #[command(flatten)]
crates\spt\src\api\mod.rs-95-        auth: AuthFlags,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-96-    },
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-97-    /// Drain delivered messages (hook channel) (T5).
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-98-    Poll {
crates\spt\src\api\mod.rs-99-        id: String,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-100-        #[arg(long)]
crates\spt\src\api\mod.rs-101-        include_deferred: bool,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-102-        #[command(flatten)]
crates\spt\src\api\mod.rs-103-        auth: AuthFlags,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-104-    },
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-105-    /// Create a nested worker perch under a parent (T6).
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-106-    WorkerStart {
crates\spt\src\api\mod.rs-107-        parent: String,
crates\spt\src\api\mod.rs-108-        id: String,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-109-        #[command(flatten)]
crates\spt\src\api\mod.rs-110-        auth: AuthFlags,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-111-    },
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-112-    /// Tear down a worker perch (T6).
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-113-    WorkerStop {
crates\spt\src\api\mod.rs-114-        id: String,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-115-        #[command(flatten)]
crates\spt\src\api\mod.rs-116-        auth: AuthFlags,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-117-    },
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-118-    /// Drain a worker perch's messages (T6).
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-119-    WorkerPoll {
crates\spt\src\api\mod.rs-120-        id: String,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-121-        #[command(flatten)]
crates\spt\src\api\mod.rs-122-        auth: AuthFlags,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-123-    },
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-124-    /// Rebind the perch to a new session_id, preserving identity (T7).
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-125-    Boundary {
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-126-        #[arg(value_parser = ["clear", "compact"])]
crates\spt\src\api\mod.rs-127-        mode: String,
crates\spt\src\api\mod.rs-128-        id: String,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-129-        /// The new session id to rebind the perch to.
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-130-        #[arg(long = "to-session-id")]
crates\spt\src\api\mod.rs-131-        to_session: String,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-132-        #[command(flatten)]
crates\spt\src\api\mod.rs-133-        auth: AuthFlags,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-134-    },
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-135-    /// Soft teardown (spool/history preserved); `--erase` hard-wipes (T7).
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-136-    SessionEnd {
crates\spt\src\api\mod.rs-137-        id: String,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-138-        #[arg(long)]
crates\spt\src\api\mod.rs-139-        erase: bool,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-140-        #[command(flatten)]
crates\spt\src\api\mod.rs-141-        auth: AuthFlags,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-142-    },


Updated task #1 status


1	//! NDJSON line framing shared by the wire feeds (registry replication M4-D4d,
2	//! WAN messages M4-D5a).
3	//!
4	//! One JSON object per `\n`-terminated line. QUIC stream reads do **not**
5	//! preserve write boundaries (the read pump pulls up-to-64KiB chunks — writes
6	//! coalesce and split arbitrarily), so records need framing independent of
7	//! chunk shape; a newline delimiter on a JSON document (whose interior newlines
8	//! are always escaped) is the simplest framing that needs no length
9	//! bookkeeping. Decoding tolerates unknown fields (a newer node may add fields
10	//! — same posture as the IPC catalog) and skips undecodable lines (degrade,
11	//! don't die — the 4.3 stale-clean philosophy: one corrupt record must not
12	//! wedge the feed).
13	//!
14	//! Extracted from `replicate.rs` (where D4d proved the framing for
15	//! [`RegistryUpdate`](crate::net::replicate::RegistryUpdate)) when D5a's
16	//! [`WanMessage`](crate::net::wanmsg::WanMessage) became the second record
17	//! type riding it. The decode rules are identical for every record type; per-
18	//! record semantics (epoch lease, op-id dedup) stay with the record's module.
19
20	use std::marker::PhantomData;
21
22	use serde::de::DeserializeOwned;
23	use serde::Serialize;
24
25	/// Encode one record as one NDJSON line (the JSON document + `\n`).
26	pub fn encode_line<T: Serialize>(record: &T) -> Vec<u8> {
27	    let mut line = serde_json::to_vec(record).expect("wire record serializes");
28	    line.push(b'\n');
29	    line
30	}
31
32	/// Reassembles NDJSON records from arbitrarily-chunked stream bytes: push each
33	/// received chunk, take the complete records. A partial trailing line stays
34	/// buffered until its `\n` arrives; an undecodable line is skipped (see module
35	/// docs). One decoder per stream — records never span streams.
36	#[derive(Debug)]
37	pub struct NdjsonDecoder<T> {
38	    buf: Vec<u8>,
39	    _records: PhantomData<fn() -> T>,
40	}
41
42	impl<T> Default for NdjsonDecoder<T> {
43	    fn default() -> NdjsonDecoder<T> {
44	        NdjsonDecoder { buf: Vec::new(), _records: PhantomData }
45	    }
46	}
47
48	impl<T: DeserializeOwned> NdjsonDecoder<T> {
49	    /// A fresh decoder (empty buffer).
50	    pub fn new() -> NdjsonDecoder<T> {
51	        NdjsonDecoder::default()
52	    }
53
54	    /// Push one received chunk and return every record completed by it, in wire
55	    /// order. Bytes after the last `\n` are retained for the next push.
56	    // [impl->REQ-INST-7]
57	    pub fn push(&mut self, chunk: &[u8]) -> Vec<T> {
58	        self.buf.extend_from_slice(chunk);
59	        let mut out = Vec::new();
60	        // Split off complete lines; keep the tail.
61	        while let Some(pos) = self.buf.iter().position(|b| *b == b'\n') {
62	            let rest = self.buf.split_off(pos + 1);
63	            let line = std::mem::replace(&mut self.buf, rest);
64	            if let Ok(rec) = serde_json::from_slice::<T>(&line[..pos]) {
65	                out.push(rec);
66	            }
67	            // else: skipped — a corrupt record must not wedge the feed.
68	        }
69	        out
70	    }
71	}
72

//! NDJSON line framing shared by the wire feeds (registry replication M4-D4d,
//! WAN messages M4-D5a).
//!
//! One JSON object per `\n`-terminated line. QUIC stream reads do **not**
//! preserve write boundaries (the read pump pulls up-to-64KiB chunks — writes
//! coalesce and split arbitrarily), so records need framing independent of
//! chunk shape; a newline delimiter on a JSON document (whose interior newlines
//! are always escaped) is the simplest framing that needs no length
//! bookkeeping. Decoding tolerates unknown fields (a newer node may add fields
//! — same posture as the IPC catalog) and skips undecodable lines (degrade,
//! don't die — the 4.3 stale-clean philosophy: one corrupt record must not
//! wedge the feed).
//!
//! Extracted from `replicate.rs` (where D4d proved the framing for
//! [`RegistryUpdate`](crate::net::replicate::RegistryUpdate)) when D5a's
//! [`WanMessage`](crate::net::wanmsg::WanMessage) became the second record
//! type riding it. The decode rules are identical for every record type; per-
//! record semantics (epoch lease, op-id dedup) stay with the record's module.

use std::marker::PhantomData;

use serde::de::DeserializeOwned;
use serde::Serialize;

/// Encode one record as one NDJSON line (the JSON document + `\n`).
pub fn encode_line<T: Serialize>(record: &T) -> Vec<u8> {
    let mut line = serde_json::to_vec(record).expect("wire record serializes");
    line.push(b'\n');
    line
}

/// Reassembles NDJSON records from arbitrarily-chunked stream bytes: push each
/// received chunk, take the complete records. A partial trailing line stays
/// buffered until its `\n` arrives; an undecodable line is skipped (see module
/// docs). One decoder per stream — records never span streams.
#[derive(Debug)]
pub struct NdjsonDecoder<T> {
    buf: Vec<u8>,
    _records: PhantomData<fn() -> T>,
}

impl<T> Default for NdjsonDecoder<T> {
    fn default() -> NdjsonDecoder<T> {
        NdjsonDecoder { buf: Vec::new(), _records: PhantomData }
    }
}

impl<T: DeserializeOwned> NdjsonDecoder<T> {
    /// A fresh decoder (empty buffer).
    pub fn new() -> NdjsonDecoder<T> {
        NdjsonDecoder::default()
    }

    /// Push one received chunk and return every record completed by it, in wire
    /// order. Bytes after the last `\n` are retained for the next push.
    // [impl->REQ-INST-7]
    pub fn push(&mut self, chunk: &[u8]) -> Vec<T> {
        self.buf.extend_from_slice(chunk);
        let mut out = Vec::new();
        // Split off complete lines; keep the tail.
        while let Some(pos) = self.buf.iter().position(|b| *b == b'\n') {
            let rest = self.buf.split_off(pos + 1);
            let line = std::mem::replace(&mut self.buf, rest);
            if let Ok(rec) = serde_json::from_slice::<T>(&line[..pos]) {
                out.push(rec);
            }
            // else: skipped — a corrupt record must not wedge the feed.
        }
        out
    }
}


Write `spt-net/src/net/xfer.rs`: