1	//! D4a regression — the broker owns the node's iroh endpoint + live QUIC
2	//! connections (ADR-0004 §B), driven by the brain over the versioned IPC.
3	//!
4	//! Hermetic throughout: relays disabled, no discovery, loopback QUIC only —
5	//! no network is reached. The shapes proven here:
6	//!
7	//! - the endpoint is constructed **broker-side** and reported to the brain via
8	//!   the `net-status` frame (node id == the identity the broker bound);
9	//! - a brain dials a **second in-process broker's endpoint** through the
10	//!   `net-dial` frame; both brokers register the connection in their tables;
11	//! - an `op_id`-carrying dial is **exactly-once across a brain restart**: the
12	//!   replayed dial from the next brain life is deduped at the broker (no second
13	//!   connection) and answered with the original connection;
14	//! - an unknown `net-*` frame is tolerated (reported, connection survives) —
15	//!   the 2.3 forward-compat invariant carries into the net vocabulary;
16	//! - a net-less broker answers `net-status` with `enabled: false` and a dial
17	//!   with a typed error (capability probing, not a wedge).
18
19	use std::sync::atomic::{AtomicU32, Ordering};
20	use std::sync::Arc;
21	use std::thread;
22	use std::time::Duration;
23
24	use spt_daemon::brain::Brain;
25	use spt_daemon::nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
26	use spt_daemon::Broker;
27	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
28	use spt_proto::identity::Identity;
29
30	static SEQ: AtomicU32 = AtomicU32::new(0);
31	fn unique_name() -> String {
32	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
33	    format!("spt-daemon-d4a-{}-{}.sock", std::process::id(), n)
34	}
35
36	/// A hermetic net config: relays disabled, no discovery, loopback-only sockets
37	/// (no real interface touched; no per-test-binary Windows firewall prompt).
38	fn hermetic(identity: Identity) -> NetConfig {
39	    NetConfig {
40	        identity,
41	        relay: RelayPolicy::Disabled,
42	        discovery: LocalDiscovery::Off,
43	        scope: BindScope::Loopback,
44	        membership: None,
45	    }
46	}
47
48	/// Bind a broker (serving on a background thread) that owns a hermetic endpoint
49	/// for `identity`, with its effect journal in `dir`.
50	fn net_broker(name: &str, identity: Identity, dir: &std::path::Path) -> Arc<Broker> {
51	    let host = NetHost::start(hermetic(identity)).expect("net host start");
52	    let broker =
53	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
54	    let serve = Arc::clone(&broker);
55	    thread::spawn(move || {
56	        let _ = serve.serve();
57	    });
58	    broker
59	}
60
61	fn connect_retry(name: &str) -> Brain {
62	    for _ in 0..300 {
63	        if let Ok(b) = Brain::cold_start(name, 1) {
64	            return b;
65	        }
66	        thread::sleep(Duration::from_millis(10));
67	    }
68	    panic!("brain could not connect");
69	}
70
71	// [int->REQ-DAEMON-2] the endpoint is constructed broker-side and surfaced to
72	// the brain over the `net-status` frame: the reported node id is exactly the
73	// identity the broker bound (EndpointId == node pubkey), with a dialable
74	// address and an empty conn table.
75	#[test]
76	fn broker_owns_endpoint_and_reports_status() {
77	    let id = Identity::generate();
78	    let expect_hex = id.public_key().to_hex();
79	    let dir = tempfile::tempdir().expect("tempdir");
80	    let name = unique_name();
81	    let _broker = net_broker(&name, id, dir.path());
82
83	    let mut brain = connect_retry(&name);
84	    let status = brain.net_status().expect("net-status");
85	    assert!(status.enabled, "broker hosts an endpoint");
86	    assert_eq!(status.node_id_hex.as_deref(), Some(expect_hex.as_str()));
87	    assert!(!status.addr.is_null(), "dialable address rides the reply");
88	    assert_eq!(status.conn_count, 0);
89	}
90
91	// [int->REQ-DAEMON-2] a brain dials a second in-process broker's endpoint over
92	// hermetic loopback QUIC through the `net-dial` frame: the dialer's broker
93	// registers the connection in its table, and the second broker's accept loop
94	// registers the inbound side — both visible over their own IPC status frames.
95	#[test]
96	fn brain_dials_second_broker_over_loopback() {
97	    let dir = tempfile::tempdir().expect("tempdir");
98	    let (name_a, name_b) = (unique_name(), unique_name());
99	    let id_b = Identity::generate();
100	    let b_hex = id_b.public_key().to_hex();
101	    let _broker_a = net_broker(&name_a, Identity::generate(), &dir.path().join("a"));
102	    let _broker_b = net_broker(&name_b, id_b, &dir.path().join("b"));
103
104	    let mut brain_a = connect_retry(&name_a);
105	    let mut brain_b = connect_retry(&name_b);
106
107	    // Hand B's dialable address to A's brain (as a ticket exchange would).
108	    let addr = brain_b.net_status().expect("b status").addr;
109	    let dialed = brain_a.net_dial(addr, None).expect("dial");
110	    assert_eq!(
111	        dialed.remote_id_hex, b_hex,
112	        "QUIC handshake proves the peer"
113	    );
114	    assert!(dialed.applied_now);
115
116	    assert_eq!(brain_a.net_status().expect("a status").conn_count, 1);
117	    // B's accept loop registers the inbound connection asynchronously.
118	    let mut b_count = 0;
119	    for _ in 0..400 {
120	        b_count = brain_b.net_status().expect("b status").conn_count;
121	        if b_count == 1 {
122	            break;
123	        }
124	        thread::sleep(Duration::from_millis(5));
125	    }
126	    assert_eq!(b_count, 1, "acceptor broker registered the inbound conn");
127	}
128
129	// [int->REQ-HAZARD-RESTART-IDEMPOTENT] an `op_id`-carrying dial is exactly-once
130	// across a brain restart: a fresh brain life replaying the same durable id is
131	// deduped at the broker (no second QUIC connection — the conn table stays at
132	// one) and answered with the connection the first life opened. The broker-owned
133	// journal holds the net-namespaced key.
134	#[test]
135	fn replayed_dial_op_is_deduped_across_brain_restart() {
136	    let dir = tempfile::tempdir().expect("tempdir");
137	    let (name_a, name_b) = (unique_name(), unique_name());
138	    let broker_a = net_broker(&name_a, Identity::generate(), &dir.path().join("a"));
139	    let _broker_b = net_broker(&name_b, Identity::generate(), &dir.path().join("b"));
140
141	    let mut brain_b = connect_retry(&name_b);
142	    let addr = brain_b.net_status().expect("b status").addr;
143
144	    const OP: u64 = 42;
145	    // Life 1: journaled dial, then "crash" (connection drop).
146	    let mut life1 = connect_retry(&name_a);
147	    let first = life1.net_dial(addr.clone(), Some(OP)).expect("dial");
148	    assert!(first.applied_now, "first delivery runs the dial");
149	    drop(life1);
150
151	    // Life 2: re-drives the same durable op (the brain never learned it landed).
152	    let mut life2 = connect_retry(&name_a);
153	    let replay = life2.net_dial(addr, Some(OP)).expect("replayed dial");
154	    assert!(!replay.applied_now, "replay is deduped, not re-dialed");
155	    assert_eq!(
156	        replay.conn_id, first.conn_id,
157	        "answered with the original connection"
158	    );
159	    assert_eq!(replay.remote_id_hex, first.remote_id_hex);
160
161	    // Exactly-once at the resource: one connection, one journaled key.
162	    assert_eq!(life2.net_status().expect("a status").conn_count, 1);
163	    assert!(broker_a.journal().is_applied((NET_EFFECT_SESSION, OP)));
164	    assert_eq!(broker_a.journal().applied_count(), 1);
165	}
166
167	// [int->REQ-DAEMON-2] an unknown net frame kind is tolerated: the broker
168	// reports it and the connection keeps serving (the 2.3 unknown-kind invariant
169	// holds for the D4a vocabulary too).
170	#[test]
171	fn unknown_net_frame_is_reported_not_fatal() {
172	    let dir = tempfile::tempdir().expect("tempdir");
173	    let name = unique_name();
174	    let _broker = net_broker(&name, Identity::generate(), dir.path());
175
176	    let mut brain = connect_retry(&name);
177	    // A future net frame this broker has never heard of, sent raw.
178	    brain
179	        .net_dial(serde_json::json!("not even an address"), None)
180	        .expect_err("malformed addr is a reported error, not a panic");
181	    // The connection survived: status still answers.
182	    let status = brain.net_status().expect("net-status after error");
183	    assert!(status.enabled);
184
185	    // And a genuinely unknown KIND surfaces as an error event, connection alive.
186	    // (Drive the raw frame through the brain's transport via a second client.)
187	    use spt_daemon::codec::{read_frame, write_frame};
188	    use spt_daemon::frame::{Envelope, Role};
189	    use spt_daemon::transport::{send_hello, LocalSocketTransport};
190	    let mut conn = LocalSocketTransport::connect(&name).expect("connect raw");
191	    send_hello(&mut conn, Role::Brain).expect("hello");
192	    write_frame(
193	        &mut conn,
194	        &Envelope::new("net-frame-from-the-future", serde_json::Value::Null),
195	    )
196	    .expect("send unknown kind");
197	    let reply = read_frame(&mut conn).expect("connection still serving");
198	    assert_eq!(reply.kind, "error", "unknown kind reported, not fatal");
199	    // Still serving after the error: a status round-trips on the same connection.
200	    write_frame(
201	        &mut conn,
202	        &Envelope::new("net-status", serde_json::Value::Null),
203	    )
204	    .expect("send status");
205	    let reply = read_frame(&mut conn).expect("still alive");
206	    assert_eq!(reply.kind, "net-status-reply");
207	}
208
209	// [int->REQ-DAEMON-2] a broker bound without a network host answers capability
210	// probes gracefully: `net-status` says disabled, a dial is a typed error.
211	#[test]
212	fn netless_broker_probes_gracefully() {
213	    let dir = tempfile::tempdir().expect("tempdir");
214	    let name = unique_name();
215	    let broker = Broker::bind_in(&name, dir.path().join("effects.log")).expect("bind");
216	    let serve = Arc::clone(&broker);
217	    thread::spawn(move || {
218	        let _ = serve.serve();
219	    });
220
221	    let mut brain = connect_retry(&name);
222	    let status = brain.net_status().expect("net-status");
223	    assert!(!status.enabled, "no endpoint hosted");
224	    assert!(status.node_id_hex.is_none());
225	    assert_eq!(status.conn_count, 0);
226
227	    let err = brain
228	        .net_dial(serde_json::json!({}), None)
229	        .expect_err("dial on a net-less broker errors");
230	    assert!(
231	        err.to_string().contains("net disabled"),
232	        "typed, operator-facing: {err}"
233	    );
234	}
235

//! D4a regression — the broker owns the node's iroh endpoint + live QUIC
//! connections (ADR-0004 §B), driven by the brain over the versioned IPC.
//!
//! Hermetic throughout: relays disabled, no discovery, loopback QUIC only —
//! no network is reached. The shapes proven here:
//!
//! - the endpoint is constructed **broker-side** and reported to the brain via
//!   the `net-status` frame (node id == the identity the broker bound);
//! - a brain dials a **second in-process broker's endpoint** through the
//!   `net-dial` frame; both brokers register the connection in their tables;
//! - an `op_id`-carrying dial is **exactly-once across a brain restart**: the
//!   replayed dial from the next brain life is deduped at the broker (no second
//!   connection) and answered with the original connection;
//! - an unknown `net-*` frame is tolerated (reported, connection survives) —
//!   the 2.3 forward-compat invariant carries into the net vocabulary;
//! - a net-less broker answers `net-status` with `enabled: false` and a dial
//!   with a typed error (capability probing, not a wedge).

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

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

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

/// A hermetic net config: relays disabled, no discovery, loopback-only sockets
/// (no real interface touched; no per-test-binary Windows firewall prompt).
fn hermetic(identity: Identity) -> NetConfig {
    NetConfig {
        identity,
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
        membership: None,
    }
}

/// Bind a broker (serving on a background thread) that owns a hermetic endpoint
/// for `identity`, with its effect journal in `dir`.
fn net_broker(name: &str, identity: Identity, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic(identity)).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");
}

// [int->REQ-DAEMON-2] the endpoint is constructed broker-side and surfaced to
// the brain over the `net-status` frame: the reported node id is exactly the
// identity the broker bound (EndpointId == node pubkey), with a dialable
// address and an empty conn table.
#[test]
fn broker_owns_endpoint_and_reports_status() {
    let id = Identity::generate();
    let expect_hex = id.public_key().to_hex();
    let dir = tempfile::tempdir().expect("tempdir");
    let name = unique_name();
    let _broker = net_broker(&name, id, dir.path());

    let mut brain = connect_retry(&name);
    let status = brain.net_status().expect("net-status");
    assert!(status.enabled, "broker hosts an endpoint");
    assert_eq!(status.node_id_hex.as_deref(), Some(expect_hex.as_str()));
    assert!(!status.addr.is_null(), "dialable address rides the reply");
    assert_eq!(status.conn_count, 0);
}

// [int->REQ-DAEMON-2] a brain dials a second in-process broker's endpoint over
// hermetic loopback QUIC through the `net-dial` frame: the dialer's broker
// registers the connection in its table, and the second broker's accept loop
// registers the inbound side — both visible over their own IPC status frames.
#[test]
fn brain_dials_second_broker_over_loopback() {
    let dir = tempfile::tempdir().expect("tempdir");
    let (name_a, name_b) = (unique_name(), unique_name());
    let id_b = Identity::generate();
    let b_hex = id_b.public_key().to_hex();
    let _broker_a = net_broker(&name_a, Identity::generate(), &dir.path().join("a"));
    let _broker_b = net_broker(&name_b, id_b, &dir.path().join("b"));

    let mut brain_a = connect_retry(&name_a);
    let mut brain_b = connect_retry(&name_b);

    // Hand B's dialable address to A's brain (as a ticket exchange would).
    let addr = brain_b.net_status().expect("b status").addr;
    let dialed = brain_a.net_dial(addr, None).expect("dial");
    assert_eq!(
        dialed.remote_id_hex, b_hex,
        "QUIC handshake proves the peer"
    );
    assert!(dialed.applied_now);

    assert_eq!(brain_a.net_status().expect("a status").conn_count, 1);
    // B's accept loop registers the inbound connection asynchronously.
    let mut b_count = 0;
    for _ in 0..400 {
        b_count = brain_b.net_status().expect("b status").conn_count;
        if b_count == 1 {
            break;
        }
        thread::sleep(Duration::from_millis(5));
    }
    assert_eq!(b_count, 1, "acceptor broker registered the inbound conn");
}

// [int->REQ-HAZARD-RESTART-IDEMPOTENT] an `op_id`-carrying dial is exactly-once
// across a brain restart: a fresh brain life replaying the same durable id is
// deduped at the broker (no second QUIC connection — the conn table stays at
// one) and answered with the connection the first life opened. The broker-owned
// journal holds the net-namespaced key.
#[test]
fn replayed_dial_op_is_deduped_across_brain_restart() {
    let dir = tempfile::tempdir().expect("tempdir");
    let (name_a, name_b) = (unique_name(), unique_name());
    let broker_a = net_broker(&name_a, Identity::generate(), &dir.path().join("a"));
    let _broker_b = net_broker(&name_b, Identity::generate(), &dir.path().join("b"));

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

    const OP: u64 = 42;
    // Life 1: journaled dial, then "crash" (connection drop).
    let mut life1 = connect_retry(&name_a);
    let first = life1.net_dial(addr.clone(), Some(OP)).expect("dial");
    assert!(first.applied_now, "first delivery runs the dial");
    drop(life1);

    // Life 2: re-drives the same durable op (the brain never learned it landed).
    let mut life2 = connect_retry(&name_a);
    let replay = life2.net_dial(addr, Some(OP)).expect("replayed dial");
    assert!(!replay.applied_now, "replay is deduped, not re-dialed");
    assert_eq!(
        replay.conn_id, first.conn_id,
        "answered with the original connection"
    );
    assert_eq!(replay.remote_id_hex, first.remote_id_hex);

    // Exactly-once at the resource: one connection, one journaled key.
    assert_eq!(life2.net_status().expect("a status").conn_count, 1);
    assert!(broker_a.journal().is_applied((NET_EFFECT_SESSION, OP)));
    assert_eq!(broker_a.journal().applied_count(), 1);
}

// [int->REQ-DAEMON-2] an unknown net frame kind is tolerated: the broker
// reports it and the connection keeps serving (the 2.3 unknown-kind invariant
// holds for the D4a vocabulary too).
#[test]
fn unknown_net_frame_is_reported_not_fatal() {
    let dir = tempfile::tempdir().expect("tempdir");
    let name = unique_name();
    let _broker = net_broker(&name, Identity::generate(), dir.path());

    let mut brain = connect_retry(&name);
    // A future net frame this broker has never heard of, sent raw.
    brain
        .net_dial(serde_json::json!("not even an address"), None)
        .expect_err("malformed addr is a reported error, not a panic");
    // The connection survived: status still answers.
    let status = brain.net_status().expect("net-status after error");
    assert!(status.enabled);

    // And a genuinely unknown KIND surfaces as an error event, connection alive.
    // (Drive the raw frame through the brain's transport via a second client.)
    use spt_daemon::codec::{read_frame, write_frame};
    use spt_daemon::frame::{Envelope, Role};
    use spt_daemon::transport::{send_hello, LocalSocketTransport};
    let mut conn = LocalSocketTransport::connect(&name).expect("connect raw");
    send_hello(&mut conn, Role::Brain).expect("hello");
    write_frame(
        &mut conn,
        &Envelope::new("net-frame-from-the-future", serde_json::Value::Null),
    )
    .expect("send unknown kind");
    let reply = read_frame(&mut conn).expect("connection still serving");
    assert_eq!(reply.kind, "error", "unknown kind reported, not fatal");
    // Still serving after the error: a status round-trips on the same connection.
    write_frame(
        &mut conn,
        &Envelope::new("net-status", serde_json::Value::Null),
    )
    .expect("send status");
    let reply = read_frame(&mut conn).expect("still alive");
    assert_eq!(reply.kind, "net-status-reply");
}

// [int->REQ-DAEMON-2] a broker bound without a network host answers capability
// probes gracefully: `net-status` says disabled, a dial is a typed error.
#[test]
fn netless_broker_probes_gracefully() {
    let dir = tempfile::tempdir().expect("tempdir");
    let name = unique_name();
    let broker = Broker::bind_in(&name, dir.path().join("effects.log")).expect("bind");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });

    let mut brain = connect_retry(&name);
    let status = brain.net_status().expect("net-status");
    assert!(!status.enabled, "no endpoint hosted");
    assert!(status.node_id_hex.is_none());
    assert_eq!(status.conn_count, 0);

    let err = brain
        .net_dial(serde_json::json!({}), None)
        .expect_err("dial on a net-less broker errors");
    assert!(
        err.to_string().contains("net disabled"),
        "typed, operator-facing: {err}"
    );
}


Both int files mapped. The documented deterministic black hole is a membership mismatch (module docs line 73-74: "both peers must agree, or the side expecting the control stream would hang") or a dead UDP address. I need to know `EndpointAddr`'s shape to fabricate a black-hole peer, and confirm the `MembershipSource` type. Check spt_net endpoint.


Grep

pub struct EndpointAddr|pub fn.*EndpointAddr|impl EndpointAddr|direct_addresses|pub fn new|relay_url|node_id|from_parts|pub struct NodeAddr|with_direct


crates\spt-net\src\net\endpoint.rs:272:    pub fn node_id(&self) -> PublicKey {
crates\spt-net\src\net\endpoint.rs:278:    pub fn node_id_hex(&self) -> String {
crates\spt-net\src\net\endpoint.rs:279:        self.node_id().to_hex()
crates\spt-net\src\net\endpoint.rs:284:    pub fn addr(&self) -> EndpointAddr {
crates\spt-net\src\net\endpoint.rs:355:pub fn addr_for_node_hex(hex: &str) -> Option<EndpointAddr> {
crates\spt-net\src\net\endpoint.rs:370:pub fn conn_remote_addr(conn: &Connection) -> EndpointAddr {
crates\spt-net\src\net\endpoint.rs:377:    EndpointAddr::from_parts(conn.remote_id(), addrs)
crates\spt-net\src\net\endpoint.rs:449:            ep.node_id(),
crates\spt-net\src\net\endpoint.rs:453:        assert_eq!(ep.node_id_hex(), id.public_key().to_hex());
crates\spt-net\src\net\endpoint.rs:490:        let server_pk = server.node_id();


300
301	    /// Gracefully close the endpoint: every held connection sends its QUIC
302	    /// CLOSE frame so peers observe the disconnect promptly (a bare drop may
303	    /// tear the runtime down before the close ever flushes, leaving peers to
304	    /// an idle timeout). The D4c presence seam relies on this for prompt
305	    /// disconnected events.
306	    // [impl->REQ-NET-1]
307	    pub async fn close(&self) {
308	        self.inner.close().await;
309	    }
310
311	    /// Dial a peer by address over [`SPT_NET_ALPN`].
312	    // [impl->REQ-NET-1]
313	    pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {
314	        self.connect_with_alpn(addr, SPT_NET_ALPN).await
315	    }
316
317	    /// Dial a peer over an explicit ALPN (the pairing driver dials `SPT_PAIR_ALPN`).
318	    // [impl->REQ-NET-1]
319	    pub async fn connect_with_alpn(
320	        &self,
321	        addr: EndpointAddr,
322	        alpn: &[u8],
323	    ) -> Result<Connection, NetError> {
324	        self.inner
325	            .connect(addr, alpn)
326	            .await
327	            .map_err(|e| NetError::Connect(e.to_string()))
328	    }
329
330	    /// Dial a peer by its node id (public key) alone, letting discovery (mDNS on the
331	    /// LAN, n0 DNS on the WAN) resolve the address. This is the "no ticket needed"
332	    /// path: with [`LocalDiscovery::Mdns`] enabled, two machines on one LAN connect
333	    /// knowing only each other's stable name.
334	    // [impl->REQ-NET-1]
335	    pub async fn connect_by_id(&self, peer: PublicKey) -> Result<Connection, NetError> {
336	        let id = EndpointId::from_bytes(&peer.to_bytes())
337	            .map_err(|e| NetError::Connect(format!("invalid peer id: {e}")))?;
338	        self.connect(EndpointAddr::new(id)).await
339	    }
340
341	    /// Borrow the underlying iroh endpoint — the seam the D4 broker uses to own the
342	    /// endpoint + its live QUIC streams across brain restarts (ADR-0004 §B).
343	    pub fn iroh(&self) -> &Endpoint {
344	        &self.inner
345	    }
346	}
347
348	/// An id-only dialable [`EndpointAddr`] for a node named by its lowercase-hex
349	/// pubkey (the trust store's identity column), letting discovery (mDNS on the
350	/// LAN, n0 DNS on the WAN) resolve the transport address. The peer pump's
351	/// production resolver (D9-1): dial trusted peers knowing only their stable
352	/// name. `None` on a malformed hex — a corrupt trust row must not panic the
353	/// pump.
354	// [impl->REQ-NET-1]
355	pub fn addr_for_node_hex(hex: &str) -> Option<EndpointAddr> {
356	    let pk = spt_proto::identity::PublicKey::from_hex(hex).ok()?;
357	    let id = EndpointId::from_bytes(&pk.to_bytes()).ok()?;
358	    Some(EndpointAddr::new(id))
359	}
360
361	/// The peer's dialable address as OBSERVED from a live connection: the
362	/// handshake-proven remote id + the transport addresses of the connection's
363	/// current (non-closed) network paths. This is what the address-seeding
364	/// writers persist (M8 decisions 14/20, REQ-CONV-1): an address that just
365	/// carried a real connection is the best possible cold-start seed — unlike
366	/// the id-only `addr_for_node_hex`, it dials without a discovery round-trip.
367	/// May be path-less (id-only) when no path is readable; callers skip
368	/// persisting those (they would add nothing over id-only discovery).
369	// [impl->REQ-CONV-1]
370	pub fn conn_remote_addr(conn: &Connection) -> EndpointAddr {
371	    let addrs: Vec<_> = conn
372	        .paths()
373	        .into_iter()
374	        .filter(|p| !p.is_closed())
375	        .map(|p| p.remote_addr().clone())
376	        .collect();
377	    EndpointAddr::from_parts(conn.remote_id(), addrs)
378	}
379

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

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

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

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

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

/// An id-only dialable [`EndpointAddr`] for a node named by its lowercase-hex
/// pubkey (the trust store's identity column), letting discovery (mDNS on the
/// LAN, n0 DNS on the WAN) resolve the transport address. The peer pump's
/// production resolver (D9-1): dial trusted peers knowing only their stable
/// name. `None` on a malformed hex — a corrupt trust row must not panic the
/// pump.
// [impl->REQ-NET-1]
pub fn addr_for_node_hex(hex: &str) -> Option<EndpointAddr> {
    let pk = spt_proto::identity::PublicKey::from_hex(hex).ok()?;
    let id = EndpointId::from_bytes(&pk.to_bytes()).ok()?;
    Some(EndpointAddr::new(id))
}

/// The peer's dialable address as OBSERVED from a live connection: the
/// handshake-proven remote id + the transport addresses of the connection's
/// current (non-closed) network paths. This is what the address-seeding
/// writers persist (M8 decisions 14/20, REQ-CONV-1): an address that just
/// carried a real connection is the best possible cold-start seed — unlike
/// the id-only `addr_for_node_hex`, it dials without a discovery round-trip.
/// May be path-less (id-only) when no path is readable; callers skip
/// persisting those (they would add nothing over id-only discovery).
// [impl->REQ-CONV-1]
pub fn conn_remote_addr(conn: &Connection) -> EndpointAddr {
    let addrs: Vec<_> = conn
        .paths()
        .into_iter()
        .filter(|p| !p.is_closed())
        .map(|p| p.remote_addr().clone())
        .collect();
    EndpointAddr::from_parts(conn.remote_id(), addrs)
}


crates\spt-net\src\net\endpoint.rs:30:pub use iroh::endpoint::{Connection, RecvStream, SendStream};
crates\spt-net\src\net\endpoint.rs:31:pub use iroh::EndpointAddr;
crates\spt-net\src\net\endpoint.rs:227:                .bind_addr(std::net::SocketAddr::from((
crates\spt-net\src\net\endpoint.rs:232:                .bind_addr(std::net::SocketAddr::from((
crates\spt-net\src\net\endpoint.rs:284:    pub fn addr(&self) -> EndpointAddr {
crates\spt-net\src\net\endpoint.rs:313:    pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {
crates\spt-net\src\net\endpoint.rs:321:        addr: EndpointAddr,
crates\spt-net\src\net\endpoint.rs:338:        self.connect(EndpointAddr::new(id)).await
crates\spt-net\src\net\endpoint.rs:348:/// An id-only dialable [`EndpointAddr`] for a node named by its lowercase-hex
crates\spt-net\src\net\endpoint.rs:355:pub fn addr_for_node_hex(hex: &str) -> Option<EndpointAddr> {
crates\spt-net\src\net\endpoint.rs:358:    Some(EndpointAddr::new(id))
crates\spt-net\src\net\endpoint.rs:370:pub fn conn_remote_addr(conn: &Connection) -> EndpointAddr {
crates\spt-net\src\net\endpoint.rs:377:    EndpointAddr::from_parts(conn.remote_id(), addrs)


`EndpointAddr` is `iroh::EndpointAddr`. For a deterministic hang I'll point it at a bound-but-silent UDP socket. Need `from_parts`'s address type. Check the addr seam + iroh version.


1	//! The Iroh endpoint — the node's WAN/LAN presence (D1).
2	//!
3	//! [`NetEndpoint`] wraps an `iroh::Endpoint` bound to the node's **own** Ed25519
4	//! identity (`spt-proto`'s [`Identity`]): the iroh `EndpointId` *is* the node's
5	//! public key (same 32-byte seed → same ed25519 keypair), so a node's network
6	//! address and its stable name (`CONTEXT.md` "SptNode") are one and the same — no
7	//! second identity to reconcile. Connections speak the [`SPT_NET_ALPN`] protocol.
8	//!
9	//! Relay policy ([`RelayPolicy`]) is the ADR-0002 v1 relay stance made concrete:
10	//! n0's public relays by default for zero-config onboarding, a self-host escape
11	//! hatch, and a disabled mode (LAN/air-gapped + hermetic tests). See
12	//! [`RELAY_DISCLOSURE`] for the plain-language disclosure that ships to users.
13	//!
14	//! API shape is pinned to iroh 0.98.2 (Spike #2 — the API churns every release).
15	//! This crate owns the *mechanism*; the broker (ADR-0004 §B, D4) owns the *process*
16	//! that holds a live endpoint across brain restarts — [`NetEndpoint::iroh`] is the
17	//! escape hatch the daemon uses to take ownership.
18
19	use std::time::Duration;
20
21	use iroh::address_lookup::MdnsAddressLookup;
22	use iroh::endpoint::{presets, IdleTimeout, Incoming, PortmapperConfig, QuicTransportConfig};
23	use iroh::{Endpoint, EndpointId, RelayMap, RelayMode, SecretKey};
24	use spt_proto::identity::{Identity, PublicKey};
25
26	// Re-exported for the daemon (D4): the broker holds live QUIC connections +
27	// streams in its tables and dialable addresses in its IPC frames without growing
28	// its own iroh dependency edge — the exact version pin (0.98.2) stays in this
29	// one crate.
30	pub use iroh::endpoint::{Connection, RecvStream, SendStream};
31	pub use iroh::EndpointAddr;
32
33	/// The ALPN every spt-core node-to-node QUIC connection negotiates. Versioned so a
34	/// future wire break is a new protocol id, not a silent incompatibility.
35	pub const SPT_NET_ALPN: &[u8] = b"spt-core/net/0";
36
37	/// QUIC keep-alive PING interval — the mesh's "stay warm" knob (REQ-MESH-1).
38	/// A member connection that proves seed-knowledge once at connect must then stay
39	/// open through indefinite idleness, so re-proof is a restart/partition/rotation
40	/// event, never a per-message tax. iroh's default leaves keep-alive **off** and
41	/// idles a quiet connection out at 30s; without this a meshed-but-quiet member
42	/// would silently drop and re-handshake constantly. Must be strictly less than
43	/// [`MESH_MAX_IDLE`] so a PING always lands before the idle timer fires.
44	const MESH_KEEP_ALIVE: Duration = Duration::from_secs(15);
45

//! The Iroh endpoint — the node's WAN/LAN presence (D1).
//!
//! [`NetEndpoint`] wraps an `iroh::Endpoint` bound to the node's **own** Ed25519
//! identity (`spt-proto`'s [`Identity`]): the iroh `EndpointId` *is* the node's
//! public key (same 32-byte seed → same ed25519 keypair), so a node's network
//! address and its stable name (`CONTEXT.md` "SptNode") are one and the same — no
//! second identity to reconcile. Connections speak the [`SPT_NET_ALPN`] protocol.
//!
//! Relay policy ([`RelayPolicy`]) is the ADR-0002 v1 relay stance made concrete:
//! n0's public relays by default for zero-config onboarding, a self-host escape
//! hatch, and a disabled mode (LAN/air-gapped + hermetic tests). See
//! [`RELAY_DISCLOSURE`] for the plain-language disclosure that ships to users.
//!
//! API shape is pinned to iroh 0.98.2 (Spike #2 — the API churns every release).
//! This crate owns the *mechanism*; the broker (ADR-0004 §B, D4) owns the *process*
//! that holds a live endpoint across brain restarts — [`NetEndpoint::iroh`] is the
//! escape hatch the daemon uses to take ownership.

use std::time::Duration;

use iroh::address_lookup::MdnsAddressLookup;
use iroh::endpoint::{presets, IdleTimeout, Incoming, PortmapperConfig, QuicTransportConfig};
use iroh::{Endpoint, EndpointId, RelayMap, RelayMode, SecretKey};
use spt_proto::identity::{Identity, PublicKey};

// Re-exported for the daemon (D4): the broker holds live QUIC connections +
// streams in its tables and dialable addresses in its IPC frames without growing
// its own iroh dependency edge — the exact version pin (0.98.2) stays in this
// one crate.
pub use iroh::endpoint::{Connection, RecvStream, SendStream};
pub use iroh::EndpointAddr;

/// The ALPN every spt-core node-to-node QUIC connection negotiates. Versioned so a
/// future wire break is a new protocol id, not a silent incompatibility.
pub const SPT_NET_ALPN: &[u8] = b"spt-core/net/0";

/// QUIC keep-alive PING interval — the mesh's "stay warm" knob (REQ-MESH-1).
/// A member connection that proves seed-knowledge once at connect must then stay
/// open through indefinite idleness, so re-proof is a restart/partition/rotation
/// event, never a per-message tax. iroh's default leaves keep-alive **off** and
/// idles a quiet connection out at 30s; without this a meshed-but-quiet member
/// would silently drop and re-handshake constantly. Must be strictly less than
/// [`MESH_MAX_IDLE`] so a PING always lands before the idle timer fires.
const MESH_KEEP_ALIVE: Duration = Duration::from_secs(15);


Let me check `MembershipSource` — the documented membership-mismatch hang (module docs line 73) is the most robust spt-core-native black hole if it's cheaply constructible.


type MembershipSource|MembershipSource =|pub.*MembershipSource|fn prove_membership|MembershipSource


154:pub type MembershipSource = Arc<dyn Fn() -> Vec<SubnetCred> + Send + Sync>;
162:pub fn production_membership() -> MembershipSource {
208:pub async fn prove_membership(
558:// module stays decoupled from the spt-store directory (the `MembershipSource`


`MembershipSource = Arc<dyn Fn() -> Vec<SubnetCred>>` — cheaply constructible. The A=Some/B=None mismatch is the documented native hang. Verify `prove_membership` has no internal timeout + get `SubnetCred` shape.


120	            } else {
121	                Grade::Denied
122	            }
123	        }
124	        // Revoked: denied regardless of which generation it proved — the
125	        // tombstone locks the revokee out even before its seed is rotated away.
126	        Some(MemberStatus::Tombstoned) => Grade::Denied,
127	        // Unknown member: a current-epoch proof is admitted (roster merges
128	        // catch up on this very connection), but it is never RE-seeded.
129	        Some(MemberStatus::Absent) => {
130	            if matched_current {
131	                Grade::Full
132	            } else {
133	                Grade::Denied
134	            }
135	        }
136	        // Present member: the full one-deep grace.
137	        Some(MemberStatus::Present) => {
138	            if matched_current {
139	                Grade::Full
140	            } else if matched_prev {
141	                Grade::ReseedOnly
142	            } else {
143	                Grade::Denied
144	            }
145	        }
146	    }
147	}
148
149	/// A live source of this node's subnet credentials, re-read per connection so a
150	/// rotation (Mesh-D7) is picked up without a restart. Production wires this to
151	/// `SubnetStore::load()`; `None` on [`NetConfig`](crate::nethost::NetConfig)
152	/// disables seed-proof entirely (both peers must agree — a mixed pair would
153	/// deadlock on the missing control stream).
154	pub type MembershipSource = Arc<dyn Fn() -> Vec<SubnetCred> + Send + Sync>;
155
156	/// The production membership source: load the node's [`SubnetStore`] fresh on
157	/// every connect and project each subnet to its [`SubnetCred`]. Fresh-load (not
158	/// a snapshot) so a future seed rotation is honored without a daemon restart;
159	/// connects are rare enough that the file read is negligible. A subnet whose
160	/// stored seed hex is malformed is skipped (it can't prove anyway).
161	// [impl->REQ-MESH-1]
162	pub fn production_membership() -> MembershipSource {
163	    Arc::new(|| {
164	        SubnetStore::load()
165	            .subnets
166	            .iter()
167	            .filter_map(|r| {
168	                r.seed_bytes().map(|seed| SubnetCred {
169	                    name: r.name.clone(),
170	                    seed: seed.to_vec(),
171	                    epoch: r.epoch,
172	                    // One-deep prior generation (Mesh-D7): present after a
173	                    // rotation, lets this node verify an N-1 grace proof.
174	                    prev: r.prev_seed_bytes().map(|(s, e)| (s.to_vec(), e)),
175	                })
176	            })
177	            .collect()
178	    })
179	}
180
181	/// Cap on the seed-proof control-stream framing — a single small handshake; a
182	/// length claiming more is a malformed/hostile peer, dropped.
183	const MAX_FRAME: usize = 64 * 1024;
184
185	/// Generate a fresh 32-byte per-connection nonce from the OS CSPRNG. Reuses
186	/// `spt-proto`'s Ed25519 seed generator (OsRng) as the randomness source — the
187	/// same no-extra-`rand`-dependency trick the stores use.
188	fn fresh_nonce() -> [u8; NONCE_LEN] {
189	    Identity::generate().seed()
190	}
191
192	/// Run the mutual seed-proof on `conn` and return the set of subnets BOTH sides
193	/// proved, or `None` if the connection must be dropped (no shared subnet, a
194	/// failed proof, or a transport/protocol error). `local_id` is this node's
195	/// pubkey; `role` is which side of the connection we are.
196	///
197	/// When `roster` is `Some` (Mesh-D4, REQ-MESH-2), a roster-exchange leg rides
198	/// the **same** control stream **after** both proofs verify — so the roster is
199	/// "exchanged only over seed-proof'd member connections" by construction, not by
200	/// convention. `self_addr` is this node's live dialable address (opaque JSON,
201	/// `Null` if none) which the provider stamps onto its own advertised entry. The
202	/// leg is **best-effort**: the connection is already a proven member conn, so a
203	/// missing/short/garbled roster frame is swallowed (it never drops the conn) —
204	/// which also lets a D4 peer interoperate with a not-yet-upgraded one that sends
205	/// no roster.
206	// [impl->REQ-MESH-1]
207	// [impl->REQ-MESH-2]
208	pub async fn prove_membership(
209	    conn: &Connection,
210	    local_id: PublicKey,
211	    role: ProofRole,
212	    creds: &[SubnetCred],
213	    roster: Option<&RosterExchange>,
214	    self_addr: &serde_json::Value,
215	) -> Option<HashSet<String>> {
216	    let remote = PublicKey::from_bytes(conn.remote_id().as_bytes()).ok()?;
217	    let remote_hex = remote.to_hex();
218	    // Channel binding: the transcript pins both handshake-proven pubkeys in a
219	    // role-fixed order (dialer first), so a tag is valid on exactly this conn.
220	    let (dialer_pub, acceptor_pub) = match role {
221	        ProofRole::Dialer => (local_id, remote),
222	        ProofRole::Acceptor => (remote, local_id),
223	    };
224	    let by_name: HashMap<&str, &SubnetCred> =
225	        creds.iter().map(|c| (c.name.as_str(), c)).collect();
226
227	    match role {
228	        ProofRole::Dialer => {
229	            let (mut send, mut recv) = conn.open_bi().await.ok()?;
230	            prove_as_dialer(
231	                &mut send,
232	                &mut recv,
233	                creds,
234	                &by_name,
235	                dialer_pub,
236	                acceptor_pub,
237	                &remote_hex,
238	                roster,
239	                self_addr,
240	            )
241	            .await
242	        }
243	        ProofRole::Acceptor => {
244	            let (mut send, mut recv) = conn.accept_bi().await.ok()?;
245	            prove_as_acceptor(
246	                &mut send,
247	                &mut recv,
248	                &by_name,
249	                dialer_pub,
250	                acceptor_pub,
251	                &remote_hex,
252	                roster,
253	                self_addr,
254	            )
255	            .await
256	        }
257	    }
258	}
259
260	/// One subnet's local generations, as the proof exchange needs them: the
261	/// channel-bound transcript + membership key for the current generation and
262	/// (one-deep) the prior one (Mesh-D7). The outbound proof set is one tag per
263	/// generation; the inbound is graded against both.
264	struct GenProof {
265	    epoch: u64,
266	    mk: MembershipKey,
267	    transcript: SeedProofTranscript,
268	}
269
270	struct LocalGens {
271	    name: String,
272	    current: GenProof,
273	    prev: Option<GenProof>,
274	}
275
276	impl LocalGens {
277	    /// The `(epoch, tag)` set this node sends for the subnet, proving each
278	    /// generation it holds in role `mine` (current first).
279	    fn outbound(&self, mine: ProofRole) -> Vec<(u64, [u8; 32])> {

            } else {
                Grade::Denied
            }
        }
        // Revoked: denied regardless of which generation it proved — the
        // tombstone locks the revokee out even before its seed is rotated away.
        Some(MemberStatus::Tombstoned) => Grade::Denied,
        // Unknown member: a current-epoch proof is admitted (roster merges
        // catch up on this very connection), but it is never RE-seeded.
        Some(MemberStatus::Absent) => {
            if matched_current {
                Grade::Full
            } else {
                Grade::Denied
            }
        }
        // Present member: the full one-deep grace.
        Some(MemberStatus::Present) => {
            if matched_current {
                Grade::Full
            } else if matched_prev {
                Grade::ReseedOnly
            } else {
                Grade::Denied
            }
        }
    }
}

/// A live source of this node's subnet credentials, re-read per connection so a
/// rotation (Mesh-D7) is picked up without a restart. Production wires this to
/// `SubnetStore::load()`; `None` on [`NetConfig`](crate::nethost::NetConfig)
/// disables seed-proof entirely (both peers must agree — a mixed pair would
/// deadlock on the missing control stream).
pub type MembershipSource = Arc<dyn Fn() -> Vec<SubnetCred> + Send + Sync>;

/// The production membership source: load the node's [`SubnetStore`] fresh on
/// every connect and project each subnet to its [`SubnetCred`]. Fresh-load (not
/// a snapshot) so a future seed rotation is honored without a daemon restart;
/// connects are rare enough that the file read is negligible. A subnet whose
/// stored seed hex is malformed is skipped (it can't prove anyway).
// [impl->REQ-MESH-1]
pub fn production_membership() -> MembershipSource {
    Arc::new(|| {
        SubnetStore::load()
            .subnets
            .iter()
            .filter_map(|r| {
                r.seed_bytes().map(|seed| SubnetCred {
                    name: r.name.clone(),
                    seed: seed.to_vec(),
                    epoch: r.epoch,
                    // One-deep prior generation (Mesh-D7): present after a
                    // rotation, lets this node verify an N-1 grace proof.
                    prev: r.prev_seed_bytes().map(|(s, e)| (s.to_vec(), e)),
                })
            })
            .collect()
    })
}

/// Cap on the seed-proof control-stream framing — a single small handshake; a
/// length claiming more is a malformed/hostile peer, dropped.
const MAX_FRAME: usize = 64 * 1024;

/// Generate a fresh 32-byte per-connection nonce from the OS CSPRNG. Reuses
/// `spt-proto`'s Ed25519 seed generator (OsRng) as the randomness source — the
/// same no-extra-`rand`-dependency trick the stores use.
fn fresh_nonce() -> [u8; NONCE_LEN] {
    Identity::generate().seed()
}

/// Run the mutual seed-proof on `conn` and return the set of subnets BOTH sides
/// proved, or `None` if the connection must be dropped (no shared subnet, a
/// failed proof, or a transport/protocol error). `local_id` is this node's
/// pubkey; `role` is which side of the connection we are.
///
/// When `roster` is `Some` (Mesh-D4, REQ-MESH-2), a roster-exchange leg rides
/// the **same** control stream **after** both proofs verify — so the roster is
/// "exchanged only over seed-proof'd member connections" by construction, not by
/// convention. `self_addr` is this node's live dialable address (opaque JSON,
/// `Null` if none) which the provider stamps onto its own advertised entry. The
/// leg is **best-effort**: the connection is already a proven member conn, so a
/// missing/short/garbled roster frame is swallowed (it never drops the conn) —
/// which also lets a D4 peer interoperate with a not-yet-upgraded one that sends
/// no roster.
// [impl->REQ-MESH-1]
// [impl->REQ-MESH-2]
pub async fn prove_membership(
    conn: &Connection,
    local_id: PublicKey,
    role: ProofRole,
    creds: &[SubnetCred],
    roster: Option<&RosterExchange>,
    self_addr: &serde_json::Value,
) -> Option<HashSet<String>> {
    let remote = PublicKey::from_bytes(conn.remote_id().as_bytes()).ok()?;
    let remote_hex = remote.to_hex();
    // Channel binding: the transcript pins both handshake-proven pubkeys in a
    // role-fixed order (dialer first), so a tag is valid on exactly this conn.
    let (dialer_pub, acceptor_pub) = match role {
        ProofRole::Dialer => (local_id, remote),
        ProofRole::Acceptor => (remote, local_id),
    };
    let by_name: HashMap<&str, &SubnetCred> =
        creds.iter().map(|c| (c.name.as_str(), c)).collect();

    match role {
        ProofRole::Dialer => {
            let (mut send, mut recv) = conn.open_bi().await.ok()?;
            prove_as_dialer(
                &mut send,
                &mut recv,
                creds,
                &by_name,
                dialer_pub,
                acceptor_pub,
                &remote_hex,
                roster,
                self_addr,
            )
            .await
        }
        ProofRole::Acceptor => {
            let (mut send, mut recv) = conn.accept_bi().await.ok()?;
            prove_as_acceptor(
                &mut send,
                &mut recv,
                &by_name,
                dialer_pub,
                acceptor_pub,
                &remote_hex,
                roster,
                self_addr,
            )
            .await
        }
    }
}

/// One subnet's local generations, as the proof exchange needs them: the
/// channel-bound transcript + membership key for the current generation and
/// (one-deep) the prior one (Mesh-D7). The outbound proof set is one tag per
/// generation; the inbound is graded against both.
struct GenProof {
    epoch: u64,
    mk: MembershipKey,
    transcript: SeedProofTranscript,
}

struct LocalGens {
    name: String,
    current: GenProof,
    prev: Option<GenProof>,
}

impl LocalGens {
    /// The `(epoch, tag)` set this node sends for the subnet, proving each
    /// generation it holds in role `mine` (current first).
    fn outbound(&self, mine: ProofRole) -> Vec<(u64, [u8; 32])> {


Confirmed: `prove_membership` as Dialer (`open_bi().await` + read acceptor response) has **no internal timeout** — A=Some(cred)/B=None is the documented native hang (module docs line 73). `SubnetCred { name, seed, epoch, prev }` is fabricable.

Full design locked. Helper `bounded_block_on` wrapping the 3 QUIC await sites, `AtomicU64` bound (default 10s) + public `set_quic_op_timeout` setter mirroring `set_roster_exchange`. Create the branch + read the docs I'll edit.


Bash

git status --short; echo "=== origin/main ==="; git log origin/main --oneline -1; echo "=== create branch ==="; git checkout -b v0.8.3-broker-quic-deadline origin/main && git log --oneline -1

Branch v0.8.3 off origin/main


Grep

pub struct SubnetCred|pub mod seedproofx|mod seedproofx


=== origin/main ===
4ac17c2 release: v0.8.2 — template-argv fill + non-resident Psyche host reporting
=== create branch ===
branch 'v0.8.3-broker-quic-deadline' set up to track 'origin/main'.
Switched to a new branch 'v0.8.3-broker-quic-deadline'
4ac17c2 release: v0.8.2 — template-argv fill + non-resident Psyche host reporting


crates\spt-daemon\src\lib.rs:150:pub mod seedproofx;
crates\spt-daemon\src\seedproofx.rs:56:pub struct SubnetCred {


Branch ready. `seedproofx`/`SubnetCred` are public. Read the docs to edit + the SubnetCred fields + traceable insertion point.


50	use spt_store::subnet::SubnetStore;
51
52	/// One subnet's membership credential as the proof needs it: the human name
53	/// (goes on the wire), the raw seed bytes, and the current epoch (both local,
54	/// never on the wire). Built from `spt_store::subnet::SubnetStore`.
55	#[derive(Clone)]
56	pub struct SubnetCred {
57	    /// Subnet name — the only field that crosses the wire (a label, not a secret).
58	    pub name: String,
59	    /// The subnet seed bytes (HKDF input material). Never transmitted.
60	    pub seed: Vec<u8>,
61	    /// The local current `seed_epoch` for this subnet. Never transmitted.
62	    pub epoch: u64,
63	    /// The **immediately-prior** generation `(seed, epoch)`, retained one-deep
64	    /// (Mesh-D7). The fresh side keeps it so it can verify a benign offliner's
65	    /// `N-1` proof; the stale side keeps its own so a fresh peer's prior-epoch
66	    /// proof verifies. `None` until the subnet has rotated at least once. Never
67	    /// transmitted — the *new* seed crosses only the [`SeedTransfer`] grace leg.
68	    // [impl->REQ-MESH-4]
69	    pub prev: Option<(Vec<u8>, u64)>,
70	}
71
72	/// A peer's per-subnet roster status, as the re-seed grace needs it — supplied
73	/// by the [`RosterExchange::member_status`] seam (production: a `RosterStore`
74	/// lookup). The grace is gated on this: a tombstoned (revoked) peer is denied
75	/// outright; an absent (unknown) peer may still prove the *current* seed (a
76	/// just-paired member whose roster row has not merged yet) but is never *re*-
77	/// seeded; a present member gets the full one-deep grace.
78	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79	pub enum MemberStatus {

use spt_store::subnet::SubnetStore;

/// One subnet's membership credential as the proof needs it: the human name
/// (goes on the wire), the raw seed bytes, and the current epoch (both local,
/// never on the wire). Built from `spt_store::subnet::SubnetStore`.
#[derive(Clone)]
pub struct SubnetCred {
    /// Subnet name — the only field that crosses the wire (a label, not a secret).
    pub name: String,
    /// The subnet seed bytes (HKDF input material). Never transmitted.
    pub seed: Vec<u8>,
    /// The local current `seed_epoch` for this subnet. Never transmitted.
    pub epoch: u64,
    /// The **immediately-prior** generation `(seed, epoch)`, retained one-deep
    /// (Mesh-D7). The fresh side keeps it so it can verify a benign offliner's
    /// `N-1` proof; the stale side keeps its own so a fresh peer's prior-epoch
    /// proof verifies. `None` until the subnet has rotated at least once. Never
    /// transmitted — the *new* seed crosses only the [`SeedTransfer`] grace leg.
    // [impl->REQ-MESH-4]
    pub prev: Option<(Vec<u8>, u64)>,
}

/// A peer's per-subnet roster status, as the re-seed grace needs it — supplied
/// by the [`RosterExchange::member_status`] seam (production: a `RosterStore`
/// lookup). The grace is gated on this: a tombstoned (revoked) peer is denied
/// outright; an absent (unknown) peer may still prove the *current* seed (a
/// just-paired member whose roster row has not merged yet) but is never *re*-
/// seeded; a present member gets the full one-deep grace.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemberStatus {


794	# --- Added 2026-06-09 from grill-with-docs (ADR-0018: broker/brain in-process-collapse regression); rule 3: register before satisfying, rule 5: inactive until the broker/brain split restoration milestone (next milestone) ---
795	[[requirements]]
796	id = "REQ-HAZARD-BROKER-PROCESS-ISOLATION"
797	title = "Broker and brain are separate processes: the broker runs as its own long-lived per-machine process that survives every brain restart, so a routine (brain-only) self-update restarts the brain onto the swapped binary while every hosted endpoint (PTY child, live QUIC conn, listening socket) stays untouched at the PROCESS level. The in-process-thread broker (daemon.rs:165-170) is a regression that silently unrealizes REQ-UPD-3 — apply degrades to an in-process Brain::handoff no-op and new code does not run until an unrelated restart (KNOWN-HAZARDS 6.7). Evidence must prove process-level survival (SPIKE-01/03 productionized as int: PTY child + live QUIC survive a brain-PROCESS restart onto a swapped binary), re-pointing the regression-masked in-process int tags currently on REQ-DAEMON-2 / REQ-UPD-3 (ADR-0018)."
798	required_stages = ["doc", "impl", "unit", "int"]  # activated restoration D1 (ADR-0018 Q2/Q3): doc = KNOWN-HAZARDS 6.7 + ADR-0018 + design doc; impl = brainproc.rs (brain child entry + broker-side process supervisor) + daemon.rs spawn-brain-supervisor + the hidden `daemon brain` CLI entry; unit = brainproc supervisor respawn/backoff table + the real-binary brain_split.rs process-level smoke (broker survives a brain kill + respawns it). int ACTIVATED restoration D7 = the productionized SPIKE-01/03 process-level survival E2E (crates/spt/tests/brain_survive.rs: a PTY child + a live QUIC conn + held-AND-functional output survive a brain-PROCESS restart onto a swapped binary, exe_hash-proven new bytes — D7-1) + the new-brain x old-broker N-1 verb-surface gate (crates/spt/tests/n1_pairing.rs + ci.yml n1-gate, pinned old broker 0c95435 — D7-2). The D7-1 E2E also re-points the regression-masked in-process int tags off REQ-DAEMON-2 / REQ-UPD-3 (below)
799	[[requirements]]
800	id = "REQ-HAZARD-ROLLBACK-STATE-COMPAT"
801	title = "A brain must not irreversibly migrate durable state before update ready-promotion: the readiness-gated auto-rollback (ADR-0018 Q7) spawns the N-1 binary against durable state the new brain may have written, so every pre-ready write must stay N-1-readable (schema migrations gated behind ready-promotion, or written N-1-tolerant/additive). Else the first in-place schema migration silently bricks rollback (KNOWN-HAZARDS 6.8). Free now — a 2026-06-09 audit confirmed zero state-migration code exists; unmintable retroactively once a migration ships."
802	required_stages = ["doc", "impl", "unit"]  # doc activated restoration D1 (KNOWN-HAZARDS 6.8 + ADR-0018 + design doc V1). impl/unit activated restoration D6-3 (the one D6 toml activation): impl = rollback_compat.rs (the pre-ready durable-file registry PRE_READY_DURABLE_FILES — the enumerated guard surface) + the two-phase AppliedRecord (relcache.rs, additive durable state); unit = the rollback_compat tripwire (every current pre-ready file shape is additive / N-1-readable; a non-additive pre-ready change trips the assert). The two-phase record + the generation-stamped brain.ready are additive → N-1-safe by construction (KH 6.8 D6 guard note)
803	# --- Added 2026-06-11 (v0.4.2): the v0.4.1 fleet-roll Linux brain-respawn blocker (ADR-0018 Q3 amendment); rule 3 register-before-satisfy, rule 5 activate per task ---
804	[[requirements]]
805	id = "REQ-HAZARD-BRAIN-RESPAWN-PATH"
806	title = "The broker respawns the brain onto the APPLIED bytes, not the renamed old binary: the candidate-binary default is the canonical exe path captured ONCE at broker start, never a per-spawn std::env::current_exe() — on Linux current_exe (readlink /proc/self/exe) is inode-tracking and follows the `apply` rename (spt -> spt.old-N), so a resident broker would respawn the brain onto OLD bytes while recording `applied` (Windows GetModuleFileName is path-at-start, so Windows was green; ADR-0018 Q3 silently assumed path-string semantics). Backstop: promotion gates on bytes — a trial promotes only if brain.ready exe_hash == the staged artifact hash for this platform, else auto-rollback + loud notif (readiness != new-bytes was the false-success that recorded applied:8 over a v0.4.0 brain on kitsubito, 2026-06-11). KNOWN-HAZARDS 6.11."
807	required_stages = ["doc", "impl", "unit", "int"]  # activated v0.4.2 (2026-06-11): minted from the v0.4.1 fleet-roll blocker. doc = KNOWN-HAZARDS 6.11 + ADR-0018 Q3 amendment; int = brain_respawn_rename.rs (real broker process, in-place rename P->P.old-N + new bytes at P, respawn must run B not A — fails Linux pre-fix). Stages: impl = brainproc.rs canonical-exe capture at spawn_brain_supervisor + promotion bytes-gate in supervise_brain Promoted arm (TrialEnv ready_exe_hash/staged_artifact_hash); unit = spawn-path selection (captured-default-for-None vs per-spawn) + promotion-gate truth table (match->promote, mismatch->rollback+notif, either-absent->loud PROMOTE_BYTES_UNVERIFIED degrade-to-readiness); int = the brain_survive rename-under-supervisor sibling (real in-place rename P->P.old-N + new bytes at P through the production None selection, assert respawned exe_hash == new bytes; fails Linux pre-fix)
808	# --- Added 2026-06-03 from grill-with-docs (ADR-0012, hazard 7.3); inactive until M4-D7.5 ---
809	[[requirements]]
810	id = "REQ-HAZARD-PSYCHE-OUTBOUND-PROXY"
811	title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"
812	required_stages = ["impl", "unit"]  # activated M4-D7.5: the stdout-captured turn driver (spt-live turn.rs, D6b pull-forward) + the intent parser's structural strip (spt-live outbound.rs) + the daemon relay's re-stamp/constrained-routing boundary (spt-daemon psyrelay.rs, anti-spoof negative test). int = D9 two-host
813	[[requirements]]
814	id = "REQ-HAZARD-DAEMON-SCHED-NONBLOCKING"
815	title = "Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"
816	required_stages = ["impl", "unit"]  # activated M5-D3e on the hazard's shell-hosting face: every broker-hosted shell session gets its own drain + exit-waiter threads and every brain connection its own handler thread (broker.rs), proven by the hung-shell isolation test (one owner's hung shell binary stalls neither another owner's stdin delivery nor broker control calls). The daemon multi-agent PULSE fan-out face still pends (D4+/ADR-0004 consolidation): run_pulse_loop is def+test only and each agent drives its own pulse from its own process; M4-D9-2-3 note stands — the resting-edge feed points ARM per-endpoint echo gates (resting::arm_transition_echo) instead of hosting the bounded call, so that face binds at the per-agent-runtime fan-out
817	# --- Added 2026-06-03 at M4-D2 start (ADR-0005 Stage-A red-team #10/#11/#12); rule 3: register before satisfying, rule 5: activate per sub-task commit ---
818	[[requirements]]
819	id = "REQ-HAZARD-PAIR-TRANSCRIPT-BIND"
820	title = "Pairing transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, and confirmation MACs — or unknown-key-share/reflection/wrong-subnet/replay pairing remain possible (ADR-0005 #12)"
821	required_stages = ["impl", "unit"]  # activated M4-D2b: pairing::transcript HMAC-SHA256 confirmation MAC over the canonical transcript (domain/role/both-msgs/subnet/epoch/step/both-pubkeys, length-prefixed) + ct_eq verify, fed into the SPAKE2 KDF identity too. Negative tests in pairing::spake cover wrong-code, wrong-subnet, stale-step, stale-epoch, unknown-key-share, reflection (PAKE BadSide), tampered-tag. int = the on-wire MITM/replay E2E at D9
822	[[requirements]]
823	id = "REQ-HAZARD-PAIR-SEED-ROTATION"
824	title = "Removing a node rotates the subnet seed (epoch bump) so an old node/old seed cannot rejoin; trust-store delete alone is NOT revocation because the seed is replicated to every trusted node (ADR-0005 #10)"
825	required_stages = ["impl", "unit"]  # activated M4-D2c: spt-store::subnet SubnetStore holds N per-subnet TOTP seeds + monotonic epoch; rotate_seed mints fresh seed material + bumps the epoch (the node-removal response), persisted atomically + durable across reload. The epoch is bound into the pairing transcript (REQ-HAZARD-PAIR-TRANSCRIPT-BIND), and the spake stale_epoch/wrong_code tests prove an old seed/epoch can't rejoin. int = on-wire evict-then-rejoin E2E at D9
826	[[requirements]]
827	id = "REQ-HAZARD-PAIR-RATE-LIMIT"
828	title = "Subnet-global pairing rate limit: one active ceremony per subnet, shared attempt counter, exponential backoff — a public pre-trust relay + multiple seed-holders otherwise enables distributed SPAKE2 guessing (and ±1 TOTP window triples the valid-password space) (ADR-0005 #11)"
829	required_stages = ["impl", "unit"]  # activated M4-D2d: spt-net::pairing::ratelimit PairingRateLimiter — one in-flight ceremony per subnet (Busy), shared consecutive-failure counter, exponential backoff (1s→cap 3600s, abandoned ceremonies reclaimed+charged), per-subnet isolated. The ±1 TOTP window is JUSTIFIED (not dropped): serialization + exp backoff caps the guess rate so 3/1e6 success-per-guess stays out of reach inside a 30s code. int = on-wire distributed-guessing rejection at D2-wire/D9.
830	# --- Added 2026-06-03 at M4-D5a start (ADR-0009 origin-node gate; rule 3: register before satisfying) ---
831	[[requirements]]
832	id = "REQ-HAZARD-WAN-ORIGIN-AUTH"
833	title = "WAN-inbound origin is transport truth, never payload: the access gate's subject (ADR-0009 origin-node whitelist) is the QUIC handshake-proven remote node id from the broker's conn/stream table — a forged origin/node field inside record bytes is inert (7.5)"

# --- Added 2026-06-09 from grill-with-docs (ADR-0018: broker/brain in-process-collapse regression); rule 3: register before satisfying, rule 5: inactive until the broker/brain split restoration milestone (next milestone) ---
[[requirements]]
id = "REQ-HAZARD-BROKER-PROCESS-ISOLATION"
title = "Broker and brain are separate processes: the broker runs as its own long-lived per-machine process that survives every brain restart, so a routine (brain-only) self-update restarts the brain onto the swapped binary while every hosted endpoint (PTY child, live QUIC conn, listening socket) stays untouched at the PROCESS level. The in-process-thread broker (daemon.rs:165-170) is a regression that silently unrealizes REQ-UPD-3 — apply degrades to an in-process Brain::handoff no-op and new code does not run until an unrelated restart (KNOWN-HAZARDS 6.7). Evidence must prove process-level survival (SPIKE-01/03 productionized as int: PTY child + live QUIC survive a brain-PROCESS restart onto a swapped binary), re-pointing the regression-masked in-process int tags currently on REQ-DAEMON-2 / REQ-UPD-3 (ADR-0018)."
required_stages = ["doc", "impl", "unit", "int"]  # activated restoration D1 (ADR-0018 Q2/Q3): doc = KNOWN-HAZARDS 6.7 + ADR-0018 + design doc; impl = brainproc.rs (brain child entry + broker-side process supervisor) + daemon.rs spawn-brain-supervisor + the hidden `daemon brain` CLI entry; unit = brainproc supervisor respawn/backoff table + the real-binary brain_split.rs process-level smoke (broker survives a brain kill + respawns it). int ACTIVATED restoration D7 = the productionized SPIKE-01/03 process-level survival E2E (crates/spt/tests/brain_survive.rs: a PTY child + a live QUIC conn + held-AND-functional output survive a brain-PROCESS restart onto a swapped binary, exe_hash-proven new bytes — D7-1) + the new-brain x old-broker N-1 verb-surface gate (crates/spt/tests/n1_pairing.rs + ci.yml n1-gate, pinned old broker 0c95435 — D7-2). The D7-1 E2E also re-points the regression-masked in-process int tags off REQ-DAEMON-2 / REQ-UPD-3 (below)
[[requirements]]
id = "REQ-HAZARD-ROLLBACK-STATE-COMPAT"
title = "A brain must not irreversibly migrate durable state before update ready-promotion: the readiness-gated auto-rollback (ADR-0018 Q7) spawns the N-1 binary against durable state the new brain may have written, so every pre-ready write must stay N-1-readable (schema migrations gated behind ready-promotion, or written N-1-tolerant/additive). Else the first in-place schema migration silently bricks rollback (KNOWN-HAZARDS 6.8). Free now — a 2026-06-09 audit confirmed zero state-migration code exists; unmintable retroactively once a migration ships."
required_stages = ["doc", "impl", "unit"]  # doc activated restoration D1 (KNOWN-HAZARDS 6.8 + ADR-0018 + design doc V1). impl/unit activated restoration D6-3 (the one D6 toml activation): impl = rollback_compat.rs (the pre-ready durable-file registry PRE_READY_DURABLE_FILES — the enumerated guard surface) + the two-phase AppliedRecord (relcache.rs, additive durable state); unit = the rollback_compat tripwire (every current pre-ready file shape is additive / N-1-readable; a non-additive pre-ready change trips the assert). The two-phase record + the generation-stamped brain.ready are additive → N-1-safe by construction (KH 6.8 D6 guard note)
# --- Added 2026-06-11 (v0.4.2): the v0.4.1 fleet-roll Linux brain-respawn blocker (ADR-0018 Q3 amendment); rule 3 register-before-satisfy, rule 5 activate per task ---
[[requirements]]
id = "REQ-HAZARD-BRAIN-RESPAWN-PATH"
title = "The broker respawns the brain onto the APPLIED bytes, not the renamed old binary: the candidate-binary default is the canonical exe path captured ONCE at broker start, never a per-spawn std::env::current_exe() — on Linux current_exe (readlink /proc/self/exe) is inode-tracking and follows the `apply` rename (spt -> spt.old-N), so a resident broker would respawn the brain onto OLD bytes while recording `applied` (Windows GetModuleFileName is path-at-start, so Windows was green; ADR-0018 Q3 silently assumed path-string semantics). Backstop: promotion gates on bytes — a trial promotes only if brain.ready exe_hash == the staged artifact hash for this platform, else auto-rollback + loud notif (readiness != new-bytes was the false-success that recorded applied:8 over a v0.4.0 brain on kitsubito, 2026-06-11). KNOWN-HAZARDS 6.11."
required_stages = ["doc", "impl", "unit", "int"]  # activated v0.4.2 (2026-06-11): minted from the v0.4.1 fleet-roll blocker. doc = KNOWN-HAZARDS 6.11 + ADR-0018 Q3 amendment; int = brain_respawn_rename.rs (real broker process, in-place rename P->P.old-N + new bytes at P, respawn must run B not A — fails Linux pre-fix). Stages: impl = brainproc.rs canonical-exe capture at spawn_brain_supervisor + promotion bytes-gate in supervise_brain Promoted arm (TrialEnv ready_exe_hash/staged_artifact_hash); unit = spawn-path selection (captured-default-for-None vs per-spawn) + promotion-gate truth table (match->promote, mismatch->rollback+notif, either-absent->loud PROMOTE_BYTES_UNVERIFIED degrade-to-readiness); int = the brain_survive rename-under-supervisor sibling (real in-place rename P->P.old-N + new bytes at P through the production None selection, assert respawned exe_hash == new bytes; fails Linux pre-fix)
# --- Added 2026-06-03 from grill-with-docs (ADR-0012, hazard 7.3); inactive until M4-D7.5 ---
[[requirements]]
id = "REQ-HAZARD-PSYCHE-OUTBOUND-PROXY"
title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"
required_stages = ["impl", "unit"]  # activated M4-D7.5: the stdout-captured turn driver (spt-live turn.rs, D6b pull-forward) + the intent parser's structural strip (spt-live outbound.rs) + the daemon relay's re-stamp/constrained-routing boundary (spt-daemon psyrelay.rs, anti-spoof negative test). int = D9 two-host
[[requirements]]
id = "REQ-HAZARD-DAEMON-SCHED-NONBLOCKING"
title = "Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"
required_stages = ["impl", "unit"]  # activated M5-D3e on the hazard's shell-hosting face: every broker-hosted shell session gets its own drain + exit-waiter threads and every brain connection its own handler thread (broker.rs), proven by the hung-shell isolation test (one owner's hung shell binary stalls neither another owner's stdin delivery nor broker control calls). The daemon multi-agent PULSE fan-out face still pends (D4+/ADR-0004 consolidation): run_pulse_loop is def+test only and each agent drives its own pulse from its own process; M4-D9-2-3 note stands — the resting-edge feed points ARM per-endpoint echo gates (resting::arm_transition_echo) instead of hosting the bounded call, so that face binds at the per-agent-runtime fan-out
# --- Added 2026-06-03 at M4-D2 start (ADR-0005 Stage-A red-team #10/#11/#12); rule 3: register before satisfying, rule 5: activate per sub-task commit ---
[[requirements]]
id = "REQ-HAZARD-PAIR-TRANSCRIPT-BIND"
title = "Pairing transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, and confirmation MACs — or unknown-key-share/reflection/wrong-subnet/replay pairing remain possible (ADR-0005 #12)"
required_stages = ["impl", "unit"]  # activated M4-D2b: pairing::transcript HMAC-SHA256 confirmation MAC over the canonical transcript (domain/role/both-msgs/subnet/epoch/step/both-pubkeys, length-prefixed) + ct_eq verify, fed into the SPAKE2 KDF identity too. Negative tests in pairing::spake cover wrong-code, wrong-subnet, stale-step, stale-epoch, unknown-key-share, reflection (PAKE BadSide), tampered-tag. int = the on-wire MITM/replay E2E at D9
[[requirements]]
id = "REQ-HAZARD-PAIR-SEED-ROTATION"
title = "Removing a node rotates the subnet seed (epoch bump) so an old node/old seed cannot rejoin; trust-store delete alone is NOT revocation because the seed is replicated to every trusted node (ADR-0005 #10)"
required_stages = ["impl", "unit"]  # activated M4-D2c: spt-store::subnet SubnetStore holds N per-subnet TOTP seeds + monotonic epoch; rotate_seed mints fresh seed material + bumps the epoch (the node-removal response), persisted atomically + durable across reload. The epoch is bound into the pairing transcript (REQ-HAZARD-PAIR-TRANSCRIPT-BIND), and the spake stale_epoch/wrong_code tests prove an old seed/epoch can't rejoin. int = on-wire evict-then-rejoin E2E at D9
[[requirements]]
id = "REQ-HAZARD-PAIR-RATE-LIMIT"
title = "Subnet-global pairing rate limit: one active ceremony per subnet, shared attempt counter, exponential backoff — a public pre-trust relay + multiple seed-holders otherwise enables distributed SPAKE2 guessing (and ±1 TOTP window triples the valid-password space) (ADR-0005 #11)"
required_stages = ["impl", "unit"]  # activated M4-D2d: spt-net::pairing::ratelimit PairingRateLimiter — one in-flight ceremony per subnet (Busy), shared consecutive-failure counter, exponential backoff (1s→cap 3600s, abandoned ceremonies reclaimed+charged), per-subnet isolated. The ±1 TOTP window is JUSTIFIED (not dropped): serialization + exp backoff caps the guess rate so 3/1e6 success-per-guess stays out of reach inside a 30s code. int = on-wire distributed-guessing rejection at D2-wire/D9.
# --- Added 2026-06-03 at M4-D5a start (ADR-0009 origin-node gate; rule 3: register before satisfying) ---
[[requirements]]
id = "REQ-HAZARD-WAN-ORIGIN-AUTH"
title = "WAN-inbound origin is transport truth, never payload: the access gate's subject (ADR-0009 origin-node whitelist) is the QUIC handshake-proven remote node id from the broker's conn/stream table — a forged origin/node field inside record bytes is inert (7.5)"


Bash

Locate KNOWN-HAZARDS 7.6/7.8


=== KNOWN-HAZARDS 7.x ===
30:- **Failure:** a hook spools a deferred (spool-only, no TCP wake) notice; startup `drain_all` flushes ALL rows including deferred → event emitted at wrong time/priority.
31:- **Invariant:** startup drain (and idle/timeout TCP-wake sites) use `drain_non_deferred` only; deferred rows are picked up by their intended consumer via `peek`. All drain sites must agree on which rows they flush.
32:- **spt-core mapping:** carries directly — the daemon's spool-drain has the same deferred-vs-immediate distinction.
33:- **Sister cite:** `src/owl/poll.rs:276-316`; `spool::drain_non_deferred_with_metadata`.
122:- **Failure:** poll `drain_all` flushes a deferred (spool-only) message meant for a hook consumer → message lost.
123:- **Invariant:** deferred rows are never flushed by the event-stream drain; only `drain_non_deferred_*` / `peek_all` touch them.
168:- **Residual (documented, guard deferred):** the narrow slice — epoch file lost while the node *identity* is kept (manual state surgery, partial restore from backup) — has no guard; it waits for a field hit before one is designed (M8 decision 24). `REQ-HAZARD-EPOCH-RESET` is minted inactive (TRACEABILITY rule 5) as the tracking hook. If hit: symptoms are one node's endpoints frozen-stale on every peer while its own views are fresh; recovery today is re-pairing the node (rides the common-case eviction above).
217:- **spt-core mapping:** `spt-daemon::deelevate` (the OS-split seam) consumed by `daemon::spawn_detached` + the `Daemon::run` entry guard. The fuller Linux elevation model (install symlink + default-account election) is deferred (DEFERRED.md, M8).
257:- **spt-core mapping:** clean greenfield layout from day one (no migration window) — pick one structure, route everything through the registry. Storage layout deferred to design phase but this single-source-of-truth rule is binding.
318:- **spt-core mapping:** ADR-0018 Q4/V3/V4, restoration D5. Mechanism in `spt-daemon::deadline` (`DeadlineAnchor` periodic + `OneShotDeadline` rule-only pure helper); the pulse loop (`lifecycle::run_pulse_loop`) consumes it. The one-shot **machinery** (a durable in-daemon alarm scheduler) is the deferred alarm port (`docs/DEFERRED.md`) — the daemon has no one-shot consumer today, so building the timer now would ship untested dead code (activate-don't-pre-fail); D5 fixes the *rule* as a tested-unwired helper, the port builds the *scheduler*.
330:## 7. Boundary & delivery integrity (added 2026-05-31 — Stage A red-team)
334:### 7.1 Local `api` mutation auth  `[REQ-HAZARD-LOCAL-API-AUTH]`
340:### 7.2 Idempotent delivery across brain restart  `[REQ-HAZARD-RESTART-IDEMPOTENT]`
346:### 7.3 Psyche outbound capture + sanitization  `[REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`
352:### 7.4 Per-agent pulse/psyche/echo scheduling must not serialize across agents  `[REQ-HAZARD-DAEMON-SCHED-NONBLOCKING]`
358:### 7.5 WAN-inbound origin is transport truth, never payload  `[REQ-HAZARD-WAN-ORIGIN-AUTH]`
366:### 7.6 Pump brain-IPC reads must be deadline-bounded (a blocked read wedges the whole pump)  `[REQ-HAZARD-PUMP-IPC-DEADLINE]`
369:- **Invariant:** in PUMP mode the brain carrier is **SPLIT at construction** — a dedicated `pump-ipc-reader` thread does blocking `read_frame` on the `RecvHalf` and forwards each framed result down a channel; the main thread writes on the `SendHalf` and reads with `Receiver::recv_timeout`. Every IPC reply read is bounded by a per-call **total-wait** deadline (`PUMP_PEER_IO_TIMEOUT` = 30s, > any legitimate round-trip, < the 60s QUIC idle; re-armed on stream progress for the streaming pull legs so a healthy long sync is never killed — only a ≥30s silence). **Mechanism note (Windows):** the carrier is a reader-thread + channel, NOT a non-blocking socket + poll — interprocess 2.4.2 on Windows named pipes has no portable read timeout (`set_recv_timeout` → `no_timeouts()`) and its `set_nonblocking` uses deprecated `PIPE_NOWAIT`, which corrupts mid-stream (proven by the mesh E2E). Blocking reads work on both OSes; the channel supplies the deadline. A read that exceeds the deadline returns `io::ErrorKind::TimedOut`, treated as a **POISONED client** (a late reply could bind the wrong stream id in a later call — gotcha-#1-adjacent): it BUBBLES out of the round → `run_peer_pump` returns Err → `supervise_pump` restarts the pump with a fresh brain client + conn cache + reset WorkerLasts (the §V4 stagger re-primes every leg). NEVER a per-peer retry. An ordinary error (the broker REPLIED with one) stays a per-peer abort + conn drop + redial. **Leak watch (accepted, not fixed):** a restart abandons the old reader thread parked in `read_frame` (split halves share one OS handle → on a named pipe the broker may never signal the disconnect); bounded to ONE thread per actual wedge (the post-restart conn cache is fresh — the dead peer is re-dialed, not re-wedged), diagnosable by the reader's spawn/exit log lines, and fully cured only by the broker-side B-half (DEFERRED.md).
370:- **spt-core mapping:** `Brain::cold_start_pump` (splits the carrier + arms the deadline) / `BrainConn::Split` (the `SendHalf` + reader-thread channel) / `call_deadline` / `read_event_until` / `read_frame_until` (the `recv_timeout` dispatch), `pump::run_peer_pump` (connects in pump mode) + `pump::peer_outcome` (the tier-split), the `request_sync`/`request_update` pull loops (deadline re-armed on progress). The broker-side half — the broker must never make a brain wait unbounded on a QUIC op (bound `net_open_stream`/`send`/`dial` handlers) — is deferred to the next broker-update batch (DEFERRED.md).
371:- **Source:** field diagnosis 2026-06-11 (the 2.2h hfenduleam wedge); doyle ruling A-now / B-deferred. The stall warning (M8 decision 23) was the band-aid; this is the fix.
373:### 7.7 A slow/dead/hostile remote VIEWER must never stall the controller, child, or drain  `[REQ-HAZARD-VIEWER-ISOLATION]`
402:| 7.7 | A wedged viewer is evicted, never stalls the controller/child/drain | broker `OutputLog` fan-out (controller/viewer) |
419:| 7.6 | Pump brain-IPC reads deadline-bounded (30s total-wait); TimedOut → supervised restart, never per-peer retry | `Brain::cold_start_pump` / `BrainConn::Split` (reader-thread + `recv_timeout`), `pump::peer_outcome` |


echo "=== DEFERRED ~42 ==="; sed -n '30,60p' DEFERRED.md 2>/dev/null; echo "=== CHANGELOG head ==="; sed -n '1,30p' CHANGELOG.md 2>/dev/null

Read DEFERRED:42 + CHANGELOG head


=== DEFERRED ~42 ===
=== CHANGELOG head ===
# Changelog

All notable **user-facing** changes to `spt` — what a person running the CLI
notices or does differently. The `## [<version>]` section of each release
becomes that release's GitHub Release notes verbatim (see
`docs/RELEASE-RUNBOOK.md`). This project follows
[Keep a Changelog](https://keepachangelog.com) and semantic versioning
(pre-1.0: breaking changes bump the minor).

## [Unreleased]

## [0.8.2] - 2026-06-16

A reliability patch for command-template argument handling and dead-on-launch Psyche reporting.

### Fixed

- Command-template substitution now fills each argument as a single element: a multi-word or quoted value (e.g. a Psyche prompt) is passed through intact instead of being split or injected across multiple arguments.
- A daemon-hosted Psyche that launches but exits immediately is now correctly reported as a failed host (the harness-reachable psyche-host-error signal added in v0.8.1), instead of leaving a phantom "online" entry backed by a dead process.

## [0.8.1] - 2026-06-16

A visibility fix: a daemon that can't host a live agent's Psyche now reports it, instead of leaving the agent looking online with no cause.

### Fixed

- **Harness-reachable psyche-host failure signal.** When the daemon cannot host a live agent's Psyche (for example, the adapter's psyche binary is missing from its install directory), the failure is now recorded on the agent's perch state and surfaced by `spt endpoint list` / `spt whoami` as a `psyche-host: FAILED (<reason>; <n> attempt(s); <ts>)` line. Previously this failure was silent — visible only on the daemon's internal stderr — leaving an agent reporting `online` with no Psyche and no visible cause. Liveness (`status`) is unaffected and remains authoritative; the new `psyche_host_error` field is additive and backward-compatible.

## [0.8.0] - 2026-06-16