130	pub struct UpdatePlan {
131	    /// The update class (ADR-0004 §A).
132	    pub class: UpdateClass,
133	    /// The release the plan was built for.
134	    pub release: ReleaseSpec,
135	}
136
137	/// Classify `release` against `running` and bundle the verdict with it.
138	pub fn plan_update(running: &BrokerAbi, release: ReleaseSpec) -> UpdatePlan {
139	    UpdatePlan {
140	        class: classify(running, &release),
141	        release,
142	    }
143	}
144
145	/// The verify-before-handoff gate (C1): authenticate a signed release + confirm
146	/// the binary bytes, then — and only then — classify it into an [`UpdatePlan`].
147	///
148	/// This is the **front door** of an update: ADR-0004 §D makes signing mandatory,
149	/// so a release is never even classified (let alone applied) until it has passed
150	/// [`verify_metadata`] (signature + key trust/revocation + channel + expiry +
151	/// monotonic version) **and** [`verify_artifact`] (the real bytes match the
152	/// signed digest). A rejected release returns its [`RejectReason`] and **no plan
153	/// is produced** — there is nothing for [`apply_brain_only`] to act on, so an
154	/// unverified binary can never reach a handoff ([`REQ-UPD-2`]).
155	// [impl->REQ-UPD-2]
156	// [impl->REQ-HAZARD-UPDATE-ROLLBACK]
157	pub fn plan_verified(
158	    running: &BrokerAbi,
159	    signed: &SignedRelease,
160	    artifact: &[u8],
161	    policy: &VerifyPolicy,
162	) -> Result<UpdatePlan, RejectReason> {
163	    let meta = verify_metadata(signed, policy)?;
164	    verify_artifact(&meta, artifact)?;
165	    Ok(plan_update(running, meta.spec()))
166	}
167
168	/// The update-set front door: authenticate a signed multi-platform set, select
169	/// the recipient's Rust target triple, confirm those bytes match the signed
170	/// digest, then classify that platform artifact.
171	// [impl->REQ-UPD-6]
172	pub fn plan_verified_update_set(
173	    running: &BrokerAbi,
174	    signed: &SignedUpdateSet,
175	    platform: &str,
176	    artifact: &[u8],
177	    policy: &VerifyPolicy,
178	) -> Result<UpdatePlan, RejectReason> {
179	    let meta = verify_update_set_metadata(signed, policy)?;
180	    let selected = meta.artifact_for(platform)?;
181	    verify_update_set_artifact(&meta, platform, artifact)?;
182	    Ok(plan_update(running, selected.spec()))
183	}
184
185	/// Why a brain-only apply did not complete.
186	#[derive(Debug)]
187	pub enum ApplyError {
188	    /// The plan is not brain-only — routing it through the brain-only path would
189	    /// risk an endpoint, which this class must never do. Raised **before** the
190	    /// outgoing brain is touched. The caller routes a broker-compatible /
191	    /// broker-breaking plan through its own (consent-gated, scheduled) path;
192	    /// brain-only is the only swap C0 performs.
193	    NotBrainOnly(UpdateClass),
194	    /// The new brain failed to re-attach (the `relaunch` closure errored). The
195	    /// endpoint is **untouched** regardless — the broker + its hosted child
196	    /// persist across a failed re-attach exactly as across a brain crash — so the
197	    /// caller may retry against the still-live broker.
198	    Relaunch(io::Error),
199	}
200
201	impl std::fmt::Display for ApplyError {
202	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203	        match self {
204	            ApplyError::NotBrainOnly(c) => {
205	                write!(
206	                    f,
207	                    "apply_brain_only refused a {c:?} plan (would risk an endpoint)"
208	                )
209	            }
210	            ApplyError::Relaunch(e) => {
211	                write!(f, "brain-only relaunch failed (endpoint intact): {e}")
212	            }
213	        }
214	    }
215	}
216
217	impl std::error::Error for ApplyError {
218	    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
219	        match self {
220	            ApplyError::NotBrainOnly(_) => None,
221	            ApplyError::Relaunch(e) => Some(e),
222	        }
223	    }
224	}
225
226	/// Apply a **brain-only** update ([`REQ-UPD-3`]): swap the brain logic without
227	/// disturbing any broker-held resource or hosted endpoint.
228	///
229	/// This is a deliberate trigger of the M3b handoff path, **not** a new handoff:
230	/// it snapshots the outgoing brain ([`Brain::snapshot`]), drops it — the exact
231	/// crash boundary the broker already tolerates (the subscriber vanishes; the
232	/// child + its buffered output persist) — and re-attaches by calling `relaunch`
233	/// with the snapshot. `relaunch` stands for "exec the new binary's brain, which
234	/// re-attaches via [`Brain::handoff`]"; in the live daemon it execs the verified
235	/// new binary, and the returned brain is generation+1 with a fresh `gen_start`.
236	///
237	/// The guard is the invariant: a non-brain-only `plan` is refused
238	/// ([`ApplyError::NotBrainOnly`]) **before** the outgoing brain is dropped, so
239	/// the brain-only path can never be the thing that cycles an endpoint. Broker
240	/// touching classes route elsewhere (C2 consent + scheduling).
241	///
242	/// The production caller is [`crate::applyhost::apply_staged`] (M5-D7b): the
243	/// peer pump still stops at *staged + consent-notified* (REQ-UPD-4 gated
244	/// default), and the explicit user ack (`spt update apply`) drives the
245	/// re-verify + class-check + brain handoff orchestration through this seam.
246	// [impl->REQ-UPD-3]
247	pub fn apply_brain_only<F>(
248	    plan: &UpdatePlan,
249	    outgoing: Brain,

pub struct UpdatePlan {
    /// The update class (ADR-0004 §A).
    pub class: UpdateClass,
    /// The release the plan was built for.
    pub release: ReleaseSpec,
}

/// Classify `release` against `running` and bundle the verdict with it.
pub fn plan_update(running: &BrokerAbi, release: ReleaseSpec) -> UpdatePlan {
    UpdatePlan {
        class: classify(running, &release),
        release,
    }
}

/// The verify-before-handoff gate (C1): authenticate a signed release + confirm
/// the binary bytes, then — and only then — classify it into an [`UpdatePlan`].
///
/// This is the **front door** of an update: ADR-0004 §D makes signing mandatory,
/// so a release is never even classified (let alone applied) until it has passed
/// [`verify_metadata`] (signature + key trust/revocation + channel + expiry +
/// monotonic version) **and** [`verify_artifact`] (the real bytes match the
/// signed digest). A rejected release returns its [`RejectReason`] and **no plan
/// is produced** — there is nothing for [`apply_brain_only`] to act on, so an
/// unverified binary can never reach a handoff ([`REQ-UPD-2`]).
// [impl->REQ-UPD-2]
// [impl->REQ-HAZARD-UPDATE-ROLLBACK]
pub fn plan_verified(
    running: &BrokerAbi,
    signed: &SignedRelease,
    artifact: &[u8],
    policy: &VerifyPolicy,
) -> Result<UpdatePlan, RejectReason> {
    let meta = verify_metadata(signed, policy)?;
    verify_artifact(&meta, artifact)?;
    Ok(plan_update(running, meta.spec()))
}

/// The update-set front door: authenticate a signed multi-platform set, select
/// the recipient's Rust target triple, confirm those bytes match the signed
/// digest, then classify that platform artifact.
// [impl->REQ-UPD-6]
pub fn plan_verified_update_set(
    running: &BrokerAbi,
    signed: &SignedUpdateSet,
    platform: &str,
    artifact: &[u8],
    policy: &VerifyPolicy,
) -> Result<UpdatePlan, RejectReason> {
    let meta = verify_update_set_metadata(signed, policy)?;
    let selected = meta.artifact_for(platform)?;
    verify_update_set_artifact(&meta, platform, artifact)?;
    Ok(plan_update(running, selected.spec()))
}

/// Why a brain-only apply did not complete.
#[derive(Debug)]
pub enum ApplyError {
    /// The plan is not brain-only — routing it through the brain-only path would
    /// risk an endpoint, which this class must never do. Raised **before** the
    /// outgoing brain is touched. The caller routes a broker-compatible /
    /// broker-breaking plan through its own (consent-gated, scheduled) path;
    /// brain-only is the only swap C0 performs.
    NotBrainOnly(UpdateClass),
    /// The new brain failed to re-attach (the `relaunch` closure errored). The
    /// endpoint is **untouched** regardless — the broker + its hosted child
    /// persist across a failed re-attach exactly as across a brain crash — so the
    /// caller may retry against the still-live broker.
    Relaunch(io::Error),
}

impl std::fmt::Display for ApplyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ApplyError::NotBrainOnly(c) => {
                write!(
                    f,
                    "apply_brain_only refused a {c:?} plan (would risk an endpoint)"
                )
            }
            ApplyError::Relaunch(e) => {
                write!(f, "brain-only relaunch failed (endpoint intact): {e}")
            }
        }
    }
}

impl std::error::Error for ApplyError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ApplyError::NotBrainOnly(_) => None,
            ApplyError::Relaunch(e) => Some(e),
        }
    }
}

/// Apply a **brain-only** update ([`REQ-UPD-3`]): swap the brain logic without
/// disturbing any broker-held resource or hosted endpoint.
///
/// This is a deliberate trigger of the M3b handoff path, **not** a new handoff:
/// it snapshots the outgoing brain ([`Brain::snapshot`]), drops it — the exact
/// crash boundary the broker already tolerates (the subscriber vanishes; the
/// child + its buffered output persist) — and re-attaches by calling `relaunch`
/// with the snapshot. `relaunch` stands for "exec the new binary's brain, which
/// re-attaches via [`Brain::handoff`]"; in the live daemon it execs the verified
/// new binary, and the returned brain is generation+1 with a fresh `gen_start`.
///
/// The guard is the invariant: a non-brain-only `plan` is refused
/// ([`ApplyError::NotBrainOnly`]) **before** the outgoing brain is dropped, so
/// the brain-only path can never be the thing that cycles an endpoint. Broker
/// touching classes route elsewhere (C2 consent + scheduling).
///
/// The production caller is [`crate::applyhost::apply_staged`] (M5-D7b): the
/// peer pump still stops at *staged + consent-notified* (REQ-UPD-4 gated
/// default), and the explicit user ack (`spt update apply`) drives the
/// re-verify + class-check + brain handoff orchestration through this seam.
// [impl->REQ-UPD-3]
pub fn apply_brain_only<F>(
    plan: &UpdatePlan,
    outgoing: Brain,


Grep

(?i)pub fn handoff|pub fn cold_start|struct BrainState|pub fn snapshot|fn drop|re-?exec|execv|CommandExt|spawn.*current_exe|self.?replace


64:pub struct BrainState {
151:    pub fn cold_start(name: &str, now_ms: u64) -> io::Result<Self> {
169:    pub fn handoff(name: &str, prior: BrainState, now_ms: u64) -> io::Result<Self> {
207:    pub fn snapshot(&self) -> BrainState {


/grill-with-docs updating in place without actually changing the running code seems very silly. what can we do about it, to make the update actually handoff to the new executable seamlessly, such that…


Release publish process and signing procedure


Critical find — update.rs:234-235 says the relaunch closure, **in the live daemon, "execs the verified new binary"** and returns a gen+1 brain. But applyhost.rs's production `handoff_retry` (260-270) just calls `Brain::handoff` **in-process** — no exec. That's the gap. Need to confirm the process topology before grilling: is the broker a separate process that survives a brain re-exec, and is the daemon == the brain?


40	    Spawned, SubscribeReq, KIND_APPLIED, KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL,
41	    KIND_NET_DIAL, KIND_NET_DIALED, KIND_NET_PRESENCE_EVENT, KIND_NET_PRESENCE_SUBSCRIBE,
42	    KIND_NET_SENT, KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_NET_STREAMS,
43	    KIND_NET_STREAMS_REPLY, KIND_NET_STREAM_DATA, KIND_NET_STREAM_EOF, KIND_NET_STREAM_OPEN,
44	    KIND_NET_STREAM_OPENED, KIND_NET_STREAM_SEND, KIND_NET_STREAM_SUBSCRIBE, KIND_OUTPUT,
45	    KIND_PAIR_JOIN, KIND_PAIR_JOINED, KIND_RESIZE, KIND_SESSIONS, KIND_SESSIONS_REPLY, KIND_SPAWN,
46	    KIND_SPAWNED, KIND_SUBSCRIBE,
47	};
48	use crate::transport::{send_hello, LocalSocketTransport};
49
50	/// Wall-clock now in epoch milliseconds — the source for `gen_start` (matches
51	/// the epoch-ms stamping used elsewhere in the tree; no date dependency).
52	pub fn now_ms() -> u64 {
53	    SystemTime::now()
54	        .duration_since(UNIX_EPOCH)
55	        .map(|d| d.as_millis() as u64)
56	        .unwrap_or(0)
57	}
58
59	/// The brain's durable state across a handoff: enough to re-attach gaplessly and
60	/// advance the generation. `gen_start_ms` is recorded for observability but is
61	/// **deliberately not authoritative on restart** — [`Brain::handoff`] sets a
62	/// fresh `gen_start` from `now()` (2.4).
63	#[derive(Debug, Clone)]
64	pub struct BrainState {
65	    /// The session the brain was driving.
66	    pub session_id: u64,
67	    /// The generation counter (incremented on each handoff).
68	    pub generation: u64,
69	    /// The next output sequence still needed (the resume cursor).
70	    pub next_seq: u64,
71	    /// The prior generation's wall-clock start (ignored as authoritative on
72	    /// handoff — kept only to demonstrate it is *not* carried forward).
73	    pub gen_start_ms: u64,
74	}
75
76	/// A demuxed broker→brain event.
77	#[derive(Debug)]
78	pub enum BrokerEvent {
79	    /// A contiguous chunk of a session's output (the cursor has advanced past it).
80	    Output {
81	        session_id: u64,
82	        seq: u64,
83	        bytes: Vec<u8>,
84	    },
85	    /// A spawn succeeded.
86	    Spawned { session_id: u64, pid: Option<u32> },
87	    /// A session's child exited.
88	    Exit { session_id: u64, code: Option<i32> },
89	    /// An idempotent effect is durably applied (or was deduped — `applied_now`
90	    /// distinguishes). The exactly-once ack for a [`Brain::send_effect`] (B5).
91	    Applied {
92	        session_id: u64,
93	        op_id: u64,
94	        applied_now: bool,
95	    },
96	    /// The broker-owned net endpoint's status (a `net-status` reply, D4a).
97	    NetStatus(NetStatusReply),
98	    /// A net dial durably landed — fresh or deduped (D4a).
99	    NetDialed(NetDialed),
100	    /// A stream open durably landed — fresh or deduped (D4b).
101	    NetStreamOpened(NetStreamOpened),
102	    /// A journaled stream send durably landed — fresh or deduped (D4b).
103	    NetSent(NetSent),
104	    /// The broker's held-streams listing (D4b).
105	    NetStreams(NetStreamsReply),
106	    /// The broker's hosted-sessions listing (D9-1: session → endpoint).
107	    Sessions(SessionsReply),
108	    /// The subnet-join ceremony's verdict (a `pair-joined` reply, M7 D3).
109	    PairJoined(PairJoinReply),
110	    /// A contiguous chunk of a stream's reads (the cursor has advanced past it).
111	    NetStreamData {
112	        stream_id: u64,
113	        seq: u64,
114	        bytes: Vec<u8>,
115	    },
116	    /// A stream's read side finished cleanly; `next_seq` is the completeness
117	    /// check (the subscriber's cursor must equal it to have missed nothing).
118	    NetStreamEof { stream_id: u64, next_seq: u64 },
119	    /// A contiguous conn-liveness event (the presence cursor has advanced past
120	    /// it) — connected/disconnected on the broker's conn table (D4c).
121	    NetPresence(NetPresenceEvent),
122	    /// The broker reported a command error.
123	    Error { message: String },
124	    /// A frame of an unrecognized kind (forward-compat: tolerated, surfaced).
125	    Other(Envelope),
126	}
127
128	/// A live brain attached to a broker.
129	pub struct Brain {
130	    conn: Stream,
131	    generation: u64,
132	    gen_start_ms: u64,
133	    session_id: Option<u64>,
134	    /// The next output sequence still needed (resume cursor / dedup floor).
135	    next_seq: u64,
136	    /// Per-net-stream resume cursors (D4b) — the stream-read siblings of
137	    /// `next_seq`, set by [`Brain::net_stream_subscribe`] and advanced by
138	    /// [`Brain::read_event`] under the same accept/dedup/gap discipline.
139	    /// Durability across a handoff is the logic layer's job (a re-attaching
140	    /// brain queries `net-streams` and re-subscribes from its durable cursors).
141	    net_cursors: std::collections::HashMap<u64, u64>,
142	    /// The presence resume cursor (D4c) — the conn-liveness sibling of
143	    /// `net_cursors`, set by [`Brain::net_presence_subscribe`] and advanced by
144	    /// [`Brain::read_event`] under the same accept/dedup/gap discipline.
145	    /// `None` until subscribed (or the first event arrives).
146	    presence_cursor: Option<u64>,
147	}
148
149	impl Brain {
150	    /// Cold start: connect, handshake, fresh generation 0, `gen_start = now`.
151	    pub fn cold_start(name: &str, now_ms: u64) -> io::Result<Self> {
152	        let mut conn = LocalSocketTransport::connect(name)?;
153	        send_hello(&mut conn, Role::Brain)?;
154	        Ok(Brain {
155	            conn,
156	            generation: 0,
157	            gen_start_ms: now_ms,
158	            session_id: None,
159	            next_seq: 0,
160	            net_cursors: std::collections::HashMap::new(),
161	            presence_cursor: None,
162	        })
163	    }
164
165	    /// Handoff: connect, handshake, rehydrate the resume cursor + generation
166	    /// from `prior`, but stamp `gen_start = now()` (NOT `prior.gen_start_ms` —
167	    /// the 2.4 invariant), and re-subscribe to the session from the cursor for a
168	    /// gapless resume.
169	    pub fn handoff(name: &str, prior: BrainState, now_ms: u64) -> io::Result<Self> {
170	        let mut conn = LocalSocketTransport::connect(name)?;
171	        send_hello(&mut conn, Role::Brain)?;
172	        let mut brain = Brain {
173	            conn,
174	            generation: prior.generation + 1,
175	            gen_start_ms: now_ms, // fresh — prior.gen_start_ms is intentionally discarded
176	            session_id: Some(prior.session_id),
177	            next_seq: prior.next_seq,
178	            net_cursors: std::collections::HashMap::new(),
179	            presence_cursor: None,
180	        };
181	        brain.subscribe(prior.session_id, prior.next_seq)?;
182	        Ok(brain)
183	    }
184
185	    /// The wall-clock generation start (epoch ms) — always this generation's own
186	    /// start time, never a rehydrated value.
187	    pub fn gen_start_ms(&self) -> u64 {
188	        self.gen_start_ms
189	    }
190
191	    /// The generation counter (0 on cold start, +1 per handoff).
192	    pub fn generation(&self) -> u64 {
193	        self.generation
194	    }
195
196	    /// The next output sequence still needed (the resume cursor).
197	    pub fn next_seq(&self) -> u64 {
198	        self.next_seq
199	    }
200
201	    /// The session id the brain is driving, if any.
202	    pub fn session_id(&self) -> Option<u64> {
203	        self.session_id
204	    }
205
206	    /// Snapshot durable state for a future handoff.
207	    pub fn snapshot(&self) -> BrainState {
208	        BrainState {
209	            session_id: self.session_id.expect("snapshot before a session exists"),
210	            generation: self.generation,
211	            next_seq: self.next_seq,
212	            gen_start_ms: self.gen_start_ms,
213	        }
214	    }
215
216	    /// Spawn a session under a broker PTY; the broker auto-subscribes this
217	    /// connection. Reads until the `spawned` reply (consuming any output that
218	    /// races ahead).
219	    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
220	        self.spawn_session_pid(req)
221	            .map(|(session_id, _)| session_id)
222	    }
223
224	    /// [`spawn_session`](Self::spawn_session) that also surfaces the child's
225	    /// OS pid (when the backend exposes one) — measurement/observability
226	    /// callers (the D9-3 dormancy-budget harness) sample the seat by pid.
227	    pub fn spawn_session_pid(&mut self, req: SpawnReq) -> io::Result<(u64, Option<u32>)> {
228	        self.send(
229	            KIND_SPAWN,
230	            serde_json::to_value(req).expect("SpawnReq serializes"),
231	        )?;
232	        loop {
233	            match self.read_event()? {
234	                BrokerEvent::Spawned { session_id, pid } => {
235	                    self.session_id = Some(session_id);
236	                    return Ok((session_id, pid));
237	                }
238	                BrokerEvent::Error { message } => {
239	                    return Err(io::Error::other(message));
240	                }
241	                _ => continue,
242	            }
243	        }
244	    }
245
246	    /// Inject input bytes into the current session (send-keys), the **legacy
247	    /// direct** path: no durable id, no dedup, no ack. For exactly-once delivery
248	    /// of an effect that originates from a durable source (a spool row, a
249	    /// sequenced injection), use [`Brain::send_effect`].
250	    pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
251	        let sid = self.require_session()?;
252	        self.send(
253	            KIND_INPUT,
254	            serde_json::to_value(InputReq {
255	                session_id: sid,
256	                data_b64: encode_bytes(bytes),
257	                op_id: None,
258	            })
259	            .expect("InputReq serializes"),

    Spawned, SubscribeReq, KIND_APPLIED, KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL,
    KIND_NET_DIAL, KIND_NET_DIALED, KIND_NET_PRESENCE_EVENT, KIND_NET_PRESENCE_SUBSCRIBE,
    KIND_NET_SENT, KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_NET_STREAMS,
    KIND_NET_STREAMS_REPLY, KIND_NET_STREAM_DATA, KIND_NET_STREAM_EOF, KIND_NET_STREAM_OPEN,
    KIND_NET_STREAM_OPENED, KIND_NET_STREAM_SEND, KIND_NET_STREAM_SUBSCRIBE, KIND_OUTPUT,
    KIND_PAIR_JOIN, KIND_PAIR_JOINED, KIND_RESIZE, KIND_SESSIONS, KIND_SESSIONS_REPLY, KIND_SPAWN,
    KIND_SPAWNED, KIND_SUBSCRIBE,
};
use crate::transport::{send_hello, LocalSocketTransport};

/// Wall-clock now in epoch milliseconds — the source for `gen_start` (matches
/// the epoch-ms stamping used elsewhere in the tree; no date dependency).
pub fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// The brain's durable state across a handoff: enough to re-attach gaplessly and
/// advance the generation. `gen_start_ms` is recorded for observability but is
/// **deliberately not authoritative on restart** — [`Brain::handoff`] sets a
/// fresh `gen_start` from `now()` (2.4).
#[derive(Debug, Clone)]
pub struct BrainState {
    /// The session the brain was driving.
    pub session_id: u64,
    /// The generation counter (incremented on each handoff).
    pub generation: u64,
    /// The next output sequence still needed (the resume cursor).
    pub next_seq: u64,
    /// The prior generation's wall-clock start (ignored as authoritative on
    /// handoff — kept only to demonstrate it is *not* carried forward).
    pub gen_start_ms: u64,
}

/// A demuxed broker→brain event.
#[derive(Debug)]
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> },
    /// 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).
    NetPresence(NetPresenceEvent),
    /// The broker reported a command error.
    Error { message: String },
    /// A frame of an unrecognized kind (forward-compat: tolerated, surfaced).
    Other(Envelope),
}

/// A live brain attached to a broker.
pub struct Brain {
    conn: Stream,
    generation: u64,
    gen_start_ms: u64,
    session_id: Option<u64>,
    /// The next output sequence still needed (resume cursor / dedup floor).
    next_seq: u64,
    /// Per-net-stream resume cursors (D4b) — the stream-read siblings of
    /// `next_seq`, set by [`Brain::net_stream_subscribe`] and advanced by
    /// [`Brain::read_event`] under the same accept/dedup/gap discipline.
    /// Durability across a handoff is the logic layer's job (a re-attaching
    /// brain queries `net-streams` and re-subscribes from its durable cursors).
    net_cursors: std::collections::HashMap<u64, u64>,
    /// The presence resume cursor (D4c) — the conn-liveness sibling of
    /// `net_cursors`, set by [`Brain::net_presence_subscribe`] and advanced by
    /// [`Brain::read_event`] under the same accept/dedup/gap discipline.
    /// `None` until subscribed (or the first event arrives).
    presence_cursor: Option<u64>,
}

impl Brain {
    /// Cold start: connect, handshake, fresh generation 0, `gen_start = now`.
    pub fn cold_start(name: &str, now_ms: u64) -> io::Result<Self> {
        let mut conn = LocalSocketTransport::connect(name)?;
        send_hello(&mut conn, Role::Brain)?;
        Ok(Brain {
            conn,
            generation: 0,
            gen_start_ms: now_ms,
            session_id: None,
            next_seq: 0,
            net_cursors: std::collections::HashMap::new(),
            presence_cursor: None,
        })
    }

    /// Handoff: connect, handshake, rehydrate the resume cursor + generation
    /// from `prior`, but stamp `gen_start = now()` (NOT `prior.gen_start_ms` —
    /// the 2.4 invariant), and re-subscribe to the session from the cursor for a
    /// gapless resume.
    pub fn handoff(name: &str, prior: BrainState, now_ms: u64) -> io::Result<Self> {
        let mut conn = LocalSocketTransport::connect(name)?;
        send_hello(&mut conn, Role::Brain)?;
        let mut brain = Brain {
            conn,
            generation: prior.generation + 1,
            gen_start_ms: now_ms, // fresh — prior.gen_start_ms is intentionally discarded
            session_id: Some(prior.session_id),
            next_seq: prior.next_seq,
            net_cursors: std::collections::HashMap::new(),
            presence_cursor: None,
        };
        brain.subscribe(prior.session_id, prior.next_seq)?;
        Ok(brain)
    }

    /// The wall-clock generation start (epoch ms) — always this generation's own
    /// start time, never a rehydrated value.
    pub fn gen_start_ms(&self) -> u64 {
        self.gen_start_ms
    }

    /// The generation counter (0 on cold start, +1 per handoff).
    pub fn generation(&self) -> u64 {
        self.generation
    }

    /// The next output sequence still needed (the resume cursor).
    pub fn next_seq(&self) -> u64 {
        self.next_seq
    }

    /// The session id the brain is driving, if any.
    pub fn session_id(&self) -> Option<u64> {
        self.session_id
    }

    /// Snapshot durable state for a future handoff.
    pub fn snapshot(&self) -> BrainState {
        BrainState {
            session_id: self.session_id.expect("snapshot before a session exists"),
            generation: self.generation,
            next_seq: self.next_seq,
            gen_start_ms: self.gen_start_ms,
        }
    }

    /// Spawn a session under a broker PTY; the broker auto-subscribes this
    /// connection. Reads until the `spawned` reply (consuming any output that
    /// races ahead).
    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
        self.spawn_session_pid(req)
            .map(|(session_id, _)| session_id)
    }

    /// [`spawn_session`](Self::spawn_session) that also surfaces the child's
    /// OS pid (when the backend exposes one) — measurement/observability
    /// callers (the D9-3 dormancy-budget harness) sample the seat by pid.
    pub fn spawn_session_pid(&mut self, req: SpawnReq) -> io::Result<(u64, Option<u32>)> {
        self.send(
            KIND_SPAWN,
            serde_json::to_value(req).expect("SpawnReq serializes"),
        )?;
        loop {
            match self.read_event()? {
                BrokerEvent::Spawned { session_id, pid } => {
                    self.session_id = Some(session_id);
                    return Ok((session_id, pid));
                }
                BrokerEvent::Error { message } => {
                    return Err(io::Error::other(message));
                }
                _ => continue,
            }
        }
    }

    /// Inject input bytes into the current session (send-keys), the **legacy
    /// direct** path: no durable id, no dedup, no ack. For exactly-once delivery
    /// of an effect that originates from a durable source (a spool row, a
    /// sequenced injection), use [`Brain::send_effect`].
    pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_INPUT,
            serde_json::to_value(InputReq {
                session_id: sid,
                data_b64: encode_bytes(bytes),
                op_id: None,
            })
            .expect("InputReq serializes"),


Brain model clear: per-session logic unit, socket-attached to the broker, minimal `BrainState` (session_id, generation, next_seq) → `handoff` re-subscribes gaplessly. The whole "seamless exec handoff" question hinges on one fact: **is the broker a separate process** that survives the daemon-brain exec'ing the new binary? If it's a thread in the daemon process, `exec()` kills the PTYs/sockets/children. Checking the daemon run loop + ADR-0004:


1	//! The daemon process entrypoint + auto-start (B6).
2	//!
3	//! [`Daemon::run`] is the body of the hidden `spt daemon` subcommand: it binds
4	//! the broker (B1/B2 PTY hosting) and the seed-control channel (B6) under the
5	//! canonical per-home names ([`crate::endpoint`]) and serves both for the life
6	//! of the process.
7	//!
8	//! [`ensure_running`] is the auto-start seam (REQ-DAEMON-3): any `api`
9	//! invocation calls it; if no daemon answers the seed-control ping it spawns
10	//! `spt daemon` detached and waits (bounded) for it to come up. The reliable
11	//! anchor is the seed channel — bound for the whole daemon lifetime, so a ping
12	//! against it is a true liveness test (`listen` is the interim model's anchor;
13	//! here the seed channel plays that role for cold-start detection).
14	// [impl->REQ-DAEMON-3]
15
16	use std::io;
17	#[cfg(unix)]
18	use std::process::{Command, Stdio};
19	use std::sync::Arc;
20	use std::thread;
21	use std::time::Duration;
22
23	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
24
25	use crate::broker::{default_journal_path, Broker};
26	use crate::config::{DaemonConfig, RelayChoice};
27	use crate::digesthub::{self, DigestHub};
28	use crate::endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
29	use crate::nethost::{NetConfig, NetHost};
30	use crate::seedmap::{self, SeedRegistry};
31
32	/// How long auto-start waits for a freshly-spawned daemon to bind its channel.
33	const START_TIMEOUT: Duration = Duration::from_secs(5);
34	/// Poll cadence while waiting for the spawned daemon to come up.
35	const START_POLL: Duration = Duration::from_millis(25);
36
37	/// The per-machine broker/brain daemon process.
38	pub struct Daemon;
39
40	impl Daemon {
41	    /// Run the daemon: bind the broker (served on a background thread) + the
42	    /// seed-control channel (the foreground loop + liveness anchor) and serve
43	    /// until the process is killed. The body of `spt daemon`.
44	    pub fn run() -> io::Result<()> {
45	        // Entry elevation guard (KNOWN-HAZARDS 5.7): a DIRECTLY elevated
46	        // `spt daemon` (sudo / elevated shell) must not serve with the
47	        // elevated token — its pipes would deny unelevated clients (Windows)
48	        // or root the user's state universe (Linux). Unix drops privileges
49	        // in-process before any state is touched; Windows cannot drop a
50	        // token in-place, so it respawns de-elevated via the UAC linked
51	        // token and exits (the child re-enters here unelevated). With no
52	        // unelevated identity to drop to, serving continues as-is, loudly —
53	        // a consistent universe, never a torn one.
54	        // [impl->REQ-HAZARD-ELEVATED-DAEMON-SPAWN]
55	        // Drop target: the ELECTED default user when one exists, else the
56	        // sudo invoker — electing it on first use (M8 decision 8,
57	        // REQ-INSTALL-6: the first `sudo spt` decides; thereafter every
58	        // elevated launch lands in the same account's universe).
59	        // [impl->REQ-INSTALL-6]
60	        #[cfg(unix)]
61	        if let Some(invoker) = crate::deelevate::daemon_target_user() {
62	            match invoker.drop_in_process() {
63	                Ok(()) => eprintln!("DEELEVATED: daemon dropped to uid {}", invoker.uid),
64	                // A drop target existed but the drop failed: serving as
65	                // root would root the user's state universe — a known-torn
66	                // state. ABORT (user-ratified 2026-06-06: refuse, don't limp).
67	                Err(e) => {
68	                    return Err(io::Error::other(format!(
69	                        "DEELEVATE_FAIL: {e} — refusing to serve as root over a user universe; \
70	                         run spt unelevated"
71	                    )));
72	                }
73	            }
74	        }
75	        #[cfg(windows)]
76	        if crate::deelevate::is_elevated() {
77	            let exe = std::env::current_exe()?;
78	            match crate::deelevate::spawn_deelevated(
79	                &exe.to_string_lossy(),
80	                &["daemon".to_string(), "run".to_string()],
81	            ) {
82	                Ok(Some(pid)) => {
83	                    eprintln!(
84	                        "DEELEVATED_RESPAWN: unelevated daemon pid {pid}; elevated copy exits"
85	                    );
86	                    return Ok(());
87	                }
88	                // Uniformly-elevated universe (no unelevated identity):
89	                // serving elevated is consistent there. Loud anyway.
90	                Ok(None) => eprintln!(

//! The daemon process entrypoint + auto-start (B6).
//!
//! [`Daemon::run`] is the body of the hidden `spt daemon` subcommand: it binds
//! the broker (B1/B2 PTY hosting) and the seed-control channel (B6) under the
//! canonical per-home names ([`crate::endpoint`]) and serves both for the life
//! of the process.
//!
//! [`ensure_running`] is the auto-start seam (REQ-DAEMON-3): any `api`
//! invocation calls it; if no daemon answers the seed-control ping it spawns
//! `spt daemon` detached and waits (bounded) for it to come up. The reliable
//! anchor is the seed channel — bound for the whole daemon lifetime, so a ping
//! against it is a true liveness test (`listen` is the interim model's anchor;
//! here the seed channel plays that role for cold-start detection).
// [impl->REQ-DAEMON-3]

use std::io;
#[cfg(unix)]
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};

use crate::broker::{default_journal_path, Broker};
use crate::config::{DaemonConfig, RelayChoice};
use crate::digesthub::{self, DigestHub};
use crate::endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
use crate::nethost::{NetConfig, NetHost};
use crate::seedmap::{self, SeedRegistry};

/// How long auto-start waits for a freshly-spawned daemon to bind its channel.
const START_TIMEOUT: Duration = Duration::from_secs(5);
/// Poll cadence while waiting for the spawned daemon to come up.
const START_POLL: Duration = Duration::from_millis(25);

/// The per-machine broker/brain daemon process.
pub struct Daemon;

impl Daemon {
    /// Run the daemon: bind the broker (served on a background thread) + the
    /// seed-control channel (the foreground loop + liveness anchor) and serve
    /// until the process is killed. The body of `spt daemon`.
    pub fn run() -> io::Result<()> {
        // Entry elevation guard (KNOWN-HAZARDS 5.7): a DIRECTLY elevated
        // `spt daemon` (sudo / elevated shell) must not serve with the
        // elevated token — its pipes would deny unelevated clients (Windows)
        // or root the user's state universe (Linux). Unix drops privileges
        // in-process before any state is touched; Windows cannot drop a
        // token in-place, so it respawns de-elevated via the UAC linked
        // token and exits (the child re-enters here unelevated). With no
        // unelevated identity to drop to, serving continues as-is, loudly —
        // a consistent universe, never a torn one.
        // [impl->REQ-HAZARD-ELEVATED-DAEMON-SPAWN]
        // Drop target: the ELECTED default user when one exists, else the
        // sudo invoker — electing it on first use (M8 decision 8,
        // REQ-INSTALL-6: the first `sudo spt` decides; thereafter every
        // elevated launch lands in the same account's universe).
        // [impl->REQ-INSTALL-6]
        #[cfg(unix)]
        if let Some(invoker) = crate::deelevate::daemon_target_user() {
            match invoker.drop_in_process() {
                Ok(()) => eprintln!("DEELEVATED: daemon dropped to uid {}", invoker.uid),
                // A drop target existed but the drop failed: serving as
                // root would root the user's state universe — a known-torn
                // state. ABORT (user-ratified 2026-06-06: refuse, don't limp).
                Err(e) => {
                    return Err(io::Error::other(format!(
                        "DEELEVATE_FAIL: {e} — refusing to serve as root over a user universe; \
                         run spt unelevated"
                    )));
                }
            }
        }
        #[cfg(windows)]
        if crate::deelevate::is_elevated() {
            let exe = std::env::current_exe()?;
            match crate::deelevate::spawn_deelevated(
                &exe.to_string_lossy(),
                &["daemon".to_string(), "run".to_string()],
            ) {
                Ok(Some(pid)) => {
                    eprintln!(
                        "DEELEVATED_RESPAWN: unelevated daemon pid {pid}; elevated copy exits"
                    );
                    return Ok(());
                }
                // Uniformly-elevated universe (no unelevated identity):
                // serving elevated is consistent there. Loud anyway.
                Ok(None) => eprintln!(


No files found


Explored enough to find the **root cause** — and it reframes the whole problem.

## The decisive fact

`daemon.rs:41-44`: the daemon "binds the broker (served on a **background thread**) + the seed-control channel (the foreground loop)."

**The broker is a thread inside the daemon process, not a separate process.** That's the whole reason `apply` can't hand off to the new executable:

- The broker holds the un-transferable resources — PTY master fds, the spawned harness **child processes**, listening network sockets (CONTEXT:33).
- To run new code, the brain must `exec`/restart onto the new binary.
- But `exec` replaces the **entire process** — and the broker thread goes with it. PTYs close, children die, sockets drop. Every endpoint dies.

So the brain/broker split (ADR-0004) was *designed* for "brain restarts freely, broker persists" — and the handoff **substrate exists** (`Brain::handoff` over a socket, `BrainState` resume cursors). But the **process topology never realized it**: broker and brain are one process. The `update.rs:234` claim that "the live daemon execs the verified new binary" is aspirational — `applyhost.rs`'s production closure just re-attaches in-process (same old code), because a real exec would kill the broker.

Your instinct is right: in-place-without-swapping is silly. But the fix isn't in `apply` — it's in the **process model**.

---

This is the foundational fork, so I'll start here.

**Q1 — Separation strategy: how should the brain run new code without dropping broker-held resources?**

Three branches:

- **A. Broker as its own process.** The daemon spawns a long-lived broker child that owns PTYs/children/sockets. The brain (all logic) is a separate process connecting over the existing socket. Update = restart *the brain process* only; broker persists; new brain re-attaches via `Brain::handoff` (substrate already built). Realizes the ADR-0004 design literally. Cross-platform uniform.
- **B. In-place `exec` with FD inheritance.** Keep one process; on update, `exec()` the new binary. On Unix, `exec` preserves PID + open FDs + child relationships — PTYs/sockets/children survive *if* broker in-memory state (output buffers, journals, session table) is rehydratable from disk/FDs. **Windows has no `exec`** — would need a different path, breaking the uniformity.
- **C. Keep brain-restart model, just trigger it from `apply`.** Doesn't work as-is: restarting the one process kills the broker thread → endpoints drop. Only viable *after* A.

**My recommendation: A.** It's the literal realization of the split the docs already promise, it's the only cross-platform-uniform option, and it reuses the handoff substrate you already built and tested. B's Windows gap makes it a non-starter for a tool that ships Windows + Linux equally; FD-passing on Windows (handle duplication across a fresh process) is doable but is essentially A with extra steps.

Before you answer — one thing I want to verify so we're grounded: **how entangled are the resident loops (peer pump, pulse/psyche, routing) with the broker thread in `run()`?** That determines A's actual cost. Want me to read the rest of `daemon.rs::run()` and map exactly what's in-process today before you decide?


before Q1: "binds the broker (served on a background thread)" -- couldn't this just mean that the loop which binds daemon to broker is a background thread, while the broker itself is a separate binary? i stg we specced very early on that brokers need to be standalone processes. i'd think it'd be in CONTEXT.md.


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


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


Fair challenge — let me verify rather than assume. Checking exactly how `run()` instantiates the broker, plus what CONTEXT/ADRs actually spec:


90	                Ok(None) => eprintln!(
91	                    "ELEVATED_DAEMON: no unelevated identity to drop to — serving with the \
92	                     elevated token"
93	                ),
94	                // Target existed, respawn failed: an elevated daemon is a
95	                // known-torn state. ABORT (user-ratified 2026-06-06).
96	                Err(e) => {
97	                    return Err(io::Error::other(format!(
98	                        "DEELEVATE_FAIL: {e} — refusing to serve with the elevated token; run \
99	                         spt from an unelevated shell"
100	                    )));
101	                }
102	            }
103	        }
104
105	        let reg = Arc::new(SeedRegistry::new());
106
107	        // Boot housekeeping (KNOWN-HAZARDS 1.3): sweep stale `index.lock`
108	        // wedges out of the git-touching runtime the daemon hosts — the
109	        // context store's seed repo + its agent/project worktrees (a crashed
110	        // git leaves a 0-byte lock that blocks every later commit forever).
111	        // Path composed via the layout authority without opening the full
112	        // ContextStore on the boot path; a lock-less (or store-less) home
113	        // no-ops.
114	        // [impl->REQ-HAZARD-STALE-INDEX-LOCK]
115	        for lock in spt_store::branchstore::sweep_stale_index_locks(
116	            &spt_store::contextstore::seed_git_dir(&spt_store::perch::tracked_dir()),
117	            spt_store::branchstore::STALE_LOCK_AGE,
118	        ) {
119	            eprintln!("STALE_LOCK_SWEPT:{}", lock.display());
120	        }
121
122	        // Serve-state boot reset (M8 decision 6, REQ-SUBNET-5): the live
123	        // attachment state starts from the config's startup defaults — an
124	        // unsaved attach/detach flip deliberately does not survive a daemon
125	        // restart (`--save` is the persistence path).
126	        // [impl->REQ-SUBNET-5]
127	        {
128	            let att = spt_store::attachment::AttachmentStore::reset_to(
129	                &DaemonConfig::load().detached_subnets,
130	            );
131	            if let Err(e) = att.save() {
132	                eprintln!("ATTACHMENT_RESET_FAIL: {e} — serving with all subnets attached");
133	            }
134	            for name in &att.detached {
135	                eprintln!("SUBNET_DETACHED:{name} (startup default — `spt subnet attach {name}`)");
136	            }
137	        }
138
139	        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint
140	        // + live QUIC connections live in the broker so they survive a brain
141	        // restart. Identity is the durable node.key (stable name across daemon
142	        // restarts); relay stance comes from daemon.json; mDNS rides the
143	        // endpoint (closing the §B mDNS/relay-session row by construction). A
144	        // net failure degrades to a net-less broker — it must not cost PTY
145	        // hosting (mirrors the broker-bind soft-fail below).
146	        // [impl->REQ-DAEMON-2]
147	        // Identity (the durable node.key) decides node_hex independently of the
148	        // net bring-up: a NET_BIND_FAIL (e.g. the autostart beat the network up
149	        // at boot — REQ-DAEMON-9) is transient and retried, but a broken
150	        // identity is not, so we only arm the retry when identity is sound.
151	        let node_hex: Option<String> = spt_store::nodeid::load_or_create()
152	            .ok()
153	            .map(|i| i.public_key().to_hex());
154	        let net = if node_hex.is_some() {
155	            try_start_net()
156	        } else {
157	            eprintln!("NODE_KEY_FAIL: identity unavailable — broker runs net-less, no retry");
158	            None
159	        };
160
161	        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
162	        // background thread; a bind failure there must not stop the seed channel
163	        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
164	        let net_up = net.is_some();
165	        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
166	            Ok(broker) => {
167	                let serve_broker = Arc::clone(&broker);
168	                thread::spawn(move || {
169	                    let _ = serve_broker.serve();
170	                });
171	                // Inbound net dispatch + outbound peer pump (D9-1): only
172	                // meaningful with a net host — a net-less broker accepts nothing
173	                // and dials nobody.
174	                let hex = node_hex.clone().unwrap_or_default();
175	                if net_up {
176	                    spawn_net_consumers(&hex);
177	                } else if node_hex.is_some() {
178	                    // Boot-race self-heal (REQ-DAEMON-9): net failed to bind but
179	                    // identity is sound — almost always the autostart racing the
180	                    // network up at boot. Retry net in the background; attach it
181	                    // to the broker + spawn the consumers when it succeeds, so
182	                    // the node becomes reachable WITHOUT a manual restart.
183	                    // [impl->REQ-DAEMON-9]
184	                    let broker = Arc::clone(&broker);
185	                    thread::spawn(move || net_retry_attach(broker, hex));
186	                }
187	            }
188	            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
189	        }
190
191	        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
192	        // the digest-control channel serves `spt digest <id>` snapshot pulls +
193	        // delta-stream subscribes against it. Served on a background thread like
194	        // the broker. The per-session feed that *populates* the hub is driven by
195	        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
196	        // sessions are spawned through the broker; a bare daemon serves the
197	        // channel and answers "no live digest" until a session is hosted.
198	        let digest_hub = Arc::new(DigestHub::new());
199	        {
200	            let hub = Arc::clone(&digest_hub);
201	            thread::spawn(move || {
202	                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
203	            });
204	        }
205
206	        // Shell wake-watchers (M5-D4b): the daemon hosts the offline half of
207	        // the shell online/offline exclusivity — a reconcile loop that runs
208	        // each offline instance's manifest `wake_command` as a supervised
209	        // watcher child (exit-opcode → wake resolution) and stops it when the
210	        // binary side wins. Boot sweep doubles as orphan cleanup after a
211	        // daemon crash. The daemon runs until killed, so the stop flag here
212	        // is held for symmetry with the dispatcher loops, never raised.
213	        // [impl->REQ-SHELL-2]
214	        {
215	            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
216	            let _ = crate::shellwake::spawn_wake_host(stop);
217	        }
218
219	        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
220	        // is the socket ping, never this file, so a stale value is harmless.
221	        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());
222
223	        // The seed-control channel is the foreground loop. Binding it is what
224	        // arbitrates the single-daemon-per-home invariant: a second daemon racing
225	        // to start fails this bind and exits, leaving the winner serving.
226	        seedmap::serve_seed_control(&seed_socket_name(), reg)
227	    }
228	}
229
230	/// Try to bring up the broker's network host once: build the production
231	/// `NetConfig` (durable node.key + relay/discovery/membership) and start the
232	/// endpoint, wiring on-connect roster propagation (REQ-MESH-2). `None` on a
233	/// `NET_BIND_FAIL` (e.g. the network/DNS stack not ready yet) or a missing
234	/// identity — the caller binds net-less and (REQ-DAEMON-9) retries.
235	// [impl->REQ-DAEMON-2]
236	// [impl->REQ-DAEMON-9]
237	fn try_start_net() -> Option<NetHost> {
238	    let cfg = DaemonConfig::load();
239	    let relay = match cfg.relay {
240	        RelayChoice::N0 => RelayPolicy::N0Default,
241	        RelayChoice::Disabled => RelayPolicy::Disabled,
242	    };
243	    let identity = spt_store::nodeid::load_or_create().ok()?;
244	    let net_cfg = NetConfig {
245	        identity,
246	        relay,
247	        discovery: LocalDiscovery::Mdns,
248	        scope: BindScope::All, // production: real interfaces
249	        // Mesh-D2: enforce seed-proof on every dial + accept, reading the live
250	        // subnet store each connect.
251	        membership: Some(crate::seedproofx::production_membership()),
252	    };
253	    match NetHost::start(net_cfg) {
254	        Ok(host) => {
255	            host.set_roster_exchange(crate::seedproofx::production_roster_exchange());
256	            Some(host)
257	        }
258	        Err(e) => {
259	            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");

                Ok(None) => eprintln!(
                    "ELEVATED_DAEMON: no unelevated identity to drop to — serving with the \
                     elevated token"
                ),
                // Target existed, respawn failed: an elevated daemon is a
                // known-torn state. ABORT (user-ratified 2026-06-06).
                Err(e) => {
                    return Err(io::Error::other(format!(
                        "DEELEVATE_FAIL: {e} — refusing to serve with the elevated token; run \
                         spt from an unelevated shell"
                    )));
                }
            }
        }

        let reg = Arc::new(SeedRegistry::new());

        // Boot housekeeping (KNOWN-HAZARDS 1.3): sweep stale `index.lock`
        // wedges out of the git-touching runtime the daemon hosts — the
        // context store's seed repo + its agent/project worktrees (a crashed
        // git leaves a 0-byte lock that blocks every later commit forever).
        // Path composed via the layout authority without opening the full
        // ContextStore on the boot path; a lock-less (or store-less) home
        // no-ops.
        // [impl->REQ-HAZARD-STALE-INDEX-LOCK]
        for lock in spt_store::branchstore::sweep_stale_index_locks(
            &spt_store::contextstore::seed_git_dir(&spt_store::perch::tracked_dir()),
            spt_store::branchstore::STALE_LOCK_AGE,
        ) {
            eprintln!("STALE_LOCK_SWEPT:{}", lock.display());
        }

        // Serve-state boot reset (M8 decision 6, REQ-SUBNET-5): the live
        // attachment state starts from the config's startup defaults — an
        // unsaved attach/detach flip deliberately does not survive a daemon
        // restart (`--save` is the persistence path).
        // [impl->REQ-SUBNET-5]
        {
            let att = spt_store::attachment::AttachmentStore::reset_to(
                &DaemonConfig::load().detached_subnets,
            );
            if let Err(e) = att.save() {
                eprintln!("ATTACHMENT_RESET_FAIL: {e} — serving with all subnets attached");
            }
            for name in &att.detached {
                eprintln!("SUBNET_DETACHED:{name} (startup default — `spt subnet attach {name}`)");
            }
        }

        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint
        // + live QUIC connections live in the broker so they survive a brain
        // restart. Identity is the durable node.key (stable name across daemon
        // restarts); relay stance comes from daemon.json; mDNS rides the
        // endpoint (closing the §B mDNS/relay-session row by construction). A
        // net failure degrades to a net-less broker — it must not cost PTY
        // hosting (mirrors the broker-bind soft-fail below).
        // [impl->REQ-DAEMON-2]
        // Identity (the durable node.key) decides node_hex independently of the
        // net bring-up: a NET_BIND_FAIL (e.g. the autostart beat the network up
        // at boot — REQ-DAEMON-9) is transient and retried, but a broken
        // identity is not, so we only arm the retry when identity is sound.
        let node_hex: Option<String> = spt_store::nodeid::load_or_create()
            .ok()
            .map(|i| i.public_key().to_hex());
        let net = if node_hex.is_some() {
            try_start_net()
        } else {
            eprintln!("NODE_KEY_FAIL: identity unavailable — broker runs net-less, no retry");
            None
        };

        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
        // background thread; a bind failure there must not stop the seed channel
        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
        let net_up = net.is_some();
        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
            Ok(broker) => {
                let serve_broker = Arc::clone(&broker);
                thread::spawn(move || {
                    let _ = serve_broker.serve();
                });
                // Inbound net dispatch + outbound peer pump (D9-1): only
                // meaningful with a net host — a net-less broker accepts nothing
                // and dials nobody.
                let hex = node_hex.clone().unwrap_or_default();
                if net_up {
                    spawn_net_consumers(&hex);
                } else if node_hex.is_some() {
                    // Boot-race self-heal (REQ-DAEMON-9): net failed to bind but
                    // identity is sound — almost always the autostart racing the
                    // network up at boot. Retry net in the background; attach it
                    // to the broker + spawn the consumers when it succeeds, so
                    // the node becomes reachable WITHOUT a manual restart.
                    // [impl->REQ-DAEMON-9]
                    let broker = Arc::clone(&broker);
                    thread::spawn(move || net_retry_attach(broker, hex));
                }
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
        }

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

        // Shell wake-watchers (M5-D4b): the daemon hosts the offline half of
        // the shell online/offline exclusivity — a reconcile loop that runs
        // each offline instance's manifest `wake_command` as a supervised
        // watcher child (exit-opcode → wake resolution) and stops it when the
        // binary side wins. Boot sweep doubles as orphan cleanup after a
        // daemon crash. The daemon runs until killed, so the stop flag here
        // is held for symmetry with the dispatcher loops, never raised.
        // [impl->REQ-SHELL-2]
        {
            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
            let _ = crate::shellwake::spawn_wake_host(stop);
        }

        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
        // is the socket ping, never this file, so a stale value is harmless.
        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());

        // The seed-control channel is the foreground loop. Binding it is what
        // arbitrates the single-daemon-per-home invariant: a second daemon racing
        // to start fails this bind and exits, leaving the winner serving.
        seedmap::serve_seed_control(&seed_socket_name(), reg)
    }
}

/// Try to bring up the broker's network host once: build the production
/// `NetConfig` (durable node.key + relay/discovery/membership) and start the
/// endpoint, wiring on-connect roster propagation (REQ-MESH-2). `None` on a
/// `NET_BIND_FAIL` (e.g. the network/DNS stack not ready yet) or a missing
/// identity — the caller binds net-less and (REQ-DAEMON-9) retries.
// [impl->REQ-DAEMON-2]
// [impl->REQ-DAEMON-9]
fn try_start_net() -> Option<NetHost> {
    let cfg = DaemonConfig::load();
    let relay = match cfg.relay {
        RelayChoice::N0 => RelayPolicy::N0Default,
        RelayChoice::Disabled => RelayPolicy::Disabled,
    };
    let identity = spt_store::nodeid::load_or_create().ok()?;
    let net_cfg = NetConfig {
        identity,
        relay,
        discovery: LocalDiscovery::Mdns,
        scope: BindScope::All, // production: real interfaces
        // Mesh-D2: enforce seed-proof on every dial + accept, reading the live
        // subnet store each connect.
        membership: Some(crate::seedproofx::production_membership()),
    };
    match NetHost::start(net_cfg) {
        Ok(host) => {
            host.set_roster_exchange(crate::seedproofx::production_roster_exchange());
            Some(host)
        }
        Err(e) => {
            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");


(?i)broker.*(standalone|separate|own process|child process|spawn|binary|process)|standalone broker|broker process


CONTEXT.md:33:- **broker** (stable "kernel") — holds *only* the un-transferable, must-not-die resources: PTY master fds, the spawned harness child processes, and listening network sockets. Minimal, dumb, versioned local IPC. Almost never updates.
CONTEXT.md:36:Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is an internal layer, not separately addressable.
CONTEXT.md:83:[Omitted long matching line]
CONTEXT.md:85:[Omitted long matching line]
CONTEXT.md:121:- **Env injection is asymmetric** (file-bridge-only-when-not-launcher, applied to env): spt-hosted sessions inherit env from the broker that spawns them; harness-hosted sessions need the harness's declared env channel. With `spt` on PATH the env table is small.
CONTEXT.md:140:- **`api emit --type <sensory_type> <payload>`** — a broker-launched **Shell** binary pushes a sensory payload to its owner agent (owner known from `api bind`; REST-only, never spooled). See the Shell model.
CONTEXT.md:224:**Provided by a shell adapter.** There are two kinds of adapter, both manifested, both under `adapters/`: a **harness adapter** (`kind="harness"`, e.g. `claude-spt`) hosts *agent* endpoints; a **shell adapter** (`kind="shell"`, e.g. `GameRobot`) provides a *Shell* endpoint. `Shell` is the endpoint type; the shell adapter name is the provider, and its manifest defines the Shell's capability surface. A shell binary is **only ever launched by SPT** (the broker spawns it; never user-launched).
CONTEXT.md:233:**Command-delivery method** is the shell adapter's choice (same modes as agent inject-input): HTTP REST, stdin-via-broker, or child-relay (`api poll`). The binary is always broker-launched, so binding is direct (`api bind`, no seed-file).
CONTEXT.md:235:[Omitted long matching line]
CONTEXT.md:301:- **Broker-PTY-dependent → spt-hosted-primary.** Tapping the live byte stream needs the **broker-owned PTY** (topology 2). **Harness-hosted (topology 1) is capability-gated:** an adapter that can feed spt-core the stream (e.g. a `pi`-style harness) may opt in via a declared capability; harnesses that own their process tree opaquely (Claude Code) cannot, and the digest is simply unavailable there. Lands with M3 (spt-term broker).
CONTEXT.md:356:[Omitted long matching line]
CONTEXT.md:463:[Omitted long matching line]
CONTEXT.md:588:**What "installed" comprises:** the `spt` binary + the broker + the `$SPT_HOME` root (holds `node.key`, trust store, registry, spools, in-memory-seed fallback state). **First run is idempotent + interactive-optional:** generates the node identity and starts the daemon unattended; pairing (and subnet naming) is a separate explicit step.
docs\adr\0004-single-daemon-broker-brain-split-and-self-update.md:20:- **broker** (stable kernel) — holds *only* un-transferable, must-not-die resources: PTY master fds, spawned harness child processes, listening network sockets. Minimal, versioned local IPC. Almost never updates.
docs\adr\0004-single-daemon-broker-brain-split-and-self-update.md:23:Routine self-updates replace only the brain → endpoints never notice. The broker updates rarely (IPC-contract change, held-resource-type change, OS PTY/socket API change, or broker bugfix); the IPC is versioned so a newer brain talks to an older broker. Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is internal, not separately addressable.
docs\adr\0004-single-daemon-broker-brain-split-and-self-update.md:34:- A small internal broker process exists beneath the daemon — a deliberate, bounded walk-back of "literally one process," preserving B1's *intent* (one network identity, one supervisor, one firewall prompt) while guaranteeing endpoint survival across updates.
docs\adr\0004-single-daemon-broker-brain-split-and-self-update.md:47:- **broker-compatible** — broker binary swap behind a versioned IPC the running broker can hot-accept; endpoints survive; no spike yet.
docs\adr\0004-single-daemon-broker-brain-split-and-self-update.md:59:| mDNS socket / relay session | **broker** | ✅ closed by construction at M4-D4a (2026-06-03): `MdnsAddressLookup` and the relay session are constructed *inside* the broker-owned iroh endpoint — there is no separate socket to own |
M3c-PLAN.md:110:     brain ⇒ **BrokerCompatible** (broker binary hot-swaps behind the versioned
M3b-PLAN.md:1:# M3b Plan — `spt-daemon` broker/brain process
M3b-PLAN.md:127:## Tasks — `spt-daemon` (the broker/brain process)
M3b-PLAN.md:132:| B1 | **Broker kernel**: spawn a child under a `spt-term::PtySession`; hold the PTY master + child + a listening/client socket; serve them over IPC (subscribe-to-output, write-input, resize, lifecycle). **No logic in the broker.** | REQ-DAEMON-2 (broker half) | a broker hosts one real PTY child on both OSes, streams its output + accepts injected input over IPC; the ADR-0004 §B ownership table is the only state it holds (review-asserted) |
M3b-PLAN.md:133:[Omitted long matching line]
docs\adr\0008-live-activity-buffer-pty-digest.md:20:- **Broker-PTY-dependent → spt-hosted-primary.** Needs the broker-owned PTY (topology 2). **Harness-hosted (topology 1) is capability-gated:** an adapter that can feed spt-core the stream (e.g. a `pi`-style harness) opts in via a declared capability; harnesses that own their process tree opaquely (Claude Code) cannot, and the digest is simply unavailable there.
M3a-PLAN.md:11:> (the `spt-daemon` broker/brain process + IPC) and **M3c** (self-update) follow.
M3a-PLAN.md:68:- **The broker *process* + versioned local IPC** (M3b) — `spt-term` provides the PTY
M3-PLAN.md:98:**Out:** the broker *process* + IPC (that's M3b — `spt-term` provides the PTY
M3-PLAN.md:219:| B1 | **Broker** skeleton: host `spt-term` PTYs + spawned children + sockets; minimal **versioned** local IPC | REQ-DAEMON-2; REQ-HAZARD-HANDOFF-ARGV-COMPAT (2.3) | newer brain talks to older broker across an IPC version bump |
docs-site\src\shells\getting-started.md:65:# Broker-launched; the {link_token} is the binary's only credential.
docs-site\src\self-update\overview.md:9:daemon's broker (holding PTYs, child processes, sockets) stays up; the brain
docs\DEFERRED.md:27:[Omitted long matching line]
docs\FAULT-MATRIX.md:28:| 9 | **Broker process restart (journal survives, conns don't)** | Live conns/streams lost | Effect journal (durable) | Deduped op whose resource is gone → typed "retry with a fresh op_id" error; brain re-dials | `broker.rs::dispatch_net_dial` / `dispatch_net_stream_open` restart arms |
docs\MANIFEST.md:72:Vars to inject/read, and **how**. With `spt` on PATH the table is small. The injection *channel* is asymmetric: **spt-hosted** sessions inherit env from the broker that spawns them (no channel needed); **harness-hosted** sessions need the harness's declared channel (the broker didn't spawn them). File-bridge-only-when-not-launcher, applied to env.
docs\DORMANCY-BUDGET.md:79:Broker-side overhead: ~0.3 MiB per seat. Suspended residual: host process
docs\KNOWN-HAZARDS.md:5:[Omitted long matching line]
docs-site\src\harness-contract\manifest.md:136:broker that spawned them (no channel needed); **harness-hosted** sessions need
docs\spikes\SPIKE-01-broker-handoff.md:14:- **broker** owns the *sole* PTY master reader+writer, the spawned child, the listening sockets, and every accepted client socket. Forwards PTY output to all sinks; routes brain `INPUT:` control lines to the PTY writer it owns. Never restarts.
docs\spikes\SPIKE-01-broker-handoff.md:35:Secondary gotcha: a ConPTY master does **not** EOF while the broker holds the writer, so any read loop must drain on a thread and never gate exit on a blocking `read()` returning. (Cost a build cycle — three hung `spike.exe` instances locked the binary against relink.)
docs\spikes\SPIKE-03-quic-survival.md:9:ADR-0004 §B's ownership table left one row open: *Iroh endpoint + accepted QUIC streams → broker (❌ not spiked)*. Spike #1 proved a PTY child + a plain-TCP client survive a brain restart, but plain TCP is a weak proxy — QUIC carries process-local crypto + per-stream state. Codex called live-QUIC-across-restart the most likely redesign trigger. **Does the broker-owned-endpoint shape actually keep a live QUIC connection + an in-flight transfer intact across a brain restart?**
docs\spikes\SPIKE-04-forkpty-parity.md:10:Spike #1 proved the broker/brain no-terminate handoff on Windows ConPTY — the high-risk path. ADR-0004 §E.2 asked the obvious parity question: **does the same ownership model hold on Linux `forkpty`?** `portable-pty` selects `forkpty` automatically on Unix, so this is Spike #1's binary, unchanged, on a Linux host.

[Showing results with pagination = limit: 40]


before Q1: "binds the broker (served on a background thread)" -- couldn't this just mean that the loop which binds daemon to broker is a background thread, while the broker itself is a separate binary…


You're right and I was wrong on the spec. The evidence flips my framing — and reveals a **spec/impl drift**, not a design gap. Let me show you, then confirm the code.

**Spec says separate process** (you remembered correctly):
- `docs/adr/0004-...split-and-self-update.md:34`: *"A small internal broker **process** exists beneath the daemon — a deliberate, bounded walk-back of 'literally one process,' preserving B1's intent (one network identity, one supervisor, one firewall prompt) while guaranteeing endpoint survival across updates."*
- `M3b-PLAN.md:1` "spt-daemon broker/brain **process**"; `M3a/M3-PLAN` both say "the broker **process** + IPC (M3b)."
- Spikes 01/03/04 literally proved "PTY child + QUIC conn survive a **brain restart**" — which only works if the broker is a separate process.
- `self-update/overview.md:9`: "the broker … stays up; the brain [restarts]."

**But the current impl runs the broker as a background thread** (`daemon.rs:165-170`):
```rust
match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
    Ok(broker) => {
        let serve_broker = Arc::clone(&broker);
        thread::spawn(move || { let _ = serve_broker.serve(); });
```
`Arc<Broker>` held + served on a thread in the daemon process — not a spawned child.

So the brain/broker split is **specced + spiked but not realized in the production daemon's process topology.** That's why `apply` can't restart the brain: there's no separate broker to survive it. Let me confirm there's no separate-process broker spawn I'm missing, and read ADR-0004's intended topology in full:


1	# Single consolidated daemon with broker/brain split; peer-propagated gated self-update
2
3	## Status
4
5	accepted (2026-05-29)
6
7	## Context
8
9	ADR-0002 collapsed PTY-hosting and network-hosting into one per-machine `spt-daemon`. Two further forces refine its shape:
10
11	1. **Consolidation.** The sister project runs poll listeners as ephemeral per-session background tasks and Psyche wrappers as detached per-live-agent supervisor processes. But poll listeners already interact directly with the agent session (capsule/idle), and Psyche wrappers already invoke harness binaries directly. Once the daemon owns every PTY, keeping these as separate processes is unjustified.
12
13	2. **Seamless self-update with a hard no-terminate constraint.** Self-update is a day-one pillar. The constraint: *no endpoint process may terminate or suspend during an spt-core update* — we cannot assume every endpoint can safely suspend. The naive "drain + restart the daemon" approach violates this for spt-hosted sessions (the daemon owns their PTY; killing the daemon SIGHUPs the child).
14
15	## Decision
16
17	**Consolidate all per-machine logic into the one daemon.** Poll-listener logic and Psyche/pulse loops move into the `spt-daemon` — no separate listener or wrapper processes. The only residue is a thin, stateless **in-session relay** for harness-hosted sessions (topology 1), where spt cannot reach into a process tree it doesn't own; the relay just pipes the daemon's events into the session's stdout and is freely killable.
18
19	**Split the logical daemon into two implementation layers** to satisfy the no-terminate invariant:
20	- **broker** (stable kernel) — holds *only* un-transferable, must-not-die resources: PTY master fds, spawned harness child processes, listening network sockets. Minimal, versioned local IPC. Almost never updates.
21	- **daemon brain** (userspace) — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely; rehydrates from disk state and re-attaches to broker-held handles.
22
23	Routine self-updates replace only the brain → endpoints never notice. The broker updates rarely (IPC-contract change, held-resource-type change, OS PTY/socket API change, or broker bugfix); the IPC is versioned so a newer brain talks to an older broker. Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is internal, not separately addressable.
24
25	Rejected alternatives for the invariant: **drain + restart** (violates no-terminate for spt-hosted sessions); **whole-daemon live FD-passing** (ConPTY handle transfer mid-swap is hard and platform-divergent — pushing that complexity into the rare broker update instead of every routine update is strictly better); **topology-1-only invariant with spt-hosted as fast-follow** (silently breaks the stated hard constraint for the exact new topology spt-core introduces).
26
27	**Self-update delivery:** peer-propagated over P2P, layered on self-fetch, out-of-band still supported. All binaries signature-verified before handoff (spt-core's own release key) regardless of source — peer-propagation otherwise lets one compromised node poison the subnet. spt-core conducts updates for the whole stack: self first, then ripple-update each registered adapter via the adapter manifest's update declaration (file-pull or delegated command). The plugin's role shrinks to initial bootstrap only.
28
29	**Cadence/consent:** not fully automatic by default; gated on user confirmation delivered to the most-recently-active live session, with an opt-in full-auto choice.
30
31	## Consequences
32
33	- The daemon is the single brain for a machine; crash-recovery and update logic must cover PTYs, networking, registry, spools, listeners, and psyche loops together.
34	- A small internal broker process exists beneath the daemon — a deliberate, bounded walk-back of "literally one process," preserving B1's *intent* (one network identity, one supervisor, one firewall prompt) while guaranteeing endpoint survival across updates.
35	- Peer-propagated updates make release signing mandatory, not optional.
36	- spt-core becomes the update conductor for adapters too; adapter manifests must declare an update avenue.
37	- The "deliver to most-recently-active session" mechanism is a v1 building block that the deferred PresenceChannel will later generalize.
38	- Whole-daemon live FD-passing (zero-interruption even for broker updates) remains a possible future polish but is explicitly not required for v1.
39
40	## Validation & amendments (2026-05-31 — Stage A red-team + Spike #1)
41
42	Codex adversarial review (`docs/reviews/STAGE-A-codex-redteam.md`) put 4 FATAL findings on this ADR; Spike #1 (`docs/spikes/SPIKE-01-broker-handoff.md`, `spt-spikes/spike-01-broker-handoff`) tested the hardest path. Resulting binding amendments:
43
44	### A. Update-class taxonomy (resolves FATAL #1 — R-UPD-3 self-contradiction)
45	R-UPD-3's "no endpoint terminates during update" is **only** absolute for the *brain-only* class. Three classes, each with its own invariant:
46	- **brain-only** (the routine case) — endpoints MUST survive untouched. **Spike #1: PROVEN on Windows ConPTY.**
47	- **broker-compatible** — broker binary swap behind a versioned IPC the running broker can hot-accept; endpoints survive; no spike yet.
48	- **broker-breaking** — held-resource-type or OS-API change; requires a *planned endpoint-cycle*. R-UPD-3 is explicitly weakened here: this class MAY suspend endpoints, with consent + scheduling. PRD R-UPD-3 must be reworded to scope the absolute guarantee to brain-only.
49
50	### B. Ownership table (resolves FATAL #4 — "any live stream owned by brain breaks the invariant")
51	The boundary is normative, not "broker holds fds, brain holds logic." A resource is broker-owned **iff** a live consumer would lose continuity on brain restart.
52
53	| Resource | Owner | Spiked? |
54	|---|---|---|
55	| PTY master read+write handle | **broker** | ✅ #1 |
56	| Spawned harness child process | **broker** | ✅ #1 |
57	| Accepted local client socket (`api listen`, relay) | **broker** | ✅ #1 (plain TCP) |
58	| Iroh endpoint + accepted QUIC streams | **broker** | ✅ #3 (loopback shape) → **implemented M4-D4a** (`spt-daemon::nethost::NetHost`: dedicated tokio runtime in the broker owns the endpoint + conn table; brain drives status/dial over IPC net frames) |
59	| mDNS socket / relay session | **broker** | ✅ closed by construction at M4-D4a (2026-06-03): `MdnsAddressLookup` and the relay session are constructed *inside* the broker-owned iroh endpoint — there is no separate socket to own |
60	| Listening sockets | **broker** | ✅ #1 |
61	| Routing, registry state, pulse/psyche loops, manifest parse, update orchestration | brain (rehydrate from disk) | n/a |
62
63	Consequence codex forced into the open: pushing live Iroh/QUIC ownership into the broker means networking stream-state lives in the "stable kernel," enlarging it. Accepted as the cost of the invariant; the broker is "stable" in *update cadence*, not in *narrowness*.
64
65	### C. ConPTY DSR gotcha (new hazard, discovered by Spike #1)
66	ConPTY withholds **all** child stdout until the terminal answers its startup cursor-position query (`ESC [ 6 n`). A broker reading a ConPTY master that does not reply `ESC [ <r>;<c> R` sees only the 4-byte query and then nothing — looks like a hung/silent child. Spike got 0 bytes of program output until the reader answered DSR, then full output. → `REQ-HAZARD-CONPTY-DSR`; every ConPTY reader must auto-answer DSR. Added to KNOWN-HAZARDS §5.
67
68	### D. Self-update delivery hardening (resolves SERIOUS #5)
69	Signature-verify alone is insufficient. v1 adds: monotonic version field (rollback rejection), release-metadata expiry, channel pinning, key rotation/revocation list, and **adapter content signing** (adapter file-pull / delegated-command updates were uncovered by "binary signature"). Track as `REQ-HAZARD-UPDATE-ROLLBACK`.
70
71	### E. Open spike gaps (must close before M3 builds the daemon)
72	1. ✅ **CLOSED (Spike #3, 2026-06-01).** Live Iroh + file-transfer stream survival across brain restart (FATAL #2). Spike #1 only proved PTY + plain TCP; Spike #3 stood up a broker-owned Iroh endpoint + uni QUIC transfer and proved a live peer download survives a brain restart gapless + exactly-once. Loopback shape validated; off-node transport + the QUIC-ownership *implementation* defer to M4. (`docs/spikes/SPIKE-03-quic-survival.md`.)
73	2. ✅ **CLOSED (Spike #4, 2026-06-01).** Linux `forkpty` parity. Spike #1's binary, unchanged, passed all four invariants on `gravity-linux` (Ubuntu 22.04) — `portable-pty` selects `forkpty` on Unix; the ConPTY-DSR branch is inert there. Invariant B passed with the *strict* contiguity check that ConPTY-under-resize fails (Spike #5): `forkpty` is a raw pipe, ConPTY a screen buffer — different stream contracts, promoted into M3a's `SessionSurface` design. (`docs/spikes/SPIKE-04-forkpty-parity.md`.)
74	3. ✅ **CLOSED (Spike #5, 2026-06-01).** 100× restart + resize-under-load stress (codex #3). The broker survived 100 rapid brain kill/restart cycles + a resize thread racing the reader with no leak (one child, clean reap), no hang (watchdog deadline), no lost byte (full value-set coverage in the client's observation window). Surfaced two binding ConPTY findings for M3a/REQ-TERM-3: resize triggers a repaint that reorders+dups the stream (terminal-stream contract ≠ exactly-once transfer contract); mid-session attach yields the viewport only, not scrollback. (`docs/spikes/SPIKE-05-restart-stress.md`.)
75	4. ✅ **CLOSED (Spike #6, 2026-06-01).** Idempotent/exactly-once delivery across brain restart (FATAL-adjacent #14). Durable-ID + WAL + dedup-at-effect proven exactly-once across a crash at any protocol point (before-intent / before-effect / after-effect). Two binding design constraints surfaced for `REQ-HAZARD-RESTART-IDEMPOTENT` / M3b-B5: broker-owned recovery anchor; dedup-at-effect keyed by durable ID. (`docs/spikes/SPIKE-06-idempotent-boundary.md`.)
76
77	**Gate status (2026-06-01): ALL FOUR §E GAPS CLOSED PASS** (#3, #4, #5, #6) → the M3-PLAN Phase-0 spike-gate is **complete**. The broker/brain split is validated on both OSes (ConPTY + `forkpty`), across QUIC-stream survival, restart+resize churn, and exactly-once idempotency. M3a and M3b are both unblocked; the QUIC-ownership *implementation* and off-node transport remain explicitly deferred to M4 (only the *shape* was spiked).
78

# Single consolidated daemon with broker/brain split; peer-propagated gated self-update

## Status

accepted (2026-05-29)

## Context

ADR-0002 collapsed PTY-hosting and network-hosting into one per-machine `spt-daemon`. Two further forces refine its shape:

1. **Consolidation.** The sister project runs poll listeners as ephemeral per-session background tasks and Psyche wrappers as detached per-live-agent supervisor processes. But poll listeners already interact directly with the agent session (capsule/idle), and Psyche wrappers already invoke harness binaries directly. Once the daemon owns every PTY, keeping these as separate processes is unjustified.

2. **Seamless self-update with a hard no-terminate constraint.** Self-update is a day-one pillar. The constraint: *no endpoint process may terminate or suspend during an spt-core update* — we cannot assume every endpoint can safely suspend. The naive "drain + restart the daemon" approach violates this for spt-hosted sessions (the daemon owns their PTY; killing the daemon SIGHUPs the child).

## Decision

**Consolidate all per-machine logic into the one daemon.** Poll-listener logic and Psyche/pulse loops move into the `spt-daemon` — no separate listener or wrapper processes. The only residue is a thin, stateless **in-session relay** for harness-hosted sessions (topology 1), where spt cannot reach into a process tree it doesn't own; the relay just pipes the daemon's events into the session's stdout and is freely killable.

**Split the logical daemon into two implementation layers** to satisfy the no-terminate invariant:
- **broker** (stable kernel) — holds *only* un-transferable, must-not-die resources: PTY master fds, spawned harness child processes, listening network sockets. Minimal, versioned local IPC. Almost never updates.
- **daemon brain** (userspace) — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely; rehydrates from disk state and re-attaches to broker-held handles.

Routine self-updates replace only the brain → endpoints never notice. The broker updates rarely (IPC-contract change, held-resource-type change, OS PTY/socket API change, or broker bugfix); the IPC is versioned so a newer brain talks to an older broker. Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is internal, not separately addressable.

Rejected alternatives for the invariant: **drain + restart** (violates no-terminate for spt-hosted sessions); **whole-daemon live FD-passing** (ConPTY handle transfer mid-swap is hard and platform-divergent — pushing that complexity into the rare broker update instead of every routine update is strictly better); **topology-1-only invariant with spt-hosted as fast-follow** (silently breaks the stated hard constraint for the exact new topology spt-core introduces).

**Self-update delivery:** peer-propagated over P2P, layered on self-fetch, out-of-band still supported. All binaries signature-verified before handoff (spt-core's own release key) regardless of source — peer-propagation otherwise lets one compromised node poison the subnet. spt-core conducts updates for the whole stack: self first, then ripple-update each registered adapter via the adapter manifest's update declaration (file-pull or delegated command). The plugin's role shrinks to initial bootstrap only.

**Cadence/consent:** not fully automatic by default; gated on user confirmation delivered to the most-recently-active live session, with an opt-in full-auto choice.

## Consequences

- The daemon is the single brain for a machine; crash-recovery and update logic must cover PTYs, networking, registry, spools, listeners, and psyche loops together.
- A small internal broker process exists beneath the daemon — a deliberate, bounded walk-back of "literally one process," preserving B1's *intent* (one network identity, one supervisor, one firewall prompt) while guaranteeing endpoint survival across updates.
- Peer-propagated updates make release signing mandatory, not optional.
- spt-core becomes the update conductor for adapters too; adapter manifests must declare an update avenue.
- The "deliver to most-recently-active session" mechanism is a v1 building block that the deferred PresenceChannel will later generalize.
- Whole-daemon live FD-passing (zero-interruption even for broker updates) remains a possible future polish but is explicitly not required for v1.

## Validation & amendments (2026-05-31 — Stage A red-team + Spike #1)

Codex adversarial review (`docs/reviews/STAGE-A-codex-redteam.md`) put 4 FATAL findings on this ADR; Spike #1 (`docs/spikes/SPIKE-01-broker-handoff.md`, `spt-spikes/spike-01-broker-handoff`) tested the hardest path. Resulting binding amendments:

### A. Update-class taxonomy (resolves FATAL #1 — R-UPD-3 self-contradiction)
R-UPD-3's "no endpoint terminates during update" is **only** absolute for the *brain-only* class. Three classes, each with its own invariant:
- **brain-only** (the routine case) — endpoints MUST survive untouched. **Spike #1: PROVEN on Windows ConPTY.**
- **broker-compatible** — broker binary swap behind a versioned IPC the running broker can hot-accept; endpoints survive; no spike yet.
- **broker-breaking** — held-resource-type or OS-API change; requires a *planned endpoint-cycle*. R-UPD-3 is explicitly weakened here: this class MAY suspend endpoints, with consent + scheduling. PRD R-UPD-3 must be reworded to scope the absolute guarantee to brain-only.

### B. Ownership table (resolves FATAL #4 — "any live stream owned by brain breaks the invariant")
The boundary is normative, not "broker holds fds, brain holds logic." A resource is broker-owned **iff** a live consumer would lose continuity on brain restart.

| Resource | Owner | Spiked? |
|---|---|---|
| PTY master read+write handle | **broker** | ✅ #1 |
| Spawned harness child process | **broker** | ✅ #1 |
| Accepted local client socket (`api listen`, relay) | **broker** | ✅ #1 (plain TCP) |
| Iroh endpoint + accepted QUIC streams | **broker** | ✅ #3 (loopback shape) → **implemented M4-D4a** (`spt-daemon::nethost::NetHost`: dedicated tokio runtime in the broker owns the endpoint + conn table; brain drives status/dial over IPC net frames) |
| mDNS socket / relay session | **broker** | ✅ closed by construction at M4-D4a (2026-06-03): `MdnsAddressLookup` and the relay session are constructed *inside* the broker-owned iroh endpoint — there is no separate socket to own |
| Listening sockets | **broker** | ✅ #1 |
| Routing, registry state, pulse/psyche loops, manifest parse, update orchestration | brain (rehydrate from disk) | n/a |

Consequence codex forced into the open: pushing live Iroh/QUIC ownership into the broker means networking stream-state lives in the "stable kernel," enlarging it. Accepted as the cost of the invariant; the broker is "stable" in *update cadence*, not in *narrowness*.

### C. ConPTY DSR gotcha (new hazard, discovered by Spike #1)
ConPTY withholds **all** child stdout until the terminal answers its startup cursor-position query (`ESC [ 6 n`). A broker reading a ConPTY master that does not reply `ESC [ <r>;<c> R` sees only the 4-byte query and then nothing — looks like a hung/silent child. Spike got 0 bytes of program output until the reader answered DSR, then full output. → `REQ-HAZARD-CONPTY-DSR`; every ConPTY reader must auto-answer DSR. Added to KNOWN-HAZARDS §5.

### D. Self-update delivery hardening (resolves SERIOUS #5)
Signature-verify alone is insufficient. v1 adds: monotonic version field (rollback rejection), release-metadata expiry, channel pinning, key rotation/revocation list, and **adapter content signing** (adapter file-pull / delegated-command updates were uncovered by "binary signature"). Track as `REQ-HAZARD-UPDATE-ROLLBACK`.

### E. Open spike gaps (must close before M3 builds the daemon)
1. ✅ **CLOSED (Spike #3, 2026-06-01).** Live Iroh + file-transfer stream survival across brain restart (FATAL #2). Spike #1 only proved PTY + plain TCP; Spike #3 stood up a broker-owned Iroh endpoint + uni QUIC transfer and proved a live peer download survives a brain restart gapless + exactly-once. Loopback shape validated; off-node transport + the QUIC-ownership *implementation* defer to M4. (`docs/spikes/SPIKE-03-quic-survival.md`.)
2. ✅ **CLOSED (Spike #4, 2026-06-01).** Linux `forkpty` parity. Spike #1's binary, unchanged, passed all four invariants on `gravity-linux` (Ubuntu 22.04) — `portable-pty` selects `forkpty` on Unix; the ConPTY-DSR branch is inert there. Invariant B passed with the *strict* contiguity check that ConPTY-under-resize fails (Spike #5): `forkpty` is a raw pipe, ConPTY a screen buffer — different stream contracts, promoted into M3a's `SessionSurface` design. (`docs/spikes/SPIKE-04-forkpty-parity.md`.)
3. ✅ **CLOSED (Spike #5, 2026-06-01).** 100× restart + resize-under-load stress (codex #3). The broker survived 100 rapid brain kill/restart cycles + a resize thread racing the reader with no leak (one child, clean reap), no hang (watchdog deadline), no lost byte (full value-set coverage in the client's observation window). Surfaced two binding ConPTY findings for M3a/REQ-TERM-3: resize triggers a repaint that reorders+dups the stream (terminal-stream contract ≠ exactly-once transfer contract); mid-session attach yields the viewport only, not scrollback. (`docs/spikes/SPIKE-05-restart-stress.md`.)
4. ✅ **CLOSED (Spike #6, 2026-06-01).** Idempotent/exactly-once delivery across brain restart (FATAL-adjacent #14). Durable-ID + WAL + dedup-at-effect proven exactly-once across a crash at any protocol point (before-intent / before-effect / after-effect). Two binding design constraints surfaced for `REQ-HAZARD-RESTART-IDEMPOTENT` / M3b-B5: broker-owned recovery anchor; dedup-at-effect keyed by durable ID. (`docs/spikes/SPIKE-06-idempotent-boundary.md`.)

**Gate status (2026-06-01): ALL FOUR §E GAPS CLOSED PASS** (#3, #4, #5, #6) → the M3-PLAN Phase-0 spike-gate is **complete**. The broker/brain split is validated on both OSes (ConPTY + `forkpty`), across QUIC-stream survival, restart+resize churn, and exactly-once idempotency. M3a and M3b are both unblocked; the QUIC-ownership *implementation* and off-node transport remain explicitly deferred to M4 (only the *shape* was spiked).


(?i)Command::new.*broker|spawn.*broker|"broker"|daemon broker run|broker.*subcommand|fn bind_in_with_net|fn bind\b


crates\spt-msg\src\listener.rs:82:    pub fn bind(id: &str) -> Result<Self, String> {
crates\spt\tests\shell_e2e.rs:5://! spawn (real gate chain) → broker-launch of the real binary → its
crates\spt-daemon\tests\digest.rs:87:    let broker_name = unique("broker");
crates\spt-daemon\tests\daemon_e2e.rs:8://! 1. **spawn** — a [`Broker`] hosts a real PTY child; a [`Brain`] cold-starts,
crates\spt-daemon\tests\daemon_e2e.rs:134:    // ── 1. spawn: the broker hosts a real PTY child; a brain drives it ──────────
crates\spt-daemon\tests\daemon_e2e.rs:135:    let broker_name = unique("broker");
crates\spt-daemon\tests\broker.rs:8://! spawn under a broker PTY, stream output, accept input, report exit — through
crates\spt-daemon\tests\broker.rs:88:    // Spawn the echo child under a broker PTY; read until the `spawned` reply,
crates\spt-daemon\tests\budget.rs:16://! 1. spawn N idle sessions through the real broker (the production PTY
crates\spt-daemon\tests\budget.rs:20://! 3. also sample THIS process's RSS before/after spawning (the broker-side
crates\spt-daemon\tests\mesh.rs:151:        let _broker = net_broker(&name, &root.join("broker"));
crates\spt-daemon\src\broker.rs:181:    pub fn bind(name: &str) -> io::Result<Arc<Self>> {
crates\spt-daemon\src\broker.rs:196:    pub fn bind_in_with_net(
crates\spt-daemon\tests\dispatch.rs:96:fn spawn_dispatcher_for(broker_name: &str, scratch: &std::path::Path) -> Arc<AtomicBool> {
crates\spt-net\src\net\endpoint.rs:160:    pub async fn bind(
crates\spt-daemon\src\dispatch.rs:628:/// Spawn the dispatcher on its own thread against the canonical broker
crates\spt-daemon\src\brain.rs:216:    /// Spawn a session under a broker PTY; the broker auto-subscribes this
crates\spt-daemon\src\msg.rs:21:/// Brain→broker: spawn a child under a fresh broker-owned PTY.
crates\spt-daemon\src\msg.rs:106:/// `spawn` payload — the program + args + initial geometry. The broker spawns
crates\spt-daemon\src\msg.rs:128:/// `spawned` payload — the broker-minted session id + the child's OS pid.
crates\spt-daemon\src\daemon.rs:185:                    thread::spawn(move || net_retry_attach(broker, hex));
crates\spt-daemon\src\daemon.rs:196:        // sessions are spawned through the broker; a bare daemon serves the
crates\spt-daemon\src\daemon.rs:280:        let _ = crate::peerloop::spawn_peer_pump(broker_socket_name(), registry, cfg, stop);
crates\spt-daemon\src\relay.rs:39:    pub fn bind(id: &str, perch_path: &Path) -> std::io::Result<Self> {
crates\spt-daemon\src\transport.rs:45:    pub fn bind(name: &str) -> io::Result<Self> {
crates\spt-daemon\src\shellhost.rs:143:/// binary drains via `api poll --link`); **stdin** spawns broker-hosted so
crates\spt-daemon\src\shellhost.rs:204:/// The stdin-receipt launch: spawn the filled template as a **broker-hosted**


Confirmed. You were right, and this is **spec/impl drift** — a significant finding. Let me lay it out, then restart the grill from the corrected root.

## What the spec actually decided (you remembered correctly)

ADR-0004 is explicit and *accepted*:
- §Consequences:34 — *"A small internal broker **process** exists beneath the daemon."*
- §25 — the rejected alternatives include **"whole-daemon live FD-passing"** (too platform-divergent) and **"drain + restart"** (violates no-terminate). The chosen design is the broker/brain **process** split: routine update = restart brain, broker process survives.
- §B ownership table — PTY handles, child processes, listening sockets, **and the Iroh/QUIC endpoint** all broker-owned "iff a live consumer would lose continuity on brain restart."
- Spikes 01/03/04/05/06 all **PROVEN** with *two separate binaries* (`spt-spikes/spike-01-broker-handoff`): broker process never restarts, brain restarts 100× with no dropped PTY/QUIC/byte.

## What production actually does

`daemon.rs::run()` holds an in-process `Arc<Broker>` and serves it on a **thread** (`broker.rs:181 bind() -> Arc<Self>`; `:196 bind_in_with_net`). The pump (`:280`), dispatch, digest hub, net consumers, psyche loops — **all threads in the one `spt daemon` process.** I grepped: there is **no** `Command`/subcommand that spawns a broker child anywhere. Even the QUIC endpoint that §B-table says is "implemented M4-D4a … in the broker" is a tokio runtime on a thread *in the same process*.

So: **the two-process topology was specced and spiked, but the production daemon collapsed it back into one process.** The spike binaries proved a brain *restart*; production has no separate brain to restart. That's why:
- `apply` can't run new code (killing the process kills the broker → drops every endpoint),
- `applyhost`'s `handoff_retry` does an in-process `Brain::handoff` (achieves nothing for a binary swap),
- `update.rs:234`'s "the live daemon execs the new binary" is aspirational — the process split it relies on was never wired.

Your "silly" instinct was dead on; it's just upstream of `apply`.

---

**Q1 (reframed) — Is the in-process broker known-deferred scope, or unintended drift? And do we commit to realizing the two-process split as the fix?**

This determines whether we're closing a planned gap or correcting a regression — and it's the root every other decision hangs off.

**My recommendation: treat it as drift to close by realizing the split.** ADR-0004 already *decided* this and explicitly *rejected* the alternatives (FD-passing, drain+restart). The spikes already de-risked it on both OSes. The seamless-update property you want is *defined* as "brain restarts, broker survives" — there's no version of "handoff to the new executable with no dropped processes" that doesn't first put the broker in its own process. FD-passing (ADR-0004:38) was explicitly punted to "future polish for broker updates," not the routine path.

One thing I want your read on before I go deeper: do you recall the in-process collapse being a *deliberate* M3b shortcut ("ship it in-process, split later") — or should this be a fresh finding I write up? That changes whether the next step is an ADR amendment vs. a drift/hazard entry.

And want me to map the exact blast radius next — enumerate every thread `run()` spawns and bucket each into broker-process (survives) vs brain-process (restarts), so we can see the real cost of the split?


it's wholly unintended drift. this is NOT ok. the model should have never changed from the original spec. we're correcting a regression. no, i have no recollection of in-process collapse being deliberate. yes, map the blast radius