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. `version` is the monotonic release counter;
65	    /// `product_version` is the human-facing semver (empty for pre-v0.3.2
66	    /// metadata, whence the counter-based fallback message).
67	    // [impl->REQ-UPD-8]
68	    Applied {
69	        version: u64,
70	        product_version: String,
71	    },
72	}
73
74	/// Apply the staged release (module docs). `exe_path` is the executable to
75	/// swap (production: `std::env::current_exe()`); `broker_name` the canonical
76	/// broker socket the brain handoff runs against.
77	// [impl->REQ-UPD-3]
78	// [impl->REQ-UPD-4]
79	pub fn apply_staged(
80	    cache: &ReleaseCache,
81	    release_keys: &Path,
82	    exe_path: &Path,
83	    broker_name: &str,
84	) -> Result<ApplyStagedOutcome, String> {
85	    let Some(staged) = cache.staged_update() else {
86	        return Ok(ApplyStagedOutcome::NoUpdate);
87	    };
88	    let version = cache.staged_version().unwrap_or(0);
89	    let product_version = staged.product_version().unwrap_or_default();
90
91	    // At-rest re-verification. Version floor 0: monotonicity already gated
92	    // the stage; this check is authenticity/integrity/expiry of the bytes on
93	    // disk. Fail-closed on an unprovisioned key file (same posture as the
94	    // pump's pull leg).
95	    let policy = VerifyPolicy::load_from(release_keys, 0, now_ms());
96	    if policy.trusted_keys.is_empty() {
97	        return Ok(ApplyStagedOutcome::Rejected(
98	            "release-keys.json unprovisioned (no trusted keys)".to_string(),
99	        ));
100	    }
101	    let (plan, artifact) = match staged {
102	        StagedUpdate::Single(signed) => {
103	            // Platform guard (REQ-UPD-8) — defense-in-depth against the v0.3.1
104	            // cross-OS brick. A legacy single carries NO platform identity in
105	            // its signed metadata, so apply trusts only the cache's platform
106	            // stamp: apply proceeds solely when the stage asserted THIS node's
107	            // platform. An unstamped single (every P2P-pulled single, and any
108	            // pre-v0.3.2 stage) or one stamped for another OS fail-safe refuses
109	            // — exactly the path that swapped a Linux ELF in as `spt.exe`.
110	            let here = current_platform();
111	            match cache.staged_platform() {
112	                Some(p) if p == here => {}
113	                Some(p) => {
114	                    return Ok(ApplyStagedOutcome::Rejected(format!(
115	                        "staged single targets platform {p}, not this node's {here} — \
116	                         re-fetch with `spt update fetch` on v0.3.2+ (platform-safe update set)"
117	                    )))
118	                }
119	                None => {
120	                    return Ok(ApplyStagedOutcome::Rejected(
121	                        "staged single release has no platform stamp (pre-v0.3.2 / peer-propagated) — \
122	                         refusing a platform-blind single; re-fetch with `spt update fetch` on v0.3.2+ \
123	                         (stages a platform-safe update set)"
124	                            .to_string(),
125	                    ))
126	                }
127	            }
128	            let artifact = cache
129	                .load_artifact()
130	                .map_err(|e| format!("staged artifact: {e}"))?;
131	            let plan = match plan_verified(&BrokerAbi::current(), &signed, &artifact, &policy) {
132	                Ok(p) => p,
133	                Err(reason) => return Ok(ApplyStagedOutcome::Rejected(format!("{reason:?}"))),
134	            };
135	            (plan, artifact)
136	        }
137	        StagedUpdate::Set(signed) => {
138	            let platform = current_platform();
139	            let artifact = cache
140	                .load_artifact_for_platform(platform)
141	                .map_err(|e| format!("staged artifact for {platform}: {e}"))?;
142	            let plan = match plan_verified_update_set(
143	                &BrokerAbi::current(),
144	                &signed,
145	                platform,
146	                &artifact,
147	                &policy,
148	            ) {
149	                Ok(p) => p,
150	                Err(reason) => return Ok(ApplyStagedOutcome::Rejected(format!("{reason:?}"))),
151	            };
152	            (plan, artifact)
153	        }
154	    };
155	    if plan.class != UpdateClass::BrainOnly {
156	        return Ok(ApplyStagedOutcome::RefusedClass(plan.class));
157	    }
158
159	    // The brain connects BEFORE the binary moves — a dead broker fails the
160	    // apply with the seat untouched.
161	    let brain = connect_retry(broker_name).map_err(|e| format!("broker connect: {e}"))?;
162
163	    // Swap the binary: step aside, land the artifact, roll back on failure.
164	    let aside = swap_aside_path(exe_path, version);
165	    let _ = std::fs::remove_file(&aside); // stale prior apply of this version
166	    std::fs::rename(exe_path, &aside).map_err(|e| format!("step current binary aside: {e}"))?;
167	    if let Err(e) = land_artifact(exe_path, &artifact) {
168	        let _ = std::fs::rename(&aside, exe_path); // roll back — never half-swapped
169	        return Err(format!("land new binary (rolled back): {e}"));
170	    }
171	    // The binary IS swapped from here on — record the applied fact for the
172	    // status-only convergence query (M8 decision 19, `xtask debug-converge`),
173	    // whatever the brain handoff below concludes. Best-effort: an unrecorded
174	    // apply degrades the convergence table, never the apply.
175	    // [impl->REQ-UPD-6]
176	    let _ = cache.record_applied(version);
177	    let _ = cache.record_last_outcome("applied");
178
179	    // The brain-only handoff against the live broker — REQ-UPD-3's moment. A
180	    // node with no broker-hosted sessions has nothing to hand off; the
181	    // invariant is vacuous there, and the next daemon start uses the swapped
182	    // executable.
183	    match brain {
184	        Some(brain) => {
185	            let name = broker_name.to_string();
186	            match apply_brain_only(&plan, brain, move |state| handoff_retry(&name, state)) {
187	                Ok(_new_brain) => Ok(ApplyStagedOutcome::Applied {
188	                    version,
189	                    product_version,
190	                }),
191	                Err(e) => Err(format!(
192	                    "binary swapped to v{version}, but the brain re-attach failed: {e} — the broker \
193	                     and every hosted endpoint are intact; retry `spt update apply` or restart the brain"
194	                )),
195	            }
196	        }
197	        None => Ok(ApplyStagedOutcome::Applied {
198	            version,
199	            product_version,
200	        }),
201	    }
202	}
203
204	/// Where the outgoing binary steps aside: a sibling `<file>.old-<version>`
205	/// (suffix appended, never `set_extension` — `spt.exe` must keep `.exe` out
206	/// of the aside name's way).
207	fn swap_aside_path(exe_path: &Path, version: u64) -> std::path::PathBuf {
208	    let name = exe_path
209	        .file_name()
210	        .and_then(|n| n.to_str())
211	        .unwrap_or("spt-binary");
212	    exe_path.with_file_name(format!("{name}.old-{version}"))
213	}
214
215	/// Land the artifact at the executable path (atomic temp→rename) and mark it
216	/// executable where the OS needs that.
217	fn land_artifact(exe_path: &Path, artifact: &[u8]) -> io::Result<()> {
218	    spt_store::atomic::atomic_write_bytes(exe_path, artifact)?;
219	    #[cfg(unix)]
220	    {
221	        use std::os::unix::fs::PermissionsExt;
222	        std::fs::set_permissions(exe_path, std::fs::Permissions::from_mode(0o755))?;
223	    }
224	    Ok(())
225	}
226
227	/// Connect a brain to the broker and attach it to an existing broker-held
228	/// session, retrying through startup races. The update engine's handoff
229	/// primitive snapshots a session-driving brain; this attaches as a logic
230	/// successor over an already-hosted session without spawning or terminating
231	/// anything.
232	fn connect_retry(broker_name: &str) -> io::Result<Option<Brain>> {
233	    let mut last = io::Error::other("broker never answered");
234	    for _ in 0..300 {
235	        match Brain::cold_start(broker_name, now_ms()) {
236	            Ok(mut b) => match b.sessions() {
237	                Ok(reply) => {
238	                    if let Some(session) = reply.sessions.first() {
239	                        if let Err(e) = b.attach(session.session_id, 0) {
240	                            last = e;
241	                        } else {
242	                            return Ok(Some(b));
243	                        }
244	                    } else {
245	                        return Ok(None);
246	                    }
247	                }
248	                Err(e) => last = e,
249	            },
250	            Err(e) => last = e,
251	        }
252	        std::thread::sleep(Duration::from_millis(10));
253	    }
254	    Err(last)
255	}
256
257	/// The relaunch closure's body: the new brain re-attaches over the existing
258	/// substrate, retrying the slot race (the broker may not have freed the
259	/// outgoing brain's seat the instant it dropped) — the `brain_swap.rs` shape.
260	fn handoff_retry(broker_name: &str, prior: BrainState) -> io::Result<Brain> {
261	    let mut last = None;
262	    for _ in 0..300 {
263	        match Brain::handoff(broker_name, prior.clone(), now_ms()) {
264	            Ok(b) => return Ok(b),
265	            Err(e) => last = Some(e),
266	        }
267	        std::thread::sleep(Duration::from_millis(10));
268	    }
269	    Err(last.unwrap_or_else(|| io::Error::other("relaunch never connected")))
270	}
271
272	#[cfg(test)]
273	mod tests {
274	    use super::*;
275	    use crate::broker::Broker;
276	    use crate::msg::SpawnReq;
277	    use crate::release::{
278	        current_platform, sha256_hex, ReleaseMetadata, SignedRelease, SignedUpdateSet,
279	        UpdateArtifactMetadata, UpdateSetMetadata,
280	    };
281	    use crate::update::BROKER_RESOURCE_ABI;
282	    use ed25519_dalek::{Signer, SigningKey};
283	    use std::sync::atomic::{AtomicU32, Ordering};
284	    use std::sync::Arc;
285
286	    static SEQ: AtomicU32 = AtomicU32::new(0);
287	    fn unique_name() -> String {
288	        let n = SEQ.fetch_add(1, Ordering::Relaxed);
289	        format!("spt-daemon-d7b-{}-{}.sock", std::process::id(), n)
290	    }
291
292	    fn served_broker(name: &str) -> Arc<Broker> {
293	        let broker = Broker::bind(name).expect("bind broker");
294	        let serve = Arc::clone(&broker);
295	        std::thread::spawn(move || {
296	            let _ = serve.serve();
297	        });
298	        broker
299	    }
300
301	    fn cold_connect_retry(broker_name: &str) -> Brain {
302	        for _ in 0..300 {
303	            if let Ok(b) = Brain::cold_start(broker_name, now_ms()) {
304	                return b;
305	            }
306	            std::thread::sleep(Duration::from_millis(10));
307	        }
308	        panic!("brain cold-start could not connect");
309	    }
310
311	    /// Stage a signed release + write the matching release-keys.json.
312	    /// `resource_abi` lets a test force the broker-breaking class.
313	    fn stage(
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 = ReleaseMetadata {
321	            version,
322	            channel: "stable".to_string(),
323	            expires_at_ms: u64::MAX,
324	            key_id: "k".to_string(),
325	            artifact_sha256: sha256_hex(artifact),
326	            brain_ipc_version: crate::IPC_PROTOCOL_VERSION,
327	            broker_resource_abi: resource_abi,
328	            product_version: String::new(),
329	        };
330	        let metadata_json = serde_json::to_string(&meta).unwrap();
331	        let sig = sk.sign(metadata_json.as_bytes());
332	        let signed = SignedRelease {
333	            metadata_json,
334	            signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
335	        };
336	        let cache = ReleaseCache::open(&dir.join("releases"));
337	        // Stamp the local platform so the apply guard (REQ-UPD-8) lets these
338	        // single-release paths through to the class/tamper logic under test;
339	        // the unstamped/mismatched refusals get their own dedicated test.
340	        cache
341	            .stage_single_with_platform(&signed, artifact, current_platform())
342	            .expect("stage");
343
344	        let keys = dir.join("release-keys.json");
345	        let pub_hex: String = sk
346	            .verifying_key()
347	            .to_bytes()
348	            .iter()
349	            .map(|b| format!("{b:02x}"))
350	            .collect();
351	        std::fs::write(
352	            &keys,
353	            serde_json::json!({"keys": {"k": pub_hex}, "channel": "stable"}).to_string(),
354	        )
355	        .unwrap();
356	        (cache, keys)
357	    }
358
359	    /// Stage a signed update set + write matching release keys.
360	    fn stage_set(dir: &Path, version: u64, artifact: &[u8]) -> (ReleaseCache, std::path::PathBuf) {
361	        stage_set_with_resource_abi(dir, version, artifact, BROKER_RESOURCE_ABI)
362	    }
363
364	    fn stage_set_with_resource_abi(
365	        dir: &Path,
366	        version: u64,
367	        artifact: &[u8],
368	        resource_abi: u32,
369	    ) -> (ReleaseCache, std::path::PathBuf) {
370	        let sk = SigningKey::from_bytes(&[9u8; 32]);
371	        let meta = UpdateSetMetadata {
372	            version,
373	            channel: "stable".to_string(),
374	            expires_at_ms: u64::MAX,
375	            key_id: "k".to_string(),
376	            artifacts: std::collections::BTreeMap::from([(
377	                current_platform().to_string(),
378	                UpdateArtifactMetadata {
379	                    artifact_sha256: sha256_hex(artifact),
380	                    brain_ipc_version: crate::IPC_PROTOCOL_VERSION,
381	                    broker_resource_abi: resource_abi,
382	                    asset_name: None,
383	                },
384	            )]),
385	            provenance: None,
386	            product_version: String::new(),
387	        };
388	        let metadata_json = serde_json::to_string(&meta).unwrap();
389	        let sig = sk.sign(metadata_json.as_bytes());
390	        let signed = SignedUpdateSet {
391	            metadata_json,
392	            signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
393	        };
394	        let cache = ReleaseCache::open(&dir.join("releases"));
395	        cache
396	            .stage_update_set(
397	                &signed,
398	                &std::collections::BTreeMap::from([(
399	                    current_platform().to_string(),
400	                    artifact.to_vec(),
401	                )]),
402	            )
403	            .expect("stage set");
404
405	        let keys = dir.join("release-keys.json");
406	        let pub_hex: String = sk
407	            .verifying_key()
408	            .to_bytes()
409	            .iter()
410	            .map(|b| format!("{b:02x}"))
411	            .collect();
412	        std::fs::write(
413	            &keys,
414	            serde_json::json!({"keys": {"k": pub_hex}, "channel": "stable"}).to_string(),
415	        )
416	        .unwrap();
417	        (cache, keys)
418	    }
419
420	    /// Sign a single release (sk = [9u8;32], matching `stage`) and write the
421	    /// matching release-keys.json — WITHOUT staging, so a test controls the
422	    /// platform stamp itself.
423	    fn sign_single(dir: &Path, version: u64, artifact: &[u8]) -> (SignedRelease, std::path::PathBuf) {
424	        let sk = SigningKey::from_bytes(&[9u8; 32]);
425	        let meta = ReleaseMetadata {
426	            version,
427	            channel: "stable".to_string(),
428	            expires_at_ms: u64::MAX,
429	            key_id: "k".to_string(),
430	            artifact_sha256: sha256_hex(artifact),
431	            brain_ipc_version: crate::IPC_PROTOCOL_VERSION,
432	            broker_resource_abi: BROKER_RESOURCE_ABI,
433	            product_version: String::new(),
434	        };
435	        let metadata_json = serde_json::to_string(&meta).unwrap();
436	        let sig = sk.sign(metadata_json.as_bytes());
437	        let signed = SignedRelease {
438	            metadata_json,
439	            signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
440	        };
441	        let keys = dir.join("release-keys.json");
442	        let pub_hex: String = sk
443	            .verifying_key()
444	            .to_bytes()
445	            .iter()
446	            .map(|b| format!("{b:02x}"))
447	            .collect();
448	        std::fs::write(
449	            &keys,
450	            serde_json::json!({"keys": {"k": pub_hex}, "channel": "stable"}).to_string(),
451	        )
452	        .unwrap();
453	        (signed, keys)
454	    }
455
456	    // [unit->REQ-UPD-8] the platform guard — the v0.3.1 cross-OS brick
457	    // regression. A single staged WITHOUT a matching platform stamp must never
458	    // apply: an unstamped single (every peer-propagated / pre-v0.3.2 single)
459	    // refuses, a single stamped for ANOTHER OS refuses, and only a single
460	    // stamped for THIS node's platform proceeds. This is exactly what would
461	    // have stopped a Linux ELF from being swapped in as `spt.exe`.
462	    #[test]
463	    fn apply_staged_refuses_platform_blind_or_mismatched_single() {
464	        let dir = tempfile::tempdir().unwrap();
465	        let name = unique_name();
466	        let _broker = served_broker(&name);
467	        let exe = dir.path().join("spt-binary");
468	        std::fs::write(&exe, b"OLD").unwrap();
469
470	        let cache = ReleaseCache::open(&dir.path().join("releases"));
471	        let (signed, keys) = sign_single(dir.path(), 7, b"CROSS-OS BYTES");
472
473	        // 1. Unstamped single (a bare stage, i.e. every P2P-pulled single) —
474	        //    the exact shape that bricked hfenduleam — refuses, seat untouched.
475	        cache.stage(&signed, b"CROSS-OS BYTES").unwrap();
476	        let out = apply_staged(&cache, &keys, &exe, &name).expect("ok");
477	        assert!(
478	            matches!(out, ApplyStagedOutcome::Rejected(ref r) if r.contains("platform")),
479	            "unstamped single must refuse, got {out:?}"
480	        );
481	        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");
482
483	        // 2. Single stamped for the OTHER OS refuses too.
484	        let other = if current_platform() == "x86_64-pc-windows-msvc" {
485	            "x86_64-unknown-linux-gnu"
486	        } else {
487	            "x86_64-pc-windows-msvc"
488	        };
489	        cache
490	            .stage_single_with_platform(&signed, b"CROSS-OS BYTES", other)
491	            .unwrap();
492	        let out = apply_staged(&cache, &keys, &exe, &name).expect("ok");
493	        assert!(
494	            matches!(out, ApplyStagedOutcome::Rejected(ref r) if r.contains(other)),
495	            "mismatched-platform single must refuse, got {out:?}"
496	        );
497	        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");
498
499	        // 3. Stamped for THIS platform: the single applies (the legitimate
500	        //    origin-fetched-local path).
501	        cache
502	            .stage_single_with_platform(&signed, b"CROSS-OS BYTES", current_platform())
503	            .unwrap();
504	        let out = apply_staged(&cache, &keys, &exe, &name).expect("ok");
505	        assert!(
506	            matches!(out, ApplyStagedOutcome::Applied { version: 7, .. }),
507	            "matching-platform single applies, got {out:?}"
508	        );
509	        assert_eq!(std::fs::read(&exe).unwrap(), b"CROSS-OS BYTES");
510	    }
511
512	    fn echo_spawn_req() -> SpawnReq {
513	        #[cfg(unix)]
514	        let (program, args) = ("cat".to_string(), Vec::<String>::new());
515	        #[cfg(windows)]
516	        let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
517	        SpawnReq {
518	            program,
519	            args,
520	            rows: 24,
521	            cols: 80,
522	            endpoint: String::new(),
523	        }
524	    }
525
526	    // [unit->REQ-UPD-4] the ack-driven apply end to end: a staged + verified
527	    // brain-only release swaps the binary (artifact lands at the exe path,
528	    // the original steps aside) and triggers the brain handoff with the
529	    // hosted endpoint untouched — the production caller the M4-D9 residual
530	    // promised. [unit->REQ-UPD-3]
531	    #[test]
532	    fn apply_staged_swaps_binary_and_hands_off_without_touching_the_endpoint() {
533	        let dir = tempfile::tempdir().unwrap();
534	        let name = unique_name();
535	        let broker = served_broker(&name);
536
537	        // A hosted endpoint that must survive the apply.
538	        let mut seat = cold_connect_retry(&name);
539	        let sid = seat.spawn_session(echo_spawn_req()).expect("spawn");
540	        let child_pid = broker.session_pid(sid);
541	        drop(seat); // the seat brain detaches; the broker keeps the child
542
543	        let exe = dir.path().join("spt-binary");
544	        std::fs::write(&exe, b"OLD BINARY").unwrap();
545	        let (cache, keys) = stage(dir.path(), 7, b"NEW BINARY BYTES", BROKER_RESOURCE_ABI);
546
547	        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
548	        assert!(
549	            matches!(out, ApplyStagedOutcome::Applied { version: 7, .. }),
550	            "got {out:?}"
551	        );
552	        assert_eq!(
553	            std::fs::read(&exe).unwrap(),
554	            b"NEW BINARY BYTES",
555	            "artifact landed"
556	        );
557	        assert_eq!(
558	            std::fs::read(swap_aside_path(&exe, 7)).unwrap(),
559	            b"OLD BINARY",
560	            "the outgoing binary stepped aside"
561	        );
562	        // REQ-UPD-3: the hosted child never noticed.
563	        assert_eq!(
564	            broker.session_count(),
565	            1,
566	            "exactly one session across the apply"
567	        );
568	        assert_eq!(broker.session_pid(sid), child_pid, "child pid unchanged");
569	    }
570
571	    // [unit->REQ-UPD-6] apply-time re-verification handles platform-targeted
572	    // update sets by selecting the local target triple's artifact before
573	    // swapping the executable.
574	    #[test]
575	    fn apply_staged_update_set_selects_local_platform_artifact() {
576	        let dir = tempfile::tempdir().unwrap();
577	        let name = unique_name();
578	        let broker = served_broker(&name);
579
580	        let mut seat = cold_connect_retry(&name);
581	        let sid = seat.spawn_session(echo_spawn_req()).expect("spawn");
582	        let child_pid = broker.session_pid(sid);
583	        drop(seat);
584
585	        let exe = dir.path().join("spt-binary");
586	        std::fs::write(&exe, b"OLD").unwrap();
587	        let (cache, keys) = stage_set(dir.path(), 11, b"SET BINARY BYTES");
588
589	        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
590	        assert!(
591	            matches!(out, ApplyStagedOutcome::Applied { version: 11, .. }),
592	            "got {out:?}"
593	        );
594	        assert_eq!(std::fs::read(&exe).unwrap(), b"SET BINARY BYTES");
595	        assert_eq!(broker.session_pid(sid), child_pid, "child pid unchanged");
596	    }
597
598	    // [unit->REQ-UPD-3] A node with no broker-hosted sessions still applies a
599	    // brain-only update: there is nothing to hand off, and no endpoint can be
600	    // terminated by the apply.
601	    #[test]
602	    fn apply_staged_without_broker_hosted_sessions_swaps_binary() {
603	        let dir = tempfile::tempdir().unwrap();
604	        let name = unique_name();
605	        let broker = served_broker(&name);
606
607	        let exe = dir.path().join("spt-binary");
608	        std::fs::write(&exe, b"OLD").unwrap();
609	        let (cache, keys) = stage(dir.path(), 13, b"NEW WITHOUT SESSIONS", BROKER_RESOURCE_ABI);
610
611	        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
612	        assert!(
613	            matches!(out, ApplyStagedOutcome::Applied { version: 13, .. }),
614	            "got {out:?}"
615	        );
616	        assert_eq!(std::fs::read(&exe).unwrap(), b"NEW WITHOUT SESSIONS");
617	        assert_eq!(broker.session_count(), 0);
618	    }
619
620	    // [unit->REQ-UPD-6] Broker-touching debug/update-set payloads are accepted
621	    // as staged candidates, but apply refuses them typed while the broker-held
622	    // runtime is live. The first broker-touching flow is refusal-only; it does
623	    // not auto-suspend or cycle hosted work.
624	    #[test]
625	    fn apply_staged_update_set_refuses_broker_touching_payloads() {
626	        let dir = tempfile::tempdir().unwrap();
627	        let name = unique_name();
628	        let _broker = served_broker(&name);
629
630	        let exe = dir.path().join("spt-binary");
631	        std::fs::write(&exe, b"OLD").unwrap();
632	        let (cache, keys) = stage_set_with_resource_abi(
633	            dir.path(),
634	            12,
635	            b"SET BROKER BYTES",
636	            BROKER_RESOURCE_ABI + 1,
637	        );
638
639	        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
640	        assert!(
641	            matches!(
642	                out,
643	                ApplyStagedOutcome::RefusedClass(UpdateClass::BrokerBreaking)
644	            ),
645	            "got {out:?}"
646	        );
647	        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");
648	        assert!(cache.staged_update().is_some(), "candidate stays staged");
649	    }
650
651	    // [unit->REQ-UPD-4] idempotent no-op and fail-closed gates: nothing
652	    // staged ⇒ NoUpdate; a non-brain-only class refuses typed with the seat
653	    // untouched; a tampered at-rest artifact rejects with the seat untouched.
654	    #[test]
655	    fn apply_staged_gates_fail_closed_with_the_seat_untouched() {
656	        let dir = tempfile::tempdir().unwrap();
657	        let name = unique_name();
658	        let _broker = served_broker(&name);
659	        let exe = dir.path().join("spt-binary");
660	        std::fs::write(&exe, b"OLD").unwrap();
661
662	        // Nothing staged.
663	        let empty = ReleaseCache::open(&dir.path().join("releases"));
664	        let keys = dir.path().join("release-keys.json");
665	        let out = apply_staged(&empty, &keys, &exe, &name).expect("ok");
666	        assert!(matches!(out, ApplyStagedOutcome::NoUpdate), "got {out:?}");
667
668	        // Broker-breaking class: typed refusal, artifact stays staged.
669	        let (cache, keys) = stage(dir.path(), 8, b"X", BROKER_RESOURCE_ABI + 1);
670	        let out = apply_staged(&cache, &keys, &exe, &name).expect("ok");
671	        assert!(
672	            matches!(
673	                out,
674	                ApplyStagedOutcome::RefusedClass(UpdateClass::BrokerBreaking)
675	            ),
676	            "got {out:?}"
677	        );
678	        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");
679	        assert!(cache.staged().is_some(), "artifact stays staged");
680
681	        // Tampered at rest: the apply-time re-verification rejects.
682	        let (cache, keys) = stage(dir.path(), 9, b"GOOD BYTES", BROKER_RESOURCE_ABI);
683	        std::fs::write(cache.artifact_path(), b"EVIL BYTES").unwrap();
684	        let out = apply_staged(&cache, &keys, &exe, &name).expect("ok");
685	        assert!(
686	            matches!(out, ApplyStagedOutcome::Rejected(_)),
687	            "got {out:?}"
688	        );
689	        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");
690
691	        // Unprovisioned keys: fail-closed.
692	        let (cache, _) = stage(dir.path(), 10, b"Y", BROKER_RESOURCE_ABI);
693	        let out =
694	            apply_staged(&cache, &dir.path().join("missing-keys.json"), &exe, &name).expect("ok");
695	        assert!(
696	            matches!(out, ApplyStagedOutcome::Rejected(_)),
697	            "got {out:?}"
698	        );
699	        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD");
700	    }
701	}
702

//! 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. `version` is the monotonic release counter;
    /// `product_version` is the human-facing semver (empty for pre-v0.3.2
    /// metadata, whence the counter-based fallback message).
    // [impl->REQ-UPD-8]
    Applied {
        version: u64,
        product_version: String,
    },
}

/// 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);
    let product_version = staged.product_version().unwrap_or_default();

    // 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) => {
            // Platform guard (REQ-UPD-8) — defense-in-depth against the v0.3.1
            // cross-OS brick. A legacy single carries NO platform identity in
            // its signed metadata, so apply trusts only the cache's platform
            // stamp: apply proceeds solely when the stage asserted THIS node's
            // platform. An unstamped single (every P2P-pulled single, and any
            // pre-v0.3.2 stage) or one stamped for another OS fail-safe refuses
            // — exactly the path that swapped a Linux ELF in as `spt.exe`.
            let here = current_platform();
            match cache.staged_platform() {
                Some(p) if p == here => {}
                Some(p) => {
                    return Ok(ApplyStagedOutcome::Rejected(format!(
                        "staged single targets platform {p}, not this node's {here} — \
                         re-fetch with `spt update fetch` on v0.3.2+ (platform-safe update set)"
                    )))
                }
                None => {
                    return Ok(ApplyStagedOutcome::Rejected(
                        "staged single release has no platform stamp (pre-v0.3.2 / peer-propagated) — \
                         refusing a platform-blind single; re-fetch with `spt update fetch` on v0.3.2+ \
                         (stages a platform-safe update set)"
                            .to_string(),
                    ))
                }
            }
            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 binary IS swapped from here on — record the applied fact for the
    // status-only convergence query (M8 decision 19, `xtask debug-converge`),
    // whatever the brain handoff below concludes. Best-effort: an unrecorded
    // apply degrades the convergence table, never the apply.
    // [impl->REQ-UPD-6]
    let _ = cache.record_applied(version);
    let _ = cache.record_last_outcome("applied");

    // 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,
                    product_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,
            product_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,
            product_version: String::new(),
        };
        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"));
        // Stamp the local platform so the apply guard (REQ-UPD-8) lets these
        // single-release paths through to the class/tamper logic under test;
        // the unstamped/mismatched refusals get their own dedicated test.
        cache
            .stage_single_with_platform(&signed, artifact, current_platform())
            .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,
            product_version: String::new(),
        };
        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)
    }

    /// Sign a single release (sk = [9u8;32], matching `stage`) and write the
    /// matching release-keys.json — WITHOUT staging, so a test controls the
    /// platform stamp itself.
    fn sign_single(dir: &Path, version: u64, artifact: &[u8]) -> (SignedRelease, 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: BROKER_RESOURCE_ABI,
            product_version: String::new(),
        };
        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 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();
        (signed, keys)
    }

    // [unit->REQ-UPD-8] the platform guard — the v0.3.1 cross-OS brick
    // regression. A single staged WITHOUT a matching platform stamp must never
    // apply: an unstamped single (every peer-propagated / pre-v0.3.2 single)
    // refuses, a single stamped for ANOTHER OS refuses, and only a single
    // stamped for THIS node's platform proceeds. This is exactly what would
    // have stopped a Linux ELF from being swapped in as `spt.exe`.
    #[test]
    fn apply_staged_refuses_platform_blind_or_mismatched_single() {
        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 = ReleaseCache::open(&dir.path().join("releases"));
        let (signed, keys) = sign_single(dir.path(), 7, b"CROSS-OS BYTES");

        // 1. Unstamped single (a bare stage, i.e. every P2P-pulled single) —
        //    the exact shape that bricked hfenduleam — refuses, seat untouched.
        cache.stage(&signed, b"CROSS-OS BYTES").unwrap();
        let out = apply_staged(&cache, &keys, &exe, &name).expect("ok");
        assert!(
            matches!(out, ApplyStagedOutcome::Rejected(ref r) if r.contains("platform")),
            "unstamped single must refuse, got {out:?}"
        );
        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");

        // 2. Single stamped for the OTHER OS refuses too.
        let other = if current_platform() == "x86_64-pc-windows-msvc" {
            "x86_64-unknown-linux-gnu"
        } else {
            "x86_64-pc-windows-msvc"
        };
        cache
            .stage_single_with_platform(&signed, b"CROSS-OS BYTES", other)
            .unwrap();
        let out = apply_staged(&cache, &keys, &exe, &name).expect("ok");
        assert!(
            matches!(out, ApplyStagedOutcome::Rejected(ref r) if r.contains(other)),
            "mismatched-platform single must refuse, got {out:?}"
        );
        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");

        // 3. Stamped for THIS platform: the single applies (the legitimate
        //    origin-fetched-local path).
        cache
            .stage_single_with_platform(&signed, b"CROSS-OS BYTES", current_platform())
            .unwrap();
        let out = apply_staged(&cache, &keys, &exe, &name).expect("ok");
        assert!(
            matches!(out, ApplyStagedOutcome::Applied { version: 7, .. }),
            "matching-platform single applies, got {out:?}"
        );
        assert_eq!(std::fs::read(&exe).unwrap(), b"CROSS-OS BYTES");
    }

    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");
    }
}


if doyle asks you to sign the release build, you have the necessary var in your ENV. just run `cargo run -p xtask -- release-publish --tag v<version_number> --key-id rel-primary-2026 --version <monoto…


Release publish process and signing procedure


<task-notification>
<task-id>b3w2z0q23</task-id>
<summary>Monitor event: "kitsubito serve/pull activity for v6 set"</summary>
<event>[Monitor timed out — re-arm if needed.]</event>
</task-notification>


Found the answer in the source — and it's **not a bug**. Reading the daemon-effects.log to confirm enlyzeam matched the spec:


<system-reminder>Warning: the file exists but is shorter than the provided offset (1). The file has 1 lines.</system-reminder>


Prompt:
<task-notification>
<task-id>b3w2z0q23</task-id>
<summary>Monitor event: "kitsubito serve/pull activity for v6 set"</summary>
<event>[Monitor timed out — re-arm if needed.]</event>
</task-notification>


Log tail came back empty (kitsubito monitor also timed out — no longer needed). Re-tailing enlyzeam's effects log directly to ground the enlyzeam-specific outcome:


Bash

ssh enlyzeam 'bash -lc "wc -l /mnt/c/Users/decid/AppData/Local/spt-core/daemon-effects.log; echo ---TAIL---; tail -25 /mnt/c/Users/decid/AppData/Local/spt-core/daemon-effects.log"' 2>&1

enlyzeam: re-tail daemon-effects.log directly


Command was manually backgrounded by user with ID: b0lbz5qn3. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\84c95489-7e9c-42fe-9b5b-e1fb164fd365\tasks\b0lbz5qn3.output


<task-notification>
<task-id>b0lbz5qn3</task-id>
<tool-use-id>toolu_01JRG5GDzrbYDrwHRXXRx3ok</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\84c95489-7e9c-42fe-9b5b-e1fb164fd365\tasks\b0lbz5qn3.output</output-file>
<status>failed</status>
<summary>Background command "enlyzeam: re-tail daemon-effects.log directly" failed with exit code 1</summary>
</task-notification>


Answer's in the source — `crates/spt-daemon/src/applyhost.rs`. **The hot-swap exists, IS wired into `apply`, and it ran. It's just scoped narrower than "swap the whole daemon."**

What `apply_staged` does (applyhost.rs:79-202):
1. Re-verify staged bytes at rest.
2. Swap binary on disk: old steps aside → `spt.exe.old-6`, new lands atomically. ✅ (that's why disk = 0.3.2)
3. **Trigger brain handoff** (`apply_brain_only` → `handoff`, line 186) — snapshot → drop → re-attach over the live broker. This is the "seamless swap" you remember, proven by `brain_swap.rs`. Its job (REQ-UPD-3): hosted endpoints don't terminate. gerald survives.

But the daemon **process** is deliberately *not* re-execed. Verbatim, applyhost.rs:36-38:

> 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.

So pid `68580` unchanged is **exactly to spec**. The hot-swap protects the *session/brain layer* (endpoints), not the *process*. New binary on disk; resident loops keep running 0.3.0 until the next `ensure_running` (logon / manual restart).

Why hfenduleam showed a new pid (18112) but enlyzeam didn't: doyle **manually restarted** hfenduleam's daemon (to revive its stalled pump before pulling) — that restart is what put its *running* daemon on 0.3.2. enlyzeam got no restart, so disk=0.3.2, running daemon=0.3.0. Both behaved correctly.

Net: nothing broken. The brick fix is verified (valid PE applied, gerald alive). The only "hot-swap that didn't happen" is full-process replacement — which `apply` never promised; it scopes the seamless part to endpoint survival and leaves process-refresh to routine restart.

**Possible gap worth noting:** since the broker substrate + brain handoff already preserve sessions, `apply` *could* additionally self-re-exec the daemon onto the new binary without cycling endpoints — closing the "disk-new / running-old" window automatically. Spec chose not to (routine restart suffices). That's a product call, not a bug.

So, the now-informed version of my earlier question:


AskUserQuestion

Question: enlyzeam: disk binary=0.3.2 (brick fix applied, gerald alive), running daemon still 0.3.0 in-memory by design. How do you want to handle the running-old window?

Choices:
Per spec — routine restart at next logon loads 0.3.2. No gerald disruption. Fleet binaries all 0.3.2; enlyzeam's running daemon lags until logon. Lowest risk.
Manually restart enlyzeam's at-logon daemon task so it runs 0.3.2 immediately (like doyle did on hfenduleam). gerald's perch drops a few seconds, then re-registers. Fleet fully on 0.3.2 running.
Leave enlyzeam as-is AND log a follow-up: should apply self-re-exec the daemon onto the new binary (endpoint-safe) to close the disk-new/running-old window? New REQ + design discussion.


Your questions have been answered: "enlyzeam: disk binary=0.3.2 (brick fix applied, gerald alive), running daemon still 0.3.0 in-memory by design. How do you want to handle the running-old window?"="so if the daemon doesn't update during `apply`, then what actually updates? and what features is that part responsible for?". You can now continue with these answers in mind.

Question: enlyzeam: disk binary=0.3.2 (brick fix applied, gerald alive), running daemon still 0.3.0 in-memory by design. How do you want to handle the running-old window?

Choices:
Per spec — routine restart at next logon loads 0.3.2. No gerald disruption. Fleet binaries all 0.3.2; enlyzeam's running daemon lags until logon. Lowest risk.
Manually restart enlyzeam's at-logon daemon task so it runs 0.3.2 immediately (like doyle did on hfenduleam). gerald's perch drops a few seconds, then re-registers. Fleet fully on 0.3.2 running.
Leave enlyzeam as-is AND log a follow-up: should apply self-re-exec the daemon onto the new binary (endpoint-safe) to close the disk-new/running-old window? New REQ + design discussion.

so if the daemon doesn't update during `apply`, then what actually updates? and what features is that part responsible for?


Prompt:
<task-notification>
<task-id>b0lbz5qn3</task-id>
<tool-use-id>toolu_01JRG5GDzrbYDrwHRXXRx3ok</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\84c95489-7e9c-42fe-9b5b-e1fb164fd365\tasks\b0lbz5qn3.output</output-file>
<status>failed</status>
<summary>Background command "enlyzeam: re-tail daemon-effects.log directly" failed with exit code 1</summary>
</task-notification>


Good question — and I want to answer it from the architecture, not hand-wave. Let me read the brain/broker/daemon model in the authoritative glossary + the apply-class definitions:


33:- **broker** (stable "kernel") — holds *only* the un-transferable, must-not-die resources: PTY master fds, the spawned harness child processes, and listening network sockets. Minimal, dumb, versioned local IPC. Almost never updates.
34:- **daemon brain** ("userspace") — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely on update; rehydrates from disk state and re-attaches to the broker's held handles.
36:Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is an internal layer, not separately addressable.
39:A thin, stateless `spt.exe` task that exists only in **harness-hosted** sessions (where the agent harness is the parent process and spt cannot reach into its process tree — today's Monitor model). It streams the daemon brain's events into the session's stdout. All *stateful* listener logic lives in the daemon; the relay is a dumb pipe, freely killable and respawnable. **spt-hosted** sessions need no relay — the daemon injects events straight into the PTY.
83:[Omitted long matching line]
85:[Omitted long matching line]
121:- **Env injection is asymmetric** (file-bridge-only-when-not-launcher, applied to env): spt-hosted sessions inherit env from the broker that spawns them; harness-hosted sessions need the harness's declared env channel. With `spt` on PATH the env table is small.
139:- **`api presence`** — adapter reports user interaction → updates the presence datum `(last_active_node, last_active_endpoint, ts)`. In the spt-hosted topology, presence is **also** updated by the broker *detecting* (sensing, not watching/logging) user input on a held PTY — privacy-preserving (it notes that input occurred, records no content).
140:- **`api emit --type <sensory_type> <payload>`** — a broker-launched **Shell** binary pushes a sensory payload to its owner agent (owner known from `api bind`; REST-only, never spooled). See the Shell model.
156:**spt-hosted (terminal wrapper / GUI launcher; the daemon launches the binary into a broker PTY):**
157:1. The frontend/CLI launches the agent: the daemon runs the **spawn-session** command template into a broker-held PTY.
191:**PresenceChannel** (broker endpoint — concept locked, impl deferred past v1):
192:[Omitted long matching line]
224:**Provided by a shell adapter.** There are two kinds of adapter, both manifested, both under `adapters/`: a **harness adapter** (`kind="harness"`, e.g. `claude-spt`) hosts *agent* endpoints; a **shell adapter** (`kind="shell"`, e.g. `GameRobot`) provides a *Shell* endpoint. `Shell` is the endpoint type; the shell adapter name is the provider, and its manifest defines the Shell's capability surface. A shell binary is **only ever launched by SPT** (the broker spawns it; never user-launched).
233:**Command-delivery method** is the shell adapter's choice (same modes as agent inject-input): HTTP REST, stdin-via-broker, or child-relay (`api poll`). The binary is always broker-launched, so binding is direct (`api bind`, no seed-file).
235:[Omitted long matching line]
286:[Omitted long matching line]
300:[Omitted long matching line]
301:- **Broker-PTY-dependent → spt-hosted-primary.** Tapping the live byte stream needs the **broker-owned PTY** (topology 2). **Harness-hosted (topology 1) is capability-gated:** an adapter that can feed spt-core the stream (e.g. a `pi`-style harness) may opt in via a declared capability; harnesses that own their process tree opaquely (Claude Code) cannot, and the digest is simply unavailable there. Lands with M3 (spt-term broker).
302:[Omitted long matching line]
356:[Omitted long matching line]
358:**broker update frequency** — rare by design. Triggers: broker↔brain IPC contract change (versioned so a newer brain talks to an older broker — broker stays put as the brain updates), held-resource-type change, OS PTY/socket API change, or a broker bugfix. When the broker *must* update (the one case that can disturb held endpoints), it is a small well-scoped place that can use FD-passing or a rare planned endpoint-cycle — not the whole daemon.
463:[Omitted long matching line]
534:[Omitted long matching line]
540:[Omitted long matching line]
588:**What "installed" comprises:** the `spt` binary + the broker + the `$SPT_HOME` root (holds `node.key`, trust store, registry, spools, in-memory-seed fallback state). **First run is idempotent + interactive-optional:** generates the node identity and starts the daemon unattended; pairing (and subnet naming) is a separate explicit step.


Grep

(?i)enum UpdateClass|BrainOnly|BrokerBreaking|fn apply_brain_only|brain-only|resident loop|ensure_running


crates\spt-daemon\src\applyhost.rs:21://!    [`UpdateClass::BrainOnly`] applies in M5 — the broker-touching classes
crates\spt-daemon\src\applyhost.rs:36://! The daemon's own resident loops keep running the old logic until its next
crates\spt-daemon\src\applyhost.rs:37://! restart (`ensure_running` spawns the executable path — now the new
crates\spt-daemon\src\applyhost.rs:61:    /// Verified but not brain-only — the M5 apply surface refuses the
crates\spt-daemon\src\applyhost.rs:155:    if plan.class != UpdateClass::BrainOnly {
crates\spt-daemon\src\applyhost.rs:179:    // The brain-only handoff against the live broker — REQ-UPD-3's moment. A
crates\spt-daemon\src\applyhost.rs:527:    // brain-only release swaps the binary (artifact lands at the exe path,
crates\spt-daemon\src\applyhost.rs:599:    // brain-only update: there is nothing to hand off, and no endpoint can be
crates\spt-daemon\src\applyhost.rs:643:                ApplyStagedOutcome::RefusedClass(UpdateClass::BrokerBreaking)
crates\spt-daemon\src\applyhost.rs:652:    // staged ⇒ NoUpdate; a non-brain-only class refuses typed with the seat
crates\spt-daemon\src\applyhost.rs:674:                ApplyStagedOutcome::RefusedClass(UpdateClass::BrokerBreaking)
crates\spt-daemon\src\deelevate.rs:4://! daemon spawner (`ensure_running`) — including the elevation-gated ones
crates\spt-daemon\src\daemon.rs:8://! [`ensure_running`] is the auto-start seam (REQ-DAEMON-3): any `api`
crates\spt-daemon\src\daemon.rs:330:/// out. Shared by every start path (`ensure_running`, `start_daemon`):
crates\spt-daemon\src\daemon.rs:356:pub fn ensure_running() -> io::Result<()> {
crates\spt-daemon\src\daemon.rs:747:    // seed-control server answers, it flips true and ensure_running() is a no-op
crates\spt-daemon\src\daemon.rs:767:            // ensure_running short-circuits (no real process spawn).
crates\spt-daemon\src\daemon.rs:768:            ensure_running().expect("ensure is a no-op when already up");
crates\spt-daemon\src\lib.rs:45://! **brain-only zero-interruption swap** ([`apply_brain_only`], [`REQ-UPD-3`]).
crates\spt-daemon\src\lib.rs:46://! The brain-only apply *triggers* a handoff over the B2 substrate — snapshot →
crates\spt-daemon\src\lib.rs:172:    active_service_conflict, ensure_running, is_running, request_stop, start_daemon, stop_daemon,
crates\spt-daemon\src\propagate.rs:652:                .is_some_and(|o| o.contains("BrokerBreaking"))
crates\spt-daemon\src\propagate.rs:741:                Some("rejected:RefusedClass(BrokerBreaking)")
crates\spt-daemon\src\service.rs:4://! `daemon start`/`daemon stop` verbs and the internal `ensure_running`
crates\spt-daemon\src\service.rs:27:/// What `daemon start` (and `ensure_running`) resolves to once the live +
crates\spt-daemon\src\service.rs:53:/// Pure router for `start`/`ensure_running` (REQ-DAEMON-6/-8): prefer the
crates\spt-daemon\src\shellchan.rs:214:    crate::ensure_running().map_err(|e| format!("daemon start: {e}"))?;
crates\spt-daemon\src\shellhost.rs:165:            crate::ensure_running().map_err(|e| format!("daemon start: {e}"))?;
crates\spt-daemon\src\update.rs:1://! The self-update engine (C0) — the update-class taxonomy and the brain-only
crates\spt-daemon\src\update.rs:7://! - **brain-only** — the routine case. The running broker can already host the
crates\spt-daemon\src\update.rs:21://! C0 implements the **classifier** ([`classify`]) and the **brain-only apply**
crates\spt-daemon\src\update.rs:41:/// touches this — that is precisely what keeps the routine case *brain-only*.
crates\spt-daemon\src\update.rs:90:pub enum UpdateClass {
crates\spt-daemon\src\update.rs:93:    BrainOnly,
crates\spt-daemon\src\update.rs:99:    BrokerBreaking,
crates\spt-daemon\src\update.rs:111:///    only logic swaps: **brain-only**. Otherwise the broker must move to a build
crates\spt-daemon\src\update.rs:117:        return UpdateClass::BrokerBreaking;
crates\spt-daemon\src\update.rs:120:        UpdateClass::BrainOnly
crates\spt-daemon\src\update.rs:185:/// Why a brain-only apply did not complete.
crates\spt-daemon\src\update.rs:188:    /// The plan is not brain-only — routing it through the brain-only path would

[Showing results with pagination = limit: 40]


Now I have the authoritative model (CONTEXT.md:33-34). Let me read the update engine's own framing to be precise about what "brain-only" swaps and what runs old vs new:


1	//! The self-update engine (C0) — the update-class taxonomy and the brain-only
2	//! zero-interruption swap.
3	//!
4	//! ADR-0004 §A splits a self-update into **three classes**, each with its own
5	//! invariant, decided on two axes against the *running* broker:
6	//!
7	//! - **brain-only** — the routine case. The running broker can already host the
8	//!   release's brain (the versioned-IPC handshake accepts it) and the broker's
9	//!   held-resource ABI is unchanged, so only the logic swaps. **No endpoint
10	//!   process terminates or suspends** ([`REQ-UPD-3`], absolute for this class) —
11	//!   proven E2E over the M3b handoff substrate.
12	//! - **broker-compatible** — the release ships a newer broker that the running
13	//!   broker's IPC floor cannot host the new brain against, but the held-resource
14	//!   *type* is unchanged, so the new broker hot-adopts the existing handles
15	//!   behind the versioned IPC; endpoints still survive. (Apply path deferred —
16	//!   no spike yet, ADR-0004 §A.)
17	//! - **broker-breaking** — the held-resource *type* or an OS PTY/socket API
18	//!   changed; this needs a *planned endpoint-cycle* and MAY suspend endpoints
19	//!   with consent + scheduling. [`REQ-UPD-3`] is explicitly weakened here.
20	//!
21	//! C0 implements the **classifier** ([`classify`]) and the **brain-only apply**
22	//! ([`apply_brain_only`]). The apply *triggers a handoff* over the existing M3b
23	//! substrate ([`crate::brain::Brain::handoff`]) — it does **not** re-implement
24	//! one (clean-room posture, `M3c-PLAN.md` §C0). The signing/rollback hardening
25	//! that gates *which* binary is even eligible to swap arrives in C1.
26	// [impl->REQ-UPD-3]
27
28	use std::io;
29
30	use crate::brain::{Brain, BrainState};
31	use crate::frame::{IPC_PROTOCOL_VERSION, MIN_COMPATIBLE_VERSION};
32	use crate::release::{
33	    verify_artifact, verify_metadata, verify_update_set_artifact, verify_update_set_metadata,
34	    RejectReason, SignedRelease, SignedUpdateSet, VerifyPolicy,
35	};
36
37	/// The held-resource-type ABI of this build's broker — the broker-breaking axis
38	/// (ADR-0004 §A/§B). Bumped **only** when the *kind* of resource the broker holds
39	/// changes: a new held-resource type, or a new OS PTY/socket API that changes how
40	/// an existing handle is carried. A pure logic change (the routine update) never
41	/// touches this — that is precisely what keeps the routine case *brain-only*.
42	pub const BROKER_RESOURCE_ABI: u32 = 1;
43
44	/// What the currently-running broker provides — the fixed point an incoming
45	/// release is classified against (ADR-0004 §A). Captured from the live broker,
46	/// not from this build, because the whole point of a self-update is that the
47	/// *running* broker may be older than the binary proposing the update.
48	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49	pub struct BrokerAbi {
50	    /// The IPC protocol version the running broker speaks.
51	    pub ipc_version: u32,
52	    /// The oldest brain IPC version the running broker will handshake
53	    /// (`accept_hello`'s floor). A release brain at or above this can attach to
54	    /// the running broker as-is.
55	    pub min_compatible: u32,
56	    /// The held-resource-type ABI tag ([`BROKER_RESOURCE_ABI`]) — the
57	    /// broker-breaking axis.
58	    pub resource_abi: u32,
59	}
60
61	impl BrokerAbi {
62	    /// The ABI of *this* build's broker. In the live daemon the running broker's
63	    /// ABI is read off the broker over the IPC; this is the value a broker built
64	    /// from this source reports (and the classifier's stand-in when the running
65	    /// broker is this same build).
66	    pub fn current() -> Self {
67	        BrokerAbi {
68	            ipc_version: IPC_PROTOCOL_VERSION,
69	            min_compatible: MIN_COMPATIBLE_VERSION,
70	            resource_abi: BROKER_RESOURCE_ABI,
71	        }
72	    }
73	}
74
75	/// What a candidate release declares about its broker/brain — the subset the
76	/// classifier needs (C1 adds version + signature + expiry). Forward-compatible
77	/// by intent: only the two ADR-0004 §A axes live here.
78	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79	pub struct ReleaseSpec {
80	    /// The IPC protocol version the release's **brain** speaks.
81	    pub brain_ipc_version: u32,
82	    /// The held-resource-type ABI the release's **broker** implements — compared
83	    /// against the running broker's [`BrokerAbi::resource_abi`] to detect a
84	    /// broker-breaking change.
85	    pub broker_resource_abi: u32,
86	}
87
88	/// The three update classes (ADR-0004 §A), each routed to its own invariant.
89	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90	pub enum UpdateClass {
91	    /// Swap logic only; the running broker hosts the new brain unchanged. No
92	    /// endpoint terminates ([`REQ-UPD-3`], absolute). The routine case.
93	    BrainOnly,
94	    /// The broker binary hot-swaps behind the versioned IPC (held-resource type
95	    /// unchanged); endpoints survive. Apply path deferred (no spike — ADR-0004 §A).
96	    BrokerCompatible,
97	    /// Held-resource-type / OS-API change; a planned endpoint-cycle is required
98	    /// and endpoints MAY suspend with consent + scheduling. [`REQ-UPD-3`] weakened.
99	    BrokerBreaking,
100	}
101
102	/// Classify a candidate release against the running broker (ADR-0004 §A).
103	///
104	/// The decision is purely the two ADR axes:
105	/// 1. **held-resource ABI** — if the release's broker implements a *different*
106	///    `resource_abi`, the held-resource type changed: **broker-breaking**,
107	///    regardless of IPC (a planned endpoint-cycle is unavoidable).
108	/// 2. **IPC hostability** — with the resource type unchanged, ask whether the
109	///    *running* broker can host the release's brain. If the brain's IPC version
110	///    is at or above the running broker's floor, the handshake accepts it and
111	///    only logic swaps: **brain-only**. Otherwise the broker must move to a build
112	///    that speaks the new IPC — but since the resource type is unchanged the new
113	///    broker adopts the existing handles: **broker-compatible**.
114	// [impl->REQ-UPD-3]
115	pub fn classify(running: &BrokerAbi, release: &ReleaseSpec) -> UpdateClass {
116	    if release.broker_resource_abi != running.resource_abi {
117	        return UpdateClass::BrokerBreaking;
118	    }
119	    if release.brain_ipc_version >= running.min_compatible {
120	        UpdateClass::BrainOnly
121	    } else {
122	        UpdateClass::BrokerCompatible
123	    }
124	}
125
126	/// A classified update: the class plus the release that produced it. Built by
127	/// [`plan_update`] so the class is always derived from the same `(running,
128	/// release)` pair the apply step will act on.
129	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130	pub struct UpdatePlan {

//! The self-update engine (C0) — the update-class taxonomy and the brain-only
//! zero-interruption swap.
//!
//! ADR-0004 §A splits a self-update into **three classes**, each with its own
//! invariant, decided on two axes against the *running* broker:
//!
//! - **brain-only** — the routine case. The running broker can already host the
//!   release's brain (the versioned-IPC handshake accepts it) and the broker's
//!   held-resource ABI is unchanged, so only the logic swaps. **No endpoint
//!   process terminates or suspends** ([`REQ-UPD-3`], absolute for this class) —
//!   proven E2E over the M3b handoff substrate.
//! - **broker-compatible** — the release ships a newer broker that the running
//!   broker's IPC floor cannot host the new brain against, but the held-resource
//!   *type* is unchanged, so the new broker hot-adopts the existing handles
//!   behind the versioned IPC; endpoints still survive. (Apply path deferred —
//!   no spike yet, ADR-0004 §A.)
//! - **broker-breaking** — the held-resource *type* or an OS PTY/socket API
//!   changed; this needs a *planned endpoint-cycle* and MAY suspend endpoints
//!   with consent + scheduling. [`REQ-UPD-3`] is explicitly weakened here.
//!
//! C0 implements the **classifier** ([`classify`]) and the **brain-only apply**
//! ([`apply_brain_only`]). The apply *triggers a handoff* over the existing M3b
//! substrate ([`crate::brain::Brain::handoff`]) — it does **not** re-implement
//! one (clean-room posture, `M3c-PLAN.md` §C0). The signing/rollback hardening
//! that gates *which* binary is even eligible to swap arrives in C1.
// [impl->REQ-UPD-3]

use std::io;

use crate::brain::{Brain, BrainState};
use crate::frame::{IPC_PROTOCOL_VERSION, MIN_COMPATIBLE_VERSION};
use crate::release::{
    verify_artifact, verify_metadata, verify_update_set_artifact, verify_update_set_metadata,
    RejectReason, SignedRelease, SignedUpdateSet, VerifyPolicy,
};

/// The held-resource-type ABI of this build's broker — the broker-breaking axis
/// (ADR-0004 §A/§B). Bumped **only** when the *kind* of resource the broker holds
/// changes: a new held-resource type, or a new OS PTY/socket API that changes how
/// an existing handle is carried. A pure logic change (the routine update) never
/// touches this — that is precisely what keeps the routine case *brain-only*.
pub const BROKER_RESOURCE_ABI: u32 = 1;

/// What the currently-running broker provides — the fixed point an incoming
/// release is classified against (ADR-0004 §A). Captured from the live broker,
/// not from this build, because the whole point of a self-update is that the
/// *running* broker may be older than the binary proposing the update.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BrokerAbi {
    /// The IPC protocol version the running broker speaks.
    pub ipc_version: u32,
    /// The oldest brain IPC version the running broker will handshake
    /// (`accept_hello`'s floor). A release brain at or above this can attach to
    /// the running broker as-is.
    pub min_compatible: u32,
    /// The held-resource-type ABI tag ([`BROKER_RESOURCE_ABI`]) — the
    /// broker-breaking axis.
    pub resource_abi: u32,
}

impl BrokerAbi {
    /// The ABI of *this* build's broker. In the live daemon the running broker's
    /// ABI is read off the broker over the IPC; this is the value a broker built
    /// from this source reports (and the classifier's stand-in when the running
    /// broker is this same build).
    pub fn current() -> Self {
        BrokerAbi {
            ipc_version: IPC_PROTOCOL_VERSION,
            min_compatible: MIN_COMPATIBLE_VERSION,
            resource_abi: BROKER_RESOURCE_ABI,
        }
    }
}

/// What a candidate release declares about its broker/brain — the subset the
/// classifier needs (C1 adds version + signature + expiry). Forward-compatible
/// by intent: only the two ADR-0004 §A axes live here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReleaseSpec {
    /// The IPC protocol version the release's **brain** speaks.
    pub brain_ipc_version: u32,
    /// The held-resource-type ABI the release's **broker** implements — compared
    /// against the running broker's [`BrokerAbi::resource_abi`] to detect a
    /// broker-breaking change.
    pub broker_resource_abi: u32,
}

/// The three update classes (ADR-0004 §A), each routed to its own invariant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateClass {
    /// Swap logic only; the running broker hosts the new brain unchanged. No
    /// endpoint terminates ([`REQ-UPD-3`], absolute). The routine case.
    BrainOnly,
    /// The broker binary hot-swaps behind the versioned IPC (held-resource type
    /// unchanged); endpoints survive. Apply path deferred (no spike — ADR-0004 §A).
    BrokerCompatible,
    /// Held-resource-type / OS-API change; a planned endpoint-cycle is required
    /// and endpoints MAY suspend with consent + scheduling. [`REQ-UPD-3`] weakened.
    BrokerBreaking,
}

/// Classify a candidate release against the running broker (ADR-0004 §A).
///
/// The decision is purely the two ADR axes:
/// 1. **held-resource ABI** — if the release's broker implements a *different*
///    `resource_abi`, the held-resource type changed: **broker-breaking**,
///    regardless of IPC (a planned endpoint-cycle is unavoidable).
/// 2. **IPC hostability** — with the resource type unchanged, ask whether the
///    *running* broker can host the release's brain. If the brain's IPC version
///    is at or above the running broker's floor, the handshake accepts it and
///    only logic swaps: **brain-only**. Otherwise the broker must move to a build
///    that speaks the new IPC — but since the resource type is unchanged the new
///    broker adopts the existing handles: **broker-compatible**.
// [impl->REQ-UPD-3]
pub fn classify(running: &BrokerAbi, release: &ReleaseSpec) -> UpdateClass {
    if release.broker_resource_abi != running.resource_abi {
        return UpdateClass::BrokerBreaking;
    }
    if release.brain_ipc_version >= running.min_compatible {
        UpdateClass::BrainOnly
    } else {
        UpdateClass::BrokerCompatible
    }
}

/// A classified update: the class plus the release that produced it. Built by
/// [`plan_update`] so the class is always derived from the same `(running,
/// release)` pair the apply step will act on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UpdatePlan {


Got the full model now (CONTEXT.md:33-34 + update.rs:1-124). Answering precisely:

## The daemon is two layers

The per-machine `spt-daemon` splits into:

**broker — the "kernel."** Holds *only* the un-transferable, must-not-die resources: PTY master fds, spawned harness child processes, listening network sockets. Dumb, minimal, versioned IPC. **Almost never updates** — only when the held-resource *type* or an OS PTY/socket API changes (the "broker-breaking" class).

**daemon brain — the "userspace."** **All the logic lives here:** routing, registry, the pulse/psyche loops, manifest parsing, update orchestration, the **peer pump**, subnet/messaging, **the message-envelope codec**. This is what a release like 0.3.2 actually changes. It "restarts freely on update; rehydrates from disk state and re-attaches to the broker's held handles."

## So what updates, and how

**The brain is what updates** — and the update *mechanism is a restart*, not an in-place swap. `apply` (brain-only class, update.rs:7-11) does two things:
1. Swaps the binary **on disk** (→ 0.3.2).
2. Confirms the running broker can host the new brain (IPC compatible, held-resource ABI unchanged) — that's what makes it brain-only, REQ-UPD-3 endpoints-survive.

It does **not** re-exec the running brain. The resident loops keep running the **old** brain code until the brain process restarts (`ensure_running` spawns the now-new binary). The broker persists across that restart, the new brain re-attaches to the held PTYs/sockets/children → endpoints don't die. *That* re-attach is the "seamless swap" (`brain_swap.rs`/`handoff`) you remembered — its job is to make the brain restart endpoint-safe.

The broker is the part that "almost never updates" and needs zero downtime; the brain is the part that holds the features and is *designed* to be restart-to-update.

## The punchline for enlyzeam

This isn't cosmetic. **The brain owns the message-envelope codec** — which is where the CR-corruption fix (REQ-HAZARD-ENVELOPE-CR-LINESAFE, shipped 0.3.1) lives. enlyzeam's running brain is **still 0.3.0**. So:

> gerald on enlyzeam — the exact box from your original bug report — is **still rendering messages with the `\r` corruption right now**, even though the fixed binary sits on disk.

The brick fix's *runtime* behavior (0.3.2 pump serving sets) and the CR fix only go live when enlyzeam's **brain restarts** onto the new binary. Disk has it; the live brain doesn't.

So restarting enlyzeam's daemon is what actually closes the original bug on gerald's box. Same three-way choice as before, but now the "restart now" option is the one that ends the original symptom — and per the brain/broker split it should re-attach gerald rather than drop it. Want me to do it?


<command-message>grill-with-docs</command-message>
<command-name>/grill-with-docs</command-name>
<command-args>updating in place without actually changing the running code seems very silly. what can we do about it, to make the update actually handoff to the new executable seamlessly, such that no running processes are dropped, and any internal loop timings stay consistent across? plus any other angles that should be considered.</command-args>


Base directory for this skill: C:\Users\decid\.ccs\instances\bigscreen\skills\grill-with-docs

<what-to-do>

Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.

Ask the questions one at a time, waiting for feedback on each question before continuing.

If a question can be answered by exploring the codebase, explore the codebase instead.

</what-to-do>

<supporting-info>

## Domain awareness

During codebase exploration, also look for existing documentation:

### File structure

Most repos have a single context:

```
/
├── CONTEXT.md
├── docs/
│   └── adr/
│       ├── 0001-event-sourced-orders.md
│       └── 0002-postgres-for-write-model.md
└── src/
```

If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives:

```
/
├── CONTEXT-MAP.md
├── docs/
│   └── adr/                          ← system-wide decisions
├── src/
│   ├── ordering/
│   │   ├── CONTEXT.md
│   │   └── docs/adr/                 ← context-specific decisions
│   └── billing/
│       ├── CONTEXT.md
│       └── docs/adr/
```

Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed.

## During the session

### Challenge against the glossary

When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?"

### Sharpen fuzzy language

When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things."

### Discuss concrete scenarios

When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts.

### Cross-reference with code

When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?"

### Update CONTEXT.md inline

When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md).

`CONTEXT.md` should be totally devoid of implementation details. Do not treat `CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else.

### Offer ADRs sparingly

Only offer to create an ADR when all three are true:

1. **Hard to reverse** — the cost of changing your mind later is meaningful
2. **Surprising without context** — a future reader will wonder "why did they do it this way?"
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons

If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md).

</supporting-info>

ARGUMENTS: updating in place without actually changing the running code seems very silly. what can we do about it, to make the update actually handoff to the new executable seamlessly, such that no running processes are dropped, and any internal loop timings stay consistent across? plus any other angles that should be considered.


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


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


Before grilling, exploring the current handoff substrate — the answer to "what can we do" depends on what primitives already exist. Reading the brain handoff mechanism, what state it snapshots, and the daemon process lifecycle: