[[requirements]]
id = "REQ-ENDPOINT-STOP-OFFLINE"
title = "H3: `spt endpoint stop <id>` marks the endpoint OFFLINE (alive=false), not merely de-readied. cmd_stop (cli.rs:2994-3010) removes the ready marker + unregisters the address but does NOT set status offline, so a stopped daemon-hosted endpoint still reports alive=true (status=online latch). FIX: add set_status(perch, STATUS_OFFLINE) to cmd_stop — folds with B2 (same setter). Unit: stop → is_perch_alive=false / alive=false. (v0.12.0)"
required_stages = ["impl", "unit"]  # activated v0.12.0 wave 2: impl = cmd_stop set_status STATUS_OFFLINE (folds the B2 setter); unit = endpoint_stop_marks_offline (stop stamps offline → is_perch_alive=false)
[[requirements]]
id = "REQ-HAZARD-DAEMON-STOP-BARRIER"
title = "B3: `spt daemon stop` then an immediate `spt daemon start` does NOT race — stop fully completes before it returns. Today request_stop (seedmap.rs:240-255) returns on the KIND_STOPPING ack (sent seedmap.rs:174-176) BEFORE the seed socket unbinds, so a following is_running ping (daemon.rs:375) wins the exit window and start reports ALREADY_RUNNING (operator: daemon stop → STOPPED then start → ALREADY_RUNNING). FIX: unbind/stop-gate the seed socket BEFORE acking KIND_STOPPING, OR request_stop waits for a ping-to-fail before returning. Unit: stop then immediate is_running()==false. (v0.12.0)"
required_stages = []  # registry-first; activate [impl,unit] when built — wave 3 (with Breap). impl = stop barrier (socket unbind before ack OR wait-ping-fail); unit = post-stop is_running false immediately
[[requirements]]
id = "REQ-HAZARD-DAEMON-STOP-REAP"
title = "Breap: `spt daemon stop` REAPS the spt-hosted children it spawned — no orphaned psyche/harness processes. Today a stop leaves ~8 orphaned claude-spt-psyche.exe + spt.exe: Psyches are spawned DETACHED (runtime.rs:342-356, the Child is dropped — 'Detached' ~349) and the livehost stop flag Arc<AtomicBool> is NEVER raised (brainproc.rs:227-230 holds it 'for symmetry'). FIX: on stop, raise the livehost stop flag AND kill the spawned psyche/spt-hosted children — via a Windows job object / Unix process-group so the children die with the daemon (not detached-immortal). Folds with B3 (both the stop path). (v0.12.0)"
required_stages = []  # registry-first; activate [impl,unit] when built — wave 3 (with B3). impl = raise livehost stop flag on daemon stop + job-object/process-group child reap; unit = stop terminates the spawned children
[[requirements]]
id = "REQ-HAZARD-LIVEHOST-BOOT-LIVENESS-GATE"
title = "B5: `spt daemon start` does NOT revive phantom Psyches for dead-but-online-latched perches. Today reconcile_once (livehost.rs:285) spawns a Psyche per status=online live_agent perch at boot WITHOUT verifying the harness child / {id}-psyche is actually alive — so a Cold start after an unclean stop revives N psyches for N dead-but-latched perches (3 psyches for 3 dead perches). FIX: gate the boot psyche-spawn on real child-liveness — a perch with NO live broker session (the B2 reconcile signal) is marked OFFLINE at boot instead of hosted, so a dead-harness perch is never revived. Shares the B2 reconcile loop (this is its boot-gate arm); composes with B2's honest latch. Also closes wall-a's psyche_host_error gap (residency-confirm does not run at boot tick-1, livehost.rs:395-441 / 257-263). (v0.12.0)"
required_stages = []  # registry-first; activate [impl,unit] when built — wave 4 (after B3+Breap). Shares the B2 reconcile: boot-gate arm = a no-live-session online perch is marked offline at boot, not psyche-revived
[[requirements]]
id = "REQ-HAZARD-BRAIN-RESTART-LIFECYCLE-REHYDRATE"
title = "B4 (deepest): a bare brain restart (broker survives) REHYDRATES the live-agent lifecycle so post-restart endpoints are hosted + attachable. Today resume_sessions (brainproc.rs:186, brain.rs:797-809) re-subscribes to the broker's PTY sessions but ALL BrainLifecycle instances (lifecycle.rs:58-130; the ephemeral brain.rs:254-275) are LOST on restart → a post-restart live endpoint gets no livehost → its Psyche is never (re)hosted and new spawns die / can't attach until a FULL daemon reset (operator: perri's brain kill+restart wedged everything until a full daemon kill). FIX: on brain startup, rebuild a BrainLifecycle per resumed live-capable session — load the manifest from the adapter registry → instantiate → start the pulse — the rehydrate the resume no-op cannot do. Composes with B2 (the reconcile re-hosts from the honest on-disk status after rehydrate). (v0.12.0)"
required_stages = []  # registry-first; activate [impl,unit,int] when built — wave 5 (LAST, deepest). int: a brain restart with a live-capable endpoint re-hosts its lifecycle (no full daemon reset needed)
[[requirements]]
id = "REQ-HAZARD-VIEWER-ISOLATION"
title = "A slow / dead / hostile VIEWER must NEVER stall the controller, the PTY child, or the session drain thread. The broker drain fans output to the controller on the authoritative blocking bounded path (advances delivered_through) but to each viewer via a bounded per-viewer channel with a dedicated writer thread; the drain `try_send`s under the log lock and a viewer whose bounded queue OVERFLOWS (can't keep up) is EVICTED (queue dropped, writer thread ends, removed from the viewers map) — the drain thread NEVER touches a viewer socket, so no viewer write can backpressure or block it. A soft viewer cap bounds the thread count. Viewer eviction never perturbs the controller stream, the delivered_through cursor, or the child."
required_stages = ["unit", "int"]  # activated M12-W2.5 (unit: try_send-overflow eviction decision on the fan-out; int: a wedged viewer is evicted and the controller stream + child run on unaffected)

# ───────────────────────────── Install / migration / infra ──────────────────
[[requirements]]
id = "REQ-INSTALL-1"
title = "Two install paths; signed one-line script; OS-service registration"
required_stages = ["doc", "impl", "int"]  # activated M6-D2: installer/install.sh + install.ps1 — the standalone one-line script half (latest-release lookup → platform asset fetch → sha256 verify → per-OS install root → user PATH → absolute-path print); the harness-bootstrapped half (path a) calls into the same script by design (CONTEXT §Installation). int = oneliner_e2e.rs staged-release rung on both runners. OS-service registration EXCLUDED → docs/DEFERRED.md (daemon auto-start covers dev-stage); "signed" = sha256 at first fetch, full ed25519 is `spt update`'s job (grill decision 3)
[[requirements]]
id = "REQ-INSTALL-2"
title = "Marketplace-repackaging-friendly install"
required_stages = ["doc"]  # doc activated M6-D2: the relocatable-binary + minimal non-OS-entangled install-logic stance documented (CONTEXT §Installation tag) — the scripts are that stance (single static binary, no OS service, user-scope PATH only); actual marketplace repackaging stays doc-stage per M6-PLAN §Out
[[requirements]]
id = "REQ-INSTALL-3"
title = "Idempotent + interactive-optional first run"
required_stages = ["impl", "int"]  # activated M6-D2: idempotent re-run is construction — binary replaced atomically, PATH registration added at most once (marker-guarded .profile block / HKCU entry-set check); interactive-optional = the scripts never prompt at all (REQ-INSTALL-5's stronger non-interactive contract subsumes it; first-run identity gen + daemon start were already unattended). int = oneliner_e2e re-run rung (second run green, single PATH block)
[[requirements]]
id = "REQ-INSTALL-4"
title = "Adapter registration lifecycle: spt adapter add (--github, manifest-first, install-is-first-update) + soft-deregister remove + optional manifest uninstall template; node-local registered-adapter set self-update ripples over"
required_stages = ["impl", "unit"]  # activated M5-D2: spt-runtime::registry (validate-first register; copy-for-file_pull/avenue-less vs pointer-for-delegated under {SPT_HOME}/adapters/; soft-deregister retains record+copy, surfaces the manifest uninstall template; re-add re-activates) + spt adapter add/--github/remove/list CLI (install-is-first-update conducted through plan_adapter_update; bounded clone/conduct) + spt-daemon::adapter_update::ripple_registered (the registered set IS the R-UPD-5 ripple source). int = D3 shell-spawn consumes a registered shell adapter E2E
[[requirements]]
id = "REQ-MIGRATE-1"
title = "Auto-detect and migrate a legacy claude_skill_owl install"
required_stages = []  # rule 5: post-v1 — migration tooling needs a v1 to migrate to
[[requirements]]
id = "REQ-INFRA-1"
title = "GitHub issue tracking for v1; tangled.org as migration target"
required_stages = []  # rule 5: process req, not code — tracked operationally; activates if/when a docs/infra milestone formalizes it

# ───────────────────────────── M6 stage-setting / release ───────────────────
# Added 2026-06-05 (grill `1922fa4`); inactive until M6 execution starts
# (rule 5). Activation stages: M6-PLAN.md §Requirement activation.
[[requirements]]
id = "REQ-INSTALL-5"
title = "Non-interactive install path: the canonical one-liner doubles as every adapter's pack-in on-demand install (no second mechanism); sha256-verified fetch; user-PATH registration"
required_stages = ["impl", "int"]  # activated M6-D2: install.sh/install.ps1 non-interactive by construction (zero prompts; env knobs only — pipeable `curl|sh` / `irm|iex` cannot take args); SPT_INSTALL_ASSET_BASE is the CI/air-gap fetch override; sha256 verified against the release SHA256SUMS, mismatch refuses before placement. int = oneliner_e2e on both runners incl. the tamper-refusal negative rung
[[requirements]]
id = "REQ-INSTALL-9"
title = "Adapter add from a GitHub release archive: `spt adapter add --release <user/repo> [--tag <tag>] [--asset <name>]` fetches a `.spt` tar asset over HTTPS+GitHub trust, extracts it to the durable adapters/_github home, and registers the root — ships built binaries source-free and versioned (the distribution path for an adapter whose dev repo is a monorepo subdir, where --github root-only clone does not fit)"


340
341	/// Background net self-heal supervisor (REQ-DAEMON-9): a daemon that came up
342	/// net-less (NET_BIND_FAIL at boot — typically the autostart racing the network
343	/// up) keeps retrying `try_start_net` with capped backoff and, on success,
344	/// ATTACHES the host to the live broker (`OnceLock`, so exactly once). The node
345	/// becomes reachable without a manual restart. Runs until net binds — the
346	/// network always arrives eventually after boot. The net consumers are no longer
347	/// spawned here (restoration D2-1: they moved to the brain child, which starts
348	/// them when `net-status` reports the just-attached endpoint enabled).
349	// [impl->REQ-DAEMON-9]
350	fn net_retry_attach(broker: Arc<Broker>) {
351	    let mut backoff = NET_RETRY_FIRST;
352	    loop {
353	        thread::sleep(backoff);
354	        if let Some(host) = try_start_net() {
355	            if broker.attach_net(host) {
356	                eprintln!("NET_ATTACHED: net endpoint bound on retry — brain starts the consumers");
357	            }
358	            return;
359	        }
360	        backoff = net_retry_backoff(backoff);
361	        eprintln!("NET_BIND_RETRY: net still unavailable, retrying in {}s", backoff.as_secs());
362	    }
363	}
364
365	const NET_RETRY_FIRST: Duration = Duration::from_secs(1);
366	const NET_RETRY_CAP: Duration = Duration::from_secs(30);
367
368	/// The net self-heal backoff ladder (REQ-DAEMON-9): double each retry, capped
369	/// at [`NET_RETRY_CAP`]. Pure for tests.
370	fn net_retry_backoff(cur: Duration) -> Duration {
371	    (cur * 2).min(NET_RETRY_CAP)
372	}
373
374	/// Whether a daemon is already serving the seed-control channel for this home.
375	pub fn is_running() -> bool {
376	    seedmap::ping(&seed_socket_name()).is_ok()
377	}
378
379	/// Gracefully stop the daemon serving this home (`spt daemon stop`, M8
380	/// decision 5 / REQ-CLI-2): the seed-control stop op — acked, then the
381	/// daemon's foreground loop returns and the process exits cleanly.
382	// [impl->REQ-CLI-2]
383	pub fn request_stop() -> io::Result<()> {
384	    seedmap::request_stop(&seed_socket_name())
385	}
386
387	/// Block (bounded) until a daemon answers the seed-control channel, or time
388	/// out. Shared by every start path (`ensure_running`, `start_daemon`):
389	/// race-tolerant — if two callers spawn at once, one wins the bind and this
390	/// passes either way.
391	fn wait_until_up() -> io::Result<()> {
392	    let mut waited = Duration::ZERO;
393	    while waited < START_TIMEOUT {
394	        if is_running() {
395	            return Ok(());
396	        }
397	        thread::sleep(START_POLL);
398	        waited += START_POLL;
399	    }
400	    Err(io::Error::new(
401	        io::ErrorKind::TimedOut,
402	        "daemon did not come up within the start window",
403	    ))
404	}
405
406	/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if
407	/// one already answers; otherwise bring it up and wait (bounded) for the bind.
408	///
409	/// Service-aware (REQ-DAEMON-8): when an OS service manager has a registered

/// Background net self-heal supervisor (REQ-DAEMON-9): a daemon that came up
/// net-less (NET_BIND_FAIL at boot — typically the autostart racing the network
/// up) keeps retrying `try_start_net` with capped backoff and, on success,
/// ATTACHES the host to the live broker (`OnceLock`, so exactly once). The node
/// becomes reachable without a manual restart. Runs until net binds — the
/// network always arrives eventually after boot. The net consumers are no longer
/// spawned here (restoration D2-1: they moved to the brain child, which starts
/// them when `net-status` reports the just-attached endpoint enabled).
// [impl->REQ-DAEMON-9]
fn net_retry_attach(broker: Arc<Broker>) {
    let mut backoff = NET_RETRY_FIRST;
    loop {
        thread::sleep(backoff);
        if let Some(host) = try_start_net() {
            if broker.attach_net(host) {
                eprintln!("NET_ATTACHED: net endpoint bound on retry — brain starts the consumers");
            }
            return;
        }
        backoff = net_retry_backoff(backoff);
        eprintln!("NET_BIND_RETRY: net still unavailable, retrying in {}s", backoff.as_secs());
    }
}

const NET_RETRY_FIRST: Duration = Duration::from_secs(1);
const NET_RETRY_CAP: Duration = Duration::from_secs(30);

/// The net self-heal backoff ladder (REQ-DAEMON-9): double each retry, capped
/// at [`NET_RETRY_CAP`]. Pure for tests.
fn net_retry_backoff(cur: Duration) -> Duration {
    (cur * 2).min(NET_RETRY_CAP)
}

/// Whether a daemon is already serving the seed-control channel for this home.
pub fn is_running() -> bool {
    seedmap::ping(&seed_socket_name()).is_ok()
}

/// Gracefully stop the daemon serving this home (`spt daemon stop`, M8
/// decision 5 / REQ-CLI-2): the seed-control stop op — acked, then the
/// daemon's foreground loop returns and the process exits cleanly.
// [impl->REQ-CLI-2]
pub fn request_stop() -> io::Result<()> {
    seedmap::request_stop(&seed_socket_name())
}

/// Block (bounded) until a daemon answers the seed-control channel, or time
/// out. Shared by every start path (`ensure_running`, `start_daemon`):
/// race-tolerant — if two callers spawn at once, one wins the bind and this
/// passes either way.
fn wait_until_up() -> io::Result<()> {
    let mut waited = Duration::ZERO;
    while waited < START_TIMEOUT {
        if is_running() {
            return Ok(());
        }
        thread::sleep(START_POLL);
        waited += START_POLL;
    }
    Err(io::Error::new(
        io::ErrorKind::TimedOut,
        "daemon did not come up within the start window",
    ))
}

/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if
/// one already answers; otherwise bring it up and wait (bounded) for the bind.
///
/// Service-aware (REQ-DAEMON-8): when an OS service manager has a registered


Prompt:
<task-notification>
<task-id>boiehvnny</task-id>
<tool-use-id>toolu_01CMkKRKW53ptTbA83YdHgHF</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\1e7c582d-c03a-4db0-bd84-c7b3b7959ea0\tasks\boiehvnny.output</output-file>
<status>completed</status>
<summary>Background command "Read both wave-3 REQ blocks" completed (exit code 0)</summary>
</task-notification>


Now read the reap sites — runtime.rs detached-psyche spawn + brainproc.rs livehost stop flag.


320	            cmd.env_remove(var);
321	        }
322	        if let Some(guard) = &role.recursion_guard_env {
323	            cmd.env(guard, "1");
324	        }
325	        // Both command_for callers (spawn_session, run_bounded_stdin) run from
326	        // the console-less daemon (notif pump) and the detached live-agent
327	        // host (psyche/echo/turn): without CREATE_NO_WINDOW each spawn flashes
328	        // a visible blank conhost on the desktop. Setting it here — the one
329	        // shared builder — makes KNOWN-HAZARDS 5.8 hold for every ManifestRuntime
330	        // spawn, not just the free run_bounded_command leg.
331	        // [impl->REQ-HAZARD-CHILD-CONSOLE-FLASH]
332	        #[cfg(windows)]
333	        {
334	            use std::os::windows::process::CommandExt;
335	            cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
336	        }
337	        Ok(cmd)
338	    }
339	}
340
341	impl AgentRuntime for ManifestRuntime {
342	    fn spawn_session(
343	        &self,
344	        role: &str,
345	        keys: &BTreeMap<String, String>,
346	    ) -> Result<Spawned, RuntimeError> {
347	        let role_def = self.role(role)?;
348	        let mut cmd = self.command_for(role_def, keys)?;
349	        // Detached: discard stdio, spawn, drop the handle. Best-effort detach
350	        // for the interim no-daemon model; M3's broker owns true detachment.
351	        cmd.stdin(Stdio::null())
352	            .stdout(Stdio::null())
353	            .stderr(Stdio::null());
354	        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;
355	        Ok(Spawned { pid: child.id() })
356	    }
357
358	    fn run_bounded_stdin(
359	        &self,
360	        role: &str,
361	        keys: &BTreeMap<String, String>,
362	        timeout: Duration,
363	        stdin: Option<&[u8]>,
364	    ) -> Result<BoundedOutput, RuntimeError> {
365	        let role_def = self.role(role)?;
366	        let mut cmd = self.command_for(role_def, keys)?;
367	        cmd.stdin(if stdin.is_some() {
368	            Stdio::piped()
369	        } else {
370	            Stdio::null()
371	        })
372	        .stdout(Stdio::piped())
373	        .stderr(Stdio::piped());
374	        let label = fill_template(&role_def.command, keys)?;
375	        let mut child = cmd.spawn().map_err(RuntimeError::Spawn)?;
376	        if let Some(bytes) = stdin {
377	            if let Some(mut sink) = child.stdin.take() {
378	                let owned = bytes.to_vec();
379	                // Write on a thread + drop the sink (EOF) so the child finishes.

            cmd.env_remove(var);
        }
        if let Some(guard) = &role.recursion_guard_env {
            cmd.env(guard, "1");
        }
        // Both command_for callers (spawn_session, run_bounded_stdin) run from
        // the console-less daemon (notif pump) and the detached live-agent
        // host (psyche/echo/turn): without CREATE_NO_WINDOW each spawn flashes
        // a visible blank conhost on the desktop. Setting it here — the one
        // shared builder — makes KNOWN-HAZARDS 5.8 hold for every ManifestRuntime
        // spawn, not just the free run_bounded_command leg.
        // [impl->REQ-HAZARD-CHILD-CONSOLE-FLASH]
        #[cfg(windows)]
        {
            use std::os::windows::process::CommandExt;
            cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
        }
        Ok(cmd)
    }
}

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

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


200	    // promotes the trial binary on this stamp; promoting on a bare post-connect
201	    // breadcrumb would accept a brain that connected but never rebuilt
202	    // continuity. Today resume is a near-noop (no daemon-hosted sessions), so
203	    // this is forward-correct: the gate is defined on the real readiness
204	    // semantic the live-agent adapter inherits.
205	    write_ready(generation);
206
207	    // Shell wake-watchers (restoration D2-2): the offline half of the shell
208	    // online/offline exclusivity — a reconcile loop that runs each offline
209	    // instance's `wake_command` as a supervised watcher child. Relocated from
210	    // the broker process to the brain child: shellwake is brain-owned logic
211	    // (ADR-0018 Q5 exception), a pure disk-reconciler that only reaches the
212	    // broker over the same socket IPC for the remote-wake dial. Its boot sweep
213	    // doubles as orphan cleanup, so a brain respawn re-reconciles the watcher
214	    // children from disk — exactly the Q5 "re-reconciled on brain start"
215	    // posture. The stop flag is held for symmetry, never raised (the watchers
216	    // die with the brain process and the next brain re-sweeps them).
217	    // [impl->REQ-SHELL-2]
218	    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
219	    let _ = crate::shellwake::spawn_wake_host(Arc::new(AtomicBool::new(false)));
220
221	    // Live-agent Psyche host (M11-W0.1, REQ-DAEMON-1): the production caller
222	    // `run_pulse_loop` lacked. One thread sweeps `livehost::reconcile_once` at
223	    // boot (the rehydrate the no-op above can't do) and every tick — re-hosting
224	    // every ONLINE live endpoint from disk (the harness-endpoint mirror of the
225	    // shell waker). Online-only is staged behind the interim `api listen`
226	    // (status-absent perches are skipped — no double-host) until W0.2 makes the
227	    // brain the first-host. The stop flag is held for symmetry; the threads die
228	    // with the brain and the next brain rehydrates from disk (CONTEXT:194).
229	    // [impl->REQ-DAEMON-1]
230	    let _ = crate::livehost::spawn_live_host(Arc::new(AtomicBool::new(false)), reason);
231
232	    let mut consumers_started = false;
233	    loop {
234	        thread::sleep(BRAIN_HEARTBEAT);
235	        // `net-status` is both the net-capability probe and the broker-liveness
236	        // probe: an `Err` is the broker gone (or a future planned
237	        // snapshot+self-exit, Q3) — exit for a supervised respawn.
238	        let status = match brain.net_status() {
239	            Ok(s) => s,
240	            Err(e) => {
241	                eprintln!("BRAIN_BROKER_LOST: {e} — exiting for supervised respawn");
242	                return Err(e);
243	            }
244	        };
245	        if consumer_gate(status.enabled, consumers_started) {
246	            let hex = status.node_id_hex.clone().unwrap_or_default();
247	            spawn_net_consumers(&hex);
248	            consumers_started = true;
249	            eprintln!(
250	                "BRAIN_NET_CONSUMERS_UP: net enabled (node {hex}) — dispatcher + peer pump started"
251	            );
252	        }
253	        write_ready(generation);
254	    }
255	}
256
257	/// Whether to spawn the net consumers this heartbeat: only once the broker
258	/// reports a net endpoint up (`net_enabled`) AND we have not already started
259	/// them. Pure for the unit table — the spawn-exactly-once + net-gating

    // promotes the trial binary on this stamp; promoting on a bare post-connect
    // breadcrumb would accept a brain that connected but never rebuilt
    // continuity. Today resume is a near-noop (no daemon-hosted sessions), so
    // this is forward-correct: the gate is defined on the real readiness
    // semantic the live-agent adapter inherits.
    write_ready(generation);

    // Shell wake-watchers (restoration D2-2): the offline half of the shell
    // online/offline exclusivity — a reconcile loop that runs each offline
    // instance's `wake_command` as a supervised watcher child. Relocated from
    // the broker process to the brain child: shellwake is brain-owned logic
    // (ADR-0018 Q5 exception), a pure disk-reconciler that only reaches the
    // broker over the same socket IPC for the remote-wake dial. Its boot sweep
    // doubles as orphan cleanup, so a brain respawn re-reconciles the watcher
    // children from disk — exactly the Q5 "re-reconciled on brain start"
    // posture. The stop flag is held for symmetry, never raised (the watchers
    // die with the brain process and the next brain re-sweeps them).
    // [impl->REQ-SHELL-2]
    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    let _ = crate::shellwake::spawn_wake_host(Arc::new(AtomicBool::new(false)));

    // Live-agent Psyche host (M11-W0.1, REQ-DAEMON-1): the production caller
    // `run_pulse_loop` lacked. One thread sweeps `livehost::reconcile_once` at
    // boot (the rehydrate the no-op above can't do) and every tick — re-hosting
    // every ONLINE live endpoint from disk (the harness-endpoint mirror of the
    // shell waker). Online-only is staged behind the interim `api listen`
    // (status-absent perches are skipped — no double-host) until W0.2 makes the
    // brain the first-host. The stop flag is held for symmetry; the threads die
    // with the brain and the next brain rehydrates from disk (CONTEXT:194).
    // [impl->REQ-DAEMON-1]
    let _ = crate::livehost::spawn_live_host(Arc::new(AtomicBool::new(false)), reason);

    let mut consumers_started = false;
    loop {
        thread::sleep(BRAIN_HEARTBEAT);
        // `net-status` is both the net-capability probe and the broker-liveness
        // probe: an `Err` is the broker gone (or a future planned
        // snapshot+self-exit, Q3) — exit for a supervised respawn.
        let status = match brain.net_status() {
            Ok(s) => s,
            Err(e) => {
                eprintln!("BRAIN_BROKER_LOST: {e} — exiting for supervised respawn");
                return Err(e);
            }
        };
        if consumer_gate(status.enabled, consumers_started) {
            let hex = status.node_id_hex.clone().unwrap_or_default();
            spawn_net_consumers(&hex);
            consumers_started = true;
            eprintln!(
                "BRAIN_NET_CONSUMERS_UP: net enabled (node {hex}) — dispatcher + peer pump started"
            );
        }
        write_ready(generation);
    }
}

/// Whether to spawn the net consumers this heartbeat: only once the broker
/// reports a net endpoint up (`net_enabled`) AND we have not already started
/// them. Pure for the unit table — the spawn-exactly-once + net-gating


Process topology matters here. Daemon process serves seed-control + spawns brain child; brain spawns livehost; livehost spawns Psyches DETACHED (runtime.rs:354 drops handle). Stop hits daemon; flags live in brain. Need to map the full tree + any existing reap machinery before designing.


46:    pub fn run() -> io::Result<()> {
179:                // so they live with the restartable brain and respawn with it.
259:        // [`crate::brainproc::run_brain`].
264:        // `spt daemon brain` as a supervised child and respawns it on exit. D3-3
274:            let (_handle, restart) = crate::brainproc::spawn_brain_supervisor(stop);
287:        seedmap::serve_seed_control(&seed_socket_name(), reg)
298:    pub fn run_brain(generation: u64, start_reason: &str) -> io::Result<()> {
299:        crate::brainproc::run_brain(
347:/// spawned here (restoration D2-1: they moved to the brain child, which starts
757:    use crate::seedmap::{serve_seed_control, SeedRegistry};
816:                let _ = serve_seed_control(&name, srv);


Found 46 files
target-seam\debug\.fingerprint\windows-9187ada37c8cc94d\lib-windows.json
target-seam\debug\.fingerprint\windows-sys-cdfda4500fa4b4cd\lib-windows_sys.json
target-seam\debug\.fingerprint\windows-sys-f388e990a3fae552\lib-windows_sys.json
crates\spt-daemon\src\broker.rs
target-seam\debug\.fingerprint\windows-sys-a477be461182c2d7\lib-windows_sys.json
target-seam\debug\.fingerprint\windows-sys-79d4a233ea50a578\lib-windows_sys.json
traceable-reqs.toml
crates\spt-daemon\tests\broker.rs
crates\spt-daemon\src\livehost.rs
CHANGELOG.md
crates\spt\tests\brain_survive.rs
crates\spt-daemon\tests\handoff.rs
crates\spt-daemon\tests\daemon_e2e.rs
crates\spt-daemon\tests\brain_swap.rs
crates\spt-daemon\src\shellhost.rs
crates\spt-daemon\src\msg.rs
crates\spt-term\src\pty.rs
target-seam\debug\.fingerprint\windows-625f3bd0b8d0da3a\lib-windows.json
target-seam\debug\.fingerprint\windows-sys-0c9e7361fa9dab2a\lib-windows_sys.json
target-seam\debug\.fingerprint\windows-sys-4cf78ffb6da01f45\lib-windows_sys.json
crates\spt-store\src\info.rs
crates\spt-store\src\proc.rs
crates\spt-daemon\tests\daemon_lifecycle_real_brain.rs
crates\spt\tests\livehost_bootrace_e2e.rs
crates\spt\tests\livehost_nonresident_e2e.rs
crates\spt-runtime\src\runtime.rs
crates\spt\tests\livehost_psyche_fail_e2e.rs
crates\spt-daemon\src\lifecycle.rs
crates\spt\tests\tunnel_e2e.rs
crates\spt\tests\gateway_owner_shell_e2e.rs
crates\spt\tests\drive_e2e.rs
crates\spt-daemon\src\daemon.rs
crates\spt-daemon\src\brainproc.rs
.github\workflows\ci.yml
crates\spt-term\tests\surface.rs
crates\spt\tests\shell_sleepwake_e2e.rs
crates\spt-daemon\src\pump\mod.rs
crates\spt-daemon\tests\mesh.rs
RESTORATION-D7-PLAN.md
crates\spt-store\src\epoch.rs
docs\spikes\SPIKE-05-restart-stress.md
docs\adr\0004-single-daemon-broker-brain-split-and-self-update.md
crates\spt-store\src\gitrun.rs
M8-ACCEPTANCE-STEPS.md
M8-ACCEPTANCE.md
M5-D5-PLAN.md


40	pub struct Daemon;
41
42	impl Daemon {
43	    /// Run the daemon: bind the broker (served on a background thread) + the
44	    /// seed-control channel (the foreground loop + liveness anchor) and serve
45	    /// until the process is killed. The body of `spt daemon`.
46	    pub fn run() -> io::Result<()> {
47	        // Entry elevation guard (KNOWN-HAZARDS 5.7): a DIRECTLY elevated
48	        // `spt daemon` (sudo / elevated shell) must not serve with the
49	        // elevated token — its pipes would deny unelevated clients (Windows)
50	        // or root the user's state universe (Linux). Unix drops privileges
51	        // in-process before any state is touched; Windows cannot drop a
52	        // token in-place, so it respawns de-elevated via the UAC linked
53	        // token and exits (the child re-enters here unelevated). With no
54	        // unelevated identity to drop to, serving continues as-is, loudly —
55	        // a consistent universe, never a torn one.
56	        // [impl->REQ-HAZARD-ELEVATED-DAEMON-SPAWN]
57	        // Drop target: the ELECTED default user when one exists, else the
58	        // sudo invoker — electing it on first use (M8 decision 8,
59	        // REQ-INSTALL-6: the first `sudo spt` decides; thereafter every
60	        // elevated launch lands in the same account's universe).
61	        // [impl->REQ-INSTALL-6]
62	        #[cfg(unix)]
63	        if let Some(invoker) = crate::deelevate::daemon_target_user() {
64	            match invoker.drop_in_process() {
65	                Ok(()) => eprintln!("DEELEVATED: daemon dropped to uid {}", invoker.uid),
66	                // A drop target existed but the drop failed: serving as
67	                // root would root the user's state universe — a known-torn
68	                // state. ABORT (user-ratified 2026-06-06: refuse, don't limp).
69	                Err(e) => {
70	                    return Err(io::Error::other(format!(
71	                        "DEELEVATE_FAIL: {e} — refusing to serve as root over a user universe; \
72	                         run spt unelevated"
73	                    )));
74	                }
75	            }
76	        }
77	        #[cfg(windows)]
78	        if crate::deelevate::is_elevated() {
79	            let exe = std::env::current_exe()?;
80	            match crate::deelevate::spawn_deelevated(
81	                &exe.to_string_lossy(),
82	                &["daemon".to_string(), "run".to_string()],
83	            ) {
84	                Ok(Some(pid)) => {
85	                    eprintln!(
86	                        "DEELEVATED_RESPAWN: unelevated daemon pid {pid}; elevated copy exits"
87	                    );
88	                    return Ok(());
89	                }
90	                // Uniformly-elevated universe (no unelevated identity):
91	                // serving elevated is consistent there. Loud anyway.
92	                Ok(None) => eprintln!(
93	                    "ELEVATED_DAEMON: no unelevated identity to drop to — serving with the \
94	                     elevated token"
95	                ),
96	                // Target existed, respawn failed: an elevated daemon is a
97	                // known-torn state. ABORT (user-ratified 2026-06-06).
98	                Err(e) => {
99	                    return Err(io::Error::other(format!(
100	                        "DEELEVATE_FAIL: {e} — refusing to serve with the elevated token; run \
101	                         spt from an unelevated shell"
102	                    )));
103	                }
104	            }
105	        }
106
107	        let reg = Arc::new(SeedRegistry::new());
108
109	        // Boot housekeeping (KNOWN-HAZARDS 1.3): sweep stale `index.lock`
110	        // wedges out of the git-touching runtime the daemon hosts — the
111	        // context store's seed repo + its agent/project worktrees (a crashed
112	        // git leaves a 0-byte lock that blocks every later commit forever).
113	        // Path composed via the layout authority without opening the full
114	        // ContextStore on the boot path; a lock-less (or store-less) home
115	        // no-ops.
116	        // [impl->REQ-HAZARD-STALE-INDEX-LOCK]
117	        for lock in spt_store::branchstore::sweep_stale_index_locks(
118	            &spt_store::contextstore::seed_git_dir(&spt_store::perch::tracked_dir()),
119	            spt_store::branchstore::STALE_LOCK_AGE,
120	        ) {
121	            eprintln!("STALE_LOCK_SWEPT:{}", lock.display());
122	        }
123
124	        // Serve-state boot reset (M8 decision 6, REQ-SUBNET-5): the live
125	        // attachment state starts from the config's startup defaults — an
126	        // unsaved attach/detach flip deliberately does not survive a daemon
127	        // restart (`--save` is the persistence path).
128	        // [impl->REQ-SUBNET-5]
129	        {
130	            let att = spt_store::attachment::AttachmentStore::reset_to(
131	                &DaemonConfig::load().detached_subnets,
132	            );
133	            if let Err(e) = att.save() {
134	                eprintln!("ATTACHMENT_RESET_FAIL: {e} — serving with all subnets attached");
135	            }
136	            for name in &att.detached {
137	                eprintln!("SUBNET_DETACHED:{name} (startup default — `spt subnet attach {name}`)");
138	            }
139	        }
140
141	        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint
142	        // + live QUIC connections live in the broker so they survive a brain
143	        // restart. Identity is the durable node.key (stable name across daemon
144	        // restarts); relay stance comes from daemon.json; mDNS rides the
145	        // endpoint (closing the §B mDNS/relay-session row by construction). A
146	        // net failure degrades to a net-less broker — it must not cost PTY
147	        // hosting (mirrors the broker-bind soft-fail below).
148	        // [impl->REQ-DAEMON-2]
149	        // Identity (the durable node.key) decides node_hex independently of the
150	        // net bring-up: a NET_BIND_FAIL (e.g. the autostart beat the network up
151	        // at boot — REQ-DAEMON-9) is transient and retried, but a broken
152	        // identity is not, so we only arm the retry when identity is sound.
153	        let node_hex: Option<String> = spt_store::nodeid::load_or_create()
154	            .ok()
155	            .map(|i| i.public_key().to_hex());
156	        let net = if node_hex.is_some() {
157	            try_start_net()
158	        } else {
159	            eprintln!("NODE_KEY_FAIL: identity unavailable — broker runs net-less, no retry");
160	            None
161	        };
162
163	        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
164	        // background thread; a bind failure there must not stop the seed channel
165	        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
166	        let net_up = net.is_some();
167	        // Keep the broker handle alive past the bind so the brain supervisor's
168	        // planned-restart signal can be wired into it below (ADR-0018 D3-3): the
169	        // `KIND_BRAIN_RESTART` verb the broker serves must reach the supervisor.
170	        let broker_handle: Option<Arc<Broker>> =
171	            match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
172	                Ok(broker) => {
173	                    let serve_broker = Arc::clone(&broker);
174	                    thread::spawn(move || {
175	                        let _ = serve_broker.serve();
176	                    });
177	                // Inbound net dispatch + outbound peer pump (D9-1) now run in
178	                // the BRAIN child (restoration D2-1): they are pure IPC clients,
179	                // so they live with the restartable brain and respawn with it.
180	                // The broker keeps only the NetHost bring-up and the boot-race
181	                // self-heal that binds it; the brain polls `net-status` and
182	                // starts the consumers once net reports enabled.
183	                // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
184	                    if !net_up && node_hex.is_some() {
185	                        // Boot-race self-heal (REQ-DAEMON-9): net failed to bind
186	                        // but identity is sound — almost always the autostart
187	                        // racing the network up at boot. Retry net in the
188	                        // background and attach it to the broker when it
189	                        // succeeds, so the node becomes reachable WITHOUT a
190	                        // manual restart; the brain notices `net-status` flip
191	                        // enabled and starts the consumers then.
192	                        // [impl->REQ-DAEMON-9]
193	                        let broker = Arc::clone(&broker);
194	                        thread::spawn(move || net_retry_attach(broker));
195	                    }
196	                    Some(broker)
197	                }
198	                Err(e) => {
199	                    eprintln!("BROKER_BIND_FAIL: {e}");
200	                    None
201	                }
202	            };
203
204	        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
205	        // the digest-control channel serves `spt digest <id>` snapshot pulls +
206	        // delta-stream subscribes against it. Served on a background thread like
207	        // the broker. The per-session feed that *populates* the hub is driven by
208	        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
209	        // sessions are spawned through the broker; a bare daemon serves the
210	        // channel and answers "no live digest" until a session is hosted.
211	        let digest_hub = Arc::new(DigestHub::new());
212	        {
213	            let hub = Arc::clone(&digest_hub);
214	            thread::spawn(move || {
215	                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
216	            });
217	        }
218
219	        // The drive hub (M11-W2, REQ-SHELL-3) holds the per-shell ephemeral drive
220	        // slot — the owner→shell control surface. Broker-held like the digest hub
221	        // (the daemon-lifetime CLI-reachable anchor; a brain restart loses
222	        // nothing — the slot is ephemeral control, a restart correctly drops it).
223	        // `spt shell drive` writes it, `api drive-poll --link` takes-and-clears it.
224	        // [impl->REQ-SHELL-3]
225	        let drive_hub = Arc::new(crate::drivehub::DriveHub::new());
226	        {
227	            let hub = Arc::clone(&drive_hub);
228	            thread::spawn(move || {
229	                let _ = crate::drivehub::serve_drive_control(&drive_socket_name(), hub);
230	            });
231	        }
232
233	        // The per-shell shell-TUNNEL registry (M11-W3, REQ-SHELL-4) — the opaque
234	        // reliable-ordered byte stream's identifier map. Broker-held like the drive
235	        // hub (daemon-lifetime CLI-reachable anchor; in-memory so a restart correctly
236	        // drops a dead link generation's stream ids). `spt shell tunnel` (owner) and
237	        // the shell binary's `api tunnel` resolve their stream end through it.
238	        // [impl->REQ-SHELL-4]
239	        let tunnel_hub = Arc::new(crate::tunnelhub::TunnelHub::new());
240	        // The relay needs the broker-owned NetHost (doyle gate A: broker-homed),
241	        // so it serves only when the broker bound; a net-less daemon has no tunnel.
242	        if let Some(broker) = &broker_handle {
243	            let hub = Arc::clone(&tunnel_hub);
244	            let broker = Arc::clone(broker);
245	            thread::spawn(move || {
246	                let _ = crate::tunnelhub::serve_tunnel_control(
247	                    &crate::endpoint::tunnel_socket_name(),
248	                    hub,
249	                    broker,
250	                );
251	            });
252	        }
253
254	        // Shell wake-watchers (M5-D4b) — the offline half of the shell
255	        // online/offline exclusivity — now run in the BRAIN child (restoration
256	        // D2-2): shellwake is brain-owned logic (ADR-0018 Q5 exception), a pure
257	        // disk-reconciler whose boot sweep re-reconciles the watcher children on
258	        // every brain start. The broker no longer hosts it; see
259	        // [`crate::brainproc::run_brain`].
260	        // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
261
262	        // Brain process supervisor (ADR-0018 Q2/Q3 — broker/brain process
263	        // restoration): the broker is the always-up per-machine anchor; it spawns
264	        // `spt daemon brain` as a supervised child and respawns it on exit. D3-3
265	        // makes an update a *planned* restart on this same path: the supervisor's
266	        // `BrainRestart` signal is wired into the broker so the `KIND_BRAIN_RESTART`
267	        // verb (sent by `spt update apply` after the binary swap) cycles the brain
268	        // onto the new binary with `start-reason=update`. The stop flag is held
269	        // for symmetry, never raised (the daemon runs until killed). The brain
270	        // child inherits this process's already-unelevated token.
271	        // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
272	        {
273	            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
274	            let (_handle, restart) = crate::brainproc::spawn_brain_supervisor(stop);
275	            if let Some(broker) = &broker_handle {
276	                broker.set_brain_restart(restart);
277	            }
278	        }
279
280	        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
281	        // is the socket ping, never this file, so a stale value is harmless.
282	        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());
283
284	        // The seed-control channel is the foreground loop. Binding it is what
285	        // arbitrates the single-daemon-per-home invariant: a second daemon racing
286	        // to start fails this bind and exits, leaving the winner serving.
287	        seedmap::serve_seed_control(&seed_socket_name(), reg)
288	    }
289
290	    /// Run the brain child (`spt daemon brain`, hidden — ADR-0018 D1). The
291	    /// broker process spawns + supervises this; it connects to the broker over
292	    /// the existing socket IPC, signals ready, and idles. Not a user-facing
293	    /// entry — `spt daemon run` (the broker) launches it. The broker stamps
294	    /// `{generation, start-reason}` onto the argv (ADR-0018 V2); `start_reason`
295	    /// is parsed leniently ([`crate::brainproc::StartReason::from_arg`]) so an
296	    /// unknown value from a newer broker defaults to `Cold` (KH-2.3).
297	    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
298	    pub fn run_brain(generation: u64, start_reason: &str) -> io::Result<()> {
299	        crate::brainproc::run_brain(
300	            generation,
301	            crate::brainproc::StartReason::from_arg(start_reason),
302	        )
303	    }
304	}
305
306	/// Try to bring up the broker's network host once: build the production
307	/// `NetConfig` (durable node.key + relay/discovery/membership) and start the
308	/// endpoint, wiring on-connect roster propagation (REQ-MESH-2). `None` on a
309	/// `NET_BIND_FAIL` (e.g. the network/DNS stack not ready yet) or a missing
310	/// identity — the caller binds net-less and (REQ-DAEMON-9) retries.
311	// [impl->REQ-DAEMON-2]
312	// [impl->REQ-DAEMON-9]
313	fn try_start_net() -> Option<NetHost> {
314	    let cfg = DaemonConfig::load();
315	    let relay = match cfg.relay {
316	        RelayChoice::N0 => RelayPolicy::N0Default,
317	        RelayChoice::Disabled => RelayPolicy::Disabled,
318	    };
319	    let identity = spt_store::nodeid::load_or_create().ok()?;
320	    let net_cfg = NetConfig {
321	        identity,
322	        relay,
323	        discovery: LocalDiscovery::Mdns,
324	        scope: BindScope::All, // production: real interfaces
325	        // Mesh-D2: enforce seed-proof on every dial + accept, reading the live
326	        // subnet store each connect.
327	        membership: Some(crate::seedproofx::production_membership()),
328	    };
329	    match NetHost::start(net_cfg) {
330	        Ok(host) => {
331	            host.set_roster_exchange(crate::seedproofx::production_roster_exchange());
332	            Some(host)
333	        }
334	        Err(e) => {
335	            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
336	            None
337	        }
338	    }
339	}

pub struct Daemon;

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

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

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

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

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

        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
        // background thread; a bind failure there must not stop the seed channel
        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
        let net_up = net.is_some();
        // Keep the broker handle alive past the bind so the brain supervisor's
        // planned-restart signal can be wired into it below (ADR-0018 D3-3): the
        // `KIND_BRAIN_RESTART` verb the broker serves must reach the supervisor.
        let broker_handle: Option<Arc<Broker>> =
            match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
                Ok(broker) => {
                    let serve_broker = Arc::clone(&broker);
                    thread::spawn(move || {
                        let _ = serve_broker.serve();
                    });
                // Inbound net dispatch + outbound peer pump (D9-1) now run in
                // the BRAIN child (restoration D2-1): they are pure IPC clients,
                // so they live with the restartable brain and respawn with it.
                // The broker keeps only the NetHost bring-up and the boot-race
                // self-heal that binds it; the brain polls `net-status` and
                // starts the consumers once net reports enabled.
                // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
                    if !net_up && node_hex.is_some() {
                        // Boot-race self-heal (REQ-DAEMON-9): net failed to bind
                        // but identity is sound — almost always the autostart
                        // racing the network up at boot. Retry net in the
                        // background and attach it to the broker when it
                        // succeeds, so the node becomes reachable WITHOUT a
                        // manual restart; the brain notices `net-status` flip
                        // enabled and starts the consumers then.
                        // [impl->REQ-DAEMON-9]
                        let broker = Arc::clone(&broker);
                        thread::spawn(move || net_retry_attach(broker));
                    }
                    Some(broker)
                }
                Err(e) => {
                    eprintln!("BROKER_BIND_FAIL: {e}");
                    None
                }
            };

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

        // The drive hub (M11-W2, REQ-SHELL-3) holds the per-shell ephemeral drive
        // slot — the owner→shell control surface. Broker-held like the digest hub
        // (the daemon-lifetime CLI-reachable anchor; a brain restart loses
        // nothing — the slot is ephemeral control, a restart correctly drops it).
        // `spt shell drive` writes it, `api drive-poll --link` takes-and-clears it.
        // [impl->REQ-SHELL-3]
        let drive_hub = Arc::new(crate::drivehub::DriveHub::new());
        {
            let hub = Arc::clone(&drive_hub);
            thread::spawn(move || {
                let _ = crate::drivehub::serve_drive_control(&drive_socket_name(), hub);
            });
        }

        // The per-shell shell-TUNNEL registry (M11-W3, REQ-SHELL-4) — the opaque
        // reliable-ordered byte stream's identifier map. Broker-held like the drive
        // hub (daemon-lifetime CLI-reachable anchor; in-memory so a restart correctly
        // drops a dead link generation's stream ids). `spt shell tunnel` (owner) and
        // the shell binary's `api tunnel` resolve their stream end through it.
        // [impl->REQ-SHELL-4]
        let tunnel_hub = Arc::new(crate::tunnelhub::TunnelHub::new());
        // The relay needs the broker-owned NetHost (doyle gate A: broker-homed),
        // so it serves only when the broker bound; a net-less daemon has no tunnel.
        if let Some(broker) = &broker_handle {
            let hub = Arc::clone(&tunnel_hub);
            let broker = Arc::clone(broker);
            thread::spawn(move || {
                let _ = crate::tunnelhub::serve_tunnel_control(
                    &crate::endpoint::tunnel_socket_name(),
                    hub,
                    broker,
                );
            });
        }

        // Shell wake-watchers (M5-D4b) — the offline half of the shell
        // online/offline exclusivity — now run in the BRAIN child (restoration
        // D2-2): shellwake is brain-owned logic (ADR-0018 Q5 exception), a pure
        // disk-reconciler whose boot sweep re-reconciles the watcher children on
        // every brain start. The broker no longer hosts it; see
        // [`crate::brainproc::run_brain`].
        // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]

        // Brain process supervisor (ADR-0018 Q2/Q3 — broker/brain process
        // restoration): the broker is the always-up per-machine anchor; it spawns
        // `spt daemon brain` as a supervised child and respawns it on exit. D3-3
        // makes an update a *planned* restart on this same path: the supervisor's
        // `BrainRestart` signal is wired into the broker so the `KIND_BRAIN_RESTART`
        // verb (sent by `spt update apply` after the binary swap) cycles the brain
        // onto the new binary with `start-reason=update`. The stop flag is held
        // for symmetry, never raised (the daemon runs until killed). The brain
        // child inherits this process's already-unelevated token.
        // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
        {
            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
            let (_handle, restart) = crate::brainproc::spawn_brain_supervisor(stop);
            if let Some(broker) = &broker_handle {
                broker.set_brain_restart(restart);
            }
        }

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

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

    /// Run the brain child (`spt daemon brain`, hidden — ADR-0018 D1). The
    /// broker process spawns + supervises this; it connects to the broker over
    /// the existing socket IPC, signals ready, and idles. Not a user-facing
    /// entry — `spt daemon run` (the broker) launches it. The broker stamps
    /// `{generation, start-reason}` onto the argv (ADR-0018 V2); `start_reason`
    /// is parsed leniently ([`crate::brainproc::StartReason::from_arg`]) so an
    /// unknown value from a newer broker defaults to `Cold` (KH-2.3).
    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    pub fn run_brain(generation: u64, start_reason: &str) -> io::Result<()> {
        crate::brainproc::run_brain(
            generation,
            crate::brainproc::StartReason::from_arg(start_reason),
        )
    }
}

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


Full topology mapped:
```
spt daemon run (daemon/broker proc) — serve_seed_control = foreground/liveness anchor
└── spt daemon brain (supervised child, respawns)
    ├── shellwake watcher children
    └── psyche children (DETACHED, handle dropped @runtime.rs:354)
```
Stop hits daemon proc; brain+psyches are separate procs, no job/group → orphaned on daemon exit. Confirms Breap needs OS-level reap (job object / process-group) — in-process pid tracking can't span the brain's detached psyches.

Check existing proc abstraction + stop-test infra + brain supervisor before designing.


1	//! Process-liveness probe. Used by the registry to detect stale entries
2	//! (dead-PID rows) before resolution. Ported from sister `types.rs`
3	//! `is_process_alive` (ADR-0001): Windows `OpenProcess` (tasklist hangs on
4	//! Win11 26200+), Unix `kill(pid, 0)`.
5
6	/// Whether a process with `pid` is currently alive. Best-effort and
7	/// side-effect-free — sends no signal, opens then immediately closes a handle.
8	///
9	/// `pid == 0` always reads **dead**: it is never a real perch/listener pid
10	/// (the kernel/idle process on both OSes), and on Unix `kill(0, 0)` does not
11	/// probe a process at all — it targets the **caller's own process group**, so
12	/// it always succeeds and a pid-0 row would read permanently "alive" on Linux
13	/// while reading dead on Windows (`OpenProcess(0)` fails). The guard makes the
14	/// probe's answer OS-independent.
15	// [impl->REQ-HAZARD-REGISTRY-STALE-CLEAN]
16	pub fn is_process_alive(pid: u32) -> bool {
17	    if pid == 0 {
18	        return false; // never a probe-able process; see doc comment
19	    }
20	    #[cfg(unix)]
21	    {
22	        // kill(pid, 0) probes existence without delivering a signal — but a
23	        // **zombie** (exited child whose still-running spawner never reaped
24	        // it: in-process daemon hosting, tests) also answers. A zombie runs
25	        // nothing; for every consumer here (stale-row detection, shell-close
26	        // confirmation) it is dead. Linux: the /proc stat state field — the
27	        // 3rd field AFTER the ")" closing comm (comm itself may hold spaces).
28	        if unsafe { libc::kill(pid as i32, 0) != 0 } {
29	            return false;
30	        }
31	        #[cfg(target_os = "linux")]
32	        if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) {
33	            if let Some((_, rest)) = stat.rsplit_once(')') {
34	                if rest.trim_start().starts_with('Z') {
35	                    return false; // a zombie is dead, not alive
36	                }
37	            }
38	        }
39	        true
40	    }
41	    #[cfg(windows)]
42	    {
43	        // OpenProcess with the most limited query right; tasklist/WMIC hang on
44	        // recent Windows builds.
45	        extern "system" {
46	            fn OpenProcess(
47	                dw_desired_access: u32,
48	                b_inherit_handle: i32,
49	                dw_process_id: u32,
50	            ) -> isize;
51	            fn CloseHandle(h_object: isize) -> i32;
52	        }
53	        const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
54	        let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
55	        if handle == 0 {
56	            false
57	        } else {
58	            unsafe {
59	                CloseHandle(handle);
60	            }
61	            true
62	        }
63	    }
64	}
65
66	/// Best-effort reap of an exited child: clears the zombie a kill (or a plain
67	/// exit) leaves when the **spawner itself keeps running** — in-process daemon
68	/// hosting and tests; a short-lived CLI spawner exits and init/the OS reaps
69	/// for it. No-op on Windows (no zombies) and for a pid that is not our child
70	/// (`ECHILD` — somebody else owns the wait).
71	pub fn reap_if_child(pid: u32) {
72	    #[cfg(unix)]
73	    if pid != 0 {
74	        unsafe {
75	            libc::waitpid(pid as i32, std::ptr::null_mut(), libc::WNOHANG);
76	        }
77	    }
78	    #[cfg(windows)]
79	    let _ = pid;
80	}
81
82	/// The current process's **parent** pid — the stable session-binding anchor for
83	/// harness-hosted startup (the harness process that spawned `spt`). The
84	/// pid-recycle defense (KNOWN-HAZARDS 5.1) anchors liveness on this, not on the
85	/// ephemeral `spt` invocation's own pid.
86	///
87	/// Unix: `getppid`. Windows: `NtQueryInformationProcess(ProcessBasicInformation)`
88	/// → `InheritedFromUniqueProcessId` (tasklist/WMIC hang on recent builds; the
89	/// Nt call is the non-hanging path, mirroring the OpenProcess choice above).
90	/// Returns `None` only if the query fails.
91	pub fn parent_pid() -> Option<u32> {
92	    #[cfg(unix)]
93	    {
94	        Some(unsafe { libc::getppid() } as u32)
95	    }
96	    #[cfg(windows)]
97	    {
98	        #[repr(C)]
99	        struct ProcessBasicInformation {
100	            _exit_status: usize,
101	            _peb_base: usize,
102	            _affinity_mask: usize,
103	            _base_priority: usize,
104	            _unique_process_id: usize,
105	            inherited_from_unique_process_id: usize,
106	        }
107	        extern "system" {
108	            fn NtQueryInformationProcess(
109	                process_handle: isize,
110	                process_information_class: u32,
111	                process_information: *mut core::ffi::c_void,
112	                process_information_length: u32,
113	                return_length: *mut u32,
114	            ) -> i32;
115	        }
116	        // GetCurrentProcess() is the constant pseudo-handle -1; no import needed.
117	        const CURRENT_PROCESS: isize = -1;
118	        const PROCESS_BASIC_INFORMATION: u32 = 0;
119	        let mut info = ProcessBasicInformation {
120	            _exit_status: 0,
121	            _peb_base: 0,
122	            _affinity_mask: 0,
123	            _base_priority: 0,
124	            _unique_process_id: 0,
125	            inherited_from_unique_process_id: 0,
126	        };
127	        let mut ret_len: u32 = 0;
128	        let status = unsafe {
129	            NtQueryInformationProcess(
130	                CURRENT_PROCESS,
131	                PROCESS_BASIC_INFORMATION,
132	                &mut info as *mut _ as *mut core::ffi::c_void,
133	                core::mem::size_of::<ProcessBasicInformation>() as u32,
134	                &mut ret_len,
135	            )
136	        };
137	        if status == 0 {
138	            Some(info.inherited_from_unique_process_id as u32)
139	        } else {
140	            None
141	        }
142	    }
143	}
144
145	/// The executable **basename** of process `pid` — the bind-time adapter-resolution
146	/// match-key (ADR-0021 / REQ-MANIFEST-8): a harness session's parent pid maps to
147	/// the harness binary's basename (e.g. `claude.exe` on Windows, `claude` on
148	/// Linux), matched case-insensitively and `.exe`-stripped against an adapter's
149	/// `[adapter] host_binaries`. Returns the raw basename (extension intact — the
150	/// matcher normalizes); `None` when the query fails (dead pid, permission, or an
151	/// unsupported platform), where the caller degrades to the `--adapter` escape.
152	///
153	/// Windows: `QueryFullProcessImageNameW` over an `OpenProcess`
154	/// (`PROCESS_QUERY_LIMITED_INFORMATION`) handle — the non-hanging path, mirroring
155	/// [`is_process_alive`] (tasklist/WMIC hang on recent builds). Linux: the
156	/// `/proc/<pid>/exe` symlink target. Other unix: `None` (no `/proc`).
157	// [impl->REQ-MANIFEST-8]
158	pub fn exe_basename(pid: u32) -> Option<String> {
159	    if pid == 0 {
160	        return None; // never a probe-able process (see is_process_alive)
161	    }
162	    #[cfg(windows)]
163	    {
164	        extern "system" {
165	            fn OpenProcess(
166	                dw_desired_access: u32,
167	                b_inherit_handle: i32,
168	                dw_process_id: u32,
169	            ) -> isize;
170	            fn CloseHandle(h_object: isize) -> i32;
171	            fn QueryFullProcessImageNameW(
172	                h_process: isize,
173	                dw_flags: u32,
174	                lp_exe_name: *mut u16,
175	                lpdw_size: *mut u32,
176	            ) -> i32;
177	        }
178	        const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
179	        let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
180	        if handle == 0 {
181	            return None;
182	        }
183	        let mut buf = vec![0u16; 4096];
184	        let mut size = buf.len() as u32;
185	        let ok = unsafe { QueryFullProcessImageNameW(handle, 0, buf.as_mut_ptr(), &mut size) };
186	        unsafe {
187	            CloseHandle(handle);
188	        }
189	        if ok == 0 {
190	            return None;
191	        }
192	        let path = String::from_utf16_lossy(&buf[..size as usize]);
193	        std::path::Path::new(&path)
194	            .file_name()
195	            .map(|s| s.to_string_lossy().into_owned())
196	    }
197	    #[cfg(target_os = "linux")]
198	    {
199	        std::fs::read_link(format!("/proc/{pid}/exe"))
200	            .ok()
201	            .and_then(|t| t.file_name().map(|s| s.to_string_lossy().into_owned()))
202	    }
203	    #[cfg(all(unix, not(target_os = "linux")))]
204	    {
205	        let _ = pid; // no /proc — degrade to the --adapter escape
206	        None
207	    }
208	}
209
210	#[cfg(test)]
211	mod tests {
212	    use super::*;
213
214	    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] the current process is alive.
215	    #[test]
216	    fn current_process_is_alive() {
217	        assert!(is_process_alive(std::process::id()));
218	    }
219
220	    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] a pid far above any real
221	    // allocation reads as dead. Chosen deterministically (above Linux pid_max
222	    // ~4.2M and Windows ranges; positive as i32 so the Unix kill-group
223	    // semantics don't trigger) to avoid the pid-reuse flakiness of
224	    // spawn-then-reap under parallel test execution.
225	    #[test]
226	    fn unassigned_pid_is_dead() {
227	        const DEAD_PID: u32 = 2_000_000_000;
228	        assert!(
229	            !is_process_alive(DEAD_PID),
230	            "unassigned pid must read as dead"
231	        );
232	    }
233
234	    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] pid 0 reads dead on BOTH OSes.
235	    // Without the guard, Unix `kill(0, 0)` signals the caller's own process
236	    // group (always succeeds) — a pid-0 row read permanently "alive" on Linux
237	    // while reading dead on Windows (the D3f rename-test CI divergence).
238	    #[test]
239	    fn pid_zero_is_dead_on_every_os() {
240	        assert!(!is_process_alive(0), "pid 0 is never a probe-able process");
241	    }
242
243	    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] a zombie reads DEAD: an exited
244	    // child whose spawner never reaps it (in-process daemon hosting, tests)
245	    // still answers kill(pid,0) — but it runs nothing. Without the /proc
246	    // state check, a shell close would hold a dead binary "running" through
247	    // its whole grace window (the M5-D4a Linux CI failure).
248	    #[cfg(target_os = "linux")]
249	    #[test]
250	    fn zombie_child_reads_dead() {
251	        let child = std::process::Command::new("true").spawn().unwrap();
252	        let pid = child.id();
253	        std::mem::forget(child); // never reaped here — a zombie once exited
254	        for _ in 0..150 {
255	            if !is_process_alive(pid) {
256	                return; // exited (zombie or reaped-by-os) reads dead
257	            }
258	            std::thread::sleep(std::time::Duration::from_millis(20));
259	        }
260	        panic!("an exited, unreaped child must read dead");
261	    }
262
263	    // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] the parent pid resolves to a real,
264	    // live process (the test runner that spawned this process).
265	    #[test]
266	    fn parent_pid_resolves_and_is_alive() {
267	        let ppid = parent_pid().expect("parent pid must resolve");
268	        assert_ne!(ppid, 0, "parent pid must be non-zero");
269	        assert_ne!(ppid, std::process::id(), "parent must differ from self");
270	        assert!(is_process_alive(ppid), "the live parent must read as alive");
271	    }
272
273	    // [unit->REQ-MANIFEST-8] the bind-time match-key: a live pid resolves to its
274	    // own executable basename (extension intact), matching what `current_exe`
275	    // reports — the raw key the resolver normalizes (lowercase, `.exe`-strip).
276	    #[test]
277	    fn exe_basename_resolves_current_process() {
278	        let want = std::env::current_exe()
279	            .ok()
280	            .and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned()));
281	        let got = exe_basename(std::process::id());
282	        assert!(got.is_some(), "the current process's exe basename resolves");
283	        assert!(!got.as_deref().unwrap_or("").is_empty(), "basename non-empty");
284	        assert_eq!(got, want, "matches current_exe's basename");
285	    }
286
287	    // [unit->REQ-MANIFEST-8] a dead/unassigned pid yields no basename — the
288	    // resolver degrades to the --adapter escape rather than guessing.
289	    #[test]
290	    fn exe_basename_dead_pid_is_none() {
291	        const DEAD_PID: u32 = 2_000_000_000;
292	        assert!(exe_basename(DEAD_PID).is_none(), "no basename for a dead pid");
293	        assert!(exe_basename(0).is_none(), "pid 0 is never probe-able");
294	    }
295	}
296

//! Process-liveness probe. Used by the registry to detect stale entries
//! (dead-PID rows) before resolution. Ported from sister `types.rs`
//! `is_process_alive` (ADR-0001): Windows `OpenProcess` (tasklist hangs on
//! Win11 26200+), Unix `kill(pid, 0)`.

/// Whether a process with `pid` is currently alive. Best-effort and
/// side-effect-free — sends no signal, opens then immediately closes a handle.
///
/// `pid == 0` always reads **dead**: it is never a real perch/listener pid
/// (the kernel/idle process on both OSes), and on Unix `kill(0, 0)` does not
/// probe a process at all — it targets the **caller's own process group**, so
/// it always succeeds and a pid-0 row would read permanently "alive" on Linux
/// while reading dead on Windows (`OpenProcess(0)` fails). The guard makes the
/// probe's answer OS-independent.
// [impl->REQ-HAZARD-REGISTRY-STALE-CLEAN]
pub fn is_process_alive(pid: u32) -> bool {
    if pid == 0 {
        return false; // never a probe-able process; see doc comment
    }
    #[cfg(unix)]
    {
        // kill(pid, 0) probes existence without delivering a signal — but a
        // **zombie** (exited child whose still-running spawner never reaped
        // it: in-process daemon hosting, tests) also answers. A zombie runs
        // nothing; for every consumer here (stale-row detection, shell-close
        // confirmation) it is dead. Linux: the /proc stat state field — the
        // 3rd field AFTER the ")" closing comm (comm itself may hold spaces).
        if unsafe { libc::kill(pid as i32, 0) != 0 } {
            return false;
        }
        #[cfg(target_os = "linux")]
        if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) {
            if let Some((_, rest)) = stat.rsplit_once(')') {
                if rest.trim_start().starts_with('Z') {
                    return false; // a zombie is dead, not alive
                }
            }
        }
        true
    }
    #[cfg(windows)]
    {
        // OpenProcess with the most limited query right; tasklist/WMIC hang on
        // recent Windows builds.
        extern "system" {
            fn OpenProcess(
                dw_desired_access: u32,
                b_inherit_handle: i32,
                dw_process_id: u32,
            ) -> isize;
            fn CloseHandle(h_object: isize) -> i32;
        }
        const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
        let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
        if handle == 0 {
            false
        } else {
            unsafe {
                CloseHandle(handle);
            }
            true
        }
    }
}

/// Best-effort reap of an exited child: clears the zombie a kill (or a plain
/// exit) leaves when the **spawner itself keeps running** — in-process daemon
/// hosting and tests; a short-lived CLI spawner exits and init/the OS reaps
/// for it. No-op on Windows (no zombies) and for a pid that is not our child
/// (`ECHILD` — somebody else owns the wait).
pub fn reap_if_child(pid: u32) {
    #[cfg(unix)]
    if pid != 0 {
        unsafe {
            libc::waitpid(pid as i32, std::ptr::null_mut(), libc::WNOHANG);
        }
    }
    #[cfg(windows)]
    let _ = pid;
}

/// The current process's **parent** pid — the stable session-binding anchor for
/// harness-hosted startup (the harness process that spawned `spt`). The
/// pid-recycle defense (KNOWN-HAZARDS 5.1) anchors liveness on this, not on the
/// ephemeral `spt` invocation's own pid.
///
/// Unix: `getppid`. Windows: `NtQueryInformationProcess(ProcessBasicInformation)`
/// → `InheritedFromUniqueProcessId` (tasklist/WMIC hang on recent builds; the
/// Nt call is the non-hanging path, mirroring the OpenProcess choice above).
/// Returns `None` only if the query fails.
pub fn parent_pid() -> Option<u32> {
    #[cfg(unix)]
    {
        Some(unsafe { libc::getppid() } as u32)
    }
    #[cfg(windows)]
    {
        #[repr(C)]
        struct ProcessBasicInformation {
            _exit_status: usize,
            _peb_base: usize,
            _affinity_mask: usize,
            _base_priority: usize,
            _unique_process_id: usize,
            inherited_from_unique_process_id: usize,
        }
        extern "system" {
            fn NtQueryInformationProcess(
                process_handle: isize,
                process_information_class: u32,
                process_information: *mut core::ffi::c_void,
                process_information_length: u32,
                return_length: *mut u32,
            ) -> i32;
        }
        // GetCurrentProcess() is the constant pseudo-handle -1; no import needed.
        const CURRENT_PROCESS: isize = -1;
        const PROCESS_BASIC_INFORMATION: u32 = 0;
        let mut info = ProcessBasicInformation {
            _exit_status: 0,
            _peb_base: 0,
            _affinity_mask: 0,
            _base_priority: 0,
            _unique_process_id: 0,
            inherited_from_unique_process_id: 0,
        };
        let mut ret_len: u32 = 0;
        let status = unsafe {
            NtQueryInformationProcess(
                CURRENT_PROCESS,
                PROCESS_BASIC_INFORMATION,
                &mut info as *mut _ as *mut core::ffi::c_void,
                core::mem::size_of::<ProcessBasicInformation>() as u32,
                &mut ret_len,
            )
        };
        if status == 0 {
            Some(info.inherited_from_unique_process_id as u32)
        } else {
            None
        }
    }
}

/// The executable **basename** of process `pid` — the bind-time adapter-resolution
/// match-key (ADR-0021 / REQ-MANIFEST-8): a harness session's parent pid maps to
/// the harness binary's basename (e.g. `claude.exe` on Windows, `claude` on
/// Linux), matched case-insensitively and `.exe`-stripped against an adapter's
/// `[adapter] host_binaries`. Returns the raw basename (extension intact — the
/// matcher normalizes); `None` when the query fails (dead pid, permission, or an
/// unsupported platform), where the caller degrades to the `--adapter` escape.
///
/// Windows: `QueryFullProcessImageNameW` over an `OpenProcess`
/// (`PROCESS_QUERY_LIMITED_INFORMATION`) handle — the non-hanging path, mirroring
/// [`is_process_alive`] (tasklist/WMIC hang on recent builds). Linux: the
/// `/proc/<pid>/exe` symlink target. Other unix: `None` (no `/proc`).
// [impl->REQ-MANIFEST-8]
pub fn exe_basename(pid: u32) -> Option<String> {
    if pid == 0 {
        return None; // never a probe-able process (see is_process_alive)
    }
    #[cfg(windows)]
    {
        extern "system" {
            fn OpenProcess(
                dw_desired_access: u32,
                b_inherit_handle: i32,
                dw_process_id: u32,
            ) -> isize;
            fn CloseHandle(h_object: isize) -> i32;
            fn QueryFullProcessImageNameW(
                h_process: isize,
                dw_flags: u32,
                lp_exe_name: *mut u16,
                lpdw_size: *mut u32,
            ) -> i32;
        }
        const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
        let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
        if handle == 0 {
            return None;
        }
        let mut buf = vec![0u16; 4096];
        let mut size = buf.len() as u32;
        let ok = unsafe { QueryFullProcessImageNameW(handle, 0, buf.as_mut_ptr(), &mut size) };
        unsafe {
            CloseHandle(handle);
        }
        if ok == 0 {
            return None;
        }
        let path = String::from_utf16_lossy(&buf[..size as usize]);
        std::path::Path::new(&path)
            .file_name()
            .map(|s| s.to_string_lossy().into_owned())
    }
    #[cfg(target_os = "linux")]
    {
        std::fs::read_link(format!("/proc/{pid}/exe"))
            .ok()
            .and_then(|t| t.file_name().map(|s| s.to_string_lossy().into_owned()))
    }
    #[cfg(all(unix, not(target_os = "linux")))]
    {
        let _ = pid; // no /proc — degrade to the --adapter escape
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] the current process is alive.
    #[test]
    fn current_process_is_alive() {
        assert!(is_process_alive(std::process::id()));
    }

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] a pid far above any real
    // allocation reads as dead. Chosen deterministically (above Linux pid_max
    // ~4.2M and Windows ranges; positive as i32 so the Unix kill-group
    // semantics don't trigger) to avoid the pid-reuse flakiness of
    // spawn-then-reap under parallel test execution.
    #[test]
    fn unassigned_pid_is_dead() {
        const DEAD_PID: u32 = 2_000_000_000;
        assert!(
            !is_process_alive(DEAD_PID),
            "unassigned pid must read as dead"
        );
    }

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] pid 0 reads dead on BOTH OSes.
    // Without the guard, Unix `kill(0, 0)` signals the caller's own process
    // group (always succeeds) — a pid-0 row read permanently "alive" on Linux
    // while reading dead on Windows (the D3f rename-test CI divergence).
    #[test]
    fn pid_zero_is_dead_on_every_os() {
        assert!(!is_process_alive(0), "pid 0 is never a probe-able process");
    }

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] a zombie reads DEAD: an exited
    // child whose spawner never reaps it (in-process daemon hosting, tests)
    // still answers kill(pid,0) — but it runs nothing. Without the /proc
    // state check, a shell close would hold a dead binary "running" through
    // its whole grace window (the M5-D4a Linux CI failure).
    #[cfg(target_os = "linux")]
    #[test]
    fn zombie_child_reads_dead() {
        let child = std::process::Command::new("true").spawn().unwrap();
        let pid = child.id();
        std::mem::forget(child); // never reaped here — a zombie once exited
        for _ in 0..150 {
            if !is_process_alive(pid) {
                return; // exited (zombie or reaped-by-os) reads dead
            }
            std::thread::sleep(std::time::Duration::from_millis(20));
        }
        panic!("an exited, unreaped child must read dead");
    }

    // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] the parent pid resolves to a real,
    // live process (the test runner that spawned this process).
    #[test]
    fn parent_pid_resolves_and_is_alive() {
        let ppid = parent_pid().expect("parent pid must resolve");
        assert_ne!(ppid, 0, "parent pid must be non-zero");
        assert_ne!(ppid, std::process::id(), "parent must differ from self");
        assert!(is_process_alive(ppid), "the live parent must read as alive");
    }

    // [unit->REQ-MANIFEST-8] the bind-time match-key: a live pid resolves to its
    // own executable basename (extension intact), matching what `current_exe`
    // reports — the raw key the resolver normalizes (lowercase, `.exe`-strip).
    #[test]
    fn exe_basename_resolves_current_process() {
        let want = std::env::current_exe()
            .ok()
            .and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned()));
        let got = exe_basename(std::process::id());
        assert!(got.is_some(), "the current process's exe basename resolves");
        assert!(!got.as_deref().unwrap_or("").is_empty(), "basename non-empty");
        assert_eq!(got, want, "matches current_exe's basename");
    }

    // [unit->REQ-MANIFEST-8] a dead/unassigned pid yields no basename — the
    // resolver degrades to the --adapter escape rather than guessing.
    #[test]
    fn exe_basename_dead_pid_is_none() {
        const DEAD_PID: u32 = 2_000_000_000;
        assert!(exe_basename(DEAD_PID).is_none(), "no basename for a dead pid");
        assert!(exe_basename(0).is_none(), "pid 0 is never probe-able");
    }
}