1	//! Endpoint access control — the ADR-0009 middle gate (M4-D5d, REQ-SEC-1).
2	//!
3	//! ADR-0009 models access as **three orthogonal, nested gates**, evaluated in
4	//! order: subnet *visibility* (is the endpoint routable here at all? —
5	//! `spt_store::visibility`, applied inside registry resolution) → the
6	//! **endpoint access whitelist** (may *your node* remotely reach it? — THIS
7	//! module) → *capability grants* (may this agent do this high-risk thing? —
8	//! [`crate::consent`], default-deny). The whitelist is the outer gate before
9	//! grants: an origin the whitelist refuses never reaches a grant check.
10	//!
11	//! ## Whitelist semantics (stateful firewall, not a blanket block)
12	//!
13	//! - **Origin-node gate:** the subject is the node the inbound interaction
14	//!   *originates from* (where the operator is acting) — never the sender
15	//!   endpoint's identity or home. The subject value MUST be the QUIC
16	//!   handshake-proven remote node id from the broker's conn/stream table,
17	//!   never a payload field (REQ-HAZARD-WAN-ORIGIN-AUTH).
18	//! - **Outbound** → any visible node: the whitelist never restricts who an
19	//!   endpoint talks *to*.
20	//! - **Inbound reply** correlated to the endpoint's own prior outbound
21	//!   (`__REPLY_TO__`) → allowed from any visible node ("established/related").
22	//!   Classification reads the durable [`RecentOutbound`] log
23	//!   ([`classify_inbound`]); senders feed it via [`note_outbound`].
24	//! - **Unsolicited/direct inbound** → only from a whitelisted node.
25	//! - **Same-node operation is always allowed** — the gate sits on the
26	//!   net-inbound path only, so the local delivery path never sees it; an
27	//!   origin that *is* this node (a loopback self-dial) short-circuits Allow.
28	//! - **Default absent = open**; setting a whitelist restricts (an empty entry
29	//!   = locked down). Node-tier ships M4 (Ed25519 node pubkeys); the user tier
30	//!   is an inert schema reservation until the cross-user model (ADR-0006).
31	//!
32	//! The store is [`spt_store::access::AccessStore`] (`trust/access.json` —
33	//! security material next to the trust store). The gate re-reads it per check:
34	//! an `spt access` edit takes effect on the next inbound without a daemon
35	//! restart, and the checks run at message/stream-establishment rate where a
36	//! small-file read is noise.
37	//!
38	//! **Class policy:** only the *message* funnel ([`crate::wan`]) classifies
39	//! reply-vs-unsolicited — a `__REPLY_TO__` conversation is message traffic.
40	//! Control surfaces (terminal attach, file transfer) are never replies; their
41	//! serves always gate as [`InboundClass::Unsolicited`].
42
43	use spt_store::access::{AccessStore, RecentOutbound, REPLY_WINDOW_MS};
44	use spt_store::perch::node_key_file;
45
46	/// How an inbound interaction reached this node, for the stateful-firewall
47	/// split.
48	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49	pub enum InboundClass {
50	    /// A new/direct control or message — gated by the whitelist.
51	    Unsolicited,
52	    /// Correlated to the target endpoint's own prior outbound (`__REPLY_TO__`)
53	    /// — allowed from any visible node.
54	    Reply,
55	}
56
57	/// The gate's verdict.
58	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59	pub enum AccessDecision {
60	    /// Pass to the next gate (grants, for gated capabilities) / deliver.
61	    Allow,
62	    /// Refused at the whitelist — drop before any grant check.
63	    Deny,
64	}
65
66	/// This node's own pubkey hex, if an identity exists. Deliberately
67	/// non-minting: an absent `node.key` reads `None` (the same-node fast path
68	/// simply doesn't match) rather than creating an identity as a side effect of
69	/// a gate check.
70	fn local_node_hex() -> Option<String> {
71	    if !node_key_file().exists() {
72	        return None;
73	    }
74	    spt_store::nodeid::load_or_create()
75	        .ok()
76	        .map(|id| id.public_key().to_hex())
77	}
78
79	/// The ADR-0009 access-whitelist gate for one wire-inbound interaction against
80	/// `endpoint`, originating from handshake-proven `origin_node`.
81	///
82	/// Decision order: same-node short-circuit → store lookup (absent = open) →
83	/// class split (reply exempt, unsolicited gated on the node list).
84	// [impl->REQ-SEC-1]
85	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
86	pub fn access_check(endpoint: &str, origin_node: &str, class: InboundClass) -> AccessDecision {
87	    // Same-node is local operation that happened to arrive over a loopback
88	    // dial — never whitelisted away.
89	    if local_node_hex().as_deref() == Some(origin_node) {
90	        return AccessDecision::Allow;
91	    }
92	    let store = AccessStore::load();
93	    let Some(entry) = store.lookup(endpoint) else {
94	        // Absent = default-open (ADR-0009).
95	        return AccessDecision::Allow;
96	    };
97	    match class {
98	        // Established/related: the endpoint spoke to that node first.
99	        InboundClass::Reply => AccessDecision::Allow,
100	        InboundClass::Unsolicited => {
101	            if entry.nodes.iter().any(|n| n == origin_node) {
102	                AccessDecision::Allow
103	            } else {
104	                AccessDecision::Deny
105	            }
106	        }
107	    }
108	}
109
110	/// Classify an inbound *message* for the gate: [`InboundClass::Reply`] iff the
111	/// target endpoint's own recent outbound (the durable [`RecentOutbound`] log)
112	/// names the origin node within the reply window — else the conservative
113	/// [`InboundClass::Unsolicited`].
114	// [impl->REQ-SEC-1]
115	pub fn classify_inbound(endpoint: &str, origin_node: &str, now_ms: u64) -> InboundClass {
116	    if RecentOutbound::load().correlates(endpoint, origin_node, now_ms, REPLY_WINDOW_MS) {
117	        InboundClass::Reply
118	    } else {
119	        InboundClass::Unsolicited
120	    }
121	}
122
123	/// Record that `endpoint` sent off-node to `node` at `now_ms` — the fact
124	/// [`classify_inbound`] later correlates a reply against. Call on every
125	/// outbound WAN send (the sending leg's side of the stateful firewall).
126	/// Best-effort: a failed write only costs a future reply its exemption (it
127	/// will classify unsolicited — the conservative direction).
128	// [impl->REQ-SEC-1]
129	pub fn note_outbound(endpoint: &str, node: &str, now_ms: u64) {
130	    let mut log = RecentOutbound::load();
131	    log.record(endpoint, node, now_ms, REPLY_WINDOW_MS);
132	    let _ = log.save();
133	}
134
135	#[cfg(test)]
136	mod tests {
137	    use super::*;
138	    use crate::test_home::with_home;
139
140	    // [unit->REQ-SEC-1] the gate's decision table: absent entry = open;
141	    // restricted endpoint admits listed nodes and refuses unlisted ones;
142	    // an empty entry is locked down; other endpoints stay open.
143	    #[test]
144	    fn gate_decision_table() {
145	        with_home(|_| {
146	            // Default-open before any store exists.
147	            assert_eq!(
148	                access_check("ling", "aa11", InboundClass::Unsolicited),
149	                AccessDecision::Allow
150	            );
151
152	            let mut s = AccessStore::default();
153	            s.allow("ling", "aa11");
154	            s.save().unwrap();
155	            assert_eq!(
156	                access_check("ling", "aa11", InboundClass::Unsolicited),
157	                AccessDecision::Allow,
158	                "whitelisted node passes"
159	            );
160	            assert_eq!(
161	                access_check("ling", "bb22", InboundClass::Unsolicited),
162	                AccessDecision::Deny,
163	                "non-listed unsolicited refused"
164	            );
165	            assert_eq!(
166	                access_check("doyle", "bb22", InboundClass::Unsolicited),
167	                AccessDecision::Allow,
168	                "other endpoints stay default-open"
169	            );
170
171	            // Locked down: entry present, no nodes.
172	            let mut s = AccessStore::load();
173	            s.revoke("ling", "aa11");
174	            s.save().unwrap();
175	            assert_eq!(
176	                access_check("ling", "aa11", InboundClass::Unsolicited),
177	                AccessDecision::Deny,
178	                "empty entry refuses everyone"
179	            );
180
181	            // Open: entry deleted → default-open again.
182	            let mut s = AccessStore::load();
183	            s.open("ling");
184	            s.save().unwrap();
185	            assert_eq!(
186	                access_check("ling", "bb22", InboundClass::Unsolicited),
187	                AccessDecision::Allow
188	            );
189	        });
190	    }
191
192	    // [unit->REQ-SEC-1] the stateful-firewall exemption: a Reply classifies
193	    // past a whitelist that would refuse the same node unsolicited — and
194	    // classification itself turns on the recorded outbound within the window.
195	    #[test]
196	    fn reply_passes_where_unsolicited_refused() {
197	        with_home(|_| {
198	            let mut s = AccessStore::default();
199	            s.allow("ling", "aa11"); // restrict ling to aa11 only
200	            s.save().unwrap();
201
202	            // bb22 unsolicited → refused; as a classified reply → allowed.
203	            assert_eq!(
204	                access_check("ling", "bb22", InboundClass::Unsolicited),
205	                AccessDecision::Deny
206	            );
207	            assert_eq!(
208	                access_check("ling", "bb22", InboundClass::Reply),
209	                AccessDecision::Allow
210	            );
211
212	            // Classification: before any outbound → Unsolicited; after the
213	            // endpoint itself sent to bb22 → Reply, within the window only.
214	            assert_eq!(
215	                classify_inbound("ling", "bb22", 1_000),
216	                InboundClass::Unsolicited
217	            );
218	            note_outbound("ling", "bb22", 1_000);
219	            assert_eq!(classify_inbound("ling", "bb22", 2_000), InboundClass::Reply);
220	            assert_eq!(
221	                classify_inbound("ling", "bb22", 2_000 + REPLY_WINDOW_MS),
222	                InboundClass::Unsolicited,
223	                "window expired → conservative class"
224	            );
225	            assert_eq!(
226	                classify_inbound("doyle", "bb22", 2_000),
227	                InboundClass::Unsolicited,
228	                "another endpoint's outbound does not exempt this one"
229	            );
230	        });
231	    }
232
233	    // [unit->REQ-SEC-1] same-node always passes: an origin that IS this node's
234	    // own identity short-circuits Allow even against a whitelist that names
235	    // someone else (local operation over a loopback dial is never gated away).
236	    // Absent node.key, the fast path must not mint an identity.
237	    #[test]
238	    fn same_node_always_passes_without_minting() {
239	        with_home(|_| {
240	            // No identity yet: the gate must not create node.key as a side effect.
241	            let mut s = AccessStore::default();
242	            s.allow("ling", "someone-else");
243	            s.save().unwrap();
244	            assert_eq!(
245	                access_check("ling", "deadbeef", InboundClass::Unsolicited),
246	                AccessDecision::Deny
247	            );
248	            assert!(!node_key_file().exists(), "gate check minted no identity");
249
250	            // With an identity: own-origin passes the same whitelist.
251	            let me = spt_store::nodeid::load_or_create()
252	                .expect("identity")
253	                .public_key()
254	                .to_hex();
255	            assert_eq!(
256	                access_check("ling", &me, InboundClass::Unsolicited),
257	                AccessDecision::Allow,
258	                "same-node origin is never whitelisted away"
259	            );
260	        });
261	    }
262	}
263

//! Endpoint access control — the ADR-0009 middle gate (M4-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").
//!   Classification reads the durable [`RecentOutbound`] log
//!   ([`classify_inbound`]); senders feed it via [`note_outbound`].
//! - **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; an
//!   origin that *is* this node (a loopback self-dial) short-circuits Allow.
//! - **Default absent = open**; setting a whitelist restricts (an empty entry
//!   = locked down). Node-tier ships M4 (Ed25519 node pubkeys); the user tier
//!   is an inert schema reservation until the cross-user model (ADR-0006).
//!
//! The store is [`spt_store::access::AccessStore`] (`trust/access.json` —
//! security material next to the trust store). The gate re-reads it per check:
//! an `spt access` edit takes effect on the next inbound without a daemon
//! restart, and the checks run at message/stream-establishment rate where a
//! small-file read is noise.
//!
//! **Class policy:** only the *message* funnel ([`crate::wan`]) classifies
//! reply-vs-unsolicited — a `__REPLY_TO__` conversation is message traffic.
//! Control surfaces (terminal attach, file transfer) are never replies; their
//! serves always gate as [`InboundClass::Unsolicited`].

use spt_store::access::{AccessStore, RecentOutbound, REPLY_WINDOW_MS};
use spt_store::perch::node_key_file;

/// How an inbound interaction reached this node, for the stateful-firewall
/// split.
#[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,
}

/// This node's own pubkey hex, if an identity exists. Deliberately
/// non-minting: an absent `node.key` reads `None` (the same-node fast path
/// simply doesn't match) rather than creating an identity as a side effect of
/// a gate check.
fn local_node_hex() -> Option<String> {
    if !node_key_file().exists() {
        return None;
    }
    spt_store::nodeid::load_or_create()
        .ok()
        .map(|id| id.public_key().to_hex())
}

/// The ADR-0009 access-whitelist gate for one wire-inbound interaction against
/// `endpoint`, originating from handshake-proven `origin_node`.
///
/// Decision order: same-node short-circuit → store lookup (absent = open) →
/// class split (reply exempt, unsolicited gated on the node list).
// [impl->REQ-SEC-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
pub fn access_check(endpoint: &str, origin_node: &str, class: InboundClass) -> AccessDecision {
    // Same-node is local operation that happened to arrive over a loopback
    // dial — never whitelisted away.
    if local_node_hex().as_deref() == Some(origin_node) {
        return AccessDecision::Allow;
    }
    let store = AccessStore::load();
    let Some(entry) = store.lookup(endpoint) else {
        // Absent = default-open (ADR-0009).
        return AccessDecision::Allow;
    };
    match class {
        // Established/related: the endpoint spoke to that node first.
        InboundClass::Reply => AccessDecision::Allow,
        InboundClass::Unsolicited => {
            if entry.nodes.iter().any(|n| n == origin_node) {
                AccessDecision::Allow
            } else {
                AccessDecision::Deny
            }
        }
    }
}

/// Classify an inbound *message* for the gate: [`InboundClass::Reply`] iff the
/// target endpoint's own recent outbound (the durable [`RecentOutbound`] log)
/// names the origin node within the reply window — else the conservative
/// [`InboundClass::Unsolicited`].
// [impl->REQ-SEC-1]
pub fn classify_inbound(endpoint: &str, origin_node: &str, now_ms: u64) -> InboundClass {
    if RecentOutbound::load().correlates(endpoint, origin_node, now_ms, REPLY_WINDOW_MS) {
        InboundClass::Reply
    } else {
        InboundClass::Unsolicited
    }
}

/// Record that `endpoint` sent off-node to `node` at `now_ms` — the fact
/// [`classify_inbound`] later correlates a reply against. Call on every
/// outbound WAN send (the sending leg's side of the stateful firewall).
/// Best-effort: a failed write only costs a future reply its exemption (it
/// will classify unsolicited — the conservative direction).
// [impl->REQ-SEC-1]
pub fn note_outbound(endpoint: &str, node: &str, now_ms: u64) {
    let mut log = RecentOutbound::load();
    log.record(endpoint, node, now_ms, REPLY_WINDOW_MS);
    let _ = log.save();
}

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

    // [unit->REQ-SEC-1] the gate's decision table: absent entry = open;
    // restricted endpoint admits listed nodes and refuses unlisted ones;
    // an empty entry is locked down; other endpoints stay open.
    #[test]
    fn gate_decision_table() {
        with_home(|_| {
            // Default-open before any store exists.
            assert_eq!(
                access_check("ling", "aa11", InboundClass::Unsolicited),
                AccessDecision::Allow
            );

            let mut s = AccessStore::default();
            s.allow("ling", "aa11");
            s.save().unwrap();
            assert_eq!(
                access_check("ling", "aa11", InboundClass::Unsolicited),
                AccessDecision::Allow,
                "whitelisted node passes"
            );
            assert_eq!(
                access_check("ling", "bb22", InboundClass::Unsolicited),
                AccessDecision::Deny,
                "non-listed unsolicited refused"
            );
            assert_eq!(
                access_check("doyle", "bb22", InboundClass::Unsolicited),
                AccessDecision::Allow,
                "other endpoints stay default-open"
            );

            // Locked down: entry present, no nodes.
            let mut s = AccessStore::load();
            s.revoke("ling", "aa11");
            s.save().unwrap();
            assert_eq!(
                access_check("ling", "aa11", InboundClass::Unsolicited),
                AccessDecision::Deny,
                "empty entry refuses everyone"
            );

            // Open: entry deleted → default-open again.
            let mut s = AccessStore::load();
            s.open("ling");
            s.save().unwrap();
            assert_eq!(
                access_check("ling", "bb22", InboundClass::Unsolicited),
                AccessDecision::Allow
            );
        });
    }

    // [unit->REQ-SEC-1] the stateful-firewall exemption: a Reply classifies
    // past a whitelist that would refuse the same node unsolicited — and
    // classification itself turns on the recorded outbound within the window.
    #[test]
    fn reply_passes_where_unsolicited_refused() {
        with_home(|_| {
            let mut s = AccessStore::default();
            s.allow("ling", "aa11"); // restrict ling to aa11 only
            s.save().unwrap();

            // bb22 unsolicited → refused; as a classified reply → allowed.
            assert_eq!(
                access_check("ling", "bb22", InboundClass::Unsolicited),
                AccessDecision::Deny
            );
            assert_eq!(
                access_check("ling", "bb22", InboundClass::Reply),
                AccessDecision::Allow
            );

            // Classification: before any outbound → Unsolicited; after the
            // endpoint itself sent to bb22 → Reply, within the window only.
            assert_eq!(
                classify_inbound("ling", "bb22", 1_000),
                InboundClass::Unsolicited
            );
            note_outbound("ling", "bb22", 1_000);
            assert_eq!(classify_inbound("ling", "bb22", 2_000), InboundClass::Reply);
            assert_eq!(
                classify_inbound("ling", "bb22", 2_000 + REPLY_WINDOW_MS),
                InboundClass::Unsolicited,
                "window expired → conservative class"
            );
            assert_eq!(
                classify_inbound("doyle", "bb22", 2_000),
                InboundClass::Unsolicited,
                "another endpoint's outbound does not exempt this one"
            );
        });
    }

    // [unit->REQ-SEC-1] same-node always passes: an origin that IS this node's
    // own identity short-circuits Allow even against a whitelist that names
    // someone else (local operation over a loopback dial is never gated away).
    // Absent node.key, the fast path must not mint an identity.
    #[test]
    fn same_node_always_passes_without_minting() {
        with_home(|_| {
            // No identity yet: the gate must not create node.key as a side effect.
            let mut s = AccessStore::default();
            s.allow("ling", "someone-else");
            s.save().unwrap();
            assert_eq!(
                access_check("ling", "deadbeef", InboundClass::Unsolicited),
                AccessDecision::Deny
            );
            assert!(!node_key_file().exists(), "gate check minted no identity");

            // With an identity: own-origin passes the same whitelist.
            let me = spt_store::nodeid::load_or_create()
                .expect("identity")
                .public_key()
                .to_hex();
            assert_eq!(
                access_check("ling", &me, InboundClass::Unsolicited),
                AccessDecision::Allow,
                "same-node origin is never whitelisted away"
            );
        });
    }
}


Q1 confirmed: `access.rs:89` same-node short-circuit → Allow. Loopback conn must surface `origin_node = local_node_hex()` (broker-minted own-node id). Invariant already documented at access.rs:26-27. No access change needed.

Now map brain `net_dial` IPC + broker net-frame dispatch to add `net_dial_loopback`.


38:    KillReq, NetDialReq, NetDialed, NetPresenceEvent, NetPresenceSubscribeReq, NetSent,
109:    NetDialed(NetDialed),
598:                    let ev: NetDialed = serde_json::from_value(env.payload)
600:                    return Ok(BrokerEvent::NetDialed(ev));
753:    pub fn net_status(&mut self) -> io::Result<NetStatusReply> {
798:    /// already-open connection ([`NetDialed::applied_now`] distinguishes).
800:    pub fn net_dial(
804:    ) -> io::Result<NetDialed> {
812:                BrokerEvent::NetDialed(d) => return Ok(d),
824:    pub fn net_open_stream(
849:    pub fn net_stream_send(
881:    pub fn net_streams(&mut self) -> io::Result<NetStreamsReply> {
938:    pub fn net_stream_subscribe(&mut self, stream_id: u64, from_seq: u64) -> io::Result<()> {
952:    pub fn net_cursor(&self, stream_id: u64) -> Option<u64> {
960:    pub fn net_presence_subscribe(&mut self, from_seq: u64) -> io::Result<()> {


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


790	            }
791	        }
792	    }
793
794	    /// Dial a peer over the broker-owned endpoint (D4a). `addr` is the peer's
795	    /// serialized `EndpointAddr` (as another node's `net-status` reply carries).
796	    /// With `op_id` the dial is exactly-once: a replay of the same durable id
797	    /// after a brain crash is deduped at the broker and answered with the
798	    /// already-open connection ([`NetDialed::applied_now`] distinguishes).
799	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
800	    pub fn net_dial(
801	        &mut self,
802	        addr: serde_json::Value,
803	        op_id: Option<u64>,
804	    ) -> io::Result<NetDialed> {
805	        self.send(
806	            KIND_NET_DIAL,
807	            serde_json::to_value(NetDialReq { addr, op_id }).expect("NetDialReq serializes"),
808	        )?;
809	        let deadline = self.call_deadline();
810	        loop {
811	            match self.read_event_until(deadline)? {
812	                BrokerEvent::NetDialed(d) => return Ok(d),
813	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
814	                _ => continue,
815	            }
816	        }
817	    }
818
819	    /// Open a bidi stream on a held connection (D4b). With `op_id` the open is
820	    /// exactly-once (a replay is answered with the original stream). NOTE all
821	    /// net op ids — dials, opens, sends — share ONE durable namespace; mint
822	    /// them from a single durable counter.
823	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
824	    pub fn net_open_stream(
825	        &mut self,
826	        conn_id: u64,
827	        op_id: Option<u64>,
828	    ) -> io::Result<NetStreamOpened> {
829	        self.send(
830	            KIND_NET_STREAM_OPEN,
831	            serde_json::to_value(NetStreamOpenReq { conn_id, op_id })
832	                .expect("NetStreamOpenReq serializes"),
833	        )?;
834	        let deadline = self.call_deadline();
835	        loop {
836	            match self.read_event_until(deadline)? {
837	                BrokerEvent::NetStreamOpened(ev) => return Ok(ev),
838	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
839	                _ => continue,
840	            }
841	        }
842	    }
843
844	    /// Write bytes to a held stream over the broker (D4b). With `op_id` the
845	    /// write is exactly-once and this blocks for the `net-sent` ack (returned);
846	    /// without one it is a direct fire-and-forget write (`None` returned).
847	    /// `finish` closes the send side after the write.
848	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
849	    pub fn net_stream_send(
850	        &mut self,
851	        stream_id: u64,
852	        bytes: &[u8],
853	        op_id: Option<u64>,
854	        finish: bool,
855	    ) -> io::Result<Option<NetSent>> {
856	        self.send(
857	            KIND_NET_STREAM_SEND,
858	            serde_json::to_value(NetStreamSendReq {
859	                stream_id,
860	                data_b64: encode_bytes(bytes),
861	                op_id,
862	                finish,
863	            })
864	            .expect("NetStreamSendReq serializes"),
865	        )?;
866	        if op_id.is_none() {
867	            return Ok(None);
868	        }
869	        let deadline = self.call_deadline();
870	        loop {
871	            match self.read_event_until(deadline)? {
872	                BrokerEvent::NetSent(ev) if Some(ev.op_id) == op_id => return Ok(Some(ev)),
873	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
874	                _ => continue,
875	            }
876	        }
877	    }
878
879	    /// List the streams the broker holds (D4b) — how a re-attaching brain
880	    /// finds streams (incl. peer-initiated ones) that arrived while it was down.
881	    pub fn net_streams(&mut self) -> io::Result<NetStreamsReply> {
882	        self.send(KIND_NET_STREAMS, serde_json::Value::Null)?;
883	        loop {
884	            match self.read_event()? {
885	                BrokerEvent::NetStreams(ev) => return Ok(ev),
886	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
887	                _ => continue,
888	            }
889	        }
890	    }
891
892	    /// Trigger a **planned brain-process restart** on the broker (ADR-0018 D3-3,
893	    /// Q3): the broker signals its supervisor to cycle the brain child onto the
894	    /// freshly-swapped binary, with `start-reason=update`. `spt update apply`
895	    /// calls this after swapping the binary on disk — the seam that makes a
896	    /// routine update actually run new code without a manual bounce, every
897	    /// broker-held endpoint untouched at the process level.
898	    ///
899	    /// Returns `honored`: whether the broker had a supervisor wired (false on a
900	    /// bare broker with no supervisor, e.g. a test broker — the verb is accepted
901	    /// but nothing respawns). An older broker that predates the verb answers with
902	    /// an error (KH-2.3 forward-compat: surfaced, never a silent no-op of a
903	    /// swapped binary). Readiness-gated promotion / auto-rollback is D6.
904	    // [impl->REQ-UPD-3]
905	    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
906	    pub fn request_brain_restart(&mut self) -> io::Result<bool> {
907	        self.send(KIND_BRAIN_RESTART, serde_json::Value::Null)?;
908	        loop {
909	            match self.read_event()? {
910	                BrokerEvent::Other(env) if env.kind == KIND_BRAIN_RESTARTED => {
911	                    let reply: BrainRestarted = serde_json::from_value(env.payload)
912	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
913	                    return Ok(reply.honored);
914	                }
915	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
916	                _ => continue,
917	            }
918	        }
919	    }

            }
        }
    }

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

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

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

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

    /// Trigger a **planned brain-process restart** on the broker (ADR-0018 D3-3,
    /// Q3): the broker signals its supervisor to cycle the brain child onto the
    /// freshly-swapped binary, with `start-reason=update`. `spt update apply`
    /// calls this after swapping the binary on disk — the seam that makes a
    /// routine update actually run new code without a manual bounce, every
    /// broker-held endpoint untouched at the process level.
    ///
    /// Returns `honored`: whether the broker had a supervisor wired (false on a
    /// bare broker with no supervisor, e.g. a test broker — the verb is accepted
    /// but nothing respawns). An older broker that predates the verb answers with
    /// an error (KH-2.3 forward-compat: surfaced, never a silent no-op of a
    /// swapped binary). Readiness-gated promotion / auto-rollback is D6.
    // [impl->REQ-UPD-3]
    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    pub fn request_brain_restart(&mut self) -> io::Result<bool> {
        self.send(KIND_BRAIN_RESTART, serde_json::Value::Null)?;
        loop {
            match self.read_event()? {
                BrokerEvent::Other(env) if env.kind == KIND_BRAIN_RESTARTED => {
                    let reply: BrainRestarted = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(reply.honored);
                }
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => continue,
            }
        }
    }


Now broker.rs net-dial dispatch + how worker gets stream origin. Grep.


49:    applied_envelope, decode_bytes, net_dialed_envelope, output_envelope, BrainRestarted,
50:    ErrorEvent, ExitEvent, InputReq, KillReq, NetDialReq, NetPresenceSubscribeReq, NetSent,
54:    KIND_INPUT, KIND_KILL, KIND_NET_DIAL, KIND_NET_PRESENCE_SUBSCRIBE, KIND_NET_SENT,
55:    KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_NET_STREAMS, KIND_NET_STREAMS_REPLY,
56:    KIND_NET_STREAM_OPEN, KIND_NET_STREAM_OPENED, KIND_NET_STREAM_SEND, KIND_NET_STREAM_SUBSCRIBE,
434:                KIND_NET_STATUS => self.dispatch_net_status(&send),
435:                KIND_NET_DIAL => {
436:                    if let Err(msg) = self.dispatch_net_dial(env, &send) {
445:                KIND_NET_STREAM_OPEN => {
450:                KIND_NET_STREAM_SEND => {
455:                KIND_NET_STREAMS => {
487:                KIND_NET_STREAM_SUBSCRIBE => match self.dispatch_net_stream_subscribe(env, &send) {
491:                KIND_NET_PRESENCE_SUBSCRIBE => {
688:    fn dispatch_net_status(&self, send: &SharedSend) {
704:            KIND_NET_STATUS_REPLY,
746:    fn dispatch_net_dial(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
747:        let req: NetDialReq = serde_json::from_value(env.payload)
762:                    &net_dialed_envelope(conn_id, remote, None, true, observed),
786:                    &net_dialed_envelope(conn_id, remote, Some(op), applied_now, observed),
852:    /// under the same discipline as [`Self::dispatch_net_dial`]: the minted
856:    fn dispatch_net_stream_open(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
862:                KIND_NET_STREAM_OPENED,
904:    fn dispatch_net_stream_send(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
922:                    KIND_NET_SENT,
938:    fn dispatch_net_streams(&self, send: &SharedSend) -> Result<(), String> {
941:            KIND_NET_STREAMS_REPLY,
954:    fn dispatch_net_stream_subscribe(
971:    fn dispatch_net_presence_subscribe(


746	    fn dispatch_net_dial(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
747	        let req: NetDialReq = serde_json::from_value(env.payload)
748	            .map_err(|e| format!("bad net-dial payload: {e}"))?;
749	        let host = self
750	            .net
751	            .get()
752	            .ok_or("net disabled: this broker was bound without a network host")?;
753	        let addr: EndpointAddr =
754	            serde_json::from_value(req.addr).map_err(|e| format!("bad net-dial addr: {e}"))?;
755	        match req.op_id {
756	            None => {
757	                // Direct (non-journaled) dial — no dedup, like a no-op_id input.
758	                let (conn_id, remote) = host.dial(addr).map_err(|e| e.to_string())?;
759	                let observed = host.conn_remote_addr_json(conn_id);
760	                send_frame(
761	                    send,
762	                    &net_dialed_envelope(conn_id, remote, None, true, observed),
763	                );
764	                Ok(())
765	            }
766	            Some(op) => {
767	                let outcome = self
768	                    .journal
769	                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetDial, || {
770	                        let (conn_id, _) = host.dial(addr)?;
771	                        host.record_dial_op(op, conn_id);
772	                        Ok(())
773	                    })
774	                    .map_err(|e| format!("net-dial failed: {e}"))?;
775	                let conn_id = host.dial_op_conn(op).ok_or(
776	                    "net-dial op already applied but its connection is no longer held \
777	                     (broker restarted); retry with a fresh op_id",
778	                )?;
779	                let remote = host
780	                    .conn_remote_hex(conn_id)
781	                    .ok_or("dialed connection missing from the conn table")?;
782	                let applied_now = outcome == Outcome::Applied;
783	                let observed = host.conn_remote_addr_json(conn_id);
784	                send_frame(
785	                    send,
786	                    &net_dialed_envelope(conn_id, remote, Some(op), applied_now, observed),
787	                );
788	                Ok(())
789	            }
790	        }
791	    }
792
793	    /// Run the joiner leg of the subnet pairing ceremony (M7 D3,
794	    /// REQ-SUBNET-2) on the broker-owned endpoint — it binds and pins the
795	    /// REAL node pubkey, so it cannot run anywhere else. Not journaled: a
796	    /// repeated join is idempotent at the stores (re-pair keeps the existing
797	    /// trust row + seed), and the TOTP code in the request must never land
798	    /// in a durable journal. Blocks this client's channel for the ceremony's
799	    /// (bounded) life; the reply is always a `pair-joined` frame, with
800	    /// failures classified for the CLI's guided UX.
801	    // [impl->REQ-SUBNET-2]
802	    fn dispatch_pair_join(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
803	        let req: PairJoinReq = serde_json::from_value(env.payload)
804	            .map_err(|e| format!("bad pair-join payload: {e}"))?;
805	        let host = self
806	            .net
807	            .get()
808	            .ok_or("net disabled: this broker was bound without a network host")?;
809	        // The optional explicit responder address (the rig seam) decodes
810	        // strictly — a malformed address is a usage error, not a dial flop.
811	        let responder_addr = match req.responder_addr {
812	            Some(v) => Some(
813	                serde_json::from_value::<EndpointAddr>(v)
814	                    .map_err(|e| format!("bad pair-join responder_addr: {e}"))?,
815	            ),
816	            None => None,
817	        };
818	        let reply = match host.pair_join(&req.subnet, &req.code, responder_addr) {
819	            Ok(outcome) => PairJoinReply {
820	                ok: true,
821	                class: None,
822	                retry_after_secs: None,
823	                detail: outcome,
824	            },
825	            Err(fail) => {
826	                use crate::pairhost::JoinFail;
827	                let (class, retry) = match &fail {
828	                    JoinFail::NoSeedHolder(_) => ("no-seed-holder", None),
829	                    JoinFail::WrongCode => ("wrong-code", None),
830	                    JoinFail::RateLimited(retry) => ("rate-limited", *retry),
831	                    JoinFail::UnknownSubnet(_) => ("unknown-subnet", None),
832	                    JoinFail::ClockSkew => ("clock-skew", None),
833	                    JoinFail::Other(_) => ("other", None),
834	                };
835	                PairJoinReply {
836	                    ok: false,
837	                    class: Some(class.to_string()),
838	                    retry_after_secs: retry,
839	                    detail: fail.to_string(),
840	                }
841	            }
842	        };
843	        let frame = Envelope::new(
844	            KIND_PAIR_JOINED,
845	            serde_json::to_value(reply).expect("PairJoinReply serializes"),
846	        );
847	        send_frame(send, &frame);
848	        Ok(())
849	    }
850
851	    /// Open a bidi stream on a held connection (D4b). Journaled exactly-once
852	    /// under the same discipline as [`Self::dispatch_net_dial`]: the minted
853	    /// `stream_id` is recorded inside the `apply_once` closure, and a deduped
854	    /// replay is answered with the stream the first apply opened.
855	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
856	    fn dispatch_net_stream_open(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
857	        let req: NetStreamOpenReq = serde_json::from_value(env.payload)
858	            .map_err(|e| format!("bad net-stream-open payload: {e}"))?;
859	        let host = self.net.get().ok_or("net disabled")?;
860	        let build = |stream_id: u64, conn_id: u64, op_id: Option<u64>, applied_now: bool| {
861	            Envelope::new(
862	                KIND_NET_STREAM_OPENED,
863	                serde_json::to_value(NetStreamOpened {
864	                    stream_id,
865	                    conn_id,
866	                    op_id,
867	                    applied_now,
868	                })
869	                .expect("NetStreamOpened serializes"),
870	            )
871	        };
872	        match req.op_id {
873	            None => {
874	                let stream_id = host.open_stream(req.conn_id).map_err(|e| e.to_string())?;
875	                send_frame(send, &build(stream_id, req.conn_id, None, true));
876	                Ok(())
877	            }
878	            Some(op) => {
879	                let outcome = self
880	                    .journal
881	                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
882	                        let stream_id = host.open_stream(req.conn_id)?;
883	                        host.record_stream_op(op, stream_id);
884	                        Ok(())
885	                    })
886	                    .map_err(|e| format!("net-stream-open failed: {e}"))?;
887	                let stream_id = host.stream_op_id(op).ok_or(
888	                    "stream-open op already applied but its stream is no longer held \
889	                     (broker restarted); retry with a fresh op_id",
890	                )?;
891	                let applied_now = outcome == Outcome::Applied;
892	                send_frame(send, &build(stream_id, req.conn_id, Some(op), applied_now));
893	                Ok(())
894	            }
895	        }
896	    }
897
898	    /// Write bytes to a held stream's send side (D4b). With an `op_id` the
899	    /// write is exactly-once through the broker-owned journal (a replayed
900	    /// chunk after a brain crash is not written twice — Spike #3 invariant D
901	    /// on the send path) and acked with `net-sent`; without one it is a direct
902	    /// write (no dedup, no ack), mirroring the PTY `input` contract.
903	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
904	    fn dispatch_net_stream_send(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
905	        let req: NetStreamSendReq = serde_json::from_value(env.payload)

    fn dispatch_net_dial(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: NetDialReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad net-dial payload: {e}"))?;
        let host = self
            .net
            .get()
            .ok_or("net disabled: this broker was bound without a network host")?;
        let addr: EndpointAddr =
            serde_json::from_value(req.addr).map_err(|e| format!("bad net-dial addr: {e}"))?;
        match req.op_id {
            None => {
                // Direct (non-journaled) dial — no dedup, like a no-op_id input.
                let (conn_id, remote) = host.dial(addr).map_err(|e| e.to_string())?;
                let observed = host.conn_remote_addr_json(conn_id);
                send_frame(
                    send,
                    &net_dialed_envelope(conn_id, remote, None, true, observed),
                );
                Ok(())
            }
            Some(op) => {
                let outcome = self
                    .journal
                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetDial, || {
                        let (conn_id, _) = host.dial(addr)?;
                        host.record_dial_op(op, conn_id);
                        Ok(())
                    })
                    .map_err(|e| format!("net-dial failed: {e}"))?;
                let conn_id = host.dial_op_conn(op).ok_or(
                    "net-dial op already applied but its connection is no longer held \
                     (broker restarted); retry with a fresh op_id",
                )?;
                let remote = host
                    .conn_remote_hex(conn_id)
                    .ok_or("dialed connection missing from the conn table")?;
                let applied_now = outcome == Outcome::Applied;
                let observed = host.conn_remote_addr_json(conn_id);
                send_frame(
                    send,
                    &net_dialed_envelope(conn_id, remote, Some(op), applied_now, observed),
                );
                Ok(())
            }
        }
    }

    /// Run the joiner leg of the subnet pairing ceremony (M7 D3,
    /// REQ-SUBNET-2) on the broker-owned endpoint — it binds and pins the
    /// REAL node pubkey, so it cannot run anywhere else. Not journaled: a
    /// repeated join is idempotent at the stores (re-pair keeps the existing
    /// trust row + seed), and the TOTP code in the request must never land
    /// in a durable journal. Blocks this client's channel for the ceremony's
    /// (bounded) life; the reply is always a `pair-joined` frame, with
    /// failures classified for the CLI's guided UX.
    // [impl->REQ-SUBNET-2]
    fn dispatch_pair_join(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: PairJoinReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad pair-join payload: {e}"))?;
        let host = self
            .net
            .get()
            .ok_or("net disabled: this broker was bound without a network host")?;
        // The optional explicit responder address (the rig seam) decodes
        // strictly — a malformed address is a usage error, not a dial flop.
        let responder_addr = match req.responder_addr {
            Some(v) => Some(
                serde_json::from_value::<EndpointAddr>(v)
                    .map_err(|e| format!("bad pair-join responder_addr: {e}"))?,
            ),
            None => None,
        };
        let reply = match host.pair_join(&req.subnet, &req.code, responder_addr) {
            Ok(outcome) => PairJoinReply {
                ok: true,
                class: None,
                retry_after_secs: None,
                detail: outcome,
            },
            Err(fail) => {
                use crate::pairhost::JoinFail;
                let (class, retry) = match &fail {
                    JoinFail::NoSeedHolder(_) => ("no-seed-holder", None),
                    JoinFail::WrongCode => ("wrong-code", None),
                    JoinFail::RateLimited(retry) => ("rate-limited", *retry),
                    JoinFail::UnknownSubnet(_) => ("unknown-subnet", None),
                    JoinFail::ClockSkew => ("clock-skew", None),
                    JoinFail::Other(_) => ("other", None),
                };
                PairJoinReply {
                    ok: false,
                    class: Some(class.to_string()),
                    retry_after_secs: retry,
                    detail: fail.to_string(),
                }
            }
        };
        let frame = Envelope::new(
            KIND_PAIR_JOINED,
            serde_json::to_value(reply).expect("PairJoinReply serializes"),
        );
        send_frame(send, &frame);
        Ok(())
    }

    /// Open a bidi stream on a held connection (D4b). Journaled exactly-once
    /// under the same discipline as [`Self::dispatch_net_dial`]: the minted
    /// `stream_id` is recorded inside the `apply_once` closure, and a deduped
    /// replay is answered with the stream the first apply opened.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    fn dispatch_net_stream_open(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: NetStreamOpenReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad net-stream-open payload: {e}"))?;
        let host = self.net.get().ok_or("net disabled")?;
        let build = |stream_id: u64, conn_id: u64, op_id: Option<u64>, applied_now: bool| {
            Envelope::new(
                KIND_NET_STREAM_OPENED,
                serde_json::to_value(NetStreamOpened {
                    stream_id,
                    conn_id,
                    op_id,
                    applied_now,
                })
                .expect("NetStreamOpened serializes"),
            )
        };
        match req.op_id {
            None => {
                let stream_id = host.open_stream(req.conn_id).map_err(|e| e.to_string())?;
                send_frame(send, &build(stream_id, req.conn_id, None, true));
                Ok(())
            }
            Some(op) => {
                let outcome = self
                    .journal
                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
                        let stream_id = host.open_stream(req.conn_id)?;
                        host.record_stream_op(op, stream_id);
                        Ok(())
                    })
                    .map_err(|e| format!("net-stream-open failed: {e}"))?;
                let stream_id = host.stream_op_id(op).ok_or(
                    "stream-open op already applied but its stream is no longer held \
                     (broker restarted); retry with a fresh op_id",
                )?;
                let applied_now = outcome == Outcome::Applied;
                send_frame(send, &build(stream_id, req.conn_id, Some(op), applied_now));
                Ok(())
            }
        }
    }

    /// Write bytes to a held stream's send side (D4b). With an `op_id` the
    /// write is exactly-once through the broker-owned journal (a replayed
    /// chunk after a brain crash is not written twice — Spike #3 invariant D
    /// on the send path) and acked with `net-sent`; without one it is a direct
    /// write (no dedup, no ack), mirroring the PTY `input` contract.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    fn dispatch_net_stream_send(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: NetStreamSendReq = serde_json::from_value(env.payload)


Now: how dispatch derives per-stream `origin` (stream→conn→remote_id_hex), and the existing cross-node attach int test structure (for the shared helper). Read dispatch loop + attach test.


155	    }
156	}
157
158	/// Why a dispatched stream's worker ended — loud + typed, never silent.
159	#[derive(Debug)]
160	pub enum DispatchOutcome {
161	    /// The family's serve loop ran to its own outcome (rendered for the log).
162	    Served(String),
163	    /// The first line never arrived / didn't parse — dropped.
164	    Unknown,
165	    /// The worker failed (IO against broker or store).
166	    Failed(String),
167	}
168
169	/// The SPT_HOME-anchored paths the serve arms need. One value, cloned per
170	/// worker (policies/stores re-load from the canonical paths per request —
171	/// the access-gate stance, config edits take effect without a restart).
172	#[derive(Debug, Clone)]
173	pub struct DispatchPaths {
174	    /// The perch tree (WAN message funnel target).
175	    pub owlery: PathBuf,
176	    /// The staged-release cache dir ([`ReleaseCache::open`]).
177	    pub releases: PathBuf,
178	    /// Transient bundle/artifact scratch.
179	    pub scratch: PathBuf,
180	}
181
182	impl DispatchPaths {
183	    /// The production layout under [`perch::spt_home`].
184	    pub fn production() -> DispatchPaths {
185	        let home = perch::spt_home();
186	        DispatchPaths {
187	            owlery: perch::owlery_dir(),
188	            releases: home.join("releases"),
189	            scratch: home.join("scratch").join("net"),
190	        }
191	    }
192	}
193
194	/// Watch the broker's stream table and dispatch every **peer-initiated**
195	/// stream to a family worker, until `stop` is set. Blocks the calling thread
196	/// (the daemon spawns it on its own); workers detach (each ends with its
197	/// stream).
198	pub fn run_dispatch_loop(
199	    broker_name: &str,
200	    paths: &DispatchPaths,
201	    registry: Arc<RegistryHost>,
202	    stop: &AtomicBool,
203	    poll: Duration,
204	) -> io::Result<()> {
205	    let mut brain = connect(broker_name)?;
206	    let mut claimed: HashSet<u64> = HashSet::new();
207
208	    while !stop.load(Ordering::Relaxed) {
209	        let reply = brain.net_streams()?;
210	        for s in reply.streams {
211	            // Claim only peer-initiated rows with bytes (a QUIC stream is
212	            // invisible until its first write anyway — gotcha #6 — but the
213	            // next_seq guard also skips a row whose pump hasn't appended yet).
214	            if s.initiated_locally || s.next_seq == 0 || !claimed.insert(s.stream_id) {
215	                continue;
216	            }
217	            let name = broker_name.to_string();
218	            let origin = s.remote_id_hex.clone();
219	            let stream_id = s.stream_id;
220	            let p = paths.clone();
221	            let reg = Arc::clone(&registry);
222	            thread::spawn(move || {
223	                let outcome = worker(&name, stream_id, &origin, &p, &reg);
224	                match outcome {
225	                    DispatchOutcome::Served(_) => {}
226	                    other => eprintln!("DISPATCH:{stream_id}:{other:?}"),
227	                }
228	            });
229	        }
230	        thread::sleep(poll);
231	    }
232	    Ok(())
233	}
234
235	/// Connect a fresh brain IPC client (retrying through broker startup).
236	fn connect(broker_name: &str) -> io::Result<Brain> {
237	    let mut last = io::Error::other("broker never answered");
238	    for _ in 0..300 {
239	        match Brain::cold_start(broker_name, now_ms()) {
240	            Ok(b) => return Ok(b),
241	            Err(e) => last = e,
242	        }
243	        thread::sleep(Duration::from_millis(10));
244	    }
245	    Err(last)
246	}
247
248	fn now_ms() -> u64 {
249	    std::time::SystemTime::now()
250	        .duration_since(std::time::UNIX_EPOCH)
251	        .map(|d| d.as_millis() as u64)
252	        .unwrap_or(0)
253	}
254
255	/// Peek the stream's first NDJSON line on a throwaway brain connection.
256	fn peek_first_line(broker_name: &str, stream_id: u64) -> io::Result<Option<serde_json::Value>> {
257	    let mut brain = connect(broker_name)?;
258	    brain.net_stream_subscribe(stream_id, 0)?;
259	    let mut buf: Vec<u8> = Vec::new();
260	    loop {
261	        match brain.read_event()? {
262	            BrokerEvent::NetStreamData {
263	                stream_id: sid,
264	                bytes,
265	                ..
266	            } if sid == stream_id => {
267	                buf.extend_from_slice(&bytes);
268	                if let Some(nl) = buf.iter().position(|&b| b == b'\n') {
269	                    return Ok(serde_json::from_slice(&buf[..nl]).ok());

    }
}

/// Why a dispatched stream's worker ended — loud + typed, never silent.
#[derive(Debug)]
pub enum DispatchOutcome {
    /// The family's serve loop ran to its own outcome (rendered for the log).
    Served(String),
    /// The first line never arrived / didn't parse — dropped.
    Unknown,
    /// The worker failed (IO against broker or store).
    Failed(String),
}

/// The SPT_HOME-anchored paths the serve arms need. One value, cloned per
/// worker (policies/stores re-load from the canonical paths per request —
/// the access-gate stance, config edits take effect without a restart).
#[derive(Debug, Clone)]
pub struct DispatchPaths {
    /// The perch tree (WAN message funnel target).
    pub owlery: PathBuf,
    /// The staged-release cache dir ([`ReleaseCache::open`]).
    pub releases: PathBuf,
    /// Transient bundle/artifact scratch.
    pub scratch: PathBuf,
}

impl DispatchPaths {
    /// The production layout under [`perch::spt_home`].
    pub fn production() -> DispatchPaths {
        let home = perch::spt_home();
        DispatchPaths {
            owlery: perch::owlery_dir(),
            releases: home.join("releases"),
            scratch: home.join("scratch").join("net"),
        }
    }
}

/// Watch the broker's stream table and dispatch every **peer-initiated**
/// stream to a family worker, until `stop` is set. Blocks the calling thread
/// (the daemon spawns it on its own); workers detach (each ends with its
/// stream).
pub fn run_dispatch_loop(
    broker_name: &str,
    paths: &DispatchPaths,
    registry: Arc<RegistryHost>,
    stop: &AtomicBool,
    poll: Duration,
) -> io::Result<()> {
    let mut brain = connect(broker_name)?;
    let mut claimed: HashSet<u64> = HashSet::new();

    while !stop.load(Ordering::Relaxed) {
        let reply = brain.net_streams()?;
        for s in reply.streams {
            // Claim only peer-initiated rows with bytes (a QUIC stream is
            // invisible until its first write anyway — gotcha #6 — but the
            // next_seq guard also skips a row whose pump hasn't appended yet).
            if s.initiated_locally || s.next_seq == 0 || !claimed.insert(s.stream_id) {
                continue;
            }
            let name = broker_name.to_string();
            let origin = s.remote_id_hex.clone();
            let stream_id = s.stream_id;
            let p = paths.clone();
            let reg = Arc::clone(&registry);
            thread::spawn(move || {
                let outcome = worker(&name, stream_id, &origin, &p, &reg);
                match outcome {
                    DispatchOutcome::Served(_) => {}
                    other => eprintln!("DISPATCH:{stream_id}:{other:?}"),
                }
            });
        }
        thread::sleep(poll);
    }
    Ok(())
}

/// Connect a fresh brain IPC client (retrying through broker startup).
fn connect(broker_name: &str) -> io::Result<Brain> {
    let mut last = io::Error::other("broker never answered");
    for _ in 0..300 {
        match Brain::cold_start(broker_name, now_ms()) {
            Ok(b) => return Ok(b),
            Err(e) => last = e,
        }
        thread::sleep(Duration::from_millis(10));
    }
    Err(last)
}

fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// Peek the stream's first NDJSON line on a throwaway brain connection.
fn peek_first_line(broker_name: &str, stream_id: u64) -> io::Result<Option<serde_json::Value>> {
    let mut brain = connect(broker_name)?;
    brain.net_stream_subscribe(stream_id, 0)?;
    let mut buf: Vec<u8> = Vec::new();
    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData {
                stream_id: sid,
                bytes,
                ..
            } if sid == stream_id => {
                buf.extend_from_slice(&bytes);
                if let Some(nl) = buf.iter().position(|&b| b == b'\n') {
                    return Ok(serde_json::from_slice(&buf[..nl]).ok());


Key: dispatch.rs:218 derives `origin = s.remote_id_hex`; claims only `!initiated_locally` rows. So loopback `open_stream` must mint a PAIR: operator stream (initiated_locally=true) + peer stream (initiated_locally=false, `remote_id_hex = local_node_hex`), cross-wired by in-proc channels. Peer row → dispatch → serve_attach with own-node origin → access Allow.

Need the stream-send/write path + existing cross-node attach int test harness. Read both.


760	    }
761
762	    /// Write `bytes` to a held stream's send side; `finish` closes the send
763	    /// side after the write (the peer's read pump then sees a clean end).
764	    // [impl->REQ-DAEMON-2]
765	    pub fn send_stream(&self, stream_id: u64, bytes: &[u8], finish: bool) -> io::Result<()> {
766	        let entry = {
767	            let streams = self.shared.streams.lock().unwrap();
768	            Arc::clone(streams.get(&stream_id).ok_or_else(|| {
769	                io::Error::new(
770	                    io::ErrorKind::NotFound,
771	                    format!("no such stream {stream_id}"),
772	                )
773	            })?)
774	        };
775	        let bytes = bytes.to_vec();
776	        self.runtime.handle().block_on(async move {
777	            let mut send = entry.send.lock().await;
778	            if !bytes.is_empty() {
779	                send.write_all(&bytes)
780	                    .await
781	                    .map_err(|e| io::Error::other(e.to_string()))?;
782	            }
783	            if finish {
784	                send.finish().map_err(|e| io::Error::other(e.to_string()))?;
785	            }
786	            Ok(())
787	        })
788	    }
789
790	    /// Every held stream's info — the query a re-attaching brain uses to find
791	    /// streams (including peer-initiated ones that arrived while it was down).
792	    pub fn stream_infos(&self) -> Vec<NetStreamInfo> {
793	        let streams = self.shared.streams.lock().unwrap();
794	        streams
795	            .iter()
796	            .map(|(id, e)| {
797	                let log = e.log.lock().unwrap();
798	                NetStreamInfo {
799	                    stream_id: *id,
800	                    conn_id: e.conn_id,
801	                    remote_id_hex: e.remote_id_hex.clone(),
802	                    next_seq: log.next_seq,
803	                    finished: log.finished,
804	                    initiated_locally: e.initiated_locally,
805	                }
806	            })
807	            .collect()
808	    }
809
810	    /// Attach `sub` as a stream's read subscriber, replaying from `from_seq`
811	    /// (gapless resume — the PTY `subscribe` discipline on a QUIC stream).
812	    // [impl->REQ-DAEMON-2]
813	    pub(crate) fn subscribe_stream(
814	        &self,
815	        stream_id: u64,
816	        sub: SharedSend,
817	        from_seq: u64,
818	    ) -> io::Result<()> {
819	        let log = {
820	            let streams = self.shared.streams.lock().unwrap();
821	            let e = streams.get(&stream_id).ok_or_else(|| {
822	                io::Error::new(
823	                    io::ErrorKind::NotFound,
824	                    format!("no such stream {stream_id}"),
825	                )
826	            })?;
827	            Arc::clone(&e.log)
828	        };
829	        log.lock().unwrap().attach(sub, from_seq);
830	        Ok(())
831	    }
832
833	    /// Detach `sub` from a stream iff it is the current subscriber (brain
834	    /// disconnect cleanup; the stream + its ring persist).
835	    pub(crate) fn detach_stream_sub(&self, stream_id: u64, sub: &SharedSend) {
836	        let log = {
837	            let streams = self.shared.streams.lock().unwrap();
838	            streams.get(&stream_id).map(|e| Arc::clone(&e.log))
839	        };
840	        if let Some(log) = log {
841	            log.lock().unwrap().detach_if(sub);
842	        }
843	    }
844
845	    /// Attach `sub` as the presence subscriber, replaying liveness events from
846	    /// `from_seq` (D4c) — the stream `subscribe` discipline on the conn table.
847	    // [impl->REQ-EP-4]
848	    pub(crate) fn subscribe_presence(&self, sub: SharedSend, from_seq: u64) {
849	        self.shared.presence.lock().unwrap().attach(sub, from_seq);
850	    }
851
852	    /// Detach `sub` from presence iff it is the current subscriber (brain
853	    /// disconnect cleanup; the log + its ring persist).
854	    pub(crate) fn detach_presence_sub(&self, sub: &SharedSend) {
855	        self.shared.presence.lock().unwrap().detach_if(sub);
856	    }
857
858	    /// Gracefully close the endpoint: every held connection flushes its QUIC
859	    /// CLOSE so peers' closed-watchers fire promptly (a bare host drop kills
860	    /// the runtime first and leaves peers to an idle timeout).
861	    pub fn close(&self) {
862	        let endpoint = Arc::clone(&self.endpoint);
863	        self.runtime.handle().block_on(async move {
864	            endpoint.close().await;
865	        });
866	    }
867	}
868
869	#[cfg(test)]
870	mod tests {
871	    use super::*;
872
873	    fn hermetic(identity: &Identity) -> NetConfig {
874	        NetConfig {
875	            identity: Identity::from_seed(&identity.seed()),
876	            relay: RelayPolicy::Disabled,
877	            discovery: LocalDiscovery::Off,
878	            scope: BindScope::Loopback,
879	            membership: None,
880	        }
881	    }
882
883	    // [unit->REQ-DAEMON-2] the endpoint is constructed broker-side (inside the
884	    // host's dedicated runtime) with relays disabled, bound to the node's own
885	    // identity, with an empty conn table.
886	    #[test]
887	    fn host_binds_endpoint_to_node_identity() {
888	        let id = Identity::generate();
889	        let host = NetHost::start(hermetic(&id)).expect("host start");

    }

    /// Write `bytes` to a held stream's send side; `finish` closes the send
    /// side after the write (the peer's read pump then sees a clean end).
    // [impl->REQ-DAEMON-2]
    pub fn send_stream(&self, stream_id: u64, bytes: &[u8], finish: bool) -> io::Result<()> {
        let entry = {
            let streams = self.shared.streams.lock().unwrap();
            Arc::clone(streams.get(&stream_id).ok_or_else(|| {
                io::Error::new(
                    io::ErrorKind::NotFound,
                    format!("no such stream {stream_id}"),
                )
            })?)
        };
        let bytes = bytes.to_vec();
        self.runtime.handle().block_on(async move {
            let mut send = entry.send.lock().await;
            if !bytes.is_empty() {
                send.write_all(&bytes)
                    .await
                    .map_err(|e| io::Error::other(e.to_string()))?;
            }
            if finish {
                send.finish().map_err(|e| io::Error::other(e.to_string()))?;
            }
            Ok(())
        })
    }

    /// Every held stream's info — the query a re-attaching brain uses to find
    /// streams (including peer-initiated ones that arrived while it was down).
    pub fn stream_infos(&self) -> Vec<NetStreamInfo> {
        let streams = self.shared.streams.lock().unwrap();
        streams
            .iter()
            .map(|(id, e)| {
                let log = e.log.lock().unwrap();
                NetStreamInfo {
                    stream_id: *id,
                    conn_id: e.conn_id,
                    remote_id_hex: e.remote_id_hex.clone(),
                    next_seq: log.next_seq,
                    finished: log.finished,
                    initiated_locally: e.initiated_locally,
                }
            })
            .collect()
    }

    /// Attach `sub` as a stream's read subscriber, replaying from `from_seq`
    /// (gapless resume — the PTY `subscribe` discipline on a QUIC stream).
    // [impl->REQ-DAEMON-2]
    pub(crate) fn subscribe_stream(
        &self,
        stream_id: u64,
        sub: SharedSend,
        from_seq: u64,
    ) -> io::Result<()> {
        let log = {
            let streams = self.shared.streams.lock().unwrap();
            let e = streams.get(&stream_id).ok_or_else(|| {
                io::Error::new(
                    io::ErrorKind::NotFound,
                    format!("no such stream {stream_id}"),
                )
            })?;
            Arc::clone(&e.log)
        };
        log.lock().unwrap().attach(sub, from_seq);
        Ok(())
    }

    /// Detach `sub` from a stream iff it is the current subscriber (brain
    /// disconnect cleanup; the stream + its ring persist).
    pub(crate) fn detach_stream_sub(&self, stream_id: u64, sub: &SharedSend) {
        let log = {
            let streams = self.shared.streams.lock().unwrap();
            streams.get(&stream_id).map(|e| Arc::clone(&e.log))
        };
        if let Some(log) = log {
            log.lock().unwrap().detach_if(sub);
        }
    }

    /// Attach `sub` as the presence subscriber, replaying liveness events from
    /// `from_seq` (D4c) — the stream `subscribe` discipline on the conn table.
    // [impl->REQ-EP-4]
    pub(crate) fn subscribe_presence(&self, sub: SharedSend, from_seq: u64) {
        self.shared.presence.lock().unwrap().attach(sub, from_seq);
    }

    /// Detach `sub` from presence iff it is the current subscriber (brain
    /// disconnect cleanup; the log + its ring persist).
    pub(crate) fn detach_presence_sub(&self, sub: &SharedSend) {
        self.shared.presence.lock().unwrap().detach_if(sub);
    }

    /// Gracefully close the endpoint: every held connection flushes its QUIC
    /// CLOSE so peers' closed-watchers fire promptly (a bare host drop kills
    /// the runtime first and leaves peers to an idle timeout).
    pub fn close(&self) {
        let endpoint = Arc::clone(&self.endpoint);
        self.runtime.handle().block_on(async move {
            endpoint.close().await;
        });
    }
}

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

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

    // [unit->REQ-DAEMON-2] the endpoint is constructed broker-side (inside the
    // host's dedicated runtime) with relays disabled, bound to the node's own
    // identity, with an empty conn table.
    #[test]
    fn host_binds_endpoint_to_node_identity() {
        let id = Identity::generate();
        let host = NetHost::start(hermetic(&id)).expect("host start");


`send_stream` uses quinn `write_all`+`finish`; read pump uses quinn `read_chunk`. **Design crystallized:** `tokio::io::duplex(cap)` gives a full-duplex pair — one pair wires an entire operator↔peer stream. Split each half, feed the SAME `StreamLog`/subscribe/send_stream/read-pump machinery — only the leaf send/recv become enums (`Quic`/`Loopback`). That IS the one-pump invariant by construction.

`Conn::Loopback` = singleton, no iroh handle, `remote_id_hex = own node hex`, no closed-watcher/accept-loop (streams minted eagerly in pairs at `open_stream`).

Now the existing cross-node attach int test → shape the shared helper (T1.5.4).