1	//! Staged-update apply orchestration (M5-D7b) — the **production caller** for
2	//! [`crate::update::apply_brain_only`], closing the M4-D9 honest residual
3	//! ("nothing in the daemon invokes this apply after the user acks the consent
4	//! notif").
5	//!
6	//! The ack IS the verb the consent notif names: its body says "confirm to
7	//! apply (`spt update apply`) or dismiss" (ADR-0007 — the harness's native
8	//! confirm affordance answers by running the command; dismiss latches the
9	//! notif and is a *prompt* answer, not a lock — an apply after a dismiss
10	//! still works, the user changed their mind).
11	//!
12	//! [`apply_staged`] drives the whole chain against what the peer pump left
13	//! behind (REQ-UPD-4: staged + consent-notified, never auto-applied):
14	//!
15	//! 1. **Re-verify at rest** (`plan_verified` under the production
16	//!    `release-keys.json` policy): apply never trusts the cache — a tampered
17	//!    or expired artifact refuses here. The monotonic-version floor was
18	//!    enforced at *stage* time (REQ-HAZARD-UPDATE-ROLLBACK); the apply-time
19	//!    re-check guards authenticity/integrity/expiry of the bytes on disk.
20	//! 2. **Classify** against the running broker's ABI. Only
21	//!    [`UpdateClass::BrainOnly`] applies in M5 — the broker-touching classes
22	//!    refuse typed, the artifact stays staged (their consent-and-scheduling
23	//!    orchestration is post-M5; ADR-0004 §A).
24	//! 3. **Swap the binary**: the current executable steps aside
25	//!    (`<exe>.old-<ver>` — a running image survives its own rename on both
26	//!    OSes) and the verified artifact lands at the executable path
27	//!    atomically. A failed landing rolls the original back — never a
28	//!    half-swapped seat.
29	//! 4. **Trigger the brain handoff** ([`apply_brain_only`]): snapshot → drop →
30	//!    re-attach against the live broker. No hosted endpoint terminates or
31	//!    suspends (REQ-UPD-3, absolute for this class). The relaunch closure
32	//!    re-attaches in-process over the existing substrate — the same
33	//!    documented stand-in `brain_swap.rs` proves the real handoff with; a
34	//!    freshly-exec'd brain of the new binary takes the identical path.
35	//!
36	//! The daemon's own resident loops keep running the old logic until its next
37	//! restart (`ensure_running` spawns the executable path — now the new
38	//! binary); that restart is routine lifecycle, never an endpoint cycle.
39	// [impl->REQ-UPD-3]
40	// [impl->REQ-UPD-4]
41
42	use std::io;
43	use std::path::Path;
44	use std::time::Duration;
45
46	use crate::brain::{now_ms, Brain, BrainState};
47	use crate::relcache::{ReleaseCache, StagedUpdate};
48	use crate::release::{current_platform, VerifyPolicy};
49	use crate::update::{
50	    apply_brain_only, plan_verified, plan_verified_update_set, BrokerAbi, UpdateClass,
51	};
52
53	/// What [`apply_staged`] did — loud + typed, never silent.
54	#[derive(Debug, Clone, PartialEq, Eq)]
55	pub enum ApplyStagedOutcome {
56	    /// Nothing staged (or a torn stage that never offers) — idempotent no-op.
57	    NoUpdate,
58	    /// The at-rest re-verification refused (tamper / expiry / key trust) —
59	    /// nothing swapped, the reason says why.
60	    Rejected(String),
61	    /// Verified but not brain-only — the M5 apply surface refuses the
62	    /// broker-touching classes typed; the artifact stays staged.
63	    RefusedClass(UpdateClass),
64	    /// Swapped + handed off. The version is the staged release's.
65	    Applied { version: u64 },
66	}
67
68	/// Apply the staged release (module docs). `exe_path` is the executable to
69	/// swap (production: `std::env::current_exe()`); `broker_name` the canonical
70	/// broker socket the brain handoff runs against.
71	// [impl->REQ-UPD-3]
72	// [impl->REQ-UPD-4]
73	pub fn apply_staged(
74	    cache: &ReleaseCache,
75	    release_keys: &Path,
76	    exe_path: &Path,
77	    broker_name: &str,
78	) -> Result<ApplyStagedOutcome, String> {
79	    let Some(staged) = cache.staged_update() else {
80	        return Ok(ApplyStagedOutcome::NoUpdate);
81	    };
82	    let version = cache.staged_version().unwrap_or(0);
83
84	    // At-rest re-verification. Version floor 0: monotonicity already gated
85	    // the stage; this check is authenticity/integrity/expiry of the bytes on
86	    // disk. Fail-closed on an unprovisioned key file (same posture as the
87	    // pump's pull leg).
88	    let policy = VerifyPolicy::load_from(release_keys, 0, now_ms());
89	    if policy.trusted_keys.is_empty() {
90	        return Ok(ApplyStagedOutcome::Rejected(
91	            "release-keys.json unprovisioned (no trusted keys)".to_string(),
92	        ));
93	    }
94	    let (plan, artifact) = match staged {
95	        StagedUpdate::Single(signed) => {
96	            let artifact = cache
97	                .load_artifact()
98	                .map_err(|e| format!("staged artifact: {e}"))?;
99	            let plan = match plan_verified(&BrokerAbi::current(), &signed, &artifact, &policy) {
100	                Ok(p) => p,
101	                Err(reason) => return Ok(ApplyStagedOutcome::Rejected(format!("{reason:?}"))),
102	            };
103	            (plan, artifact)
104	        }
105	        StagedUpdate::Set(signed) => {
106	            let platform = current_platform();
107	            let artifact = cache
108	                .load_artifact_for_platform(platform)
109	                .map_err(|e| format!("staged artifact for {platform}: {e}"))?;
110	            let plan = match plan_verified_update_set(
111	                &BrokerAbi::current(),
112	                &signed,
113	                platform,
114	                &artifact,
115	                &policy,
116	            ) {
117	                Ok(p) => p,
118	                Err(reason) => return Ok(ApplyStagedOutcome::Rejected(format!("{reason:?}"))),
119	            };
120	            (plan, artifact)
121	        }
122	    };
123	    if plan.class != UpdateClass::BrainOnly {
124	        return Ok(ApplyStagedOutcome::RefusedClass(plan.class));
125	    }
126
127	    // The brain connects BEFORE the binary moves — a dead broker fails the
128	    // apply with the seat untouched.
129	    let brain = connect_retry(broker_name).map_err(|e| format!("broker connect: {e}"))?;
130
131	    // Swap the binary: step aside, land the artifact, roll back on failure.
132	    let aside = swap_aside_path(exe_path, version);
133	    let _ = std::fs::remove_file(&aside); // stale prior apply of this version
134	    std::fs::rename(exe_path, &aside).map_err(|e| format!("step current binary aside: {e}"))?;
135	    if let Err(e) = land_artifact(exe_path, &artifact) {
136	        let _ = std::fs::rename(&aside, exe_path); // roll back — never half-swapped
137	        return Err(format!("land new binary (rolled back): {e}"));
138	    }
139
140	    // The brain-only handoff against the live broker — REQ-UPD-3's moment. A
141	    // node with no broker-hosted sessions has nothing to hand off; the
142	    // invariant is vacuous there, and the next daemon start uses the swapped
143	    // executable.
144	    match brain {
145	        Some(brain) => {
146	            let name = broker_name.to_string();
147	            match apply_brain_only(&plan, brain, move |state| handoff_retry(&name, state)) {
148	                Ok(_new_brain) => Ok(ApplyStagedOutcome::Applied { version }),
149	                Err(e) => Err(format!(
150	                    "binary swapped to v{version}, but the brain re-attach failed: {e} — the broker \
151	                     and every hosted endpoint are intact; retry `spt update apply` or restart the brain"
152	                )),
153	            }
154	        }
155	        None => Ok(ApplyStagedOutcome::Applied { version }),
156	    }
157	}
158
159	/// Where the outgoing binary steps aside: a sibling `<file>.old-<version>`
160	/// (suffix appended, never `set_extension` — `spt.exe` must keep `.exe` out
161	/// of the aside name's way).
162	fn swap_aside_path(exe_path: &Path, version: u64) -> std::path::PathBuf {
163	    let name = exe_path
164	        .file_name()
165	        .and_then(|n| n.to_str())
166	        .unwrap_or("spt-binary");
167	    exe_path.with_file_name(format!("{name}.old-{version}"))
168	}
169
170	/// Land the artifact at the executable path (atomic temp→rename) and mark it
171	/// executable where the OS needs that.
172	fn land_artifact(exe_path: &Path, artifact: &[u8]) -> io::Result<()> {
173	    spt_store::atomic::atomic_write_bytes(exe_path, artifact)?;
174	    #[cfg(unix)]
175	    {
176	        use std::os::unix::fs::PermissionsExt;
177	        std::fs::set_permissions(exe_path, std::fs::Permissions::from_mode(0o755))?;
178	    }
179	    Ok(())
180	}
181
182	/// Connect a brain to the broker and attach it to an existing broker-held
183	/// session, retrying through startup races. The update engine's handoff
184	/// primitive snapshots a session-driving brain; this attaches as a logic
185	/// successor over an already-hosted session without spawning or terminating
186	/// anything.
187	fn connect_retry(broker_name: &str) -> io::Result<Option<Brain>> {
188	    let mut last = io::Error::other("broker never answered");
189	    for _ in 0..300 {
190	        match Brain::cold_start(broker_name, now_ms()) {
191	            Ok(mut b) => match b.sessions() {
192	                Ok(reply) => {
193	                    if let Some(session) = reply.sessions.first() {
194	                        if let Err(e) = b.attach(session.session_id, 0) {
195	                            last = e;
196	                        } else {
197	                            return Ok(Some(b));
198	                        }
199	                    } else {
200	                        return Ok(None);
201	                    }
202	                }
203	                Err(e) => last = e,
204	            },
205	            Err(e) => last = e,
206	        }
207	        std::thread::sleep(Duration::from_millis(10));
208	    }
209	    Err(last)
210	}
211
212	/// The relaunch closure's body: the new brain re-attaches over the existing
213	/// substrate, retrying the slot race (the broker may not have freed the
214	/// outgoing brain's seat the instant it dropped) — the `brain_swap.rs` shape.
215	fn handoff_retry(broker_name: &str, prior: BrainState) -> io::Result<Brain> {
216	    let mut last = None;
217	    for _ in 0..300 {
218	        match Brain::handoff(broker_name, prior.clone(), now_ms()) {
219	            Ok(b) => return Ok(b),
220	            Err(e) => last = Some(e),
221	        }
222	        std::thread::sleep(Duration::from_millis(10));
223	    }
224	    Err(last.unwrap_or_else(|| io::Error::other("relaunch never connected")))
225	}
226
227	#[cfg(test)]
228	mod tests {
229	    use super::*;
230	    use crate::broker::Broker;
231	    use crate::msg::SpawnReq;
232	    use crate::release::{
233	        current_platform, sha256_hex, ReleaseMetadata, SignedRelease, SignedUpdateSet,
234	        UpdateArtifactMetadata, UpdateSetMetadata,
235	    };
236	    use crate::update::BROKER_RESOURCE_ABI;
237	    use ed25519_dalek::{Signer, SigningKey};
238	    use std::sync::atomic::{AtomicU32, Ordering};
239	    use std::sync::Arc;
240
241	    static SEQ: AtomicU32 = AtomicU32::new(0);
242	    fn unique_name() -> String {
243	        let n = SEQ.fetch_add(1, Ordering::Relaxed);
244	        format!("spt-daemon-d7b-{}-{}.sock", std::process::id(), n)
245	    }
246
247	    fn served_broker(name: &str) -> Arc<Broker> {
248	        let broker = Broker::bind(name).expect("bind broker");
249	        let serve = Arc::clone(&broker);
250	        std::thread::spawn(move || {
251	            let _ = serve.serve();
252	        });
253	        broker
254	    }
255
256	    fn cold_connect_retry(broker_name: &str) -> Brain {
257	        for _ in 0..300 {
258	            if let Ok(b) = Brain::cold_start(broker_name, now_ms()) {
259	                return b;
260	            }
261	            std::thread::sleep(Duration::from_millis(10));
262	        }
263	        panic!("brain cold-start could not connect");
264	    }
265
266	    /// Stage a signed release + write the matching release-keys.json.
267	    /// `resource_abi` lets a test force the broker-breaking class.
268	    fn stage(
269	        dir: &Path,
270	        version: u64,
271	        artifact: &[u8],
272	        resource_abi: u32,
273	    ) -> (ReleaseCache, std::path::PathBuf) {
274	        let sk = SigningKey::from_bytes(&[9u8; 32]);
275	        let meta = ReleaseMetadata {
276	            version,
277	            channel: "stable".to_string(),
278	            expires_at_ms: u64::MAX,
279	            key_id: "k".to_string(),
280	            artifact_sha256: sha256_hex(artifact),
281	            brain_ipc_version: crate::IPC_PROTOCOL_VERSION,
282	            broker_resource_abi: resource_abi,
283	        };
284	        let metadata_json = serde_json::to_string(&meta).unwrap();
285	        let sig = sk.sign(metadata_json.as_bytes());
286	        let signed = SignedRelease {
287	            metadata_json,
288	            signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
289	        };
290	        let cache = ReleaseCache::open(&dir.join("releases"));
291	        cache.stage(&signed, artifact).expect("stage");
292
293	        let keys = dir.join("release-keys.json");
294	        let pub_hex: String = sk
295	            .verifying_key()
296	            .to_bytes()
297	            .iter()
298	            .map(|b| format!("{b:02x}"))
299	            .collect();
300	        std::fs::write(
301	            &keys,
302	            serde_json::json!({"keys": {"k": pub_hex}, "channel": "stable"}).to_string(),
303	        )
304	        .unwrap();
305	        (cache, keys)
306	    }
307
308	    /// Stage a signed update set + write matching release keys.
309	    fn stage_set(dir: &Path, version: u64, artifact: &[u8]) -> (ReleaseCache, std::path::PathBuf) {
310	        stage_set_with_resource_abi(dir, version, artifact, BROKER_RESOURCE_ABI)
311	    }
312
313	    fn stage_set_with_resource_abi(
314	        dir: &Path,
315	        version: u64,
316	        artifact: &[u8],
317	        resource_abi: u32,
318	    ) -> (ReleaseCache, std::path::PathBuf) {
319	        let sk = SigningKey::from_bytes(&[9u8; 32]);
320	        let meta = UpdateSetMetadata {
321	            version,
322	            channel: "stable".to_string(),
323	            expires_at_ms: u64::MAX,
324	            key_id: "k".to_string(),
325	            artifacts: std::collections::BTreeMap::from([(
326	                current_platform().to_string(),
327	                UpdateArtifactMetadata {
328	                    artifact_sha256: sha256_hex(artifact),
329	                    brain_ipc_version: crate::IPC_PROTOCOL_VERSION,
330	                    broker_resource_abi: resource_abi,
331	                    asset_name: None,
332	                },
333	            )]),
334	            provenance: None,
335	        };
336	        let metadata_json = serde_json::to_string(&meta).unwrap();
337	        let sig = sk.sign(metadata_json.as_bytes());
338	        let signed = SignedUpdateSet {
339	            metadata_json,
340	            signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
341	        };
342	        let cache = ReleaseCache::open(&dir.join("releases"));
343	        cache
344	            .stage_update_set(
345	                &signed,
346	                &std::collections::BTreeMap::from([(
347	                    current_platform().to_string(),
348	                    artifact.to_vec(),
349	                )]),
350	            )
351	            .expect("stage set");
352
353	        let keys = dir.join("release-keys.json");
354	        let pub_hex: String = sk
355	            .verifying_key()
356	            .to_bytes()
357	            .iter()
358	            .map(|b| format!("{b:02x}"))
359	            .collect();
360	        std::fs::write(
361	            &keys,
362	            serde_json::json!({"keys": {"k": pub_hex}, "channel": "stable"}).to_string(),
363	        )
364	        .unwrap();
365	        (cache, keys)
366	    }
367
368	    fn echo_spawn_req() -> SpawnReq {
369	        #[cfg(unix)]
370	        let (program, args) = ("cat".to_string(), Vec::<String>::new());
371	        #[cfg(windows)]
372	        let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
373	        SpawnReq {
374	            program,
375	            args,
376	            rows: 24,
377	            cols: 80,
378	            endpoint: String::new(),
379	        }
380	    }
381
382	    // [unit->REQ-UPD-4] the ack-driven apply end to end: a staged + verified
383	    // brain-only release swaps the binary (artifact lands at the exe path,
384	    // the original steps aside) and triggers the brain handoff with the
385	    // hosted endpoint untouched — the production caller the M4-D9 residual
386	    // promised. [unit->REQ-UPD-3]
387	    #[test]
388	    fn apply_staged_swaps_binary_and_hands_off_without_touching_the_endpoint() {
389	        let dir = tempfile::tempdir().unwrap();
390	        let name = unique_name();
391	        let broker = served_broker(&name);
392
393	        // A hosted endpoint that must survive the apply.
394	        let mut seat = cold_connect_retry(&name);
395	        let sid = seat.spawn_session(echo_spawn_req()).expect("spawn");
396	        let child_pid = broker.session_pid(sid);
397	        drop(seat); // the seat brain detaches; the broker keeps the child
398
399	        let exe = dir.path().join("spt-binary");
400	        std::fs::write(&exe, b"OLD BINARY").unwrap();
401	        let (cache, keys) = stage(dir.path(), 7, b"NEW BINARY BYTES", BROKER_RESOURCE_ABI);
402
403	        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
404	        assert!(
405	            matches!(out, ApplyStagedOutcome::Applied { version: 7 }),
406	            "got {out:?}"
407	        );
408	        assert_eq!(
409	            std::fs::read(&exe).unwrap(),
410	            b"NEW BINARY BYTES",
411	            "artifact landed"
412	        );
413	        assert_eq!(
414	            std::fs::read(swap_aside_path(&exe, 7)).unwrap(),
415	            b"OLD BINARY",
416	            "the outgoing binary stepped aside"
417	        );
418	        // REQ-UPD-3: the hosted child never noticed.
419	        assert_eq!(
420	            broker.session_count(),
421	            1,
422	            "exactly one session across the apply"
423	        );
424	        assert_eq!(broker.session_pid(sid), child_pid, "child pid unchanged");
425	    }
426
427	    // [unit->REQ-UPD-6] apply-time re-verification handles platform-targeted
428	    // update sets by selecting the local target triple's artifact before
429	    // swapping the executable.
430	    #[test]
431	    fn apply_staged_update_set_selects_local_platform_artifact() {
432	        let dir = tempfile::tempdir().unwrap();
433	        let name = unique_name();
434	        let broker = served_broker(&name);
435
436	        let mut seat = cold_connect_retry(&name);
437	        let sid = seat.spawn_session(echo_spawn_req()).expect("spawn");
438	        let child_pid = broker.session_pid(sid);
439	        drop(seat);
440
441	        let exe = dir.path().join("spt-binary");
442	        std::fs::write(&exe, b"OLD").unwrap();
443	        let (cache, keys) = stage_set(dir.path(), 11, b"SET BINARY BYTES");
444
445	        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
446	        assert!(
447	            matches!(out, ApplyStagedOutcome::Applied { version: 11 }),
448	            "got {out:?}"
449	        );
450	        assert_eq!(std::fs::read(&exe).unwrap(), b"SET BINARY BYTES");
451	        assert_eq!(broker.session_pid(sid), child_pid, "child pid unchanged");
452	    }
453
454	    // [unit->REQ-UPD-3] A node with no broker-hosted sessions still applies a
455	    // brain-only update: there is nothing to hand off, and no endpoint can be
456	    // terminated by the apply.
457	    #[test]
458	    fn apply_staged_without_broker_hosted_sessions_swaps_binary() {
459	        let dir = tempfile::tempdir().unwrap();
460	        let name = unique_name();
461	        let broker = served_broker(&name);
462
463	        let exe = dir.path().join("spt-binary");
464	        std::fs::write(&exe, b"OLD").unwrap();
465	        let (cache, keys) = stage(dir.path(), 13, b"NEW WITHOUT SESSIONS", BROKER_RESOURCE_ABI);
466
467	        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
468	        assert!(
469	            matches!(out, ApplyStagedOutcome::Applied { version: 13 }),
470	            "got {out:?}"
471	        );
472	        assert_eq!(std::fs::read(&exe).unwrap(), b"NEW WITHOUT SESSIONS");
473	        assert_eq!(broker.session_count(), 0);
474	    }
475
476	    // [unit->REQ-UPD-6] Broker-touching debug/update-set payloads are accepted
477	    // as staged candidates, but apply refuses them typed while the broker-held
478	    // runtime is live. The first broker-touching flow is refusal-only; it does
479	    // not auto-suspend or cycle hosted work.
480	    #[test]
481	    fn apply_staged_update_set_refuses_broker_touching_payloads() {
482	        let dir = tempfile::tempdir().unwrap();
483	        let name = unique_name();
484	        let _broker = served_broker(&name);
485
486	        let exe = dir.path().join("spt-binary");
487	        std::fs::write(&exe, b"OLD").unwrap();
488	        let (cache, keys) = stage_set_with_resource_abi(
489	            dir.path(),
490	            12,
491	            b"SET BROKER BYTES",
492	            BROKER_RESOURCE_ABI + 1,
493	        );
494
495	        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
496	        assert!(
497	            matches!(
498	                out,
499	                ApplyStagedOutcome::RefusedClass(UpdateClass::BrokerBreaking)
500	            ),
501	            "got {out:?}"
502	        );
503	        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");
504	        assert!(cache.staged_update().is_some(), "candidate stays staged");
505	    }
506
507	    // [unit->REQ-UPD-4] idempotent no-op and fail-closed gates: nothing
508	    // staged ⇒ NoUpdate; a non-brain-only class refuses typed with the seat
509	    // untouched; a tampered at-rest artifact rejects with the seat untouched.
510	    #[test]
511	    fn apply_staged_gates_fail_closed_with_the_seat_untouched() {
512	        let dir = tempfile::tempdir().unwrap();
513	        let name = unique_name();
514	        let _broker = served_broker(&name);
515	        let exe = dir.path().join("spt-binary");
516	        std::fs::write(&exe, b"OLD").unwrap();
517
518	        // Nothing staged.
519	        let empty = ReleaseCache::open(&dir.path().join("releases"));
520	        let keys = dir.path().join("release-keys.json");
521	        let out = apply_staged(&empty, &keys, &exe, &name).expect("ok");
522	        assert!(matches!(out, ApplyStagedOutcome::NoUpdate), "got {out:?}");
523
524	        // Broker-breaking class: typed refusal, artifact stays staged.
525	        let (cache, keys) = stage(dir.path(), 8, b"X", BROKER_RESOURCE_ABI + 1);
526	        let out = apply_staged(&cache, &keys, &exe, &name).expect("ok");
527	        assert!(
528	            matches!(
529	                out,
530	                ApplyStagedOutcome::RefusedClass(UpdateClass::BrokerBreaking)
531	            ),
532	            "got {out:?}"
533	        );
534	        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");
535	        assert!(cache.staged().is_some(), "artifact stays staged");
536
537	        // Tampered at rest: the apply-time re-verification rejects.
538	        let (cache, keys) = stage(dir.path(), 9, b"GOOD BYTES", BROKER_RESOURCE_ABI);
539	        std::fs::write(cache.artifact_path(), b"EVIL BYTES").unwrap();
540	        let out = apply_staged(&cache, &keys, &exe, &name).expect("ok");
541	        assert!(
542	            matches!(out, ApplyStagedOutcome::Rejected(_)),
543	            "got {out:?}"
544	        );
545	        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");
546
547	        // Unprovisioned keys: fail-closed.
548	        let (cache, _) = stage(dir.path(), 10, b"Y", BROKER_RESOURCE_ABI);
549	        let out =
550	            apply_staged(&cache, &dir.path().join("missing-keys.json"), &exe, &name).expect("ok");
551	        assert!(
552	            matches!(out, ApplyStagedOutcome::Rejected(_)),
553	            "got {out:?}"
554	        );
555	        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD");
556	    }
557	}
558

//! Staged-update apply orchestration (M5-D7b) — the **production caller** for
//! [`crate::update::apply_brain_only`], closing the M4-D9 honest residual
//! ("nothing in the daemon invokes this apply after the user acks the consent
//! notif").
//!
//! The ack IS the verb the consent notif names: its body says "confirm to
//! apply (`spt update apply`) or dismiss" (ADR-0007 — the harness's native
//! confirm affordance answers by running the command; dismiss latches the
//! notif and is a *prompt* answer, not a lock — an apply after a dismiss
//! still works, the user changed their mind).
//!
//! [`apply_staged`] drives the whole chain against what the peer pump left
//! behind (REQ-UPD-4: staged + consent-notified, never auto-applied):
//!
//! 1. **Re-verify at rest** (`plan_verified` under the production
//!    `release-keys.json` policy): apply never trusts the cache — a tampered
//!    or expired artifact refuses here. The monotonic-version floor was
//!    enforced at *stage* time (REQ-HAZARD-UPDATE-ROLLBACK); the apply-time
//!    re-check guards authenticity/integrity/expiry of the bytes on disk.
//! 2. **Classify** against the running broker's ABI. Only
//!    [`UpdateClass::BrainOnly`] applies in M5 — the broker-touching classes
//!    refuse typed, the artifact stays staged (their consent-and-scheduling
//!    orchestration is post-M5; ADR-0004 §A).
//! 3. **Swap the binary**: the current executable steps aside
//!    (`<exe>.old-<ver>` — a running image survives its own rename on both
//!    OSes) and the verified artifact lands at the executable path
//!    atomically. A failed landing rolls the original back — never a
//!    half-swapped seat.
//! 4. **Trigger the brain handoff** ([`apply_brain_only`]): snapshot → drop →
//!    re-attach against the live broker. No hosted endpoint terminates or
//!    suspends (REQ-UPD-3, absolute for this class). The relaunch closure
//!    re-attaches in-process over the existing substrate — the same
//!    documented stand-in `brain_swap.rs` proves the real handoff with; a
//!    freshly-exec'd brain of the new binary takes the identical path.
//!
//! The daemon's own resident loops keep running the old logic until its next
//! restart (`ensure_running` spawns the executable path — now the new
//! binary); that restart is routine lifecycle, never an endpoint cycle.
// [impl->REQ-UPD-3]
// [impl->REQ-UPD-4]

use std::io;
use std::path::Path;
use std::time::Duration;

use crate::brain::{now_ms, Brain, BrainState};
use crate::relcache::{ReleaseCache, StagedUpdate};
use crate::release::{current_platform, VerifyPolicy};
use crate::update::{
    apply_brain_only, plan_verified, plan_verified_update_set, BrokerAbi, UpdateClass,
};

/// What [`apply_staged`] did — loud + typed, never silent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApplyStagedOutcome {
    /// Nothing staged (or a torn stage that never offers) — idempotent no-op.
    NoUpdate,
    /// The at-rest re-verification refused (tamper / expiry / key trust) —
    /// nothing swapped, the reason says why.
    Rejected(String),
    /// Verified but not brain-only — the M5 apply surface refuses the
    /// broker-touching classes typed; the artifact stays staged.
    RefusedClass(UpdateClass),
    /// Swapped + handed off. The version is the staged release's.
    Applied { version: u64 },
}

/// Apply the staged release (module docs). `exe_path` is the executable to
/// swap (production: `std::env::current_exe()`); `broker_name` the canonical
/// broker socket the brain handoff runs against.
// [impl->REQ-UPD-3]
// [impl->REQ-UPD-4]
pub fn apply_staged(
    cache: &ReleaseCache,
    release_keys: &Path,
    exe_path: &Path,
    broker_name: &str,
) -> Result<ApplyStagedOutcome, String> {
    let Some(staged) = cache.staged_update() else {
        return Ok(ApplyStagedOutcome::NoUpdate);
    };
    let version = cache.staged_version().unwrap_or(0);

    // At-rest re-verification. Version floor 0: monotonicity already gated
    // the stage; this check is authenticity/integrity/expiry of the bytes on
    // disk. Fail-closed on an unprovisioned key file (same posture as the
    // pump's pull leg).
    let policy = VerifyPolicy::load_from(release_keys, 0, now_ms());
    if policy.trusted_keys.is_empty() {
        return Ok(ApplyStagedOutcome::Rejected(
            "release-keys.json unprovisioned (no trusted keys)".to_string(),
        ));
    }
    let (plan, artifact) = match staged {
        StagedUpdate::Single(signed) => {
            let artifact = cache
                .load_artifact()
                .map_err(|e| format!("staged artifact: {e}"))?;
            let plan = match plan_verified(&BrokerAbi::current(), &signed, &artifact, &policy) {
                Ok(p) => p,
                Err(reason) => return Ok(ApplyStagedOutcome::Rejected(format!("{reason:?}"))),
            };
            (plan, artifact)
        }
        StagedUpdate::Set(signed) => {
            let platform = current_platform();
            let artifact = cache
                .load_artifact_for_platform(platform)
                .map_err(|e| format!("staged artifact for {platform}: {e}"))?;
            let plan = match plan_verified_update_set(
                &BrokerAbi::current(),
                &signed,
                platform,
                &artifact,
                &policy,
            ) {
                Ok(p) => p,
                Err(reason) => return Ok(ApplyStagedOutcome::Rejected(format!("{reason:?}"))),
            };
            (plan, artifact)
        }
    };
    if plan.class != UpdateClass::BrainOnly {
        return Ok(ApplyStagedOutcome::RefusedClass(plan.class));
    }

    // The brain connects BEFORE the binary moves — a dead broker fails the
    // apply with the seat untouched.
    let brain = connect_retry(broker_name).map_err(|e| format!("broker connect: {e}"))?;

    // Swap the binary: step aside, land the artifact, roll back on failure.
    let aside = swap_aside_path(exe_path, version);
    let _ = std::fs::remove_file(&aside); // stale prior apply of this version
    std::fs::rename(exe_path, &aside).map_err(|e| format!("step current binary aside: {e}"))?;
    if let Err(e) = land_artifact(exe_path, &artifact) {
        let _ = std::fs::rename(&aside, exe_path); // roll back — never half-swapped
        return Err(format!("land new binary (rolled back): {e}"));
    }

    // The brain-only handoff against the live broker — REQ-UPD-3's moment. A
    // node with no broker-hosted sessions has nothing to hand off; the
    // invariant is vacuous there, and the next daemon start uses the swapped
    // executable.
    match brain {
        Some(brain) => {
            let name = broker_name.to_string();
            match apply_brain_only(&plan, brain, move |state| handoff_retry(&name, state)) {
                Ok(_new_brain) => Ok(ApplyStagedOutcome::Applied { version }),
                Err(e) => Err(format!(
                    "binary swapped to v{version}, but the brain re-attach failed: {e} — the broker \
                     and every hosted endpoint are intact; retry `spt update apply` or restart the brain"
                )),
            }
        }
        None => Ok(ApplyStagedOutcome::Applied { version }),
    }
}

/// Where the outgoing binary steps aside: a sibling `<file>.old-<version>`
/// (suffix appended, never `set_extension` — `spt.exe` must keep `.exe` out
/// of the aside name's way).
fn swap_aside_path(exe_path: &Path, version: u64) -> std::path::PathBuf {
    let name = exe_path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("spt-binary");
    exe_path.with_file_name(format!("{name}.old-{version}"))
}

/// Land the artifact at the executable path (atomic temp→rename) and mark it
/// executable where the OS needs that.
fn land_artifact(exe_path: &Path, artifact: &[u8]) -> io::Result<()> {
    spt_store::atomic::atomic_write_bytes(exe_path, artifact)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(exe_path, std::fs::Permissions::from_mode(0o755))?;
    }
    Ok(())
}

/// Connect a brain to the broker and attach it to an existing broker-held
/// session, retrying through startup races. The update engine's handoff
/// primitive snapshots a session-driving brain; this attaches as a logic
/// successor over an already-hosted session without spawning or terminating
/// anything.
fn connect_retry(broker_name: &str) -> io::Result<Option<Brain>> {
    let mut last = io::Error::other("broker never answered");
    for _ in 0..300 {
        match Brain::cold_start(broker_name, now_ms()) {
            Ok(mut b) => match b.sessions() {
                Ok(reply) => {
                    if let Some(session) = reply.sessions.first() {
                        if let Err(e) = b.attach(session.session_id, 0) {
                            last = e;
                        } else {
                            return Ok(Some(b));
                        }
                    } else {
                        return Ok(None);
                    }
                }
                Err(e) => last = e,
            },
            Err(e) => last = e,
        }
        std::thread::sleep(Duration::from_millis(10));
    }
    Err(last)
}

/// The relaunch closure's body: the new brain re-attaches over the existing
/// substrate, retrying the slot race (the broker may not have freed the
/// outgoing brain's seat the instant it dropped) — the `brain_swap.rs` shape.
fn handoff_retry(broker_name: &str, prior: BrainState) -> io::Result<Brain> {
    let mut last = None;
    for _ in 0..300 {
        match Brain::handoff(broker_name, prior.clone(), now_ms()) {
            Ok(b) => return Ok(b),
            Err(e) => last = Some(e),
        }
        std::thread::sleep(Duration::from_millis(10));
    }
    Err(last.unwrap_or_else(|| io::Error::other("relaunch never connected")))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::broker::Broker;
    use crate::msg::SpawnReq;
    use crate::release::{
        current_platform, sha256_hex, ReleaseMetadata, SignedRelease, SignedUpdateSet,
        UpdateArtifactMetadata, UpdateSetMetadata,
    };
    use crate::update::BROKER_RESOURCE_ABI;
    use ed25519_dalek::{Signer, SigningKey};
    use std::sync::atomic::{AtomicU32, Ordering};
    use std::sync::Arc;

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

    fn served_broker(name: &str) -> Arc<Broker> {
        let broker = Broker::bind(name).expect("bind broker");
        let serve = Arc::clone(&broker);
        std::thread::spawn(move || {
            let _ = serve.serve();
        });
        broker
    }

    fn cold_connect_retry(broker_name: &str) -> Brain {
        for _ in 0..300 {
            if let Ok(b) = Brain::cold_start(broker_name, now_ms()) {
                return b;
            }
            std::thread::sleep(Duration::from_millis(10));
        }
        panic!("brain cold-start could not connect");
    }

    /// Stage a signed release + write the matching release-keys.json.
    /// `resource_abi` lets a test force the broker-breaking class.
    fn stage(
        dir: &Path,
        version: u64,
        artifact: &[u8],
        resource_abi: u32,
    ) -> (ReleaseCache, std::path::PathBuf) {
        let sk = SigningKey::from_bytes(&[9u8; 32]);
        let meta = ReleaseMetadata {
            version,
            channel: "stable".to_string(),
            expires_at_ms: u64::MAX,
            key_id: "k".to_string(),
            artifact_sha256: sha256_hex(artifact),
            brain_ipc_version: crate::IPC_PROTOCOL_VERSION,
            broker_resource_abi: resource_abi,
        };
        let metadata_json = serde_json::to_string(&meta).unwrap();
        let sig = sk.sign(metadata_json.as_bytes());
        let signed = SignedRelease {
            metadata_json,
            signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
        };
        let cache = ReleaseCache::open(&dir.join("releases"));
        cache.stage(&signed, artifact).expect("stage");

        let keys = dir.join("release-keys.json");
        let pub_hex: String = sk
            .verifying_key()
            .to_bytes()
            .iter()
            .map(|b| format!("{b:02x}"))
            .collect();
        std::fs::write(
            &keys,
            serde_json::json!({"keys": {"k": pub_hex}, "channel": "stable"}).to_string(),
        )
        .unwrap();
        (cache, keys)
    }

    /// Stage a signed update set + write matching release keys.
    fn stage_set(dir: &Path, version: u64, artifact: &[u8]) -> (ReleaseCache, std::path::PathBuf) {
        stage_set_with_resource_abi(dir, version, artifact, BROKER_RESOURCE_ABI)
    }

    fn stage_set_with_resource_abi(
        dir: &Path,
        version: u64,
        artifact: &[u8],
        resource_abi: u32,
    ) -> (ReleaseCache, std::path::PathBuf) {
        let sk = SigningKey::from_bytes(&[9u8; 32]);
        let meta = UpdateSetMetadata {
            version,
            channel: "stable".to_string(),
            expires_at_ms: u64::MAX,
            key_id: "k".to_string(),
            artifacts: std::collections::BTreeMap::from([(
                current_platform().to_string(),
                UpdateArtifactMetadata {
                    artifact_sha256: sha256_hex(artifact),
                    brain_ipc_version: crate::IPC_PROTOCOL_VERSION,
                    broker_resource_abi: resource_abi,
                    asset_name: None,
                },
            )]),
            provenance: None,
        };
        let metadata_json = serde_json::to_string(&meta).unwrap();
        let sig = sk.sign(metadata_json.as_bytes());
        let signed = SignedUpdateSet {
            metadata_json,
            signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
        };
        let cache = ReleaseCache::open(&dir.join("releases"));
        cache
            .stage_update_set(
                &signed,
                &std::collections::BTreeMap::from([(
                    current_platform().to_string(),
                    artifact.to_vec(),
                )]),
            )
            .expect("stage set");

        let keys = dir.join("release-keys.json");
        let pub_hex: String = sk
            .verifying_key()
            .to_bytes()
            .iter()
            .map(|b| format!("{b:02x}"))
            .collect();
        std::fs::write(
            &keys,
            serde_json::json!({"keys": {"k": pub_hex}, "channel": "stable"}).to_string(),
        )
        .unwrap();
        (cache, keys)
    }

    fn echo_spawn_req() -> 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: String::new(),
        }
    }

    // [unit->REQ-UPD-4] the ack-driven apply end to end: a staged + verified
    // brain-only release swaps the binary (artifact lands at the exe path,
    // the original steps aside) and triggers the brain handoff with the
    // hosted endpoint untouched — the production caller the M4-D9 residual
    // promised. [unit->REQ-UPD-3]
    #[test]
    fn apply_staged_swaps_binary_and_hands_off_without_touching_the_endpoint() {
        let dir = tempfile::tempdir().unwrap();
        let name = unique_name();
        let broker = served_broker(&name);

        // A hosted endpoint that must survive the apply.
        let mut seat = cold_connect_retry(&name);
        let sid = seat.spawn_session(echo_spawn_req()).expect("spawn");
        let child_pid = broker.session_pid(sid);
        drop(seat); // the seat brain detaches; the broker keeps the child

        let exe = dir.path().join("spt-binary");
        std::fs::write(&exe, b"OLD BINARY").unwrap();
        let (cache, keys) = stage(dir.path(), 7, b"NEW BINARY BYTES", BROKER_RESOURCE_ABI);

        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
        assert!(
            matches!(out, ApplyStagedOutcome::Applied { version: 7 }),
            "got {out:?}"
        );
        assert_eq!(
            std::fs::read(&exe).unwrap(),
            b"NEW BINARY BYTES",
            "artifact landed"
        );
        assert_eq!(
            std::fs::read(swap_aside_path(&exe, 7)).unwrap(),
            b"OLD BINARY",
            "the outgoing binary stepped aside"
        );
        // REQ-UPD-3: the hosted child never noticed.
        assert_eq!(
            broker.session_count(),
            1,
            "exactly one session across the apply"
        );
        assert_eq!(broker.session_pid(sid), child_pid, "child pid unchanged");
    }

    // [unit->REQ-UPD-6] apply-time re-verification handles platform-targeted
    // update sets by selecting the local target triple's artifact before
    // swapping the executable.
    #[test]
    fn apply_staged_update_set_selects_local_platform_artifact() {
        let dir = tempfile::tempdir().unwrap();
        let name = unique_name();
        let broker = served_broker(&name);

        let mut seat = cold_connect_retry(&name);
        let sid = seat.spawn_session(echo_spawn_req()).expect("spawn");
        let child_pid = broker.session_pid(sid);
        drop(seat);

        let exe = dir.path().join("spt-binary");
        std::fs::write(&exe, b"OLD").unwrap();
        let (cache, keys) = stage_set(dir.path(), 11, b"SET BINARY BYTES");

        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
        assert!(
            matches!(out, ApplyStagedOutcome::Applied { version: 11 }),
            "got {out:?}"
        );
        assert_eq!(std::fs::read(&exe).unwrap(), b"SET BINARY BYTES");
        assert_eq!(broker.session_pid(sid), child_pid, "child pid unchanged");
    }

    // [unit->REQ-UPD-3] A node with no broker-hosted sessions still applies a
    // brain-only update: there is nothing to hand off, and no endpoint can be
    // terminated by the apply.
    #[test]
    fn apply_staged_without_broker_hosted_sessions_swaps_binary() {
        let dir = tempfile::tempdir().unwrap();
        let name = unique_name();
        let broker = served_broker(&name);

        let exe = dir.path().join("spt-binary");
        std::fs::write(&exe, b"OLD").unwrap();
        let (cache, keys) = stage(dir.path(), 13, b"NEW WITHOUT SESSIONS", BROKER_RESOURCE_ABI);

        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
        assert!(
            matches!(out, ApplyStagedOutcome::Applied { version: 13 }),
            "got {out:?}"
        );
        assert_eq!(std::fs::read(&exe).unwrap(), b"NEW WITHOUT SESSIONS");
        assert_eq!(broker.session_count(), 0);
    }

    // [unit->REQ-UPD-6] Broker-touching debug/update-set payloads are accepted
    // as staged candidates, but apply refuses them typed while the broker-held
    // runtime is live. The first broker-touching flow is refusal-only; it does
    // not auto-suspend or cycle hosted work.
    #[test]
    fn apply_staged_update_set_refuses_broker_touching_payloads() {
        let dir = tempfile::tempdir().unwrap();
        let name = unique_name();
        let _broker = served_broker(&name);

        let exe = dir.path().join("spt-binary");
        std::fs::write(&exe, b"OLD").unwrap();
        let (cache, keys) = stage_set_with_resource_abi(
            dir.path(),
            12,
            b"SET BROKER BYTES",
            BROKER_RESOURCE_ABI + 1,
        );

        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
        assert!(
            matches!(
                out,
                ApplyStagedOutcome::RefusedClass(UpdateClass::BrokerBreaking)
            ),
            "got {out:?}"
        );
        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");
        assert!(cache.staged_update().is_some(), "candidate stays staged");
    }

    // [unit->REQ-UPD-4] idempotent no-op and fail-closed gates: nothing
    // staged ⇒ NoUpdate; a non-brain-only class refuses typed with the seat
    // untouched; a tampered at-rest artifact rejects with the seat untouched.
    #[test]
    fn apply_staged_gates_fail_closed_with_the_seat_untouched() {
        let dir = tempfile::tempdir().unwrap();
        let name = unique_name();
        let _broker = served_broker(&name);
        let exe = dir.path().join("spt-binary");
        std::fs::write(&exe, b"OLD").unwrap();

        // Nothing staged.
        let empty = ReleaseCache::open(&dir.path().join("releases"));
        let keys = dir.path().join("release-keys.json");
        let out = apply_staged(&empty, &keys, &exe, &name).expect("ok");
        assert!(matches!(out, ApplyStagedOutcome::NoUpdate), "got {out:?}");

        // Broker-breaking class: typed refusal, artifact stays staged.
        let (cache, keys) = stage(dir.path(), 8, b"X", BROKER_RESOURCE_ABI + 1);
        let out = apply_staged(&cache, &keys, &exe, &name).expect("ok");
        assert!(
            matches!(
                out,
                ApplyStagedOutcome::RefusedClass(UpdateClass::BrokerBreaking)
            ),
            "got {out:?}"
        );
        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");
        assert!(cache.staged().is_some(), "artifact stays staged");

        // Tampered at rest: the apply-time re-verification rejects.
        let (cache, keys) = stage(dir.path(), 9, b"GOOD BYTES", BROKER_RESOURCE_ABI);
        std::fs::write(cache.artifact_path(), b"EVIL BYTES").unwrap();
        let out = apply_staged(&cache, &keys, &exe, &name).expect("ok");
        assert!(
            matches!(out, ApplyStagedOutcome::Rejected(_)),
            "got {out:?}"
        );
        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");

        // Unprovisioned keys: fail-closed.
        let (cache, _) = stage(dir.path(), 10, b"Y", BROKER_RESOURCE_ABI);
        let out =
            apply_staged(&cache, &dir.path().join("missing-keys.json"), &exe, &name).expect("ok");
        assert!(
            matches!(out, ApplyStagedOutcome::Rejected(_)),
            "got {out:?}"
        );
        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD");
    }
}


/diagnose the pc crashed. before it died, you were in the middle of debugging an issue where every 60 seconds, a running spt daemon spawned 2 empty windows in short succession. each window only lasted…


`applyhost.rs` is the REQ-UPD-6 update-set apply work (debug-rollout feature). Most-recently-touched but that's the *feature* workstream. The windows bug ties to the **60s sync pull pump** + the `b90820c` "CREATE_NO_WINDOW" fix. Let me see exactly what `b90820c` changed and find spawn sites it may have MISSED.


Bash

cd C:/Users/decid/Documents/projects/spt-core && echo "=== b90820c commit ===" && git show b90820c --stat | head -30 && echo "=== FULL DIFF ===" && git show b90820c 2>/dev/null | grep -v "LF will be replaced" | head -200

Show the CREATE_NO_WINDOW fix commit


=== b90820c commit ===
commit b90820c89bb0b75399dc817632d230d8e8c143dc
Author: Reavo End <decidel@gmail.com>
Date:   Sat Jun 6 20:15:24 2026 -0700

    fix(store,daemon,runtime): CREATE_NO_WINDOW on console children of the daemon

    Field report (2026-06-06): two blank windows flashed on the desktop
    every 60 seconds. A process-spawn watcher caught the culprit pair —
    the sync pump's git calls (for-each-ref + rev-parse against
    context\.seed.git), spawned by the console-less DETACHED daemon. A
    console-subsystem child of a console-less parent allocates a fresh
    conhost with a VISIBLE window; piped/null stdio does not prevent it.

    Confirmed hypothesis: spt-store::gitrun::run_git set no
    creation_flags, so every BranchStore/ContextStore git call flashed a
    window when invoked from the daemon. Same class, fixed in the same
    sweep: shellhost::kill_shell_pid (taskkill on teardown) and
    spt-runtime::run_bounded_command (manifest hook commands). shellwake
    already carried the flag — the lesson existed in-repo but was not yet
    an invariant.

    Recorded as KNOWN-HAZARDS 5.8 / REQ-HAZARD-CHILD-CONSOLE-FLASH.
    Test-seam caveat documented: window-absence is unobservable from a
    consoled test runner (the child inherits the runner's console), so
    unit coverage asserts the flagged spawn works (the error-87 flag-combo
    regression class, cf. c7e7d9e); window-absence verified live by
    watcher before/after.

    Touched files include pre-existing rustfmt rewrap churn that was
    already in the working tree.
=== FULL DIFF ===
commit b90820c89bb0b75399dc817632d230d8e8c143dc
Author: Reavo End <decidel@gmail.com>
Date:   Sat Jun 6 20:15:24 2026 -0700

    fix(store,daemon,runtime): CREATE_NO_WINDOW on console children of the daemon

    Field report (2026-06-06): two blank windows flashed on the desktop
    every 60 seconds. A process-spawn watcher caught the culprit pair —
    the sync pump's git calls (for-each-ref + rev-parse against
    context\.seed.git), spawned by the console-less DETACHED daemon. A
    console-subsystem child of a console-less parent allocates a fresh
    conhost with a VISIBLE window; piped/null stdio does not prevent it.

    Confirmed hypothesis: spt-store::gitrun::run_git set no
    creation_flags, so every BranchStore/ContextStore git call flashed a
    window when invoked from the daemon. Same class, fixed in the same
    sweep: shellhost::kill_shell_pid (taskkill on teardown) and
    spt-runtime::run_bounded_command (manifest hook commands). shellwake
    already carried the flag — the lesson existed in-repo but was not yet
    an invariant.

    Recorded as KNOWN-HAZARDS 5.8 / REQ-HAZARD-CHILD-CONSOLE-FLASH.
    Test-seam caveat documented: window-absence is unobservable from a
    consoled test runner (the child inherits the runner's console), so
    unit coverage asserts the flagged spawn works (the error-87 flag-combo
    regression class, cf. c7e7d9e); window-absence verified live by
    watcher before/after.

    Touched files include pre-existing rustfmt rewrap churn that was
    already in the working tree.

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

diff --git a/crates/spt-daemon/src/shellhost.rs b/crates/spt-daemon/src/shellhost.rs
index 8df28cb..c0f56fe 100644
--- a/crates/spt-daemon/src/shellhost.rs
+++ b/crates/spt-daemon/src/shellhost.rs
@@ -77,7 +77,11 @@ pub fn link_key(token: &str) -> [u8; 32] {
 pub fn frame_mac(key: &[u8; 32], frame: &[u8]) -> String {
     let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("hmac accepts any key len");
     mac.update(frame);
-    mac.finalize().into_bytes().iter().map(|b| format!("{b:02x}")).collect()
+    mac.finalize()
+        .into_bytes()
+        .iter()
+        .map(|b| format!("{b:02x}"))
+        .collect()
 }

 /// Verify a frame's MAC (constant-time inner compare via the hmac crate).
@@ -125,7 +129,8 @@ fn fill_spawn_command(
         ("adapter_name".to_string(), adapter_name.to_string()),
         ("link_token".to_string(), token),
     ]);
-    let filled = spt_runtime::runtime::fill_template(&shell.spawn, &keys).map_err(|e| e.to_string())?;
+    let filled =
+        spt_runtime::runtime::fill_template(&shell.spawn, &keys).map_err(|e| e.to_string())?;
     let tokens = spt_runtime::runtime::tokenize(&filled);
     if tokens.is_empty() {
         return Err("empty spawn command".into());
@@ -154,7 +159,7 @@ pub fn launch_shell(
         Some(CommandReceipt::Http) => Err(
             "command_receipt=http is not implemented yet — spawn refused (an \
              unimplemented mode never falls back silently)"
-            .into(),
+                .into(),
         ),
         Some(CommandReceipt::Stdin) => {
             crate::ensure_running().map_err(|e| format!("daemon start: {e}"))?;
@@ -291,11 +296,18 @@ pub fn kill_shell_pid(pid: u32) {
         return;
     }
     #[cfg(windows)]
-    let _ = Command::new("taskkill")
-        .args(["/PID", &pid.to_string(), "/T", "/F"])
-        .stdout(Stdio::null())
-        .stderr(Stdio::null())
-        .status();
+    {
+        // taskkill is a console child of the console-less daemon: spawn it
+        // windowless or teardown flashes a blank conhost (KNOWN-HAZARDS 5.8).
+        // [impl->REQ-HAZARD-CHILD-CONSOLE-FLASH]
+        use std::os::windows::process::CommandExt;
+        let _ = Command::new("taskkill")
+            .args(["/PID", &pid.to_string(), "/T", "/F"])
+            .stdout(Stdio::null())
+            .stderr(Stdio::null())
+            .creation_flags(0x0800_0000) // CREATE_NO_WINDOW
+            .status();
+    }
     #[cfg(unix)]
     let _ = Command::new("kill")
         .args(["-9", &pid.to_string()])
@@ -381,9 +393,8 @@ pub fn close_shell(
             }
         }
         // 2. The termination window: a graceful exit beats the force-kill.
-        let window = std::time::Duration::from_millis(
-            shell.and_then(|s| s.close_timeout_ms).unwrap_or(0),
-        );
+        let window =
+            std::time::Duration::from_millis(shell.and_then(|s| s.close_timeout_ms).unwrap_or(0));
         let deadline = std::time::Instant::now() + window;
         while std::time::Instant::now() < deadline {
             if !pid.is_some_and(spt_store::proc::is_process_alive) {
@@ -493,7 +504,10 @@ mod tests {
             "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1\"\n\
              min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn}'\n{extra}\n"
         );
-        spt_runtime::Manifest::from_toml_str(&toml_src).unwrap().shell.unwrap()
+        spt_runtime::Manifest::from_toml_str(&toml_src)
+            .unwrap()
+            .shell
+            .unwrap()
     }

     /// A cross-platform spawnable no-op command.
@@ -536,7 +550,10 @@ mod tests {

         // The pure lookup resolves "owner from the link" WITHOUT onlining —
         // the machinery calls (emit/poll) share it; only bind flips status.
-        assert_eq!(find_shell_by_token(owlery, &ta), Some(("doyle".into(), a.clone())));
+        assert_eq!(
+            find_shell_by_token(owlery, &ta),
+            Some(("doyle".into(), a.clone()))
+        );
         assert_eq!(
             spt_store::shellinfo::read_shell_info(&pa).unwrap().status,
             SHELL_STATUS_OFFLINE,
@@ -544,11 +561,17 @@ mod tests {
         );

         // Wrong / empty tokens bind nothing.
-        assert_eq!(bind_shell_by_token(owlery, "00000000000000000000000000000000"), None);
+        assert_eq!(
+            bind_shell_by_token(owlery, "00000000000000000000000000000000"),
+            None
+        );
         assert_eq!(bind_shell_by_token(owlery, ""), None);

         // The right token binds exactly its instance.
-        assert_eq!(bind_shell_by_token(owlery, &ta), Some(("doyle".into(), a.clone())));
+        assert_eq!(
+            bind_shell_by_token(owlery, &ta),
+            Some(("doyle".into(), a.clone()))
+        );
         assert_eq!(
             spt_store::shellinfo::read_shell_info(&pa).unwrap().status,
             SHELL_STATUS_ONLINE
@@ -576,9 +599,11 @@ mod tests {
         let id = spawn_record(owlery, "doyle", "mock-shell", None).unwrap();

         let unknown_key = shell_section("prog --x {not_a_key}");
-        assert!(launch_shell(owlery, "doyle", &id, "mock-shell", &unknown_key)
-            .unwrap_err()
-            .contains("not_a_key"));
+        assert!(
+            launch_shell(owlery, "doyle", &id, "mock-shell", &unknown_key)
+                .unwrap_err()
+                .contains("not_a_key")
+        );

         let unspawnable = shell_section("definitely-not-a-real-program-xyz --link {link_token}");
         assert!(launch_shell(owlery, "doyle", &id, "mock-shell", &unspawnable).is_err());
@@ -608,7 +633,10 @@ mod tests {
         let owlery = tmp.path();
         seed_owner(owlery, "doyle");
         let id = spawn_record(owlery, "doyle", "mock-shell", None).unwrap();
-        let shell = shell_section_with(SLEEPER, "pre_close = 'park-and-save'\nclose_timeout_ms = 100");
+        let shell = shell_section_with(
+            SLEEPER,
+            "pre_close = 'park-and-save'\nclose_timeout_ms = 100",
+        );
         let pid = launch_shell(owlery, "doyle", &id, "mock-shell", &shell).unwrap();
         assert!(spt_store::proc::is_process_alive(pid), "sleeper is up");

@@ -617,7 +645,10 @@ mod tests {

         let outcome = close_shell(owlery, "doyle", &id, Some(&shell)).unwrap();
         assert_eq!(outcome, CloseOutcome::Offline);
-        assert!(!spt_store::proc::is_process_alive(pid), "the binary never survives a link-break");
+        assert!(
+            !spt_store::proc::is_process_alive(pid),
+            "the binary never survives a link-break"
+        );

         // The pre-close frame was spooled (stamped with the link key) before
         // the kill — the relay drain would have offered it during the window.
@@ -634,7 +665,10 @@ mod tests {
         assert!(read_link_token(&perch).is_none(), "stale token retired");
         assert!(!perch.join(SHELL_PID_FILE).exists(), "pid file retired");
         let info = shellinfo::read_shell_info(&perch).unwrap();
-        assert_eq!(info.status, SHELL_STATUS_OFFLINE, "persistent-side divergence: offline");
+        assert_eq!(
+            info.status, SHELL_STATUS_OFFLINE,
+            "persistent-side divergence: offline"
+        );
     }

     // [unit->REQ-SHELL-2] the divergence, ephemeral side: close IS teardown —


Found it. `b90820c` commit message has the **confirmed root cause**: the 60s sync pump's two git calls (`for-each-ref` + `rev-parse` against `context\.seed.git`) via `spt-store::gitrun::run_git` — set no `creation_flags`, so each flashed a conhost window. Fixed there + `taskkill` + `run_bounded_command`. Claims "verified live by watcher."

But the fix is committed yet I was *still* debugging at crash — so either residual windows persist (a different spawn pair the sweep missed) or it just needs re-verification. Let me see the rest of the commit landed correctly, then sweep for *any* daemon-reachable spawn site still missing the flag.


cd C:/Users/decid/Documents/projects/spt-core && echo "=== b90820c: gitrun + runtime parts ===" && git show b90820c -- '*gitrun*' '*spt-runtime*' '*spt-store*' 2>/dev/null | grep -v "LF will be replaced" | grep -A6 -B2 "creation_flags\|CREATE_NO_WINDOW\|diff --git" | head -120

Show gitrun + runtime parts of the fix commit


=== b90820c: gitrun + runtime parts ===
Date:   Sat Jun 6 20:15:24 2026 -0700

    fix(store,daemon,runtime): CREATE_NO_WINDOW on console children of the daemon

    Field report (2026-06-06): two blank windows flashed on the desktop
    every 60 seconds. A process-spawn watcher caught the culprit pair —
    the sync pump's git calls (for-each-ref + rev-parse against
    context\.seed.git), spawned by the console-less DETACHED daemon. A
    console-subsystem child of a console-less parent allocates a fresh
--

    Confirmed hypothesis: spt-store::gitrun::run_git set no
    creation_flags, so every BranchStore/ContextStore git call flashed a
    window when invoked from the daemon. Same class, fixed in the same
    sweep: shellhost::kill_shell_pid (taskkill on teardown) and
    spt-runtime::run_bounded_command (manifest hook commands). shellwake
    already carried the flag — the lesson existed in-repo but was not yet
    an invariant.

--
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

diff --git a/crates/spt-runtime/src/runtime.rs b/crates/spt-runtime/src/runtime.rs
index 2abdfc6..1c52b6b 100644
--- a/crates/spt-runtime/src/runtime.rs
+++ b/crates/spt-runtime/src/runtime.rs
@@ -67,13 +67,19 @@ impl std::fmt::Display for RuntimeError {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
--
         .stderr(Stdio::piped());
+    // Bounded commands run from the console-less daemon (notif pump, manifest
+    // hooks): without CREATE_NO_WINDOW each spawn flashes a visible blank
+    // conhost window on the desktop (KNOWN-HAZARDS 5.8).
+    // [impl->REQ-HAZARD-CHILD-CONSOLE-FLASH]
+    #[cfg(windows)]
+    {
+        use std::os::windows::process::CommandExt;
+        cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
+    }
     let mut child = cmd.spawn().map_err(RuntimeError::Spawn)?;
     if let Some(bytes) = stdin {
         if let Some(mut sink) = child.stdin.take() {
@@ -391,7 +414,10 @@ mod tests {
     use super::*;
--

     #[test] // [unit->REQ-SEAM-SPAWN]
diff --git a/crates/spt-store/src/gitrun.rs b/crates/spt-store/src/gitrun.rs
index 3cef59f..cb57a9b 100644
--- a/crates/spt-store/src/gitrun.rs
+++ b/crates/spt-store/src/gitrun.rs
@@ -61,12 +61,25 @@ pub fn run_git(
         .env("GIT_COMMITTER_NAME", "spt-core")
         .env("GIT_COMMITTER_EMAIL", "spt-core@local")
--
         cmd.current_dir(dir);
     }
+    // The daemon is a console-less DETACHED process: without CREATE_NO_WINDOW
+    // every git child pops a visible blank conhost window on the desktop
+    // (KNOWN-HAZARDS 5.8 — the 60s sync pump flashed two windows per minute).
+    // [impl->REQ-HAZARD-CHILD-CONSOLE-FLASH]
+    #[cfg(windows)]
+    {
+        use std::os::windows::process::CommandExt;
+        cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
+    }
     let mut child = cmd.spawn()?;

     if let Some(bytes) = stdin {
@@ -108,7 +121,10 @@ pub fn run_git(
                     let _ = child.wait();
--

     #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT] a completed git call reports output.
+    // [unit->REQ-HAZARD-CHILD-CONSOLE-FLASH] the CREATE_NO_WINDOW spawn still
+    // works end-to-end (window-absence itself is unobservable from a consoled
+    // test runner — see KNOWN-HAZARDS 5.8; the live watcher verified that half).
     fn version_runs_within_budget() {
         let out = run_git(&["--version"], None, None, GIT_TIMEOUT).expect("git on PATH");
         assert!(out.success());


Fix landed correctly in `gitrun.rs` + `runtime.rs` (and `taskkill`). The known culprit pair is covered. Now the diagnostic question for "where I left off": is the sweep **complete**, or does a residual daemon-reachable spawn site still flash a window? That's a deterministic static check — my feedback loop. Sweep every spawn site for a missing `creation_flags`.


cd C:/Users/decid/Documents/projects/spt-core && echo "=== ALL Command::new / CreateProcess spawn sites (src only, excl tests) ===" && grep -rn "Command::new\|CreateProcessW\|CreateProcessWithTokenW\|\.spawn()\|\.status()\|\.output()" crates/*/src adapters/*/src --include=*.rs | grep -v "test\|#\[cfg(test)\]" | grep -iv "mock" | head -60

Enumerate all process-spawn sites in src


=== ALL Command::new / CreateProcess spawn sites (src only, excl tests) ===
crates/spt-daemon/src/daemon.rs:326:        let mut cmd = Command::new(exe);
crates/spt-daemon/src/daemon.rs:353:        cmd.spawn()?;
crates/spt-daemon/src/daemon.rs:365:/// the shell binary ([`crate::shellhost::launch_shell`]). Raw `CreateProcessW`
crates/spt-daemon/src/daemon.rs:401:        fn CreateProcessW(
crates/spt-daemon/src/daemon.rs:433:        CreateProcessW(
crates/spt-daemon/src/deelevate.rs:272:        fn CreateProcessWithTokenW(
crates/spt-daemon/src/deelevate.rs:291:    // The target user's environment block — CreateProcessWithTokenW with a
crates/spt-daemon/src/deelevate.rs:315:    // CreateProcessWithTokenW accepts only a RESTRICTED creation-flag set —
crates/spt-daemon/src/deelevate.rs:358:    /// `CreateProcessWithTokenW` fails `ERROR_BAD_IMPERSONATION_LEVEL`
crates/spt-daemon/src/deelevate.rs:373:    /// KH 5.6 holds by construction: `CreateProcessWithTokenW` never
crates/spt-daemon/src/deelevate.rs:458:    /// `CreateProcessWithTokenW` under the same flags as
crates/spt-daemon/src/deelevate.rs:488:            CreateProcessWithTokenW(
crates/spt-daemon/src/shellhost.rs:188:            let pid = Command::new(program)
crates/spt-daemon/src/shellhost.rs:193:                .spawn()
crates/spt-daemon/src/shellhost.rs:304:        let _ = Command::new("taskkill")
crates/spt-daemon/src/shellhost.rs:309:            .status();
crates/spt-daemon/src/shellhost.rs:312:    let _ = Command::new("kill")
crates/spt-daemon/src/shellhost.rs:316:        .status();
crates/spt-daemon/src/shellwake.rs:194:        let mut cmd = Command::new(program);
crates/spt-daemon/src/shellwake.rs:208:        let child = cmd.spawn();
crates/spt-runtime/src/runtime.rs:240:        let mut cmd = Command::new(program);
crates/spt-runtime/src/runtime.rs:268:        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;
crates/spt-runtime/src/runtime.rs:289:        let mut child = cmd.spawn().map_err(RuntimeError::Spawn)?;
crates/spt-runtime/src/runtime.rs:381:    let mut cmd = Command::new(program);
crates/spt-runtime/src/runtime.rs:399:    let mut child = cmd.spawn().map_err(RuntimeError::Spawn)?;
crates/spt-store/src/gitrun.rs:55:    let mut cmd = Command::new("git");
crates/spt-store/src/gitrun.rs:83:    let mut child = cmd.spawn()?;
crates/spt-store/src/proc.rs:186:        let child = std::process::Command::new("true").spawn().unwrap();
crates/xtask/src/main.rs:65:    let mut cmd = Command::new(bin);
crates/xtask/src/main.rs:67:    let out = cmd.output().expect("run spt --help");
crates/xtask/src/main.rs:149:    let status = Command::new("cargo")
crates/xtask/src/main.rs:152:        .status()
crates/xtask/src/main.rs:244:    let status = cmd.status().unwrap_or_else(|e| panic!("{what}: {e}"));
crates/xtask/src/main.rs:269:        Command::new("mdbook")
crates/xtask/src/main.rs:298:        Command::new("cargo")
crates/xtask/src/main.rs:395:            Command::new("powershell")
crates/xtask/src/main.rs:397:                .output()
crates/xtask/src/main.rs:399:            Command::new("sh").args(["-c", &cmd]).output()
crates/xtask/src/main.rs:514:        Command::new("gh").args([
crates/xtask/src/main.rs:569:    let mut upload = Command::new("gh");
crates/xtask/src/main.rs:578:        Command::new("gh").args(["release", "edit", &tag, "--repo", REPO, "--draft=false"]),
crates/xtask/src/main.rs:862:        Command::new("cargo")
crates/xtask/src/main.rs:895:    let out = Command::new("git")
crates/xtask/src/main.rs:898:        .output()


Daemon-reachable spawn sites to verify (xtask = console maintainer tooling, irrelevant; proc.rs:186 = test). Fixed already: `gitrun:55`, `shellhost:304` taskkill, `runtime:381` run_bounded_command. Need to confirm the flag on the **remaining** daemon-reachable spawns: `shellhost:188` (launch_shell), `shellwake:194`, `runtime:240`, and the raw `daemon.rs` CreateProcess paths.


230	    /// Build a `Command` from a role: fill the template, tokenize, apply cwd,
231	    /// env_remove, and the recursion guard. Shared by both spawn paths.
232	    fn command_for(
233	        &self,
234	        role: &SessionRole,
235	        keys: &BTreeMap<String, String>,
236	    ) -> Result<Command, RuntimeError> {
237	        let filled = fill_template(&role.command, keys)?;
238	        let tokens = tokenize(&filled);
239	        let (program, args) = tokens.split_first().ok_or(RuntimeError::EmptyCommand)?;
240	        let mut cmd = Command::new(program);
241	        cmd.args(args);
242	        if let Some(cwd) = &role.cwd {
243	            cmd.current_dir(fill_template(cwd, keys)?);
244	        }
245	        for var in &role.env_remove {
246	            cmd.env_remove(var);
247	        }
248	        if let Some(guard) = &role.recursion_guard_env {
249	            cmd.env(guard, "1");
250	        }
251	        Ok(cmd)
252	    }
253	}
254
255	impl AgentRuntime for ManifestRuntime {
256	    fn spawn_session(
257	        &self,
258	        role: &str,
259	        keys: &BTreeMap<String, String>,
260	    ) -> Result<Spawned, RuntimeError> {
261	        let role_def = self.role(role)?;
262	        let mut cmd = self.command_for(role_def, keys)?;
263	        // Detached: discard stdio, spawn, drop the handle. Best-effort detach
264	        // for the interim no-daemon model; M3's broker owns true detachment.
265	        cmd.stdin(Stdio::null())
266	            .stdout(Stdio::null())
267	            .stderr(Stdio::null());
268	        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;
269	        Ok(Spawned { pid: child.id() })
270	    }
271
272	    fn run_bounded_stdin(
273	        &self,
274	        role: &str,
275	        keys: &BTreeMap<String, String>,
276	        timeout: Duration,
277	        stdin: Option<&[u8]>,
278	    ) -> Result<BoundedOutput, RuntimeError> {
279	        let role_def = self.role(role)?;
280	        let mut cmd = self.command_for(role_def, keys)?;
281	        cmd.stdin(if stdin.is_some() {
282	            Stdio::piped()
283	        } else {
284	            Stdio::null()
285	        })
286	        .stdout(Stdio::piped())
287	        .stderr(Stdio::piped());
288	        let label = fill_template(&role_def.command, keys)?;
289	        let mut child = cmd.spawn().map_err(RuntimeError::Spawn)?;
290	        if let Some(bytes) = stdin {
291	            if let Some(mut sink) = child.stdin.take() {
292	                let owned = bytes.to_vec();
293	                // Write on a thread + drop the sink (EOF) so the child finishes.
294	                std::thread::spawn(move || {
295	                    let _ = sink.write_all(&owned);
296	                });
297	            }
298	        }
299	        wait_bounded(child, timeout, &label)

    /// Build a `Command` from a role: fill the template, tokenize, apply cwd,
    /// env_remove, and the recursion guard. Shared by both spawn paths.
    fn command_for(
        &self,
        role: &SessionRole,
        keys: &BTreeMap<String, String>,
    ) -> Result<Command, RuntimeError> {
        let filled = fill_template(&role.command, keys)?;
        let tokens = tokenize(&filled);
        let (program, args) = tokens.split_first().ok_or(RuntimeError::EmptyCommand)?;
        let mut cmd = Command::new(program);
        cmd.args(args);
        if let Some(cwd) = &role.cwd {
            cmd.current_dir(fill_template(cwd, keys)?);
        }
        for var in &role.env_remove {
            cmd.env_remove(var);
        }
        if let Some(guard) = &role.recursion_guard_env {
            cmd.env(guard, "1");
        }
        Ok(cmd)
    }
}

impl AgentRuntime for ManifestRuntime {
    fn spawn_session(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
    ) -> Result<Spawned, RuntimeError> {
        let role_def = self.role(role)?;
        let mut cmd = self.command_for(role_def, keys)?;
        // Detached: discard stdio, spawn, drop the handle. Best-effort detach
        // for the interim no-daemon model; M3's broker owns true detachment.
        cmd.stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null());
        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;
        Ok(Spawned { pid: child.id() })
    }

    fn run_bounded_stdin(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
        stdin: Option<&[u8]>,
    ) -> Result<BoundedOutput, RuntimeError> {
        let role_def = self.role(role)?;
        let mut cmd = self.command_for(role_def, keys)?;
        cmd.stdin(if stdin.is_some() {
            Stdio::piped()
        } else {
            Stdio::null()
        })
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
        let label = fill_template(&role_def.command, keys)?;
        let mut child = cmd.spawn().map_err(RuntimeError::Spawn)?;
        if let Some(bytes) = stdin {
            if let Some(mut sink) = child.stdin.take() {
                let owned = bytes.to_vec();
                // Write on a thread + drop the sink (EOF) so the child finishes.
                std::thread::spawn(move || {
                    let _ = sink.write_all(&owned);
                });
            }
        }
        wait_bounded(child, timeout, &label)


185	    resolve: impl FnOnce() -> Result<String, String>,
186	) {
187	    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
188	    let Some((program, args)) = tokens.split_first() else {
189	        eprintln!("WAKER_EMPTY:{owner}/{shell_id}: empty wake_command");
190	        return;
191	    };
192	    let mut failures = 0u32;
193	    while !stop.load(Ordering::SeqCst) {
194	        let mut cmd = Command::new(program);
195	        cmd.args(args)
196	            .stdin(Stdio::null())
197	            .stdout(Stdio::null())
198	            .stderr(Stdio::null());
199	        // The daemon (a console-less DETACHED process) hosting a
200	        // console-subsystem waker would otherwise pop a visible Terminal
201	        // window per spawn on Windows 11 (the KH 5.6-adjacent D3e lesson —
202	        // mock-shell's spt_cmd does the same).
203	        #[cfg(windows)]
204	        {
205	            use std::os::windows::process::CommandExt;
206	            cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
207	        }
208	        let child = cmd.spawn();
209	        let mut child = match child {
210	            Ok(c) => c,
211	            Err(e) => {
212	                // An unspawnable waker is a permanent condition — latch now.
213	                eprintln!("WAKER_SPAWN_FAIL:{owner}/{shell_id}: {e} (giving up)");
214	                let _ = std::fs::write(perch.join(WAKER_GAVE_UP_FILE), b"");

    resolve: impl FnOnce() -> Result<String, String>,
) {
    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
    let Some((program, args)) = tokens.split_first() else {
        eprintln!("WAKER_EMPTY:{owner}/{shell_id}: empty wake_command");
        return;
    };
    let mut failures = 0u32;
    while !stop.load(Ordering::SeqCst) {
        let mut cmd = Command::new(program);
        cmd.args(args)
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null());
        // The daemon (a console-less DETACHED process) hosting a
        // console-subsystem waker would otherwise pop a visible Terminal
        // window per spawn on Windows 11 (the KH 5.6-adjacent D3e lesson —
        // mock-shell's spt_cmd does the same).
        #[cfg(windows)]
        {
            use std::os::windows::process::CommandExt;
            cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
        }
        let child = cmd.spawn();
        let mut child = match child {
            Ok(c) => c,
            Err(e) => {
                // An unspawnable waker is a permanent condition — latch now.
                eprintln!("WAKER_SPAWN_FAIL:{owner}/{shell_id}: {e} (giving up)");
                let _ = std::fs::write(perch.join(WAKER_GAVE_UP_FILE), b"");


180	            // capturing `spt shell spawn`'s output — at any depth of the
181	            // process chain — hangs forever on a pipe write-end the immortal
182	            // child holds. Same no-inherit spawn as the daemon's.
183	            // [impl->REQ-HAZARD-DETACHED-PIPE-INHERIT]
184	            #[cfg(windows)]
185	            let pid = crate::daemon::detached_no_inherit(program, args)
186	                .map_err(|e| format!("spawn {program}: {e}"))?;
187	            #[cfg(unix)]
188	            let pid = Command::new(program)
189	                .args(args)
190	                .stdin(Stdio::null())
191	                .stdout(Stdio::null())
192	                .stderr(Stdio::null())
193	                .spawn()
194	                .map_err(|e| format!("spawn {program}: {e}"))?
195	                .id();
196	            let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
197	            std::fs::write(perch.join(SHELL_PID_FILE), pid.to_string())
198	                .map_err(|e| format!("record pid: {e}"))?;
199	            Ok(pid)
200	        }
201	    }
202	}
203
204	/// The stdin-receipt launch: spawn the filled template as a **broker-hosted**

            // capturing `spt shell spawn`'s output — at any depth of the
            // process chain — hangs forever on a pipe write-end the immortal
            // child holds. Same no-inherit spawn as the daemon's.
            // [impl->REQ-HAZARD-DETACHED-PIPE-INHERIT]
            #[cfg(windows)]
            let pid = crate::daemon::detached_no_inherit(program, args)
                .map_err(|e| format!("spawn {program}: {e}"))?;
            #[cfg(unix)]
            let pid = Command::new(program)
                .args(args)
                .stdin(Stdio::null())
                .stdout(Stdio::null())
                .stderr(Stdio::null())
                .spawn()
                .map_err(|e| format!("spawn {program}: {e}"))?
                .id();
            let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
            std::fs::write(perch.join(SHELL_PID_FILE), pid.to_string())
                .map_err(|e| format!("record pid: {e}"))?;
            Ok(pid)
        }
    }
}

/// The stdin-receipt launch: spawn the filled template as a **broker-hosted**


- `shellwake:194` ✓ has flag. `shellhost:188` → Windows uses `detached_no_inherit` (raw CreateProcess — verify next).
- **`runtime.rs:240 command_for`** is the shared builder for `spawn_session` (line 268) and `run_bounded_stdin` (line 289) — and it does **NOT** set `creation_flags`. b90820c only patched the *standalone* `run_bounded_command` (line 399), not these two. Potential residual.

Check the raw daemon CreateProcess flags + whether those `command_for` paths are daemon-periodic (the 60s pump).


405	            thread_attrs: *mut core::ffi::c_void,
406	            inherit_handles: i32,
407	            flags: u32,
408	            env: *mut core::ffi::c_void,
409	            cwd: *const u16,
410	            startup: *mut StartupInfoW,
411	            out: *mut ProcessInformation,
412	        ) -> i32;
413	        fn CloseHandle(h: isize) -> i32;
414	    }
415	    // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW: no
416	    // inherited console, no popup window, its own signal group.
417	    const FLAGS: u32 = 0x0000_0008 | 0x0000_0200 | 0x0800_0000;
418
419	    let mut cmdline = String::new();
420	    quote_windows_arg(&mut cmdline, program);
421	    for a in args {
422	        cmdline.push(' ');
423	        quote_windows_arg(&mut cmdline, a);
424	    }
425	    let mut cmdline_w: Vec<u16> = std::ffi::OsStr::new(&cmdline)
426	        .encode_wide()
427	        .chain(std::iter::once(0))
428	        .collect();
429	    let mut si: StartupInfoW = unsafe { std::mem::zeroed() };
430	    si.cb = std::mem::size_of::<StartupInfoW>() as u32;
431	    let mut pi: ProcessInformation = unsafe { std::mem::zeroed() };
432	    let ok = unsafe {
433	        CreateProcessW(
434	            std::ptr::null(),
435	            cmdline_w.as_mut_ptr(),
436	            std::ptr::null_mut(),
437	            std::ptr::null_mut(),
438	            0, // bInheritHandles = FALSE — the whole point.
439	            FLAGS,
440	            std::ptr::null_mut(), // inherit this process's environment
441	            std::ptr::null(),
442	            &mut si,
443	            &mut pi,
444	        )
445	    };
446	    if ok == 0 {
447	        return Err(io::Error::last_os_error());
448	    }
449	    unsafe {
450	        CloseHandle(pi.process);
451	        CloseHandle(pi.thread);
452	    }
453	    Ok(pi.pid)
454	}
455
456	/// Append `arg` to a Windows command line under `CommandLineToArgvW` quoting
457	/// rules (quote when needed; double preceding backslashes before an embedded
458	/// quote; double trailing backslashes before the closing quote).
459	// [impl->REQ-HAZARD-DETACHED-PIPE-INHERIT]

            thread_attrs: *mut core::ffi::c_void,
            inherit_handles: i32,
            flags: u32,
            env: *mut core::ffi::c_void,
            cwd: *const u16,
            startup: *mut StartupInfoW,
            out: *mut ProcessInformation,
        ) -> i32;
        fn CloseHandle(h: isize) -> i32;
    }
    // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW: no
    // inherited console, no popup window, its own signal group.
    const FLAGS: u32 = 0x0000_0008 | 0x0000_0200 | 0x0800_0000;

    let mut cmdline = String::new();
    quote_windows_arg(&mut cmdline, program);
    for a in args {
        cmdline.push(' ');
        quote_windows_arg(&mut cmdline, a);
    }
    let mut cmdline_w: Vec<u16> = std::ffi::OsStr::new(&cmdline)
        .encode_wide()
        .chain(std::iter::once(0))
        .collect();
    let mut si: StartupInfoW = unsafe { std::mem::zeroed() };
    si.cb = std::mem::size_of::<StartupInfoW>() as u32;
    let mut pi: ProcessInformation = unsafe { std::mem::zeroed() };
    let ok = unsafe {
        CreateProcessW(
            std::ptr::null(),
            cmdline_w.as_mut_ptr(),
            std::ptr::null_mut(),
            std::ptr::null_mut(),
            0, // bInheritHandles = FALSE — the whole point.
            FLAGS,
            std::ptr::null_mut(), // inherit this process's environment
            std::ptr::null(),
            &mut si,
            &mut pi,
        )
    };
    if ok == 0 {
        return Err(io::Error::last_os_error());
    }
    unsafe {
        CloseHandle(pi.process);
        CloseHandle(pi.thread);
    }
    Ok(pi.pid)
}

/// Append `arg` to a Windows command line under `CommandLineToArgvW` quoting
/// rules (quote when needed; double preceding backslashes before an embedded
/// quote; double trailing backslashes before the closing quote).
// [impl->REQ-HAZARD-DETACHED-PIPE-INHERIT]


crates\spt-live\src\echo.rs:106:        runtime.run_bounded_stdin(ECHO_COMMUNE_ROLE, keys, timeout, Some(stdin.as_bytes()))?;
crates\spt-runtime\src\runtime.rs:176:    fn spawn_session(
crates\spt-runtime\src\runtime.rs:186:    fn run_bounded_stdin(
crates\spt-runtime\src\runtime.rs:194:    /// Bounded role invocation with no stdin — sugar over [`Self::run_bounded_stdin`].
crates\spt-runtime\src\runtime.rs:195:    fn run_bounded(
crates\spt-runtime\src\runtime.rs:201:        self.run_bounded_stdin(role, keys, timeout, None)
crates\spt-runtime\src\runtime.rs:256:    fn spawn_session(
crates\spt-runtime\src\runtime.rs:272:    fn run_bounded_stdin(
crates\spt-runtime\src\runtime.rs:361:/// same kill-on-timeout guarantee as [`AgentRuntime::run_bounded`]
crates\spt-runtime\src\runtime.rs:367:/// lines that live *outside* the role set, so [`ManifestRuntime::run_bounded`]
crates\spt-runtime\src\runtime.rs:372:pub fn run_bounded_command(
crates\spt-runtime\src\runtime.rs:453:        let e = rt.spawn_session("self", &keys(&[])).unwrap_err();
crates\spt-runtime\src\runtime.rs:474:            .run_bounded("self", &keys(&[]), Duration::from_secs(10))
crates\spt-runtime\src\runtime.rs:489:            .run_bounded("self", &keys(&[]), Duration::from_millis(300))
crates\spt-runtime\src\runtime.rs:500:    fn spawn_session_detached_returns_pid() {
crates\spt-runtime\src\runtime.rs:505:        let spawned = rt.spawn_session("self", &keys(&[])).expect("spawn");
crates\spt-live\src\history.rs:21:// opaque templates run through `run_bounded_command`; spt-core selects no model.
crates\spt-live\src\history.rs:25:use spt_runtime::{run_bounded_command, RuntimeError};
crates\spt-live\src\history.rs:100:            let out = run_bounded_command(cmd, keys, timeout, None)?;
crates\spt-live\src\history.rs:123:            let out = run_bounded_command(normalize, keys, timeout, Some(&raw))?;
crates\spt-runtime\src\lib.rs:26:    run_bounded_command, AgentRuntime, BoundedOutput, ManifestRuntime, RuntimeError, Spawned,
crates\spt-live\src\psyche.rs:132:    let spawned = runtime.spawn_session(role, &k)?;
crates\spt-live\src\turn.rs:6://! sister) — **never** the detached `Stdio::null()` `spawn_session`, whose
crates\spt-live\src\turn.rs:79:        .run_bounded_stdin(PSYCHE_TURN_ROLE, keys, timeout, Some(stdin))
crates\spt-daemon\tests\daemon_e2e.rs:145:    let sid = brain.spawn_session(echo_req()).expect("spawn echo child");
crates\spt-daemon\tests\attach.rs:196:        .spawn_session(echo_spawn_req())
crates\spt-daemon\tests\attach.rs:270:        .spawn_session(echo_spawn_req())
crates\spt-daemon\tests\attach.rs:419:        .spawn_session(echo_spawn_req())
crates\spt-daemon\tests\budget.rs:91:        let (session, pid) = brain.spawn_session_pid(req).expect("spawn seat");
crates\spt\tests\contract_e2e.rs:112:        .run_bounded(
crates\spt-daemon\tests\idempotent.rs:162:        .spawn_session(echo_spawn_req())
crates\spt-daemon\tests\handoff.rs:88:        .spawn_session(echo_spawn_req())
crates\spt-daemon\tests\brain_swap.rs:97:        .spawn_session(echo_spawn_req())
crates\spt-daemon\tests\digest.rs:112:    let _sid = brain.spawn_session(echo_req()).expect("spawn echo child");
crates\spt-daemon\tests\dispatch.rs:425:        .spawn_session(spt_daemon::msg::SpawnReq {
crates\spt-daemon\tests\dispatch.rs:434:        .spawn_session(spt_daemon::msg::SpawnReq {
crates\spt-daemon\src\applyhost.rs:395:        let sid = seat.spawn_session(echo_spawn_req()).expect("spawn");
crates\spt-daemon\src\applyhost.rs:437:        let sid = seat.spawn_session(echo_spawn_req()).expect("spawn");
crates\spt-daemon\src\brain.rs:219:    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
crates\spt-daemon\src\brain.rs:220:        self.spawn_session_pid(req)
crates\spt-daemon\src\brain.rs:224:    /// [`spawn_session`](Self::spawn_session) that also surfaces the child's
crates\spt-daemon\src\brain.rs:227:    pub fn spawn_session_pid(&mut self, req: SpawnReq) -> io::Result<(u64, Option<u32>)> {
crates\spt-daemon\src\brain.rs:530:    /// until the reply (consuming interleaved events like [`Brain::spawn_session`]).
crates\spt-daemon\tests\twohost.rs:548:        .spawn_session(spt_daemon::msg::SpawnReq {
crates\spt\src\cli.rs:2684:        match spt_runtime::run_bounded_command(
crates\spt\src\cli.rs:2727:                    match spt_runtime::run_bounded_command(
crates\spt-daemon\src\notif.rs:300:        .spawn_session(NOTIF_ROLE, &notif_keys(row))
crates\spt-daemon\src\notif.rs:920:            .run_bounded(
crates\spt-daemon\src\shellhost.rs:223:        .spawn_session_pid(SpawnReq {


Critical finding. The raw daemon `detached_no_inherit` is windowless ✓ (`FLAGS = DETACHED | NEW_PROCESS_GROUP | CREATE_NO_WINDOW`). **But** `ManifestRuntime::command_for` (runtime.rs:240) — the shared builder for `spawn_session` and `run_bounded_stdin` — sets **no** creation flags, and b90820c never touched it. Its daemon-side callers:
- `notif.rs:300` → `spawn_session(NOTIF_ROLE)`
- `notif.rs:920` → `run_bounded(...)`

That's the **notif pump** spawning console children windowless-less. Check the hazard's stated invariant + whether the notif pump is periodic + traceability coverage.


cd C:/Users/decid/Documents/projects/spt-core && echo "=== KNOWN-HAZARDS 5.8 ===" && sed -n '/5\.8/,/^## \|^### 5\.9\|^## 6/p' docs/KNOWN-HAZARDS.md 2>/dev/null | grep -v "LF will be" | head -40 && echo "=== REQ-HAZARD-CHILD-CONSOLE-FLASH in toml ===" && grep -n -A8 "HAZARD-CHILD-CONSOLE-FLASH" traceable-reqs.toml && echo "=== traceable-reqs check ===" && (traceable-reqs check 2>&1 | tail -20 || echo "traceable-reqs not on PATH")

Read hazard invariant + req coverage + check status


=== KNOWN-HAZARDS 5.8 ===
### 5.8 Console children of the console-less daemon flash visible windows  `[REQ-HAZARD-CHILD-CONSOLE-FLASH]`
- **Failure:** the daemon runs DETACHED (no console, 5.6/`detached_no_inherit`). Any console-subsystem child it spawns (`git`, `taskkill`, manifest hook commands) gets a **fresh conhost with a visible window** — piped/null stdio does NOT prevent it. Field shape: the 60s sync pump's two git calls (`for-each-ref` + `rev-parse`) flashed two blank windows per minute on the user's desktop (2026-06-06).
- **Invariant:** every short-lived console child spawned from daemon-reachable code sets `creation_flags(0x0800_0000)` (`CREATE_NO_WINDOW`). Long-lived detached children use `detached_no_inherit` (already `DETACHED_PROCESS | CREATE_NO_WINDOW`); de-elevated spawns use `CREATE_NEW_CONSOLE + SW_HIDE` (5.7 — `CreateProcessWithTokenW` rejects `CREATE_NO_WINDOW`, error 87).
- **Test seam caveat:** window-absence is unobservable from a consoled test runner — the child inherits the runner's console and never creates a window, flag or no flag. Unit coverage asserts the flagged spawn still works (the error-87 "flag combo breaks spawn" regression class); window-absence was verified live by process-watch capture.
- **spt-core mapping:** `spt-store::gitrun::run_git` (every BranchStore/ContextStore git call), `spt-daemon::shellhost::kill_shell_pid` (taskkill), `spt-runtime::run_bounded_command` (manifest hook commands), `spt-daemon::shellwake` (already guarded).
- **Source:** spt-core field bug, 2026-06-06 — two blank windows flashing every 60 seconds on a desktop workstation, caught by process-spawn watcher (git.exe parent=spt daemon, conhost.exe child each).

---

## 6. Documented regressions (non-obvious invariants)
| 5.8 | `CREATE_NO_WINDOW` on every console child of the console-less daemon | `gitrun::run_git`, `kill_shell_pid`, `run_bounded_command`, `shellwake` |
| 6.1 | Single path/registry source of truth | storage layout |
| 6.4 | Drop files supervisor-owned single-writer | runtime contract |
| 6.5 | Direct-write precedence marker (+ node id) | cross-node Psyche sync |
| 6.6 | Surfaced conflicts preserve both versions until dominated | context conflict artifacts (ADR-0013) |
| 7.1 | Local `api` mutation authenticated to endpoint | api surface / broker IPC |
| 7.2 | Idempotent delivery across brain restart | broker↔brain IPC |
| 7.3 | Psyche outbound captured + `from=`/target stripped + reply-to-sender / notify-to-own-user | live-Psyche driver / daemon relay (ADR-0012) |
| 7.4 | Per-agent pulse/psyche/echo runs off the shared scheduler (no serial blocking across agents) | daemon multi-agent hosting (ADR-0004) |
| 7.5 | WAN-inbound origin = QUIC handshake identity from the broker's stream table, never payload bytes | wan receive funnel + every wire-inbound consumer (ADR-0009) |
=== REQ-HAZARD-CHILD-CONSOLE-FLASH in toml ===
593:id = "REQ-HAZARD-CHILD-CONSOLE-FLASH"
594-title = "Console-subsystem children of the console-less daemon spawn with CREATE_NO_WINDOW, or each spawn flashes a visible blank window on the user's desktop (5.8)"
595-required_stages = ["impl", "unit"]  # activated post-M7 field bug 2026-06-06: sync-pump git spawns flashed 2 windows/min on a Windows desktop; window-absence itself is untestable from a consoled test runner (child inherits the console) — the unit stage covers "flag does not break the spawn" (the error-87 regression class)
596-[[requirements]]
597-id = "REQ-HAZARD-LOCAL-API-AUTH"
598-title = "Every local `api` mutation authenticated to an endpoint/session (codex #13)"
599-required_stages = ["impl", "unit"]
600-[[requirements]]
601-id = "REQ-HAZARD-RESTART-IDEMPOTENT"
=== traceable-reqs check ===
  [OK] REQ-TERM-1  required: [impl, unit]  stages: -doc +impl +unit -int
       Process-supervisor terminal wrapper hosting broker PTYs
  [OK] REQ-TERM-2  required: [impl, unit]  stages: -doc +impl +unit -int
       session-surface abstraction; send-keys + send-line injection
  [OK] REQ-TERM-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Byte-stream remote terminal streaming for v1
  [OK] REQ-TERM-4  required: [impl, unit, int]  stages: +doc +impl +unit +int
       Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log
  [OK] REQ-UPD-1  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Peer-propagated update over P2P
  [OK] REQ-UPD-2  required: [impl, unit]  stages: -doc +impl +unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-core ripple-updates registered adapters
  [OK] REQ-UPD-6  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       Platform-targeted update sets and debug rollout: signed multi-platform update metadata, recipient platform selection, channel-scoped monotonic counters, debug-channel opt-in via release-key overlay, local staging plus pull-based peer propagation, and maintainer-only convergence tooling (ADR-0016)


cd C:/Users/decid/Documents/projects/spt-core && echo "=== notif pump cadence (who calls notif spawn periodically) ===" && grep -rn "NOTIF_ROLE\|notif_pump\|notif.*period\|drain_notif\|pump" crates/spt-daemon/src/notif.rs | grep -v "LF will be" | head -20 && echo "=== notif.rs:295-305 + 915-925 ===" && sed -n '295,305p;915,925p' crates/spt-daemon/src/notif.rs

Inspect notif pump periodicity and spawn sites


=== notif pump cadence (who calls notif spawn periodically) ===
274:pub const NOTIF_ROLE: &str = "notif";
277:/// [`NOTIF_ROLE`] template: `{notif_id}`, `{notif_from}`, `{notif_subnet}`,
300:        .spawn_session(NOTIF_ROLE, &notif_keys(row))
921:                NOTIF_ROLE,
=== notif.rs:295-305 + 915-925 ===
pub fn spawn_notif_command(
    runtime: &dyn spt_runtime::AgentRuntime,
    row: &NotifRow,
) -> Result<u32, String> {
    runtime
        .spawn_session(NOTIF_ROLE, &notif_keys(row))
        .map(|s| s.pid)
        .map_err(|e| e.to_string())
}

/// The `[session.notif]` seam generalized to SHELL adapters (M5-D8b,
            last_surfaced_ms: 0,
        };
        // The bounded run shares the template/key fill with the detached
        // spawn (command_for) — capturing it proves the render.
        let out = rt
            .run_bounded(
                NOTIF_ROLE,
                &notif_keys(&row),
                std::time::Duration::from_secs(30),
            )
            .unwrap();


Full picture recovered. Here's the diagnostic state.

## What I recovered (pre-crash work)

**`b90820c` (HEAD, committed) — root cause found + fixed.** Field bug: 2 blank windows every 60s. Process-watcher caught the pair: the **60s sync pump's two git calls** (`for-each-ref` + `rev-parse` against `context\.seed.git`) via `spt-store::gitrun::run_git`, which set no `creation_flags`. A console child of the console-less DETACHED daemon allocates a fresh **visible** conhost — null/piped stdio does not stop it. Fixed `run_git` + `taskkill` + `run_bounded_command`; `shellwake` already had it. Recorded as **KNOWN-HAZARDS 5.8 / REQ-HAZARD-CHILD-CONSOLE-FLASH**. Commit claims "verified live by watcher before/after."

## The residual gap (where it gets interesting)

Hazard 5.8 invariant reads: *"**every** short-lived console child spawned from daemon-reachable code sets CREATE_NO_WINDOW."* But b90820c patched only **4 named sites**. It missed one shared builder:

`ManifestRuntime::command_for` (runtime.rs:240) sets **no** creation flags — and it feeds **two** more spawn paths:
- `spawn_session` ← **`notif.rs:300 spawn_notif_command`** (notif pump) + live-agent `psyche.rs:132`
- `run_bounded_stdin` ← live-agent `echo.rs`, `turn.rs`

So the notif pump + live agents still spawn console children windowless-less on Windows. b90820c's own closing line admits it: *"the lesson existed in-repo but was not yet an invariant."* I had **not** started this edit (working-tree `command_for` is unpatched) — this is the pickup point.

## Feedback-loop honesty (Phase 1)

Per the hazard's own test-seam caveat: **window-absence is unobservable from a consoled test runner** — the child inherits the runner's console and never makes a window, flag or no flag. So I cannot build a deterministic pass/fail loop in-process. Live verification needs your process-watcher. Unit tests can only assert "the flag doesn't break the spawn" (the error-87 regression class).

## Ranked hypotheses

1. **Git pair fixed; I was extending 5.8 to a true invariant** — centralize CREATE_NO_WINDOW in `command_for` so notif-pump `spawn_session` + `run_bounded_stdin` stop flashing. *(most likely — matches the commit's parting note)*
2. **Post-b90820c the watcher still caught a residual pair** — from the notif pump, not git. *(if so, the 60s flash never fully died)*
3. **60s flash fully dead + verified; only commit hygiene left** — the uncommitted cargo-fmt churn.

One thing only you know — gating which hypothesis is live:


AskUserQuestion

Question: After b90820c (the committed CREATE_NO_WINDOW fix), do the windows STILL flash every 60s on your desktop?

Choices:
Daemon running b90820c still pops blank windows — fix incomplete, residual spawn site live
b90820c killed the 60s flash; watcher confirmed no windows. Pickup = harden the invariant + hygiene
Haven't run the daemon+watcher since b90820c landed. Need to verify before deciding

Question: Regardless of current state — close the residual gap (CREATE_NO_WINDOW in command_for, covering notif pump + live agents)?

Choices:
Centralize the flag in command_for so spawn_session + run_bounded_stdin can't flash — makes 5.8 a real invariant
Don't touch it unless H2 confirms a live residual pair from the notif path
Walk through the command_for change before I edit — some spawns may need a console


Your questions have been answered: "After b90820c (the committed CREATE_NO_WINDOW fix), do the windows STILL flash every 60s on your desktop?"="Stopped — verified clean", "Regardless of current state — close the residual gap (CREATE_NO_WINDOW in command_for, covering notif pump + live agents)?"="Yes, harden command_for". You can now continue with these answers in mind.

Question: After b90820c (the committed CREATE_NO_WINDOW fix), do the windows STILL flash every 60s on your desktop?

Choices:
Daemon running b90820c still pops blank windows — fix incomplete, residual spawn site live
b90820c killed the 60s flash; watcher confirmed no windows. Pickup = harden the invariant + hygiene
Haven't run the daemon+watcher since b90820c landed. Need to verify before deciding

Question: Regardless of current state — close the residual gap (CREATE_NO_WINDOW in command_for, covering notif pump + live agents)?

Choices:
Centralize the flag in command_for so spawn_session + run_bounded_stdin can't flash — makes 5.8 a real invariant
Don't touch it unless H2 confirms a live residual pair from the notif path
Walk through the command_for change before I edit — some spawns may need a console