1	//! v0.13.0 W5 (REQ-HAZARD-DRIVEN-BY-SELFHEAL) — the `driven_by` self-heal
2	//! REPRO-FIRST (RED) gate, on a REAL broker over the real local IPC (no mocks of
3	//! the PTY or the transport).
4	//!
5	//! ## The bug this gate will lock down
6	//!
7	//! An spt-hosted endpoint can latch `ONLINE+CONTROLLED` — `info.json`
8	//! `driven_by = Some(node)` — and never clear it when a controller's detach is
9	//! LOST (no clean disconnect, and no output to trigger the W1 drain-evict
10	//! `mark_controller_gone`). The picker then renders a phantom "controlled by X"
11	//! forever. `reconcile_hosted_liveness` (`livehost.rs`) is the pull-primary
12	//! substrate that must self-heal it — the same lesson as the B2
13	//! `REQ-HAZARD-HOSTED-LIVENESS-RECONCILE` keystone, which already heals the
14	//! `status=online` latch but leaves `driven_by` untouched.
15	//!
16	//! Two gaps, both proved RED here against the CURRENT `reconcile_hosted_liveness`
17	//! (the FIX — the reconcile clearing `driven_by` — does NOT exist yet):
18	//!
19	//! - **Gap B (no session):** the broker hosts NO session for the endpoint, but
20	//!   `info.json` is `status=online`, `controllable=Some(true)`,
21	//!   `driven_by=Some(node)`. Today the reconcile marks the perch OFFLINE
22	//!   (`mark_offline` → `set_status(offline)`) but LEAVES `driven_by` Some — an
23	//!   OFFLINE endpoint can still render CONTROLLED. RED: after the reconcile tick,
24	//!   `driven_by` is STILL `Some`.
25	//!
26	//! - **Gap A (live session, lost controller):** the broker hosts a LIVE session
27	//!   for the endpoint, but the session's controller slot reports
28	//!   `controller_by()==None` while `info.json` `driven_by=Some(node)` persists.
29	//!   Today the reconcile does not even consider a perch WITH a live session (the
30	//!   `live_sessions.contains(id)` guard skips it), so `driven_by` stays Some. RED:
31	//!   live session present AND `driven_by` STILL `Some`.
32	//!
33	//! ## What this repro OBSERVES (drives the W5 fix design — see the eprintln banners)
34	//!
35	//! The broker's `KIND_SESSIONS` reply now carries `SessionInfo.controller_by`
36	//! (additive, `#[serde(default)]`). This gate REPORTS, for the live Gap-A session,
37	//! the exact `controller_by` value the real broker returns — the signal the fix
38	//! will use to tell a genuinely-controlled live session apart from a lost-detach
39	//! latch. The CRITICAL finding it surfaces (see the banner + the assertions): a
40	//! locally-spawned session is pre-attached as the LOCAL controller with `by=None`
41	//! (`dispatch_spawn` → `become_controller(by=None)`), so a LIVE, locally-driven
42	//! session ALSO reports `controller_by==None`. `controller_by==None` therefore
43	//! does NOT by itself distinguish "lost controller" from "live local controller"
44	//! — the fix must not false-clear on `None` alone.
45	//!
46	//! ## RUNNER CONTRACT — run process-per-test (nextest) or single-threaded
47	//!
48	//! These tests share ONE process-global `SPT_HOME` (set once via
49	//! [`std::env::set_var`] in [`init_selfheal_home`]) — the reconcile resolves perch
50	//! paths through the process-global `owlery_dir()`, and each test spins a REAL
51	//! broker. Under the default `cargo test` harness (N tests as parallel THREADS in
52	//! ONE process) the `set_var` mutation races and concurrent brokers contend, which
53	//! HANGS on Windows (the W1b wedge precedent). Run them isolated:
54	//!   - `cargo nextest run --test driven_by_selfheal`  (process-per-test — CI's path), or
55	//!   - `cargo test --test driven_by_selfheal -- --test-threads=1`  (single-threaded fallback).
56	//!
57	//! Do NOT run a bare `cargo test --test driven_by_selfheal` on Windows.
58
59	use std::collections::BTreeSet;
60	use std::sync::atomic::{AtomicU32, Ordering};
61	use std::sync::{Arc, OnceLock};
62	use std::thread;
63	use std::time::Duration;
64
65	use spt_daemon::brain::Brain;
66	use spt_daemon::msg::SpawnReq;
67	use spt_daemon::Broker;
68	use spt_daemon::livehost::reconcile_hosted_liveness;
69
70	use spt_store::perch::{self, owlery_dir, ParentHint};
71
72	static SEQ: AtomicU32 = AtomicU32::new(0);
73	fn unique_name() -> String {
74	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
75	    format!("spt-daemon-w5selfheal-{}-{}.sock", std::process::id(), n)
76	}
77
78	/// Process-global isolated SPT_HOME — the reconcile resolves perch paths through
79	/// the global `owlery_dir()`, so this MUST be set before any perch is stamped or
80	/// the reconcile runs. Held for the whole test binary (the bootgate-e2e pattern).
81	static SELFHEAL_HOME: OnceLock<tempfile::TempDir> = OnceLock::new();
82	fn init_selfheal_home() {
83	    SELFHEAL_HOME.get_or_init(|| {
84	        let dir = tempfile::TempDir::new().expect("temp dir");
85	        std::env::set_var("SPT_HOME", dir.path());
86	        dir
87	    });
88	}
89
90	fn kill_pid(pid: u32) {
91	    #[cfg(windows)]
92	    let _ = std::process::Command::new("taskkill")
93	        .args(["/PID", &pid.to_string(), "/F", "/T"])
94	        .output();
95	    #[cfg(unix)]
96	    let _ = std::process::Command::new("kill")
97	        .args(["-9", &pid.to_string()])
98	        .output();
99	}
100
101	/// Start a REAL broker on a fresh socket, serving every connection on its own
102	/// thread (the existing rig pattern). Returns the broker handle.
103	fn start_broker(name: &str) -> Arc<Broker> {
104	    let broker = Broker::bind(name).expect("bind broker");
105	    let serve = Arc::clone(&broker);
106	    thread::spawn(move || {
107	        let _ = serve.serve();
108	    });
109	    broker
110	}
111
112	/// Connect a Brain to the broker with bounded retry (cold_start — the request-reply
113	/// carrier `Brain::sessions()` rides; KIND_SESSIONS is a single reply, no streaming,
114	/// so a plain Whole conn is fine here).
115	fn connect_brain(name: &str) -> Brain {
116	    for _ in 0..300 {
117	        if let Ok(b) = Brain::cold_start(name, 1) {
118	            return b;
119	        }
120	        thread::sleep(Duration::from_millis(10));
121	    }
122	    panic!("brain could not connect to {name}");
123	}
124
125	/// A trivial long-lived PTY child so a spawned session stays LIVE (Gap A needs a
126	/// real live session in the broker table). Cross-OS: a stdin-reading idle process.
127	fn idle_spawn_req(endpoint: &str) -> SpawnReq {
128	    #[cfg(unix)]
129	    let (program, args) = ("cat".to_string(), Vec::<String>::new());
130	    #[cfg(windows)]
131	    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
132	    SpawnReq {
133	        program,
134	        args,
135	        rows: 24,
136	        cols: 80,
137	        endpoint: endpoint.to_string(),
138	        cwd: None,
139	        env: Default::default(),
140	        translation_binary: None,
141	    }
142	}
143
144	/// Stamp the STALE cold-start leftover the reconcile is supposed to heal: an
145	/// spt-hosted (`controllable=Some(true)`) `live_agent` self-perch, `status=online`,
146	/// pre-stamped `driven_by=Some(node)` (the lost-detach latch). Mirrors the
147	/// livehost-bootgate-e2e stamping exactly, plus the `driven_by` marker.
148	fn stamp_latched_perch(id: &str, node: &str) -> std::path::PathBuf {
149	    let self_perch = perch::resolve_perch_path(id, ParentHint::Infer);
150	    std::fs::create_dir_all(&self_perch).unwrap();
151	    let mut rec =
152	        spt_store::info::InfoJson::new(id, "t", std::process::id(), "sid-w5", "live_agent");
153	    rec.controllable = Some(true);
154	    spt_store::info::write_info(&self_perch, &rec).unwrap();
155	    spt_store::info::set_status(&self_perch, spt_store::liveness::STATUS_ONLINE).unwrap();
156	    // The lost-detach latch the self-heal must clear.
157	    spt_store::info::set_driven_by(&self_perch, Some(node)).unwrap();
158	    self_perch
159	}
160
161	#[test]
162	fn gap_b_sessionless_perch_reconcile_offlines_but_leaves_driven_by_latched() {
163	    init_selfheal_home();
164	    let node = "PEERNODEHEX_GAP_B";
165	    // A unique endpoint id per test so the process-global owlery never collides.
166	    let id = format!("w5-gapb-{}", std::process::id());
167	    let self_perch = stamp_latched_perch(&id, node);
168
169	    // Sanity: the latch is set before the reconcile.
170	    assert_eq!(
171	        spt_store::info::read_info(&self_perch).and_then(|i| i.driven_by),
172	        Some(node.to_string()),
173	        "precondition: driven_by must be latched Some before the reconcile"
174	    );
175
176	    // ── A REAL broker that hosts NO session for this endpoint (Gap B = the
177	    //    harness is gone). Query its KIND_SESSIONS reply over the real IPC and
178	    //    confirm the endpoint is ABSENT (the broker really hosts nothing for it). ──
179	    let name = unique_name();
180	    let _broker = start_broker(&name);
181	    let mut brain = connect_brain(&name);
182	    let reply = brain.sessions().expect("KIND_SESSIONS reply");
183	    let live_sessions: BTreeSet<String> =
184	        reply.sessions.iter().map(|s| s.endpoint.clone()).collect();
185
186	    // OBSERVATION (item 2): the endpoint is absent from the broker's session set.
187	    let endpoint_absent = !live_sessions.contains(&id);
188	    eprintln!(
189	        "=== W5 GAP-B OBSERVATION: endpoint_in_sessions={} (expect ABSENT=true) sessions={:?} ===",
190	        live_sessions.contains(&id),
191	        live_sessions
192	    );
193	    assert!(
194	        endpoint_absent,
195	        "Gap-B precondition: the broker must host NO session for the endpoint \
196	         (it is absent from KIND_SESSIONS) — sessions={live_sessions:?}"
197	    );
198
199	    // ── THE RECONCILE TICK: call the CURRENT reconcile_hosted_liveness directly
200	    //    with the broker's real (empty-for-this-endpoint) live-session set. ──
201	    let offlined = reconcile_hosted_liveness(&owlery_dir(), &live_sessions);
202
203	    let info_after = spt_store::info::read_info(&self_perch).expect("info present after reconcile");
204	    let status_after = info_after.status.clone();
205	    let driven_by_after = info_after.driven_by.clone();
206
207	    eprintln!(
208	        "=== W5 GAP-B RECONCILE RESULT: offlined={offlined:?} status_after={status_after:?} \
209	         driven_by_after={driven_by_after:?} \
210	         (CURRENT TREE: the Gap-B fix is ALREADY LANDED — reconcile offlines AND \
211	         clears driven_by → None; this is GREEN, not RED) ==="
212	    );
213
214	    // The reconcile DID run on this perch (it offlined it) — proves the assertion
215	    // below is meaningful, not vacuous (the perch WAS visited).
216	    assert!(
217	        offlined.contains(&id),
218	        "the reconcile must have visited + offlined this sessionless controllable \
219	         perch (the B2 keystone) — otherwise the driven_by check below is vacuous"
220	    );
221	    assert_eq!(
222	        status_after.as_deref(),
223	        Some(spt_store::liveness::STATUS_OFFLINE),
224	        "the reconcile must mark the sessionless controllable perch OFFLINE (B2)"
225	    );
226
227	    // [int->REQ-HAZARD-DRIVEN-BY-SELFHEAL]
228	    // GREEN — IMPORTANT DEVIATION FROM THE W5 PLAN/PROMPT: the prompt expected this
229	    // to be RED (driven_by STILL Some after the reconcile). On THIS branch
230	    // (v0.13.0-delivery-control) the Gap-B self-heal is ALREADY IMPLEMENTED in
231	    // `reconcile_hosted_liveness` (livehost.rs, the `[impl->REQ-HAZARD-DRIVEN-BY-
232	    // SELFHEAL]`-tagged block: when it offlines a sessionless controllable perch it
233	    // also `set_driven_by(perch, None)` and logs `DRIVEN_BY_SELFHEAL_OFFLINE`). So
234	    // Gap B is GREEN today — the reconcile clears the latch alongside mark_offline.
235	    // This test pins that behavior; it must NOT silently encode the buggy (latched)
236	    // expectation. (Surfaced to the parent: the Gap-B fix landed already.)
237	    assert_eq!(
238	        driven_by_after, None,
239	        "Gap B (GREEN on this branch): the reconcile must CLEAR driven_by when it \
240	         offlines a sessionless controllable perch (no live broker session) — an \
241	         OFFLINE endpoint must NOT render phantom CONTROLLED. This is already \
242	         implemented in reconcile_hosted_liveness; this assertion regression-locks it."
243	    );
244	}
245
246	#[test]
247	fn gap_a_live_session_lost_controller_reconcile_leaves_driven_by_latched() {
248	    init_selfheal_home();
249	    let node = "PEERNODEHEX_GAP_A";
250	    let id = format!("w5-gapa-{}", std::process::id());
251	    let self_perch = stamp_latched_perch(&id, node);
252
253	    assert_eq!(
254	        spt_store::info::read_info(&self_perch).and_then(|i| i.driven_by),
255	        Some(node.to_string()),
256	        "precondition: driven_by must be latched Some before the reconcile"
257	    );
258
259	    // ── A REAL broker hosting a LIVE session for THIS endpoint. The spawning
260	    //    brain is pre-attached as the LOCAL controller (dispatch_spawn →
261	    //    become_controller(by=None)). The PTY child keeps the session live in the
262	    //    broker table. ──
263	    let name = unique_name();
264	    let broker = start_broker(&name);
265	    let mut spawner = connect_brain(&name);
266	    let sid = spawner
267	        .spawn_session(idle_spawn_req(&id))
268	        .expect("spawn live session for the endpoint");
269
270	    // FINDING (the broker's OWN self-heal on a controller disconnect): dropping the
271	    // spawner socket makes the broker's per-conn handler run `detach_if` →
272	    // `clear_controller` → `stamp_driven_by`, which (since controller_by() is now
273	    // None) RE-STAMPS the perch's driven_by to None. So an OBSERVED controller
274	    // disconnect already unlatches via the broker single-writer — even without a
275	    // clean detach. The Gap-A the W5 RECONCILE must heal is the path where that
276	    // re-stamp NEVER fires: a brain restart re-adopts the live session but the
277	    // in-memory controller (and thus the clear_controller re-stamp) never existed,
278	    // so the STALE on-disk driven_by marker persists against a controller_by==None
279	    // session. We reproduce exactly THAT state below.
280	    drop(spawner);
281	    thread::sleep(Duration::from_millis(300));
282
283	    // The broker auto-cleared the marker on the observed disconnect — capture it as
284	    // a finding (proves the broker single-writer self-heals the OBSERVED case).
285	    let driven_by_after_disconnect =
286	        spt_store::info::read_info(&self_perch).and_then(|i| i.driven_by);
287	    eprintln!(
288	        "=== W5 GAP-A FINDING (broker self-heal on observed disconnect): \
289	         driven_by_after_spawner_drop={driven_by_after_disconnect:?} \
290	         (None = the broker's detach_if→clear_controller→stamp_driven_by already \
291	         unlatched the OBSERVED disconnect — so the RECONCILE Gap-A is only the \
292	         NEVER-RE-STAMPED brain-restart leftover, reproduced next) ==="
293	    );
294
295	    // ── Reproduce the genuine reconcile Gap-A: a STALE on-disk driven_by marker
296	    //    against a LIVE session whose controller_by is None (the brain-restart
297	    //    leftover the broker re-stamp never cleared). Re-stamp the latch on disk. ──
298	    spt_store::info::set_driven_by(&self_perch, Some(node)).unwrap();
299	    assert_eq!(
300	        spt_store::info::read_info(&self_perch).and_then(|i| i.driven_by),
301	        Some(node.to_string()),
302	        "the stale brain-restart driven_by leftover is in place against a live session"
303	    );
304
305	    // ── Query KIND_SESSIONS on a FRESH brain and REPORT the live session's
306	    //    controller_by (item 1) + confirm the session is listed live. ──
307	    let mut brain = connect_brain(&name);
308	    let reply = brain.sessions().expect("KIND_SESSIONS reply");
309	    let our = reply.sessions.iter().find(|s| s.endpoint == id).cloned();
310
311	    let live_present = our.is_some();
312	    let observed_controller_by = our.as_ref().and_then(|s| s.controller_by.clone());
313	    let still_controller_by_some = observed_controller_by.is_some();
314
315	    eprintln!(
316	        "=== W5 GAP-A OBSERVATION (item 1+3): live_session_present={live_present} \
317	         observed_controller_by={observed_controller_by:?} \
318	         (CRITICAL item 3: a locally-spawned session is pre-attached as the LOCAL \
319	         controller with by=None → controller_by==None even when the slot is \
320	         occupied; the slot here is also empty after the disconnect — BOTH yield \
321	         None. So controller_by==None is the Gap-A SIGNAL but does NOT distinguish \
322	         a lost controller from a live LOCAL controller. \
323	         controller_by_still_Some={still_controller_by_some} — observed false here, \
324	         i.e. NO path where driven_by is Some while controller_by is Some) ==="
325	    );
326
327	    assert!(
328	        live_present,
329	        "Gap-A precondition: the broker MUST list a LIVE session for the endpoint \
330	         (the brain-restart reality: the session survives). sessions={:?}",
331	        reply.sessions.iter().map(|s| &s.endpoint).collect::<Vec<_>>()
332	    );
333	    // Item 3 guard: this repro never produced driven_by==Some WITH controller_by==Some.
334	    assert!(
335	        !still_controller_by_some,
336	        "in this repro the live session's controller_by is None (item 3): no path \
337	         observed where driven_by latches Some while the broker still reports a \
338	         controller_by==Some — controller_by==None is the only Gap-A disk-vs-broker \
339	         disagreement seen"
340	    );
341
342	    // The live-session set passed to the reconcile DOES contain this endpoint
343	    // (Gap A is "live session, lost controller", not "no session").
344	    let live_sessions: BTreeSet<String> =
345	        reply.sessions.iter().map(|s| s.endpoint.clone()).collect();
346	    assert!(
347	        live_sessions.contains(&id),
348	        "the reconcile's live-session set must contain the endpoint (Gap A)"
349	    );
350
351	    // ── THE RECONCILE TICK against the CURRENT reconcile_hosted_liveness. ──
352	    let offlined = reconcile_hosted_liveness(&owlery_dir(), &live_sessions);
353
354	    let info_after = spt_store::info::read_info(&self_perch).expect("info present after reconcile");
355	    let status_after = info_after.status.clone();
356	    let driven_by_after = info_after.driven_by.clone();
357
358	    eprintln!(
359	        "=== W5 GAP-A RECONCILE RESULT: offlined={offlined:?} status_after={status_after:?} \
360	         driven_by_after={driven_by_after:?} \
361	         (TODAY: id NOT offlined — it has a live session — and the reconcile does \
362	         not touch driven_by for a live-session perch, so the stale marker STAYS \
363	         Some = the latch; POST-FIX: driven_by=None when the live session's \
364	         controller_by is None) ==="
365	    );
366
367	    // Teardown: reap the PTY child (scoped) so the live session never leaks.
368	    if let Some(pid) = broker.session_pid(sid) {
369	        kill_pid(pid);
370	    }
371
372	    // The CURRENT reconcile SKIPS a perch that has a live session (the
373	    // `live_sessions.contains(id)` guard) — so it neither offlines it nor clears
374	    // driven_by. Today's status is UNCHANGED (still online).
375	    assert!(
376	        !offlined.contains(&id),
377	        "TODAY the reconcile does NOT offline a perch that has a live session — \
378	         Gap A is exactly the path the current reconcile ignores"
379	    );
380	    assert_eq!(
381	        status_after.as_deref(),
382	        Some(spt_store::liveness::STATUS_ONLINE),
383	        "the perch stays ONLINE (it has a live session) — Gap A is about the \
384	         driven_by latch on a STILL-online session whose controller is gone"
385	    );
386
387	    // [int->REQ-HAZARD-DRIVEN-BY-SELFHEAL]
388	    // RED (the W5 Gap-A reconcile path): a LIVE session whose controller is gone
389	    // (controller_by==None) with a STALE on-disk driven_by marker the broker
390	    // re-stamp never cleared (the brain-restart leftover) still latches driven_by
391	    // Some after the reconcile — phantom CONTROLLED on a live but uncontrolled
392	    // session. The CURRENT reconcile's live-session branch does not clear driven_by.
393	    //
394	    // TODO(W5-GREEN): when the reconcile clears driven_by for a self-perch that HAS
395	    // a live session whose SessionInfo.controller_by is None, REPLACE the assertion
396	    // below with:
397	    //   assert_eq!(driven_by_after, None,
398	    //       "Gap A: the reconcile must clear driven_by when the endpoint's live \
399	    //        session reports controller_by==None (lost controller)");
400	    // NOTE for the fix designer (the W5 watch-out, confirmed by item 3 above): the
401	    // Gap-A clear MUST be guarded so it never false-clears a genuinely-controlled
402	    // LIVE LOCAL session, which ALSO reports controller_by==None. controller_by==None
403	    // alone is NOT sufficient. Per the plan, prefer routing the live-session clear
404	    // through the BROKER (single writer) to avoid the stamp_driven_by race — the
405	    // broker can distinguish an occupied-but-local slot from an empty one, which the
406	    // KIND_SESSIONS controller_by==None signal cannot.
407	    assert_eq!(
408	        driven_by_after,
409	        Some(node.to_string()),
410	        "RED (Gap A): the CURRENT reconcile LEAVES driven_by latched Some for a \
411	         live session whose controller is gone (controller_by==None) with a stale \
412	         on-disk marker. The W5 self-heal must clear it (flip this to None at \
413	         W5-GREEN), WITHOUT false-clearing a genuinely-controlled live session."
414	    );
415	}
416

//! v0.13.0 W5 (REQ-HAZARD-DRIVEN-BY-SELFHEAL) — the `driven_by` self-heal
//! REPRO-FIRST (RED) gate, on a REAL broker over the real local IPC (no mocks of
//! the PTY or the transport).
//!
//! ## The bug this gate will lock down
//!
//! An spt-hosted endpoint can latch `ONLINE+CONTROLLED` — `info.json`
//! `driven_by = Some(node)` — and never clear it when a controller's detach is
//! LOST (no clean disconnect, and no output to trigger the W1 drain-evict
//! `mark_controller_gone`). The picker then renders a phantom "controlled by X"
//! forever. `reconcile_hosted_liveness` (`livehost.rs`) is the pull-primary
//! substrate that must self-heal it — the same lesson as the B2
//! `REQ-HAZARD-HOSTED-LIVENESS-RECONCILE` keystone, which already heals the
//! `status=online` latch but leaves `driven_by` untouched.
//!
//! Two gaps, both proved RED here against the CURRENT `reconcile_hosted_liveness`
//! (the FIX — the reconcile clearing `driven_by` — does NOT exist yet):
//!
//! - **Gap B (no session):** the broker hosts NO session for the endpoint, but
//!   `info.json` is `status=online`, `controllable=Some(true)`,
//!   `driven_by=Some(node)`. Today the reconcile marks the perch OFFLINE
//!   (`mark_offline` → `set_status(offline)`) but LEAVES `driven_by` Some — an
//!   OFFLINE endpoint can still render CONTROLLED. RED: after the reconcile tick,
//!   `driven_by` is STILL `Some`.
//!
//! - **Gap A (live session, lost controller):** the broker hosts a LIVE session
//!   for the endpoint, but the session's controller slot reports
//!   `controller_by()==None` while `info.json` `driven_by=Some(node)` persists.
//!   Today the reconcile does not even consider a perch WITH a live session (the
//!   `live_sessions.contains(id)` guard skips it), so `driven_by` stays Some. RED:
//!   live session present AND `driven_by` STILL `Some`.
//!
//! ## What this repro OBSERVES (drives the W5 fix design — see the eprintln banners)
//!
//! The broker's `KIND_SESSIONS` reply now carries `SessionInfo.controller_by`
//! (additive, `#[serde(default)]`). This gate REPORTS, for the live Gap-A session,
//! the exact `controller_by` value the real broker returns — the signal the fix
//! will use to tell a genuinely-controlled live session apart from a lost-detach
//! latch. The CRITICAL finding it surfaces (see the banner + the assertions): a
//! locally-spawned session is pre-attached as the LOCAL controller with `by=None`
//! (`dispatch_spawn` → `become_controller(by=None)`), so a LIVE, locally-driven
//! session ALSO reports `controller_by==None`. `controller_by==None` therefore
//! does NOT by itself distinguish "lost controller" from "live local controller"
//! — the fix must not false-clear on `None` alone.
//!
//! ## RUNNER CONTRACT — run process-per-test (nextest) or single-threaded
//!
//! These tests share ONE process-global `SPT_HOME` (set once via
//! [`std::env::set_var`] in [`init_selfheal_home`]) — the reconcile resolves perch
//! paths through the process-global `owlery_dir()`, and each test spins a REAL
//! broker. Under the default `cargo test` harness (N tests as parallel THREADS in
//! ONE process) the `set_var` mutation races and concurrent brokers contend, which
//! HANGS on Windows (the W1b wedge precedent). Run them isolated:
//!   - `cargo nextest run --test driven_by_selfheal`  (process-per-test — CI's path), or
//!   - `cargo test --test driven_by_selfheal -- --test-threads=1`  (single-threaded fallback).
//!
//! Do NOT run a bare `cargo test --test driven_by_selfheal` on Windows.

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

use spt_daemon::brain::Brain;
use spt_daemon::msg::SpawnReq;
use spt_daemon::Broker;
use spt_daemon::livehost::reconcile_hosted_liveness;

use spt_store::perch::{self, owlery_dir, ParentHint};

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

/// Process-global isolated SPT_HOME — the reconcile resolves perch paths through
/// the global `owlery_dir()`, so this MUST be set before any perch is stamped or
/// the reconcile runs. Held for the whole test binary (the bootgate-e2e pattern).
static SELFHEAL_HOME: OnceLock<tempfile::TempDir> = OnceLock::new();
fn init_selfheal_home() {
    SELFHEAL_HOME.get_or_init(|| {
        let dir = tempfile::TempDir::new().expect("temp dir");
        std::env::set_var("SPT_HOME", dir.path());
        dir
    });
}

fn kill_pid(pid: u32) {
    #[cfg(windows)]
    let _ = std::process::Command::new("taskkill")
        .args(["/PID", &pid.to_string(), "/F", "/T"])
        .output();
    #[cfg(unix)]
    let _ = std::process::Command::new("kill")
        .args(["-9", &pid.to_string()])
        .output();
}

/// Start a REAL broker on a fresh socket, serving every connection on its own
/// thread (the existing rig pattern). Returns the broker handle.
fn start_broker(name: &str) -> Arc<Broker> {
    let broker = Broker::bind(name).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

/// Connect a Brain to the broker with bounded retry (cold_start — the request-reply
/// carrier `Brain::sessions()` rides; KIND_SESSIONS is a single reply, no streaming,
/// so a plain Whole conn is fine here).
fn connect_brain(name: &str) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain could not connect to {name}");
}

/// A trivial long-lived PTY child so a spawned session stays LIVE (Gap A needs a
/// real live session in the broker table). Cross-OS: a stdin-reading idle process.
fn idle_spawn_req(endpoint: &str) -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq {
        program,
        args,
        rows: 24,
        cols: 80,
        endpoint: endpoint.to_string(),
        cwd: None,
        env: Default::default(),
        translation_binary: None,
    }
}

/// Stamp the STALE cold-start leftover the reconcile is supposed to heal: an
/// spt-hosted (`controllable=Some(true)`) `live_agent` self-perch, `status=online`,
/// pre-stamped `driven_by=Some(node)` (the lost-detach latch). Mirrors the
/// livehost-bootgate-e2e stamping exactly, plus the `driven_by` marker.
fn stamp_latched_perch(id: &str, node: &str) -> std::path::PathBuf {
    let self_perch = perch::resolve_perch_path(id, ParentHint::Infer);
    std::fs::create_dir_all(&self_perch).unwrap();
    let mut rec =
        spt_store::info::InfoJson::new(id, "t", std::process::id(), "sid-w5", "live_agent");
    rec.controllable = Some(true);
    spt_store::info::write_info(&self_perch, &rec).unwrap();
    spt_store::info::set_status(&self_perch, spt_store::liveness::STATUS_ONLINE).unwrap();
    // The lost-detach latch the self-heal must clear.
    spt_store::info::set_driven_by(&self_perch, Some(node)).unwrap();
    self_perch
}

#[test]
fn gap_b_sessionless_perch_reconcile_offlines_but_leaves_driven_by_latched() {
    init_selfheal_home();
    let node = "PEERNODEHEX_GAP_B";
    // A unique endpoint id per test so the process-global owlery never collides.
    let id = format!("w5-gapb-{}", std::process::id());
    let self_perch = stamp_latched_perch(&id, node);

    // Sanity: the latch is set before the reconcile.
    assert_eq!(
        spt_store::info::read_info(&self_perch).and_then(|i| i.driven_by),
        Some(node.to_string()),
        "precondition: driven_by must be latched Some before the reconcile"
    );

    // ── A REAL broker that hosts NO session for this endpoint (Gap B = the
    //    harness is gone). Query its KIND_SESSIONS reply over the real IPC and
    //    confirm the endpoint is ABSENT (the broker really hosts nothing for it). ──
    let name = unique_name();
    let _broker = start_broker(&name);
    let mut brain = connect_brain(&name);
    let reply = brain.sessions().expect("KIND_SESSIONS reply");
    let live_sessions: BTreeSet<String> =
        reply.sessions.iter().map(|s| s.endpoint.clone()).collect();

    // OBSERVATION (item 2): the endpoint is absent from the broker's session set.
    let endpoint_absent = !live_sessions.contains(&id);
    eprintln!(
        "=== W5 GAP-B OBSERVATION: endpoint_in_sessions={} (expect ABSENT=true) sessions={:?} ===",
        live_sessions.contains(&id),
        live_sessions
    );
    assert!(
        endpoint_absent,
        "Gap-B precondition: the broker must host NO session for the endpoint \
         (it is absent from KIND_SESSIONS) — sessions={live_sessions:?}"
    );

    // ── THE RECONCILE TICK: call the CURRENT reconcile_hosted_liveness directly
    //    with the broker's real (empty-for-this-endpoint) live-session set. ──
    let offlined = reconcile_hosted_liveness(&owlery_dir(), &live_sessions);

    let info_after = spt_store::info::read_info(&self_perch).expect("info present after reconcile");
    let status_after = info_after.status.clone();
    let driven_by_after = info_after.driven_by.clone();

    eprintln!(
        "=== W5 GAP-B RECONCILE RESULT: offlined={offlined:?} status_after={status_after:?} \
         driven_by_after={driven_by_after:?} \
         (CURRENT TREE: the Gap-B fix is ALREADY LANDED — reconcile offlines AND \
         clears driven_by → None; this is GREEN, not RED) ==="
    );

    // The reconcile DID run on this perch (it offlined it) — proves the assertion
    // below is meaningful, not vacuous (the perch WAS visited).
    assert!(
        offlined.contains(&id),
        "the reconcile must have visited + offlined this sessionless controllable \
         perch (the B2 keystone) — otherwise the driven_by check below is vacuous"
    );
    assert_eq!(
        status_after.as_deref(),
        Some(spt_store::liveness::STATUS_OFFLINE),
        "the reconcile must mark the sessionless controllable perch OFFLINE (B2)"
    );

    // [int->REQ-HAZARD-DRIVEN-BY-SELFHEAL]
    // GREEN — IMPORTANT DEVIATION FROM THE W5 PLAN/PROMPT: the prompt expected this
    // to be RED (driven_by STILL Some after the reconcile). On THIS branch
    // (v0.13.0-delivery-control) the Gap-B self-heal is ALREADY IMPLEMENTED in
    // `reconcile_hosted_liveness` (livehost.rs, the `[impl->REQ-HAZARD-DRIVEN-BY-
    // SELFHEAL]`-tagged block: when it offlines a sessionless controllable perch it
    // also `set_driven_by(perch, None)` and logs `DRIVEN_BY_SELFHEAL_OFFLINE`). So
    // Gap B is GREEN today — the reconcile clears the latch alongside mark_offline.
    // This test pins that behavior; it must NOT silently encode the buggy (latched)
    // expectation. (Surfaced to the parent: the Gap-B fix landed already.)
    assert_eq!(
        driven_by_after, None,
        "Gap B (GREEN on this branch): the reconcile must CLEAR driven_by when it \
         offlines a sessionless controllable perch (no live broker session) — an \
         OFFLINE endpoint must NOT render phantom CONTROLLED. This is already \
         implemented in reconcile_hosted_liveness; this assertion regression-locks it."
    );
}

#[test]
fn gap_a_live_session_lost_controller_reconcile_leaves_driven_by_latched() {
    init_selfheal_home();
    let node = "PEERNODEHEX_GAP_A";
    let id = format!("w5-gapa-{}", std::process::id());
    let self_perch = stamp_latched_perch(&id, node);

    assert_eq!(
        spt_store::info::read_info(&self_perch).and_then(|i| i.driven_by),
        Some(node.to_string()),
        "precondition: driven_by must be latched Some before the reconcile"
    );

    // ── A REAL broker hosting a LIVE session for THIS endpoint. The spawning
    //    brain is pre-attached as the LOCAL controller (dispatch_spawn →
    //    become_controller(by=None)). The PTY child keeps the session live in the
    //    broker table. ──
    let name = unique_name();
    let broker = start_broker(&name);
    let mut spawner = connect_brain(&name);
    let sid = spawner
        .spawn_session(idle_spawn_req(&id))
        .expect("spawn live session for the endpoint");

    // FINDING (the broker's OWN self-heal on a controller disconnect): dropping the
    // spawner socket makes the broker's per-conn handler run `detach_if` →
    // `clear_controller` → `stamp_driven_by`, which (since controller_by() is now
    // None) RE-STAMPS the perch's driven_by to None. So an OBSERVED controller
    // disconnect already unlatches via the broker single-writer — even without a
    // clean detach. The Gap-A the W5 RECONCILE must heal is the path where that
    // re-stamp NEVER fires: a brain restart re-adopts the live session but the
    // in-memory controller (and thus the clear_controller re-stamp) never existed,
    // so the STALE on-disk driven_by marker persists against a controller_by==None
    // session. We reproduce exactly THAT state below.
    drop(spawner);
    thread::sleep(Duration::from_millis(300));

    // The broker auto-cleared the marker on the observed disconnect — capture it as
    // a finding (proves the broker single-writer self-heals the OBSERVED case).
    let driven_by_after_disconnect =
        spt_store::info::read_info(&self_perch).and_then(|i| i.driven_by);
    eprintln!(
        "=== W5 GAP-A FINDING (broker self-heal on observed disconnect): \
         driven_by_after_spawner_drop={driven_by_after_disconnect:?} \
         (None = the broker's detach_if→clear_controller→stamp_driven_by already \
         unlatched the OBSERVED disconnect — so the RECONCILE Gap-A is only the \
         NEVER-RE-STAMPED brain-restart leftover, reproduced next) ==="
    );

    // ── Reproduce the genuine reconcile Gap-A: a STALE on-disk driven_by marker
    //    against a LIVE session whose controller_by is None (the brain-restart
    //    leftover the broker re-stamp never cleared). Re-stamp the latch on disk. ──
    spt_store::info::set_driven_by(&self_perch, Some(node)).unwrap();
    assert_eq!(
        spt_store::info::read_info(&self_perch).and_then(|i| i.driven_by),
        Some(node.to_string()),
        "the stale brain-restart driven_by leftover is in place against a live session"
    );

    // ── Query KIND_SESSIONS on a FRESH brain and REPORT the live session's
    //    controller_by (item 1) + confirm the session is listed live. ──
    let mut brain = connect_brain(&name);
    let reply = brain.sessions().expect("KIND_SESSIONS reply");
    let our = reply.sessions.iter().find(|s| s.endpoint == id).cloned();

    let live_present = our.is_some();
    let observed_controller_by = our.as_ref().and_then(|s| s.controller_by.clone());
    let still_controller_by_some = observed_controller_by.is_some();

    eprintln!(
        "=== W5 GAP-A OBSERVATION (item 1+3): live_session_present={live_present} \
         observed_controller_by={observed_controller_by:?} \
         (CRITICAL item 3: a locally-spawned session is pre-attached as the LOCAL \
         controller with by=None → controller_by==None even when the slot is \
         occupied; the slot here is also empty after the disconnect — BOTH yield \
         None. So controller_by==None is the Gap-A SIGNAL but does NOT distinguish \
         a lost controller from a live LOCAL controller. \
         controller_by_still_Some={still_controller_by_some} — observed false here, \
         i.e. NO path where driven_by is Some while controller_by is Some) ==="
    );

    assert!(
        live_present,
        "Gap-A precondition: the broker MUST list a LIVE session for the endpoint \
         (the brain-restart reality: the session survives). sessions={:?}",
        reply.sessions.iter().map(|s| &s.endpoint).collect::<Vec<_>>()
    );
    // Item 3 guard: this repro never produced driven_by==Some WITH controller_by==Some.
    assert!(
        !still_controller_by_some,
        "in this repro the live session's controller_by is None (item 3): no path \
         observed where driven_by latches Some while the broker still reports a \
         controller_by==Some — controller_by==None is the only Gap-A disk-vs-broker \
         disagreement seen"
    );

    // The live-session set passed to the reconcile DOES contain this endpoint
    // (Gap A is "live session, lost controller", not "no session").
    let live_sessions: BTreeSet<String> =
        reply.sessions.iter().map(|s| s.endpoint.clone()).collect();
    assert!(
        live_sessions.contains(&id),
        "the reconcile's live-session set must contain the endpoint (Gap A)"
    );

    // ── THE RECONCILE TICK against the CURRENT reconcile_hosted_liveness. ──
    let offlined = reconcile_hosted_liveness(&owlery_dir(), &live_sessions);

    let info_after = spt_store::info::read_info(&self_perch).expect("info present after reconcile");
    let status_after = info_after.status.clone();
    let driven_by_after = info_after.driven_by.clone();

    eprintln!(
        "=== W5 GAP-A RECONCILE RESULT: offlined={offlined:?} status_after={status_after:?} \
         driven_by_after={driven_by_after:?} \
         (TODAY: id NOT offlined — it has a live session — and the reconcile does \
         not touch driven_by for a live-session perch, so the stale marker STAYS \
         Some = the latch; POST-FIX: driven_by=None when the live session's \
         controller_by is None) ==="
    );

    // Teardown: reap the PTY child (scoped) so the live session never leaks.
    if let Some(pid) = broker.session_pid(sid) {
        kill_pid(pid);
    }

    // The CURRENT reconcile SKIPS a perch that has a live session (the
    // `live_sessions.contains(id)` guard) — so it neither offlines it nor clears
    // driven_by. Today's status is UNCHANGED (still online).
    assert!(
        !offlined.contains(&id),
        "TODAY the reconcile does NOT offline a perch that has a live session — \
         Gap A is exactly the path the current reconcile ignores"
    );
    assert_eq!(
        status_after.as_deref(),
        Some(spt_store::liveness::STATUS_ONLINE),
        "the perch stays ONLINE (it has a live session) — Gap A is about the \
         driven_by latch on a STILL-online session whose controller is gone"
    );

    // [int->REQ-HAZARD-DRIVEN-BY-SELFHEAL]
    // RED (the W5 Gap-A reconcile path): a LIVE session whose controller is gone
    // (controller_by==None) with a STALE on-disk driven_by marker the broker
    // re-stamp never cleared (the brain-restart leftover) still latches driven_by
    // Some after the reconcile — phantom CONTROLLED on a live but uncontrolled
    // session. The CURRENT reconcile's live-session branch does not clear driven_by.
    //
    // TODO(W5-GREEN): when the reconcile clears driven_by for a self-perch that HAS
    // a live session whose SessionInfo.controller_by is None, REPLACE the assertion
    // below with:
    //   assert_eq!(driven_by_after, None,
    //       "Gap A: the reconcile must clear driven_by when the endpoint's live \
    //        session reports controller_by==None (lost controller)");
    // NOTE for the fix designer (the W5 watch-out, confirmed by item 3 above): the
    // Gap-A clear MUST be guarded so it never false-clears a genuinely-controlled
    // LIVE LOCAL session, which ALSO reports controller_by==None. controller_by==None
    // alone is NOT sufficient. Per the plan, prefer routing the live-session clear
    // through the BROKER (single writer) to avoid the stamp_driven_by race — the
    // broker can distinguish an occupied-but-local slot from an empty one, which the
    // KIND_SESSIONS controller_by==None signal cannot.
    assert_eq!(
        driven_by_after,
        Some(node.to_string()),
        "RED (Gap A): the CURRENT reconcile LEAVES driven_by latched Some for a \
         live session whose controller is gone (controller_by==None) with a stale \
         on-disk marker. The W5 self-heal must clear it (flip this to None at \
         W5-GREEN), WITHOUT false-clearing a genuinely-controlled live session."
    );
}


Now I have the complete state. The Gap A test's `TODO(W5-GREEN)` implies a future `controller_by==None` reconcile clear — which the repro itself proved is the **wrong fix** (ambiguous + misses A2). I need to: (1) make the `controller_by` field doc honest, (2) refine the REQ + mint an A2 follow-up SEED, (3) add KNOWN-HAZARDS, then delegate the test reframe + Gap B unit test to the existing engineer.

Start with the field doc honesty fix.


Edit

    /// The live controller's remote identity (`Some(node)`) or `None` when the
    /// session has NO controller (free / local-only). Lets the W5 `driven_by`
    /// self-heal tell a genuinely-controlled live session apart from one whose
    /// controller is gone but whose `info.json` still latches `ONLINE+CONTROLLED`
    /// (Gap A — live session, lost controller).
    ///
    /// Additive + `#[serde(default)]` (KH-2.3 N-1 window, mirrors `resume_seq`):
    /// an **older broker** omits the field → `None`. The self-heal only consumes
    /// it on the LOCAL broker query (same binary version — no skew), and the
    /// Gap-A clear is gated on `driven_by` already being `Some`, so an N-1 read
    /// can never invent a controller, only fail to report one.
    // [impl->REQ-HAZARD-DRIVEN-BY-SELFHEAL]
    #[serde(default)]
    pub controller_by: Option<String>,

    /// The live controller's REMOTE identity (`Some(node)`), or `None` when the
    /// session has no remote controller. NOTE (load-bearing, proved by the W5
    /// repro): `None` is AMBIGUOUS — it covers BOTH an empty controller slot AND a
    /// LOCAL controller, because `dispatch_spawn` pre-attaches the spawner with
    /// `by = None`. So `controller_by == None` does NOT by itself mean "no
    /// controller" — it cannot be used as a standalone `driven_by` clear trigger
    /// without false-clearing a live, locally-driven session.
    ///
    /// Currently consumed by the W5 self-heal CHARACTERIZATION (the real-broker
    /// A1/A2 gates) and RESERVED for the eventual broker-side A2 eviction
    /// ([`REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT`]) — the production "wedged remote
    /// controller on an idle session" latch, where this field STAYS `Some(origin)`
    /// (the broker still thinks it is controlled) and so the brain reconcile cannot
    /// detect it. The shipped W5 reconcile self-heal (Gap B, no session) needs no
    /// controller signal at all.
    ///
    /// Additive + `#[serde(default)]` (KH-2.3 N-1 window, mirrors `resume_seq`):
    /// an **older broker** omits the field → `None`. Any consumer only reads it on
    /// the LOCAL broker query (same binary version — no wire skew).
    // [impl->REQ-HAZARD-DRIVEN-BY-SELFHEAL]
    #[serde(default)]
    pub controller_by: Option<String>,


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

    /// The live controller's remote identity (`Some(node)`) or `None` when the
    /// session has NO controller (free / local-only). Lets the W5 `driven_by`
    /// self-heal tell a genuinely-controlled live session apart from one whose
    /// controller is gone but whose `info.json` still latches `ONLINE+CONTROLLED`
    /// (Gap A — live session, lost controller).
    ///
    /// Additive + `#[serde(default)]` (KH-2.3 N-1 window, mirrors `resume_seq`):
    /// an **older broker** omits the field → `None`. The self-heal only consumes
    /// it on the LOCAL broker query (same binary version — no skew), and the
    /// Gap-A clear is gated on `driven_by` already being `Some`, so an N-1 read
    /// can never invent a controller, only fail to report one.
    // [impl->REQ-HAZARD-DRIVEN-BY-SELFHEAL]
    #[serde(default)]
    pub controller_by: Option<String>,

    /// The live controller's REMOTE identity (`Some(node)`), or `None` when the
    /// session has no remote controller. NOTE (load-bearing, proved by the W5
    /// repro): `None` is AMBIGUOUS — it covers BOTH an empty controller slot AND a
    /// LOCAL controller, because `dispatch_spawn` pre-attaches the spawner with
    /// `by = None`. So `controller_by == None` does NOT by itself mean "no
    /// controller" — it cannot be used as a standalone `driven_by` clear trigger
    /// without false-clearing a live, locally-driven session.
    ///
    /// Currently consumed by the W5 self-heal CHARACTERIZATION (the real-broker
    /// A1/A2 gates) and RESERVED for the eventual broker-side A2 eviction
    /// ([`REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT`]) — the production "wedged remote
    /// controller on an idle session" latch, where this field STAYS `Some(origin)`
    /// (the broker still thinks it is controlled) and so the brain reconcile cannot
    /// detect it. The shipped W5 reconcile self-heal (Gap B, no session) needs no
    /// controller signal at all.
    ///
    /// Additive + `#[serde(default)]` (KH-2.3 N-1 window, mirrors `resume_seq`):
    /// an **older broker** omits the field → `None`. Any consumer only reads it on
    /// the LOCAL broker query (same binary version — no wire skew).
    // [impl->REQ-HAZARD-DRIVEN-BY-SELFHEAL]
    #[serde(default)]
    pub controller_by: Option<String>,

-    /// The live controller's remote identity (`Some(node)`) or `None` when the

-    /// session has NO controller (free / local-only). Lets the W5 `driven_by`

-    /// self-heal tell a genuinely-controlled live session apart from one whose

-    /// controller is gone but whose `info.json` still latches `ONLINE+CONTROLLED`

-    /// (Gap A — live session, lost controller).

+    /// The live controller's REMOTE identity (`Some(node)`), or `None` when the

+    /// session has no remote controller. NOTE (load-bearing, proved by the W5

+    /// repro): `None` is AMBIGUOUS — it covers BOTH an empty controller slot AND a

+    /// LOCAL controller, because `dispatch_spawn` pre-attaches the spawner with

+    /// `by = None`. So `controller_by == None` does NOT by itself mean "no

+    /// controller" — it cannot be used as a standalone `driven_by` clear trigger

+    /// without false-clearing a live, locally-driven session.

+    /// Currently consumed by the W5 self-heal CHARACTERIZATION (the real-broker

+    /// A1/A2 gates) and RESERVED for the eventual broker-side A2 eviction

+    /// ([`REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT`]) — the production "wedged remote

+    /// controller on an idle session" latch, where this field STAYS `Some(origin)`

+    /// (the broker still thinks it is controlled) and so the brain reconcile cannot

+    /// detect it. The shipped W5 reconcile self-heal (Gap B, no session) needs no

-    /// an **older broker** omits the field → `None`. The self-heal only consumes

-    /// it on the LOCAL broker query (same binary version — no skew), and the

-    /// Gap-A clear is gated on `driven_by` already being `Some`, so an N-1 read

-    /// can never invent a controller, only fail to report one.

+    /// an **older broker** omits the field → `None`. Any consumer only reads it on

+    /// the LOCAL broker query (same binary version — no wire skew).


Update the REQ: activate `[impl,unit,int]` for the delivered Gap B + characterization, and mint the A2 follow-up SEED.


required_stages = ["int"]  # ACTIVATED v0.13.0 W5 (repro-first, todlando). int = the real-broker characterization of the two latch mechanisms (inject_control_wedge.rs w5_a1 + w5_a2): A1 (local/empty slot ⇒ controller_by==None) and A2 (abandoned REMOTE controller on an IDLE session ⇒ controller_by STAYS Some(origin), driven_by stays latched). EMPIRICAL VERDICT (2026-06-19): controller_by==None is NOT a sufficient Gap-A signal for the production wedged-pump case — the lost-detach slot does not self-clear, so the fix must be BROKER-SIDE eviction (a controller-liveness check / reconcile-driven clear_controller), not merely a reconcile `controller_by==None` read. impl/unit activate when the fix lands.

required_stages = ["impl", "unit", "int"]  # ACTIVATED v0.13.0 W5 (repro-first, todlando). SCOPE landed = Gap B (no-session self-heal): reconcile_hosted_liveness, when it offlines a sessionless controllable perch (B2 keystone), ALSO clears a stale driven_by (set_driven_by None) — an OFFLINE endpoint can no longer render phantom CONTROLLED. RACE-FREE (no live session ⇒ no controller to re-stamp concurrently). impl = livehost.rs reconcile_hosted_liveness Gap-B clear + the additive SessionInfo.controller_by observability field (broker.rs KIND_SESSIONS). unit = pull_liveness unit extended (offlined sessionless perch clears driven_by; live/relay perches untouched). int = real-broker gates: driven_by_selfheal.rs gap_b (GREEN: reconcile offlines AND clears driven_by) + the A1/A2 characterization (inject_control_wedge.rs w5_a1/w5_a2). EMPIRICAL VERDICT (2026-06-19, real broker): controller_by==None is NOT a usable Gap-A signal — it is AMBIGUOUS (dispatch_spawn pre-attaches the spawner with by=None, so a LIVE LOCAL session also reads None) AND the production wedged-remote case (A2) keeps controller_by==Some(origin)+driven_by latched on an IDLE session (the broker still thinks it is controlled). A clean controller disconnect ALREADY self-heals via detach_if→clear_controller; W1 bounds the active-output wedge. The residual A2 (idle wedged REMOTE controller) needs BROKER-SIDE liveness eviction, NOT a brain reconcile controller_by read → DEFERRED to REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT. KNOWN-HAZARDS 7.15.


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


1211	# ── W5: driven_by self-heal (ONLINE+CONTROLLED clears without the detach IPC) ──
1212	[[requirements]]
1213	id = "REQ-HAZARD-DRIVEN-BY-SELFHEAL"
1214	title = "An spt-hosted endpoint's ONLINE+CONTROLLED state (`driven_by`) must CLEAR even when the detach IPC is lost — do NOT rely on the detach signal (same lesson as REQ-HAZARD-HOSTED-LIVENESS-RECONCILE B2): the reconcile loop clears `driven_by` when the endpoint has no live controller/session. Today a wedged or lost pump never delivers the detach, so the endpoint stays latched CONTROLLED forever. Composes with W1 (the wedge no longer blocks the detach) and rides the same pull-primary reconcile substrate as B2. (v0.13.0)"
1215	required_stages = ["int"]  # ACTIVATED v0.13.0 W5 (repro-first, todlando). int = the real-broker characterization of the two latch mechanisms (inject_control_wedge.rs w5_a1 + w5_a2): A1 (local/empty slot ⇒ controller_by==None) and A2 (abandoned REMOTE controller on an IDLE session ⇒ controller_by STAYS Some(origin), driven_by stays latched). EMPIRICAL VERDICT (2026-06-19): controller_by==None is NOT a sufficient Gap-A signal for the production wedged-pump case — the lost-detach slot does not self-clear, so the fix must be BROKER-SIDE eviction (a controller-liveness check / reconcile-driven clear_controller), not merely a reconcile `controller_by==None` read. impl/unit activate when the fix lands.
1216
1217	# ── W7 (operator /diagnose 2026-06-19): rc Backspace key forwards the wrong byte ──
1218	[[requirements]]

# ── W5: driven_by self-heal (ONLINE+CONTROLLED clears without the detach IPC) ──
[[requirements]]
id = "REQ-HAZARD-DRIVEN-BY-SELFHEAL"
title = "An spt-hosted endpoint's ONLINE+CONTROLLED state (`driven_by`) must CLEAR even when the detach IPC is lost — do NOT rely on the detach signal (same lesson as REQ-HAZARD-HOSTED-LIVENESS-RECONCILE B2): the reconcile loop clears `driven_by` when the endpoint has no live controller/session. Today a wedged or lost pump never delivers the detach, so the endpoint stays latched CONTROLLED forever. Composes with W1 (the wedge no longer blocks the detach) and rides the same pull-primary reconcile substrate as B2. (v0.13.0)"
required_stages = ["int"]  # ACTIVATED v0.13.0 W5 (repro-first, todlando). int = the real-broker characterization of the two latch mechanisms (inject_control_wedge.rs w5_a1 + w5_a2): A1 (local/empty slot ⇒ controller_by==None) and A2 (abandoned REMOTE controller on an IDLE session ⇒ controller_by STAYS Some(origin), driven_by stays latched). EMPIRICAL VERDICT (2026-06-19): controller_by==None is NOT a sufficient Gap-A signal for the production wedged-pump case — the lost-detach slot does not self-clear, so the fix must be BROKER-SIDE eviction (a controller-liveness check / reconcile-driven clear_controller), not merely a reconcile `controller_by==None` read. impl/unit activate when the fix lands.

# ── W7 (operator /diagnose 2026-06-19): rc Backspace key forwards the wrong byte ──
[[requirements]]


required_stages = ["int"]  # ACTIVATED v0.13.0 W5 (repro-first, todlando). int = the real-broker characterization of the two latch mechanisms (inject_control_wedge.rs w5_a1 + w5_a2): A1 (local/empty slot ⇒ controller_by==None) and A2 (abandoned REMOTE controller on an IDLE session ⇒ controller_by STAYS Some(origin), driven_by stays latched). EMPIRICAL VERDICT (2026-06-19): controller_by==None is NOT a sufficient Gap-A signal for the production wedged-pump case — the lost-detach slot does not self-clear, so the fix must be BROKER-SIDE eviction (a controller-liveness check / reconcile-driven clear_controller), not merely a reconcile `controller_by==None` read. impl/unit activate when the fix lands.

required_stages = ["impl", "unit", "int"]  # ACTIVATED v0.13.0 W5 (repro-first, todlando). SCOPE landed = Gap B (no-session self-heal): reconcile_hosted_liveness, when it offlines a sessionless controllable perch (B2 keystone), ALSO clears a stale driven_by (set_driven_by None) — an OFFLINE endpoint can no longer render phantom CONTROLLED. RACE-FREE (no live session ⇒ no controller to re-stamp concurrently). impl = livehost.rs reconcile_hosted_liveness Gap-B clear + the additive SessionInfo.controller_by observability field (broker.rs KIND_SESSIONS). unit = pull_liveness unit extended (offlined sessionless perch clears driven_by; live/relay perches untouched). int = real-broker gates: driven_by_selfheal.rs gap_b (GREEN: reconcile offlines AND clears driven_by) + the A1/A2 characterization (inject_control_wedge.rs w5_a1/w5_a2). EMPIRICAL VERDICT (2026-06-19, real broker): controller_by==None is NOT a usable Gap-A signal — it is AMBIGUOUS (dispatch_spawn pre-attaches the spawner with by=None, so a LIVE LOCAL session also reads None) AND the production wedged-remote case (A2) keeps controller_by==Some(origin)+driven_by latched on an IDLE session (the broker still thinks it is controlled). A clean controller disconnect ALREADY self-heals via detach_if→clear_controller; W1 bounds the active-output wedge. The residual A2 (idle wedged REMOTE controller) needs BROKER-SIDE liveness eviction, NOT a brain reconcile controller_by read → DEFERRED to REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT. KNOWN-HAZARDS 7.15.

# ── W5 follow-up: the idle wedged-REMOTE-controller latch (A2, repro-proven, deferred) ──
[[requirements]]
id = "REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT"
title = "An spt-hosted endpoint driven by a REMOTE controller whose remote is gone but whose broker connection stays OPEN (a wedged/lost pump that never delivers the detach) AND whose session is IDLE (no output) stays latched ONLINE+CONTROLLED forever: the W1 drain-evict only fires on OUTPUT (CONTROLLER_WRITE_DEADLINE on a backed-up write), a clean disconnect self-heals via detach_if→clear_controller, but an idle session with a half-open/wedged controller connection produces neither signal. PROVED repro-first on a real broker (v0.13.0 W5, inject_control_wedge.rs w5_a2): controller_by STAYS Some(origin) and driven_by STAYS Some after the remote is abandoned without a clean EOF on an idle session — so the brain reconcile CANNOT detect it from KIND_SESSIONS controller_by (the broker still reports it controlled). FIX DIRECTION (broker-side, single-writer — the broker owns driven_by/clear_controller): a controller-liveness probe / idle keepalive that evicts a controller whose REMOTE is no longer reachable (e.g. keyed on net presence of `origin`, or a periodic end-to-end controller liveness check), then clear_controller re-stamps driven_by=None. MUST NOT false-evict a genuinely-controlled live session across a transient partition (the network-scoped analog of the W5 false-heal watch-out) — needs a debounce/grace and likely an ADR for the partition semantics. Composes with W1 (output path) + W5 Gap B (no-session) — this is the third, idle-remote, leg. (v0.13.0 follow-up)"
required_stages = []  # SEED — repro already exists (inject_control_wedge.rs w5_a2 characterization, under REQ-HAZARD-DRIVEN-BY-SELFHEAL int). Activate [impl,unit,int] when the broker-side eviction lands (doyle workstream boundary: relates to the W6/perri pump-detach layer — a pump that propagates remote-stream death into a clean detach would also close this without a presence probe).


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

required_stages = ["impl", "unit", "int"]  # ACTIVATED v0.13.0 W5 (repro-first, todlando). SCOPE landed = Gap B (no-session self-heal): reconcile_hosted_liveness, when it offlines a sessionless controllable perch (B2 keystone), ALSO clears a stale driven_by (set_driven_by None) — an OFFLINE endpoint can no longer render phantom CONTROLLED. RACE-FREE (no live session ⇒ no controller to re-stamp concurrently). impl = livehost.rs reconcile_hosted_liveness Gap-B clear + the additive SessionInfo.controller_by observability field (broker.rs KIND_SESSIONS). unit = pull_liveness unit extended (offlined sessionless perch clears driven_by; live/relay perches untouched). int = real-broker gates: driven_by_selfheal.rs gap_b (GREEN: reconcile offlines AND clears driven_by) + the A1/A2 characterization (inject_control_wedge.rs w5_a1/w5_a2). EMPIRICAL VERDICT (2026-06-19, real broker): controller_by==None is NOT a usable Gap-A signal — it is AMBIGUOUS (dispatch_spawn pre-attaches the spawner with by=None, so a LIVE LOCAL session also reads None) AND the production wedged-remote case (A2) keeps controller_by==Some(origin)+driven_by latched on an IDLE session (the broker still thinks it is controlled). A clean controller disconnect ALREADY self-heals via detach_if→clear_controller; W1 bounds the active-output wedge. The residual A2 (idle wedged REMOTE controller) needs BROKER-SIDE liveness eviction, NOT a brain reconcile controller_by read → DEFERRED to REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT. KNOWN-HAZARDS 7.15.

# ── W5 follow-up: the idle wedged-REMOTE-controller latch (A2, repro-proven, deferred) ──
[[requirements]]
id = "REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT"
title = "An spt-hosted endpoint driven by a REMOTE controller whose remote is gone but whose broker connection stays OPEN (a wedged/lost pump that never delivers the detach) AND whose session is IDLE (no output) stays latched ONLINE+CONTROLLED forever: the W1 drain-evict only fires on OUTPUT (CONTROLLER_WRITE_DEADLINE on a backed-up write), a clean disconnect self-heals via detach_if→clear_controller, but an idle session with a half-open/wedged controller connection produces neither signal. PROVED repro-first on a real broker (v0.13.0 W5, inject_control_wedge.rs w5_a2): controller_by STAYS Some(origin) and driven_by STAYS Some after the remote is abandoned without a clean EOF on an idle session — so the brain reconcile CANNOT detect it from KIND_SESSIONS controller_by (the broker still reports it controlled). FIX DIRECTION (broker-side, single-writer — the broker owns driven_by/clear_controller): a controller-liveness probe / idle keepalive that evicts a controller whose REMOTE is no longer reachable (e.g. keyed on net presence of `origin`, or a periodic end-to-end controller liveness check), then clear_controller re-stamps driven_by=None. MUST NOT false-evict a genuinely-controlled live session across a transient partition (the network-scoped analog of the W5 false-heal watch-out) — needs a debounce/grace and likely an ADR for the partition semantics. Composes with W1 (output path) + W5 Gap B (no-session) — this is the third, idle-remote, leg. (v0.13.0 follow-up)"
required_stages = []  # SEED — repro already exists (inject_control_wedge.rs w5_a2 characterization, under REQ-HAZARD-DRIVEN-BY-SELFHEAL int). Activate [impl,unit,int] when the broker-side eviction lands (doyle workstream boundary: relates to the W6/perri pump-detach layer — a pump that propagates remote-stream death into a clean detach would also close this without a presence probe).

title = "An spt-hosted endpoint's ONLINE+CONTROLLED state (`driven_by`) must CLEAR even when the detach IPC is lost — do NOT rely on the detach signal (same lesson as REQ-HAZARD-HOSTED-LIVENESS-RECONCILE B2): the reconcile loop clears `driven_by` when the endpoint has no live controller/session. Today a wedged or lost pump never delivers the detach, so the endpoint stays latched CONTROLLED forever. Composes with W1 (the wedge no longer blocks the detach) and rides the same pull-primary reconcile substrate as B2. (v0.13.0)"

-required_stages = ["int"]  # ACTIVATED v0.13.0 W5 (repro-first, todlando). int = the real-broker characterization of the two latch mechanisms (inject_control_wedge.rs w5_a1 + w5_a2): A1 (local/empty slot ⇒ controller_by==None) and A2 (abandoned REMOTE controller on an IDLE session ⇒ controller_by STAYS Some(origin), driven_by stays latched). EMPIRICAL VERDICT (2026-06-19): controller_by==None is NOT a sufficient Gap-A signal for the production wedged-pump case — the lost-detach slot does not self-clear, so the fix must be BROKER-SIDE eviction (a controller-liveness check / reconcile-driven clear_controller), not merely a reconcile `controller_by==None` read. impl/unit activate when the fix lands.

+required_stages = ["impl", "unit", "int"]  # ACTIVATED v0.13.0 W5 (repro-first, todlando). SCOPE landed = Gap B (no-session self-heal): reconcile_hosted_liveness, when it offlines a sessionless controllable perch (B2 keystone), ALSO clears a stale driven_by (set_driven_by None) — an OFFLINE endpoint can no longer render phantom CONTROLLED. RACE-FREE (no live session ⇒ no controller to re-stamp concurrently). impl = livehost.rs reconcile_hosted_liveness Gap-B clear + the additive SessionInfo.controller_by observability field (broker.rs KIND_SESSIONS). unit = pull_liveness unit extended (offlined sessionless perch clears driven_by; live/relay perches untouched). int = real-broker gates: driven_by_selfheal.rs gap_b (GREEN: reconcile offlines AND clears driven_by) + the A1/A2 characterization (inject_control_wedge.rs w5_a1/w5_a2). EMPIRICAL VERDICT (2026-06-19, real broker): controller_by==None is NOT a usable Gap-A signal — it is AMBIGUOUS (dispatch_spawn pre-attaches the spawner with by=None, so a LIVE LOCAL session also reads None) AND the production wedged-remote case (A2) keeps controller_by==Some(origin)+driven_by latched on an IDLE session (the broker still thinks it is controlled). A clean controller disconnect ALREADY self-heals via detach_if→clear_controller; W1 bounds the active-output wedge. The residual A2 (idle wedged REMOTE controller) needs BROKER-SIDE liveness eviction, NOT a brain reconcile controller_by read → DEFERRED to REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT. KNOWN-HAZARDS 7.15.

+# ── W5 follow-up: the idle wedged-REMOTE-controller latch (A2, repro-proven, deferred) ──

+title = "An spt-hosted endpoint driven by a REMOTE controller whose remote is gone but whose broker connection stays OPEN (a wedged/lost pump that never delivers the detach) AND whose session is IDLE (no output) stays latched ONLINE+CONTROLLED forever: the W1 drain-evict only fires on OUTPUT (CONTROLLER_WRITE_DEADLINE on a backed-up write), a clean disconnect self-heals via detach_if→clear_controller, but an idle session with a half-open/wedged controller connection produces neither signal. PROVED repro-first on a real broker (v0.13.0 W5, inject_control_wedge.rs w5_a2): controller_by STAYS Some(origin) and driven_by STAYS Some after the remote is abandoned without a clean EOF on an idle session — so the brain reconcile CANNOT detect it from KIND_SESSIONS controller_by (the broker still reports it controlled). FIX DIRECTION (broker-side, single-writer — the broker owns driven_by/clear_controller): a controller-liveness probe / idle keepalive that evicts a controller whose REMOTE is no longer reachable (e.g. keyed on net presence of `origin`, or a periodic end-to-end controller liveness check), then clear_controller re-stamps driven_by=None. MUST NOT false-evict a genuinely-controlled live session across a transient partition (the network-scoped analog of the W5 false-heal watch-out) — needs a debounce/grace and likely an ADR for the partition semantics. Composes with W1 (output path) + W5 Gap B (no-session) — this is the third, idle-remote, leg. (v0.13.0 follow-up)"

+required_stages = []  # SEED — repro already exists (inject_control_wedge.rs w5_a2 characterization, under REQ-HAZARD-DRIVEN-BY-SELFHEAL int). Activate [impl,unit,int] when the broker-side eviction lands (doyle workstream boundary: relates to the W6/perri pump-detach layer — a pump that propagates remote-stream death into a clean detach would also close this without a presence probe).

# ── W7 (operator /diagnose 2026-06-19): rc Backspace key forwards the wrong byte ──


Now the KNOWN-HAZARDS entry. Check the format around 7.13/7.14.


Bash

Find KNOWN-HAZARDS 7.x section


174:## 5. Platform-specific
259:## 6. Documented regressions (non-obvious invariants)
337:## 7. Boundary & delivery integrity (added 2026-05-31 — Stage A red-team)
341:### 7.1 Local `api` mutation auth  `[REQ-HAZARD-LOCAL-API-AUTH]`
347:### 7.2 Idempotent delivery across brain restart  `[REQ-HAZARD-RESTART-IDEMPOTENT]`
353:### 7.3 Psyche outbound capture + sanitization  `[REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`
359:### 7.4 Per-agent pulse/psyche/echo scheduling must not serialize across agents  `[REQ-HAZARD-DAEMON-SCHED-NONBLOCKING]`
365:### 7.5 WAN-inbound origin is transport truth, never payload  `[REQ-HAZARD-WAN-ORIGIN-AUTH]`
373:### 7.6 Pump brain-IPC reads must be deadline-bounded (a blocked read wedges the whole pump)  `[REQ-HAZARD-PUMP-IPC-DEADLINE]`
380:### 7.7 A slow/dead/hostile remote VIEWER must never stall the controller, child, or drain  `[REQ-HAZARD-VIEWER-ISOLATION]`
387:### 7.8 The broker must never make a brain wait UNBOUNDED on a QUIC op (the pump-IPC-deadline B-half)  `[REQ-HAZARD-BROKER-QUIC-DEADLINE]`
394:### 7.9 A daemon-state wire change needs a deliberate BROKER restart (the broker is resident across a brain self-update)  `[REQ-HAZARD-BROKER-SEED-WIRE-SKEW]`
401:### 7.10 A VIEW is independent from the endpoint — closing the launching tab must NOT reap the daemon-hosted harness  `[REQ-HAZARD-VIEWER-CLOSE-DETACH]`
408:### 7.11 A dead PTY child + a dropped operator pump must NOT wedge the broker for other clients  `[REQ-HAZARD-ATTACH-WEDGE]`
414:### 7.12 Controller output must NOT be written inline on the drain thread — a backed-up controller wedges the session  `[REQ-HAZARD-INJECT-CONTROL-COEXIST]`
420:### 7.13 `spt rc` must SWAP Windows legacy-console Backspace/Ctrl+Backspace (`^H`↔DEL); do NOT enable VT console input  `[REQ-HAZARD-RC-INPUT-KEY-ENCODING]`
427:### 7.14 The effect journal must NOT hold its lock across the PTY write (or fsync every keystroke) — interactive input stutters then wedges  `[REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE]`
435:## Conformance checklist (condensed)
479:| 7.13 | `spt rc` SWAPS Windows legacy-console delete bytes `^H` (0x08)↔VT DEL (0x7f) in the raw forward path (Backspace→char-delete, Ctrl+Backspace→word-delete; 0x02 detach + other bytes untouched); does NOT enable VT console input (= win32-input-mode on WT, broke detach) | `rc.rs` `normalize_key_byte` in `spawn_stdin_reader` |
480:| 7.14 | `EffectJournal::apply_once` releases its lock ACROSS `effect()` (reserve→release→run→finalize), never holding it across the blocking PTY write; `EffectKind::PtyWrite` is ephemeral (no per-keystroke fsync, in-memory dedup), durable kinds keep fsync — fixes interactive stutter + the hard input wedge (`brain IPC read deadline`) | `effect.rs` `apply_once`/`is_durable`, int `inject_control_wedge.rs` |


420	### 7.13 `spt rc` must SWAP Windows legacy-console Backspace/Ctrl+Backspace (`^H`↔DEL); do NOT enable VT console input  `[REQ-HAZARD-RC-INPUT-KEY-ENCODING]`
421	- **Failure (operator dogfooding):** `spt rc` is a raw verbatim stdin byte pump; the legacy Windows console delivers Backspace as `^H` (0x08), and Claude Code maps `^H` → backward-kill-word, so every Backspace deletes a whole word. Confirmed bytes (operator HITL capture, real Windows Terminal): Backspace=0x08, Ctrl+Backspace=0x7f.
422	- **REJECTED fix (dc07c39, reverted):** enabling `ENABLE_VIRTUAL_TERMINAL_INPUT` on the stdin console BACKFIRED — on Windows Terminal that flag yields **win32-input-mode**, not legacy xterm VT. Every key then arrives as a 6-field `ESC[Vk;Sc;Uc;Kd;Cs;Rc_` key-down/up record: (1) the ctrl-b detach broke (0x02 is wrapped, so `parse_stdin_chunk` never sees the raw `DETACH_PREFIX`), and (2) win32-input-mode was forwarded into Claude Code's own ConPTY input negotiation (garbage). The VT lever is wrong for Windows Terminal. (Lesson: verify a terminal-input fix on the REAL terminal via the operator re-capture — unit/clippy never exercise the live key path.)
423	- **Invariant:** stay a raw byte pump and NARROW-normalize in the forward path — **SWAP** the two delete bytes: `0x08` → `0x7f` (VT DEL, char-delete) AND `0x7f` → `0x08` (^H, kill-word), AFTER the detach state machine consumes any `0x02`, leaving `0x02` and every other byte untouched. Because CC reads `^H`=word-delete and DEL=char-delete, swapping the *input* bytes gives Backspace=char-delete and Ctrl+Backspace=word-delete — the native Win11 convention (the operator rejected losing word-delete; the earlier one-way `0x08`→`0x7f` map dropped it). cfg(windows) only (Unix is already VT; a real `ctrl+h`/`ctrl+?` there must pass through). Accepted minor loss: a real `ctrl+h` (0x08) and a real DEL (0x7f) are swapped on Windows — both are delete keys, so the practical effect is the Backspace/Ctrl+Backspace native mapping.
424	- **spt-core mapping:** impl `rc.rs` `normalize_key_byte` (applied in `spawn_stdin_reader`'s forward path); unit `normalize_maps_backspace_but_leaves_detach_and_others` (0x08↔0x7f swap, 0x02/CR/ESC/ordinary untouched); the live keypress is HITL operator re-capture.
425	- **Source:** v0.13.0 W7 (operator dogfooding; doyle /diagnose + byte confirm + VT-backfire re-capture 2026-06-19; one-way→swap upgrade 2026-06-19).
426
427	### 7.14 The effect journal must NOT hold its lock across the PTY write (or fsync every keystroke) — interactive input stutters then wedges  `[REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE]`
428	- **Failure (operator dogfooding + doyle /diagnose, MEASURED on the real Windows box, 2026-06-19, post-W1 escape):** `EffectJournal::apply_once` held its global `inner` mutex ACROSS `write_line(PENDING)` → `effect()` → `write_line(DONE)`, and `write_line` does `flush()+sync_all()` (a full fsync). Every operator keystroke is a `PtyWrite` effect (via `send_effect` → `dispatch_input` op_id branch), so each paid TWO fsyncs serialized under a GLOBAL lock with the blocking PTY write INSIDE the lock. Two facets, one root: **(A) stutter** — measured fsync on `%LOCALAPPDATA%\spt-core` median 6.5 ms / spikes 198 ms, ×2 per keystroke → choppy, worsens with volume; **(B) HARD WEDGE** — a blocking `PtyWrite` (ConPTY/forkpty input buffer not draining) held the lock indefinitely, so the single-threaded dispatch could open NO attaches → every `spt rc --view/--take` died with `brain IPC read deadline elapsed` (broker control-plane KIND queries still answered = a different thread). DISTINCT from 7.12 (the OUTPUT drain); this is the INPUT/effect-journal path 7.12 never touched. **Refutes** 7.12's "input parks are Windows-benign (ConPTY absorbs)" deferral — on the real box the input path wedges regardless, because the wedge is the LOCK HOLD, not the PTY write blocking per se.
429	- **Invariant:** `apply_once` RELEASES the inner lock across `effect()` — reserve the key (fsync `PENDING` for DURABLE kinds) under the lock, RELEASE, run the effect OFF the lock, then re-acquire to finalize (fsync `DONE` for durable, mark applied). Crash-idempotency comes from the per-key reservation + the applied-set, not from holding the lock across the effect (the brain-crash guarantee is unchanged: the broker survives the brain, so reserve→effect→finalize runs uninterrupted on the broker thread; a replay hits the applied-set and dedups). `EffectKind::PtyWrite` is **EPHEMERAL** — NO journal lines, NO fsync, in-memory dedup only (a keystroke lost to a broker crash is retyped; PTY state is never rebuilt from keystroke replay) — while durable kinds (spool/registry/net) keep their fsync'd markers. The PtyWrite itself must additionally be bounded/fail-fast and must not hold the writer mutex across a blocking write (the Unix-forkpty park bound; ConPTY absorbs = benign).
430	- **spt-core mapping:** impl `effect.rs` `apply_once` (reserve/release/finalize) + `EffectKind::is_durable`; unit (`effect.rs`) a barrier proving the lock is NOT held across `effect()` (a different key's `apply_once` completes while one effect blocks) + `PtyWrite` writes no journal line while a durable kind (`NetSend`) does (the no-fsync proxy); int `crates/spt-daemon/tests/inject_control_wedge.rs` (real broker+PTY, a stalled consumer + sustained blocking input effect: a concurrent `spt rc` attach stays serviceable AND actually receives PTY bytes — the assertion W1's gate lacked). Unix park-(b) folded here, gated on gravity-linux. doyle GATE-GUARD: assert structurally, NEVER on fsync wall-clock (env-dependent, flaky); the stutter is fixed as a consequence of the no-fsync path, proven by the structural unit.
431	- **Source:** v0.13.0 W1b (operator dogfooding post-W1 escape; doyle /diagnose + measurement 2026-06-19).
432
433	---
434

### 7.13 `spt rc` must SWAP Windows legacy-console Backspace/Ctrl+Backspace (`^H`↔DEL); do NOT enable VT console input  `[REQ-HAZARD-RC-INPUT-KEY-ENCODING]`
- **Failure (operator dogfooding):** `spt rc` is a raw verbatim stdin byte pump; the legacy Windows console delivers Backspace as `^H` (0x08), and Claude Code maps `^H` → backward-kill-word, so every Backspace deletes a whole word. Confirmed bytes (operator HITL capture, real Windows Terminal): Backspace=0x08, Ctrl+Backspace=0x7f.
- **REJECTED fix (dc07c39, reverted):** enabling `ENABLE_VIRTUAL_TERMINAL_INPUT` on the stdin console BACKFIRED — on Windows Terminal that flag yields **win32-input-mode**, not legacy xterm VT. Every key then arrives as a 6-field `ESC[Vk;Sc;Uc;Kd;Cs;Rc_` key-down/up record: (1) the ctrl-b detach broke (0x02 is wrapped, so `parse_stdin_chunk` never sees the raw `DETACH_PREFIX`), and (2) win32-input-mode was forwarded into Claude Code's own ConPTY input negotiation (garbage). The VT lever is wrong for Windows Terminal. (Lesson: verify a terminal-input fix on the REAL terminal via the operator re-capture — unit/clippy never exercise the live key path.)
- **Invariant:** stay a raw byte pump and NARROW-normalize in the forward path — **SWAP** the two delete bytes: `0x08` → `0x7f` (VT DEL, char-delete) AND `0x7f` → `0x08` (^H, kill-word), AFTER the detach state machine consumes any `0x02`, leaving `0x02` and every other byte untouched. Because CC reads `^H`=word-delete and DEL=char-delete, swapping the *input* bytes gives Backspace=char-delete and Ctrl+Backspace=word-delete — the native Win11 convention (the operator rejected losing word-delete; the earlier one-way `0x08`→`0x7f` map dropped it). cfg(windows) only (Unix is already VT; a real `ctrl+h`/`ctrl+?` there must pass through). Accepted minor loss: a real `ctrl+h` (0x08) and a real DEL (0x7f) are swapped on Windows — both are delete keys, so the practical effect is the Backspace/Ctrl+Backspace native mapping.
- **spt-core mapping:** impl `rc.rs` `normalize_key_byte` (applied in `spawn_stdin_reader`'s forward path); unit `normalize_maps_backspace_but_leaves_detach_and_others` (0x08↔0x7f swap, 0x02/CR/ESC/ordinary untouched); the live keypress is HITL operator re-capture.
- **Source:** v0.13.0 W7 (operator dogfooding; doyle /diagnose + byte confirm + VT-backfire re-capture 2026-06-19; one-way→swap upgrade 2026-06-19).

### 7.14 The effect journal must NOT hold its lock across the PTY write (or fsync every keystroke) — interactive input stutters then wedges  `[REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE]`
- **Failure (operator dogfooding + doyle /diagnose, MEASURED on the real Windows box, 2026-06-19, post-W1 escape):** `EffectJournal::apply_once` held its global `inner` mutex ACROSS `write_line(PENDING)` → `effect()` → `write_line(DONE)`, and `write_line` does `flush()+sync_all()` (a full fsync). Every operator keystroke is a `PtyWrite` effect (via `send_effect` → `dispatch_input` op_id branch), so each paid TWO fsyncs serialized under a GLOBAL lock with the blocking PTY write INSIDE the lock. Two facets, one root: **(A) stutter** — measured fsync on `%LOCALAPPDATA%\spt-core` median 6.5 ms / spikes 198 ms, ×2 per keystroke → choppy, worsens with volume; **(B) HARD WEDGE** — a blocking `PtyWrite` (ConPTY/forkpty input buffer not draining) held the lock indefinitely, so the single-threaded dispatch could open NO attaches → every `spt rc --view/--take` died with `brain IPC read deadline elapsed` (broker control-plane KIND queries still answered = a different thread). DISTINCT from 7.12 (the OUTPUT drain); this is the INPUT/effect-journal path 7.12 never touched. **Refutes** 7.12's "input parks are Windows-benign (ConPTY absorbs)" deferral — on the real box the input path wedges regardless, because the wedge is the LOCK HOLD, not the PTY write blocking per se.
- **Invariant:** `apply_once` RELEASES the inner lock across `effect()` — reserve the key (fsync `PENDING` for DURABLE kinds) under the lock, RELEASE, run the effect OFF the lock, then re-acquire to finalize (fsync `DONE` for durable, mark applied). Crash-idempotency comes from the per-key reservation + the applied-set, not from holding the lock across the effect (the brain-crash guarantee is unchanged: the broker survives the brain, so reserve→effect→finalize runs uninterrupted on the broker thread; a replay hits the applied-set and dedups). `EffectKind::PtyWrite` is **EPHEMERAL** — NO journal lines, NO fsync, in-memory dedup only (a keystroke lost to a broker crash is retyped; PTY state is never rebuilt from keystroke replay) — while durable kinds (spool/registry/net) keep their fsync'd markers. The PtyWrite itself must additionally be bounded/fail-fast and must not hold the writer mutex across a blocking write (the Unix-forkpty park bound; ConPTY absorbs = benign).
- **spt-core mapping:** impl `effect.rs` `apply_once` (reserve/release/finalize) + `EffectKind::is_durable`; unit (`effect.rs`) a barrier proving the lock is NOT held across `effect()` (a different key's `apply_once` completes while one effect blocks) + `PtyWrite` writes no journal line while a durable kind (`NetSend`) does (the no-fsync proxy); int `crates/spt-daemon/tests/inject_control_wedge.rs` (real broker+PTY, a stalled consumer + sustained blocking input effect: a concurrent `spt rc` attach stays serviceable AND actually receives PTY bytes — the assertion W1's gate lacked). Unix park-(b) folded here, gated on gravity-linux. doyle GATE-GUARD: assert structurally, NEVER on fsync wall-clock (env-dependent, flaky); the stutter is fixed as a consequence of the no-fsync path, proven by the structural unit.
- **Source:** v0.13.0 W1b (operator dogfooding post-W1 escape; doyle /diagnose + measurement 2026-06-19).

---


Edit

- **Source:** v0.13.0 W1b (operator dogfooding post-W1 escape; doyle /diagnose + measurement 2026-06-19).

---

- **Source:** v0.13.0 W1b (operator dogfooding post-W1 escape; doyle /diagnose + measurement 2026-06-19).

### 7.15 An OFFLINE spt-hosted endpoint must NOT render phantom `ONLINE+CONTROLLED` — clear `driven_by` when its session is gone  `[REQ-HAZARD-DRIVEN-BY-SELFHEAL]`
- **Failure:** `driven_by` (the `info.json` `ONLINE+CONTROLLED` latch) is single-written by the broker via `clear_controller`/`stamp_driven_by`, which only fire on a controller change. When an spt-hosted endpoint's broker session is GONE (harness dead — the B2 case, 7-series sibling `REQ-HAZARD-HOSTED-LIVENESS-RECONCILE`), no controller event ever fires, so a stale `driven_by=Some(node)` persists: the picker renders a phantom "controlled by X" on an endpoint that is actually OFFLINE. The B2 reconcile already clears the `status=online` latch here but left `driven_by` untouched.
- **Invariant:** `reconcile_hosted_liveness`, when it offlines a sessionless controllable perch (the B2 keystone — no live broker session ⇒ dead harness), ALSO clears `driven_by` (`set_driven_by(perch, None)`). RACE-FREE and single-writer-safe: with NO live broker session there is no controller to re-stamp `driven_by` concurrently, so the brain may write it here without contending the broker. (The LIVE-session leg — a controller gone while its session survives — is NOT this hazard: a clean disconnect already self-heals via `detach_if`→`clear_controller`, W1 (7.12) bounds the active-output wedge, and the residual idle-wedged-REMOTE case is the deferred `REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT`, which needs BROKER-SIDE liveness eviction — a brain reconcile can NOT detect it, since the broker still reports `controller_by==Some` on an idle wedged controller. Repro-proven: `inject_control_wedge.rs` w5_a2.)
- **Watch-out (repro-proven, real broker):** `SessionInfo.controller_by==None` is AMBIGUOUS — `dispatch_spawn` pre-attaches the spawner as the LOCAL controller with `by=None`, so a live LOCALLY-driven session also reads `None`. It is therefore NOT a usable standalone `driven_by` clear trigger (would false-clear a live local session). The shipped Gap-B self-heal needs no controller signal at all (it keys on session ABSENCE).
- **spt-core mapping:** impl `livehost.rs` `reconcile_hosted_liveness` Gap-B clear + the additive `SessionInfo.controller_by` observability field (`msg.rs`, populated in `broker.rs` `KIND_SESSIONS`); unit `livehost.rs` `pull_liveness…` extended (offlined sessionless perch clears `driven_by`; live/relay perches untouched); int `crates/spt-daemon/tests/driven_by_selfheal.rs` `gap_b` (real broker: reconcile offlines AND clears `driven_by`) + the A1/A2 characterization (`inject_control_wedge.rs` w5_a1/w5_a2).
- **Source:** v0.13.0 W5 (repro-first, todlando; doyle-assigned 2026-06-19). A2 (idle wedged-remote leg) deferred to `REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT`.

---


The file C:\Users\decid\Documents\projects\spt-core\docs\KNOWN-HAZARDS.md has been updated successfully. (file state is current in your context — no need to Read it back)

- **Source:** v0.13.0 W1b (operator dogfooding post-W1 escape; doyle /diagnose + measurement 2026-06-19).

---

- **Source:** v0.13.0 W1b (operator dogfooding post-W1 escape; doyle /diagnose + measurement 2026-06-19).

### 7.15 An OFFLINE spt-hosted endpoint must NOT render phantom `ONLINE+CONTROLLED` — clear `driven_by` when its session is gone  `[REQ-HAZARD-DRIVEN-BY-SELFHEAL]`
- **Failure:** `driven_by` (the `info.json` `ONLINE+CONTROLLED` latch) is single-written by the broker via `clear_controller`/`stamp_driven_by`, which only fire on a controller change. When an spt-hosted endpoint's broker session is GONE (harness dead — the B2 case, 7-series sibling `REQ-HAZARD-HOSTED-LIVENESS-RECONCILE`), no controller event ever fires, so a stale `driven_by=Some(node)` persists: the picker renders a phantom "controlled by X" on an endpoint that is actually OFFLINE. The B2 reconcile already clears the `status=online` latch here but left `driven_by` untouched.
- **Invariant:** `reconcile_hosted_liveness`, when it offlines a sessionless controllable perch (the B2 keystone — no live broker session ⇒ dead harness), ALSO clears `driven_by` (`set_driven_by(perch, None)`). RACE-FREE and single-writer-safe: with NO live broker session there is no controller to re-stamp `driven_by` concurrently, so the brain may write it here without contending the broker. (The LIVE-session leg — a controller gone while its session survives — is NOT this hazard: a clean disconnect already self-heals via `detach_if`→`clear_controller`, W1 (7.12) bounds the active-output wedge, and the residual idle-wedged-REMOTE case is the deferred `REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT`, which needs BROKER-SIDE liveness eviction — a brain reconcile can NOT detect it, since the broker still reports `controller_by==Some` on an idle wedged controller. Repro-proven: `inject_control_wedge.rs` w5_a2.)
- **Watch-out (repro-proven, real broker):** `SessionInfo.controller_by==None` is AMBIGUOUS — `dispatch_spawn` pre-attaches the spawner as the LOCAL controller with `by=None`, so a live LOCALLY-driven session also reads `None`. It is therefore NOT a usable standalone `driven_by` clear trigger (would false-clear a live local session). The shipped Gap-B self-heal needs no controller signal at all (it keys on session ABSENCE).
- **spt-core mapping:** impl `livehost.rs` `reconcile_hosted_liveness` Gap-B clear + the additive `SessionInfo.controller_by` observability field (`msg.rs`, populated in `broker.rs` `KIND_SESSIONS`); unit `livehost.rs` `pull_liveness…` extended (offlined sessionless perch clears `driven_by`; live/relay perches untouched); int `crates/spt-daemon/tests/driven_by_selfheal.rs` `gap_b` (real broker: reconcile offlines AND clears `driven_by`) + the A1/A2 characterization (`inject_control_wedge.rs` w5_a1/w5_a2).
- **Source:** v0.13.0 W5 (repro-first, todlando; doyle-assigned 2026-06-19). A2 (idle wedged-remote leg) deferred to `REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT`.

---

- **spt-core mapping:** impl `effect.rs` `apply_once` (reserve/release/finalize) + `EffectKind::is_durable`; unit (`effect.rs`) a barrier proving the lock is NOT held across `effect()` (a different key's `apply_once` completes while one effect blocks) + `PtyWrite` writes no journal line while a durable kind (`NetSend`) does (the no-fsync proxy); int `crates/spt-daemon/tests/inject_control_wedge.rs` (real broker+PTY, a stalled consumer + sustained blocking input effect: a concurrent `spt rc` attach stays serviceable AND actually receives PTY bytes — the assertion W1's gate lacked). Unix park-(b) folded here, gated on gravity-linux. doyle GATE-GUARD: assert structurally, NEVER on fsync wall-clock (env-dependent, flaky); the stutter is fixed as a consequence of the no-fsync path, proven by the structural unit.

+### 7.15 An OFFLINE spt-hosted endpoint must NOT render phantom `ONLINE+CONTROLLED` — clear `driven_by` when its session is gone  `[REQ-HAZARD-DRIVEN-BY-SELFHEAL]`

+- **Failure:** `driven_by` (the `info.json` `ONLINE+CONTROLLED` latch) is single-written by the broker via `clear_controller`/`stamp_driven_by`, which only fire on a controller change. When an spt-hosted endpoint's broker session is GONE (harness dead — the B2 case, 7-series sibling `REQ-HAZARD-HOSTED-LIVENESS-RECONCILE`), no controller event ever fires, so a stale `driven_by=Some(node)` persists: the picker renders a phantom "controlled by X" on an endpoint that is actually OFFLINE. The B2 reconcile already clears the `status=online` latch here but left `driven_by` untouched.

+- **Invariant:** `reconcile_hosted_liveness`, when it offlines a sessionless controllable perch (the B2 keystone — no live broker session ⇒ dead harness), ALSO clears `driven_by` (`set_driven_by(perch, None)`). RACE-FREE and single-writer-safe: with NO live broker session there is no controller to re-stamp `driven_by` concurrently, so the brain may write it here without contending the broker. (The LIVE-session leg — a controller gone while its session survives — is NOT this hazard: a clean disconnect already self-heals via `detach_if`→`clear_controller`, W1 (7.12) bounds the active-output wedge, and the residual idle-wedged-REMOTE case is the deferred `REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT`, which needs BROKER-SIDE liveness eviction — a brain reconcile can NOT detect it, since the broker still reports `controller_by==Some` on an idle wedged controller. Repro-proven: `inject_control_wedge.rs` w5_a2.)

+- **Watch-out (repro-proven, real broker):** `SessionInfo.controller_by==None` is AMBIGUOUS — `dispatch_spawn` pre-attaches the spawner as the LOCAL controller with `by=None`, so a live LOCALLY-driven session also reads `None`. It is therefore NOT a usable standalone `driven_by` clear trigger (would false-clear a live local session). The shipped Gap-B self-heal needs no controller signal at all (it keys on session ABSENCE).

+- **spt-core mapping:** impl `livehost.rs` `reconcile_hosted_liveness` Gap-B clear + the additive `SessionInfo.controller_by` observability field (`msg.rs`, populated in `broker.rs` `KIND_SESSIONS`); unit `livehost.rs` `pull_liveness…` extended (offlined sessionless perch clears `driven_by`; live/relay perches untouched); int `crates/spt-daemon/tests/driven_by_selfheal.rs` `gap_b` (real broker: reconcile offlines AND clears `driven_by`) + the A1/A2 characterization (`inject_control_wedge.rs` w5_a1/w5_a2).

+- **Source:** v0.13.0 W5 (repro-first, todlando; doyle-assigned 2026-06-19). A2 (idle wedged-remote leg) deferred to `REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT`.


Add the conformance checklist row for 7.15.


484	| 7.11 | A dead PTY child + a dropped operator pump does NOT wedge the broker — PROVE-DON'T-CHANGE: send_stream already QUIC-deadline-bounded (7.8), the loopback duplex is drained broker-internally (evict-not-park), block_on parks the dispatch thread not a net worker; dead endpoint offlined within a reconcile tick | int `attach_wedge_e2e.rs` |
485	| 7.12 | Controller output delivered OFF the drain thread (dedicated writer + bounded deadline-detach), never inline under the log lock — a backed-up controller can't wedge the session | broker `OutputLog::append`/`controller_writer`, int `inject_control_wedge.rs` |
486	| 7.13 | `spt rc` SWAPS Windows legacy-console delete bytes `^H` (0x08)↔VT DEL (0x7f) in the raw forward path (Backspace→char-delete, Ctrl+Backspace→word-delete; 0x02 detach + other bytes untouched); does NOT enable VT console input (= win32-input-mode on WT, broke detach) | `rc.rs` `normalize_key_byte` in `spawn_stdin_reader` |
487	| 7.14 | `EffectJournal::apply_once` releases its lock ACROSS `effect()` (reserve→release→run→finalize), never holding it across the blocking PTY write; `EffectKind::PtyWrite` is ephemeral (no per-keystroke fsync, in-memory dedup), durable kinds keep fsync — fixes interactive stutter + the hard input wedge (`brain IPC read deadline`) | `effect.rs` `apply_once`/`is_durable`, int `inject_control_wedge.rs` |
488

| 7.11 | A dead PTY child + a dropped operator pump does NOT wedge the broker — PROVE-DON'T-CHANGE: send_stream already QUIC-deadline-bounded (7.8), the loopback duplex is drained broker-internally (evict-not-park), block_on parks the dispatch thread not a net worker; dead endpoint offlined within a reconcile tick | int `attach_wedge_e2e.rs` |
| 7.12 | Controller output delivered OFF the drain thread (dedicated writer + bounded deadline-detach), never inline under the log lock — a backed-up controller can't wedge the session | broker `OutputLog::append`/`controller_writer`, int `inject_control_wedge.rs` |
| 7.13 | `spt rc` SWAPS Windows legacy-console delete bytes `^H` (0x08)↔VT DEL (0x7f) in the raw forward path (Backspace→char-delete, Ctrl+Backspace→word-delete; 0x02 detach + other bytes untouched); does NOT enable VT console input (= win32-input-mode on WT, broke detach) | `rc.rs` `normalize_key_byte` in `spawn_stdin_reader` |
| 7.14 | `EffectJournal::apply_once` releases its lock ACROSS `effect()` (reserve→release→run→finalize), never holding it across the blocking PTY write; `EffectKind::PtyWrite` is ephemeral (no per-keystroke fsync, in-memory dedup), durable kinds keep fsync — fixes interactive stutter + the hard input wedge (`brain IPC read deadline`) | `effect.rs` `apply_once`/`is_durable`, int `inject_control_wedge.rs` |


| 7.14 | `EffectJournal::apply_once` releases its lock ACROSS `effect()` (reserve→release→run→finalize), never holding it across the blocking PTY write; `EffectKind::PtyWrite` is ephemeral (no per-keystroke fsync, in-memory dedup), durable kinds keep fsync — fixes interactive stutter + the hard input wedge (`brain IPC read deadline`) | `effect.rs` `apply_once`/`is_durable`, int `inject_control_wedge.rs` |

| 7.14 | `EffectJournal::apply_once` releases its lock ACROSS `effect()` (reserve→release→run→finalize), never holding it across the blocking PTY write; `EffectKind::PtyWrite` is ephemeral (no per-keystroke fsync, in-memory dedup), durable kinds keep fsync — fixes interactive stutter + the hard input wedge (`brain IPC read deadline`) | `effect.rs` `apply_once`/`is_durable`, int `inject_control_wedge.rs` |
| 7.15 | `reconcile_hosted_liveness` clears stale `driven_by` when it offlines a sessionless controllable perch (no live broker session) — an OFFLINE endpoint never renders phantom `ONLINE+CONTROLLED`; race-free (no session ⇒ no concurrent broker re-stamp). `controller_by==None` is ambiguous (local controller reads None) so it is NOT a clear trigger; the idle wedged-remote leg is deferred (`REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT`) | `livehost.rs` `reconcile_hosted_liveness`, int `driven_by_selfheal.rs`/`inject_control_wedge.rs` |


| 7.14 | `EffectJournal::apply_once` releases its lock ACROSS `effect()` (reserve→release→run→finalize), never holding it across the blocking PTY write; `EffectKind::PtyWrite` is ephemeral (no per-keystroke fsync, in-memory dedup), durable kinds keep fsync — fixes interactive stutter + the hard input wedge (`brain IPC read deadline`) | `effect.rs` `apply_once`/`is_durable`, int `inject_control_wedge.rs` |
| 7.15 | `reconcile_hosted_liveness` clears stale `driven_by` when it offlines a sessionless controllable perch (no live broker session) — an OFFLINE endpoint never renders phantom `ONLINE+CONTROLLED`; race-free (no session ⇒ no concurrent broker re-stamp). `controller_by==None` is ambiguous (local controller reads None) so it is NOT a clear trigger; the idle wedged-remote leg is deferred (`REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT`) | `livehost.rs` `reconcile_hosted_liveness`, int `driven_by_selfheal.rs`/`inject_control_wedge.rs` |

| 7.12 | Controller output delivered OFF the drain thread (dedicated writer + bounded deadline-detach), never inline under the log lock — a backed-up controller can't wedge the session | broker `OutputLog::append`/`controller_writer`, int `inject_control_wedge.rs` |

| 7.13 | `spt rc` SWAPS Windows legacy-console delete bytes `^H` (0x08)↔VT DEL (0x7f) in the raw forward path (Backspace→char-delete, Ctrl+Backspace→word-delete; 0x02 detach + other bytes untouched); does NOT enable VT console input (= win32-input-mode on WT, broke detach) | `rc.rs` `normalize_key_byte` in `spawn_stdin_reader` |

+| 7.15 | `reconcile_hosted_liveness` clears stale `driven_by` when it offlines a sessionless controllable perch (no live broker session) — an OFFLINE endpoint never renders phantom `ONLINE+CONTROLLED`; race-free (no session ⇒ no concurrent broker re-stamp). `controller_by==None` is ambiguous (local controller reads None) so it is NOT a clear trigger; the idle wedged-remote leg is deferred (`REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT`) | `livehost.rs` `reconcile_hosted_liveness`, int `driven_by_selfheal.rs`/`inject_control_wedge.rs` |


Src + REQ + docs done. Now delegate the two test tasks to the existing engineer (a27bb87b, owns `driven_by_selfheal.rs`) — reframe the Gap A test (no `controller_by==None` clear; A2 deferred) + add the Gap B `[unit]` test. Background; single engineer.