<memformat>
<?xml version="1.0"?>
<memformat version="1">
<!-- Changelog: v1 - initial seed -->
<topics>
  <topic name="events">Notable or unusual events and requests</topic>
  <topic name="interactions">Agent interactions -- who, roles, contributions</topic>
  <topic name="reflections">Thoughts on recent work -- what went well, what was tricky</topic>
  <topic name="ideas">Ideas to raise with the user</topic>
  <topic name="improvements">Further improvement ideas for recent problem spaces</topic>
  <topic name="timeline">What happened when, timing, sequencing</topic>
  <topic name="blockers">Current blockers or concerns</topic>
  <topic name="focus">Current focus and immediate next steps</topic>
</topics>
</memformat>
</memformat>

<current machine="HFENDULEAM" project="spt-core" branch="m11-shell-substrate" head_sha="fa9aa6226966645f7b2b32e01e8cab69e430fabc" head_subject="M11-W3 T3.2b: clear the tunnel entry on link-break (REQ-SHELL-4)" commits_since="0" commits_unpulled="0"/>
Generation 46. Pulse 2026-06-16T03:29:13Z. Head 88a9548. **W3 IN DESIGN-GATE. T3.1 DONE.**

## W3 Status: DESIGN-GATE SENT
T3.1 @88a9548 committed. `[shell.tunnel]` manifest opt-in (enable + protocol label), schema regen'd, REQ-SHELL-4 evidenced [doc+impl+unit, -int expected]. Pre-flight all green: clippy workspace, traceable, xtask check. W5 scope-add logged @8efaf76 (M11-PLAN T3.2, REQ-DOCS-6 tie, no new REQ code).

Design-gate shape sent to doyle **twice**: initial substrate shape + refined with key finding: shell-link is **request/reply per-action** (no held link-conn). Tunnel anchor sharpens to same-node loopback: open at shell-online (`shellhost` online/`bind_shell_by_token` @277), close at `close_shell` (@370), loopback conn via nethost singleton (@819). doyle ack'd ("log W5, don't interrupt W3") — actively engaged.

**Awaiting doyle gate on:**
- **Anchor**: same-node-loopback confirm (C2: on-LAN/same-node mock only, no cross-WAN)
- **Surface**: Q2 (distinct `spt shell tunnel` CLI vs fold into raw-frame?), Q3 (shell-binary side via new `api` verb vs child-relay poll?)

Grounding complete. T3.2/T3.3 ready to execute instantly doyle gates.

## Tunnel Mechanics (Settled)
- Opaque QUIC stream pair (NOT typed channel — taxonomy must not reinterpret bytes)
- First consumer = usbip URB (raw wire traffic)
- Uses D4 net-stream substrate: `brain.net_open_stream` / `net_stream_subscribe` / `net_stream_send` (same machinery shell-link rides, kept open + link-bound)
- Link-break closes tunnel
- Reliable-ordered → on-LAN only (no cross-WAN loss), documented posture
- Same-node mock E2E only (no real usbip)
- Manifest opt-in (REQ-SHELL-4 activated)
- Not enveloped, not MAC-framed, not spooled

## Next
doyle's gate on anchor + surface drives T3.2/T3.3 (transport + API shape impl). Hold at design boundary rather than race.<project-context-resolved name="spt-core"/>
T3.1 done @88a9548. W5 scope-add logged @8efaf76 in M11-PLAN T5.2. Design-gate sent to doyle (both anchor + surface shape refinement); doyle actively engaged, awaiting thumbs-up on same-node-loopback anchor + surface design before T3.2/T3.3 execute. Shell-link grounding complete: request/reply per-action, no held link-conn — sharpens tunnel anchor design. Next = doyle gate gates T3.2/T3.3 build.


Psyche at gen 46 — knows design-gate SENT/awaiting, but NOT the GO + answers + landed T3.2a/b + bridge next-step. Write the delta:


Write

<live-context>
**W3 DESIGN-GATE = GO (doyle PASS, supersedes "awaiting").** All three Qs + two reinforcements locked. Build underway — two more commits landed past T3.1.

doyle gate answers (was awaiting these at gen 46):
- Q1 → SINGLE tunnel per link (multiplex deferred, not in documented design).
- Q2 → dedicated `spt shell tunnel <id>` (owner write-in/subscribe-out) + shell-side `api tunnel`. NOT folded into raw-frame (taxonomy must not reinterpret opaque bytes).
- Q3 → NEW raw-duplex `api tunnel` verb, NOT child-relay `api poll` (poll is the durable envelope/spool drain; conflating reinterprets the opaque path).
- R1 → the online-gen/link-token stamp on the held stream_id is THE crash-proof leg; close_shell drop = mere cleanup. Prove open→break→relink-fresh-gen→old stream_id inert in T3.3.
- R2 → W3 same-node loopback proves MECHANISM only; cross-node-on-LAN real-Iroh wire = a W5 [twohost] rung beside the drive rung. No loopback overclaim; docs read "mechanism proven same-node; cross-node-on-LAN is W5 [twohost]".
- DOC → add tunnel to ADR-0020's <EVENT>-exemption enumeration (light, not a new ADR).

LANDED since gen 46 (branch m11-shell-substrate, not pushed — rides next push, CI fires on PR only):
- @6462f16 plan gate-fold (T3.x/T5.x updated with all gate answers + R1/R2 carries)
- @692f810 T3.2a: broker TunnelHub registry + control channel (crates/spt-daemon/src/tunnelhub.rs) — per-shell owner_stream+shell_stream+token, token-gated resolve = R1, 5 unit tests green
- @fa9aa62 T3.2b: close_shell fires tunnel_clear (same link-break seam as drive_clear)
REQ-SHELL-4 active [doc,impl,unit]; clippy workspace + traceable EXIT 0 + xtask check all green at each commit.

IMMEDIATE NEXT: T3.2 open-hook + the bridge (the real remaining work). See project-context for the architecture. Then T3.3 mock E2E → activate int. Context was high → checkpointed (memory m11-w3-progress written, MEMORY.md pointer added). Did NOT self-clear (API-blocked). Honest state: clean checkpoint, 4 green commits, no broken tree.
</live-context>
<project-context>
M11-W3 tunnel (REQ-SHELL-4) — the remaining build is the OPEN-hook + a QUIC-stream↔api-tunnel BRIDGE, and it has a real architectural shape worth recording:

- OPEN hook site = `api bind-shell` (crates/spt/src/api/mod.rs:327, the shell-online call). If manifest tunnel-enabled, open the loopback stream + register in TunnelHub.
- THE HARD PART: the inbound dispatcher (crates/spt-daemon/src/dispatch.rs:206-214) claims peer-initiated stream rows by FIRST-RECORD CONTENT demux (kind=request+owner+shell_ref → serve_shell_link). A bytes-less peer row isn't claimed. So the tunnel needs (1) a NEW first-record classification (kind=tunnel) in the demux table, and (2) a `serve_tunnel` relay loop that BRIDGES the held broker-owned QUIC stream to the EXTERNAL `api tunnel` surface — because the mock shell-side is an api REST caller, NOT an internal serve loop. That bridge = broker-buffered in/out byte queues per shell (api tunnel read drains in-buf fed by the stream's recv; api tunnel write pushes out-buf the serve loop sends on the stream). serve_attach (crates/spt-daemon/src/attach.rs) is the closest "bridge a held stream to a surface" template.
- Then the two surfaces: `spt shell tunnel` CLI (mirror cli.rs shell-drive ~5660) + `api tunnel` verb (mirror api drive-poll). Any CLI add → `cargo run -p xtask -- gen` + NO milestone/REQ codes in clap help.
- Substrate (settled): D4 net-stream — NetHost::open_stream(conn_id) broker-owned bidi QUIC stream surviving brain restart (ADR-0004 B); loopback conn cross-wires operator+peer rows in-process (the same-node mock vehicle). Shell-link itself is request/reply per-action — no held link-conn — so the tunnel is a genuinely NEW long-lived held-stream lifecycle.
- The TunnelHub (broker state, like DriveHub) holds ONLY identifiers (the two stream ids + the link-token stamp); bytes never touch it. Token-gated resolve is the no-stale-stream R1 guarantee.

Full resume detail + pre-flight recipe in memory m11-w3-progress. After W3: W4 Gateway-owner capstone (REQ-SHELL-5 E2E), W5 rig+docs (tunnel + drive [twohost] rungs, ADR-0020 cross-ref, subnet how-to topic).
</project-context>


File created successfully at: C:\Users\decid\Documents\projects\spt-core\.claude\todlando-commune.md (file state is current in your context — no need to Read it back)

<live-context>
**W3 DESIGN-GATE = GO (doyle PASS, supersedes "awaiting").** All three Qs + two reinforcements locked. Build underway — two more commits landed past T3.1.

doyle gate answers (was awaiting these at gen 46):
- Q1 → SINGLE tunnel per link (multiplex deferred, not in documented design).
- Q2 → dedicated `spt shell tunnel <id>` (owner write-in/subscribe-out) + shell-side `api tunnel`. NOT folded into raw-frame (taxonomy must not reinterpret opaque bytes).
- Q3 → NEW raw-duplex `api tunnel` verb, NOT child-relay `api poll` (poll is the durable envelope/spool drain; conflating reinterprets the opaque path).
- R1 → the online-gen/link-token stamp on the held stream_id is THE crash-proof leg; close_shell drop = mere cleanup. Prove open→break→relink-fresh-gen→old stream_id inert in T3.3.
- R2 → W3 same-node loopback proves MECHANISM only; cross-node-on-LAN real-Iroh wire = a W5 [twohost] rung beside the drive rung. No loopback overclaim; docs read "mechanism proven same-node; cross-node-on-LAN is W5 [twohost]".
- DOC → add tunnel to ADR-0020's <EVENT>-exemption enumeration (light, not a new ADR).

LANDED since gen 46 (branch m11-shell-substrate, not pushed — rides next push, CI fires on PR only):
- @6462f16 plan gate-fold (T3.x/T5.x updated with all gate answers + R1/R2 carries)
- @692f810 T3.2a: broker TunnelHub registry + control channel (crates/spt-daemon/src/tunnelhub.rs) — per-shell owner_stream+shell_stream+token, token-gated resolve = R1, 5 unit tests green
- @fa9aa62 T3.2b: close_shell fires tunnel_clear (same link-break seam as drive_clear)
REQ-SHELL-4 active [doc,impl,unit]; clippy workspace + traceable EXIT 0 + xtask check all green at each commit.

IMMEDIATE NEXT: T3.2 open-hook + the bridge (the real remaining work). See project-context for the architecture. Then T3.3 mock E2E → activate int. Context was high → checkpointed (memory m11-w3-progress written, MEMORY.md pointer added). Did NOT self-clear (API-blocked). Honest state: clean checkpoint, 4 green commits, no broken tree.
</live-context>
<project-context>
M11-W3 tunnel (REQ-SHELL-4) — the remaining build is the OPEN-hook + a QUIC-stream↔api-tunnel BRIDGE, and it has a real architectural shape worth recording:

- OPEN hook site = `api bind-shell` (crates/spt/src/api/mod.rs:327, the shell-online call). If manifest tunnel-enabled, open the loopback stream + register in TunnelHub.
- THE HARD PART: the inbound dispatcher (crates/spt-daemon/src/dispatch.rs:206-214) claims peer-initiated stream rows by FIRST-RECORD CONTENT demux (kind=request+owner+shell_ref → serve_shell_link). A bytes-less peer row isn't claimed. So the tunnel needs (1) a NEW first-record classification (kind=tunnel) in the demux table, and (2) a `serve_tunnel` relay loop that BRIDGES the held broker-owned QUIC stream to the EXTERNAL `api tunnel` surface — because the mock shell-side is an api REST caller, NOT an internal serve loop. That bridge = broker-buffered in/out byte queues per shell (api tunnel read drains in-buf fed by the stream's recv; api tunnel write pushes out-buf the serve loop sends on the stream). serve_attach (crates/spt-daemon/src/attach.rs) is the closest "bridge a held stream to a surface" template.
- Then the two surfaces: `spt shell tunnel` CLI (mirror cli.rs shell-drive ~5660) + `api tunnel` verb (mirror api drive-poll). Any CLI add → `cargo run -p xtask -- gen` + NO milestone/REQ codes in clap help.
- Substrate (settled): D4 net-stream — NetHost::open_stream(conn_id) broker-owned bidi QUIC stream surviving brain restart (ADR-0004 B); loopback conn cross-wires operator+peer rows in-process (the same-node mock vehicle). Shell-link itself is request/reply per-action — no held link-conn — so the tunnel is a genuinely NEW long-lived held-stream lifecycle.
- The TunnelHub (broker state, like DriveHub) holds ONLY identifiers (the two stream ids + the link-token stamp); bytes never touch it. Token-gated resolve is the no-stale-stream R1 guarantee.

Full resume detail + pre-flight recipe in memory m11-w3-progress. After W3: W4 Gateway-owner capstone (REQ-SHELL-5 E2E), W5 rig+docs (tunnel + drive [twohost] rungs, ADR-0020 cross-ref, subnet how-to topic).
</project-context>


Commune delta written (Self ingests on next poll). State fully preserved.

**W3 checkpoint — clean, 4 green commits on `m11-shell-substrate`:**

| commit | what |
|---|---|
| `88a9548` | T3.1 `[shell.tunnel]` manifest opt-in (enable + protocol label, validate) |
| `6462f16` | plan: fold doyle design-gate (Q1/Q2/Q3 + R1/R2 + ADR-0020 cross-ref) |
| `692f810` | T3.2a broker `TunnelHub` registry + control channel (5 unit tests) |
| `fa9aa62` | T3.2b `close_shell` → `tunnel_clear` (link-break seam) |

**doyle design-gate = GO.** Substrate risk resolved (D4 net-stream fits — broker-owned QUIC streams, loopback cross-wires the pair). All Qs locked: single tunnel/link · dedicated `spt shell tunnel` + `api tunnel` · new raw verb not poll · online-gen stamp = crash-proof R1 · cross-node-on-LAN = W5 [twohost] rung (no loopback overclaim). REQ-SHELL-4 active `[doc,impl,unit]`; clippy workspace + traceable EXIT 0 + xtask green at every commit. Not pushed (rides next push; CI fires on PR).

**Remaining T3.2 = the bridge** (the real work): dispatch claims peer rows by first-record demux → tunnel needs a new `kind=tunnel` classification + a `serve_tunnel` relay loop bridging the held QUIC stream to the **external** `api tunnel` surface (broker-buffered queues; mock shell-side is an api caller, not internal serve). Then `spt shell tunnel` CLI + `api tunnel` verb → T3.3 same-node mock E2E → activate int.

State checkpointed (memory `m11-w3-progress` + commune to psyche). Stopped at green, no broken tree. Bridge build is next — say go and I execute it against the recorded plan.


proceed


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


Proceed. Reconsider the bridge — a simpler broker-side design may avoid the whole dispatch/serve_tunnel relay: the broker owns the NetHost + the StreamLog already buffers reads. If the tunnel-control socket can reach the NetHost, it serves `send`/`recv` directly. Check whether the broker exposes NetHost to its control threads.


44:use spt_net::net::attach::AttachIntent;
45:use spt_net::net::endpoint::EndpointAddr;
65:use crate::nethost::{NetHost, NET_EFFECT_SESSION};
523:    net: std::sync::OnceLock<NetHost>,
553:        net: Option<NetHost>,
567:        net: Option<NetHost>,
580:            net: net_cell,
601:    pub fn attach_net(&self, host: NetHost) -> bool {
602:        self.net.set(host).is_ok()
612:    pub fn net(&self) -> Option<&NetHost> {
613:        self.net.get()
663:    pub fn net_conn_count(&self) -> usize {
664:        self.net.get().map(|h| h.conn_count() as usize).unwrap_or(0)
816:        if let Some(host) = self.net.get() {
1057:        let reply = match self.net.get() {
1248:        let host = self.net.get().ok_or("net disabled")?;
1263:                let stream_id = host.open_stream(req.conn_id).map_err(|e| e.to_string())?;
1271:                        let stream_id = host.open_stream(req.conn_id)?;
1298:        let host = self.net.get().ok_or("net disabled")?;
1301:                .send_stream(req.stream_id, &bytes, req.finish)
1307:                        host.send_stream(req.stream_id, &bytes, req.finish)
1328:        let host = self.net.get().ok_or("net disabled")?;
1350:        let host = self.net.get().ok_or("net disabled")?;
1367:        let host = self.net.get().ok_or("net disabled")?;


if you encounter any roadblocks or finish this task, lean on doyle to resolve or greenlight the next task


Broker holds `net: OnceLock<NetHost>`, exposes `net()`. A broker thread with `Arc<Broker>` reaches NetHost directly → the simplified bridge is feasible. Check the send/subscribe handlers + whether there's a synchronous StreamLog read (for `tunnel_recv`).


1290	    /// on the send path) and acked with `net-sent`; without one it is a direct
1291	    /// write (no dedup, no ack), mirroring the PTY `input` contract.
1292	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
1293	    fn dispatch_net_stream_send(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
1294	        let req: NetStreamSendReq = serde_json::from_value(env.payload)
1295	            .map_err(|e| format!("bad net-stream-send payload: {e}"))?;
1296	        let bytes =
1297	            decode_bytes(&req.data_b64).map_err(|e| format!("bad net-stream-send base64: {e}"))?;
1298	        let host = self.net.get().ok_or("net disabled")?;
1299	        match req.op_id {
1300	            None => host
1301	                .send_stream(req.stream_id, &bytes, req.finish)
1302	                .map_err(|e| e.to_string()),
1303	            Some(op) => {
1304	                let outcome = self
1305	                    .journal
1306	                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
1307	                        host.send_stream(req.stream_id, &bytes, req.finish)
1308	                    })
1309	                    .map_err(|e| format!("net-stream-send failed: {e}"))?;
1310	                let frame = Envelope::new(
1311	                    KIND_NET_SENT,
1312	                    serde_json::to_value(NetSent {
1313	                        stream_id: req.stream_id,
1314	                        op_id: op,
1315	                        applied_now: outcome == Outcome::Applied,
1316	                    })
1317	                    .expect("NetSent serializes"),
1318	                );
1319	                send_frame(send, &frame);
1320	                Ok(())
1321	            }
1322	        }
1323	    }
1324
1325	    /// List every stream the broker holds (D4b) — the query a re-attaching
1326	    /// brain uses to find streams that arrived while it was down.
1327	    fn dispatch_net_streams(&self, send: &SharedSend) -> Result<(), String> {
1328	        let host = self.net.get().ok_or("net disabled")?;
1329	        let frame = Envelope::new(
1330	            KIND_NET_STREAMS_REPLY,
1331	            serde_json::to_value(NetStreamsReply {
1332	                streams: host.stream_infos(),
1333	            })
1334	            .expect("NetStreamsReply serializes"),
1335	        );
1336	        send_frame(send, &frame);
1337	        Ok(())
1338	    }
1339
1340	    /// Re-attach a brain to a stream's buffered reads from a cursor (D4b) —
1341	    /// the gapless-resume `subscribe` discipline on a QUIC stream.
1342	    // [impl->REQ-DAEMON-2]
1343	    fn dispatch_net_stream_subscribe(
1344	        &self,
1345	        env: Envelope,
1346	        send: &SharedSend,
1347	    ) -> Result<u64, String> {
1348	        let req: NetStreamSubscribeReq = serde_json::from_value(env.payload)
1349	            .map_err(|e| format!("bad net-stream-subscribe payload: {e}"))?;
1350	        let host = self.net.get().ok_or("net disabled")?;
1351	        host.subscribe_stream(req.stream_id, Arc::clone(send), req.from_seq)
1352	            .map_err(|e| e.to_string())?;
1353	        Ok(req.stream_id)
1354	    }
1355
1356	    /// Attach a brain to the conn-liveness event log from a cursor (D4c) — the
1357	    /// PresenceChannel broker seam: gapless resume over connection presence,
1358	    /// same discipline as a stream subscribe.
1359	    // [impl->REQ-EP-4]
1360	    fn dispatch_net_presence_subscribe(
1361	        &self,
1362	        env: Envelope,
1363	        send: &SharedSend,
1364	    ) -> Result<(), String> {
1365	        let req: NetPresenceSubscribeReq = serde_json::from_value(env.payload)
1366	            .map_err(|e| format!("bad net-presence-subscribe payload: {e}"))?;
1367	        let host = self.net.get().ok_or("net disabled")?;
1368	        host.subscribe_presence(Arc::clone(send), req.from_seq);
1369	        Ok(())
1370	    }
1371
1372	    /// Terminate a session's child. The exit waiter emits the `exit` frame and
1373	    /// reaps the session once the child is dead.
1374	    fn dispatch_kill(&self, env: Envelope) -> Result<(), String> {
1375	        let req: KillReq =
1376	            serde_json::from_value(env.payload).map_err(|e| format!("bad kill payload: {e}"))?;
1377	        let sessions = self.sessions.lock().unwrap();
1378	        let h = sessions
1379	            .get(&req.session_id)
1380	            .ok_or_else(|| format!("no such session {}", req.session_id))?;
1381	        h.session.kill().map_err(|e| format!("kill failed: {e}"))
1382	    }
1383	}
1384
1385	/// The default broker-owned effect-journal path: `<spt_home>/daemon-effects.log`.
1386	pub(crate) fn default_journal_path() -> std::path::PathBuf {
1387	    spt_store::perch::spt_home().join("daemon-effects.log")
1388	}
1389
1390	/// Write a frame to the brain, ignoring a broken-pipe failure (brain gone).
1391	fn send_frame(send: &SharedSend, frame: &Envelope) {
1392	    if let Ok(mut s) = send.lock() {
1393	        let _ = write_frame(&mut *s, frame);
1394	    }
1395	}
1396
1397	/// Send an `error` event to the brain (best-effort).
1398	fn send_error(send: &SharedSend, message: &str) {
1399	    let frame = Envelope::new(

    /// 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)
            .map_err(|e| format!("bad net-stream-send payload: {e}"))?;
        let bytes =
            decode_bytes(&req.data_b64).map_err(|e| format!("bad net-stream-send base64: {e}"))?;
        let host = self.net.get().ok_or("net disabled")?;
        match req.op_id {
            None => host
                .send_stream(req.stream_id, &bytes, req.finish)
                .map_err(|e| e.to_string()),
            Some(op) => {
                let outcome = self
                    .journal
                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
                        host.send_stream(req.stream_id, &bytes, req.finish)
                    })
                    .map_err(|e| format!("net-stream-send failed: {e}"))?;
                let frame = Envelope::new(
                    KIND_NET_SENT,
                    serde_json::to_value(NetSent {
                        stream_id: req.stream_id,
                        op_id: op,
                        applied_now: outcome == Outcome::Applied,
                    })
                    .expect("NetSent serializes"),
                );
                send_frame(send, &frame);
                Ok(())
            }
        }
    }

    /// List every stream the broker holds (D4b) — the query a re-attaching
    /// brain uses to find streams that arrived while it was down.
    fn dispatch_net_streams(&self, send: &SharedSend) -> Result<(), String> {
        let host = self.net.get().ok_or("net disabled")?;
        let frame = Envelope::new(
            KIND_NET_STREAMS_REPLY,
            serde_json::to_value(NetStreamsReply {
                streams: host.stream_infos(),
            })
            .expect("NetStreamsReply serializes"),
        );
        send_frame(send, &frame);
        Ok(())
    }

    /// Re-attach a brain to a stream's buffered reads from a cursor (D4b) —
    /// the gapless-resume `subscribe` discipline on a QUIC stream.
    // [impl->REQ-DAEMON-2]
    fn dispatch_net_stream_subscribe(
        &self,
        env: Envelope,
        send: &SharedSend,
    ) -> Result<u64, String> {
        let req: NetStreamSubscribeReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad net-stream-subscribe payload: {e}"))?;
        let host = self.net.get().ok_or("net disabled")?;
        host.subscribe_stream(req.stream_id, Arc::clone(send), req.from_seq)
            .map_err(|e| e.to_string())?;
        Ok(req.stream_id)
    }

    /// Attach a brain to the conn-liveness event log from a cursor (D4c) — the
    /// PresenceChannel broker seam: gapless resume over connection presence,
    /// same discipline as a stream subscribe.
    // [impl->REQ-EP-4]
    fn dispatch_net_presence_subscribe(
        &self,
        env: Envelope,
        send: &SharedSend,
    ) -> Result<(), String> {
        let req: NetPresenceSubscribeReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad net-presence-subscribe payload: {e}"))?;
        let host = self.net.get().ok_or("net disabled")?;
        host.subscribe_presence(Arc::clone(send), req.from_seq);
        Ok(())
    }

    /// Terminate a session's child. The exit waiter emits the `exit` frame and
    /// reaps the session once the child is dead.
    fn dispatch_kill(&self, env: Envelope) -> Result<(), String> {
        let req: KillReq =
            serde_json::from_value(env.payload).map_err(|e| format!("bad kill payload: {e}"))?;
        let sessions = self.sessions.lock().unwrap();
        let h = sessions
            .get(&req.session_id)
            .ok_or_else(|| format!("no such session {}", req.session_id))?;
        h.session.kill().map_err(|e| format!("kill failed: {e}"))
    }
}

/// The default broker-owned effect-journal path: `<spt_home>/daemon-effects.log`.
pub(crate) fn default_journal_path() -> std::path::PathBuf {
    spt_store::perch::spt_home().join("daemon-effects.log")
}

/// Write a frame to the brain, ignoring a broken-pipe failure (brain gone).
fn send_frame(send: &SharedSend, frame: &Envelope) {
    if let Ok(mut s) = send.lock() {
        let _ = write_frame(&mut *s, frame);
    }
}

/// Send an `error` event to the brain (best-effort).
fn send_error(send: &SharedSend, message: &str) {
    let frame = Envelope::new(


Prompt: if you encounter any roadblocks or finish this task, lean on doyle to resolve or greenlight the next task


Subscribe is push-based to a brain `SharedSend`. For a broker-side poll bridge I need a synchronous StreamLog drain. Check the StreamLog + whether NetHost has a sync read accessor.


Grep

struct StreamLog|fn subscribe_stream|next_seq|chunks|fn .*log|fn read|impl StreamLog|finished|push_chunk|fn append


80:/// Default bounded stream-read ring depth (chunks) — same sizing rationale as
82:/// unreplayed chunks in practice; bounded so an unsubscribed stream can't
126:/// subscriber send happens under the enclosing `Mutex`, so live chunks and a
131:struct StreamLog {
133:    next_seq: u64,
135:    cap_chunks: usize,
137:    /// Read side finished cleanly (peer called finish / conn ended).
138:    finished: bool,
141:impl StreamLog {
142:    fn new(stream_id: u64, cap_chunks: usize) -> Self {
145:            next_seq: 0,
147:            cap_chunks,
149:            finished: false,
155:    fn append(&mut self, bytes: &[u8]) {
156:        let seq = self.next_seq;
157:        self.next_seq += 1;
159:        while self.ring.len() > self.cap_chunks {
170:    /// Mark the read side finished and tell the subscriber (with the final
173:        self.finished = true;
175:            let frame = net_stream_eof_envelope(self.stream_id, self.next_seq);
183:    /// `seq >= from_seq` (then eof, if already finished) before live streaming.
192:        if self.finished {
193:            let frame = net_stream_eof_envelope(self.stream_id, self.next_seq);
219:    next_seq: u64,
228:            next_seq: 0,
240:    fn append(&mut self, kind: &str, conn_id: u64, remote_id_hex: &str) {
242:            seq: self.next_seq,
247:        self.next_seq += 1;
461:    // and a torn end (conn lost) both finish the log; the buffered chunks stay
972:                    next_seq: log.next_seq,
973:                    finished: log.finished,
983:    pub(crate) fn subscribe_stream(
1149:                .any(|s| s.stream_id == peer_id && s.next_seq > 0)


120	    proven_subnets: HashSet<String>,
121	}
122
123	/// A bounded, sequenced read log for one QUIC stream — the PTY `OutputLog`
124	/// discipline applied to the network (D4b): the stream's single read pump is
125	/// the only producer; a brain connection attaches as the subscriber; every
126	/// subscriber send happens under the enclosing `Mutex`, so live chunks and a
127	/// replay can never interleave out of `seq` order. Chunks produced while no
128	/// brain is attached sit in the ring and replay on the next subscribe — a
129	/// brain restart loses nothing (Spike #3 invariant B/D).
130	// [impl->REQ-DAEMON-2]
131	struct StreamLog {
132	    stream_id: u64,
133	    next_seq: u64,
134	    ring: VecDeque<(u64, Vec<u8>)>,
135	    cap_chunks: usize,
136	    subscriber: Option<SharedSend>,
137	    /// Read side finished cleanly (peer called finish / conn ended).
138	    finished: bool,
139	}
140
141	impl StreamLog {
142	    fn new(stream_id: u64, cap_chunks: usize) -> Self {
143	        StreamLog {
144	            stream_id,
145	            next_seq: 0,
146	            ring: VecDeque::new(),
147	            cap_chunks,
148	            subscriber: None,
149	            finished: false,
150	        }
151	    }
152
153	    /// Append a freshly-read chunk: assign the next `seq`, retain it in the
154	    /// bounded ring, live-send to the current subscriber. Read-pump only.
155	    fn append(&mut self, bytes: &[u8]) {
156	        let seq = self.next_seq;
157	        self.next_seq += 1;
158	        self.ring.push_back((seq, bytes.to_vec()));
159	        while self.ring.len() > self.cap_chunks {
160	            self.ring.pop_front();
161	        }
162	        if let Some(sub) = &self.subscriber {
163	            let frame = net_stream_data_envelope(self.stream_id, seq, bytes);
164	            if let Ok(mut s) = sub.lock() {
165	                let _ = write_frame(&mut *s, &frame);
166	            }
167	        }
168	    }
169
170	    /// Mark the read side finished and tell the subscriber (with the final
171	    /// cursor, so it can verify completeness).
172	    fn finish(&mut self) {
173	        self.finished = true;
174	        if let Some(sub) = &self.subscriber {
175	            let frame = net_stream_eof_envelope(self.stream_id, self.next_seq);
176	            if let Ok(mut s) = sub.lock() {
177	                let _ = write_frame(&mut *s, &frame);
178	            }
179	        }
180	    }
181
182	    /// Attach `sub` as the subscriber, replaying every retained chunk with
183	    /// `seq >= from_seq` (then eof, if already finished) before live streaming.
184	    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
185	        self.subscriber = Some(Arc::clone(&sub));
186	        for (seq, bytes) in self.ring.iter().filter(|(s, _)| *s >= from_seq) {
187	            let frame = net_stream_data_envelope(self.stream_id, *seq, bytes);
188	            if let Ok(mut s) = sub.lock() {
189	                let _ = write_frame(&mut *s, &frame);
190	            }
191	        }
192	        if self.finished {
193	            let frame = net_stream_eof_envelope(self.stream_id, self.next_seq);
194	            if let Ok(mut s) = sub.lock() {
195	                let _ = write_frame(&mut *s, &frame);
196	            }
197	        }
198	    }
199
200	    /// Detach `sub` iff it is the current subscriber (a late disconnect must
201	    /// not unsubscribe a newer brain).
202	    fn detach_if(&mut self, sub: &SharedSend) {
203	        if let Some(cur) = &self.subscriber {
204	            if Arc::ptr_eq(cur, sub) {
205	                self.subscriber = None;
206	            }
207	        }
208	    }
209	}
210
211	/// A bounded, sequenced log of connection liveness events — the broker-owned
212	/// PresenceChannel seam (D4c, REQ-EP-4): the StreamLog ring/cursor discipline
213	/// applied to the conn table itself. `register_conn` appends `connected`; each
214	/// connection's closed-watcher appends `disconnected` after removing the row.
215	/// A brain subscribes with a cursor and replays events produced while it was
216	/// down, so presence survives a brain restart exactly like stream reads do.
217	// [impl->REQ-EP-4]
218	struct PresenceLog {
219	    next_seq: u64,
220	    ring: VecDeque<NetPresenceEvent>,
221	    cap_events: usize,
222	    subscriber: Option<SharedSend>,
223	}
224
225	impl PresenceLog {
226	    fn new(cap_events: usize) -> Self {
227	        PresenceLog {
228	            next_seq: 0,
229	            ring: VecDeque::new(),
230	            cap_events,
231	            subscriber: None,
232	        }
233	    }
234
235	    /// Append a liveness event: assign the next `seq`, retain it in the bounded
236	    /// ring, live-send to the current subscriber. Producers: `register_conn`
237	    /// (connected) and the per-conn closed-watchers (disconnected) — every
238	    /// append serializes on the enclosing `Mutex`, so subscriber frames arrive
239	    /// in strict `seq` order.
240	    fn append(&mut self, kind: &str, conn_id: u64, remote_id_hex: &str) {
241	        let ev = NetPresenceEvent {
242	            seq: self.next_seq,
243	            kind: kind.to_string(),
244	            conn_id,
245	            remote_id_hex: remote_id_hex.to_string(),
246	        };
247	        self.next_seq += 1;
248	        self.ring.push_back(ev.clone());
249	        while self.ring.len() > self.cap_events {
250	            self.ring.pop_front();
251	        }
252	        if let Some(sub) = &self.subscriber {
253	            let frame = net_presence_event_envelope(&ev);
254	            if let Ok(mut s) = sub.lock() {
255	                let _ = write_frame(&mut *s, &frame);
256	            }
257	        }
258	    }
259
260	    /// Attach `sub` as the subscriber, replaying every retained event with
261	    /// `seq >= from_seq` before live streaming (the gapless-resume attach).
262	    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
263	        self.subscriber = Some(Arc::clone(&sub));
264	        for ev in self.ring.iter().filter(|e| e.seq >= from_seq) {
265	            let frame = net_presence_event_envelope(ev);
266	            if let Ok(mut s) = sub.lock() {
267	                let _ = write_frame(&mut *s, &frame);
268	            }
269	        }
270	    }
271
272	    /// Detach `sub` iff it is the current subscriber (a late disconnect must
273	    /// not unsubscribe a newer brain).
274	    fn detach_if(&mut self, sub: &SharedSend) {
275	        if let Some(cur) = &self.subscriber {
276	            if Arc::ptr_eq(cur, sub) {
277	                self.subscriber = None;
278	            }
279	        }
280	    }
281	}
282
283	/// A stream's send half — the same two transports as [`ConnKind`]. The
284	/// `serve_attach`/operator pump and the `StreamLog` read discipline are
285	/// IDENTICAL across both; only this leaf write differs (QUIC stream vs the
286	/// write end of an in-process duplex). That is the M12 W1.5 one-pump invariant
287	/// made structural: a loopback attach and a cross-node attach diverge at this
288	/// leaf and nowhere above it.
289	enum SendHalf {
290	    Quic(SendStream),
291	    Loopback(WriteHalf<DuplexStream>),
292	}
293
294	impl SendHalf {
295	    /// Write all `bytes` (no-op on empty). Runs on the host runtime.
296	    async fn write_all(&mut self, bytes: &[u8]) -> io::Result<()> {
297	        match self {
298	            SendHalf::Quic(s) => s
299	                .write_all(bytes)
300	                .await
301	                .map_err(|e| io::Error::other(e.to_string())),
302	            SendHalf::Loopback(w) => w.write_all(bytes).await,
303	        }
304	    }
305
306	    /// Close the send side so the peer's read pump sees a clean end (QUIC
307	    /// `finish` / duplex `shutdown`).
308	    async fn finish(&mut self) -> io::Result<()> {
309	        match self {
310	            SendHalf::Quic(s) => s.finish().map_err(|e| io::Error::other(e.to_string())),
311	            SendHalf::Loopback(w) => w.shutdown().await,
312	        }
313	    }
314	}
315
316	/// A stream's read half — fed into the single read pump in [`register_stream`].
317	/// QUIC reads via quinn's chunk API; loopback via the read end of the duplex.
318	/// Both append to the same [`StreamLog`], so the subscriber (and everything
319	/// above it) cannot tell the transports apart.
320	enum RecvHalf {
321	    Quic(RecvStream),
322	    Loopback(ReadHalf<DuplexStream>),
323	}
324
325	/// One live stream the broker holds (QUIC or loopback): the send half (brain
326	/// writes ride it over IPC) + the sequenced read log.
327	struct StreamEntry {
328	    conn_id: u64,
329	    remote_id_hex: String,
330	    /// Whether THIS node opened the stream (`open_stream`) vs the peer
331	    /// (accept loop). The inbound dispatcher (D9-1) claims only
332	    /// peer-initiated streams; without this fact the table is ambiguous —
333	    /// it holds locally-opened outbound streams too (gotcha: a dispatcher
334	    /// that served its own requester stream would deadlock on itself).

    proven_subnets: HashSet<String>,
}

/// A bounded, sequenced read log for one QUIC stream — the PTY `OutputLog`
/// discipline applied to the network (D4b): the stream's single read pump is
/// the only producer; a brain connection attaches as the subscriber; every
/// subscriber send happens under the enclosing `Mutex`, so live chunks and a
/// replay can never interleave out of `seq` order. Chunks produced while no
/// brain is attached sit in the ring and replay on the next subscribe — a
/// brain restart loses nothing (Spike #3 invariant B/D).
// [impl->REQ-DAEMON-2]
struct StreamLog {
    stream_id: u64,
    next_seq: u64,
    ring: VecDeque<(u64, Vec<u8>)>,
    cap_chunks: usize,
    subscriber: Option<SharedSend>,
    /// Read side finished cleanly (peer called finish / conn ended).
    finished: bool,
}

impl StreamLog {
    fn new(stream_id: u64, cap_chunks: usize) -> Self {
        StreamLog {
            stream_id,
            next_seq: 0,
            ring: VecDeque::new(),
            cap_chunks,
            subscriber: None,
            finished: false,
        }
    }

    /// Append a freshly-read chunk: assign the next `seq`, retain it in the
    /// bounded ring, live-send to the current subscriber. Read-pump only.
    fn append(&mut self, bytes: &[u8]) {
        let seq = self.next_seq;
        self.next_seq += 1;
        self.ring.push_back((seq, bytes.to_vec()));
        while self.ring.len() > self.cap_chunks {
            self.ring.pop_front();
        }
        if let Some(sub) = &self.subscriber {
            let frame = net_stream_data_envelope(self.stream_id, seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Mark the read side finished and tell the subscriber (with the final
    /// cursor, so it can verify completeness).
    fn finish(&mut self) {
        self.finished = true;
        if let Some(sub) = &self.subscriber {
            let frame = net_stream_eof_envelope(self.stream_id, self.next_seq);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Attach `sub` as the subscriber, replaying every retained chunk with
    /// `seq >= from_seq` (then eof, if already finished) before live streaming.
    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
        self.subscriber = Some(Arc::clone(&sub));
        for (seq, bytes) in self.ring.iter().filter(|(s, _)| *s >= from_seq) {
            let frame = net_stream_data_envelope(self.stream_id, *seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
        if self.finished {
            let frame = net_stream_eof_envelope(self.stream_id, self.next_seq);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Detach `sub` iff it is the current subscriber (a late disconnect must
    /// not unsubscribe a newer brain).
    fn detach_if(&mut self, sub: &SharedSend) {
        if let Some(cur) = &self.subscriber {
            if Arc::ptr_eq(cur, sub) {
                self.subscriber = None;
            }
        }
    }
}

/// A bounded, sequenced log of connection liveness events — the broker-owned
/// PresenceChannel seam (D4c, REQ-EP-4): the StreamLog ring/cursor discipline
/// applied to the conn table itself. `register_conn` appends `connected`; each
/// connection's closed-watcher appends `disconnected` after removing the row.
/// A brain subscribes with a cursor and replays events produced while it was
/// down, so presence survives a brain restart exactly like stream reads do.
// [impl->REQ-EP-4]
struct PresenceLog {
    next_seq: u64,
    ring: VecDeque<NetPresenceEvent>,
    cap_events: usize,
    subscriber: Option<SharedSend>,
}

impl PresenceLog {
    fn new(cap_events: usize) -> Self {
        PresenceLog {
            next_seq: 0,
            ring: VecDeque::new(),
            cap_events,
            subscriber: None,
        }
    }

    /// Append a liveness event: assign the next `seq`, retain it in the bounded
    /// ring, live-send to the current subscriber. Producers: `register_conn`
    /// (connected) and the per-conn closed-watchers (disconnected) — every
    /// append serializes on the enclosing `Mutex`, so subscriber frames arrive
    /// in strict `seq` order.
    fn append(&mut self, kind: &str, conn_id: u64, remote_id_hex: &str) {
        let ev = NetPresenceEvent {
            seq: self.next_seq,
            kind: kind.to_string(),
            conn_id,
            remote_id_hex: remote_id_hex.to_string(),
        };
        self.next_seq += 1;
        self.ring.push_back(ev.clone());
        while self.ring.len() > self.cap_events {
            self.ring.pop_front();
        }
        if let Some(sub) = &self.subscriber {
            let frame = net_presence_event_envelope(&ev);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Attach `sub` as the subscriber, replaying every retained event with
    /// `seq >= from_seq` before live streaming (the gapless-resume attach).
    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
        self.subscriber = Some(Arc::clone(&sub));
        for ev in self.ring.iter().filter(|e| e.seq >= from_seq) {
            let frame = net_presence_event_envelope(ev);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Detach `sub` iff it is the current subscriber (a late disconnect must
    /// not unsubscribe a newer brain).
    fn detach_if(&mut self, sub: &SharedSend) {
        if let Some(cur) = &self.subscriber {
            if Arc::ptr_eq(cur, sub) {
                self.subscriber = None;
            }
        }
    }
}

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

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

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

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

/// One live stream the broker holds (QUIC or loopback): the send half (brain
/// writes ride it over IPC) + the sequenced read log.
struct StreamEntry {
    conn_id: u64,
    remote_id_hex: String,
    /// Whether THIS node opened the stream (`open_stream`) vs the peer
    /// (accept loop). The inbound dispatcher (D9-1) claims only
    /// peer-initiated streams; without this fact the table is ambiguous —
    /// it holds locally-opened outbound streams too (gotcha: a dispatcher
    /// that served its own requester stream would deadlock on itself).


StreamLog = a `(seq,bytes)` ring + finished flag, single subscriber, no sync read accessor. The bridge has a genuine design fork (mechanism, not the gated shape):
- **A** — broker-side poll bridge: `serve_tunnel_control` gets `Arc<Broker>` → `tunnel_ensure`/`send`/`recv` operate NetHost directly + a new sync StreamLog drain. No dispatch changes, stays in broker, poll-based (faithful for the mock).
- **B** — dispatch `kind=tunnel` classification + `serve_tunnel` relay loop (matches shell-link, heavier, live-ish).

Per your guidance, this fork is doyle's to greenlight. Verify Option A's one feasibility fact (Arc<Broker> at the hub spawn site) so the recommendation is grounded.


Bash

grep -n 'let broker\|Broker::\|broker_socket_name\|Arc::clone(&broker)\|fn run\|serve_drive_control\|drive_hub' crates/spt-daemon/src/daemon.rs | head -30

Check Arc<Broker> scope at hub spawn site


29:    broker_socket_name, daemon_pid_path, digest_socket_name, drive_socket_name, seed_socket_name,
46:    pub fn run() -> io::Result<()> {
170:        let broker_handle: Option<Arc<Broker>> =
171:            match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
173:                    let serve_broker = Arc::clone(&broker);
193:                        let broker = Arc::clone(&broker);
225:        let drive_hub = Arc::new(crate::drivehub::DriveHub::new());
227:            let hub = Arc::clone(&drive_hub);
229:                let _ = crate::drivehub::serve_drive_control(&drive_socket_name(), hub);
294:    pub fn run_brain(generation: u64, start_reason: &str) -> io::Result<()> {


170	        let broker_handle: Option<Arc<Broker>> =
171	            match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
172	                Ok(broker) => {
173	                    let serve_broker = Arc::clone(&broker);
174	                    thread::spawn(move || {
175	                        let _ = serve_broker.serve();
176	                    });
177	                // Inbound net dispatch + outbound peer pump (D9-1) now run in
178	                // the BRAIN child (restoration D2-1): they are pure IPC clients,
179	                // so they live with the restartable brain and respawn with it.
180	                // The broker keeps only the NetHost bring-up and the boot-race
181	                // self-heal that binds it; the brain polls `net-status` and
182	                // starts the consumers once net reports enabled.
183	                // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
184	                    if !net_up && node_hex.is_some() {
185	                        // Boot-race self-heal (REQ-DAEMON-9): net failed to bind
186	                        // but identity is sound — almost always the autostart
187	                        // racing the network up at boot. Retry net in the
188	                        // background and attach it to the broker when it
189	                        // succeeds, so the node becomes reachable WITHOUT a
190	                        // manual restart; the brain notices `net-status` flip
191	                        // enabled and starts the consumers then.
192	                        // [impl->REQ-DAEMON-9]
193	                        let broker = Arc::clone(&broker);
194	                        thread::spawn(move || net_retry_attach(broker));
195	                    }
196	                    Some(broker)
197	                }
198	                Err(e) => {
199	                    eprintln!("BROKER_BIND_FAIL: {e}");
200	                    None
201	                }
202	            };
203
204	        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
205	        // the digest-control channel serves `spt digest <id>` snapshot pulls +
206	        // delta-stream subscribes against it. Served on a background thread like
207	        // the broker. The per-session feed that *populates* the hub is driven by
208	        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
209	        // sessions are spawned through the broker; a bare daemon serves the
210	        // channel and answers "no live digest" until a session is hosted.
211	        let digest_hub = Arc::new(DigestHub::new());
212	        {
213	            let hub = Arc::clone(&digest_hub);
214	            thread::spawn(move || {
215	                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
216	            });
217	        }
218
219	        // The drive hub (M11-W2, REQ-SHELL-3) holds the per-shell ephemeral drive
220	        // slot — the owner→shell control surface. Broker-held like the digest hub
221	        // (the daemon-lifetime CLI-reachable anchor; a brain restart loses
222	        // nothing — the slot is ephemeral control, a restart correctly drops it).
223	        // `spt shell drive` writes it, `api drive-poll --link` takes-and-clears it.
224	        // [impl->REQ-SHELL-3]
225	        let drive_hub = Arc::new(crate::drivehub::DriveHub::new());
226	        {
227	            let hub = Arc::clone(&drive_hub);
228	            thread::spawn(move || {
229	                let _ = crate::drivehub::serve_drive_control(&drive_socket_name(), hub);

        let broker_handle: Option<Arc<Broker>> =
            match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
                Ok(broker) => {
                    let serve_broker = Arc::clone(&broker);
                    thread::spawn(move || {
                        let _ = serve_broker.serve();
                    });
                // Inbound net dispatch + outbound peer pump (D9-1) now run in
                // the BRAIN child (restoration D2-1): they are pure IPC clients,
                // so they live with the restartable brain and respawn with it.
                // The broker keeps only the NetHost bring-up and the boot-race
                // self-heal that binds it; the brain polls `net-status` and
                // starts the consumers once net reports enabled.
                // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
                    if !net_up && node_hex.is_some() {
                        // Boot-race self-heal (REQ-DAEMON-9): net failed to bind
                        // but identity is sound — almost always the autostart
                        // racing the network up at boot. Retry net in the
                        // background and attach it to the broker when it
                        // succeeds, so the node becomes reachable WITHOUT a
                        // manual restart; the brain notices `net-status` flip
                        // enabled and starts the consumers then.
                        // [impl->REQ-DAEMON-9]
                        let broker = Arc::clone(&broker);
                        thread::spawn(move || net_retry_attach(broker));
                    }
                    Some(broker)
                }
                Err(e) => {
                    eprintln!("BROKER_BIND_FAIL: {e}");
                    None
                }
            };

        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
        // the digest-control channel serves `spt digest <id>` snapshot pulls +
        // delta-stream subscribes against it. Served on a background thread like
        // the broker. The per-session feed that *populates* the hub is driven by
        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
        // sessions are spawned through the broker; a bare daemon serves the
        // channel and answers "no live digest" until a session is hosted.
        let digest_hub = Arc::new(DigestHub::new());
        {
            let hub = Arc::clone(&digest_hub);
            thread::spawn(move || {
                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
            });
        }

        // The drive hub (M11-W2, REQ-SHELL-3) holds the per-shell ephemeral drive
        // slot — the owner→shell control surface. Broker-held like the digest hub
        // (the daemon-lifetime CLI-reachable anchor; a brain restart loses
        // nothing — the slot is ephemeral control, a restart correctly drops it).
        // `spt shell drive` writes it, `api drive-poll --link` takes-and-clears it.
        // [impl->REQ-SHELL-3]
        let drive_hub = Arc::new(crate::drivehub::DriveHub::new());
        {
            let hub = Arc::clone(&drive_hub);
            thread::spawn(move || {
                let _ = crate::drivehub::serve_drive_control(&drive_socket_name(), hub);


`broker_handle: Option<Arc<Broker>>` is in scope at the hub-spawn site → Option A is feasible (gate the tunnel-control spawn on `Some`, pass the `Arc<Broker>` for `net()`). This bridge-mechanism fork is a genuine roadblock with a semantic implication (poll vs live-duplex) — per your guidance, putting it to doyle with my recommendation (A).


"$OWL" send doyle <<'EOF'
W3 T3.2 BRIDGE — design fork, need your greenlight (mechanism, not the gated surface).

Context: both tunnel surfaces (`spt shell tunnel` owner + `api tunnel` shell) are transient `spt` CLIs on a control socket. The opaque bytes live in the broker-owned QUIC stream (loopback open_stream cross-wires an operator+peer row pair, same-node). So a BRIDGE is needed between the control socket and NetHost. Dispatch (dispatch.rs:206-214) claims peer rows by first-record content demux — relevant only to Option B.

OPTION A (RECOMMEND): broker-side POLL bridge. serve_tunnel_control gains an Arc<Broker> (feasible — broker_handle:Option<Arc<Broker>> is in scope at the hub-spawn, gate on Some) so it reaches broker.net(). Ops on the tunnel-control socket: tunnel_ensure(open the loopback pair + register in TunnelHub), tunnel_send(stream_id,bytes)->NetHost::send_stream, tunnel_recv(stream_id,from_seq)->a NEW small synchronous StreamLog drain on NetHost (the ring already buffers reads independent of brain subscribe; I add a sync read-at-cursor accessor). NO dispatch changes. Bidirectional (send+recv) but POLL-based, not a live-blocking duplex.

OPTION B: dispatch kind=tunnel first-record classification + a brain-driven serve_tunnel relay loop (matches shell-link) bridging the peer row to the api surface, live-ish. Heavier; a brain-owned serve loop + broker-buffered queues.

Q: is A (broker poll bridge) sufficient for the W3 same-node mock-MECHANISM proof? It rides the real D4 net-stream (open_stream + send_stream + StreamLog ring) so it proves byte-exact opaque round-trip + link-break close + R1 stale-stream-inert — just via poll-drain not live push. My lean A: far less surface, same invariants, and the live-duplex refinement naturally rides the future real usbip consumer (deferred). Tradeoff: poll recv ≠ live push; the "held bidirectional byte stream" framing is satisfied structurally (one held QUIC stream pair, opaque) but drained by poll. Greenlight A, or do you want B's live duplex now?
EOF
echo "EXIT=$?"

Send bridge design fork to doyle


➜ SENT:doyle
EXIT=0


Bridge fork sent to doyle, recommendation A (broker poll bridge). Awaiting greenlight — won't build speculatively since the fork picks the build direction.

**Status:** W3 T3.2 bridge = the one real design fork left. Surface gated (GO), but the internal mechanism (poll-bridge-in-broker vs dispatch-relay-loop) has a poll-vs-live-duplex semantic implication → doyle's to greenlight per your guidance.

- 4 green commits landed (T3.1 manifest, T3.2a hub, T3.2b close-hook + plan).
- Recommendation **A**: broker-side poll bridge — rides real D4 net-stream (open_stream + StreamLog), proves byte-exact opaque round-trip + link-break close + R1 stale-stream, far less surface than B. Feasibility confirmed (`Arc<Broker>` reachable at spawn site).

doyle's reply drives the build. The moment A greenlights: add the sync StreamLog drain + `tunnel_ensure/send/recv` on the tunnel-control socket + the open-hook at `api bind-shell` + `spt shell tunnel` CLI + `api tunnel` verb → T3.3 mock E2E → activate int.


<task-notification>
<task-id>be1did0ug</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/7" id="1b1d4ae4" type="msg" from="doyle">W3 T3.2 bridge fork: GREENLIGHT A (broker poll bridge). It is the right call for the W3 same-node mechanism proof — and it is also the better posture: A keeps the tunnel relay broker-homed (Arc&lt;Broker&gt; -&gt; broker.net(), no dispatch change, no brain serve loop), whereas B puts a live-relay serve loop on the BRA</EVENT-PART>
<EVENT-PART seq="2/7" id="1b1d4ae4">IN — the restartable child. Per the broker-is-the-anchor posture, A is structurally sounder, not just lighter. Poll-drain vs live-push is a surface-delivery choice, NOT a CONTEXT invariant — CONTEXT:262 specifies a &quot;reliable, ordered byte stream,&quot; it does not dictate how the shell-side surface drains it. A satisfies the framing s</EVENT-PART>
<EVENT-PART seq="3/7" id="1b1d4ae4">tructurally: one held opaque QUIC stream pair, link-lifetime, bidirectional (send + recv both exist).

ONE CONDITION (the single CONTEXT:262 invariant poll could break — gate-blocking): tunnel_recv must be LOSSLESS + ORDERED. CONTEXT:262 is explicit — &quot;tunneled protocols cannot drop frames, so congestion surfaces as lag, never l</EVENT-PART>
<EVENT-PART seq="4/7" id="1b1d4ae4">oss.&quot; So the StreamLog ring your sync cursor-drain reads MUST be cursor-retentive / backpressured: NO byte may be evicted before the recv cursor passes it. If the ring is a bounded overwrite-on-full buffer, a slow poll silently drops bytes = frame loss = contradicts the locked design (the gate-against-documented-design trap: an impl limit</EVENT-PART>
<EVENT-PART seq="5/7" id="1b1d4ae4">ation that breaks a recorded invariant). Confirm the ring retains-until-drained (or backpressures the sender); if it can evict unread, fix that before A ships.

Prove it in T3.3: byte-exact round-trip of a MULTI-WRITE / sizable payload that would actually expose an ordering or eviction bug across the poll boundary — not a single small </EVENT-PART>
<EVENT-PART seq="6/7" id="1b1d4ae4">frame that can't. Plus the R1 stale-stream-inert (open -&gt; break -&gt; relink-fresh-gen -&gt; old stream_id unreferenced) and link-break close, as gated.

B (live-push duplex) DEFERRED — it rides the real usbip consumer, exactly where live duplex matters. Do NOT build the heavier brain-driven serve loop speculatively (activate-don't-</EVENT-PART>
<EVENT-PART seq="7/7" id="1b1d4ae4">pre-fail).

DOC honesty (same no-overclaim discipline as the cross-node R2 carry): frame it as &quot;held opaque stream, poll-drained at the surface; live-push duplex deferred to the real consumer&quot; — never imply live duplex. Greenlit — build A.</EVENT-PART></event>
</task-notification>


doyle: **GREENLIGHT A** + one gate-blocking condition — `tunnel_recv` must be **lossless + ordered**. The StreamLog ring is bounded overwrite-on-full (`pop_front` when `len > cap_chunks`) → it CAN evict unread = frame loss = breaks CONTEXT:262 "cannot drop frames." Must make the tunnel stream **retentive** (no evict before drain). doyle wants a multi-write sizable-payload T3.3 proof. B deferred. Ack + build, fixing the ring first.


"$OWL" send doyle <<'EOF'
A greenlit, ack. The ring IS bounded overwrite-on-full (StreamLog pop_front when len>cap_chunks) — so it CAN evict unread = your gate-blocking condition is live. Fix FIRST: a per-stream RETENTIVE mode for tunnel streams — append skips cap-eviction; tunnel_recv pops undrained chunks (cursor passes = the only removal), lossless+ordered. Non-tunnel streams keep bounded-overwrite (a slow/absent brain must not OOM the broker). Unit-prove the retentive ring drops nothing under multi-write pressure; T3.3 proves byte-exact round-trip of a multi-write sizable payload (would expose eviction/ordering) + R1 stale-stream + link-break close. Docs frame it "held opaque stream, poll-drained; live-push duplex deferred to the real consumer." Building.
EOF
echo "EXIT=$?"

Ack doyle greenlight + lossless plan


Find the loopback-conn helper + the ring cap constant, then build the retentive ring + drain:


800	        let addr = spt_net::net::endpoint::conn_remote_addr(&conn);
801	        if addr.is_empty() {
802	            return serde_json::Value::Null; // id-only adds nothing over discovery
803	        }
804	        serde_json::to_value(addr).unwrap_or(serde_json::Value::Null)
805	    }
806
807	    /// Mint (or reuse) the broker's single in-process loopback connection
808	    /// (M12 W1.5, B1) and return `(conn_id, remote_id_hex)` — `remote_id_hex` is
809	    /// THIS node's own id, so a stream served over it gates same-node (access
810	    /// short-circuit Allow). The conn carries NO iroh handshake: its identity is
811	    /// broker-minted BY CONSTRUCTION and can never arise from an inbound dial.
812	    /// This is what lets a LOCAL `spt rc` ride the byte-identical `serve_attach`
813	    /// pump — QUIC self-dial is refused by iroh, so local attach uses this.
814	    /// Idempotent: every call returns the same singleton conn (one is all a
815	    /// local attach needs); no journaling needed (reuse is the dedup).
816	    // [impl->REQ-RC-1]
817	    pub fn dial_loopback(&self) -> (u64, String) {
818	        let remote_id_hex = self.local_id.to_hex();
819	        let mut slot = self.loopback_conn.lock().unwrap();

        let addr = spt_net::net::endpoint::conn_remote_addr(&conn);
        if addr.is_empty() {
            return serde_json::Value::Null; // id-only adds nothing over discovery
        }
        serde_json::to_value(addr).unwrap_or(serde_json::Value::Null)
    }

    /// Mint (or reuse) the broker's single in-process loopback connection
    /// (M12 W1.5, B1) and return `(conn_id, remote_id_hex)` — `remote_id_hex` is
    /// THIS node's own id, so a stream served over it gates same-node (access
    /// short-circuit Allow). The conn carries NO iroh handshake: its identity is
    /// broker-minted BY CONSTRUCTION and can never arise from an inbound dial.
    /// This is what lets a LOCAL `spt rc` ride the byte-identical `serve_attach`
    /// pump — QUIC self-dial is refused by iroh, so local attach uses this.
    /// Idempotent: every call returns the same singleton conn (one is all a
    /// local attach needs); no journaling needed (reuse is the dedup).
    // [impl->REQ-RC-1]
    pub fn dial_loopback(&self) -> (u64, String) {
        let remote_id_hex = self.local_id.to_hex();
        let mut slot = self.loopback_conn.lock().unwrap();


grep -n 'cap_chunks\|STREAM.*CHUNK\|fn register_stream\|streams:\|StreamLog::new\|StreamEntry {\|log:\|next_seq:\|finished:' crates/spt-daemon/src/nethost.rs | head -40

Find ring cap + register_stream + StreamEntry shape


84:const DEFAULT_STREAM_RING_CHUNKS: usize = 4096;
87:const STREAM_READ_CHUNK: usize = 64 * 1024;
133:    next_seq: u64,
135:    cap_chunks: usize,
138:    finished: bool,
142:    fn new(stream_id: u64, cap_chunks: usize) -> Self {
145:            next_seq: 0,
147:            cap_chunks,
149:            finished: false,
159:        while self.ring.len() > self.cap_chunks {
219:    next_seq: u64,
228:            next_seq: 0,
327:struct StreamEntry {
339:    log: Arc<Mutex<StreamLog>>,
348:    streams: Mutex<HashMap<u64, Arc<StreamEntry>>>,
360:            streams: Mutex::new(HashMap::new()),
361:            ring_cap: DEFAULT_STREAM_RING_CHUNKS,
362:            presence: Mutex::new(PresenceLog::new(DEFAULT_STREAM_RING_CHUNKS)),
439:fn register_stream(
448:    let log = Arc::new(Mutex::new(StreamLog::new(id, shared.ring_cap)));
451:        Arc::new(StreamEntry {
456:            log: Arc::clone(&log),
468:                match recv.read_chunk(STREAM_READ_CHUNK).await {
477:                let mut buf = vec![0u8; STREAM_READ_CHUNK];
887:                let (op_io, peer_io) = tokio::io::duplex(STREAM_READ_CHUNK);
972:                    next_seq: log.next_seq,
973:                    finished: log.finished,


439	fn register_stream(
440	    shared: &Arc<NetShared>,
441	    conn_id: u64,
442	    remote_id_hex: &str,
443	    send: SendHalf,
444	    recv: RecvHalf,
445	    initiated_locally: bool,
446	) -> u64 {
447	    let id = shared.next_stream_id.fetch_add(1, Ordering::Relaxed);
448	    let log = Arc::new(Mutex::new(StreamLog::new(id, shared.ring_cap)));
449	    shared.streams.lock().unwrap().insert(
450	        id,
451	        Arc::new(StreamEntry {
452	            conn_id,
453	            remote_id_hex: remote_id_hex.to_string(),
454	            initiated_locally,
455	            send: tokio::sync::Mutex::new(send),
456	            log: Arc::clone(&log),
457	        }),
458	    );
459
460	    // The read pump — the stream's single producer. A clean end (peer finish)
461	    // and a torn end (conn lost) both finish the log; the buffered chunks stay
462	    // replayable either way. The transport differs only at this read leaf
463	    // (QUIC chunk API vs the loopback duplex's `AsyncRead`); both append to the
464	    // same `StreamLog`, so nothing above the pump can tell them apart.
465	    tokio::spawn(async move {
466	        match recv {
467	            RecvHalf::Quic(mut recv) => loop {
468	                match recv.read_chunk(STREAM_READ_CHUNK).await {
469	                    Ok(Some(chunk)) => log.lock().unwrap().append(&chunk.bytes),
470	                    Ok(None) | Err(_) => {
471	                        log.lock().unwrap().finish();
472	                        break;
473	                    }
474	                }
475	            },
476	            RecvHalf::Loopback(mut recv) => {
477	                let mut buf = vec![0u8; STREAM_READ_CHUNK];
478	                loop {
479	                    match recv.read(&mut buf).await {
480	                        Ok(0) | Err(_) => {
481	                            log.lock().unwrap().finish();
482	                            break;
483	                        }
484	                        Ok(n) => log.lock().unwrap().append(&buf[..n]),
485	                    }
486	                }
487	            }
488	        }
489	    });
490
491	    id
492	}
493
494	/// The broker-owned net endpoint + live connection/stream tables (see module
495	/// docs).
496	pub struct NetHost {
497	    /// The dedicated tokio runtime hosting the endpoint and its accept loop.
498	    /// Dropping the host drops the runtime, stopping every net task with it.

fn register_stream(
    shared: &Arc<NetShared>,
    conn_id: u64,
    remote_id_hex: &str,
    send: SendHalf,
    recv: RecvHalf,
    initiated_locally: bool,
) -> u64 {
    let id = shared.next_stream_id.fetch_add(1, Ordering::Relaxed);
    let log = Arc::new(Mutex::new(StreamLog::new(id, shared.ring_cap)));
    shared.streams.lock().unwrap().insert(
        id,
        Arc::new(StreamEntry {
            conn_id,
            remote_id_hex: remote_id_hex.to_string(),
            initiated_locally,
            send: tokio::sync::Mutex::new(send),
            log: Arc::clone(&log),
        }),
    );

    // The read pump — the stream's single producer. A clean end (peer finish)
    // and a torn end (conn lost) both finish the log; the buffered chunks stay
    // replayable either way. The transport differs only at this read leaf
    // (QUIC chunk API vs the loopback duplex's `AsyncRead`); both append to the
    // same `StreamLog`, so nothing above the pump can tell them apart.
    tokio::spawn(async move {
        match recv {
            RecvHalf::Quic(mut recv) => loop {
                match recv.read_chunk(STREAM_READ_CHUNK).await {
                    Ok(Some(chunk)) => log.lock().unwrap().append(&chunk.bytes),
                    Ok(None) | Err(_) => {
                        log.lock().unwrap().finish();
                        break;
                    }
                }
            },
            RecvHalf::Loopback(mut recv) => {
                let mut buf = vec![0u8; STREAM_READ_CHUNK];
                loop {
                    match recv.read(&mut buf).await {
                        Ok(0) | Err(_) => {
                            log.lock().unwrap().finish();
                            break;
                        }
                        Ok(n) => log.lock().unwrap().append(&buf[..n]),
                    }
                }
            }
        }
    });

    id
}

/// The broker-owned net endpoint + live connection/stream tables (see module
/// docs).
pub struct NetHost {
    /// The dedicated tokio runtime hosting the endpoint and its accept loop.
    /// Dropping the host drops the runtime, stopping every net task with it.


Have everything. The loopback peer row registers immediately before the operator row (open_stream registers peer first). Building the gate-blocking lossless piece first: per-stream retentive ring + sync drain + loopback-pair opener on NetHost. Edit StreamLog:


Edit

struct StreamLog {
    stream_id: u64,
    next_seq: u64,
    ring: VecDeque<(u64, Vec<u8>)>,
    cap_chunks: usize,
    subscriber: Option<SharedSend>,
    /// Read side finished cleanly (peer called finish / conn ended).
    finished: bool,
}

impl StreamLog {
    fn new(stream_id: u64, cap_chunks: usize) -> Self {
        StreamLog {
            stream_id,
            next_seq: 0,
            ring: VecDeque::new(),
            cap_chunks,
            subscriber: None,
            finished: false,
        }
    }

    /// Append a freshly-read chunk: assign the next `seq`, retain it in the
    /// bounded ring, live-send to the current subscriber. Read-pump only.
    fn append(&mut self, bytes: &[u8]) {
        let seq = self.next_seq;
        self.next_seq += 1;
        self.ring.push_back((seq, bytes.to_vec()));
        while self.ring.len() > self.cap_chunks {
            self.ring.pop_front();
        }
        if let Some(sub) = &self.subscriber {
            let frame = net_stream_data_envelope(self.stream_id, seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

struct StreamLog {
    stream_id: u64,
    next_seq: u64,
    ring: VecDeque<(u64, Vec<u8>)>,
    cap_chunks: usize,
    subscriber: Option<SharedSend>,
    /// Read side finished cleanly (peer called finish / conn ended).
    finished: bool,
    /// **Retentive** (M11-W3, REQ-SHELL-4): a shell-tunnel stream must NOT drop
    /// bytes — CONTEXT:262 "tunneled protocols cannot drop frames, so congestion
    /// surfaces as lag, never loss." A retentive log never cap-evicts on append; a
    /// chunk leaves the ring ONLY when [`drain`](Self::drain) (the recv cursor)
    /// pops it. Default `false` keeps the bounded-overwrite discipline for ordinary
    /// (brain-subscribed) streams, where a slow/absent brain must not OOM the
    /// broker. A retentive stream is drained by the sync cursor, never subscribed.
    retentive: bool,
}

impl StreamLog {
    fn new(stream_id: u64, cap_chunks: usize) -> Self {
        StreamLog {
            stream_id,
            next_seq: 0,
            ring: VecDeque::new(),
            cap_chunks,
            subscriber: None,
            finished: false,
            retentive: false,
        }
    }

    /// Append a freshly-read chunk: assign the next `seq`, retain it in the ring,
    /// live-send to the current subscriber. Read-pump only. A non-retentive log
    /// cap-evicts the oldest chunks past `cap_chunks`; a retentive (tunnel) log
    /// never evicts — bytes leave only via [`drain`](Self::drain).
    fn append(&mut self, bytes: &[u8]) {
        let seq = self.next_seq;
        self.next_seq += 1;
        self.ring.push_back((seq, bytes.to_vec()));
        if !self.retentive {
            while self.ring.len() > self.cap_chunks {
                self.ring.pop_front();
            }
        }
        if let Some(sub) = &self.subscriber {
            let frame = net_stream_data_envelope(self.stream_id, seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Drain every undrained chunk in order (the shell-tunnel recv cursor, W3).
    /// Pops the whole ring front-to-back (FIFO by `seq`) and concatenates the
    /// bytes — chunk boundaries are arbitrary read-pump splits, irrelevant to an
    /// opaque byte stream. Lossless + ordered: a byte leaves the ring only here,
    /// once. Returns `(bytes, finished)`; `finished` reflects a clean/torn end so
    /// the poller sees EOF and closes. Empty bytes with `finished=false` ⇒ nothing
    /// pending yet.
    fn drain(&mut self) -> (Vec<u8>, bool) {
        let mut out = Vec::new();
        while let Some((_, chunk)) = self.ring.pop_front() {
            out.extend_from_slice(&chunk);
        }
        (out, self.finished)
    }


<owl_messages>
<owl_message from="doyle" priority="high">Good catch confirming the eviction is live — that is exactly the frame-loss the condition was guarding. Per-stream retentive mode for tunnel streams + non-tunnel keeps bounded-overwrite (so a slow/absent brain can't OOM the broker): right split. ONE refinement before you cut it:

"append skips cap-eviction" makes the retentive ring UNBOUNDED — it swaps frame-loss for broker-OOM. A tunnel consumer that stops draining while the link is still up (a wedged/hung shell binary — a real scenario) would grow the ring without bound. That is the SAME OOM concern you correctly preserve for non-tunnel streams; don't drop it for tunnel streams.

CONTEXT:262 already names the correct cure: "congestion surfaces as LAG, never loss." Lag = backpressure, NOT unbounded buffering. So make the retentive ring BOUNDED-WITH-BACKPRESSURE: cap it, and when undrained depth hits the cap, STOP draining the QUIC RecvStream into the ring — let QUIC's native stream flow-control lag the SENDER. Never evict (no loss), never grow unbounded (no OOM). That is "lag, never loss" implemented exactly, and it leverages machinery you already have (QUIC flow-control) rather than an unbounded Vec.

Note this is a change to the READ loop (stop pulling from the RecvStream when the tunnel ring is full), not just the ring's eviction policy — slightly more than skip-eviction, but it's the CONTEXT-correct form of the same fix.

Add a test: a non-draining tunnel consumer bounds broker memory (backpressure engaged), does NOT evict and does NOT grow unbounded. Keep everything else as you said — multi-write byte-exact T3.3, R1 stale-stream, link-break close, non-tunnel bounded-overwrite untouched, the poll-drained/live-duplex-deferred doc framing. Build it.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


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

struct StreamLog {
    stream_id: u64,
    next_seq: u64,
    ring: VecDeque<(u64, Vec<u8>)>,
    cap_chunks: usize,
    subscriber: Option<SharedSend>,
    /// Read side finished cleanly (peer called finish / conn ended).
    finished: bool,
}

impl StreamLog {
    fn new(stream_id: u64, cap_chunks: usize) -> Self {
        StreamLog {
            stream_id,
            next_seq: 0,
            ring: VecDeque::new(),
            cap_chunks,
            subscriber: None,
            finished: false,
        }
    }

    /// Append a freshly-read chunk: assign the next `seq`, retain it in the
    /// bounded ring, live-send to the current subscriber. Read-pump only.
    fn append(&mut self, bytes: &[u8]) {
        let seq = self.next_seq;
        self.next_seq += 1;
        self.ring.push_back((seq, bytes.to_vec()));
        while self.ring.len() > self.cap_chunks {
            self.ring.pop_front();
        }
        if let Some(sub) = &self.subscriber {
            let frame = net_stream_data_envelope(self.stream_id, seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

struct StreamLog {
    stream_id: u64,
    next_seq: u64,
    ring: VecDeque<(u64, Vec<u8>)>,
    cap_chunks: usize,
    subscriber: Option<SharedSend>,
    /// Read side finished cleanly (peer called finish / conn ended).
    finished: bool,
    /// **Retentive** (M11-W3, REQ-SHELL-4): a shell-tunnel stream must NOT drop
    /// bytes — CONTEXT:262 "tunneled protocols cannot drop frames, so congestion
    /// surfaces as lag, never loss." A retentive log never cap-evicts on append; a
    /// chunk leaves the ring ONLY when [`drain`](Self::drain) (the recv cursor)
    /// pops it. Default `false` keeps the bounded-overwrite discipline for ordinary
    /// (brain-subscribed) streams, where a slow/absent brain must not OOM the
    /// broker. A retentive stream is drained by the sync cursor, never subscribed.
    retentive: bool,
}

impl StreamLog {
    fn new(stream_id: u64, cap_chunks: usize) -> Self {
        StreamLog {
            stream_id,
            next_seq: 0,
            ring: VecDeque::new(),
            cap_chunks,
            subscriber: None,
            finished: false,
            retentive: false,
        }
    }

    /// Append a freshly-read chunk: assign the next `seq`, retain it in the ring,
    /// live-send to the current subscriber. Read-pump only. A non-retentive log
    /// cap-evicts the oldest chunks past `cap_chunks`; a retentive (tunnel) log
    /// never evicts — bytes leave only via [`drain`](Self::drain).
    fn append(&mut self, bytes: &[u8]) {
        let seq = self.next_seq;
        self.next_seq += 1;
        self.ring.push_back((seq, bytes.to_vec()));
        if !self.retentive {
            while self.ring.len() > self.cap_chunks {
                self.ring.pop_front();
            }
        }
        if let Some(sub) = &self.subscriber {
            let frame = net_stream_data_envelope(self.stream_id, seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Drain every undrained chunk in order (the shell-tunnel recv cursor, W3).
    /// Pops the whole ring front-to-back (FIFO by `seq`) and concatenates the
    /// bytes — chunk boundaries are arbitrary read-pump splits, irrelevant to an
    /// opaque byte stream. Lossless + ordered: a byte leaves the ring only here,
    /// once. Returns `(bytes, finished)`; `finished` reflects a clean/torn end so
    /// the poller sees EOF and closes. Empty bytes with `finished=false` ⇒ nothing
    /// pending yet.
    fn drain(&mut self) -> (Vec<u8>, bool) {
        let mut out = Vec::new();
        while let Some((_, chunk)) = self.ring.pop_front() {
            out.extend_from_slice(&chunk);
        }
        (out, self.finished)
    }

+    /// **Retentive** (M11-W3, REQ-SHELL-4): a shell-tunnel stream must NOT drop

+    /// bytes — CONTEXT:262 "tunneled protocols cannot drop frames, so congestion

+    /// surfaces as lag, never loss." A retentive log never cap-evicts on append; a

+    /// chunk leaves the ring ONLY when [`drain`](Self::drain) (the recv cursor)

+    /// pops it. Default `false` keeps the bounded-overwrite discipline for ordinary

+    /// (brain-subscribed) streams, where a slow/absent brain must not OOM the

+    /// broker. A retentive stream is drained by the sync cursor, never subscribed.

-    /// Append a freshly-read chunk: assign the next `seq`, retain it in the

-    /// bounded ring, live-send to the current subscriber. Read-pump only.

+    /// Append a freshly-read chunk: assign the next `seq`, retain it in the ring,

+    /// live-send to the current subscriber. Read-pump only. A non-retentive log

+    /// cap-evicts the oldest chunks past `cap_chunks`; a retentive (tunnel) log

+    /// never evicts — bytes leave only via [`drain`](Self::drain).

-        while self.ring.len() > self.cap_chunks {

+            while self.ring.len() > self.cap_chunks {

+    /// Drain every undrained chunk in order (the shell-tunnel recv cursor, W3).

+    /// Pops the whole ring front-to-back (FIFO by `seq`) and concatenates the

+    /// bytes — chunk boundaries are arbitrary read-pump splits, irrelevant to an

+    /// opaque byte stream. Lossless + ordered: a byte leaves the ring only here,

+    /// once. Returns `(bytes, finished)`; `finished` reflects a clean/torn end so

+    /// the poller sees EOF and closes. Empty bytes with `finished=false` ⇒ nothing

+    fn drain(&mut self) -> (Vec<u8>, bool) {

+        while let Some((_, chunk)) = self.ring.pop_front() {

+            out.extend_from_slice(&chunk);

/// Mark the read side finished and tell the subscriber (with the final

/// cursor, so it can verify completeness).


doyle refinement (gate-blocking, correct): skip-eviction makes the retentive ring **unbounded** → trades frame-loss for broker-OOM (a wedged non-draining shell grows it without bound). CONTEXT:262 "lag, never loss" = **backpressure**, not unbounded buffering. Fix = **bounded-with-backpressure**: cap the retentive ring, and when undrained depth hits cap, **stop pulling the RecvStream** → QUIC/duplex native flow-control lags the sender. Change is in the READ PUMP, not just eviction policy. Ack + implement.


Bash

"$OWL" send doyle <<'EOF'
Ack — correct, skip-evict = unbounded = OOM on a wedged non-draining shell. Implementing bounded-with-backpressure: retentive append never evicts (lossless), AND the READ PUMP gates — when the retentive ring is at cap it stops pulling the RecvStream, so the loopback duplex / QUIC flow-control lags the SENDER (lag-never-loss, CONTEXT:262 exactly). Mechanism: a per-stream Notify; the pump awaits ring-room before each read; drain notifies. Dedicated small tunnel cap so the bound is testable. Test: non-draining consumer → writer lags, ring depth bounded at cap, zero eviction, all bytes drain byte-exact after the reader catches up. Non-tunnel streams keep bounded-overwrite untouched. Building now.
EOF
echo "EXIT=$?"

Ack doyle backpressure refinement


1	- [pre-flight clippy --workspace](ci-clippy-preflight-workspace.md) — CI denies warnings workspace-wide (`clippy --workspace --all-targets -D warnings`); a `-p <crate>` local check misses sibling-crate lints → CI red round-trip (M11-W1: `-p spt` missed a `useless_vec` in spt-daemon's act-gate test). Pre-flight the exact CI invocation. CI only fires on PR (push branches=main/dev-freeform)
2	- [don't wait — proceed autonomously](dont-wait-proceed-autonomously.md) — operator 2026-06-14: don't stop to ask permission between steps; chain next action (ping/commit gated batch/next wave). After doyle W4 gate-PASS, commit myself
3	- [alarm port deferred — arrangement system](alarm-port-deferred-arrangement.md) — /spt:new-alarm durable scheduler = ACCEPTED parity gap, stays DEFERRED (DEFERRED.md:43, ADR-0018 Q4/V3). spt-core has alarm shape+relay only; real timer in legacy owl in-mem. Operator 2026-06-15: keep deferred, a superseding "arrangement" system is coming (not ready to discuss). Don't dispatch the port, don't build an adapter stopgap. perri logs it as known gap
4	- [broker is daemon state anchor](broker-is-daemon-state-anchor.md) — post-ADR-0018 the BROKER (not restartable brain child) is the always-up CLI-reachable daemon-lifetime anchor (serves all sockets, "brain restart loses nothing", holds DigestHub); place ephemeral in-mem daemon state there. My M11-W2 drive-slot gate said "brain" — wrong word, functional spec = broker; todlando caught it. Drive no-replay needs link-gen stamping (clear-on-link-break not crash-robust alone)
5	- [adapter tips → published docs](adapter-tips-published-docs.md) — synthesized perri's SPT_HARNESS_ADAPTER_TIPS.md into a NEW harness-agnostic docs-site page harness-contract/patterns.md ("Adapter patterns & pitfalls"); generalized all [CC] specifics, zero leakage; published live to GH-Pages via `gh workflow run docs-publish.yml --ref main` (fires on tags/dispatch only, NOT main push). Pre-flight: xtask check + xtask site
6	- [how-to subnet → W5 scope](howto-subnet-w5-scope.md) — perri found `spt how-to subnet` dead-ends (NO_SUCH_TOPIC); HOW_TO_TOPICS locked to ready/send (v1, M7 decision 12). Genuine REQ-DOCS-6 content gap. doyle ACCEPTED → scheduled M11-W5 docs wave (HOW_TO_SUBNET text + registry + bump v1-lock test). Confirm landed at W5 gate
7	- [M11-W3 progress](m11-w3-progress.md) — shell TUNNEL (REQ-SHELL-4): doyle design-gate GO; manifest opt-in + broker TunnelHub registry + close-hook LANDED on branch; NEXT = open-hook + the QUIC-stream↔`api tunnel` bridge (dispatch first-record demux + serve_tunnel relay loop), then T3.3 same-node mock E2E. Full next-step detail in the file
8	- [M11-W2 progress](m11-w2-progress.md) — drive channel (REQ-SHELL-3): ALL 4 tasks DONE + PUSHED to PR #16 (T2.1 b919dfd / T2.2 0557bc6 / T2.3 d200524 / T2.4 13fa53e + drift-fix 9d5fb63). REQ-SHELL-3 activated [impl,unit,int]. CI re-running post-drift-fix (run 27590716942); on green → submit W2 to doyle. Full detail in Psyche commune
9	- [CLI command → docs-drift gate](cli-command-docs-drift.md) — adding/changing a `spt` CLI command needs `cargo run -p xtask -- gen` (regen docs-site/src/cli/reference.md) AND no `M#-W#`/`REQ-*` in the clap `///` help (it IS the public --help). Docs-drift gate is CI-gated both runners, fires independent of traceable. M11-W2 first CI run RED on exactly this
10	- [gate against documented design](gate-against-documented-design.md) — gating lesson: verify a wave's premise vs the RECORDED design (CONTEXT.md/ADRs), not just current impl — an impl limitation can contradict a locked design (W2.5 single-subscriber vs locked controller/viewer model; operator caught it)
11	- [grep REQ tags to find impl](grep-req-tags-to-find-impl.md) — operator 2026-06-15: to reconcile is-REQ-X-wired, grep `REQ-X` FIRST — the [impl/int->REQ-X] tags point straight to the code; faster + more authoritative than an agent trace (I burned an agent trace + overstated "false-green" on REQ-DAEMON-1; tags showed it's built+E2E-tested, production-activation staged behind the live-agent adapter)
12	- [public docs: version not milestone](public-docs-version-not-milestone.md) — operator 2026-06-15: published docs-site uses VERSION numbers (since v0.7.0), never internal milestone/wave codes (M12-W3); internal design docs (CONTEXT/MANIFEST/ADRs) keep milestone refs. Grep docs-site edits for M#/W#/wave before commit
13	- [M12 W1 progress](m12-w1-progress.md) — **M12 FULLY GATED end-to-end** (W1·W1.5·HR2·W3·W2·follow-on·W4·W5 + **W2.5 GATE-PASS @6b4859f + gate-#7 combined close-out @f521df2**, attach 12/12 reproduced green, traceable EXIT=0, all 6 conditions + gate-#7 self-kick non-negotiable verified by doyle). M12 publishing (v0.6.0 baseline used to start perri). doyle next: ping perri when M12 publishes + triage any spt-core bug perri reports
14	- [cplugs publish mechanics](cplugs-publish-mechanics.md) — concrete cplugs marketplace publish steps live in claude_skill_owl/docs/DEPLOY.md (+DEPLOY.ps1); spt-claude-code takes only the SKELETON subset (no binary in cplugs — binary+manifest ride spt-core adapter registry). Relayed to perri
15	- [perri question triage protocol](perri-question-triage-protocol.md) — STANDING (operator 2026-06-15): perri builds the adapter blind from PUBLISHED docs + binary only. Every impl Q from her = a diagnostic; classify with SOLID EVIDENCE into 1 of 3 — (1) docs-not-read (point to the section), (2) doc gap (fix docs, verify vs source), (3) missing feature for legacy parity (mint REQ→todlando→gate). Then guide/orchestrate. Never answer from memory. new-alarm = clean case-3
16	- [perri needs reply to continue](perri-needs-reply-to-continue.md) — STANDING (operator 2026-06-15): ALWAYS reply to perri's reports (ack + keep-going + confirm next plan); she treats silence as a stop-point and stalls. todlando self-drives (no nudge needed); perri needs it
17	- [no machine-wide kill on shared runner](no-machinewide-killon-shared-runner.md) — BINDING (doyle 2026-06-15): HFENDULEAM = self-hosted CI runner sharing the box with LIVE agents; never `Get-Process spt|Stop-Process` machine-wide (tears down live broker); scope reaps to $GITHUB_WORKSPACE/target path or a PID. I'd done the unsafe form 3× locally — disclosed
18	- [agent roles](agent-roles.md) — doyle=gater/spt-core owner (don't self-execute big spt-core builds), todlando=spt-core executor, perri=spt-claude-code adapter builder. perri finds gap → doyle designs/dispatches → todlando builds → doyle gates → perri validates
19	- [EVENT envelope + __REPLY_TO__ removal](event-envelope-reply-to-removal.md) — ADR-0020 (2026-06-15 grill-with-docs): `<EVENT type=msg from=…>` envelope = sole arriving-message format at EVERY surface incl api poll; `__REPLY_TO__` mis-elevated relic REMOVED; F-002 dissolves; docs done (ADR+amendments+CONTEXT+REQ-MSG-ENVELOPE), multi-crate IMPL refactor PENDING (poll still emits relic until then); perri parses `<EVENT>` not the relic
20	- [spt-core harness boundary + grounding](spt-core-harness-boundary-and-grounding.md) — BINDING (operator 2026-06-15): spt-core never caters to a specific harness (adapter formats spt-core's output for its harness; don't doc CC-specifics on spt-core surface); ground all completeness Qs in CONTEXT.md/ADRs (thoroughly planned — apparent gaps usually already specified). F-001 disposition: most=adapter glue, residual=publish agnostic primitives
21	- [sptc plugin name ruling](sptc-plugin-name-ruling.md) — spt-claude-code cplugs plugin = `sptc` (interim) → flips to `spt` on proven parity (then retire legacy owl `spt`); CC hard-ties skill prefix to plugin.json name (no override) so /spt: parity needs name=spt, blocked by live legacy owl. Distinct from adapter_name claude-spt
22	- [experimplate traceable seed leaks](experimplate-traceable-seed-leaks.md) — experimplate template ships traceable seed defects (4 tag-leaks + manifest_error in own example docs); perri fixed in-instance via scan-root trim; doyle to fix upstream (scope scan-root / inert example tags) before next adapter reuse

... [34 lines truncated] ...


MEMORY.md update noted. doyle's backpressure refinement needs a per-stream cap (testable) + pump gating + a Notify. Switching `retentive: bool` → `retentive_cap: usize` (0=off) so tests can use a tiny cap. Fix the field + append:


Edit

    /// **Retentive** (M11-W3, REQ-SHELL-4): a shell-tunnel stream must NOT drop
    /// bytes — CONTEXT:262 "tunneled protocols cannot drop frames, so congestion
    /// surfaces as lag, never loss." A retentive log never cap-evicts on append; a
    /// chunk leaves the ring ONLY when [`drain`](Self::drain) (the recv cursor)
    /// pops it. Default `false` keeps the bounded-overwrite discipline for ordinary
    /// (brain-subscribed) streams, where a slow/absent brain must not OOM the
    /// broker. A retentive stream is drained by the sync cursor, never subscribed.
    retentive: bool,
}

    /// **Retentive backpressure cap** (M11-W3, REQ-SHELL-4), in chunks; `0` ⇒ not
    /// retentive (ordinary bounded-overwrite stream). A shell-tunnel stream must
    /// NOT drop bytes — CONTEXT:262 "tunneled protocols cannot drop frames, so
    /// congestion surfaces as lag, never loss." So a retentive log never cap-evicts
    /// on append (no loss), and the read pump REFUSES to pull the next chunk once
    /// the undrained ring reaches this cap (no unbounded growth ⇒ no OOM) — the
    /// transport's native flow-control then lags the *sender*. A chunk leaves the
    /// ring ONLY when [`drain`](Self::drain) (the recv cursor) pops it; the pump is
    /// woken to resume via the entry's `room` notifier. Ordinary streams keep
    /// `retentive_cap = 0` (a slow/absent brain must not OOM the broker via an
    /// unbounded ring) and are drained by subscribe, never the sync cursor.
    retentive_cap: usize,
}


    /// **Retentive** (M11-W3, REQ-SHELL-4): a shell-tunnel stream must NOT drop
    /// bytes — CONTEXT:262 "tunneled protocols cannot drop frames, so congestion
    /// surfaces as lag, never loss." A retentive log never cap-evicts on append; a
    /// chunk leaves the ring ONLY when [`drain`](Self::drain) (the recv cursor)
    /// pops it. Default `false` keeps the bounded-overwrite discipline for ordinary
    /// (brain-subscribed) streams, where a slow/absent brain must not OOM the
    /// broker. A retentive stream is drained by the sync cursor, never subscribed.
    retentive: bool,
}

    /// **Retentive backpressure cap** (M11-W3, REQ-SHELL-4), in chunks; `0` ⇒ not
    /// retentive (ordinary bounded-overwrite stream). A shell-tunnel stream must
    /// NOT drop bytes — CONTEXT:262 "tunneled protocols cannot drop frames, so
    /// congestion surfaces as lag, never loss." So a retentive log never cap-evicts
    /// on append (no loss), and the read pump REFUSES to pull the next chunk once
    /// the undrained ring reaches this cap (no unbounded growth ⇒ no OOM) — the
    /// transport's native flow-control then lags the *sender*. A chunk leaves the
    /// ring ONLY when [`drain`](Self::drain) (the recv cursor) pops it; the pump is
    /// woken to resume via the entry's `room` notifier. Ordinary streams keep
    /// `retentive_cap = 0` (a slow/absent brain must not OOM the broker via an
    /// unbounded ring) and are drained by subscribe, never the sync cursor.
    retentive_cap: usize,
}

/// Read side finished cleanly (peer called finish / conn ended).

-    /// **Retentive** (M11-W3, REQ-SHELL-4): a shell-tunnel stream must NOT drop

-    /// bytes — CONTEXT:262 "tunneled protocols cannot drop frames, so congestion

-    /// surfaces as lag, never loss." A retentive log never cap-evicts on append; a

-    /// chunk leaves the ring ONLY when [`drain`](Self::drain) (the recv cursor)

-    /// pops it. Default `false` keeps the bounded-overwrite discipline for ordinary

-    /// (brain-subscribed) streams, where a slow/absent brain must not OOM the

-    /// broker. A retentive stream is drained by the sync cursor, never subscribed.

+    /// **Retentive backpressure cap** (M11-W3, REQ-SHELL-4), in chunks; `0` ⇒ not

+    /// retentive (ordinary bounded-overwrite stream). A shell-tunnel stream must

+    /// NOT drop bytes — CONTEXT:262 "tunneled protocols cannot drop frames, so

+    /// congestion surfaces as lag, never loss." So a retentive log never cap-evicts

+    /// on append (no loss), and the read pump REFUSES to pull the next chunk once

+    /// the undrained ring reaches this cap (no unbounded growth ⇒ no OOM) — the

+    /// transport's native flow-control then lags the *sender*. A chunk leaves the

+    /// ring ONLY when [`drain`](Self::drain) (the recv cursor) pops it; the pump is

+    /// woken to resume via the entry's `room` notifier. Ordinary streams keep

+    /// `retentive_cap = 0` (a slow/absent brain must not OOM the broker via an

+    /// unbounded ring) and are drained by subscribe, never the sync cursor.


            subscriber: None,
            finished: false,
            retentive: false,
        }
    }

            subscriber: None,
            finished: false,
            retentive_cap: 0,
        }
    }

    /// Mark this log retentive with backpressure `cap` (chunks). Idempotent set.
    fn set_retentive(&mut self, cap: usize) {
        self.retentive_cap = cap;
    }


            subscriber: None,
            finished: false,
            retentive: false,
        }
    }

            subscriber: None,
            finished: false,
            retentive_cap: 0,
        }
    }

    /// Mark this log retentive with backpressure `cap` (chunks). Idempotent set.
    fn set_retentive(&mut self, cap: usize) {
        self.retentive_cap = cap;
    }

+    /// Mark this log retentive with backpressure `cap` (chunks). Idempotent set.

+    fn set_retentive(&mut self, cap: usize) {

/// Append a freshly-read chunk: assign the next `seq`, retain it in the ring,

/// live-send to the current subscriber. Read-pump only. A non-retentive log

/// cap-evicts the oldest chunks past `cap_chunks`; a retentive (tunnel) log


        self.ring.push_back((seq, bytes.to_vec()));
        if !self.retentive {
            while self.ring.len() > self.cap_chunks {
                self.ring.pop_front();
            }
        }

        self.ring.push_back((seq, bytes.to_vec()));
        if self.retentive_cap == 0 {
            while self.ring.len() > self.cap_chunks {
                self.ring.pop_front();
            }
        }


        self.ring.push_back((seq, bytes.to_vec()));
        if !self.retentive {
            while self.ring.len() > self.cap_chunks {
                self.ring.pop_front();
            }
        }

        self.ring.push_back((seq, bytes.to_vec()));
        if self.retentive_cap == 0 {
            while self.ring.len() > self.cap_chunks {
                self.ring.pop_front();
            }
        }


Now add the `room` notifier to StreamEntry + gate the read pump. Read the StreamEntry struct: