diff --git a/crates/spt-daemon/src/broker.rs b/crates/spt-daemon/src/broker.rs
index 29dd9e4..e051681 100644
--- a/crates/spt-daemon/src/broker.rs
+++ b/crates/spt-daemon/src/broker.rs
@@ -52,13 +52,14 @@ use crate::frame::{Envelope, Role};
 use crate::msg::{
     applied_envelope, decode_bytes, displaced_envelope, endpoint_injected_envelope,
     evicted_envelope, net_dialed_envelope, output_envelope,
-    size_envelope, subscribed_envelope, BrainRestarted, EndpointInputReq,
+    size_envelope, subscribed_envelope, AdapterApplyReq, BrainRestarted, EndpointInputReq,
     ErrorEvent, ExitEvent, InputReq, KillReq, NetDialReq, NetPresenceSubscribeReq, NetSent,
     NetStatusReply, NetStreamOpenReq, NetStreamOpened, NetStreamSendReq, NetStreamSubscribeReq,
     NetStreamsReply, PairJoinReply, PairJoinReq, ResizeReq, SessionInfo, SessionsReply, SpawnReq,
     Spawned, SubscribeOutcome, SubscribeReq, UnsubscribeReq, KIND_BRAIN_RESTART, KIND_BRAIN_RESTARTED, KIND_ENDPOINT_INPUT, KIND_ERROR, KIND_EXIT,
     KIND_INPUT, KIND_KILL, KIND_NET_DIAL, KIND_NET_DIAL_LOOPBACK, KIND_NET_PRESENCE_SUBSCRIBE,
     KIND_NET_SENT,
+    KIND_ADAPTER_APPLY, KIND_APPLIED,
     KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_NET_STREAMS, KIND_NET_STREAMS_REPLY,
     KIND_NET_STREAM_OPEN, KIND_NET_STREAM_OPENED, KIND_NET_STREAM_SEND, KIND_NET_STREAM_SUBSCRIBE,
     KIND_PAIR_JOIN, KIND_PAIR_JOINED, KIND_RESIZE, KIND_SESSIONS, KIND_SESSIONS_REPLY, KIND_SPAWN,
@@ -1030,6 +1031,69 @@ fn flush_inject_floor(floor: &Mutex<InjectFloor>, input: &InputWriter) {
 /// sequence and the operator's live controller share one PTY without clobbering
 /// each other (REQ-HAZARD-INJECT-CONTROL-COEXIST), and the deadline FAULT path is
 /// the "never park forever" bound applied to injection.
+fn build_translation(
+    path: &str,
+    endpoint_id: &str,
+    input: &Arc<InputWriter>,
+) -> Option<Arc<Translation>> {
+    let (cmd_tx, cmd_rx) = channel::<KeyCmd>();
+    match TranslationChild::spawn(path, cmd_tx) {
+        Ok(child) => {
+            let child = Arc::new(child);
+            let _ = child.send(&ToBinary::Init {
+                endpoint_id: endpoint_id.to_string(),
+                node: spt_store::hostlabel::os_hostname().unwrap_or_default(),
+            });
+            let floor = Arc::new(Mutex::new(InjectFloor::default()));
+            let faulted = Arc::new(AtomicBool::new(false));
+            let (event_tx, event_rx) = channel::<String>();
+            let w_input = Arc::clone(input);
+            let w_child = Arc::clone(&child);
+            let w_floor = Arc::clone(&floor);
+            let w_faulted = Arc::clone(&faulted);
+            thread::spawn(move || {
+                run_inject_worker(w_input, w_child, w_floor, event_rx, cmd_rx, w_faulted);
+            });
+            Some(Arc::new(Translation {
+                floor,
+                event_tx,
+                child,
+                faulted,
+            }))
+        }
+        Err(e) => {
+            eprintln!("TRANSLATION_SPAWN_FAILED:{path}: {e} — degrading to raw inject");
+            None
+        }
+    }
+}
+
+fn read_translation_path(install_dir: &std::path::Path) -> Option<String> {
+    let s = std::fs::read_to_string(install_dir.join("manifest.toml")).ok()?;
+    let manifest = spt_runtime::Manifest::from_toml_str(&s).ok()?;
+    manifest
+        .message_idle_translation_binary
+        .map(|b| b.path)
+}
+
 // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
 // [impl->REQ-HAZARD-INJECT-CONTROL-COEXIST]
 fn run_inject_worker(
@@ -1390,6 +1454,12 @@ impl Broker {
                         send_error(&send, &msg);
                     }
                 }
+                KIND_ADAPTER_APPLY => {
+                    if let Err(msg) = self.dispatch_adapter_apply(env, &send) {
+                        send_error(&send, &msg);
+                    }
+                }
                 KIND_NET_STATUS => self.dispatch_net_status(&send),
                 KIND_NET_DIAL => {
                     if let Err(msg) = self.dispatch_net_dial(env, &send) {
@@ -1601,41 +1671,10 @@ impl Broker {
         // owns the atomic PTY apply. A spawn failure FAILS CLOSED to raw inject
         // (logged, `None`) rather than aborting the session — a missing binary must
         // never cost the operator their agent.
-        let translation = req.translation_binary.as_deref().and_then(|path| {
-            let (cmd_tx, cmd_rx) = channel::<KeyCmd>();
-            match TranslationChild::spawn(path, cmd_tx) {
-                Ok(child) => {
-                    let child = Arc::new(child);
-                    // First line: identity (content-free of any PTY bytes).
-                    let _ = child.send(&ToBinary::Init {
-                        endpoint_id: req.endpoint.clone(),
-                        node: spt_store::hostlabel::os_hostname().unwrap_or_default(),
-                    });
-                    let floor = Arc::new(Mutex::new(InjectFloor::default()));
-                    let faulted = Arc::new(AtomicBool::new(false));
-                    let (event_tx, event_rx) = channel::<String>();
-                    let w_input = Arc::clone(&input);
-                    let w_child = Arc::clone(&child);
-                    let w_floor = Arc::clone(&floor);
-                    let w_faulted = Arc::clone(&faulted);
-                    thread::spawn(move || {
-                        run_inject_worker(w_input, w_child, w_floor, event_rx, cmd_rx, w_faulted);
-                    });
-                    Some(Arc::new(Translation {
-                        floor,
-                        event_tx,
-                        child,
-                        faulted,
-                    }))
-                }
-                Err(e) => {
-                    eprintln!(
-                        "TRANSLATION_SPAWN_FAILED:{path}: {e} — degrading to raw inject"
-                    );
-                    None
-                }
-            }
-        });
+        let translation = req
+            .translation_binary
+            .as_deref()
+            .and_then(|path| build_translation(path, &req.endpoint, &input));

         self.sessions.lock().unwrap().insert(
             id,
@@ -2250,6 +2289,101 @@ impl Broker {
             .ok_or_else(|| format!("no such session {}", req.session_id))?;
         h.session.kill().map_err(|e| format!("kill failed: {e}"))
     }
+
+    fn dispatch_adapter_apply(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
+        let req: AdapterApplyReq = serde_json::from_value(env.payload)
+            .map_err(|e| format!("bad adapter-apply payload: {e}"))?;
+        let install_dir = std::path::PathBuf::from(&req.install_dir);
+        let staging = std::path::PathBuf::from(&req.staging_dir);
+
+        let affected: Vec<(u64, String, Option<Arc<Translation>>)> = {
+            let map = self.sessions.lock().unwrap();
+            map.iter()
+                .filter(|(_, s)| s.adapter == req.adapter)
+                .map(|(id, s)| (*id, s.endpoint.clone(), s.translation.clone()))
+                .collect()
+        };
+        if affected.is_empty() {
+            send_frame(send, &Envelope::new(KIND_APPLIED, serde_json::Value::Null));
+            return Ok(());
+        }
+
+        let old_path = read_translation_path(&install_dir);
+
+        for (_, _, t) in &affected {
+            if let Some(t) = t {
+                t.child.terminate();
+            }
+        }
+
+        let swap_result = (|| -> Result<(), String> {
+            let plan = crate::crc_swap::plan_crc_swap(&staging, &install_dir)
+                .map_err(|e| e.to_string())?;
+            crate::crc_swap::apply_crc_swap(&plan).map_err(|e| e.to_string())
+        })();
+
+        let restart_path = match &swap_result {
+            Ok(()) => read_translation_path(&install_dir),
+            Err(_) => old_path.clone(),
+        };
+        for (id, endpoint, _) in &affected {
+            let input = {
+                let map = self.sessions.lock().unwrap();
+                map.get(id).map(|s| Arc::clone(&s.input))
+            };
+            let Some(input) = input else {
+                continue; // session exited mid-apply — nothing to restart
+            };
+            let new_t = restart_path
+                .as_deref()
+                .and_then(|p| build_translation(p, endpoint, &input));
+            let mut map = self.sessions.lock().unwrap();
+            if let Some(s) = map.get_mut(id) {
+                s.translation = new_t;
+            }
+        }
+
+        match swap_result {
+            Ok(()) => {
+                send_frame(send, &Envelope::new(KIND_APPLIED, serde_json::Value::Null));
+                Ok(())
+            }
+            Err(e) => Err(format!(
+                "crc swap failed (translations restarted on old version): {e}"
+            )),
+        }
+    }
 }


W3d-ii broker core is **correct** — careful trace:
- **Lock discipline** ✓: sessions Mutex held only for snapshot, input-fetch, re-insert; never across `terminate()`/swap/`build_translation`.
- **Partial-failure** ✓: swap Err → `restart_path = old_path` → re-spawn every affected from OLD bytes (W3b rolled back) → return Err; never translation-less.
- **build_translation** ✓: pure verbatim extract (removed inline block == new fn body); dispatch_spawn calls it, zero semantic change.
- **manifest re-read** ✓ (flag-5): `read_translation_path` re-reads post-swap for the new path.
- **(c) simplification** ✓: only translation cycled (psyche runs from .live-bin, never locks install_dir).

Now the CLI routing + brain client + msg, then gates:


Bash

git diff b62476e..322ca55 -- crates/spt-daemon/src/msg.rs crates/spt-daemon/src/brain.rs | grep -v "^+ *//[^!]" | sed -n '1,90p'
echo "=== CLI routing: adapter_has_live_endpoint + apply_release_via_daemon ==="; git diff b62476e..322ca55 -- crates/spt/src/cli.rs | grep -E "^\+" | grep -iE "fn adapter_has_live|fn apply_release_via|adapter_has_live_endpoint|apply_release_via_daemon|roster|is_perch_alive|adapter_apply" | head -20

Read msg/brain client + CLI routing


diff --git a/crates/spt-daemon/src/brain.rs b/crates/spt-daemon/src/brain.rs
index 3e65b90..26727cc 100644
--- a/crates/spt-daemon/src/brain.rs
+++ b/crates/spt-daemon/src/brain.rs
@@ -34,15 +34,15 @@ use interprocess::local_socket::{SendHalf, Stream};
 use crate::codec::{read_frame, write_frame};
 use crate::frame::{Envelope, Role};
 use crate::msg::{
-    decode_bytes, encode_bytes, AppliedEvent, BrainRestarted, DisplacedEvent, EndpointInjected,
-    EndpointInputReq, ErrorEvent, ExitEvent,
+    decode_bytes, encode_bytes, AdapterApplyReq, AppliedEvent, BrainRestarted, DisplacedEvent,
+    EndpointInjected, EndpointInputReq, ErrorEvent, ExitEvent,
     InputReq,
     KillReq, NetDialReq, NetDialed, NetPresenceEvent, NetPresenceSubscribeReq, NetSent,
     NetStatusReply, NetStreamData, NetStreamEof, NetStreamOpenReq, NetStreamOpened, NetStreamSendReq,
     NetStreamSubscribeReq, NetStreamsReply, OutputEvent, PairJoinReply, PairJoinReq, ResizeReq,
     SessionsReply, SizeEvent, SpawnReq, Spawned, SubscribeOutcome, SubscribeReq, SubscribedReply,
     ViewerEvictedEvent,
-    KIND_APPLIED, KIND_BRAIN_RESTART, KIND_VIEWER_EVICTED,
+    KIND_ADAPTER_APPLY, KIND_APPLIED, KIND_BRAIN_RESTART, KIND_VIEWER_EVICTED,
     KIND_BRAIN_RESTARTED, KIND_DISPLACED, KIND_ENDPOINT_INJECTED, KIND_ENDPOINT_INPUT, KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL, KIND_NET_DIAL,
     KIND_NET_DIALED, KIND_NET_DIAL_LOOPBACK, KIND_NET_PRESENCE_EVENT, KIND_NET_PRESENCE_SUBSCRIBE,
     KIND_NET_SENT,
@@ -972,6 +972,34 @@ impl Brain {
         }
     }

+    pub fn adapter_apply(&mut self, req: &AdapterApplyReq) -> io::Result<()> {
+        self.send(
+            KIND_ADAPTER_APPLY,
+            serde_json::to_value(req).expect("AdapterApplyReq serializes"),
+        )?;
+        loop {
+            let env = self.read_frame_until(None)?;
+            match env.kind.as_str() {
+                KIND_APPLIED => return Ok(()),
+                KIND_ERROR => {
+                    let message = serde_json::from_value::<ErrorEvent>(env.payload)
+                        .map(|e| e.message)
+                        .unwrap_or_else(|_| "adapter apply failed".to_string());
+                    return Err(io::Error::other(message));
+                }
+                _ => continue, // ignore any unrelated frame on the conn
+            }
+        }
+    }
+
     /// Run the joiner leg of the subnet pairing ceremony on the broker (M7
     /// D3, REQ-SUBNET-2): the broker owns the node-identity endpoint the
     /// ceremony binds and pins. Blocks for the ceremony's bounded life; the
diff --git a/crates/spt-daemon/src/msg.rs b/crates/spt-daemon/src/msg.rs
index 20813e2..49c47f6 100644
--- a/crates/spt-daemon/src/msg.rs
+++ b/crates/spt-daemon/src/msg.rs
@@ -38,6 +38,12 @@ pub const KIND_INPUT: &str = "input";
 pub const KIND_RESIZE: &str = "resize";
 /// Brain→broker: terminate a session's child.
 pub const KIND_KILL: &str = "kill";
+pub const KIND_ADAPTER_APPLY: &str = "adapter-apply";
 /// Broker→brain: a chunk of a session's output (pushed as the child produces it).
 pub const KIND_OUTPUT: &str = "output";
 /// Broker→brain: a session's child exited.
@@ -245,6 +251,22 @@ pub struct Spawned {
     pub pid: Option<u32>,
 }

+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AdapterApplyReq {
+    pub adapter: String,
+    pub staging_dir: String,
+    pub install_dir: String,
+}
+
 /// `subscribe` payload — attach this connection as a session's output
 /// subscriber, replaying buffered chunks with `seq >= from_seq` before
 /// streaming live ones. A reconnecting (handed-off) brain passes its
=== CLI routing: adapter_has_live_endpoint + apply_release_via_daemon ===
+/// its update must be **daemon-coordinated** ([`apply_release_via_daemon`]); with
+fn adapter_has_live_endpoint(adapter: &str) -> bool {
+        if on_adapter && spt_store::liveness::is_perch_alive(&perch_path) {
+fn apply_release_via_daemon(
+            .adapter_apply(&spt_daemon::msg::AdapterApplyReq {
+        let applied = if adapter_has_live_endpoint(&record.name) {
+            apply_release_via_daemon(&staged, &dest, &record.name)
+    // [unit->REQ-ADAPTER-LIVE-UPDATE] `adapter_has_live_endpoint` is the gate the
+    fn adapter_has_live_endpoint_detects_matching_live_perch() {
+            spt_store::liveness::is_perch_alive(&perch),
+            adapter_has_live_endpoint("myadapter"),
+    fn adapter_has_live_endpoint_false_for_other_or_dead() {
+            spt_store::liveness::is_perch_alive(&other),
+            !spt_store::liveness::is_perch_alive(&dead),
+            !adapter_has_live_endpoint("myadapter"),


msg/brain/CLI-routing all clean — `adapter_apply` client (send→loop until APPLIED/ERROR), `adapter_has_live_endpoint` (roster × adapter × `is_perch_alive`) routes some-live→daemon, none-live→W3d-i direct, non-vacuous units. Run gates:


cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -2; echo "CLIPPY=${PIPESTATUS[0]}"
echo "=== W3d-ii CLI routing units ==="; timeout 150 cargo test -p spt --bins adapter_has_live_endpoint 2>&1 | grep -E "test cli|test result"
echo "=== traceable ==="; "C:/Users/decid/AppData/Local/Programs/traceable-reqs/traceable-reqs" check >/tmp/tdii.txt 2>&1; echo "EXIT=$?"

W3d-ii gates: clippy + routing units + traceable


    Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.65s
CLIPPY=0
=== W3d-ii CLI routing units ===
test cli::tests::adapter_has_live_endpoint_detects_matching_live_perch ... ok
test cli::tests::adapter_has_live_endpoint_false_for_other_or_dead ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 222 filtered out; finished in 0.03s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
=== traceable ===
EXIT=0


**W3d-ii = GATE-PASS** @322ca55 — the keystone cross-process apply is correct (clippy 0, routing units pass, traceable 0). Report + greenlight W3e (the ship-blocker), then resume the grill:


"$OWL" send todlando doyle <<'EOF'
W3d-ii GATE-PASS @322ca55 — the careful cross-process gate, all clear. Verified the broker apply by reading:
- LOCK DISCIPLINE: sessions Mutex held ONLY for the affected snapshot, the per-session input fetch, and the translation re-insert — DROPPED before terminate(), the crc swap, and build_translation. Correct, no wedge.
- PARTIAL-FAILURE: swap Err → restart_path=old_path → every affected re-spawned from OLD bytes (W3b rolled back) → return Err. Never translation-less, never stopped-without-restart.
- build_translation: verbatim pure extract (the removed dispatch_spawn inline block == the new fn body); dispatch_spawn now calls build_translation(path,&req.endpoint,&input), zero semantic change.
- manifest re-read (flag-5): read_translation_path re-reads install_dir/manifest.toml post-swap → restart uses the NEW path, never the stale cache.
- (c) SIMPLIFICATION: translation-only cycle, no psyche stop/reap — correct (psyche runs from .live-bin, never locks install_dir). Good call.
- IPC clean: KIND_ADAPTER_APPLY/AdapterApplyReq additive; Brain::adapter_apply send+loop-until-APPLIED/ERROR; CLI adapter_has_live_endpoint routes some-live→daemon, none-live→W3d-i direct (units non-vacuous).
clippy 0, routing units pass, traceable 0. The brief terminate→swap→restart window degrades to raw-inject on the faulted child — graceful + bounded. Solid.

GREENLIGHT W3e — the NO-mocks keystone int, the ship-blocker. Gate it HARD against perri's EXACT 4-step repro (real spt-hosted endpoint + dummy-harness + real daemon, NO mocks): bring up live agent → kill psyche parent → endpoint stop → adapter update. PASS = (a) orphan reaped AND/OR (c) psyche never locked the target AND/OR (b) locked-file no longer fails the whole update. The WINDOWS locked-binary case (pre-fix Access-denied os-5, post-fix clean) is THE regression gate. Add the happy-path live-update variant (endpoint stays live, translation stops→CRC-swaps→restarts→serves new binary). Activate REQ-ADAPTER-LIVE-UPDATE int + REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP int at W3e. Reuse the v0.12.1 dummy-harness fixture; SPT_ATTACH_IPC_DEADLINE_MS/watchdog precedent if it flakes on the contended runner. This is the whole point of W3 — make it real. Ping me; I gate it as the ship-blocker.
EOF
echo "---sent $?---"

Report W3d-ii pass + greenlight W3e ship-blocker


➜ SENT:todlando
---sent 0---


W3d-ii gated, W3e ship-blocker greenlit (building async). Resuming the grill — Q6 (Unbound lifecycle + MRU storage):


AskUserQuestion

Question: An UNBOUND endpoint (session live, never bound — e.g. harness exits before bind, or operator never clears the prompt): how does it leave UNBOUND, and is it message-addressable?

Choices:
No new teardown. Daemon stamps UNBOUND at spawn; broker session death → OFFLINE via the EXISTING exit-waiter/reconcile (same path that marks dead-but-latched perches offline, the B2/B5 work). UNBOUND is ATTACHABLE (live session) but NOT message-addressable (no session_id until bind) — messaging stays gated on ONLINE/bound, attach gated on session-exists. UNBOUND auto-resolves: →ONLINE on bind, →OFFLINE on session death.
spawn      → UNBOUND (attachable, not addressable)
bind       → ONLINE  (addressable)
sess death → OFFLINE (existing exit-waiter/reconcile)
NET: no new teardown; messaging bound-gated,
     attach session-gated
A never-bound endpoint times out after N seconds in UNBOUND → auto OFFLINE/purge. Bounds a stuck bringup explicitly, but adds a timer + a new teardown edge, and fights the very goal (you WANT a long-lived attachable UNBOUND so the operator can clear a slow prompt).
spawn → UNBOUND --(N s, no bind)--> OFFLINE/purge
NET: new timer + teardown edge;
     risks killing a slow-to-bind bringup

Question: Where is the MRU (most-recently-chosen home subnet) persisted, and at what scope?

Choices:
One MRU per node, a small state file under $SPT_HOME (or a field on the subnet store). The default-home convenience is node-wide. Simplest; the MRU is only a default (the real home is committed per-endpoint at creation, immutable).
$SPT_HOME/last-home-subnet  (or subnet store field)
  updated whenever a home is chosen (run/--subnet)
  read as the multi-subnet default at next run
NET: one tiny node-global state
MRU keyed by project (cwd/project_id), so each project remembers its own last home. More granular — matches the project-scoped picker model — but more state to store + a fallback chain when a project has no record yet.
MRU keyed by project_id
  project A → SPT_DEV, project B → BIGNET
NET: granular but more state + fallback chain


Your questions have been answered: "An UNBOUND endpoint (session live, never bound — e.g. harness exits before bind, or operator never clears the prompt): how does it leave UNBOUND, and is it message-addressable?"="Reuse reconcile; bound-gated messaging" selected preview:
spawn      → UNBOUND (attachable, not addressable)
bind       → ONLINE  (addressable)
sess death → OFFLINE (existing exit-waiter/reconcile)
NET: no new teardown; messaging bound-gated,
     attach session-gated, "Where is the MRU (most-recently-chosen home subnet) persisted, and at what scope?"=(no option selected) notes: as hinted at before, needs to be a list, not just single-most-recent. additionally, the list should exist on both levels: per-project lists, and then an always-updated node-global list which acts as the fallback for endpoints created in MRU-less projects.. You can now continue with these answers in mind.

Question: An UNBOUND endpoint (session live, never bound — e.g. harness exits before bind, or operator never clears the prompt): how does it leave UNBOUND, and is it message-addressable?

Choices:
No new teardown. Daemon stamps UNBOUND at spawn; broker session death → OFFLINE via the EXISTING exit-waiter/reconcile (same path that marks dead-but-latched perches offline, the B2/B5 work). UNBOUND is ATTACHABLE (live session) but NOT message-addressable (no session_id until bind) — messaging stays gated on ONLINE/bound, attach gated on session-exists. UNBOUND auto-resolves: →ONLINE on bind, →OFFLINE on session death.
spawn      → UNBOUND (attachable, not addressable)
bind       → ONLINE  (addressable)
sess death → OFFLINE (existing exit-waiter/reconcile)
NET: no new teardown; messaging bound-gated,
     attach session-gated
A never-bound endpoint times out after N seconds in UNBOUND → auto OFFLINE/purge. Bounds a stuck bringup explicitly, but adds a timer + a new teardown edge, and fights the very goal (you WANT a long-lived attachable UNBOUND so the operator can clear a slow prompt).
spawn → UNBOUND --(N s, no bind)--> OFFLINE/purge
NET: new timer + teardown edge;
     risks killing a slow-to-bind bringup

Question: Where is the MRU (most-recently-chosen home subnet) persisted, and at what scope?

Choices:
One MRU per node, a small state file under $SPT_HOME (or a field on the subnet store). The default-home convenience is node-wide. Simplest; the MRU is only a default (the real home is committed per-endpoint at creation, immutable).
$SPT_HOME/last-home-subnet  (or subnet store field)
  updated whenever a home is chosen (run/--subnet)
  read as the multi-subnet default at next run
NET: one tiny node-global state
MRU keyed by project (cwd/project_id), so each project remembers its own last home. More granular — matches the project-scoped picker model — but more state to store + a fallback chain when a project has no record yet.
MRU keyed by project_id
  project A → SPT_DEV, project B → BIGNET
NET: granular but more state + fallback chain


W1 — Multi-platform .spt packaging (ADR-0024)

REQ-ADAPTER-MULTIPLATFORM-SPT. Fat archive: shared manifest.toml+strings/ at root, binaries under per-target-triple subdirs. Extract shared root + only current_platform() triple, flatten into install_dir. Mode detection on known-triple subdir; legacy flat = back-compat. min_spt_core_version>=0.13.2 forward gate. --asset optional default adapter.spt. Gate: unit+int(real fixture)+doc. Executor builds, doyle gates.

W2 — gh transport for private repos

REQ-ADAPTER-GH-TRANSPORT. transport = https|gh|auto (default auto) on gh_release avenue + add --release. gh shells gh release download/gh api (private path; honors OAuth+GH_TOKEN, no token custody). --gh/--https force. auto = prefer gh if installed+authed else https. Files: fetch path (http_get_bytes, gh_latest_release_version, stage_gh_release_archive), Update struct transport field. Gate: unit (decision+command shape); int HITL/skip. doc=manifest.md+CONTEXT.

W3 — Live daemon-coordinated update + CRC swap (ADR-0025) KEYSTONE

REQ-ADAPTER-LIVE-UPDATE (ADR-0025). W3a resident-children registry; W3b CRC-gated swap (std hash, no new dep); W3c manifest refresh into running BrainLifecycle; W3d daemon-apply IPC (stop resident→swap→refresh→restart; not-running→CLI direct); W3e int keystone NO mocks (real spt-hosted endpoint + running translation binary; Windows locked-binary regression). Bounded stop/restart (join_bounded, never 240s backstop). Brain never restarts. Partial-failure safe.

PSYCHE-LOCK RESOLUTION (doyle design, absorbs task#7 / perri F-010×F-015 — ADR-0025's resident/ephemeral split assumed psyche=in-daemon but it's a separate install-dir binary that locks the target):
- PRIMARY (c): hosted psyche + any daemon-spawned long-lived install-dir binary runs from a PER-ENDPOINT PRIVATE COPY, never the shared install-dir binary → removes the lock vector entirely; consistent with ephemeral-self-heals-on-next-spawn (next spawn copies updated binary).
- SUPPORTING (a): close F-010 orphan reap on endpoint-stop/agent-death (extend v0.12.0 ID-specific scoped-reap to the stop path) — fixes the lingering-orphan leak independent of updates. Check existing psyche-reap REQ before minting new.
- BACKSTOP (b): extract-to-temp + per-file atomic swap so one busy file never fails the whole update (composes with W3b). NOTE (b) alone can't replace a running locked .exe on Windows — (c)/(a) are primary.
W3 int absorbs perri's 4-step repro (task#7) as the NO-mocks acceptance. May need ADR-0025 amendment for the separate-process-psyche vector + possibly a new REQ for the stop-path orphan reap. Author full W3 design brief after W2 gates.

W4 — update-message

REQ-ADAPTER-UPDATE-MESSAGE. [update].message plain multi-line, markdown-rendered (helpfmt prose path), ONLY when update actually applied (version changed), not no-op. Read from newly-installed manifest, avenue-agnostic. Files: Update struct message:Option<String>; cmd_adapter_update after successful apply→helpfmt::render+print. Gate: unit (rendered on applied, NOT no-op). doc=manifest.md+CONTEXT. No int.

W5 — *-proof --dir/--manifest override (F-011)

REQ-ADAPTER-PROOF-DIR-OVERRIDE. --dir <path>/--manifest <file> on digest-proof+translate-proof → proof DEV binary vs on-disk manifest+dir without full extracted install. Files: cmd_adapter_digest_proof + cmd_adapter_translate_proof accept --dir/--manifest, resolve from there not registry::resolve_option. Gate: unit (override resolves dev manifest+dir); int (mirror proof ints with --dir). doc=proof --help+patterns. May ship v0.13.2 or separate batch.

Release — cut v0.13.2 (deployah role)

After all waves gate-pass + CI green BOTH runners: verify release counter against spt-releases metadata at cut (v0.13.1 lesson, expect 29). Author version bump (workspace.package.version 0.13.1→0.13.2 + CHANGELOG section=release body + Cargo.lock first-party lines). Push bump→CI green both→tag→sign+publish. Coordinate publish ping to perri (drops claude-spt F-014 per-OS stopgap; F-011 closed). W3 int + Windows-lock regression = ship-blockers.

W3 design input — adapter psyche binary locks shared install-dir (perri F-010×F-015)

perri live repro 2026-06-22 (spt 0.13.1, claude-spt 0.6.0, Win): claude-spt-psyche.exe (pid 181272, --id wall-a-psyche) ran DETACHED from shared install-dir, parent (152200) DEAD = orphan (~4h), held binary lock → adapter update/add --release failed (tar exit Some(1)). endpoint stop wall-a set alive=false but did NOT reap the detached psyche.

ROOT MECHANICS: psyche is detached per manifest [session.psyche_init] detach=true (Stdio::null, unsupervised), hosted by daemon livehost off the live_agent online status. It runs from the SHARED install dir (lock surface = manifest.toml + claude-spt-digest.exe + claude-spt-psyche.exe + cc-spt-idle-translate.exe + strings/).

LOAD-BEARING ON W3: ADR-0025 resident/ephemeral split assumed psyche=daemon-hosted in-process (ADR-0004), EXCLUDED from cycling. But it's a SEPARATE install-dir binary → unsupervised locker W3's resident-only stop/restart won't release. Fixes compose: (c) psyche-from-own-copy not shared dir = cleanest structural kill, removes vector independent of reap timing; (a) orphan reap on stop/agent-death = F-010 root close; (b) extract-to-temp+atomic-swap = F-015 backstop (one locked file ≠ whole-update fail).

W3 INT FIXTURE (perri spec, deterministic, NO mocks): 1. bring up claude-spt live agent wall-X → daemon spawns detached psyche from install dir; 2. kill psyche PARENT (livehost/agent) → parent-dead orphan; 3. spt endpoint stop wall-X → alive=false BUT psyche survives (reap gap); 4. spt adapter update claude-spt → tar overwrites locked psyche.exe → exit 1 → whole update fails. PASS post-W3: step 3 reaps orphan AND/OR psyche never ran from shared target AND/OR locked-file extract no longer fails whole update. perri re-runs this exact repro as the ship acceptance test on doyle ping.

v0.13.3 fast-follow — multi-subnet endpoint-run bind LATENT GAP (perri)

LATENT GAP (perri, 2026-06-22) — NOT a version regression (≥0.11.0 all HOME_REFUSED). spt-hosted bringup never supported multi-subnet home; BIGNET 2nd-subnet crossing exposed it. Single-subnet unaffected. F-013 class. v0.13.3 EXPEDITE (operator declined stopgap, blocked by choice).

TWO REQs (grilled with operator 2026-06-22; grill ~80%, PAUSED for v0.13.2 W3d-ii keystone gate):

REQ-A — multi-subnet home resolution:
- Home IMMUTABLE (ADR-0010). Gap is ONLY first-creation on multi-subnet (re-bind inherits via establish_perch prior-branch).
- MECHANISM: reuse existing skeleton→live (CONTEXT:113/159). endpoint run creates the skeleton perch WITH resolved home BEFORE spawn (currently does NOT — spawns straight to launch_harness_brokered_in cli.rs:1384, bind creates fresh None-branch → assign_home → HOME_REFUSED). bind flips skeleton→live inheriting home (prior-branch). NO hook change, NO env (supersedes original 3-part env design).
- HOME RESOLUTION at skeleton-create: single→auto; multi no --subnet → NON-INTERACTIVE refuse (std::io::IsTerminal, cli.rs:1006/3956) w/ MRU-ordered --subnet guidance; INTERACTIVE → print proposed config (name/project/adapter[:profile]/home=MRU) + "Ok to proceed? Y/n", n→--subnet guidance. --subnet overrides+validates (assign_home NotMember).
- MRU: default home = most-recently-chosen subnet, persisted (location/scope TBD - grill).

REQ-B — attach-on-session-exists + Unbound state:
- Current: cmd_endpoint_run awaits perch STATUS_ONLINE (await_endpoint_online cli.rs:1434) before attach → deadlocks when a harness prompt blocks bind (operator can't see it).
- FIX: gate attach on the BROKER SESSION being attachable (broker creates session+PTY+OutputLog at spawn, BEFORE bind), not perch-online. run_attach + `spt rc <id>` attach to a live session regardless of perch status (works for headless bringups too). Source = broker sessions map (W3a endpoint field). Local-only (remote rows render plain, like controllability).
- NEW on-disk status STATUS_UNBOUND (operator chose on-disk over derived): spawn→UNBOUND, bind→ONLINE, death→OFFLINE. New reconcile/teardown edges to keep honest.
- DISPLAY: EpDisplay gains Unbound (picker/model.rs — currently Offline-gray/Online-green/HarnessOnly-amber/Controlled-blue). amber is TAKEN (HarnessOnly=not-controllable, semantic opposite of Unbound which IS attachable). Use HOLLOW treatment (operator: "hollow is good precedent") + a CONTROLLED-VARIANT of hollow (Unbound + driven_by).

STILL TO GRILL (paused): skeleton/Unbound lifecycle cleanup (teardown if never binds — now visible via UNBOUND so operator can act; session-death→OFFLINE; purge); MRU storage/scope; ADR (skeleton-with-home creation flow + STATUS_UNBOUND + attach-on-session — likely warrants one ADR). Then mint REQ-A + REQ-B, dispatch todlando after v0.13.2. perri committing repro script (regression test MUST run multi-subnet).

Tracking v0.13.3 multi-subnet bind regression


Q6 captured. **MRU = a LIST (not single), at two levels**: per-project lists + an always-updated node-global list (fallback for MRU-less projects); default = top of project list ?? node-global; the interactive picker/`--subnet` guidance lists subnets MRU-ordered.

The design is now comprehensive — and notably **bigger than a "fast-follow patch."** Capturing in task #8, then the final grill decision (ADR + scope/sequencing):


TaskUpdate

LATENT GAP (perri, 2026-06-22) — NOT a regression (≥0.11.0 all HOME_REFUSED). spt-hosted bringup never supported multi-subnet home; BIGNET crossing exposed it. F-013 class. Operator blocked by choice (declined stopgap).

DESIGN COMPLETE (grilled with operator 2026-06-22). Grown well beyond a patch — see SCOPE note.

REQ-A — multi-subnet home resolution:
- Home IMMUTABLE (ADR-0010). Gap only on first-creation (re-bind inherits via establish_perch prior-branch).
- Reuse skeleton→live (CONTEXT:113/159): endpoint run creates skeleton perch WITH resolved home BEFORE spawn (currently doesn't — bind creates fresh None-branch → assign_home → HOME_REFUSED @home.rs). bind inherits via prior-branch. NO hook change, NO env (supersedes original 3-part env design).
- Resolution: single→auto; multi no --subnet → NON-INTERACTIVE refuse (std::io::IsTerminal) w/ MRU-ordered guidance; INTERACTIVE → proposed config (name/project/adapter[:profile]/home=MRU-top) + "Ok to proceed? Y/n", n→--subnet guidance. --subnet overrides+validates (assign_home NotMember).
- MRU = LISTs (ordered, move-to-front), TWO levels: per-project + always-updated node-global (fallback for MRU-less projects). Default = project-list-top ?? node-global-top. Both updated on every home choice.

REQ-B — attach-on-session-exists + Unbound state:
- Gate attach on BROKER SESSION attachable (created at spawn, before bind), not perch-online. run_attach + `spt rc <id>` attach to a live session regardless of perch status (headless bringups too). Replaces await_endpoint_online (cli.rs:1434).
- NEW on-disk STATUS_UNBOUND: spawn→UNBOUND, bind→ONLINE, death→OFFLINE.
- LIFECYCLE: reuse existing exit-waiter/reconcile (session death→OFFLINE, the B2/B5 path) — no new teardown. UNBOUND = ATTACHABLE but NOT message-addressable (no session_id until bind); messaging stays ONLINE/bound-gated, attach session-gated. Auto-resolves.
- DISPLAY: EpDisplay gains Unbound (picker/model.rs; amber=HarnessOnly is TAKEN + means not-controllable = opposite of Unbound). HOLLOW treatment + a CONTROLLED-VARIANT of hollow (Unbound + driven_by).

SCOPE NOTE: this is now a meaty feature set (new on-disk status, EpDisplay variants, liveness-predicate edits, attach-gate change, skeleton-creation flow, two-level MRU, interactive confirm) — larger than a fast-follow patch. Scope/sequencing being decided (split minimal-unblock vs full-rework). ADR(s) to write. perri repro = REQ-A regression test (MUST run multi-subnet). Dispatch after v0.13.2.


Updated task #8 description


AskUserQuestion

Question: This grew past a fast-follow patch (new on-disk status, EpDisplay variants, attach-gate rework, skeleton-creation flow, two-level MRU). How to scope/sequence it relative to unblocking the operator now?

Choices:
Split: minimal unblock now, rest as milestone
v0.13.3 ships REQ-A CORE only — endpoint run resolves home (--subnet + skeleton-with-home + non-interactive-refuse / interactive-confirm + node-global MRU). That unblocks the operator's multi-subnet bringup fast. The fuller rework (REQ-B Unbound state + attach-ASAP + per-project MRU lists + hollow display) lands as a deliberate follow-on milestone. Each piece tested in isolation.
v0.13.3 (fast): REQ-A core
  --subnet + skeleton-home + confirm/refuse
  + node-global MRU  → operator UNBLOCKED
M-next: REQ-B Unbound + attach-ASAP
  + per-project MRU + hollow display
Ship the entire endpoint-run creation-flow rework (REQ-A + REQ-B) as one release. Cohesive UX in a single landing, but the operator stays multi-subnet-blocked longer while the bigger piece is built + the keystone-risk (new on-disk status, attach-gate) is gated.
v0.13.3 (or v0.14.0): REQ-A + REQ-B together
  home + skeleton + MRU-lists + Unbound
  + attach-ASAP + hollow display
  — one landing; operator blocked until done

Question: How to capture the design as ADR(s)? (All three ADR criteria are met: hard-to-reverse on-disk STATUS_UNBOUND + attach-gate basis; surprising; real trade-offs.)

Choices:
ADR-0026: multi-subnet home at endpoint-run creation (skeleton-with-home + interactive-confirm + two-level MRU; refines ADR-0010's creation seam). ADR-0027: Unbound endpoint state + attach-on-session-exists (new STATUS_UNBOUND, EpDisplay, attach-gate basis). Maps 1:1 to REQ-A / REQ-B; each independently reviewable + matches the split.
ADR-0026  multi-subnet home @ creation (REQ-A)
ADR-0027  Unbound state + attach-on-session (REQ-B)
— 1:1 with the REQs + the scope split
A single ADR covering the whole endpoint-run interactive creation flow + Unbound state. Cohesive narrative, but bundles two independently-reversible decisions (home-resolution vs the liveness-model change) into one record.
ADR-0026  endpoint-run creation flow + Unbound
  (home + skeleton + MRU + Unbound + attach)
— one record, two decisions bundled


Your questions have been answered: "This grew past a fast-follow patch (new on-disk status, EpDisplay variants, attach-gate rework, skeleton-creation flow, two-level MRU). How to scope/sequence it relative to unblocking the operator now?"="All together, one milestone" selected preview:
v0.13.3 (or v0.14.0): REQ-A + REQ-B together
  home + skeleton + MRU-lists + Unbound
  + attach-ASAP + hollow display
  — one landing; operator blocked until done, "How to capture the design as ADR(s)? (All three ADR criteria are met: hard-to-reverse on-disk STATUS_UNBOUND + attach-gate basis; surprising; real trade-offs.)"="Two ADRs" selected preview:
ADR-0026  multi-subnet home @ creation (REQ-A)
ADR-0027  Unbound state + attach-on-session (REQ-B)
— 1:1 with the REQs + the scope split. You can now continue with these answers in mind.

Question: This grew past a fast-follow patch (new on-disk status, EpDisplay variants, attach-gate rework, skeleton-creation flow, two-level MRU). How to scope/sequence it relative to unblocking the operator now?

Choices:
Split: minimal unblock now, rest as milestone
v0.13.3 ships REQ-A CORE only — endpoint run resolves home (--subnet + skeleton-with-home + non-interactive-refuse / interactive-confirm + node-global MRU). That unblocks the operator's multi-subnet bringup fast. The fuller rework (REQ-B Unbound state + attach-ASAP + per-project MRU lists + hollow display) lands as a deliberate follow-on milestone. Each piece tested in isolation.
v0.13.3 (fast): REQ-A core
  --subnet + skeleton-home + confirm/refuse
  + node-global MRU  → operator UNBLOCKED
M-next: REQ-B Unbound + attach-ASAP
  + per-project MRU + hollow display
Ship the entire endpoint-run creation-flow rework (REQ-A + REQ-B) as one release. Cohesive UX in a single landing, but the operator stays multi-subnet-blocked longer while the bigger piece is built + the keystone-risk (new on-disk status, attach-gate) is gated.
v0.13.3 (or v0.14.0): REQ-A + REQ-B together
  home + skeleton + MRU-lists + Unbound
  + attach-ASAP + hollow display
  — one landing; operator blocked until done

Question: How to capture the design as ADR(s)? (All three ADR criteria are met: hard-to-reverse on-disk STATUS_UNBOUND + attach-gate basis; surprising; real trade-offs.)

Choices:
ADR-0026: multi-subnet home at endpoint-run creation (skeleton-with-home + interactive-confirm + two-level MRU; refines ADR-0010's creation seam). ADR-0027: Unbound endpoint state + attach-on-session-exists (new STATUS_UNBOUND, EpDisplay, attach-gate basis). Maps 1:1 to REQ-A / REQ-B; each independently reviewable + matches the split.
ADR-0026  multi-subnet home @ creation (REQ-A)
ADR-0027  Unbound state + attach-on-session (REQ-B)
— 1:1 with the REQs + the scope split
A single ADR covering the whole endpoint-run interactive creation flow + Unbound state. Cohesive narrative, but bundles two independently-reversible decisions (home-resolution vs the liveness-model change) into one record.
ADR-0026  endpoint-run creation flow + Unbound
  (home + skeleton + MRU + Unbound + attach)
— one record, two decisions bundled


Grill complete: **all-together as one milestone** (operator accepts the wait for a cohesive landing — so this is a milestone, not a v0.13.3 patch) + **two ADRs** (0026 home@creation = REQ-A; 0027 Unbound+attach-on-session = REQ-B).

Now capturing the design per the grill contract (2 ADRs + CONTEXT glossary + REQ registration). Grounding the house format + latest ADR number + git state first (these design docs belong off the v0.13.2 branch):


Bash

echo "=== latest ADR number ==="; ls docs/adr/ | sort | tail -4
echo "=== ADR-FORMAT (skill) ==="; cat "C:\Users\decid\.ccs\instances\bigscreen\skills\grill-with-docs\ADR-FORMAT.md" 2>/dev/null | head -60
echo "=== git state ==="; git status --short | head; git branch --show-current

Latest ADR + ADR format + git state


<owl_messages>
<owl_message from="todlando" priority="high">W3e DONE @92ee788 (pushed). The NO-MOCKS keystone int — the ship-blocker. W3 is COMPLETE end to end. Ready for your ship-blocker gate (+ loop perri to validate her exact repro).

TWO real-daemon ints (real `spt daemon run` + real broker + real fixtures, NO mocks), both PASS deterministically on this Windows host (14s + 3s):

1. live_adapt_psyche_lock_e2e [int->REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP + int->REQ-ADAPTER-LIVE-UPDATE] — perri's 4-step, all green: live agent up; (c) psyche spawns from <perch>/.live-bin/; the shared install-dir source binary is OVERWRITABLE while the psyche runs (lock-free — a shared-install-dir spawn would os-5 lock on Windows: the (b)+(c) proof); kill the brain PARENT only (/F no /T) -> psyche orphans; `endpoint stop` -> the cmdline-scoped fail-safe-decline guard REAPS the brain-less orphan (the test never kills it).

2. live_adapt_translation_swap_e2e [int->REQ-ADAPTER-LIVE-UPDATE] — the keystone apply: a `cc` adapter whose [message-idle-translation-binary] lives IN the install dir; broker spawns it; #[cfg(windows)] LOCK REGRESSION asserted (direct overwrite of the running <install_dir>/xlate.exe FAILS os-5 — locked); Brain::adapter_apply -> broker stops translation (frees lock) -> CRC-swap -> restart -> swap LANDED on the previously-locked binary AND the endpoint never restarted (brain-parity: harness_alive_after + still_online).

NOTE on the repro's step 4: the int drives the APPLY via the real broker IPC (Brain::adapter_apply — the exact code path cmd_adapter_update routes some-live to), NOT `spt adapter update` (whose version query needs GitHub; the network leg is REQ-UPD-9/W2, orthogonal to the W3 apply). Still zero mocks — real daemon/broker/translation binary.

DOCS/REQ: ADR-0025 -> ACCEPTED with the W3 amendment (3 corrections: psyche-is-a-separate-install-dir-binary; re-clone-not-implementable [broker != brain PROCESS — corrects your verdict caution-ii]; + the 3rd axis ephemeral-but-install-dir-locking->own-copy). REQ-ADAPTER-LIVE-UPDATE + REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP both now [doc,impl,unit,int].

GOTCHA worth your awareness: test bins are named `live_adapt_*` not `live_update_*` — the latter triggers Windows UAC name-elevation (os error 740: the update/setup/install/patch heuristic). Same hazard class as KH windows-uac-binary-name.

GATES (ran myself): clippy --workspace --all-targets -D warnings clean; traceable exit 0 (289/289, both REQs +int); xtask drift OK; both e2e pass. Scope-audited (only the 2 new test files + ADR + toml; no prod/Cargo edits).

W3 FULL CHAIN: W3a+W3b @b49d4a0, W3c @97ff869, (a)+(c) @d6d7816, W3d-i @b62476e, W3d-ii @322ca55, W3e @92ee788 — all gate-passed except this final W3e awaiting your verdict. On your gate + perri's validation, deployah cuts v0.13.2 (counter 29 — verify vs spt-releases metadata, the v0.13.1 lesson). Standing by.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


=== latest ADR number ===
0022-spt-hosted-idle-delivery-translation-binary.md
0023-always-on-endpoints-resident-supervised-binary-sigil-addressing.md
0024-multi-platform-adapter-spt-packaging.md
0025-live-daemon-coordinated-adapter-update.md
=== ADR-FORMAT (skill) ===
# ADR Format

ADRs live in `docs/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc.

Create the `docs/adr/` directory lazily — only when the first ADR is needed.

## Template

```md
# {Short title of the decision}

{1-3 sentences: what's the context, what did we decide, and why.}
```

That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections.

## Optional sections

Only include these when they add genuine value. Most ADRs won't need them.

- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited
- **Considered Options** — only when the rejected alternatives are worth remembering
- **Consequences** — only when non-obvious downstream effects need to be called out

## Numbering

Scan `docs/adr/` for the highest existing number and increment by one.

## When to offer an ADR

All three of these must be true:

1. **Hard to reverse** — the cost of changing your mind later is meaningful
2. **Surprising without context** — a future reader will look at the code and wonder "why on earth 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 a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing."

### What qualifies

- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres."
- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP."
- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out.
- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s.
- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate.
- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract."
- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months.
=== git state ===
v0.13.2-adapter-packaging


W3e — the **NO-MOCKS keystone int, the ship-blocker** — landed @92ee788, W3 complete end-to-end. This is the gate that matters most; pausing the ADR-writing (that's future-milestone capture) to gate it hard. Fetch + read both real-daemon int files:


git pull --ff-only --quiet 2>&1 | tail -1; echo "===HEAD==="; git log --oneline -1 | cat; echo "===W3e diff stat 322ca55..92ee788==="; git diff --stat 322ca55..92ee788 | cat; echo "=== NO-MOCKS sanity: any mock/stub in the new test files? ==="; git diff 322ca55..92ee788 --name-only | grep "tests/" | while read f; do echo "--$f--"; grep -ic "mock\|stub\|fake" "$f" 2>/dev/null; done

Fetch W3e + diff stat + mock sanity check


===HEAD===
92ee788 test(daemon): W3e no-mocks keystone int + ADR-0025 accepted (live update)
===W3e diff stat 322ca55..92ee788===
 crates/spt/tests/live_adapt_psyche_lock_e2e.rs     | 386 ++++++++++++++++++++
 .../spt/tests/live_adapt_translation_swap_e2e.rs   | 394 +++++++++++++++++++++
 .../0025-live-daemon-coordinated-adapter-update.md |  17 +-
 traceable-reqs.toml                                |   4 +-
 4 files changed, 798 insertions(+), 3 deletions(-)
=== NO-MOCKS sanity: any mock/stub in the new test files? ===
--crates/spt/tests/live_adapt_psyche_lock_e2e.rs--
9
--crates/spt/tests/live_adapt_translation_swap_e2e.rs--
8


9 and 8 "mock" hits — need to confirm these are the `mock-session` dummy-harness fixture name / "NO mocks" comments, not actual daemon/broker mocking. Read the keystone translation-swap int (Windows lock regression + brain-parity):


1	//! W3e NO-MOCKS int B — REQ-ADAPTER-LIVE-UPDATE (v0.13.2 live adapter update).
2	//!
3	//! The broker-coordinated live apply, the keystone PASS: a daemon-hosted endpoint
4	//! is running a resident translation child FROM its install dir; a live adapter
5	//! update routes (because the endpoint is live) through the broker —
6	//! `Brain::adapter_apply` → stop the resident translation child (frees the OS lock)
7	//! → CRC-swap the install dir → restart the translation from the new manifest path
8	//! — and the previously-LOCKED install-dir binary is overwritten with the NEW bytes
9	//! WHILE the endpoint/session keeps running untouched (brain-parity, no restart).
10	//!
11	//! Real `spt daemon run` broker + supervised brain + a REAL dummy-harness
12	//! `[session.self]` session + a REAL translation binary (`translate_proof_fixture`,
13	//! a sibling `[[bin]]`) living IN the swap-target install dir. NO mocks.
14	//!
15	//! Windows lock regression (`#[cfg(windows)]`): while the translation child runs,
16	//! overwriting `<install_dir>/xlate.exe` DIRECTLY fails (os error 5 — the running
17	//! .exe is locked). That is the bug the daemon-coordinated apply fixes: it stops the
18	//! child first, so the swap lands on the now-unlocked binary. On Unix a running exe
19	//! is not locked, so the pre-assert is skipped; the apply flow still runs.
20	//!
21	//! Bounded everywhere; every spawned daemon / brain / harness / translation pid is
22	//! reaped SCOPED at test end (a survivor locks target\debug and breaks the next
23	//! build). Run as its own integration binary (own process → no SPT_HOME race):
24	//!   cargo test -p spt --test live_update_translation_swap_e2e -- --test-threads=1
25
26	use std::path::{Path, PathBuf};
27	use std::process::{Child, Command, Output, Stdio};
28	use std::time::{Duration, Instant};
29
30	mod common;
31	use common::CommandNoWindowExt;
32
33	use spt_daemon::brain::Brain;
34	use spt_daemon::endpoint::broker_socket_name;
35	use spt_daemon::msg::AdapterApplyReq;
36	use spt_store::perch::{self, ParentHint};
37
38	/// Scoped pid-tree kill (cleanup) — never machine-wide.
39	fn kill_pid(pid: u32) {
40	    #[cfg(windows)]
41	    let _ = Command::new("taskkill")
42	        .no_window()
43	        .args(["/PID", &pid.to_string(), "/F", "/T"])
44	        .output();
45	    #[cfg(unix)]
46	    let _ = Command::new("kill").args(["-9", &pid.to_string()]).output();
47	}
48
49	/// `Command::output()` with a deadline, off-thread — bounds a hang into a failure.
50	fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
51	    let (tx, rx) = std::sync::mpsc::channel();
52	    std::thread::spawn(move || {
53	        let _ = tx.send(cmd.output());
54	    });
55	    rx.recv_timeout(deadline)
56	        .expect("captured spt call must complete within its deadline")
57	        .expect("run spt")
58	}
59
60	/// Poll `brain.ready` for the brain pid, up to `budget`.
61	fn wait_for_ready_pid(path: &Path, budget: Duration) -> Option<u32> {
62	    let deadline = Instant::now() + budget;
63	    while Instant::now() < deadline {
64	        if let Ok(s) = std::fs::read_to_string(path) {
65	            if let Some(pid) = serde_json::from_str::<serde_json::Value>(&s)
66	                .ok()
67	                .and_then(|v| v.get("pid").and_then(|p| p.as_u64()))
68	            {
69	                return Some(pid as u32);
70	            }
71	        }
72	        std::thread::sleep(Duration::from_millis(50));
73	    }
74	    None
75	}
76
77	fn sibling_bin(name: &str) -> PathBuf {
78	    PathBuf::from(env!("CARGO_BIN_EXE_spt"))
79	        .with_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX))
80	}
81
82	/// Lowercase-hex SHA-256 of a file (so we can assert the swap landed NEW bytes).
83	fn file_hash(path: &Path) -> Option<String> {
84	    use sha2::{Digest, Sha256};
85	    let bytes = std::fs::read(path).ok()?;
86	    let mut h = Sha256::new();
87	    h.update(&bytes);
88	    let digest = h.finalize();
89	    let mut s = String::with_capacity(digest.len() * 2);
90	    for b in digest {
91	        s.push_str(&format!("{b:02x}"));
92	    }
93	    Some(s)
94	}
95
96	// [int->REQ-ADAPTER-LIVE-UPDATE]
97	#[test]
98	fn adapter_apply_swaps_locked_translation_binary_without_restarting_endpoint() {
99	    let now_ms = || {
100	        std::time::SystemTime::now()
101	            .duration_since(std::time::UNIX_EPOCH)
102	            .map(|d| d.as_millis() as u64)
103	            .unwrap_or(0)
104	    };
105
106	    // ── (1) Isolated, NET-LESS SPT_HOME. ──
107	    let home = tempfile::tempdir().unwrap();
108	    std::env::set_var("SPT_HOME", home.path());
109	    let identity_dir = home.path().join("identity");
110	    std::fs::create_dir_all(&identity_dir).unwrap();
111	    std::fs::write(identity_dir.join("node.key"), "not-a-valid-seed").unwrap();
112
113	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
114	    let exe_suffix = spt_bin
115	        .extension()
116	        .map(|e| format!(".{}", e.to_string_lossy()))
117	        .unwrap_or_default();
118	    let mock_session = sibling_bin("mock-session");
119	    let xlate_fixture = sibling_bin("translate_proof_fixture");
120	    assert!(
121	        mock_session.exists(),
122	        "the dummy-harness program must be built: {}",
123	        mock_session.display()
124	    );
125	    assert!(
126	        xlate_fixture.exists(),
127	        "the translation-binary fixture must be built: {} (cargo test -p spt builds it)",
128	        xlate_fixture.display()
129	    );
130
131	    // ── (2) Register adapter `cc`: a long-lived dummy-harness `[session.self]` PLUS a
132	    //    `[message-idle-translation-binary]` whose `path` is the install-dir copy of
133	    //    the fixture (`<install_dir>/xlate<EXE_SUFFIX>`). The install dir is
134	    //    `SPT_HOME/srcs/cc` (the record's source_dir = the W3a swap target). We COPY
135	    //    the fixture there so the translation binary lives IN the swap target. ──
136	    let install_dir = perch::spt_home().join("srcs").join("cc");
137	    std::fs::create_dir_all(&install_dir).unwrap();
138	    let xlate_install = install_dir.join(format!("xlate{exe_suffix}"));
139	    std::fs::copy(&xlate_fixture, &xlate_install).expect("stage xlate into the install dir");
140
141	    let spt_for_cmd = spt_bin.to_string_lossy().replace('\\', "/");
142	    let mock_for_cmd = mock_session.to_string_lossy().replace('\\', "/");
143	    let xlate_for_toml = xlate_install.to_string_lossy().replace('\\', "/");
144	    let self_cmd = format!(
145	        "{mock_for_cmd} --mode dummy --id {{id}} --session-id {{session_id}} \
146	         --spt {spt_for_cmd} --mock-adapter cc --type live_agent --heartbeat-ms 200"
147	    );
148	    let psyche_bin = install_dir.join(format!("psychebin{exe_suffix}"));
149	    std::fs::copy(&spt_bin, &psyche_bin).expect("stage the psyche binary fixture");
150	    let drops = home.path().join("drops");
151	    std::fs::create_dir_all(&drops).unwrap();
152	    let dir = drops.to_string_lossy().replace('\\', "/");
153	    // The manifest declares BOTH the long-lived session AND the translation binary at
154	    // the absolute install-dir path. `read_translation_path(install_dir)` reads THIS
155	    // file (`<install_dir>/manifest.toml`) before AND after the swap, so the path must
156	    // be stable (absolute) across the swap.
157	    let manifest_toml = format!(
158	        "[adapter]\nname=\"cc\"\nkind=\"harness\"\nversion=\"1\"\n\
159	         min_spt_core_version=\"0\"\n\n[session.self]\ncommand='{self_cmd}'\n\n\
160	         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
161	         [session.psyche_init]\ncommand='psychebin ready {{id}}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n\n\
162	         [message-idle-translation-binary]\npath = '{xlate_for_toml}'\n"
163	    );
164	    std::fs::write(install_dir.join("manifest.toml"), &manifest_toml).unwrap();
165	    spt_runtime::registry::register(&perch::adapters_dir(), &install_dir, 1000).unwrap();
166
167	    // ── (3) The REAL daemon broker process. ──
168	    let brain_log = home.path().join("brain.stderr.log");
169	    let brain_log_file = std::fs::File::create(&brain_log).expect("create brain stderr log");
170	    let mut broker: Child = Command::new(&spt_bin)
171	        .no_window()
172	        .args(["daemon", "run"])
173	        .env("SPT_HOME", home.path())
174	        .stdout(Stdio::null())
175	        .stderr(Stdio::from(brain_log_file))
176	        .spawn()
177	        .expect("spawn spt daemon run (broker process)");
178	    let brain_pid =
179	        match wait_for_ready_pid(&home.path().join("brain.ready"), Duration::from_secs(30)) {
180	            Some(p) => p,
181	            None => {
182	                let _ = broker.kill();
183	                let _ = broker.wait();
184	                panic!(
185	                    "PRECONDITION: brain never came up.\n{}",
186	                    std::fs::read_to_string(&brain_log).unwrap_or_default()
187	                );
188	            }
189	        };
190
191	    // ── (4) `endpoint run --adapter cc --id ccX --start`: the broker spawns the
192	    //    harness session AND the resident translation child from `<install_dir>/xlate`
193	    //    (on Windows the running .exe is now locked). Wait for the perch ONLINE. ──
194	    let id = "ccX";
195	    let run = {
196	        let mut cmd = Command::new(&spt_bin);
197	        cmd.no_window()
198	            .args(["endpoint", "run", "--adapter", "cc", "--id", id, "--start"])
199	            .env("SPT_HOME", home.path());
200	        output_bounded(cmd, Duration::from_secs(45))
201	    };
202	    let run_stderr = String::from_utf8_lossy(&run.stderr).to_string();
203	    let harness_pid: Option<u32> = run_stderr
204	        .split("pid=")
205	        .nth(1)
206	        .and_then(|s| s.split_whitespace().next())
207	        .and_then(|s| s.trim().parse().ok());
208
209	    let self_perch = perch::resolve_perch_path(id, ParentHint::Infer);
210	    let status = || spt_store::info::read_info(&self_perch).and_then(|i| i.status);
211	    let mut online = false;
212	    let deadline = Instant::now() + Duration::from_secs(25);
213	    while Instant::now() < deadline {
214	        if status().as_deref() == Some(spt_store::liveness::STATUS_ONLINE) {
215	            online = true;
216	            break;
217	        }
218	        std::thread::sleep(Duration::from_millis(150));
219	    }
220
221	    // Give the resident translation child a beat to spawn from the install-dir binary
222	    // (it spawns right after the session, on the broker dispatch_spawn path).
223	    std::thread::sleep(Duration::from_millis(800));
224
225	    // ── (5) WINDOWS LOCK REGRESSION (`#[cfg(windows)]`): while the translation child
226	    //    runs, overwriting `<install_dir>/xlate.exe` DIRECTLY must FAIL (os error 5).
227	    //    This proves the binary IS locked — the exact condition the daemon-coordinated
228	    //    apply must work around (stop child → swap → restart). On Unix this won't lock,
229	    //    so we skip the pre-assert and just proceed to the apply. ──
230	    #[cfg(windows)]
231	    let direct_overwrite_locked = {
232	        use std::io::Write;
233	        // A direct truncate-write of the running .exe — the naive "just overwrite the
234	        // install dir" a non-daemon-coordinated update would attempt.
235	        let res = std::fs::OpenOptions::new()
236	            .write(true)
237	            .truncate(true)
238	            .open(&xlate_install)
239	            .and_then(|mut f| f.write_all(b"clobber"));
240	        res.is_err()
241	    };
242
243	    // ── (6) Build the STAGING tree: a dir with manifest.toml (SAME translation path)
244	    //    + xlate<EXE_SUFFIX> = the fixture bytes WITH a trailing-byte change (different
245	    //    sha256; PE/ELF tolerate trailing data — see brain_survive.rs). The CRC-swap
246	    //    copies every diffing file from staging onto the install dir. ──
247	    let staging = home.path().join("staging-cc");
248	    std::fs::create_dir_all(&staging).unwrap();
249	    // New xlate = fixture + trailing padding (behavior identical, hash flips).
250	    let xlate_staged = staging.join(format!("xlate{exe_suffix}"));
251	    std::fs::copy(&xlate_fixture, &xlate_staged).expect("stage new xlate");
252	    {
253	        use std::io::Write;
254	        let mut f = std::fs::OpenOptions::new()
255	            .append(true)
256	            .open(&xlate_staged)
257	            .expect("open staged xlate to append padding");
258	        f.write_all(&[0u8; 4096]).expect("append padding to staged xlate");
259	    }
260	    // The staging manifest declares the SAME absolute translation path (so the
261	    // post-swap install-dir manifest still points the broker at `<install_dir>/xlate`).
262	    std::fs::write(staging.join("manifest.toml"), &manifest_toml).unwrap();
263
264	    let hash_before = file_hash(&xlate_install);
265	    let hash_staged = file_hash(&xlate_staged);
266	    assert_ne!(
267	        hash_before, hash_staged,
268	        "PRECONDITION: the staged xlate must differ from the install-dir xlate (so a \
269	         landed swap is observable as a hash flip)"
270	    );
271
272	    // ── (7) The broker-coordinated apply: connect a Brain and call adapter_apply. The
273	    //    broker stops the resident translation child (frees the lock), CRC-swaps the
274	    //    install dir from staging, restarts the translation from the new path. ──
275	    let mut brain = Brain::cold_start(&broker_socket_name(), now_ms())
276	        .expect("connect a Brain to the live broker for adapter_apply");
277	    let apply_result = brain.adapter_apply(&AdapterApplyReq {
278	        adapter: "cc".to_string(),
279	        staging_dir: staging.to_string_lossy().into_owned(),
280	        install_dir: install_dir.to_string_lossy().into_owned(),
281	    });
282	    drop(brain);
283
284	    // ── (8) POST assertions (the keystone PASS):
285	    //    (a) the install-dir xlate now has the NEW bytes (hash flipped → the swap
286	    //        LANDED on the previously-locked binary);
287	    //    (b) the session/endpoint is STILL alive (no restart — brain-parity): the
288	    //        harness pid is alive AND the perch is still ONLINE. ──
289	    let hash_after = file_hash(&xlate_install);
290	    let swap_landed = hash_after.is_some()
291	        && hash_after == hash_staged
292	        && hash_after != hash_before;
293
294	    let harness_alive_after = harness_pid
295	        .map(spt_store::proc::is_process_alive)
296	        .unwrap_or(false);
297	    // The perch should remain ONLINE across the apply (the session was never torn
298	    // down). Poll briefly in case info.json lags; the apply must not knock it offline.
299	    let mut still_online = false;
300	    let online_deadline = Instant::now() + Duration::from_secs(8);
301	    while Instant::now() < online_deadline {
302	        if status().as_deref() == Some(spt_store::liveness::STATUS_ONLINE) {
303	            still_online = true;
304	            break;
305	        }
306	        std::thread::sleep(Duration::from_millis(150));
307	    }
308
309	    let brain_stderr = std::fs::read_to_string(&brain_log).unwrap_or_default();
310	    eprintln!(
311	        "=== DIAGNOSTIC (W3e int B): run_ok={} online={online} harness_pid={harness_pid:?} \
312	         apply_ok={} swap_landed={swap_landed} hash_before={hash_before:?} \
313	         hash_after={hash_after:?} hash_staged={hash_staged:?} \
314	         harness_alive_after={harness_alive_after} still_online={still_online} ===\n\
315	         === endpoint run stderr ===\n{run_stderr}\n=== brain stderr ===\n{brain_stderr}",
316	        run.status.success(),
317	        apply_result.is_ok(),
318	    );
319
320	    // ── (9) Reap the ENTIRE tree SCOPED before asserting. ──
321	    if let Some(p) = harness_pid {
322	        kill_pid(p);
323	    }
324	    let psyche_perch =
325	        perch::resolve_perch_path(&format!("{id}-psyche"), ParentHint::Explicit(id));
326	    if let Some(p) = spt_store::info::read_pid(&psyche_perch) {
327	        kill_pid(p);
328	    }
329	    let _ = {
330	        let mut cmd = Command::new(&spt_bin);
331	        cmd.no_window().args(["daemon", "stop"]).env("SPT_HOME", home.path());
332	        output_bounded(cmd, Duration::from_secs(20))
333	    };
334	    kill_pid(brain_pid);
335	    let _ = broker.kill();
336	    let _ = broker.wait();
337
338	    // ── ASSERTIONS ──
339	    assert!(
340	        run.status.success(),
341	        "endpoint run --start must succeed: {run_stderr}"
342	    );
343	    assert!(
344	        online,
345	        "PRECONDITION: the cc harness must bind its perch ONLINE before the apply.\n\
346	         === brain stderr ===\n{brain_stderr}"
347	    );
348
349	    // The Windows lock regression: the running translation .exe WAS locked (a naive
350	    // direct overwrite failed), which is exactly why the daemon must coordinate.
351	    #[cfg(windows)]
352	    assert!(
353	        direct_overwrite_locked,
354	        "REQ-ADAPTER-LIVE-UPDATE (Windows regression): while the resident translation \
355	         child runs, a DIRECT overwrite of <install_dir>/xlate.exe must FAIL (os error 5 \
356	         — the running .exe is locked). It SUCCEEDED, so this host did not actually \
357	         spawn/lock the translation child — the apply test would be vacuous. ({})\n\
358	         === brain stderr ===\n{brain_stderr}",
359	        xlate_install.display()
360	    );
361
362	    assert!(
363	        apply_result.is_ok(),
364	        "REQ-ADAPTER-LIVE-UPDATE: Brain::adapter_apply must return Ok (the broker stopped \
365	         the resident translation child, CRC-swapped the install dir, and restarted the \
366	         translation) — got {:?}.\n=== brain stderr ===\n{brain_stderr}",
367	        apply_result.err().map(|e| e.to_string())
368	    );
369
370	    // THE KEYSTONE: the swap landed the NEW bytes on the previously-LOCKED binary.
371	    assert!(
372	        swap_landed,
373	        "REQ-ADAPTER-LIVE-UPDATE: after adapter_apply the install-dir translation binary \
374	         {} must hold the NEW (staged) bytes — the daemon-coordinated swap must land on \
375	         the previously-locked .exe. hash_before={hash_before:?} hash_after={hash_after:?} \
376	         hash_staged={hash_staged:?}.\n=== brain stderr ===\n{brain_stderr}",
377	        xlate_install.display()
378	    );
379
380	    // Brain-parity: the endpoint NEVER restarted across the live update.
381	    assert!(
382	        harness_alive_after,
383	        "REQ-ADAPTER-LIVE-UPDATE (brain-parity): the harness session must STILL be ALIVE \
384	         after the live update — only the translation child cycles, the endpoint never \
385	         restarts. harness_pid={harness_pid:?} was dead post-apply.\n\
386	         === brain stderr ===\n{brain_stderr}"
387	    );
388	    assert!(
389	        still_online,
390	        "REQ-ADAPTER-LIVE-UPDATE (brain-parity): the endpoint perch must remain ONLINE \
391	         across the live update (the session was never torn down).\n\
392	         === brain stderr ===\n{brain_stderr}"
393	    );
394	}
395

//! W3e NO-MOCKS int B — REQ-ADAPTER-LIVE-UPDATE (v0.13.2 live adapter update).
//!
//! The broker-coordinated live apply, the keystone PASS: a daemon-hosted endpoint
//! is running a resident translation child FROM its install dir; a live adapter
//! update routes (because the endpoint is live) through the broker —
//! `Brain::adapter_apply` → stop the resident translation child (frees the OS lock)
//! → CRC-swap the install dir → restart the translation from the new manifest path
//! — and the previously-LOCKED install-dir binary is overwritten with the NEW bytes
//! WHILE the endpoint/session keeps running untouched (brain-parity, no restart).
//!
//! Real `spt daemon run` broker + supervised brain + a REAL dummy-harness
//! `[session.self]` session + a REAL translation binary (`translate_proof_fixture`,
//! a sibling `[[bin]]`) living IN the swap-target install dir. NO mocks.
//!
//! Windows lock regression (`#[cfg(windows)]`): while the translation child runs,
//! overwriting `<install_dir>/xlate.exe` DIRECTLY fails (os error 5 — the running
//! .exe is locked). That is the bug the daemon-coordinated apply fixes: it stops the
//! child first, so the swap lands on the now-unlocked binary. On Unix a running exe
//! is not locked, so the pre-assert is skipped; the apply flow still runs.
//!
//! Bounded everywhere; every spawned daemon / brain / harness / translation pid is
//! reaped SCOPED at test end (a survivor locks target\debug and breaks the next
//! build). Run as its own integration binary (own process → no SPT_HOME race):
//!   cargo test -p spt --test live_update_translation_swap_e2e -- --test-threads=1

use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};

mod common;
use common::CommandNoWindowExt;

use spt_daemon::brain::Brain;
use spt_daemon::endpoint::broker_socket_name;
use spt_daemon::msg::AdapterApplyReq;
use spt_store::perch::{self, ParentHint};

/// Scoped pid-tree kill (cleanup) — never machine-wide.
fn kill_pid(pid: u32) {
    #[cfg(windows)]
    let _ = Command::new("taskkill")
        .no_window()
        .args(["/PID", &pid.to_string(), "/F", "/T"])
        .output();
    #[cfg(unix)]
    let _ = Command::new("kill").args(["-9", &pid.to_string()]).output();
}

/// `Command::output()` with a deadline, off-thread — bounds a hang into a failure.
fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(cmd.output());
    });
    rx.recv_timeout(deadline)
        .expect("captured spt call must complete within its deadline")
        .expect("run spt")
}

/// Poll `brain.ready` for the brain pid, up to `budget`.
fn wait_for_ready_pid(path: &Path, budget: Duration) -> Option<u32> {
    let deadline = Instant::now() + budget;
    while Instant::now() < deadline {
        if let Ok(s) = std::fs::read_to_string(path) {
            if let Some(pid) = serde_json::from_str::<serde_json::Value>(&s)
                .ok()
                .and_then(|v| v.get("pid").and_then(|p| p.as_u64()))
            {
                return Some(pid as u32);
            }
        }
        std::thread::sleep(Duration::from_millis(50));
    }
    None
}

fn sibling_bin(name: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_spt"))
        .with_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX))
}

/// Lowercase-hex SHA-256 of a file (so we can assert the swap landed NEW bytes).
fn file_hash(path: &Path) -> Option<String> {
    use sha2::{Digest, Sha256};
    let bytes = std::fs::read(path).ok()?;
    let mut h = Sha256::new();
    h.update(&bytes);
    let digest = h.finalize();
    let mut s = String::with_capacity(digest.len() * 2);
    for b in digest {
        s.push_str(&format!("{b:02x}"));
    }
    Some(s)
}

// [int->REQ-ADAPTER-LIVE-UPDATE]
#[test]
fn adapter_apply_swaps_locked_translation_binary_without_restarting_endpoint() {
    let now_ms = || {
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0)
    };

    // ── (1) Isolated, NET-LESS SPT_HOME. ──
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());
    let identity_dir = home.path().join("identity");
    std::fs::create_dir_all(&identity_dir).unwrap();
    std::fs::write(identity_dir.join("node.key"), "not-a-valid-seed").unwrap();

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let exe_suffix = spt_bin
        .extension()
        .map(|e| format!(".{}", e.to_string_lossy()))
        .unwrap_or_default();
    let mock_session = sibling_bin("mock-session");
    let xlate_fixture = sibling_bin("translate_proof_fixture");
    assert!(
        mock_session.exists(),
        "the dummy-harness program must be built: {}",
        mock_session.display()
    );
    assert!(
        xlate_fixture.exists(),
        "the translation-binary fixture must be built: {} (cargo test -p spt builds it)",
        xlate_fixture.display()
    );

    // ── (2) Register adapter `cc`: a long-lived dummy-harness `[session.self]` PLUS a
    //    `[message-idle-translation-binary]` whose `path` is the install-dir copy of
    //    the fixture (`<install_dir>/xlate<EXE_SUFFIX>`). The install dir is
    //    `SPT_HOME/srcs/cc` (the record's source_dir = the W3a swap target). We COPY
    //    the fixture there so the translation binary lives IN the swap target. ──
    let install_dir = perch::spt_home().join("srcs").join("cc");
    std::fs::create_dir_all(&install_dir).unwrap();
    let xlate_install = install_dir.join(format!("xlate{exe_suffix}"));
    std::fs::copy(&xlate_fixture, &xlate_install).expect("stage xlate into the install dir");

    let spt_for_cmd = spt_bin.to_string_lossy().replace('\\', "/");
    let mock_for_cmd = mock_session.to_string_lossy().replace('\\', "/");
    let xlate_for_toml = xlate_install.to_string_lossy().replace('\\', "/");
    let self_cmd = format!(
        "{mock_for_cmd} --mode dummy --id {{id}} --session-id {{session_id}} \
         --spt {spt_for_cmd} --mock-adapter cc --type live_agent --heartbeat-ms 200"
    );
    let psyche_bin = install_dir.join(format!("psychebin{exe_suffix}"));
    std::fs::copy(&spt_bin, &psyche_bin).expect("stage the psyche binary fixture");
    let drops = home.path().join("drops");
    std::fs::create_dir_all(&drops).unwrap();
    let dir = drops.to_string_lossy().replace('\\', "/");
    // The manifest declares BOTH the long-lived session AND the translation binary at
    // the absolute install-dir path. `read_translation_path(install_dir)` reads THIS
    // file (`<install_dir>/manifest.toml`) before AND after the swap, so the path must
    // be stable (absolute) across the swap.
    let manifest_toml = format!(
        "[adapter]\nname=\"cc\"\nkind=\"harness\"\nversion=\"1\"\n\
         min_spt_core_version=\"0\"\n\n[session.self]\ncommand='{self_cmd}'\n\n\
         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
         [session.psyche_init]\ncommand='psychebin ready {{id}}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n\n\
         [message-idle-translation-binary]\npath = '{xlate_for_toml}'\n"
    );
    std::fs::write(install_dir.join("manifest.toml"), &manifest_toml).unwrap();
    spt_runtime::registry::register(&perch::adapters_dir(), &install_dir, 1000).unwrap();

    // ── (3) The REAL daemon broker process. ──
    let brain_log = home.path().join("brain.stderr.log");
    let brain_log_file = std::fs::File::create(&brain_log).expect("create brain stderr log");
    let mut broker: Child = Command::new(&spt_bin)
        .no_window()
        .args(["daemon", "run"])
        .env("SPT_HOME", home.path())
        .stdout(Stdio::null())
        .stderr(Stdio::from(brain_log_file))
        .spawn()
        .expect("spawn spt daemon run (broker process)");
    let brain_pid =
        match wait_for_ready_pid(&home.path().join("brain.ready"), Duration::from_secs(30)) {
            Some(p) => p,
            None => {
                let _ = broker.kill();
                let _ = broker.wait();
                panic!(
                    "PRECONDITION: brain never came up.\n{}",
                    std::fs::read_to_string(&brain_log).unwrap_or_default()
                );
            }
        };

    // ── (4) `endpoint run --adapter cc --id ccX --start`: the broker spawns the
    //    harness session AND the resident translation child from `<install_dir>/xlate`
    //    (on Windows the running .exe is now locked). Wait for the perch ONLINE. ──
    let id = "ccX";
    let run = {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window()
            .args(["endpoint", "run", "--adapter", "cc", "--id", id, "--start"])
            .env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(45))
    };
    let run_stderr = String::from_utf8_lossy(&run.stderr).to_string();
    let harness_pid: Option<u32> = run_stderr
        .split("pid=")
        .nth(1)
        .and_then(|s| s.split_whitespace().next())
        .and_then(|s| s.trim().parse().ok());

    let self_perch = perch::resolve_perch_path(id, ParentHint::Infer);
    let status = || spt_store::info::read_info(&self_perch).and_then(|i| i.status);
    let mut online = false;
    let deadline = Instant::now() + Duration::from_secs(25);
    while Instant::now() < deadline {
        if status().as_deref() == Some(spt_store::liveness::STATUS_ONLINE) {
            online = true;
            break;
        }
        std::thread::sleep(Duration::from_millis(150));
    }

    // Give the resident translation child a beat to spawn from the install-dir binary
    // (it spawns right after the session, on the broker dispatch_spawn path).
    std::thread::sleep(Duration::from_millis(800));

    // ── (5) WINDOWS LOCK REGRESSION (`#[cfg(windows)]`): while the translation child
    //    runs, overwriting `<install_dir>/xlate.exe` DIRECTLY must FAIL (os error 5).
    //    This proves the binary IS locked — the exact condition the daemon-coordinated
    //    apply must work around (stop child → swap → restart). On Unix this won't lock,
    //    so we skip the pre-assert and just proceed to the apply. ──
    #[cfg(windows)]
    let direct_overwrite_locked = {
        use std::io::Write;
        // A direct truncate-write of the running .exe — the naive "just overwrite the
        // install dir" a non-daemon-coordinated update would attempt.
        let res = std::fs::OpenOptions::new()
            .write(true)
            .truncate(true)
            .open(&xlate_install)
            .and_then(|mut f| f.write_all(b"clobber"));
        res.is_err()
    };

    // ── (6) Build the STAGING tree: a dir with manifest.toml (SAME translation path)
    //    + xlate<EXE_SUFFIX> = the fixture bytes WITH a trailing-byte change (different
    //    sha256; PE/ELF tolerate trailing data — see brain_survive.rs). The CRC-swap
    //    copies every diffing file from staging onto the install dir. ──
    let staging = home.path().join("staging-cc");
    std::fs::create_dir_all(&staging).unwrap();
    // New xlate = fixture + trailing padding (behavior identical, hash flips).
    let xlate_staged = staging.join(format!("xlate{exe_suffix}"));
    std::fs::copy(&xlate_fixture, &xlate_staged).expect("stage new xlate");
    {
        use std::io::Write;
        let mut f = std::fs::OpenOptions::new()
            .append(true)
            .open(&xlate_staged)
            .expect("open staged xlate to append padding");
        f.write_all(&[0u8; 4096]).expect("append padding to staged xlate");
    }
    // The staging manifest declares the SAME absolute translation path (so the
    // post-swap install-dir manifest still points the broker at `<install_dir>/xlate`).
    std::fs::write(staging.join("manifest.toml"), &manifest_toml).unwrap();

    let hash_before = file_hash(&xlate_install);
    let hash_staged = file_hash(&xlate_staged);
    assert_ne!(
        hash_before, hash_staged,
        "PRECONDITION: the staged xlate must differ from the install-dir xlate (so a \
         landed swap is observable as a hash flip)"
    );

    // ── (7) The broker-coordinated apply: connect a Brain and call adapter_apply. The
    //    broker stops the resident translation child (frees the lock), CRC-swaps the
    //    install dir from staging, restarts the translation from the new path. ──
    let mut brain = Brain::cold_start(&broker_socket_name(), now_ms())
        .expect("connect a Brain to the live broker for adapter_apply");
    let apply_result = brain.adapter_apply(&AdapterApplyReq {
        adapter: "cc".to_string(),
        staging_dir: staging.to_string_lossy().into_owned(),
        install_dir: install_dir.to_string_lossy().into_owned(),
    });
    drop(brain);

    // ── (8) POST assertions (the keystone PASS):
    //    (a) the install-dir xlate now has the NEW bytes (hash flipped → the swap
    //        LANDED on the previously-locked binary);
    //    (b) the session/endpoint is STILL alive (no restart — brain-parity): the
    //        harness pid is alive AND the perch is still ONLINE. ──
    let hash_after = file_hash(&xlate_install);
    let swap_landed = hash_after.is_some()
        && hash_after == hash_staged
        && hash_after != hash_before;

    let harness_alive_after = harness_pid
        .map(spt_store::proc::is_process_alive)
        .unwrap_or(false);
    // The perch should remain ONLINE across the apply (the session was never torn
    // down). Poll briefly in case info.json lags; the apply must not knock it offline.
    let mut still_online = false;
    let online_deadline = Instant::now() + Duration::from_secs(8);
    while Instant::now() < online_deadline {
        if status().as_deref() == Some(spt_store::liveness::STATUS_ONLINE) {
            still_online = true;
            break;
        }
        std::thread::sleep(Duration::from_millis(150));
    }

    let brain_stderr = std::fs::read_to_string(&brain_log).unwrap_or_default();
    eprintln!(
        "=== DIAGNOSTIC (W3e int B): run_ok={} online={online} harness_pid={harness_pid:?} \
         apply_ok={} swap_landed={swap_landed} hash_before={hash_before:?} \
         hash_after={hash_after:?} hash_staged={hash_staged:?} \
         harness_alive_after={harness_alive_after} still_online={still_online} ===\n\
         === endpoint run stderr ===\n{run_stderr}\n=== brain stderr ===\n{brain_stderr}",
        run.status.success(),
        apply_result.is_ok(),
    );

    // ── (9) Reap the ENTIRE tree SCOPED before asserting. ──
    if let Some(p) = harness_pid {
        kill_pid(p);
    }
    let psyche_perch =
        perch::resolve_perch_path(&format!("{id}-psyche"), ParentHint::Explicit(id));
    if let Some(p) = spt_store::info::read_pid(&psyche_perch) {
        kill_pid(p);
    }
    let _ = {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window().args(["daemon", "stop"]).env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(20))
    };
    kill_pid(brain_pid);
    let _ = broker.kill();
    let _ = broker.wait();

    // ── ASSERTIONS ──
    assert!(
        run.status.success(),
        "endpoint run --start must succeed: {run_stderr}"
    );
    assert!(
        online,
        "PRECONDITION: the cc harness must bind its perch ONLINE before the apply.\n\
         === brain stderr ===\n{brain_stderr}"
    );

    // The Windows lock regression: the running translation .exe WAS locked (a naive
    // direct overwrite failed), which is exactly why the daemon must coordinate.
    #[cfg(windows)]
    assert!(
        direct_overwrite_locked,
        "REQ-ADAPTER-LIVE-UPDATE (Windows regression): while the resident translation \
         child runs, a DIRECT overwrite of <install_dir>/xlate.exe must FAIL (os error 5 \
         — the running .exe is locked). It SUCCEEDED, so this host did not actually \
         spawn/lock the translation child — the apply test would be vacuous. ({})\n\
         === brain stderr ===\n{brain_stderr}",
        xlate_install.display()
    );

    assert!(
        apply_result.is_ok(),
        "REQ-ADAPTER-LIVE-UPDATE: Brain::adapter_apply must return Ok (the broker stopped \
         the resident translation child, CRC-swapped the install dir, and restarted the \
         translation) — got {:?}.\n=== brain stderr ===\n{brain_stderr}",
        apply_result.err().map(|e| e.to_string())
    );

    // THE KEYSTONE: the swap landed the NEW bytes on the previously-LOCKED binary.
    assert!(
        swap_landed,
        "REQ-ADAPTER-LIVE-UPDATE: after adapter_apply the install-dir translation binary \
         {} must hold the NEW (staged) bytes — the daemon-coordinated swap must land on \
         the previously-locked .exe. hash_before={hash_before:?} hash_after={hash_after:?} \
         hash_staged={hash_staged:?}.\n=== brain stderr ===\n{brain_stderr}",
        xlate_install.display()
    );

    // Brain-parity: the endpoint NEVER restarted across the live update.
    assert!(
        harness_alive_after,
        "REQ-ADAPTER-LIVE-UPDATE (brain-parity): the harness session must STILL be ALIVE \
         after the live update — only the translation child cycles, the endpoint never \
         restarts. harness_pid={harness_pid:?} was dead post-apply.\n\
         === brain stderr ===\n{brain_stderr}"
    );
    assert!(
        still_online,
        "REQ-ADAPTER-LIVE-UPDATE (brain-parity): the endpoint perch must remain ONLINE \
         across the live update (the session was never torn down).\n\
         === brain stderr ===\n{brain_stderr}"
    );
}


W3e int B (translation swap) is **excellent** — real `spt daemon run` broker + brain + dummy-harness (`mock-session` = fixture *name*, not a code mock) + real translation binary in the install dir, isolated tempdir SPT_HOME (doesn't touch the live daemon), scoped PID-tree cleanup. The Windows lock-regression pre-assert (direct overwrite FAILS os-5) is the **non-vacuity guard** — without it the apply test would be hollow. Keystone assert (swap_landed hash-flip on the previously-locked binary) + brain-parity (harness_alive + still_online). Read int A (perri's 4-step):


96	#[test]
97	fn live_update_own_copy_frees_install_binary_and_stop_reaps_orphan_psyche() {
98	    // ── (1) Isolated, NET-LESS SPT_HOME (corrupt node.key → no net; the brain still
99	    //    hosts the psyche locally, and this test never needs the net loopback). ──
100	    let home = tempfile::tempdir().unwrap();
101	    std::env::set_var("SPT_HOME", home.path());
102	    let identity_dir = home.path().join("identity");
103	    std::fs::create_dir_all(&identity_dir).unwrap();
104	    std::fs::write(identity_dir.join("node.key"), "not-a-valid-seed").unwrap();
105
106	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
107	    let exe_suffix = spt_bin
108	        .extension()
109	        .map(|e| format!(".{}", e.to_string_lossy()))
110	        .unwrap_or_default();
111	    let mock_session = sibling_bin("mock-session");
112	    assert!(
113	        mock_session.exists(),
114	        "the dummy-harness program must be built: {} \
115	         (pre-build: cargo build -p mock-adapter --bin mock-session)",
116	        mock_session.display()
117	    );
118
119	    // ── (2) Register the DUMMY-HARNESS adapter (verbatim shape from
120	    //    dummy_harness_e2e): a long-lived kind=harness `[session.self]` dummy that
121	    //    binds + heartbeats, and a `[session.psyche_init]` whose program token
122	    //    `psychebin` is a COPY of `spt` run as the blocking `ready {id}` listener.
123	    //    The brain's reconcile then hosts a RESIDENT `{id}-psyche` — the W3 own-copy
124	    //    target. The install dir is `SPT_HOME/srcs/dummyharness` (the swap/own-copy
125	    //    source for `psychebin`). ──
126	    let spt_for_cmd = spt_bin.to_string_lossy().replace('\\', "/");
127	    let mock_for_cmd = mock_session.to_string_lossy().replace('\\', "/");
128	    let self_cmd = format!(
129	        "{mock_for_cmd} --mode dummy --id {{id}} --session-id {{session_id}} \
130	         --spt {spt_for_cmd} --mock-adapter dummyharness --type live_agent --heartbeat-ms 200"
131	    );
132	    let src = perch::spt_home().join("srcs").join("dummyharness");
133	    std::fs::create_dir_all(&src).unwrap();
134	    let psyche_bin = src.join(format!("psychebin{exe_suffix}"));
135	    std::fs::copy(&spt_bin, &psyche_bin).expect("stage the psyche binary fixture");
136	    let drops = home.path().join("drops");
137	    std::fs::create_dir_all(&drops).unwrap();
138	    let dir = drops.to_string_lossy().replace('\\', "/");
139	    let manifest_toml = format!(
140	        "[adapter]\nname=\"dummyharness\"\nkind=\"harness\"\nversion=\"1\"\n\
141	         min_spt_core_version=\"0\"\n\n[session.self]\ncommand='{self_cmd}'\n\n\
142	         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
143	         [session.psyche_init]\ncommand='psychebin ready {{id}}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
144	    );
145	    std::fs::write(src.join("manifest.toml"), &manifest_toml).unwrap();
146	    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
147
148	    // ── (3) The REAL daemon broker process (spawns the supervised brain). ──
149	    let brain_log = home.path().join("brain.stderr.log");
150	    let brain_log_file = std::fs::File::create(&brain_log).expect("create brain stderr log");
151	    let mut broker: Child = Command::new(&spt_bin)
152	        .no_window()
153	        .args(["daemon", "run"])
154	        .env("SPT_HOME", home.path())
155	        .stdout(Stdio::null())
156	        .stderr(Stdio::from(brain_log_file))
157	        .spawn()
158	        .expect("spawn spt daemon run (broker process)");
159	    let brain_pid =
160	        match wait_for_ready_pid(&home.path().join("brain.ready"), Duration::from_secs(30)) {
161	            Some(p) => p,
162	            None => {
163	                let _ = broker.kill();
164	                let _ = broker.wait();
165	                panic!(
166	                    "PRECONDITION: brain never came up.\n{}",
167	                    std::fs::read_to_string(&brain_log).unwrap_or_default()
168	                );
169	            }
170	        };
171
172	    // ── STEP 1: `spt endpoint run --id wallX --start` → broker spawns the dummy into
173	    //    a PTY; the dummy binds + the brain reconcile hosts the resident psyche. Wait
174	    //    until the `{wallX}-psyche` perch has a LIVE pid (bounded). ──
175	    let id = "wallX";
176	    let run = {
177	        let mut cmd = Command::new(&spt_bin);
178	        cmd.no_window()
179	            .args(["endpoint", "run", "--adapter", "dummyharness", "--id", id, "--start"])
180	            .env("SPT_HOME", home.path());
181	        output_bounded(cmd, Duration::from_secs(45))
182	    };
183
184	    let psyche_perch =
185	        perch::resolve_perch_path(&format!("{id}-psyche"), ParentHint::Explicit(id));
186	    let mut psyche_pid: Option<u32> = None;
187	    let hosted_deadline = Instant::now() + Duration::from_secs(30);
188	    while Instant::now() < hosted_deadline {
189	        if let Some(p) = spt_store::info::read_pid(&psyche_perch) {
190	            if spt_store::proc::is_process_alive(p) {
191	                psyche_pid = Some(p);
192	                break;
193	            }
194	        }
195	        std::thread::sleep(Duration::from_millis(200));
196	    }
197	    let psyche_pid = match psyche_pid {
198	        Some(p) => p,
199	        None => {
200	            let _ = {
201	                let mut cmd = Command::new(&spt_bin);
202	                cmd.no_window().args(["daemon", "stop"]).env("SPT_HOME", home.path());
203	                output_bounded(cmd, Duration::from_secs(20))
204	            };
205	            kill_pid(brain_pid);
206	            let _ = broker.kill();
207	            let _ = broker.wait();
208	            panic!(
209	                "PRECONDITION: the brain never hosted a resident `{id}-psyche`.\n\
210	                 === endpoint run stderr ===\n{}\n=== brain stderr ===\n{}",
211	                String::from_utf8_lossy(&run.stderr),
212	                std::fs::read_to_string(&brain_log).unwrap_or_default(),
213	            );
214	        }
215	    };

#[test]
fn live_update_own_copy_frees_install_binary_and_stop_reaps_orphan_psyche() {
    // ── (1) Isolated, NET-LESS SPT_HOME (corrupt node.key → no net; the brain still
    //    hosts the psyche locally, and this test never needs the net loopback). ──
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());
    let identity_dir = home.path().join("identity");
    std::fs::create_dir_all(&identity_dir).unwrap();
    std::fs::write(identity_dir.join("node.key"), "not-a-valid-seed").unwrap();

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let exe_suffix = spt_bin
        .extension()
        .map(|e| format!(".{}", e.to_string_lossy()))
        .unwrap_or_default();
    let mock_session = sibling_bin("mock-session");
    assert!(
        mock_session.exists(),
        "the dummy-harness program must be built: {} \
         (pre-build: cargo build -p mock-adapter --bin mock-session)",
        mock_session.display()
    );

    // ── (2) Register the DUMMY-HARNESS adapter (verbatim shape from
    //    dummy_harness_e2e): a long-lived kind=harness `[session.self]` dummy that
    //    binds + heartbeats, and a `[session.psyche_init]` whose program token
    //    `psychebin` is a COPY of `spt` run as the blocking `ready {id}` listener.
    //    The brain's reconcile then hosts a RESIDENT `{id}-psyche` — the W3 own-copy
    //    target. The install dir is `SPT_HOME/srcs/dummyharness` (the swap/own-copy
    //    source for `psychebin`). ──
    let spt_for_cmd = spt_bin.to_string_lossy().replace('\\', "/");
    let mock_for_cmd = mock_session.to_string_lossy().replace('\\', "/");
    let self_cmd = format!(
        "{mock_for_cmd} --mode dummy --id {{id}} --session-id {{session_id}} \
         --spt {spt_for_cmd} --mock-adapter dummyharness --type live_agent --heartbeat-ms 200"
    );
    let src = perch::spt_home().join("srcs").join("dummyharness");
    std::fs::create_dir_all(&src).unwrap();
    let psyche_bin = src.join(format!("psychebin{exe_suffix}"));
    std::fs::copy(&spt_bin, &psyche_bin).expect("stage the psyche binary fixture");
    let drops = home.path().join("drops");
    std::fs::create_dir_all(&drops).unwrap();
    let dir = drops.to_string_lossy().replace('\\', "/");
    let manifest_toml = format!(
        "[adapter]\nname=\"dummyharness\"\nkind=\"harness\"\nversion=\"1\"\n\
         min_spt_core_version=\"0\"\n\n[session.self]\ncommand='{self_cmd}'\n\n\
         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
         [session.psyche_init]\ncommand='psychebin ready {{id}}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
    );
    std::fs::write(src.join("manifest.toml"), &manifest_toml).unwrap();
    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();

    // ── (3) The REAL daemon broker process (spawns the supervised brain). ──
    let brain_log = home.path().join("brain.stderr.log");
    let brain_log_file = std::fs::File::create(&brain_log).expect("create brain stderr log");
    let mut broker: Child = Command::new(&spt_bin)
        .no_window()
        .args(["daemon", "run"])
        .env("SPT_HOME", home.path())
        .stdout(Stdio::null())
        .stderr(Stdio::from(brain_log_file))
        .spawn()
        .expect("spawn spt daemon run (broker process)");
    let brain_pid =
        match wait_for_ready_pid(&home.path().join("brain.ready"), Duration::from_secs(30)) {
            Some(p) => p,
            None => {
                let _ = broker.kill();
                let _ = broker.wait();
                panic!(
                    "PRECONDITION: brain never came up.\n{}",
                    std::fs::read_to_string(&brain_log).unwrap_or_default()
                );
            }
        };

    // ── STEP 1: `spt endpoint run --id wallX --start` → broker spawns the dummy into
    //    a PTY; the dummy binds + the brain reconcile hosts the resident psyche. Wait
    //    until the `{wallX}-psyche` perch has a LIVE pid (bounded). ──
    let id = "wallX";
    let run = {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window()
            .args(["endpoint", "run", "--adapter", "dummyharness", "--id", id, "--start"])
            .env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(45))
    };

    let psyche_perch =
        perch::resolve_perch_path(&format!("{id}-psyche"), ParentHint::Explicit(id));
    let mut psyche_pid: Option<u32> = None;
    let hosted_deadline = Instant::now() + Duration::from_secs(30);
    while Instant::now() < hosted_deadline {
        if let Some(p) = spt_store::info::read_pid(&psyche_perch) {
            if spt_store::proc::is_process_alive(p) {
                psyche_pid = Some(p);
                break;
            }
        }
        std::thread::sleep(Duration::from_millis(200));
    }
    let psyche_pid = match psyche_pid {
        Some(p) => p,
        None => {
            let _ = {
                let mut cmd = Command::new(&spt_bin);
                cmd.no_window().args(["daemon", "stop"]).env("SPT_HOME", home.path());
                output_bounded(cmd, Duration::from_secs(20))
            };
            kill_pid(brain_pid);
            let _ = broker.kill();
            let _ = broker.wait();
            panic!(
                "PRECONDITION: the brain never hosted a resident `{id}-psyche`.\n\
                 === endpoint run stderr ===\n{}\n=== brain stderr ===\n{}",
                String::from_utf8_lossy(&run.stderr),
                std::fs::read_to_string(&brain_log).unwrap_or_default(),
            );
        }
    };