100	// [impl->REQ-HAZARD-VIEWER-ISOLATION]
101	fn viewer_send_evicts<T>(r: &Result<(), TrySendError<T>>) -> bool {
102	    r.is_err()
103	}
104
105	/// One read-only viewer's isolated sink (REQ-HAZARD-VIEWER-ISOLATION). The drain
106	/// thread `try_send`s output frames into `tx` (a bounded queue); a dedicated
107	/// `writer` thread does the BLOCKING socket write, so a wedged viewer backs up
108	/// only its own queue — never the drain. When the queue overflows the sink is
109	/// dropped (the `tx` closes → the writer ends) and removed from the map.
110	struct ViewerSink {
111	    /// The bounded live-frame queue to this viewer's writer thread.
112	    tx: SyncSender<Envelope>,
113	    /// The viewer's IPC send half — kept ONLY for identity matching on detach
114	    /// (a dropped brain connection removes its viewer rows); never written here.
115	    send: SharedSend,
116	    /// The writer thread handle (held for ownership; it exits when `tx` drops or
117	    /// a socket write fails).
118	    _writer: JoinHandle<()>,
119	}
120
121	/// The controller live-frame channel depth (frames). Deeper than a viewer's
122	/// queue because the controller is the AUTHORITATIVE view — unlike a viewer it is
123	/// NOT evicted on a SINGLE full `try_send` (W1 / REQ-HAZARD-INJECT-CONTROL-COEXIST).
124	/// b4: the drain does ONE non-blocking `try_send` per chunk and NEVER blocks; a
125	/// merely-slow controller DROPS chunks + falls behind the ring, and only a channel
126	/// that stays full for [`CONTROLLER_WRITE_DEADLINE`] (tracked via
127	/// [`ControllerSink::last_ok`]) is detached. Generous so a momentary slow consumer
128	/// never trips the deadline.
129	const CONTROLLER_CHANNEL_DEPTH: usize = 4096;
130
131	/// How long a CONTROLLER's channel may stay FULL (no successful handoff since
132	/// [`ControllerSink::last_ok`]) before the drain declares it stalled and detaches it
133	/// (W1, refined by b4 / REQ-HAZARD-VIEWER-STARVE-UNDER-CONTROLLER-BACKPRESSURE). This
134	/// BOUNDS the wedge: a backed-up controller falls behind for at most this long before
135	/// eviction — never forever (the v0.12.x root) — and the drain NEVER blocks or sleeps
136	/// (a single off-lock `try_send` per chunk; see [`OutputLog::append`]). A live but
137	/// merely slow operator lands a frame before this elapses (resetting `last_ok`) and is
138	/// untouched.
139	const CONTROLLER_WRITE_DEADLINE: Duration = Duration::from_secs(5);
140
141	/// MANDATORY bound on one injected keystroke sequence (W2 Layer C, ADR-0022 /
142	/// doyle non-negotiable = W1's "never park forever"): the inject worker holds the
143	/// floor — buffering operator input — only while a binary's response to one
144	/// `{type:event}` is in flight. If no `{commit}` arrives within this window the
145	/// worker FLUSHES the buffer, RELEASES the floor, FAULTS the binary (terminate, no
146	/// respawn → the session degrades to raw inject), and never re-opens. A wedged or
147	/// buggy translation binary can therefore stall operator input for at most this
148	/// long. Mirrors [`CONTROLLER_WRITE_DEADLINE`]'s order (5 s).
149	const INJECT_COMMIT_DEADLINE: Duration = Duration::from_secs(5);
150
151	/// The effective commit deadline. Production is the const above; the int gate
152	/// (Layer G no-commit FAULT test) shrinks it via `SPT_INJECT_COMMIT_DEADLINE_MS`
153	/// so the structural outcome (floor releases → buffered input flushes → binary
154	/// faults) is asserted in well under a second rather than a 5 s wall-clock wait —
155	/// the deadline's PRESENCE is the invariant, not its exact length.
156	fn inject_commit_deadline() -> Duration {
157	    match std::env::var("SPT_INJECT_COMMIT_DEADLINE_MS") {
158	        Ok(ms) => ms
159	            .parse::<u64>()
160	            .map(Duration::from_millis)
161	            .unwrap_or(INJECT_COMMIT_DEADLINE),
162	        Err(_) => INJECT_COMMIT_DEADLINE,
163	    }
164	}
165
166	/// The interactive CONTROLLER's isolated sink (W1 / REQ-HAZARD-INJECT-CONTROL-COEXIST).
167	/// Symmetric with [`ViewerSink`] — a dedicated writer thread does the BLOCKING
168	/// socket write so a backed-up controller can never stall the session drain
169	/// thread (the v0.12.x wedge: the controller write was inline on the drain,
170	/// holding `Mutex<OutputLog>`). Unlike a viewer it is AUTHORITATIVE: its writer
171	/// advances `delivered_through` on each successful write, and it is never evicted
172	/// on a single full `try_send` — only after [`CONTROLLER_WRITE_DEADLINE`].
173	struct ControllerSink {
174	    /// The bounded live-frame queue to the controller's writer thread.
175	    tx: SyncSender<CtrlMsg>,
176	    /// The controller's IPC send half — kept for identity matching on detach and
177	    /// for the `exit`-frame fan-out ([`OutputLog::all_sinks`]).
178	    send: SharedSend,
179	    /// The controller's identity: `Some(node)` = a remote operator, `None` =
180	    /// local (the brain's spawn-reader / resume). Ownership keys on this.
181	    by: Option<String>,
182	    /// Time of the last SUCCESSFUL handoff (`try_send` Ok) to this controller's
183	    /// writer channel (b4, REQ-HAZARD-VIEWER-STARVE-UNDER-CONTROLLER-BACKPRESSURE).
184	    /// The drain does a SINGLE non-blocking `try_send` per chunk and NEVER sleeps;
185	    /// a channel that stays Full for [`CONTROLLER_WRITE_DEADLINE`] PAST this stamp
186	    /// evicts the stalled controller (bounded wedge preserved), while a slow-but-
187	    /// alive controller just drops the chunk + falls behind the ring (resume-from-
188	    /// floor). Seeded `now` at [`OutputLog::become_controller`].
189	    last_ok: Instant,
190	    /// The writer thread handle (held for ownership; exits when `tx` drops — the
191	    /// drain's bounded `send_timeout` then sees `Disconnected` and detaches — or
192	    /// when a socket write fails).
193	    _writer: JoinHandle<()>,
194	}
195
196	/// A bounded, sequenced output log for one session — the gapless-resume buffer
197	/// AND the W2.5 controller/viewer fan-out hub (REQ-RCVIEW-1).
198	///
199	/// The session's single drain thread is the only producer ([`OutputLog::append`]).
200	/// Output fans out to ONE interactive `controller` (the authoritative,
201	/// cursor-advancing, blocking path) plus ANY NUMBER of read-only `viewers` (each
202	/// an isolated bounded queue + writer thread, evicted on overflow). Every
203	/// controller send and viewer `try_send` happens while the enclosing
204	/// `Mutex<OutputLog>` is held, so frames reach each sink in strict `seq` order.
205	struct OutputLog {
206	    session_id: u64,
207	    next_seq: u64,
208	    ring: VecDeque<(u64, Vec<u8>)>,
209	    cap_chunks: usize,
210	    /// The single interactive controller's isolated sink (W1: a dedicated writer
211	    /// thread + bounded channel + authoritative cursor). `None` ⇒ undriven. The
212	    /// controller's identity (`Some(node)` remote / `None` local) lives in
213	    /// [`ControllerSink::by`] and keys ownership — a same-`by` re-subscribe
214	    /// silently re-takes (no `Displaced`, the gate-#7 self-kick guard); only a
215	    /// DIFFERENT remote's `Take` displaces.
216	    controller: Option<ControllerSink>,
217	    /// Monotonic controller generation, shared with the live `controller_writer`
218	    /// thread (`Arc<AtomicU64>`). Bumped on every [`become_controller`]; the
219	    /// drain's deadline-evict ([`mark_controller_gone`]) carries the epoch it

// [impl->REQ-HAZARD-VIEWER-ISOLATION]
fn viewer_send_evicts<T>(r: &Result<(), TrySendError<T>>) -> bool {
    r.is_err()
}

/// One read-only viewer's isolated sink (REQ-HAZARD-VIEWER-ISOLATION). The drain
/// thread `try_send`s output frames into `tx` (a bounded queue); a dedicated
/// `writer` thread does the BLOCKING socket write, so a wedged viewer backs up
/// only its own queue — never the drain. When the queue overflows the sink is
/// dropped (the `tx` closes → the writer ends) and removed from the map.
struct ViewerSink {
    /// The bounded live-frame queue to this viewer's writer thread.
    tx: SyncSender<Envelope>,
    /// The viewer's IPC send half — kept ONLY for identity matching on detach
    /// (a dropped brain connection removes its viewer rows); never written here.
    send: SharedSend,
    /// The writer thread handle (held for ownership; it exits when `tx` drops or
    /// a socket write fails).
    _writer: JoinHandle<()>,
}

/// The controller live-frame channel depth (frames). Deeper than a viewer's
/// queue because the controller is the AUTHORITATIVE view — unlike a viewer it is
/// NOT evicted on a SINGLE full `try_send` (W1 / REQ-HAZARD-INJECT-CONTROL-COEXIST).
/// b4: the drain does ONE non-blocking `try_send` per chunk and NEVER blocks; a
/// merely-slow controller DROPS chunks + falls behind the ring, and only a channel
/// that stays full for [`CONTROLLER_WRITE_DEADLINE`] (tracked via
/// [`ControllerSink::last_ok`]) is detached. Generous so a momentary slow consumer
/// never trips the deadline.
const CONTROLLER_CHANNEL_DEPTH: usize = 4096;

/// How long a CONTROLLER's channel may stay FULL (no successful handoff since
/// [`ControllerSink::last_ok`]) before the drain declares it stalled and detaches it
/// (W1, refined by b4 / REQ-HAZARD-VIEWER-STARVE-UNDER-CONTROLLER-BACKPRESSURE). This
/// BOUNDS the wedge: a backed-up controller falls behind for at most this long before
/// eviction — never forever (the v0.12.x root) — and the drain NEVER blocks or sleeps
/// (a single off-lock `try_send` per chunk; see [`OutputLog::append`]). A live but
/// merely slow operator lands a frame before this elapses (resetting `last_ok`) and is
/// untouched.
const CONTROLLER_WRITE_DEADLINE: Duration = Duration::from_secs(5);

/// MANDATORY bound on one injected keystroke sequence (W2 Layer C, ADR-0022 /
/// doyle non-negotiable = W1's "never park forever"): the inject worker holds the
/// floor — buffering operator input — only while a binary's response to one
/// `{type:event}` is in flight. If no `{commit}` arrives within this window the
/// worker FLUSHES the buffer, RELEASES the floor, FAULTS the binary (terminate, no
/// respawn → the session degrades to raw inject), and never re-opens. A wedged or
/// buggy translation binary can therefore stall operator input for at most this
/// long. Mirrors [`CONTROLLER_WRITE_DEADLINE`]'s order (5 s).
const INJECT_COMMIT_DEADLINE: Duration = Duration::from_secs(5);

/// The effective commit deadline. Production is the const above; the int gate
/// (Layer G no-commit FAULT test) shrinks it via `SPT_INJECT_COMMIT_DEADLINE_MS`
/// so the structural outcome (floor releases → buffered input flushes → binary
/// faults) is asserted in well under a second rather than a 5 s wall-clock wait —
/// the deadline's PRESENCE is the invariant, not its exact length.
fn inject_commit_deadline() -> Duration {
    match std::env::var("SPT_INJECT_COMMIT_DEADLINE_MS") {
        Ok(ms) => ms
            .parse::<u64>()
            .map(Duration::from_millis)
            .unwrap_or(INJECT_COMMIT_DEADLINE),
        Err(_) => INJECT_COMMIT_DEADLINE,
    }
}

/// The interactive CONTROLLER's isolated sink (W1 / REQ-HAZARD-INJECT-CONTROL-COEXIST).
/// Symmetric with [`ViewerSink`] — a dedicated writer thread does the BLOCKING
/// socket write so a backed-up controller can never stall the session drain
/// thread (the v0.12.x wedge: the controller write was inline on the drain,
/// holding `Mutex<OutputLog>`). Unlike a viewer it is AUTHORITATIVE: its writer
/// advances `delivered_through` on each successful write, and it is never evicted
/// on a single full `try_send` — only after [`CONTROLLER_WRITE_DEADLINE`].
struct ControllerSink {
    /// The bounded live-frame queue to the controller's writer thread.
    tx: SyncSender<CtrlMsg>,
    /// The controller's IPC send half — kept for identity matching on detach and
    /// for the `exit`-frame fan-out ([`OutputLog::all_sinks`]).
    send: SharedSend,
    /// The controller's identity: `Some(node)` = a remote operator, `None` =
    /// local (the brain's spawn-reader / resume). Ownership keys on this.
    by: Option<String>,
    /// Time of the last SUCCESSFUL handoff (`try_send` Ok) to this controller's
    /// writer channel (b4, REQ-HAZARD-VIEWER-STARVE-UNDER-CONTROLLER-BACKPRESSURE).
    /// The drain does a SINGLE non-blocking `try_send` per chunk and NEVER sleeps;
    /// a channel that stays Full for [`CONTROLLER_WRITE_DEADLINE`] PAST this stamp
    /// evicts the stalled controller (bounded wedge preserved), while a slow-but-
    /// alive controller just drops the chunk + falls behind the ring (resume-from-
    /// floor). Seeded `now` at [`OutputLog::become_controller`].
    last_ok: Instant,
    /// The writer thread handle (held for ownership; exits when `tx` drops — the
    /// drain's bounded `send_timeout` then sees `Disconnected` and detaches — or
    /// when a socket write fails).
    _writer: JoinHandle<()>,
}

/// A bounded, sequenced output log for one session — the gapless-resume buffer
/// AND the W2.5 controller/viewer fan-out hub (REQ-RCVIEW-1).
///
/// The session's single drain thread is the only producer ([`OutputLog::append`]).
/// Output fans out to ONE interactive `controller` (the authoritative,
/// cursor-advancing, blocking path) plus ANY NUMBER of read-only `viewers` (each
/// an isolated bounded queue + writer thread, evicted on overflow). Every
/// controller send and viewer `try_send` happens while the enclosing
/// `Mutex<OutputLog>` is held, so frames reach each sink in strict `seq` order.
struct OutputLog {
    session_id: u64,
    next_seq: u64,
    ring: VecDeque<(u64, Vec<u8>)>,
    cap_chunks: usize,
    /// The single interactive controller's isolated sink (W1: a dedicated writer
    /// thread + bounded channel + authoritative cursor). `None` ⇒ undriven. The
    /// controller's identity (`Some(node)` remote / `None` local) lives in
    /// [`ControllerSink::by`] and keys ownership — a same-`by` re-subscribe
    /// silently re-takes (no `Displaced`, the gate-#7 self-kick guard); only a
    /// DIFFERENT remote's `Take` displaces.
    controller: Option<ControllerSink>,
    /// Monotonic controller generation, shared with the live `controller_writer`
    /// thread (`Arc<AtomicU64>`). Bumped on every [`become_controller`]; the
    /// drain's deadline-evict ([`mark_controller_gone`]) carries the epoch it


Bash

grep -rn "fn serve_attach\|enum AttachRecord\|enum AttachIntent\|EVICT\|Evict\|evict" crates/spt-daemon/src/attach.rs crates/spt-daemon/src/msg.rs crates/spt-net/src/net/attach.rs 2>/dev/null | head -40

Find serve_attach and AttachRecord wire types


crates/spt-daemon/src/attach.rs:107:pub fn serve_attach(
crates/spt-daemon/src/msg.rs:615:    /// A1/A2 gates) and RESERVED for the eventual broker-side A2 eviction
crates/spt-daemon/src/msg.rs:616:    /// ([`REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT`]) — the production "wedged remote
crates/spt-net/src/net/attach.rs:44:pub enum AttachIntent {
crates/spt-net/src/net/attach.rs:65:pub enum AttachRecord {


40	/// silently steal a controlled one (it gets the busy-refusal reply instead).
41	// [impl->REQ-RCVIEW-1] [impl->REQ-KICK-1]
42	#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
43	#[serde(rename_all = "snake_case")]
44	pub enum AttachIntent {
45	    /// Read-only viewer: output only, no input, no resize. Never displaces a
46	    /// controller; coexists with any number of other viewers.
47	    Viewer,
48	    /// Become the interactive controller — but only of a FREE endpoint; a
49	    /// `Control` request to a controlled endpoint is refused with guidance.
50	    #[default]
51	    Control,
52	    /// Kick the incumbent controller (loud [`AttachRecord::Displaced`] notice to
53	    /// them) and take control in one motion (`spt rc --take` / picker Kick).
54	    Take,
55	}
56
57	/// One attach-protocol record. Tagged (`kind`) so both directions share one
58	/// stream decoder; an unknown future kind fails the line's decode and is
59	/// skipped (degrade, don't die — same posture as every wire feed). The
60	/// W2.5 additions (`intent`, `Resize`, `Size`, `Displaced`) are additive: an
61	/// N-1 peer omits `intent` (→ `Control`) and skips the unknown record kinds.
62	// [impl->REQ-INST-8] [impl->REQ-RCVIEW-1]
63	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64	#[serde(tag = "kind", rename_all = "snake_case")]
65	pub enum AttachRecord {
66	    /// Operator → target, first record on the stream: attach to `session_id`,
67	    /// replaying output from `from_seq` (the operator's resume cursor — 0 for
68	    /// a fresh attach, the last-rendered seq + 1 on re-attach). `intent` selects
69	    /// viewer / control / take (defaulted to `Control` for N-1 operators).
70	    Request {
71	        session_id: u64,
72	        from_seq: u64,
73	        #[serde(default)]
74	        intent: AttachIntent,
75	    },
76	    /// Target → operator: one session output chunk. `seq` is the target
77	    /// session's `OutputLog` sequence verbatim (the operator's dedup cursor).
78	    Output { seq: u64, data_b64: String },
79	    /// Operator → target: input bytes for the session's PTY. `op_id` is the
80	    /// operator-minted durable id the target feeds into its journaled
81	    /// exactly-once input path (a replayed record cannot double-type). Honored
82	    /// only from the CONTROLLER's stream (a viewer sends none).
83	    Input { data_b64: String, op_id: u64 },
84	    /// Operator(controller) → target: resize the PTY to the controller's viewport.
85	    /// Controller-EXCLUSIVE — the broker rejects a `Resize` arriving on a viewer's
86	    /// stream (REQ-RCVIEW-1, resize is controller-exclusive for ConPTY repaint cost).
87	    Resize { rows: u16, cols: u16 },
88	    /// Target → operator(viewer): the current PTY size, sent on a viewer's attach
89	    /// and on every controller resize, so the viewer letterboxes client-side
90	    /// (never resizing the PTY itself).
91	    Size { rows: u16, cols: u16 },
92	    /// Target → operator: the session's child exited; the viewport is done.
93	    Exit { code: Option<i32> },
94	    /// Target → operator(controller): you have been displaced as controller by
95	    /// `by` (a `--take`/`kick`). The displaced controller renders this LOUD notice
96	    /// and detaches — it is NOT demoted to a viewer (REQ-KICK-1).
97	    Displaced { by: String },
98	}
99
100	impl AttachRecord {
101	    /// Encode as one NDJSON line (the JSON document + `\n`).
102	    pub fn encode_line(&self) -> Vec<u8> {
103	        ndjson::encode_line(self)
104	    }
105	}
106
107	/// The attach stream's NDJSON reassembler — the shared
108	/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
109	/// [`AttachRecord`]s. One decoder per stream side.
110	pub type AttachDecoder = NdjsonDecoder<AttachRecord>;
111
112	#[cfg(test)]
113	mod tests {
114	    use super::*;
115
116	    // [unit->REQ-INST-8] every record kind round-trips through the NDJSON line
117	    // encoding, and the decoder skips an unknown future kind without wedging
118	    // the stream (forward compat: degrade, don't die).
119	    #[test]
120	    fn records_round_trip_and_unknown_kind_is_skipped() {
121	        let records = vec![
122	            AttachRecord::Request {
123	                session_id: 7,
124	                from_seq: 0,
125	                intent: AttachIntent::Control,
126	            },
127	            AttachRecord::Output {
128	                seq: 3,
129	                data_b64: "aGk=".into(),
130	            },
131	            AttachRecord::Input {
132	                data_b64: "bHM=".into(),
133	                op_id: 42,
134	            },
135	            AttachRecord::Resize { rows: 40, cols: 120 },
136	            AttachRecord::Size { rows: 40, cols: 120 },
137	            AttachRecord::Exit { code: Some(0) },
138	            AttachRecord::Displaced { by: "hfenduleam".into() },
139	        ];
140	        let mut wire = Vec::new();
141	        for r in &records {
142	            wire.extend_from_slice(&r.encode_line());
143	        }
144	        // An unknown future kind in the middle of the feed is skipped.
145	        let mut with_junk = records[0].encode_line();
146	        with_junk.extend_from_slice(b"{\"kind\":\"added_at_d9\",\"x\":1}\n");
147	        with_junk.extend_from_slice(&records[3].encode_line());
148
149	        let mut dec = AttachDecoder::new();
150	        assert_eq!(dec.push(&wire), records, "all kinds round-trip in order");
151
152	        let mut dec = AttachDecoder::new();
153	        assert_eq!(
154	            dec.push(&with_junk),
155	            vec![records[0].clone(), records[3].clone()],
156	            "unknown kind skipped, feed not wedged"
157	        );
158	    }
159

/// silently steal a controlled one (it gets the busy-refusal reply instead).
// [impl->REQ-RCVIEW-1] [impl->REQ-KICK-1]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AttachIntent {
    /// Read-only viewer: output only, no input, no resize. Never displaces a
    /// controller; coexists with any number of other viewers.
    Viewer,
    /// Become the interactive controller — but only of a FREE endpoint; a
    /// `Control` request to a controlled endpoint is refused with guidance.
    #[default]
    Control,
    /// Kick the incumbent controller (loud [`AttachRecord::Displaced`] notice to
    /// them) and take control in one motion (`spt rc --take` / picker Kick).
    Take,
}

/// One attach-protocol record. Tagged (`kind`) so both directions share one
/// stream decoder; an unknown future kind fails the line's decode and is
/// skipped (degrade, don't die — same posture as every wire feed). The
/// W2.5 additions (`intent`, `Resize`, `Size`, `Displaced`) are additive: an
/// N-1 peer omits `intent` (→ `Control`) and skips the unknown record kinds.
// [impl->REQ-INST-8] [impl->REQ-RCVIEW-1]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AttachRecord {
    /// Operator → target, first record on the stream: attach to `session_id`,
    /// replaying output from `from_seq` (the operator's resume cursor — 0 for
    /// a fresh attach, the last-rendered seq + 1 on re-attach). `intent` selects
    /// viewer / control / take (defaulted to `Control` for N-1 operators).
    Request {
        session_id: u64,
        from_seq: u64,
        #[serde(default)]
        intent: AttachIntent,
    },
    /// Target → operator: one session output chunk. `seq` is the target
    /// session's `OutputLog` sequence verbatim (the operator's dedup cursor).
    Output { seq: u64, data_b64: String },
    /// Operator → target: input bytes for the session's PTY. `op_id` is the
    /// operator-minted durable id the target feeds into its journaled
    /// exactly-once input path (a replayed record cannot double-type). Honored
    /// only from the CONTROLLER's stream (a viewer sends none).
    Input { data_b64: String, op_id: u64 },
    /// Operator(controller) → target: resize the PTY to the controller's viewport.
    /// Controller-EXCLUSIVE — the broker rejects a `Resize` arriving on a viewer's
    /// stream (REQ-RCVIEW-1, resize is controller-exclusive for ConPTY repaint cost).
    Resize { rows: u16, cols: u16 },
    /// Target → operator(viewer): the current PTY size, sent on a viewer's attach
    /// and on every controller resize, so the viewer letterboxes client-side
    /// (never resizing the PTY itself).
    Size { rows: u16, cols: u16 },
    /// Target → operator: the session's child exited; the viewport is done.
    Exit { code: Option<i32> },
    /// Target → operator(controller): you have been displaced as controller by
    /// `by` (a `--take`/`kick`). The displaced controller renders this LOUD notice
    /// and detaches — it is NOT demoted to a viewer (REQ-KICK-1).
    Displaced { by: String },
}

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

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

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

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

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

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


107	pub fn serve_attach(
108	    brain: &mut Brain,
109	    broker_name: &str,
110	    stream_id: u64,
111	    endpoint: &str,
112	    origin_node: &str,
113	    net_from_seq: u64,
114	) -> io::Result<AttachServeOutcome> {
115	    brain.net_stream_subscribe(stream_id, net_from_seq)?;
116	    // TWO-CONN SPLIT (REQ-HAZARD-ENDPOINT-RUN-ATTACH-OUTPUT): the wire FORWARDS
117	    // (session Output / Size / Exit / Displaced / detach-finish onto the operator
118	    // stream) ride a SEPARATE broker connection from `brain`. `brain` RECEIVES
119	    // session output (broker→serve) — including the controller's synchronous,
120	    // inline, multi-frame ring replay (`become_controller`), which holds the
121	    // broker's single-threaded per-conn handler for the whole replay. If the
122	    // forwards rode `brain` too, that stuck handler could not read them →
123	    // serve→broker backs up while broker→serve replay backs up → mutual IPC
124	    // backpressure DEADLOCK, and the operator receives ZERO bytes (the bug: an
125	    // attach to an ALREADY-PRODUCING endpoint-run harness, non-empty ring). The
126	    // two directions on independent single-threaded handlers cannot block each
127	    // other. `send_stream` is stream_id-keyed (conn-agnostic), so forwarding from
128	    // a second conn is correct. Order is preserved: this one serve thread reads
129	    // every Output in seq order on `brain` and forwards it in that same order on
130	    // `wire`. (DEEPER ROOT, post-v0.12.1 follow-up: the CONTROLLER replay is a
131	    // SYNCHRONOUS inline write, while VIEWERS use the async dedicated-writer-
132	    // thread + bounded-channel pattern (`add_viewer`/`viewer_writer`); unifying
133	    // the controller onto that async path would remove the inline-replay block at
134	    // the source, but it touches the authoritative cursor-advancing path.)
135	    let mut wire = Brain::cold_start(broker_name, now_ms())?;
136	    let mut decoder = AttachDecoder::new();
137	    let mut attached = false;
138	    // The Request's intent — gates wire input (a viewer's keystrokes never reach
139	    // the PTY) independent of the broker's later role reply (no IPC-ordering
140	    // race on the input gate).
141	    let mut intent = AttachIntent::Control;
142	    // The broker-confirmed role (arrives as `Subscribed`). `None` until then:
143	    // controller input that races ahead of the reply is BUFFERED, not dropped
144	    // (the replayed-input-after-restart case) and not leaked to the PTY before
145	    // we know we control the session (the busy-refuse case).
146	    let mut role: Option<ServeRole> = None;
147	    let mut pending_input: Vec<(u64, Vec<u8>)> = Vec::new();
148	    // The attached session — captured from the Request so the terminal paths can
149	    // explicitly release this worker's role (clearing driven_by / viewer_count
150	    // promptly, not only on connection drop).
151	    let mut attached_session: Option<u64> = None;
152
153	    loop {
154	        match brain.read_event()? {
155	            // ── Wire → session: requests, input, resize. ───────────────────
156	            BrokerEvent::NetStreamData {
157	                stream_id: sid,
158	                bytes,
159	                ..
160	            } if sid == stream_id => {
161	                for rec in decoder.push(&bytes) {
162	                    match rec {
163	                        AttachRecord::Request {
164	                            session_id,
165	                            from_seq,
166	                            intent: req_intent,
167	                        } => {
168	                            // Unsolicited wire inbound — the ADR-0009 gate, under
169	                            // the transport-proven origin. Viewing is gated
170	                            // IDENTICALLY to driving in v1 (REQ-RCVIEW-1, Gap A:
171	                            // watching reveals full session contents).
172	                            if access_check(endpoint, origin_node, InboundClass::Unsolicited)
173	                                == AccessDecision::Deny
174	                            {
175	                                wire.net_stream_send(stream_id, &[], None, true)?;
176	                                return Ok(AttachServeOutcome::Refused);
177	                            }
178	                            intent = req_intent;
179	                            attached_session = Some(session_id);
180	                            // Role-aware subscribe (REQ-RCVIEW-1): the broker
181	                            // resolves controller / viewer / busy / take by the
182	                            // intent + this handshake-proven origin, and replies a
183	                            // `Subscribed`. driven_by / viewer_count are now
184	                            // BROKER-owned (single writer — resolves the
185	                            // clear-race), so this loop no longer stamps them.
186	                            brain.attach_as(session_id, from_seq, req_intent, Some(origin_node))?;
187	                            attached = true;
188	                        }
189	                        AttachRecord::Input { data_b64, op_id }
190	                            if attached && intent != AttachIntent::Viewer =>
191	                        {
192	                            let bytes = decode_bytes(&data_b64)
193	                                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
194	                            match role {
195	                                // Confirmed controller — exactly-once at the PTY
196	                                // (the operator-minted id rides the B5 journal). NO-ACK
197	                                // (REQ-HAZARD-INPUT-ACK-BACKPRESSURE): this path is
198	                                // fire-and-forward (the ack is discarded here) and can
199	                                // flood N input frames on this one conn; an acked write
200	                                // back would fill the return direction and deadlock the
201	                                // broker's per-conn handler. Dedup still holds at the
202	                                // broker (op_id applied-set).
203	                                // [impl->REQ-HAZARD-INPUT-ACK-BACKPRESSURE]
204	                                Some(ServeRole::Controller) => {
205	                                    brain.send_effect_no_ack(op_id, &bytes)?
206	                                }
207	                                // Outcome not yet known — buffer; flushed on a
208	                                // controller confirmation, discarded on busy/viewer.
209	                                None => pending_input.push((op_id, bytes)),
210	                                // A viewer never reaches here (intent gate).
211	                                Some(ServeRole::Viewer) => {}
212	                            }
213	                        }
214	                        AttachRecord::Resize { rows, cols }
215	                            if attached && intent != AttachIntent::Viewer =>
216	                        {
217	                            // Controller → PTY resize (controller-EXCLUSIVE; the
218	                            // broker rejects it on a viewer's stream). A viewer's
219	                            // Resize is dropped here by the intent gate too.
220	                            let _ = brain.resize(rows, cols);
221	                        }
222	                        // Input/Resize before Request, a viewer's input/resize,
223	                        // or target-direction records echoed back: noise — ignore.
224	                        _ => {}
225	                    }
226	                }
227	            }
228
229	            // ── Broker → loop: the resolved controller/viewer role. ─────────
230	            BrokerEvent::Subscribed { outcome, .. } => {
231	                use crate::msg::SubscribeOutcome as O;
232	                match outcome {
233	                    O::Controller | O::TookControl => {
234	                        role = Some(ServeRole::Controller);
235	                        // A driver attached — the wake edge (controller-only;
236	                        // idempotent on an already-active seat).
237	                        feed_rest(endpoint, crate::resting::RestEvent::Wake);
238	                        // Flush any input that raced ahead of the reply.
239	                        for (op, b) in pending_input.drain(..) {
240	                            brain.send_effect(op, &b)?;
241	                        }
242	                    }
243	                    O::Viewer => {
244	                        role = Some(ServeRole::Viewer);
245	                        // Wake-neutral: a viewer never wakes the endpoint, never
246	                        // keeps it awake (Gap B). Discard any racing input.
247	                        pending_input.clear();
248	                    }
249	                    O::BusyControlled { .. } => {
250	                        // A different operator controls it — refuse (no PTY input
251	                        // ever reached it; the buffer is discarded). The rc
252	                        // renders the busy guidance client-side from driven_by.
253	                        wire.net_stream_send(stream_id, &[], None, true)?;
254	                        return Ok(AttachServeOutcome::Refused);
255	                    }
256	                }
257	            }
258	            // Viewer letterbox feed: the current PTY size (on attach + resize).
259	            BrokerEvent::Size { rows, cols, .. } if attached => {
260	                let line = ndjson::encode_line(&AttachRecord::Size { rows, cols });
261	                wire.net_stream_send(stream_id, &line, None, false)?;
262	            }
263	            // This controller was kicked by a different operator's `--take`.
264	            BrokerEvent::Displaced { by, .. } if attached => {
265	                let line = ndjson::encode_line(&AttachRecord::Displaced { by });
266	                wire.net_stream_send(stream_id, &line, None, true)?;

pub fn serve_attach(
    brain: &mut Brain,
    broker_name: &str,
    stream_id: u64,
    endpoint: &str,
    origin_node: &str,
    net_from_seq: u64,
) -> io::Result<AttachServeOutcome> {
    brain.net_stream_subscribe(stream_id, net_from_seq)?;
    // TWO-CONN SPLIT (REQ-HAZARD-ENDPOINT-RUN-ATTACH-OUTPUT): the wire FORWARDS
    // (session Output / Size / Exit / Displaced / detach-finish onto the operator
    // stream) ride a SEPARATE broker connection from `brain`. `brain` RECEIVES
    // session output (broker→serve) — including the controller's synchronous,
    // inline, multi-frame ring replay (`become_controller`), which holds the
    // broker's single-threaded per-conn handler for the whole replay. If the
    // forwards rode `brain` too, that stuck handler could not read them →
    // serve→broker backs up while broker→serve replay backs up → mutual IPC
    // backpressure DEADLOCK, and the operator receives ZERO bytes (the bug: an
    // attach to an ALREADY-PRODUCING endpoint-run harness, non-empty ring). The
    // two directions on independent single-threaded handlers cannot block each
    // other. `send_stream` is stream_id-keyed (conn-agnostic), so forwarding from
    // a second conn is correct. Order is preserved: this one serve thread reads
    // every Output in seq order on `brain` and forwards it in that same order on
    // `wire`. (DEEPER ROOT, post-v0.12.1 follow-up: the CONTROLLER replay is a
    // SYNCHRONOUS inline write, while VIEWERS use the async dedicated-writer-
    // thread + bounded-channel pattern (`add_viewer`/`viewer_writer`); unifying
    // the controller onto that async path would remove the inline-replay block at
    // the source, but it touches the authoritative cursor-advancing path.)
    let mut wire = Brain::cold_start(broker_name, now_ms())?;
    let mut decoder = AttachDecoder::new();
    let mut attached = false;
    // The Request's intent — gates wire input (a viewer's keystrokes never reach
    // the PTY) independent of the broker's later role reply (no IPC-ordering
    // race on the input gate).
    let mut intent = AttachIntent::Control;
    // The broker-confirmed role (arrives as `Subscribed`). `None` until then:
    // controller input that races ahead of the reply is BUFFERED, not dropped
    // (the replayed-input-after-restart case) and not leaked to the PTY before
    // we know we control the session (the busy-refuse case).
    let mut role: Option<ServeRole> = None;
    let mut pending_input: Vec<(u64, Vec<u8>)> = Vec::new();
    // The attached session — captured from the Request so the terminal paths can
    // explicitly release this worker's role (clearing driven_by / viewer_count
    // promptly, not only on connection drop).
    let mut attached_session: Option<u64> = None;

    loop {
        match brain.read_event()? {
            // ── Wire → session: requests, input, resize. ───────────────────
            BrokerEvent::NetStreamData {
                stream_id: sid,
                bytes,
                ..
            } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        AttachRecord::Request {
                            session_id,
                            from_seq,
                            intent: req_intent,
                        } => {
                            // Unsolicited wire inbound — the ADR-0009 gate, under
                            // the transport-proven origin. Viewing is gated
                            // IDENTICALLY to driving in v1 (REQ-RCVIEW-1, Gap A:
                            // watching reveals full session contents).
                            if access_check(endpoint, origin_node, InboundClass::Unsolicited)
                                == AccessDecision::Deny
                            {
                                wire.net_stream_send(stream_id, &[], None, true)?;
                                return Ok(AttachServeOutcome::Refused);
                            }
                            intent = req_intent;
                            attached_session = Some(session_id);
                            // Role-aware subscribe (REQ-RCVIEW-1): the broker
                            // resolves controller / viewer / busy / take by the
                            // intent + this handshake-proven origin, and replies a
                            // `Subscribed`. driven_by / viewer_count are now
                            // BROKER-owned (single writer — resolves the
                            // clear-race), so this loop no longer stamps them.
                            brain.attach_as(session_id, from_seq, req_intent, Some(origin_node))?;
                            attached = true;
                        }
                        AttachRecord::Input { data_b64, op_id }
                            if attached && intent != AttachIntent::Viewer =>
                        {
                            let bytes = decode_bytes(&data_b64)
                                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                            match role {
                                // Confirmed controller — exactly-once at the PTY
                                // (the operator-minted id rides the B5 journal). NO-ACK
                                // (REQ-HAZARD-INPUT-ACK-BACKPRESSURE): this path is
                                // fire-and-forward (the ack is discarded here) and can
                                // flood N input frames on this one conn; an acked write
                                // back would fill the return direction and deadlock the
                                // broker's per-conn handler. Dedup still holds at the
                                // broker (op_id applied-set).
                                // [impl->REQ-HAZARD-INPUT-ACK-BACKPRESSURE]
                                Some(ServeRole::Controller) => {
                                    brain.send_effect_no_ack(op_id, &bytes)?
                                }
                                // Outcome not yet known — buffer; flushed on a
                                // controller confirmation, discarded on busy/viewer.
                                None => pending_input.push((op_id, bytes)),
                                // A viewer never reaches here (intent gate).
                                Some(ServeRole::Viewer) => {}
                            }
                        }
                        AttachRecord::Resize { rows, cols }
                            if attached && intent != AttachIntent::Viewer =>
                        {
                            // Controller → PTY resize (controller-EXCLUSIVE; the
                            // broker rejects it on a viewer's stream). A viewer's
                            // Resize is dropped here by the intent gate too.
                            let _ = brain.resize(rows, cols);
                        }
                        // Input/Resize before Request, a viewer's input/resize,
                        // or target-direction records echoed back: noise — ignore.
                        _ => {}
                    }
                }
            }

            // ── Broker → loop: the resolved controller/viewer role. ─────────
            BrokerEvent::Subscribed { outcome, .. } => {
                use crate::msg::SubscribeOutcome as O;
                match outcome {
                    O::Controller | O::TookControl => {
                        role = Some(ServeRole::Controller);
                        // A driver attached — the wake edge (controller-only;
                        // idempotent on an already-active seat).
                        feed_rest(endpoint, crate::resting::RestEvent::Wake);
                        // Flush any input that raced ahead of the reply.
                        for (op, b) in pending_input.drain(..) {
                            brain.send_effect(op, &b)?;
                        }
                    }
                    O::Viewer => {
                        role = Some(ServeRole::Viewer);
                        // Wake-neutral: a viewer never wakes the endpoint, never
                        // keeps it awake (Gap B). Discard any racing input.
                        pending_input.clear();
                    }
                    O::BusyControlled { .. } => {
                        // A different operator controls it — refuse (no PTY input
                        // ever reached it; the buffer is discarded). The rc
                        // renders the busy guidance client-side from driven_by.
                        wire.net_stream_send(stream_id, &[], None, true)?;
                        return Ok(AttachServeOutcome::Refused);
                    }
                }
            }
            // Viewer letterbox feed: the current PTY size (on attach + resize).
            BrokerEvent::Size { rows, cols, .. } if attached => {
                let line = ndjson::encode_line(&AttachRecord::Size { rows, cols });
                wire.net_stream_send(stream_id, &line, None, false)?;
            }
            // This controller was kicked by a different operator's `--take`.
            BrokerEvent::Displaced { by, .. } if attached => {
                let line = ndjson::encode_line(&AttachRecord::Displaced { by });
                wire.net_stream_send(stream_id, &line, None, true)?;


266	                wire.net_stream_send(stream_id, &line, None, true)?;
267	                // No dormant edge: the endpoint stays driven by the taker (whose
268	                // own serve loop already fired the wake).
269	                return Ok(AttachServeOutcome::Displaced);
270	            }
271
272	            // The operator closed its send side: the viewport is over.
273	            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
274	                wire.net_stream_send(stream_id, &[], None, true)?;
275	                // ORDER MATTERS: feed the resting edge FIRST (it read-modify-writes
276	                // info.json, carrying the current driven_by through), THEN release
277	                // the role over IPC so the broker's driven_by=None is the LAST
278	                // write — otherwise a concurrent resting RMW restores the stale
279	                // marker (the clear-race the broker-single-writer model averts).
280	                if role == Some(ServeRole::Controller) {
281	                    // The driver detached — the endpoint rests warm (controller-
282	                    // only; a viewer leaving is wake-neutral).
283	                    feed_rest(endpoint, crate::resting::RestEvent::Detach);
284	                }
285	                if let Some(sid) = attached_session {
286	                    let _ = brain.detach_session(sid); // release the role (clears markers)
287	                }
288	                return Ok(AttachServeOutcome::Detached);
289	            }
290
291	            // ── Session → wire: output and exit. ────────────────────────────
292	            BrokerEvent::Output { seq, bytes, .. } if attached => {
293	                let line = ndjson::encode_line(&AttachRecord::Output {
294	                    seq,
295	                    data_b64: encode_bytes(&bytes),
296	                });
297	                // Unjournaled by design (see module docs): `seq` rides inside
298	                // the record, and the operator's render cursor is the dedup.
299	                wire.net_stream_send(stream_id, &line, None, false)?;
300	            }
301	            BrokerEvent::Exit { code, .. } if attached => {
302	                let line = ndjson::encode_line(&AttachRecord::Exit { code });
303	                wire.net_stream_send(stream_id, &line, None, true)?;
304	                if role == Some(ServeRole::Controller) {
305	                    feed_rest(endpoint, crate::resting::RestEvent::Detach);
306	                }
307	                return Ok(AttachServeOutcome::Exited(code));
308	            }
309
310	            BrokerEvent::Error { message } => {
311	                if role == Some(ServeRole::Controller) {
312	                    feed_rest(endpoint, crate::resting::RestEvent::Detach);
313	                }
314	                return Err(io::Error::other(message));
315	            }
316	            // Applied/NetSent acks and unrelated events: not ours to handle.
317	            _ => {}
318	        }
319	    }
320	}
321
322	/// Operator-side: open an attach stream on a dialed connection and send the
323	/// [`AttachRecord::Request`]. Returns the stream id the caller then consumes
324	/// ([`AttachRecord::Output`] frames, deduped by `seq`) and writes
325	/// ([`AttachRecord::Input`] with operator-minted op ids). `open_op` is the
326	/// durable id for the exactly-once stream open (safe to block for its ack —
327	/// no attach traffic can be in flight before the stream exists); the Request
328	/// send itself is unjournaled (see module docs — a replayed Request re-gates
329	/// and re-attaches idempotently).
330	// [impl->REQ-INST-8]
331	pub fn request_attach(
332	    brain: &mut Brain,
333	    conn_id: u64,
334	    session_id: u64,
335	    from_seq: u64,
336	    open_op: u64,
337	    intent: AttachIntent,
338	) -> io::Result<u64> {
339	    let opened = brain.net_open_stream(conn_id, Some(open_op))?;
340	    let line = ndjson::encode_line(&AttachRecord::Request {
341	        session_id,
342	        from_seq,
343	        intent,
344	    });
345	    brain.net_stream_send(opened.stream_id, &line, None, false)?;
346	    Ok(opened.stream_id)
347	}
348
349	/// Operator-side (CONTROLLER): send a resize record onto an attach stream —
350	/// the controller's viewport size, forwarded into the target PTY (REQ-RCVIEW-1,
351	/// controller-exclusive: the broker rejects a resize from a viewer's stream).
352	/// Sent on attach (initial size) and on every window-change. Unjournaled (a
353	/// stale resize is harmless — the next one corrects it).
354	// [impl->REQ-RCVIEW-1]
355	pub fn send_attach_resize(
356	    brain: &mut Brain,
357	    stream_id: u64,
358	    rows: u16,
359	    cols: u16,
360	) -> io::Result<()> {
361	    let line = ndjson::encode_line(&AttachRecord::Resize { rows, cols });
362	    brain.net_stream_send(stream_id, &line, None, false)?;
363	    Ok(())
364	}
365
366	/// Operator-side: send one input record (the keystrokes) onto an attach
367	/// stream. `wire_op` is the operator-minted durable id the TARGET journals the
368	/// PTY write under — exactly-once at the keys, however often the record
369	/// crosses the wire. The send itself is unjournaled (see module docs).
370	// [impl->REQ-INST-8]
371	pub fn send_attach_input(
372	    brain: &mut Brain,
373	    stream_id: u64,
374	    bytes: &[u8],
375	    wire_op: u64,
376	) -> io::Result<()> {
377	    let line = ndjson::encode_line(&AttachRecord::Input {
378	        data_b64: encode_bytes(bytes),
379	        op_id: wire_op,
380	    });
381	    brain.net_stream_send(stream_id, &line, None, false)?;
382	    Ok(())
383	}
384

                wire.net_stream_send(stream_id, &line, None, true)?;
                // No dormant edge: the endpoint stays driven by the taker (whose
                // own serve loop already fired the wake).
                return Ok(AttachServeOutcome::Displaced);
            }

            // The operator closed its send side: the viewport is over.
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                wire.net_stream_send(stream_id, &[], None, true)?;
                // ORDER MATTERS: feed the resting edge FIRST (it read-modify-writes
                // info.json, carrying the current driven_by through), THEN release
                // the role over IPC so the broker's driven_by=None is the LAST
                // write — otherwise a concurrent resting RMW restores the stale
                // marker (the clear-race the broker-single-writer model averts).
                if role == Some(ServeRole::Controller) {
                    // The driver detached — the endpoint rests warm (controller-
                    // only; a viewer leaving is wake-neutral).
                    feed_rest(endpoint, crate::resting::RestEvent::Detach);
                }
                if let Some(sid) = attached_session {
                    let _ = brain.detach_session(sid); // release the role (clears markers)
                }
                return Ok(AttachServeOutcome::Detached);
            }

            // ── Session → wire: output and exit. ────────────────────────────
            BrokerEvent::Output { seq, bytes, .. } if attached => {
                let line = ndjson::encode_line(&AttachRecord::Output {
                    seq,
                    data_b64: encode_bytes(&bytes),
                });
                // Unjournaled by design (see module docs): `seq` rides inside
                // the record, and the operator's render cursor is the dedup.
                wire.net_stream_send(stream_id, &line, None, false)?;
            }
            BrokerEvent::Exit { code, .. } if attached => {
                let line = ndjson::encode_line(&AttachRecord::Exit { code });
                wire.net_stream_send(stream_id, &line, None, true)?;
                if role == Some(ServeRole::Controller) {
                    feed_rest(endpoint, crate::resting::RestEvent::Detach);
                }
                return Ok(AttachServeOutcome::Exited(code));
            }

            BrokerEvent::Error { message } => {
                if role == Some(ServeRole::Controller) {
                    feed_rest(endpoint, crate::resting::RestEvent::Detach);
                }
                return Err(io::Error::other(message));
            }
            // Applied/NetSent acks and unrelated events: not ours to handle.
            _ => {}
        }
    }
}

/// Operator-side: open an attach stream on a dialed connection and send the
/// [`AttachRecord::Request`]. Returns the stream id the caller then consumes
/// ([`AttachRecord::Output`] frames, deduped by `seq`) and writes
/// ([`AttachRecord::Input`] with operator-minted op ids). `open_op` is the
/// durable id for the exactly-once stream open (safe to block for its ack —
/// no attach traffic can be in flight before the stream exists); the Request
/// send itself is unjournaled (see module docs — a replayed Request re-gates
/// and re-attaches idempotently).
// [impl->REQ-INST-8]
pub fn request_attach(
    brain: &mut Brain,
    conn_id: u64,
    session_id: u64,
    from_seq: u64,
    open_op: u64,
    intent: AttachIntent,
) -> io::Result<u64> {
    let opened = brain.net_open_stream(conn_id, Some(open_op))?;
    let line = ndjson::encode_line(&AttachRecord::Request {
        session_id,
        from_seq,
        intent,
    });
    brain.net_stream_send(opened.stream_id, &line, None, false)?;
    Ok(opened.stream_id)
}

/// Operator-side (CONTROLLER): send a resize record onto an attach stream —
/// the controller's viewport size, forwarded into the target PTY (REQ-RCVIEW-1,
/// controller-exclusive: the broker rejects a resize from a viewer's stream).
/// Sent on attach (initial size) and on every window-change. Unjournaled (a
/// stale resize is harmless — the next one corrects it).
// [impl->REQ-RCVIEW-1]
pub fn send_attach_resize(
    brain: &mut Brain,
    stream_id: u64,
    rows: u16,
    cols: u16,
) -> io::Result<()> {
    let line = ndjson::encode_line(&AttachRecord::Resize { rows, cols });
    brain.net_stream_send(stream_id, &line, None, false)?;
    Ok(())
}

/// Operator-side: send one input record (the keystrokes) onto an attach
/// stream. `wire_op` is the operator-minted durable id the TARGET journals the
/// PTY write under — exactly-once at the keys, however often the record
/// crosses the wire. The send itself is unjournaled (see module docs).
// [impl->REQ-INST-8]
pub fn send_attach_input(
    brain: &mut Brain,
    stream_id: u64,
    bytes: &[u8],
    wire_op: u64,
) -> io::Result<()> {
    let line = ndjson::encode_line(&AttachRecord::Input {
        data_b64: encode_bytes(bytes),
        op_id: wire_op,
    });
    brain.net_stream_send(stream_id, &line, None, false)?;
    Ok(())
}


grep -n "fn viewer_writer\|fn add_viewer\|fn mark_controller_gone\|ViewerSink {\|BrokerEvent::\|enum BrokerEvent\|fn subscribe_stream\|Detached\|Evicted\|fn resolve_subscribe\|fn become_controller" crates/spt-daemon/src/broker.rs | head -50

Locate viewer_writer, add_viewer, BrokerEvent enum


110:struct ViewerSink {
388:    fn become_controller(&mut self, sub: SharedSend, by: Option<String>, from_seq: u64) {
435:    fn mark_controller_gone(&mut self, epoch: u64) {
445:    fn add_viewer(&mut self, sub: SharedSend, from_seq: u64) {
463:            ViewerSink {
476:    fn resolve_subscribe(
645:fn viewer_writer(send: SharedSend, initial: Vec<Envelope>, rx: Receiver<Envelope>) {
2307:            .insert(0, ViewerSink { tx: vtx, send: vsend, _writer: thread::spawn(|| {}) });


445	    fn add_viewer(&mut self, sub: SharedSend, from_seq: u64) {
446	        let vid = self.next_viewer_id;
447	        self.next_viewer_id += 1;
448	        // The size frame + the ring replay are the writer's initial batch (owned
449	        // by the thread, not the bounded queue).
450	        let mut initial: Vec<Envelope> =
451	            vec![size_envelope(self.session_id, self.size.0, self.size.1)];
452	        initial.extend(
453	            self.ring
454	                .iter()
455	                .filter(|(s, _)| *s >= from_seq)
456	                .map(|(seq, bytes)| output_envelope(self.session_id, *seq, bytes)),
457	        );
458	        let (tx, rx) = sync_channel::<Envelope>(VIEWER_CHANNEL_DEPTH);
459	        let writer_send = Arc::clone(&sub);
460	        let writer = thread::spawn(move || viewer_writer(writer_send, initial, rx));
461	        self.viewers.insert(
462	            vid,
463	            ViewerSink {
464	                tx,
465	                send: sub,
466	                _writer: writer,
467	            },
468	        );
469	    }
470
471	    /// Resolve a controller/viewer subscribe under the W2.5 model
472	    /// (REQ-RCVIEW-1 / REQ-KICK-1). Returns the [`SubscribeOutcome`]; performs
473	    /// the controller take / viewer add / loud displace as a side effect. See the
474	    /// identity matrix in the body — the `by`-keyed logic is what makes a
475	    /// brain-restart re-serve silent (gate #7) and a cross-operator `Take` loud.
476	    fn resolve_subscribe(
477	        &mut self,
478	        sub: SharedSend,
479	        from_seq: u64,
480	        intent: AttachIntent,
481	        by: Option<String>,
482	    ) -> SubscribeOutcome {
483	        if intent == AttachIntent::Viewer {
484	            if self.viewers.len() >= MAX_VIEWERS {

    fn add_viewer(&mut self, sub: SharedSend, from_seq: u64) {
        let vid = self.next_viewer_id;
        self.next_viewer_id += 1;
        // The size frame + the ring replay are the writer's initial batch (owned
        // by the thread, not the bounded queue).
        let mut initial: Vec<Envelope> =
            vec![size_envelope(self.session_id, self.size.0, self.size.1)];
        initial.extend(
            self.ring
                .iter()
                .filter(|(s, _)| *s >= from_seq)
                .map(|(seq, bytes)| output_envelope(self.session_id, *seq, bytes)),
        );
        let (tx, rx) = sync_channel::<Envelope>(VIEWER_CHANNEL_DEPTH);
        let writer_send = Arc::clone(&sub);
        let writer = thread::spawn(move || viewer_writer(writer_send, initial, rx));
        self.viewers.insert(
            vid,
            ViewerSink {
                tx,
                send: sub,
                _writer: writer,
            },
        );
    }

    /// Resolve a controller/viewer subscribe under the W2.5 model
    /// (REQ-RCVIEW-1 / REQ-KICK-1). Returns the [`SubscribeOutcome`]; performs
    /// the controller take / viewer add / loud displace as a side effect. See the
    /// identity matrix in the body — the `by`-keyed logic is what makes a
    /// brain-restart re-serve silent (gate #7) and a cross-operator `Take` loud.
    fn resolve_subscribe(
        &mut self,
        sub: SharedSend,
        from_seq: u64,
        intent: AttachIntent,
        by: Option<String>,
    ) -> SubscribeOutcome {
        if intent == AttachIntent::Viewer {
            if self.viewers.len() >= MAX_VIEWERS {


645	fn viewer_writer(send: SharedSend, initial: Vec<Envelope>, rx: Receiver<Envelope>) {
646	    for frame in &initial {
647	        let ok = match send.lock() {
648	            Ok(mut s) => write_frame(&mut *s, frame).is_ok(),
649	            Err(_) => false,
650	        };
651	        if !ok {
652	            return;
653	        }
654	    }
655	    while let Ok(frame) = rx.recv() {
656	        let ok = match send.lock() {
657	            Ok(mut s) => write_frame(&mut *s, &frame).is_ok(),
658	            Err(_) => false,
659	        };
660	        if !ok {
661	            return;
662	        }
663	    }
664	}
665
666	/// A message to the CONTROLLER writer thread (W1). `Output` carries a sequenced
667	/// chunk and advances the authoritative `delivered_through` on a successful
668	/// write; `Control` is an out-of-band notice (Displaced, …) that is written but
669	/// does NOT move the resume cursor.
670	enum CtrlMsg {
671	    Output(u64, Envelope),
672	    Control(Envelope),
673	}
674

fn viewer_writer(send: SharedSend, initial: Vec<Envelope>, rx: Receiver<Envelope>) {
    for frame in &initial {
        let ok = match send.lock() {
            Ok(mut s) => write_frame(&mut *s, frame).is_ok(),
            Err(_) => false,
        };
        if !ok {
            return;
        }
    }
    while let Ok(frame) = rx.recv() {
        let ok = match send.lock() {
            Ok(mut s) => write_frame(&mut *s, &frame).is_ok(),
            Err(_) => false,
        };
        if !ok {
            return;
        }
    }
}

/// A message to the CONTROLLER writer thread (W1). `Output` carries a sequenced
/// chunk and advances the authoritative `delivered_through` on a successful
/// write; `Control` is an out-of-band notice (Displaced, …) that is written but
/// does NOT move the resume cursor.
enum CtrlMsg {
    Output(u64, Envelope),
    Control(Envelope),
}


Complete picture now. Eviction today = silent: `viewers.remove` → `tx` drops → `viewer_writer` exits without writing anything → serve_attach's `read_event()` just stops getting Output (no EOF, no error) → operator hangs. Confirmed the SILENT+PERMANENT bug.

Let me confirm the BrokerEvent enum + envelope→event decode surface (needed for a distinct eviction signal) before writing the plan.


grep -rn "enum BrokerEvent\|Output {\|Size {\|Displaced {\|Subscribed {\|NetStreamEof" crates/spt-daemon/src/*.rs | grep -i "brokerevent\|pub enum\|=> BrokerEvent\|BrokerEvent::" | head; echo "==== envelope kinds ===="; grep -rn "fn output_envelope\|fn size_envelope\|KIND_OUTPUT\|KIND_SIZE\|KIND_DISPLACED\|KIND_EXIT" crates/spt-daemon/src/broker.rs crates/spt-daemon/src/msg.rs | head -30

Find BrokerEvent enum and envelope kind decode


crates/spt-daemon/src/attach.rs:230:            BrokerEvent::Subscribed { outcome, .. } => {
crates/spt-daemon/src/attach.rs:259:            BrokerEvent::Size { rows, cols, .. } if attached => {
crates/spt-daemon/src/attach.rs:264:            BrokerEvent::Displaced { by, .. } if attached => {
crates/spt-daemon/src/attach.rs:273:            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
crates/spt-daemon/src/attach.rs:292:            BrokerEvent::Output { seq, bytes, .. } if attached => {
crates/spt-daemon/src/brain.rs:94:pub enum BrokerEvent {
crates/spt-daemon/src/brain.rs:540:                BrokerEvent::Output { bytes, .. } => buf.extend_from_slice(&bytes),
crates/spt-daemon/src/brain.rs:609:                        return Ok(BrokerEvent::Output {
crates/spt-daemon/src/brain.rs:630:                    return Ok(BrokerEvent::Output {
crates/spt-daemon/src/brain.rs:655:                    return Ok(BrokerEvent::Subscribed {
==== envelope kinds ====
crates/spt-daemon/src/broker.rs:59:    Spawned, SubscribeOutcome, SubscribeReq, UnsubscribeReq, KIND_BRAIN_RESTART, KIND_BRAIN_RESTARTED, KIND_ENDPOINT_INPUT, KIND_ERROR, KIND_EXIT,
crates/spt-daemon/src/broker.rs:1482:                    KIND_EXIT,
crates/spt-daemon/src/broker.rs:2628:            if env.kind == crate::msg::KIND_OUTPUT {
crates/spt-daemon/src/msg.rs:42:pub const KIND_OUTPUT: &str = "output";
crates/spt-daemon/src/msg.rs:44:pub const KIND_EXIT: &str = "exit";
crates/spt-daemon/src/msg.rs:57:pub const KIND_SIZE: &str = "size";
crates/spt-daemon/src/msg.rs:64:pub const KIND_DISPLACED: &str = "displaced";
crates/spt-daemon/src/msg.rs:265:    /// (which received a [`KIND_DISPLACED`]).
crates/spt-daemon/src/msg.rs:731:pub fn output_envelope(session_id: u64, seq: u64, bytes: &[u8]) -> Envelope {
crates/spt-daemon/src/msg.rs:733:        KIND_OUTPUT,
crates/spt-daemon/src/msg.rs:757:pub fn size_envelope(session_id: u64, rows: u16, cols: u16) -> Envelope {
crates/spt-daemon/src/msg.rs:759:        KIND_SIZE,
crates/spt-daemon/src/msg.rs:773:        KIND_DISPLACED,
crates/spt-daemon/src/msg.rs:976:    fn output_envelope_round_trips() {
crates/spt-daemon/src/msg.rs:978:        assert_eq!(env.kind, KIND_OUTPUT);


94	pub enum BrokerEvent {
95	    /// A contiguous chunk of a session's output (the cursor has advanced past it).
96	    Output {
97	        session_id: u64,
98	        seq: u64,
99	        bytes: Vec<u8>,
100	    },
101	    /// A spawn succeeded.
102	    Spawned { session_id: u64, pid: Option<u32> },
103	    /// A session's child exited.
104	    Exit { session_id: u64, code: Option<i32> },
105	    /// The resolved controller/viewer role of a [`Brain::attach_as`] subscribe
106	    /// (REQ-RCVIEW-1 / REQ-KICK-1). The serving brain wakes (controller-only),
107	    /// stays wake-neutral (viewer), or refuses the operator (busy).
108	    Subscribed {
109	        session_id: u64,
110	        outcome: SubscribeOutcome,
111	    },
112	    /// The current PTY size of a session, pushed to a VIEWER (the letterbox feed,
113	    /// REQ-RCVIEW-1).
114	    Size {
115	        session_id: u64,
116	        rows: u16,
117	        cols: u16,
118	    },
119	    /// This controller was DISPLACED by a different operator's `--take`/`kick`
120	    /// (REQ-KICK-1). The serving brain forwards a loud notice and detaches.
121	    Displaced { session_id: u64, by: String },
122	    /// An idempotent effect is durably applied (or was deduped — `applied_now`
123	    /// distinguishes). The exactly-once ack for a [`Brain::send_effect`] (B5).
124	    Applied {
125	        session_id: u64,
126	        op_id: u64,
127	        applied_now: bool,
128	    },
129	    /// The broker-owned net endpoint's status (a `net-status` reply, D4a).
130	    NetStatus(NetStatusReply),
131	    /// A net dial durably landed — fresh or deduped (D4a).
132	    NetDialed(NetDialed),
133	    /// A stream open durably landed — fresh or deduped (D4b).
134	    NetStreamOpened(NetStreamOpened),
135	    /// A journaled stream send durably landed — fresh or deduped (D4b).
136	    NetSent(NetSent),
137	    /// The broker's held-streams listing (D4b).
138	    NetStreams(NetStreamsReply),
139	    /// The broker's hosted-sessions listing (D9-1: session → endpoint).
140	    Sessions(SessionsReply),
141	    /// The subnet-join ceremony's verdict (a `pair-joined` reply, M7 D3).
142	    PairJoined(PairJoinReply),
143	    /// A contiguous chunk of a stream's reads (the cursor has advanced past it).
144	    NetStreamData {
145	        stream_id: u64,
146	        seq: u64,
147	        bytes: Vec<u8>,
148	    },
149	    /// A stream's read side finished cleanly; `next_seq` is the completeness
150	    /// check (the subscriber's cursor must equal it to have missed nothing).
151	    NetStreamEof { stream_id: u64, next_seq: u64 },
152	    /// A contiguous conn-liveness event (the presence cursor has advanced past
153	    /// it) — connected/disconnected on the broker's conn table (D4c).

pub enum BrokerEvent {
    /// A contiguous chunk of a session's output (the cursor has advanced past it).
    Output {
        session_id: u64,
        seq: u64,
        bytes: Vec<u8>,
    },
    /// A spawn succeeded.
    Spawned { session_id: u64, pid: Option<u32> },
    /// A session's child exited.
    Exit { session_id: u64, code: Option<i32> },
    /// The resolved controller/viewer role of a [`Brain::attach_as`] subscribe
    /// (REQ-RCVIEW-1 / REQ-KICK-1). The serving brain wakes (controller-only),
    /// stays wake-neutral (viewer), or refuses the operator (busy).
    Subscribed {
        session_id: u64,
        outcome: SubscribeOutcome,
    },
    /// The current PTY size of a session, pushed to a VIEWER (the letterbox feed,
    /// REQ-RCVIEW-1).
    Size {
        session_id: u64,
        rows: u16,
        cols: u16,
    },
    /// This controller was DISPLACED by a different operator's `--take`/`kick`
    /// (REQ-KICK-1). The serving brain forwards a loud notice and detaches.
    Displaced { session_id: u64, by: String },
    /// An idempotent effect is durably applied (or was deduped — `applied_now`
    /// distinguishes). The exactly-once ack for a [`Brain::send_effect`] (B5).
    Applied {
        session_id: u64,
        op_id: u64,
        applied_now: bool,
    },
    /// The broker-owned net endpoint's status (a `net-status` reply, D4a).
    NetStatus(NetStatusReply),
    /// A net dial durably landed — fresh or deduped (D4a).
    NetDialed(NetDialed),
    /// A stream open durably landed — fresh or deduped (D4b).
    NetStreamOpened(NetStreamOpened),
    /// A journaled stream send durably landed — fresh or deduped (D4b).
    NetSent(NetSent),
    /// The broker's held-streams listing (D4b).
    NetStreams(NetStreamsReply),
    /// The broker's hosted-sessions listing (D9-1: session → endpoint).
    Sessions(SessionsReply),
    /// The subnet-join ceremony's verdict (a `pair-joined` reply, M7 D3).
    PairJoined(PairJoinReply),
    /// A contiguous chunk of a stream's reads (the cursor has advanced past it).
    NetStreamData {
        stream_id: u64,
        seq: u64,
        bytes: Vec<u8>,
    },
    /// A stream's read side finished cleanly; `next_seq` is the completeness
    /// check (the subscriber's cursor must equal it to have missed nothing).
    NetStreamEof { stream_id: u64, next_seq: u64 },
    /// A contiguous conn-liveness event (the presence cursor has advanced past
    /// it) — connected/disconnected on the broker's conn table (D4c).


595	                    // returns it, so the boundary-dedup still applies). Discipline:
596	                    // **dedup-below + snap-above, NO reject-gap** — drop an
597	                    // already-delivered seq (the at-least-once boundary re-send),
598	                    // accept-and-snap everything `>= cursor` (the contiguous case
599	                    // AND a post-eviction ring-floor jump, which the broker's
600	                    // non-reordering replay makes the only legitimate forward jump).
601	                    if !self.session_cursors.is_empty() {
602	                        let cursor = self.session_cursors.entry(ev.session_id).or_insert(ev.seq);
603	                        if ev.seq < *cursor {
604	                            continue; // replay duplicate / boundary re-send — dedup
605	                        }
606	                        *cursor = ev.seq + 1;
607	                        let bytes = decode_bytes(&ev.data_b64)
608	                            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
609	                        return Ok(BrokerEvent::Output {
610	                            session_id: ev.session_id,
611	                            seq: ev.seq,
612	                            bytes,
613	                        });
614	                    }
615	                    // Legacy single-session path (map empty — a pure spawn/drive
616	                    // seat): strict accept-next / drop-dup / reject-gap on the one
617	                    // `next_seq` cursor, byte-for-byte unchanged.
618	                    if ev.seq < self.next_seq {
619	                        continue; // already processed — a replay duplicate; dedup
620	                    }
621	                    if ev.seq > self.next_seq {
622	                        return Err(io::Error::new(
623	                            io::ErrorKind::InvalidData,
624	                            format!("output gap: got seq {} want {}", ev.seq, self.next_seq),
625	                        ));
626	                    }
627	                    self.next_seq = ev.seq + 1;
628	                    let bytes = decode_bytes(&ev.data_b64)
629	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
630	                    return Ok(BrokerEvent::Output {
631	                        session_id: ev.session_id,
632	                        seq: ev.seq,
633	                        bytes,
634	                    });
635	                }
636	                KIND_SPAWNED => {
637	                    let ev: Spawned = serde_json::from_value(env.payload)
638	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
639	                    return Ok(BrokerEvent::Spawned {
640	                        session_id: ev.session_id,
641	                        pid: ev.pid,
642	                    });
643	                }
644	                KIND_EXIT => {
645	                    let ev: ExitEvent = serde_json::from_value(env.payload)
646	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
647	                    return Ok(BrokerEvent::Exit {
648	                        session_id: ev.session_id,
649	                        code: ev.code,
650	                    });
651	                }
652	                KIND_SUBSCRIBED => {
653	                    let ev: SubscribedReply = serde_json::from_value(env.payload)
654	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
655	                    return Ok(BrokerEvent::Subscribed {
656	                        session_id: ev.session_id,
657	                        outcome: ev.outcome,
658	                    });
659	                }
660	                KIND_SIZE => {
661	                    let ev: SizeEvent = serde_json::from_value(env.payload)
662	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
663	                    return Ok(BrokerEvent::Size {
664	                        session_id: ev.session_id,
665	                        rows: ev.rows,
666	                        cols: ev.cols,
667	                    });
668	                }
669	                KIND_DISPLACED => {
670	                    let ev: DisplacedEvent = serde_json::from_value(env.payload)
671	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
672	                    return Ok(BrokerEvent::Displaced {
673	                        session_id: ev.session_id,
674	                        by: ev.by,
675	                    });
676	                }
677	                KIND_APPLIED => {
678	                    let ev: AppliedEvent = serde_json::from_value(env.payload)
679	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
680	                    return Ok(BrokerEvent::Applied {
681	                        session_id: ev.session_id,
682	                        op_id: ev.op_id,
683	                        applied_now: ev.applied_now,
684	                    });

                    // returns it, so the boundary-dedup still applies). Discipline:
                    // **dedup-below + snap-above, NO reject-gap** — drop an
                    // already-delivered seq (the at-least-once boundary re-send),
                    // accept-and-snap everything `>= cursor` (the contiguous case
                    // AND a post-eviction ring-floor jump, which the broker's
                    // non-reordering replay makes the only legitimate forward jump).
                    if !self.session_cursors.is_empty() {
                        let cursor = self.session_cursors.entry(ev.session_id).or_insert(ev.seq);
                        if ev.seq < *cursor {
                            continue; // replay duplicate / boundary re-send — dedup
                        }
                        *cursor = ev.seq + 1;
                        let bytes = decode_bytes(&ev.data_b64)
                            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                        return Ok(BrokerEvent::Output {
                            session_id: ev.session_id,
                            seq: ev.seq,
                            bytes,
                        });
                    }
                    // Legacy single-session path (map empty — a pure spawn/drive
                    // seat): strict accept-next / drop-dup / reject-gap on the one
                    // `next_seq` cursor, byte-for-byte unchanged.
                    if ev.seq < self.next_seq {
                        continue; // already processed — a replay duplicate; dedup
                    }
                    if ev.seq > self.next_seq {
                        return Err(io::Error::new(
                            io::ErrorKind::InvalidData,
                            format!("output gap: got seq {} want {}", ev.seq, self.next_seq),
                        ));
                    }
                    self.next_seq = ev.seq + 1;
                    let bytes = decode_bytes(&ev.data_b64)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Output {
                        session_id: ev.session_id,
                        seq: ev.seq,
                        bytes,
                    });
                }
                KIND_SPAWNED => {
                    let ev: Spawned = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Spawned {
                        session_id: ev.session_id,
                        pid: ev.pid,
                    });
                }
                KIND_EXIT => {
                    let ev: ExitEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Exit {
                        session_id: ev.session_id,
                        code: ev.code,
                    });
                }
                KIND_SUBSCRIBED => {
                    let ev: SubscribedReply = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Subscribed {
                        session_id: ev.session_id,
                        outcome: ev.outcome,
                    });
                }
                KIND_SIZE => {
                    let ev: SizeEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Size {
                        session_id: ev.session_id,
                        rows: ev.rows,
                        cols: ev.cols,
                    });
                }
                KIND_DISPLACED => {
                    let ev: DisplacedEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Displaced {
                        session_id: ev.session_id,
                        by: ev.by,
                    });
                }
                KIND_APPLIED => {
                    let ev: AppliedEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Applied {
                        session_id: ev.session_id,
                        op_id: ev.op_id,
                        applied_now: ev.applied_now,
                    });


Key find at brain.rs:595-600: the Output decoder **already** has a "post-eviction ring-floor jump" snap-above path (`session_cursors`: dedup-below + snap-above, NO reject-gap). Groundwork for skip-to-live already laid on the operator dedup. But the legacy single-session path (618-626) treats a forward jump as a fatal "output gap" error. Need to know which path serve_attach's brain hits. Let me check what seeds `session_cursors`.


grep -rn "session_cursors" crates/spt-daemon/src/brain.rs

Find session_cursors seeding


263:    session_cursors: std::collections::HashMap<u64, u64>,
290:            session_cursors: std::collections::HashMap::new(),
316:            session_cursors: std::collections::HashMap::new(),
333:    /// single live writer per connection, and the seeded `session_cursors` below
350:        let mut session_cursors = std::collections::HashMap::new();
351:        session_cursors.insert(prior.session_id, prior.next_seq);
360:            session_cursors,
414:    /// brain (multiple sessions in `session_cursors`) an OUTPUT frame for a
601:                    if !self.session_cursors.is_empty() {
602:                        let cursor = self.session_cursors.entry(ev.session_id).or_insert(ev.seq);
837:    /// Each session is seeded into [`Self::session_cursors`] at its `resume_seq`,
851:            self.session_cursors
862:        self.session_cursors.get(&session_id).copied()
1189:        if !self.session_cursors.is_empty() {
1190:            self.session_cursors.insert(session_id, from_seq);


1175	        // (seeded map) that subscribes from `from_seq` must RESET this session's
1176	        // dedup cursor to `from_seq` — the broker's replay from `from_seq` is then
1177	        // re-delivered (and the operator's render cursor dedups the overlap),
1178	        // never lost. WHY THIS, NOT just `read_event`'s `or_insert`: on a re-serve,
1179	        // `serve_attach` may CONSUME a handoff-replay frame at the K boundary
1180	        // BEFORE it is `attached` (the frame is dropped by the `if attached` gate
1181	        // but the snap-above cursor still advanced past K); `attach_as(sid, 0)`
1182	        // then re-subscribes from 0, and without this reset the broker's re-send of
1183	        // seq K arrives below the advanced cursor and is deduped → seq K never
1184	        // reaches the operator viewport → a forward gap. Resetting to `from_seq`
1185	        // makes the re-subscribe re-deliver everything from its floor. A cold-start
1186	        // brain (empty map — e.g. the production dispatch serve brain) keeps the
1187	        // legacy `next_seq` path; this only arms in resume mode.
1188	        // [impl->REQ-HAZARD-CONTROLLER-WRITER-REORDER]
1189	        if !self.session_cursors.is_empty() {
1190	            self.session_cursors.insert(session_id, from_seq);
1191	        }
1192	        self.send(
1193	            KIND_SUBSCRIBE,
1194	            serde_json::to_value(SubscribeReq {
1195	                session_id,
1196	                from_seq,
1197	                intent,
1198	                by,
1199	            })
1200	            .expect("SubscribeReq serializes"),
1201	        )
1202	    }
1203
1204	    fn send(&mut self, kind: &str, payload: serde_json::Value) -> io::Result<()> {
1205	        let env = Envelope::new(kind, payload);
1206	        match &mut self.conn {
1207	            BrainConn::Whole(stream) => write_frame(stream, &env),
1208	            BrainConn::Split { send, .. } => write_frame(send, &env),
1209	        }

        // (seeded map) that subscribes from `from_seq` must RESET this session's
        // dedup cursor to `from_seq` — the broker's replay from `from_seq` is then
        // re-delivered (and the operator's render cursor dedups the overlap),
        // never lost. WHY THIS, NOT just `read_event`'s `or_insert`: on a re-serve,
        // `serve_attach` may CONSUME a handoff-replay frame at the K boundary
        // BEFORE it is `attached` (the frame is dropped by the `if attached` gate
        // but the snap-above cursor still advanced past K); `attach_as(sid, 0)`
        // then re-subscribes from 0, and without this reset the broker's re-send of
        // seq K arrives below the advanced cursor and is deduped → seq K never
        // reaches the operator viewport → a forward gap. Resetting to `from_seq`
        // makes the re-subscribe re-deliver everything from its floor. A cold-start
        // brain (empty map — e.g. the production dispatch serve brain) keeps the
        // legacy `next_seq` path; this only arms in resume mode.
        // [impl->REQ-HAZARD-CONTROLLER-WRITER-REORDER]
        if !self.session_cursors.is_empty() {
            self.session_cursors.insert(session_id, from_seq);
        }
        self.send(
            KIND_SUBSCRIBE,
            serde_json::to_value(SubscribeReq {
                session_id,
                from_seq,
                intent,
                by,
            })
            .expect("SubscribeReq serializes"),
        )
    }

    fn send(&mut self, kind: &str, payload: serde_json::Value) -> io::Result<()> {
        let env = Envelope::new(kind, payload);
        match &mut self.conn {
            BrainConn::Whole(stream) => write_frame(stream, &env),
            BrainConn::Split { send, .. } => write_frame(send, &env),
        }


Now I have the complete design surface — and found the critical risk. Let me capture it all in a focused JIT plan before implementing.

Key discoveries:
1. **Operator dedup groundwork exists** (brain.rs:595-600: "post-eviction ring-floor jump" snap-above) — but only for *resume* brains.
2. **The trap**: serve_attach's `brain` is **cold-start** (brain.rs:1186-7: "the production dispatch serve brain keeps the legacy `next_seq` path") → a post-eviction forward-jump replay hits the **fatal "output gap" error** (brain.rs:621-625) → tears down. Skip-to-live breaks unless the cursor is reset.


Write

# V0.13.0 — viewer SKIP-TO-LIVE on eviction — JIT plan

**Status:** DESIGNED (this doc). Closes **p0_paste** + **a_journaled-Linux** (post-b4 these
collapsed to ONE root — doyle's post-b4 re-measure 2026-06-21). doyle-GATED: "skip-to-live
is REAL, not relax" (eviction itself is correct session-protection; SILENT+PERMANENT
eviction is the bug). Off `viewer-drain-decouple-b4@677c3e7`. Implement AFTER b4.

## Root

Viewer eviction in `OutputLog::append` (broker.rs:326-338, `try_send` Full / Disconnected
→ `viewers.remove`) is **SILENT + PERMANENT**:

- serve_attach (attach.rs) subscribes to the broker as a VIEWER → `add_viewer` (broker.rs:445)
  → `ViewerSink` (256-deep `VIEWER_CHANNEL_DEPTH`) + a `viewer_writer` thread.
- serve_attach forwards each frame `read_event → b64decode → re-encode AttachRecord →
  net_stream_send` — SLOWER than the drain fans out under flood. The 256 channel overflows.
- The drain (append) evicts: `viewers.remove(vid)` drops `ViewerSink` → drops `tx` →
  `viewer_writer`'s `rx.recv()` returns Err → the writer `return`s **writing nothing**
  (broker.rs:655-663). serve_attach's `brain.read_event()` then **just stops** getting
  `Output` — NO EOF, NO error → serve_attach blocks forever → operator receives nothing.
  `attach_received_output=FALSE`.

Post-b4 (doyle 2026-06-21): a_journaled-Linux now = subscribed=true, drain FREE (88902
appends), VIEWER EVICTED (c1-EVICT @ seq 88901), got_output=FALSE — the **identical
signature to p0_paste**. b4 unthrottled the drain; the remaining failure is this
viewer-eviction. ONE root → skip-to-live closes BOTH.

## Design (doyle's 3 constraints, b4-doc lines 24-32)

### (1) EVICTION SIGNAL — broker→viewer, DISTINCT from session-exit EOF
The evicted viewer must learn it was evicted (≠ the session ended). serve_attach must NOT
tear down on it (it tears down on real EOF/Exit).

- `ViewerSink` += an eviction notifier shared with its `viewer_writer`: `evicted: Arc<AtomicBool>`.
- `append` overflow/disconnect arm: set `evicted.store(true)` BEFORE `viewers.remove(vid)`
  (drop `tx`). (Still under the log lock — just an atomic store, never a blocking write.)
- `viewer_writer`: when `rx.recv()` returns Err, check `evicted`:
    - `true`  → write ONE `KIND_VIEWER_EVICTED` marker frame to `send`, then exit.
    - `false` → exit silently (normal teardown: session end / conn drop).
  The marker write happens **in the writer thread = OFF the log lock / off the drain** — same
  isolation as every viewer frame (a bounded, isolated, dedicated-writer write). No W1/b4
  hazard re-introduced.
- New wire surface:
    - `msg.rs`: `KIND_VIEWER_EVICTED` + `evicted_envelope(session_id)` (+ a tiny
      `ViewerEvictedEvent { session_id }` payload struct, mirror `SizeEvent`).
    - `brain.rs`: `BrokerEvent::ViewerEvicted { session_id }` + a decode arm (mirror `KIND_SIZE`).
  Additive + tagged → N-1 peers skip an unknown kind (degrade-don't-die, per AttachRecord posture).

### (2) serve_attach: re-subscribe from the CURRENT RING FLOOR (skip-to-live)
On `BrokerEvent::ViewerEvicted` (NOT EOF — does NOT return/teardown):
- Re-issue the subscribe at the live tail: `brain.attach_as(session, LIVE_TAIL, Viewer, origin)`.
- LIVE_TAIL semantics: `from_seq = u64::MAX` → `add_viewer`'s `s >= from_seq` ring filter
  (broker.rs:455) replays NOTHING → the viewer jumps to live, sees the next live burst, skips
  the uncatchable backlog (tail -f reconnect). (Confirm u64::MAX is safe vs any `from_seq`
  arithmetic; else add an explicit "tail" flag to the subscribe.)

### (3) NO evict→resubscribe busy-loop — RATE-LIMIT (HARD constraint)
serve_attach tracks `last_resubscribe: Instant`. Min `RESUBSCRIBE_INTERVAL` (~150-250ms).
If a `ViewerEvicted` arrives within the interval, sleep the remainder before re-subscribing.
Forward-progress guarantee: each re-subscribe delivers the live tail → under max-flood the
operator sees intermittent LIVE bursts, never a CPU spin. (Do NOT lead with serve
stall/gap-detection — fragile fallback only.)

## ⚠ RISK / open gate — COLD-BRAIN REJECT-GAP (must resolve, B2-adjacent)

serve_attach's `brain` is **cold-start** (brain.rs:1186-7: "the production dispatch serve
brain keeps the legacy `next_seq` path"; `session_cursors` EMPTY). The legacy path
(brain.rs:618-626) treats a forward seq jump as a **FATAL "output gap" Err**. The
post-eviction replay-from-floor (skip-to-live) IS a forward jump → serve_attach returns Err →
**tears down** → skip-to-live BREAKS at the cold-brain dedup.

FIX: on the skip-to-live re-subscribe, RESET serve_attach brain's `next_seq` to the new floor
so the replay is contiguous-by-construction. Mirror the seeded-map reset (brain.rs:1189-1190
`session_cursors.insert(session_id, from_seq)`) but for the legacy `next_seq` field. Add a
`Brain` method (e.g. `reset_output_cursor(from_seq)` / extend `attach_as` to reset `next_seq`
on the cold path too). The snap-above path (brain.rs:595-606) ALREADY documents "a
post-eviction ring-floor jump" as the only legitimate forward jump — so the resume-brain side
is ready; this only needs the cold serve-brain to accept it.

OPERATOR render cursor: serve_attach forwards `seq` verbatim; the rc operator must accept the
forward jump (skip-to-live) in its AttachRecord::Output dedup. VERIFY rc.rs render dedup is
snap-above, not reject-gap. If reject-gap, that is a 2nd reset point.

B2 / KH: both resets are VIEWER-only — a viewer never advances `delivered_through`, is not
authoritative, exposes no resume cursor → B2-SAFE (doyle's gate rationale, b4-doc line 24).
Intersects REQ-HAZARD-CONTROLLER-WRITER-REORDER only by adjacency; no controller path touched.
**CONFIRM with doyle before fold** (he owns the B2 invariant + the p0_paste forkpty verify).

## Traceability (CLAUDE.md binding)

New req `REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT` (or fold into REQ-HAZARD-VIEWER-ISOLATION). Add to
`traceable-reqs.toml` FIRST, then satisfy: `doc` (this file) / `impl` (broker+brain+attach) /
`unit` (below). INT evidence = p0_paste warm-forkpty gate (activates on fold, rule 5).

## Tests (delegate → spt-test-engineer)

- unit (broker): `append` overflow-evict sets the eviction notifier (not a silent remove);
  `viewer_writer` writes `KIND_VIEWER_EVICTED` on an evicted rx-close + NOTHING on a normal
  close.
- unit (attach/serve): on `ViewerEvicted` serve_attach re-subscribes from the floor, does NOT
  tear down (distinct from EOF), and rate-limits (≥2 rapid evictions ⇒ ≥1 interval of spacing,
  no busy-loop).
- unit (brain): cold-brain `next_seq` reset accepts the post-eviction forward jump (no
  "output gap" Err); a NON-reset forward jump still errors (guard unchanged elsewhere).
- int: p0_paste (warm forkpty, kitsubito) green — operator receives intermittent live bursts
  after eviction. (doyle's gate.)

## Files

- `crates/spt-daemon/src/msg.rs` — `KIND_VIEWER_EVICTED`, `evicted_envelope`, payload struct.
- `crates/spt-daemon/src/broker.rs` — `ViewerSink.evicted`, `append` evict arm, `add_viewer`,
  `viewer_writer`.
- `crates/spt-daemon/src/brain.rs` — `BrokerEvent::ViewerEvicted` + decode arm; cold-cursor reset.
- `crates/spt-daemon/src/attach.rs` — `serve_attach` ViewerEvicted handling + rate-limit +
  re-subscribe-from-floor.
- `traceable-reqs.toml`.

## Sequencing vs the board (doyle 2026-06-21)

- b4 = keep (works). Windows a_journaled flip = STRUCTURALLY EXONERATED (subscribe path =
  nethost StreamLog, not OutputLog; Windows ctrl channel never fills at pumped=249<<4096) →
  likely flake; doyle confirming repro. Don't block fold on it.
- THIS (skip-to-live) = closes p0_paste + a_journaled-Linux. NEXT.
- g2 = real inject-acceptance fix (dispatch_endpoint_input must not starve under output flood);
  still RED post-b4. SEPARATE, AFTER this.
- Fold order: b4 + pump-carrier-fix + skip-to-live → delivery-control → doyle folds → release.