270	impl Default for PumpHooks {
271	    /// The production hook: conflicts are loud, never silent — the artifacts
272	    /// already persist (hazard 6.6); the reconcile turn runs at activation.
273	    fn default() -> PumpHooks {
274	        PumpHooks {
275	            on_pull: Box::new(|peer, report| {
276	                for (branch, file) in report.conflicts() {
277	                    eprintln!("SYNC_CONFLICT:{peer}:{branch}:{file}");
278	                }
279	            }),
280	        }
281	    }
282	}
283
284	/// Run the pump until `stop` is set. Owns one brain IPC connection and a
285	/// per-peer connection cache (an IO failure against a peer drops its cache
286	/// row; the next tick redials — conns are rebuilt freely, nothing leaks
287	/// because the broker's closed-watcher reaps dead conns).
288	pub fn run_peer_pump(
289	    broker_name: &str,
290	    registry: Arc<RegistryHost>,
291	    paths: &PumpPaths,
292	    resolver: PeerResolver,
293	    config: PumpConfig,
294	    mut hooks: PumpHooks,
295	    stop: &AtomicBool,
296	) -> io::Result<()> {
297	    let PumpConfig {
298	        cadence,
299	        full_auto_update,
300	    } = config;
301	    let mut brain = connect(broker_name)?;
302	    let mut ops = EpochSource::load_from(&paths.ops);
303	    let mut conns: HashMap<String, u64> = HashMap::new();
304	    // Stagger from "everything due now" — the first tick primes peers.
305	    // `None` = never run yet → due immediately. Avoids the `Instant -
306	    // Duration` underflow panic on hosts whose uptime is below the offset
307	    // (Windows monotonic clock can't represent an instant before boot).
308	    let (mut last_reg, mut last_notif, mut last_sync, mut last_upd): (
309	        Option<Instant>,
310	        Option<Instant>,
311	        Option<Instant>,
312	        Option<Instant>,
313	    ) = (None, None, None, None);
314	    let mut last_hb: Option<Instant> = None;
315
316	    while !stop.load(Ordering::Relaxed) {
317	        let now = Instant::now();
318	        // Liveness heartbeat (M8 decision 23, REQ-DAEMON-5): written while
319	        // the loop is ALIVE — including idle ticks — never per work round,
320	        // so a wedged peer_io and a healthy quiet pump are distinguishable.
321	        // Throttled: the stall warning reads 3-cadence staleness, sub-second
322	        // precision buys nothing but disk churn.
323	        // [impl->REQ-DAEMON-5]
324	        if due(last_hb, now, HEARTBEAT_PERIOD) {
325	            write_heartbeat(&paths.heartbeat);
326	            last_hb = Some(now);
327	        }
328	        // Event-driven advertisement (M8 decision 15, REQ-CONV-2): an
329	        // endpoint online/offline transition dropped the marker — consume it
330	        // and run the registry leg NOW. A wake of THIS loop, not a second
331	        // advertisement path: the forced round is the same advertise_local +
332	        // push code, so the epoch lease and visibility gates ride unchanged.
333	        // [impl->REQ-CONV-2]
334	        let wake_adv = crate::registryhost::take_advertise_now(&paths.advertise_marker);
335	        let due_reg = wake_adv || due(last_reg, now, cadence.registry);
336	        let due_notif = due(last_notif, now, cadence.notif);
337	        // The wake edge's freshness pull (D9-2, REQ-INST-3): a dormant→active
338	        // transition dropped the marker — consume it and pull NOW, the
339	        // catch-up-on-activation latency is the point. The take is the remove,
340	        // so one wake claims exactly one forced round.
341	        // [impl->REQ-INST-3]
342	        let wake_pull = crate::resting::take_freshness_pull(&paths.pull_marker);
343	        let due_sync = wake_pull || due(last_sync, now, cadence.sync);
344	        let due_upd = due(last_upd, now, cadence.update);
345	        if !(due_reg || due_notif || due_sync || due_upd) {
346	            thread::sleep(TICK);
347	            continue;
348	        }
349
350	        // Re-load config-ish stores per round (the access-gate freshness
351	        // stance — edits take effect without a restart).
352	        let subnets = SubnetStore::load_from(&paths.subnets);
353	        // Serve-state (M8 decision 6, REQ-SUBNET-5): a detached subnet is
354	        // neither advertised into nor connected to — skipped wholesale in
355	        // the per-subnet loop below. Re-read per round like every other
356	        // config-ish store, so a flip takes effect within one tick.
357	        // [impl->REQ-SUBNET-5]
358	        let attachment = spt_store::attachment::AttachmentStore::load_from(
359	            &paths

impl Default for PumpHooks {
    /// The production hook: conflicts are loud, never silent — the artifacts
    /// already persist (hazard 6.6); the reconcile turn runs at activation.
    fn default() -> PumpHooks {
        PumpHooks {
            on_pull: Box::new(|peer, report| {
                for (branch, file) in report.conflicts() {
                    eprintln!("SYNC_CONFLICT:{peer}:{branch}:{file}");
                }
            }),
        }
    }
}

/// Run the pump until `stop` is set. Owns one brain IPC connection and a
/// per-peer connection cache (an IO failure against a peer drops its cache
/// row; the next tick redials — conns are rebuilt freely, nothing leaks
/// because the broker's closed-watcher reaps dead conns).
pub fn run_peer_pump(
    broker_name: &str,
    registry: Arc<RegistryHost>,
    paths: &PumpPaths,
    resolver: PeerResolver,
    config: PumpConfig,
    mut hooks: PumpHooks,
    stop: &AtomicBool,
) -> io::Result<()> {
    let PumpConfig {
        cadence,
        full_auto_update,
    } = config;
    let mut brain = connect(broker_name)?;
    let mut ops = EpochSource::load_from(&paths.ops);
    let mut conns: HashMap<String, u64> = HashMap::new();
    // Stagger from "everything due now" — the first tick primes peers.
    // `None` = never run yet → due immediately. Avoids the `Instant -
    // Duration` underflow panic on hosts whose uptime is below the offset
    // (Windows monotonic clock can't represent an instant before boot).
    let (mut last_reg, mut last_notif, mut last_sync, mut last_upd): (
        Option<Instant>,
        Option<Instant>,
        Option<Instant>,
        Option<Instant>,
    ) = (None, None, None, None);
    let mut last_hb: Option<Instant> = None;

    while !stop.load(Ordering::Relaxed) {
        let now = Instant::now();
        // Liveness heartbeat (M8 decision 23, REQ-DAEMON-5): written while
        // the loop is ALIVE — including idle ticks — never per work round,
        // so a wedged peer_io and a healthy quiet pump are distinguishable.
        // Throttled: the stall warning reads 3-cadence staleness, sub-second
        // precision buys nothing but disk churn.
        // [impl->REQ-DAEMON-5]
        if due(last_hb, now, HEARTBEAT_PERIOD) {
            write_heartbeat(&paths.heartbeat);
            last_hb = Some(now);
        }
        // Event-driven advertisement (M8 decision 15, REQ-CONV-2): an
        // endpoint online/offline transition dropped the marker — consume it
        // and run the registry leg NOW. A wake of THIS loop, not a second
        // advertisement path: the forced round is the same advertise_local +
        // push code, so the epoch lease and visibility gates ride unchanged.
        // [impl->REQ-CONV-2]
        let wake_adv = crate::registryhost::take_advertise_now(&paths.advertise_marker);
        let due_reg = wake_adv || due(last_reg, now, cadence.registry);
        let due_notif = due(last_notif, now, cadence.notif);
        // The wake edge's freshness pull (D9-2, REQ-INST-3): a dormant→active
        // transition dropped the marker — consume it and pull NOW, the
        // catch-up-on-activation latency is the point. The take is the remove,
        // so one wake claims exactly one forced round.
        // [impl->REQ-INST-3]
        let wake_pull = crate::resting::take_freshness_pull(&paths.pull_marker);
        let due_sync = wake_pull || due(last_sync, now, cadence.sync);
        let due_upd = due(last_upd, now, cadence.update);
        if !(due_reg || due_notif || due_sync || due_upd) {
            thread::sleep(TICK);
            continue;
        }

        // Re-load config-ish stores per round (the access-gate freshness
        // stance — edits take effect without a restart).
        let subnets = SubnetStore::load_from(&paths.subnets);
        // Serve-state (M8 decision 6, REQ-SUBNET-5): a detached subnet is
        // neither advertised into nor connected to — skipped wholesale in
        // the per-subnet loop below. Re-read per round like every other
        // config-ish store, so a flip takes effect within one tick.
        // [impl->REQ-SUBNET-5]
        let attachment = spt_store::attachment::AttachmentStore::load_from(
            &paths


note that i intentionally cleared its config and subnets


note that i intentionally cleared its config and subnets before the install


<task-notification>
<task-id>bg8v7n9qi</task-id>
<tool-use-id>toolu_019BL8PiKXCyQKUsra469z2p</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bg8v7n9qi.output</output-file>
<status>completed</status>
<summary>Background command "live: uptime + status + heartbeat + journal" completed (exit code 0)</summary>
</task-notification>


Key code finding: the heartbeat is written at the **top** of the loop (peerloop.rs:324), *before* any subnet/work gating — the comment explicitly says "written while the loop is ALIVE — including idle ticks." So a standalone node with no subnets **should still heartbeat**. That means STALLED is likely a **real** pump wedge/death, not a standalone misreport — but I need to confirm against the live box.

The screenshot also shows the new `managed-by:` line → kitsubito is running **v0.3.0** (full install of the new release), and `subnets: none` is expected after a clean install (the re-pair-needed state I documented). So the only question is the pump.

Let me gather live evidence + read the supervisor in parallel:


Bash

ssh reavus@kitsubito 'echo "=== uptime ==="; uptime -p; uptime -s; echo "=== daemon status now ==="; spt daemon 2>&1 | head -6; echo "=== heartbeat file ==="; find ~/.local/share/spt ~/.config/spt /run/user/$(id -u)/spt -name "*heartbeat*" -o -name "*pump*" 2>/dev/null | head; echo "=== journalctl pump/panic (last 15m) ==="; journalctl --user -u spt-daemon --since "15 min ago" --no-pager 2>&1 | grep -iE "panic|PUMP|PEER_PUMP|FAIL|error|DEELEV|net|bind|STALL" | tail -30' 2>&1 | head -60

live: uptime + status + heartbeat + journal


Command running in background with ID: bg8v7n9qi. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bg8v7n9qi.output. You will be notified when it completes. To check interim output, use Read on that file path.


cd "C:/Users/decid/Documents/projects/spt-core" && echo "=== spawn_peer_pump supervisor ==="; git grep -n "spawn_peer_pump\|catch_unwind\|PEER_PUMP\|HEARTBEAT_PERIOD\|fn write_heartbeat" -- crates/spt-daemon/src/peerloop.rs | head; echo "=== heartbeat path ==="; git grep -n "fn pump_heartbeat_path" -- crates/spt-daemon/src/endpoint.rs

supervisor + heartbeat period


=== spawn_peer_pump supervisor ===
crates/spt-daemon/src/peerloop.rs:79:const HEARTBEAT_PERIOD: Duration = Duration::from_secs(5);
crates/spt-daemon/src/peerloop.rs:324:        if due(last_hb, now, HEARTBEAT_PERIOD) {
crates/spt-daemon/src/peerloop.rs:725:fn write_heartbeat(path: &std::path::Path) {
crates/spt-daemon/src/peerloop.rs:748:/// (`PEER_PUMP_PANIC`), an error return logged (`PEER_PUMP_FAIL`), and the
crates/spt-daemon/src/peerloop.rs:760:        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(&mut body));
crates/spt-daemon/src/peerloop.rs:768:            Ok(Ok(())) => eprintln!("PEER_PUMP_EXIT: loop returned without stop — restarting"),
crates/spt-daemon/src/peerloop.rs:769:            Ok(Err(e)) => eprintln!("PEER_PUMP_FAIL: {e}"),
crates/spt-daemon/src/peerloop.rs:776:                eprintln!("PEER_PUMP_PANIC: {msg}");
crates/spt-daemon/src/peerloop.rs:781:            "PEER_PUMP_RESTART: supervised restart in {}s",
crates/spt-daemon/src/peerloop.rs:797:pub fn spawn_peer_pump(
=== heartbeat path ===
crates/spt-daemon/src/endpoint.rs:59:pub fn pump_heartbeat_path() -> PathBuf {


Prompt: note that i intentionally cleared its config and subnets before the install


Prompt:
<task-notification>
<task-id>bg8v7n9qi</task-id>
<tool-use-id>toolu_019BL8PiKXCyQKUsra469z2p</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bg8v7n9qi.output</output-file>
<status>completed</status>
<summary>Background command "live: uptime + status + heartbeat + journal" completed (exit code 0)</summary>
</task-notification>


/diagnose this happened on kitsubito after restarting the machine post-full install. is it misreporting, or an actual issue? [Image #1]


Got it — config + subnets intentionally cleared pre-install, so `subnets: none` is confirmed expected (not a bug). That isolates the question to the pump.

`HEARTBEAT_PERIOD = 5s`, written every loop iteration before any work gating — so 352s stale = the loop genuinely stopped iterating. The supervisor logs `PEER_PUMP_PANIC`/`PEER_PUMP_FAIL`/`PEER_PUMP_RESTART`. Read the live evidence:


1	=== uptime ===
2	up 9 minutes
3	2026-06-08 21:12:27
4	=== daemon status now ===
5	daemon: running (pid 1359)
6	peer pump: STALLED (last tick 633s ago — advertising/connecting is not running; restart the daemon)
7	managed-by: systemd --user (active)
8	subnets: (none — this node is standalone)
9	local endpoints: (none)
10	=== heartbeat file ===
11	=== journalctl pump/panic (last 15m) ===
12	Jun 08 21:12:33 KITSUBITO spt[1359]: NET_BIND_FAIL: endpoint bind failed: Failed to create an address lookup service — broker runs net-less
13

=== uptime ===
up 9 minutes
2026-06-08 21:12:27
=== daemon status now ===
daemon: running (pid 1359)
peer pump: STALLED (last tick 633s ago — advertising/connecting is not running; restart the daemon)
managed-by: systemd --user (active)
subnets: (none — this node is standalone)
local endpoints: (none)
=== heartbeat file ===
=== journalctl pump/panic (last 15m) ===
Jun 08 21:12:33 KITSUBITO spt[1359]: NET_BIND_FAIL: endpoint bind failed: Failed to create an address lookup service — broker runs net-less


Decisive evidence:

1. **`NET_BIND_FAIL: endpoint bind failed: Failed to create an address lookup service — broker runs net-less`** at 21:12:33 — **6 seconds after boot** (booted 21:12:27).
2. `last tick 633s ago` but **uptime is only 540s** → the heartbeat file is a **pre-boot leftover**; the pump has written **no heartbeat this boot at all**.

So: this is a **boot race**, not a misreport-and-not-a-bug. The systemd user unit started `spt-daemon` ~6s into boot, before the network/DNS stack was ready → iroh's endpoint couldn't create its address-lookup (discovery) service → **broker came up net-less** → the pump can't run → status reads the stale pre-boot heartbeat as STALLED.

Let me confirm the causal chain in code (is the pump gated on net? does the broker retry the bind?) + check the unit's ordering + verify a restart fixes it:


160	                        discovery: LocalDiscovery::Mdns,
161	                        scope: BindScope::All, // production: real interfaces
162	                        // Mesh-D2: enforce seed-proof on every dial + accept,
163	                        // reading the live subnet store each connect.
164	                        membership: Some(crate::seedproofx::production_membership()),
165	                    };
166	                    match NetHost::start(net_cfg) {
167	                        Ok(host) => {
168	                            // Mesh-D4 (REQ-MESH-2): wire on-connect roster
169	                            // propagation onto the live host (off NetConfig by
170	                            // design — see NetHost::roster).
171	                            host.set_roster_exchange(
172	                                crate::seedproofx::production_roster_exchange(),
173	                            );
174	                            Some(host)
175	                        }
176	                        Err(e) => {
177	                            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
178	                            None
179	                        }
180	                    }
181	                }
182	                Err(e) => {
183	                    eprintln!("NODE_KEY_FAIL: {e} — broker runs net-less");
184	                    None
185	                }
186	            }
187	        };
188
189	        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
190	        // background thread; a bind failure there must not stop the seed channel
191	        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
192	        let net_up = net.is_some();
193	        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
194	            Ok(broker) => {
195	                thread::spawn(move || {
196	                    let _ = broker.serve();
197	                });
198	                // Inbound net dispatch + outbound peer pump (D9-1): the
199	                // production trigger loops. Only meaningful with a net host —
200	                // a net-less broker accepts nothing and dials nobody.
201	                if net_up {
202	                    let hex = node_hex.clone().unwrap_or_default();
203	                    let registry = Arc::new(crate::registryhost::RegistryHost::new(&hex));
204	                    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
205	                    let _ = crate::dispatch::spawn_dispatcher(
206	                        broker_socket_name(),
207	                        Arc::clone(&registry),
208	                        Arc::clone(&stop),
209	                    );
210	                    let cfg = DaemonConfig::load();
211	                    if cfg.peer_pump {
212	                        let _ = crate::peerloop::spawn_peer_pump(
213	                            broker_socket_name(),
214	                            registry,
215	                            cfg,
216	                            stop,
217	                        );
218	                    }
219	                }
220	            }
221	            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
222	        }
223
224	        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
225	        // the digest-control channel serves `spt digest <id>` snapshot pulls +
226	        // delta-stream subscribes against it. Served on a background thread like
227	        // the broker. The per-session feed that *populates* the hub is driven by
228	        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
229	        // sessions are spawned through the broker; a bare daemon serves the
230	        // channel and answers "no live digest" until a session is hosted.
231	        let digest_hub = Arc::new(DigestHub::new());
232	        {
233	            let hub = Arc::clone(&digest_hub);
234	            thread::spawn(move || {
235	                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
236	            });
237	        }
238
239	        // Shell wake-watchers (M5-D4b): the daemon hosts the offline half of
240	        // the shell online/offline exclusivity — a reconcile loop that runs
241	        // each offline instance's manifest `wake_command` as a supervised
242	        // watcher child (exit-opcode → wake resolution) and stops it when the
243	        // binary side wins. Boot sweep doubles as orphan cleanup after a
244	        // daemon crash. The daemon runs until killed, so the stop flag here
245	        // is held for symmetry with the dispatcher loops, never raised.
246	        // [impl->REQ-SHELL-2]
247	        {
248	            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
249	            let _ = crate::shellwake::spawn_wake_host(stop);
250	        }
251
252	        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
253	        // is the socket ping, never this file, so a stale value is harmless.
254	        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());
255
256	        // The seed-control channel is the foreground loop. Binding it is what
257	        // arbitrates the single-daemon-per-home invariant: a second daemon racing
258	        // to start fails this bind and exits, leaving the winner serving.
259	        seedmap::serve_seed_control(&seed_socket_name(), reg)

                        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) => {
                            // Mesh-D4 (REQ-MESH-2): wire on-connect roster
                            // propagation onto the live host (off NetConfig by
                            // design — see NetHost::roster).
                            host.set_roster_exchange(
                                crate::seedproofx::production_roster_exchange(),
                            );
                            Some(host)
                        }
                        Err(e) => {
                            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
                            None
                        }
                    }
                }
                Err(e) => {
                    eprintln!("NODE_KEY_FAIL: {e} — broker runs net-less");
                    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();
        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
            Ok(broker) => {
                thread::spawn(move || {
                    let _ = broker.serve();
                });
                // Inbound net dispatch + outbound peer pump (D9-1): the
                // production trigger loops. Only meaningful with a net host —
                // a net-less broker accepts nothing and dials nobody.
                if net_up {
                    let hex = node_hex.clone().unwrap_or_default();
                    let registry = Arc::new(crate::registryhost::RegistryHost::new(&hex));
                    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
                    let _ = crate::dispatch::spawn_dispatcher(
                        broker_socket_name(),
                        Arc::clone(&registry),
                        Arc::clone(&stop),
                    );
                    let cfg = DaemonConfig::load();
                    if cfg.peer_pump {
                        let _ = crate::peerloop::spawn_peer_pump(
                            broker_socket_name(),
                            registry,
                            cfg,
                            stop,
                        );
                    }
                }
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
        }

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

        // Shell wake-watchers (M5-D4b): the daemon hosts the offline half of
        // the shell online/offline exclusivity — a reconcile loop that runs
        // each offline instance's manifest `wake_command` as a supervised
        // watcher child (exit-opcode → wake resolution) and stops it when the
        // binary side wins. Boot sweep doubles as orphan cleanup after a
        // daemon crash. The daemon runs until killed, so the stop flag here
        // is held for symmetry with the dispatcher loops, never raised.
        // [impl->REQ-SHELL-2]
        {
            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
            let _ = crate::shellwake::spawn_wake_host(stop);
        }

        // 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)


**Root cause confirmed in code** (daemon.rs:201): the peer pump is spawned **only if `net_up`**. When `NetHost::start` fails, the broker runs net-less and **the pump is never spawned** — so no heartbeat is written this boot, and `spt daemon` renders the stale pre-boot heartbeat as STALLED.

Full chain: systemd unit autostarts the daemon ~6s into boot → network/DNS not ready → iroh can't create its address-lookup service → `NET_BIND_FAIL` → net-less broker → pump not spawned → frozen heartbeat → "STALLED".

Verify the boot-race hypothesis: a restart now (network up) should bind net + spawn the pump. Also check the unit's ordering:


ssh reavus@kitsubito 'echo "=== unit ordering (network-online?) ==="; grep -iE "After|Wants|network" ~/.config/systemd/user/spt-daemon.service; echo "(none above = no network ordering)"; echo "=== restart daemon (network now up) ==="; systemctl --user restart spt-daemon; sleep 4; echo "=== status after restart ==="; spt daemon 2>&1 | head -4; echo "=== net bind this time? ==="; journalctl --user -u spt-daemon --since "20 sec ago" --no-pager 2>&1 | grep -iE "NET_BIND_FAIL|online|attached|pump" | tail -5; echo "=== wait 8s, re-check heartbeat advances ==="; sleep 8; spt daemon 2>&1 | grep -i "peer pump"' 2>&1 | head -30

verify restart fixes net + pump (boot race)


Command running in background with ID: byrxxji0b. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\byrxxji0b.output. You will be notified when it completes. To check interim output, use Read on that file path.


until grep -qE "peer pump" "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/byrxxji0b.output" 2>/dev/null; do sleep 3; done; cat "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/byrxxji0b.output"

read restart verification


<task-notification>
<task-id>byrxxji0b</task-id>
<tool-use-id>toolu_013hm8kTbGkHsEiu6BrGQ1Et</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\byrxxji0b.output</output-file>
<status>completed</status>
<summary>Background command "verify restart fixes net + pump (boot race)" completed (exit code 0)</summary>
</task-notification>


=== unit ordering (network-online?) ===
(none above = no network ordering)
=== restart daemon (network now up) ===
=== status after restart ===
daemon: running (pid 30621)
peer pump: live (last tick 3s ago)
managed-by: systemd --user (active)
subnets: (none — this node is standalone)
=== net bind this time? ===
=== wait 8s, re-check heartbeat advances ===
peer pump: live (last tick 1s ago)


Prompt:
<task-notification>
<task-id>byrxxji0b</task-id>
<tool-use-id>toolu_013hm8kTbGkHsEiu6BrGQ1Et</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\byrxxji0b.output</output-file>
<status>completed</status>
<summary>Background command "verify restart fixes net + pump (boot race)" completed (exit code 0)</summary>
</task-notification>


Diagnosis confirmed end-to-end. The restart proved it: with the network up, net binds first try and `peer pump: live (last tick 1s ago)`. The unit has **no** `network-online` ordering.

## Verdict: both — a real bug *and* a misreport

**`subnets: none`** — not a bug. You cleared config/subnets pre-install; correct render.

**`peer pump: STALLED`** — a **real issue**, surfaced misleadingly.

### Root cause (boot race), fully traced
1. Machine boots 21:12:27. The systemd **user** unit autostarts `spt-daemon` at 21:12:33 — **6s into boot, before the network/DNS stack is up**. The unit has no `After=/Wants=network-online.target`.
2. iroh's endpoint can't initialize its discovery resolver → journal: `NET_BIND_FAIL: ... Failed to create an address lookup service — broker runs net-less`.
3. `daemon.rs:201` spawns the dispatcher + peer pump **only `if net_up`**. Net-less → **pump never spawned**.
4. No pump → no heartbeat written this boot. `spt daemon` reads the **leftover pre-boot heartbeat** and renders it as `STALLED (633s ago)` — note 633s **>** the 540s uptime, the tell that it's a stale file, not a real 633s-old tick.

### Two real defects behind it
1. **A net-less broker stays net-less forever.** `NetHost::start` is one-shot — no retry, no self-heal when the network appears. The node is unreachable after every reboot until a manual `systemctl --user restart spt-daemon`. (The REQ-DAEMON-5 pump supervisor doesn't help — it only restarts a pump that was *spawned*; here it never was.)
2. **Status hides the cause.** It blames the pump ("advertising/connecting is not running; restart the daemon") and shows a bogus age, never surfacing that the **broker is net-less / no connection**. The `NET_BIND_FAIL` is invisible to the user.

### Fix options (ranked)
1. **Net-bind retry / late-bring-up in the broker** *(the real fix)* — on `NET_BIND_FAIL`, retry with backoff (or spawn the pump regardless and have it bring net up when the network appears). Self-heals no matter *why* net was down at boot. Testable in CI.
2. **Unit ordering** *(cheap proximate fix)* — add `Wants=network-online.target` + `After=network-online.target` to the unit + the installer that writes it. Fixes the systemd path only; user-unit network-online is a bit finicky with linger.
3. **Status honesty** *(the misreport half)* — when the broker is net-less, render "no connection (net endpoint failed to bind)"; treat a heartbeat older than process start as "no heartbeat this boot," not a giant age. Ties into the existing deferred "blocked-inbound → no connection" row.

kitsubito is **healthy now** (I restarted it; pump live). It'll recur on next reboot until fixed.

Want me to implement? I'd suggest **#1 + #3** together (real self-heal + honest status) and **#2** as a belt-and-suspenders installer tweak — minting `REQ-DAEMON-*` first per the rules. Or I can drop it into DEFERRED.md if you'd rather not touch main right after the release. Which way?


proceed to implement as recommended, then deploy as v0.3.1 when ready


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


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


On it — implement #1 + #3 + #2, mint REQs first, ship v0.3.1. First I need to understand the net/broker plumbing to choose the least-invasive robust seam (inline retry vs late-attach). Read the broker net wiring + NetHost::start:


cd "C:/Users/decid/Documents/projects/spt-core" && echo "=== bind_in_with_net + net field ==="; git grep -nE "fn bind_in_with_net|fn bind_in|net:|net_host|NetHost|attach_net|set_net|fn serve\b" -- crates/spt-daemon/src/broker.rs | head -25; echo "=== NetHost::start signature + address lookup ==="; git grep -nE "fn start|address lookup|DiscoveryService|Failed to create|impl NetHost" -- crates/spt-net/src/**/*.rs crates/spt-daemon/src/nethost.rs 2>/dev/null | head

broker net plumbing + nethost start


=== bind_in_with_net + net field ===
crates/spt-daemon/src/broker.rs:43:use spt_net::net::endpoint::EndpointAddr;
crates/spt-daemon/src/broker.rs:59:use crate::nethost::{NetHost, NET_EFFECT_SESSION};
crates/spt-daemon/src/broker.rs:171:    net: Option<NetHost>,
crates/spt-daemon/src/broker.rs:183:    pub fn bind_in(name: &str, journal_path: std::path::PathBuf) -> io::Result<Arc<Self>> {
crates/spt-daemon/src/broker.rs:192:    pub fn bind_in_with_net(
crates/spt-daemon/src/broker.rs:195:        net: Option<NetHost>,
crates/spt-daemon/src/broker.rs:213:    pub fn net(&self) -> Option<&NetHost> {
crates/spt-daemon/src/broker.rs:223:    pub fn serve(self: &Arc<Self>) -> io::Result<()> {
=== NetHost::start signature + address lookup ===
crates/spt-daemon/src/nethost.rs:437:impl NetHost {
crates/spt-daemon/src/nethost.rs:449:    pub fn start(cfg: NetConfig) -> io::Result<NetHost> {


Broker holds `net: Option<NetHost>` as an immutable field behind `Arc` (set at bind). The pump/dispatcher reach net via IPC, not a direct handle — so late-attach is plausible if the broker's net field becomes late-settable. Need to see how `serve()` uses net + the accept loop. Read broker net usage + NetHost::start:


183	    pub fn bind_in(name: &str, journal_path: std::path::PathBuf) -> io::Result<Arc<Self>> {
184	        Self::bind_in_with_net(name, journal_path, None)
185	    }
186
187	    /// Bind with an already-started broker-owned network host (D4a). Taking the
188	    /// host (not its config) keeps a net bind failure separable from a listener
189	    /// bind failure — the daemon degrades to a net-less broker rather than
190	    /// losing PTY hosting (see `Daemon::run`).
191	    // [impl->REQ-DAEMON-2]
192	    pub fn bind_in_with_net(
193	        name: &str,
194	        journal_path: std::path::PathBuf,
195	        net: Option<NetHost>,
196	    ) -> io::Result<Arc<Self>> {
197	        Ok(Arc::new(Broker {
198	            listener: LocalSocketTransport::bind(name)?,
199	            sessions: Arc::new(Mutex::new(HashMap::new())),
200	            next_id: AtomicU64::new(1),
201	            log_cap_chunks: DEFAULT_LOG_CHUNKS,
202	            journal: Arc::new(EffectJournal::open(journal_path)?),
203	            net,
204	        }))
205	    }
206
207	    /// The broker-owned exactly-once anchor (for introspection / tests).
208	    pub fn journal(&self) -> &Arc<EffectJournal> {
209	        &self.journal
210	    }
211
212	    /// The broker-owned network host, if this broker was bound with one.
213	    pub fn net(&self) -> Option<&NetHost> {
214	        self.net.as_ref()
215	    }
216
217	    /// Accept connections forever, handling each on its own thread. The broker
218	    /// is the long-lived process; this is its main loop. Per-connection
219	    /// threading is half the KH 7.4 isolation stance: one brain's blocking
220	    /// dispatch can never stall another's (the per-session half is
221	    /// [`Broker::dispatch_spawn`]'s drain/waiter threads).
222	    // [impl->REQ-HAZARD-DAEMON-SCHED-NONBLOCKING]
223	    pub fn serve(self: &Arc<Self>) -> io::Result<()> {
224	        loop {
225	            let conn = self.listener.accept()?;
226	            let me = Arc::clone(self);
227	            thread::spawn(move || {
228	                let _ = me.handle_conn(conn);
229	            });
230	        }
231	    }
232
233	    /// Accept exactly one connection and handle it on a spawned thread, returning
234	    /// that thread's handle. The single-brain entry point the tests exercise.
235	    pub fn serve_once(self: &Arc<Self>) -> io::Result<JoinHandle<()>> {
236	        let conn = self.listener.accept()?;
237	        let me = Arc::clone(self);
238	        Ok(thread::spawn(move || {
239	            let _ = me.handle_conn(conn);
240	        }))
241	    }
242
243	    /// Number of currently hosted sessions (for tests / introspection).
244	    pub fn session_count(&self) -> usize {
245	        self.sessions.lock().unwrap().len()
246	    }
247
248	    /// The child pid of a hosted session, if present (tests assert it is stable
249	    /// across brain restarts).
250	    pub fn session_pid(&self, id: u64) -> Option<u32> {
251	        self.sessions
252	            .lock()

    pub fn bind_in(name: &str, journal_path: std::path::PathBuf) -> io::Result<Arc<Self>> {
        Self::bind_in_with_net(name, journal_path, None)
    }

    /// Bind with an already-started broker-owned network host (D4a). Taking the
    /// host (not its config) keeps a net bind failure separable from a listener
    /// bind failure — the daemon degrades to a net-less broker rather than
    /// losing PTY hosting (see `Daemon::run`).
    // [impl->REQ-DAEMON-2]
    pub fn bind_in_with_net(
        name: &str,
        journal_path: std::path::PathBuf,
        net: Option<NetHost>,
    ) -> io::Result<Arc<Self>> {
        Ok(Arc::new(Broker {
            listener: LocalSocketTransport::bind(name)?,
            sessions: Arc::new(Mutex::new(HashMap::new())),
            next_id: AtomicU64::new(1),
            log_cap_chunks: DEFAULT_LOG_CHUNKS,
            journal: Arc::new(EffectJournal::open(journal_path)?),
            net,
        }))
    }

    /// The broker-owned exactly-once anchor (for introspection / tests).
    pub fn journal(&self) -> &Arc<EffectJournal> {
        &self.journal
    }

    /// The broker-owned network host, if this broker was bound with one.
    pub fn net(&self) -> Option<&NetHost> {
        self.net.as_ref()
    }

    /// Accept connections forever, handling each on its own thread. The broker
    /// is the long-lived process; this is its main loop. Per-connection
    /// threading is half the KH 7.4 isolation stance: one brain's blocking
    /// dispatch can never stall another's (the per-session half is
    /// [`Broker::dispatch_spawn`]'s drain/waiter threads).
    // [impl->REQ-HAZARD-DAEMON-SCHED-NONBLOCKING]
    pub fn serve(self: &Arc<Self>) -> io::Result<()> {
        loop {
            let conn = self.listener.accept()?;
            let me = Arc::clone(self);
            thread::spawn(move || {
                let _ = me.handle_conn(conn);
            });
        }
    }

    /// Accept exactly one connection and handle it on a spawned thread, returning
    /// that thread's handle. The single-brain entry point the tests exercise.
    pub fn serve_once(self: &Arc<Self>) -> io::Result<JoinHandle<()>> {
        let conn = self.listener.accept()?;
        let me = Arc::clone(self);
        Ok(thread::spawn(move || {
            let _ = me.handle_conn(conn);
        }))
    }

    /// Number of currently hosted sessions (for tests / introspection).
    pub fn session_count(&self) -> usize {
        self.sessions.lock().unwrap().len()
    }

    /// The child pid of a hosted session, if present (tests assert it is stable
    /// across brain restarts).
    pub fn session_pid(&self, id: u64) -> Option<u32> {
        self.sessions
            .lock()


please roll `spt update fetch` into 0.3.1 after the fix code is done.


`serve()` is just the IPC accept loop — net is reached on-demand via `broker.net()` per IPC effect, not in a startup loop. So **late-attach is clean**: bind net-less, serve immediately, attach net when a background retry succeeds, then spawn the pump. Size the blast radius — `net()` and `bind_in_with_net` callers:


cd "C:/Users/decid/Documents/projects/spt-core" && echo "=== broker.net() callers ==="; git grep -nE "\.net\(\)" -- crates/spt-daemon/src | grep -v "fn net" | head -20; echo "=== bind_in_with_net callers ==="; git grep -n "bind_in_with_net" -- crates/spt-daemon/src crates/spt/src | head; echo "=== NetHost Send/Sync ==="; git grep -nE "struct NetHost|unsafe impl (Send|Sync) for NetHost" -- crates/spt-daemon/src/nethost.rs | head

net() + bind_in_with_net callers


=== broker.net() callers ===
=== bind_in_with_net callers ===
crates/spt-daemon/src/broker.rs:184:        Self::bind_in_with_net(name, journal_path, None)
crates/spt-daemon/src/broker.rs:192:    pub fn bind_in_with_net(
crates/spt-daemon/src/daemon.rs:193:        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
crates/spt/src/wansend.rs:576:        let broker = Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host))
=== NetHost Send/Sync ===
crates/spt-daemon/src/nethost.rs:411:pub struct NetHost {


Prompt: please roll `spt update fetch` into 0.3.1 after the fix code is done.


Acknowledged — `spt update fetch` (REQ-UPD-7) rolls into 0.3.1 too, after the fix code. So 0.3.1 = net-retry self-heal + status honesty + unit ordering + origin-source update fetch.

`.net()` has no callers — so net is consumed another way (the effect-session wiring). Let me see how `self.net` is actually used before locking the late-attach approach:


cd "C:/Users/decid/Documents/projects/spt-core" && echo "=== self.net usage in broker ==="; git grep -nE "self\.net|\.net\b|NET_EFFECT_SESSION|net_effect|register.*net|net\.register" -- crates/spt-daemon/src/broker.rs | head -20; echo "=== NET_EFFECT_SESSION def + wiring ==="; git grep -nE "NET_EFFECT_SESSION|fn net_session|effect_session" -- crates/spt-daemon/src/nethost.rs crates/spt-daemon/src/broker.rs | head

how broker uses net field


=== self.net usage in broker ===
crates/spt-daemon/src/broker.rs:59:use crate::nethost::{NetHost, NET_EFFECT_SESSION};
crates/spt-daemon/src/broker.rs:214:        self.net.as_ref()
crates/spt-daemon/src/broker.rs:371:        if let Some(host) = &self.net {
crates/spt-daemon/src/broker.rs:548:        let reply = match &self.net {
crates/spt-daemon/src/broker.rs:572:    /// [`EffectJournal`] under the reserved [`NET_EFFECT_SESSION`] namespace,
crates/spt-daemon/src/broker.rs:585:            .net
crates/spt-daemon/src/broker.rs:604:                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetDial, || {
crates/spt-daemon/src/broker.rs:641:            .net
crates/spt-daemon/src/broker.rs:694:        let host = self.net.as_ref().ok_or("net disabled")?;
crates/spt-daemon/src/broker.rs:716:                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
crates/spt-daemon/src/broker.rs:744:        let host = self.net.as_ref().ok_or("net disabled")?;
crates/spt-daemon/src/broker.rs:752:                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
crates/spt-daemon/src/broker.rs:774:        let host = self.net.as_ref().ok_or("net disabled")?;
crates/spt-daemon/src/broker.rs:796:        let host = self.net.as_ref().ok_or("net disabled")?;
crates/spt-daemon/src/broker.rs:813:        let host = self.net.as_ref().ok_or("net disabled")?;
=== NET_EFFECT_SESSION def + wiring ===
crates/spt-daemon/src/broker.rs:59:use crate::nethost::{NetHost, NET_EFFECT_SESSION};
crates/spt-daemon/src/broker.rs:572:    /// [`EffectJournal`] under the reserved [`NET_EFFECT_SESSION`] namespace,
crates/spt-daemon/src/broker.rs:604:                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetDial, || {
crates/spt-daemon/src/broker.rs:716:                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
crates/spt-daemon/src/broker.rs:752:                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
crates/spt-daemon/src/nethost.rs:52:pub const NET_EFFECT_SESSION: u64 = 0;


Net is read via `&self.net` at ~10 effect-handler sites — switching the field to `OnceLock<NetHost>` (lock-free `.get()`) makes it late-settable cleanly, with `attach_net()` for the retry path. Approach locked.

v0.3.1 scope is now substantial: **(1)** net-retry self-heal + late pump spawn, **(2)** status honesty, **(3)** unit ordering, **(4)** `spt update fetch`. Let me track it with a JIT plan, then mint REQs. Switch to dev-freeform:


cd "C:/Users/decid/Documents/projects/spt-core" && git checkout dev-freeform 2>&1 | tail -1 && git log --oneline -1; echo "=== REQ-UPD tail + REQ-DAEMON region for minting ==="; grep -nE "REQ-UPD-6|REQ-DAEMON-8|REQ-INSTALL-8" traceable-reqs.toml | head

switch branch + locate req insert points


Switched to branch 'dev-freeform'
9c825cf docs(deferred): origin-source update bootstrap (`spt update fetch` from GitHub)
=== REQ-UPD tail + REQ-DAEMON region for minting ===
359:id = "REQ-UPD-6"
687:# at D3, REQ-CONV-*/REQ-PAIR-8/REQ-DAEMON-5 at D4 (D4 also adds REQ-UPD-6 int
726:id = "REQ-INSTALL-8"
757:id = "REQ-DAEMON-8"


Write

# v0.3.1 — boot-race net self-heal + status honesty + `spt update fetch`

JIT plan. Origin: kitsubito 2026-06-08 post-reboot diagnosis — the systemd user unit autostarts `spt-daemon` ~6s into boot, before the network/DNS stack is up → `NET_BIND_FAIL: Failed to create an address lookup service` → broker runs net-less → the peer pump is gated on `net_up` (`daemon.rs:201`) so it is NEVER spawned → no heartbeat this boot → `spt daemon` renders the stale pre-boot heartbeat as `STALLED` (age > uptime). Node stays unreachable until a manual `systemctl --user restart spt-daemon`. Restart with the network up binds net first try (verified live).

## Scope (REQ-DAEMON-9, REQ-UPD-7)

### 1. Net-bind self-heal — REQ-DAEMON-9 (impl, unit)
- `Broker.net`: `Option<NetHost>` → `OnceLock<NetHost>` (lock-free `.get()`); ~10 read sites `self.net.as_ref()`/`&self.net` → `self.net.get()`. `bind_in_with_net(Some(host))` sets it immediately (fast path). Add `attach_net(&self, host) -> bool`.
- `Daemon::run`: factor the dispatcher+pump spawn into a `spawn_net_consumers(broker, registry, stop, cfg)` helper. Fast path (NetHost::start Ok at boot) → bind, attach, spawn consumers as today. Slow path (NET_BIND_FAIL) → bind net-less, serve seed-control immediately (don't block liveness), spawn a BACKGROUND supervisor thread that retries `NetHost::start` with capped backoff (≤ ~60s) until it succeeds → `attach_net` → spawn consumers. Log `NET_BIND_RETRY` / `NET_ATTACHED`.
- Pure-testable seam: the backoff ladder (reuse the REQ-DAEMON-5 pump-supervisor backoff shape) + "consumers spawn exactly once on attach". OnceLock guarantees single attach.

### 2. Status honesty — REQ-DAEMON-9 (impl, unit)
- `cmd_daemon_status`: when the broker is net-less, render `connection: no connection (net endpoint not bound — retrying)` instead of only the pump-stall line; surface that net is down as the CAUSE.
- Heartbeat-age sanity: a heartbeat timestamp older than process/daemon start = "no heartbeat this boot", render as starting/np, NOT a giant bogus age. (Reader: compare last-tick vs a daemon-start marker, or treat age > some ceiling as "not yet ticked".) Pure unit on the render classifier.

### 3. Unit ordering — REQ-DAEMON-9 (impl) / installer
- `installer/install.sh` systemd unit: add `Wants=network-online.target` + `After=network-online.target` (+ `[Install] WantedBy` unchanged). Belt-and-suspenders with the self-heal. (User units + network-online is imperfect under linger — the daemon self-heal #1 is the real guarantee.)

### 4. `spt update fetch` — REQ-UPD-7 (impl, unit) — origin-source bootstrap
- New `spt update fetch [--channel <ch>] [--tag vX.Y.Z]`: pull the signed release from `github.com/SaberMage/spt-releases/releases/{latest,tag/<tag>}/download/` — the per-platform artifact + its `<asset>.release.json` (`SignedRelease` metadata) — and feed BOTH through the existing `plan_verified` gate (`update.rs:157`: verify_metadata two-key sig/channel/expiry/rollback + verify_artifact SHA-256), then `cache.stage()`. After staging, the existing consent-notif / `spt update apply` flow is unchanged.
- HTTP client: the daemon has zero HTTP today (iroh QUIC only; reqwest transitive). Decision: add a SMALL HTTPS client. Prefer `reqwest` as a direct dep on the `spt` CLI crate (blocking client, rustls) — the fetch runs in the CLI, not the daemon, so no daemon HTTP surface. Verify it builds `--no-default-features`-clean (feature-gate behind the `net` feature if needed).
- Honor channel pin (`release-keys.json`), monotonic rollback floor, configurable repo (mirror `SPT_INSTALL_REPO`). Asset names: `spt-x86_64-linux` / `spt-x86_64-windows.exe` + `<asset>.release.json`.
- Surface in reference.md (xtask gen → docs-drift will fire). Unit: the URL/asset derivation + the verify-then-stage wiring against a fixture release.json (reuse release.rs test vectors).

## Sequencing
Mint REQ-DAEMON-9 + REQ-UPD-7 → impl #1 → #2(status) → #3(installer) → #4(update fetch) → gates each step → push dev-freeform → CI green BOTH runners (the boot-race lesson: CI is the real surface) → version bump 0.3.1 + CHANGELOG → FF main → tag v0.3.1 → user signs (`release-publish --version 5`). Deploy/redeploy kitsubito after publish (verify reboot self-heal on the real rig).

## Done when
- Reboot kitsubito → daemon comes up net-less briefly, self-heals within the retry window, pump goes live WITHOUT manual restart; `spt daemon` shows the honest net state during the window.
- `spt update fetch` stages the latest signed release from GitHub on a peerless node → `spt update apply` applies it.
- traceable-reqs (REQ-DAEMON-9, REQ-UPD-7) / clippy / --no-default-features / xtask check all green on both runners.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\V031-PLAN.md (file state is current in your context — no need to Read it back)

# v0.3.1 — boot-race net self-heal + status honesty + `spt update fetch`

JIT plan. Origin: kitsubito 2026-06-08 post-reboot diagnosis — the systemd user unit autostarts `spt-daemon` ~6s into boot, before the network/DNS stack is up → `NET_BIND_FAIL: Failed to create an address lookup service` → broker runs net-less → the peer pump is gated on `net_up` (`daemon.rs:201`) so it is NEVER spawned → no heartbeat this boot → `spt daemon` renders the stale pre-boot heartbeat as `STALLED` (age > uptime). Node stays unreachable until a manual `systemctl --user restart spt-daemon`. Restart with the network up binds net first try (verified live).

## Scope (REQ-DAEMON-9, REQ-UPD-7)

### 1. Net-bind self-heal — REQ-DAEMON-9 (impl, unit)
- `Broker.net`: `Option<NetHost>` → `OnceLock<NetHost>` (lock-free `.get()`); ~10 read sites `self.net.as_ref()`/`&self.net` → `self.net.get()`. `bind_in_with_net(Some(host))` sets it immediately (fast path). Add `attach_net(&self, host) -> bool`.
- `Daemon::run`: factor the dispatcher+pump spawn into a `spawn_net_consumers(broker, registry, stop, cfg)` helper. Fast path (NetHost::start Ok at boot) → bind, attach, spawn consumers as today. Slow path (NET_BIND_FAIL) → bind net-less, serve seed-control immediately (don't block liveness), spawn a BACKGROUND supervisor thread that retries `NetHost::start` with capped backoff (≤ ~60s) until it succeeds → `attach_net` → spawn consumers. Log `NET_BIND_RETRY` / `NET_ATTACHED`.
- Pure-testable seam: the backoff ladder (reuse the REQ-DAEMON-5 pump-supervisor backoff shape) + "consumers spawn exactly once on attach". OnceLock guarantees single attach.

### 2. Status honesty — REQ-DAEMON-9 (impl, unit)
- `cmd_daemon_status`: when the broker is net-less, render `connection: no connection (net endpoint not bound — retrying)` instead of only the pump-stall line; surface that net is down as the CAUSE.
- Heartbeat-age sanity: a heartbeat timestamp older than process/daemon start = "no heartbeat this boot", render as starting/np, NOT a giant bogus age. (Reader: compare last-tick vs a daemon-start marker, or treat age > some ceiling as "not yet ticked".) Pure unit on the render classifier.

### 3. Unit ordering — REQ-DAEMON-9 (impl) / installer
- `installer/install.sh` systemd unit: add `Wants=network-online.target` + `After=network-online.target` (+ `[Install] WantedBy` unchanged). Belt-and-suspenders with the self-heal. (User units + network-online is imperfect under linger — the daemon self-heal #1 is the real guarantee.)

### 4. `spt update fetch` — REQ-UPD-7 (impl, unit) — origin-source bootstrap
- New `spt update fetch [--channel <ch>] [--tag vX.Y.Z]`: pull the signed release from `github.com/SaberMage/spt-releases/releases/{latest,tag/<tag>}/download/` — the per-platform artifact + its `<asset>.release.json` (`SignedRelease` metadata) — and feed BOTH through the existing `plan_verified` gate (`update.rs:157`: verify_metadata two-key sig/channel/expiry/rollback + verify_artifact SHA-256), then `cache.stage()`. After staging, the existing consent-notif / `spt update apply` flow is unchanged.
- HTTP client: the daemon has zero HTTP today (iroh QUIC only; reqwest transitive). Decision: add a SMALL HTTPS client. Prefer `reqwest` as a direct dep on the `spt` CLI crate (blocking client, rustls) — the fetch runs in the CLI, not the daemon, so no daemon HTTP surface. Verify it builds `--no-default-features`-clean (feature-gate behind the `net` feature if needed).
- Honor channel pin (`release-keys.json`), monotonic rollback floor, configurable repo (mirror `SPT_INSTALL_REPO`). Asset names: `spt-x86_64-linux` / `spt-x86_64-windows.exe` + `<asset>.release.json`.
- Surface in reference.md (xtask gen → docs-drift will fire). Unit: the URL/asset derivation + the verify-then-stage wiring against a fixture release.json (reuse release.rs test vectors).

## Sequencing
Mint REQ-DAEMON-9 + REQ-UPD-7 → impl #1 → #2(status) → #3(installer) → #4(update fetch) → gates each step → push dev-freeform → CI green BOTH runners (the boot-race lesson: CI is the real surface) → version bump 0.3.1 + CHANGELOG → FF main → tag v0.3.1 → user signs (`release-publish --version 5`). Deploy/redeploy kitsubito after publish (verify reboot self-heal on the real rig).

## Done when
- Reboot kitsubito → daemon comes up net-less briefly, self-heals within the retry window, pump goes live WITHOUT manual restart; `spt daemon` shows the honest net state during the window.
- `spt update fetch` stages the latest signed release from GitHub on a peerless node → `spt update apply` applies it.
- traceable-reqs (REQ-DAEMON-9, REQ-UPD-7) / clippy / --no-default-features / xtask check all green on both runners.


Plan written. Mint REQ-DAEMON-9 + REQ-UPD-7. Read the two insert points:


757	id = "REQ-DAEMON-8"
758	title = "Internal auto-start prefers the service: `ensure_running` (any spt command's implicit daemon start, REQ-DAEMON-3) routes through the service-aware start path — when a manager has a registered service it starts THAT, never a competing manual `spawn_detached` daemon that would fight the service for the socket."
759	required_stages = ["impl", "unit"]  # impl = ensure_running consults service::platform_service(): detected→service.start() else spawn_detached(), then the existing bounded bind-wait. unit = the plan_start router already proves the detected→ServiceStart vs ManualSpawn branch (shared with REQ-DAEMON-6); ensure_running's no-op-when-already-running short-circuit preserved.
760	[[requirements]]
761	id = "REQ-HAZARD-EPOCH-RESET"
762	title = "Advertisement-epoch reset strands a node: peers' higher last-seen epoch drops the reset node's fresh advertisements as Stale until the counter outruns history. Common case (full reinstall/re-pair) is mitigated by REQ-SUBNET-7's ceremony eviction (peer-side epoch memory dies with the deleted row — acceptance-verified); the residual narrow slice (epoch file lost, identity kept) is documented, guard deferred to a field hit (4.11)"
763	required_stages = []  # rule 5 + M8 decision 24: documented hazard (KNOWN-HAZARDS 4.11 at D5), guard waits for a field hit of the narrow slice; the re-pair eviction path is tested under REQ-SUBNET-7
764

id = "REQ-DAEMON-8"
title = "Internal auto-start prefers the service: `ensure_running` (any spt command's implicit daemon start, REQ-DAEMON-3) routes through the service-aware start path — when a manager has a registered service it starts THAT, never a competing manual `spawn_detached` daemon that would fight the service for the socket."
required_stages = ["impl", "unit"]  # impl = ensure_running consults service::platform_service(): detected→service.start() else spawn_detached(), then the existing bounded bind-wait. unit = the plan_start router already proves the detected→ServiceStart vs ManualSpawn branch (shared with REQ-DAEMON-6); ensure_running's no-op-when-already-running short-circuit preserved.
[[requirements]]
id = "REQ-HAZARD-EPOCH-RESET"
title = "Advertisement-epoch reset strands a node: peers' higher last-seen epoch drops the reset node's fresh advertisements as Stale until the counter outruns history. Common case (full reinstall/re-pair) is mitigated by REQ-SUBNET-7's ceremony eviction (peer-side epoch memory dies with the deleted row — acceptance-verified); the residual narrow slice (epoch file lost, identity kept) is documented, guard deferred to a field hit (4.11)"
required_stages = []  # rule 5 + M8 decision 24: documented hazard (KNOWN-HAZARDS 4.11 at D5), guard waits for a field hit of the narrow slice; the re-pair eviction path is tested under REQ-SUBNET-7


359	id = "REQ-UPD-6"
360	title = "Platform-targeted update sets and debug rollout: signed multi-platform update metadata, recipient platform selection, channel-scoped monotonic counters, debug-channel opt-in via release-key overlay, local staging plus pull-based peer propagation, and maintainer-only convergence tooling (ADR-0016)"
361	required_stages = ["doc", "impl", "unit", "int"]  # activated 2026-06-06: first slice = signed update-set metadata + verification, platform artifact selection, cache/propagation/apply compatibility, maintainer xtask helpers. int activated M8-D4 (decision 19): xtask debug-converge watcher per docs/DEBUG-CONVERGE-PLAN.md — status-only query on the update wire + loopback convergence test
362
363	# ───────────────────────────── Terminal wrapper / frontend ──────────────────
364	[[requirements]]
365	id = "REQ-TERM-1"
366	title = "Process-supervisor terminal wrapper hosting broker PTYs"

id = "REQ-UPD-6"
title = "Platform-targeted update sets and debug rollout: signed multi-platform update metadata, recipient platform selection, channel-scoped monotonic counters, debug-channel opt-in via release-key overlay, local staging plus pull-based peer propagation, and maintainer-only convergence tooling (ADR-0016)"
required_stages = ["doc", "impl", "unit", "int"]  # activated 2026-06-06: first slice = signed update-set metadata + verification, platform artifact selection, cache/propagation/apply compatibility, maintainer xtask helpers. int activated M8-D4 (decision 19): xtask debug-converge watcher per docs/DEBUG-CONVERGE-PLAN.md — status-only query on the update wire + loopback convergence test

# ───────────────────────────── Terminal wrapper / frontend ──────────────────
[[requirements]]
id = "REQ-TERM-1"
title = "Process-supervisor terminal wrapper hosting broker PTYs"


Edit

required_stages = ["impl", "unit"]  # impl = ensure_running consults service::platform_service(): detected→service.start() else spawn_detached(), then the existing bounded bind-wait. unit = the plan_start router already proves the detected→ServiceStart vs ManualSpawn branch (shared with REQ-DAEMON-6); ensure_running's no-op-when-already-running short-circuit preserved.
[[requirements]]
id = "REQ-HAZARD-EPOCH-RESET"

required_stages = ["impl", "unit"]  # impl = ensure_running consults service::platform_service(): detected→service.start() else spawn_detached(), then the existing bounded bind-wait. unit = the plan_start router already proves the detected→ServiceStart vs ManualSpawn branch (shared with REQ-DAEMON-6); ensure_running's no-op-when-already-running short-circuit preserved.
[[requirements]]
id = "REQ-DAEMON-9"
title = "Net-bind boot-race resilience: a daemon that comes up net-less (NetHost::start failed — e.g. the systemd unit autostarted before the network/DNS stack was ready, `Failed to create an address lookup service`) must SELF-HEAL — retry the net bring-up in the background with capped backoff and, on success, attach net to the broker + spawn the dispatcher/peer-pump (which today are gated on `net_up` at boot and so never start, leaving the node silently unreachable until a manual restart — kitsubito 2026-06-08). Status surfaces the net-less state honestly (a net-less broker renders as 'no connection', not only a pump-STALLED line with a bogus pre-boot heartbeat age). The installer's autostart unit waits for the network (`Wants=/After=network-online.target`) as belt-and-suspenders."
required_stages = ["impl", "unit"]  # activate v0.3.1. impl = Broker.net → OnceLock<NetHost> + attach_net(); Daemon::run factors spawn_net_consumers(), fast-path attaches at boot, slow-path spawns a background NetHost::start retry supervisor (capped backoff) that attaches + spawns consumers on success (NET_BIND_RETRY/NET_ATTACHED logs); cmd_daemon_status renders net-less as 'no connection' + treats a heartbeat older than daemon start as 'no tick yet' not a giant age; install.sh unit gains network-online ordering. unit = backoff ladder caps + OnceLock single-attach (consumers spawn exactly once) + the status net-less/heartbeat-age render classifier.
[[requirements]]
id = "REQ-HAZARD-EPOCH-RESET"


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

required_stages = ["impl", "unit"]  # impl = ensure_running consults service::platform_service(): detected→service.start() else spawn_detached(), then the existing bounded bind-wait. unit = the plan_start router already proves the detected→ServiceStart vs ManualSpawn branch (shared with REQ-DAEMON-6); ensure_running's no-op-when-already-running short-circuit preserved.
[[requirements]]
id = "REQ-HAZARD-EPOCH-RESET"

required_stages = ["impl", "unit"]  # impl = ensure_running consults service::platform_service(): detected→service.start() else spawn_detached(), then the existing bounded bind-wait. unit = the plan_start router already proves the detected→ServiceStart vs ManualSpawn branch (shared with REQ-DAEMON-6); ensure_running's no-op-when-already-running short-circuit preserved.
[[requirements]]
id = "REQ-DAEMON-9"
title = "Net-bind boot-race resilience: a daemon that comes up net-less (NetHost::start failed — e.g. the systemd unit autostarted before the network/DNS stack was ready, `Failed to create an address lookup service`) must SELF-HEAL — retry the net bring-up in the background with capped backoff and, on success, attach net to the broker + spawn the dispatcher/peer-pump (which today are gated on `net_up` at boot and so never start, leaving the node silently unreachable until a manual restart — kitsubito 2026-06-08). Status surfaces the net-less state honestly (a net-less broker renders as 'no connection', not only a pump-STALLED line with a bogus pre-boot heartbeat age). The installer's autostart unit waits for the network (`Wants=/After=network-online.target`) as belt-and-suspenders."
required_stages = ["impl", "unit"]  # activate v0.3.1. impl = Broker.net → OnceLock<NetHost> + attach_net(); Daemon::run factors spawn_net_consumers(), fast-path attaches at boot, slow-path spawns a background NetHost::start retry supervisor (capped backoff) that attaches + spawns consumers on success (NET_BIND_RETRY/NET_ATTACHED logs); cmd_daemon_status renders net-less as 'no connection' + treats a heartbeat older than daemon start as 'no tick yet' not a giant age; install.sh unit gains network-online ordering. unit = backoff ladder caps + OnceLock single-attach (consumers spawn exactly once) + the status net-less/heartbeat-age render classifier.
[[requirements]]
id = "REQ-HAZARD-EPOCH-RESET"

title = "Internal auto-start prefers the service: `ensure_running` (any spt command's implicit daemon start, REQ-DAEMON-3) routes through the service-aware start path — when a manager has a registered service it starts THAT, never a competing manual `spawn_detached` daemon that would fight the service for the socket."

+title = "Net-bind boot-race resilience: a daemon that comes up net-less (NetHost::start failed — e.g. the systemd unit autostarted before the network/DNS stack was ready, `Failed to create an address lookup service`) must SELF-HEAL — retry the net bring-up in the background with capped backoff and, on success, attach net to the broker + spawn the dispatcher/peer-pump (which today are gated on `net_up` at boot and so never start, leaving the node silently unreachable until a manual restart — kitsubito 2026-06-08). Status surfaces the net-less state honestly (a net-less broker renders as 'no connection', not only a pump-STALLED line with a bogus pre-boot heartbeat age). The installer's autostart unit waits for the network (`Wants=/After=network-online.target`) as belt-and-suspenders."

+required_stages = ["impl", "unit"]  # activate v0.3.1. impl = Broker.net → OnceLock<NetHost> + attach_net(); Daemon::run factors spawn_net_consumers(), fast-path attaches at boot, slow-path spawns a background NetHost::start retry supervisor (capped backoff) that attaches + spawns consumers on success (NET_BIND_RETRY/NET_ATTACHED logs); cmd_daemon_status renders net-less as 'no connection' + treats a heartbeat older than daemon start as 'no tick yet' not a giant age; install.sh unit gains network-online ordering. unit = backoff ladder caps + OnceLock single-attach (consumers spawn exactly once) + the status net-less/heartbeat-age render classifier.

title = "Advertisement-epoch reset strands a node: peers' higher last-seen epoch drops the reset node's fresh advertisements as Stale until the counter outruns history. Common case (full reinstall/re-pair) is mitigated by REQ-SUBNET-7's ceremony eviction (peer-side epoch memory dies with the deleted row — acceptance-verified); the residual narrow slice (epoch file lost, identity kept) is documented, guard deferred to a field hit (4.11)"

required_stages = []  # rule 5 + M8 decision 24: documented hazard (KNOWN-HAZARDS 4.11 at D5), guard waits for a field hit of the narrow slice; the re-pair eviction path is tested under REQ-SUBNET-7


required_stages = ["doc", "impl", "unit", "int"]  # activated 2026-06-06: first slice = signed update-set metadata + verification, platform artifact selection, cache/propagation/apply compatibility, maintainer xtask helpers. int activated M8-D4 (decision 19): xtask debug-converge watcher per docs/DEBUG-CONVERGE-PLAN.md — status-only query on the update wire + loopback convergence test

# ───────────────────────────── Terminal wrapper / frontend ──────────────────

required_stages = ["doc", "impl", "unit", "int"]  # activated 2026-06-06: first slice = signed update-set metadata + verification, platform artifact selection, cache/propagation/apply compatibility, maintainer xtask helpers. int activated M8-D4 (decision 19): xtask debug-converge watcher per docs/DEBUG-CONVERGE-PLAN.md — status-only query on the update wire + loopback convergence test
[[requirements]]
id = "REQ-UPD-7"
title = "Origin-source update bootstrap (`spt update fetch`): pull the latest signed release directly from the GitHub release origin (`SaberMage/spt-releases`) — the per-platform artifact + its `<asset>.release.json` SignedRelease metadata — and stage it through the EXISTING verify→stage pipeline (the same `plan_verified` gate: two-key signature + channel + monotonic rollback floor + SHA-256), after which the normal consent-notif / `spt update apply` flow is unchanged. Closes the peer-only-discovery gap (REQ-UPD-1): a first-in-fleet / isolated node can update with no peer to pull from. The signed-release anchor keeps the GitHub transport untrusted-but-verified."
required_stages = ["impl", "unit"]  # activate v0.3.1. impl = `spt update fetch [--channel <ch>] [--tag vX.Y.Z]` (default latest on pinned channel); HTTPS GET of artifact + <asset>.release.json from releases/{latest,tag/<tag>}/download; feed both through plan_verified then cache.stage(); reuse cmd_update_apply machinery downstream. Add a direct HTTPS client (reqwest, blocking+rustls) on the `spt` CLI crate (fetch runs in the CLI, NOT the daemon — no new daemon HTTP surface); honor SPT_INSTALL_REPO override. unit = URL/asset-name derivation (platform × latest/tag) + verify-then-stage against a fixture release.json (reuse release.rs vectors); rollback floor + channel-pin rejection paths.

# ───────────────────────────── Terminal wrapper / frontend ──────────────────


required_stages = ["doc", "impl", "unit", "int"]  # activated 2026-06-06: first slice = signed update-set metadata + verification, platform artifact selection, cache/propagation/apply compatibility, maintainer xtask helpers. int activated M8-D4 (decision 19): xtask debug-converge watcher per docs/DEBUG-CONVERGE-PLAN.md — status-only query on the update wire + loopback convergence test

# ───────────────────────────── Terminal wrapper / frontend ──────────────────

required_stages = ["doc", "impl", "unit", "int"]  # activated 2026-06-06: first slice = signed update-set metadata + verification, platform artifact selection, cache/propagation/apply compatibility, maintainer xtask helpers. int activated M8-D4 (decision 19): xtask debug-converge watcher per docs/DEBUG-CONVERGE-PLAN.md — status-only query on the update wire + loopback convergence test
[[requirements]]
id = "REQ-UPD-7"
title = "Origin-source update bootstrap (`spt update fetch`): pull the latest signed release directly from the GitHub release origin (`SaberMage/spt-releases`) — the per-platform artifact + its `<asset>.release.json` SignedRelease metadata — and stage it through the EXISTING verify→stage pipeline (the same `plan_verified` gate: two-key signature + channel + monotonic rollback floor + SHA-256), after which the normal consent-notif / `spt update apply` flow is unchanged. Closes the peer-only-discovery gap (REQ-UPD-1): a first-in-fleet / isolated node can update with no peer to pull from. The signed-release anchor keeps the GitHub transport untrusted-but-verified."
required_stages = ["impl", "unit"]  # activate v0.3.1. impl = `spt update fetch [--channel <ch>] [--tag vX.Y.Z]` (default latest on pinned channel); HTTPS GET of artifact + <asset>.release.json from releases/{latest,tag/<tag>}/download; feed both through plan_verified then cache.stage(); reuse cmd_update_apply machinery downstream. Add a direct HTTPS client (reqwest, blocking+rustls) on the `spt` CLI crate (fetch runs in the CLI, NOT the daemon — no new daemon HTTP surface); honor SPT_INSTALL_REPO override. unit = URL/asset-name derivation (platform × latest/tag) + verify-then-stage against a fixture release.json (reuse release.rs vectors); rollback floor + channel-pin rejection paths.

# ───────────────────────────── Terminal wrapper / frontend ──────────────────

title = "Platform-targeted update sets and debug rollout: signed multi-platform update metadata, recipient platform selection, channel-scoped monotonic counters, debug-channel opt-in via release-key overlay, local staging plus pull-based peer propagation, and maintainer-only convergence tooling (ADR-0016)"

+title = "Origin-source update bootstrap (`spt update fetch`): pull the latest signed release directly from the GitHub release origin (`SaberMage/spt-releases`) — the per-platform artifact + its `<asset>.release.json` SignedRelease metadata — and stage it through the EXISTING verify→stage pipeline (the same `plan_verified` gate: two-key signature + channel + monotonic rollback floor + SHA-256), after which the normal consent-notif / `spt update apply` flow is unchanged. Closes the peer-only-discovery gap (REQ-UPD-1): a first-in-fleet / isolated node can update with no peer to pull from. The signed-release anchor keeps the GitHub transport untrusted-but-verified."

+required_stages = ["impl", "unit"]  # activate v0.3.1. impl = `spt update fetch [--channel <ch>] [--tag vX.Y.Z]` (default latest on pinned channel); HTTPS GET of artifact + <asset>.release.json from releases/{latest,tag/<tag>}/download; feed both through plan_verified then cache.stage(); reuse cmd_update_apply machinery downstream. Add a direct HTTPS client (reqwest, blocking+rustls) on the `spt` CLI crate (fetch runs in the CLI, NOT the daemon — no new daemon HTTP surface); honor SPT_INSTALL_REPO override. unit = URL/asset-name derivation (platform × latest/tag) + verify-then-stage against a fixture release.json (reuse release.rs vectors); rollback floor + channel-pin rejection paths.


REQs minted (REQ-DAEMON-9, REQ-UPD-7). Now implement #1. Read the broker struct field + the net read sites:


160	/// recovery anchor a brain crash must not lose, so it lives in the broker, not
161	/// the brain (Spike #6 constraint 1).
162	pub struct Broker {
163	    listener: LocalSocketTransport,
164	    sessions: Arc<Mutex<HashMap<u64, HostedSession>>>,
165	    next_id: AtomicU64,
166	    log_cap_chunks: usize,
167	    /// The exactly-once anchor for idempotent (`op_id`-carrying) effects.
168	    journal: Arc<EffectJournal>,
169	    /// The broker-owned net endpoint + live QUIC connections (D4a, ADR-0004 §B).
170	    /// `None` when bound without a network host (net-disabled / most tests).
171	    net: Option<NetHost>,
172	}
173
174	impl Broker {
175	    /// Bind the broker's listening endpoint at the namespaced `name`, with the

/// recovery anchor a brain crash must not lose, so it lives in the broker, not
/// the brain (Spike #6 constraint 1).
pub struct Broker {
    listener: LocalSocketTransport,
    sessions: Arc<Mutex<HashMap<u64, HostedSession>>>,
    next_id: AtomicU64,
    log_cap_chunks: usize,
    /// The exactly-once anchor for idempotent (`op_id`-carrying) effects.
    journal: Arc<EffectJournal>,
    /// The broker-owned net endpoint + live QUIC connections (D4a, ADR-0004 §B).
    /// `None` when bound without a network host (net-disabled / most tests).
    net: Option<NetHost>,
}

impl Broker {
    /// Bind the broker's listening endpoint at the namespaced `name`, with the


Switch the field to `OnceLock` + add `attach_net`:


    /// The broker-owned net endpoint + live QUIC connections (D4a, ADR-0004 §B).
    /// `None` when bound without a network host (net-disabled / most tests).
    net: Option<NetHost>,
}

    /// The broker-owned net endpoint + live QUIC connections (D4a, ADR-0004 §B).
    /// Empty when bound without a network host (net-disabled / most tests) OR
    /// when net is still being brought up (REQ-DAEMON-9 boot-race self-heal):
    /// a `OnceLock` so the background net-retry can ATTACH the host after bind
    /// without a relock on the hot effect-dispatch read path (`.get()` is
    /// lock-free). Settable exactly once — the first host wins.
    net: std::sync::OnceLock<NetHost>,
}


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

    /// The broker-owned net endpoint + live QUIC connections (D4a, ADR-0004 §B).
    /// `None` when bound without a network host (net-disabled / most tests).
    net: Option<NetHost>,
}

    /// The broker-owned net endpoint + live QUIC connections (D4a, ADR-0004 §B).
    /// Empty when bound without a network host (net-disabled / most tests) OR
    /// when net is still being brought up (REQ-DAEMON-9 boot-race self-heal):
    /// a `OnceLock` so the background net-retry can ATTACH the host after bind
    /// without a relock on the hot effect-dispatch read path (`.get()` is
    /// lock-free). Settable exactly once — the first host wins.
    net: std::sync::OnceLock<NetHost>,
}

/// The exactly-once anchor for idempotent (`op_id`-carrying) effects.

-    /// `None` when bound without a network host (net-disabled / most tests).

+    /// Empty when bound without a network host (net-disabled / most tests) OR

+    /// when net is still being brought up (REQ-DAEMON-9 boot-race self-heal):

+    /// a `OnceLock` so the background net-retry can ATTACH the host after bind

+    /// without a relock on the hot effect-dispatch read path (`.get()` is

+    /// lock-free). Settable exactly once — the first host wins.


        Ok(Arc::new(Broker {
            listener: LocalSocketTransport::bind(name)?,
            sessions: Arc::new(Mutex::new(HashMap::new())),
            next_id: AtomicU64::new(1),
            log_cap_chunks: DEFAULT_LOG_CHUNKS,
            journal: Arc::new(EffectJournal::open(journal_path)?),
            net,
        }))
    }

        let net_cell = std::sync::OnceLock::new();
        if let Some(host) = net {
            let _ = net_cell.set(host);
        }
        Ok(Arc::new(Broker {
            listener: LocalSocketTransport::bind(name)?,
            sessions: Arc::new(Mutex::new(HashMap::new())),
            next_id: AtomicU64::new(1),
            log_cap_chunks: DEFAULT_LOG_CHUNKS,
            journal: Arc::new(EffectJournal::open(journal_path)?),
            net: net_cell,
        }))
    }

    /// Attach a network host to a broker that was bound net-less (REQ-DAEMON-9):
    /// the boot-race self-heal path calls this once the background retry brings
    /// the net endpoint up, after which the dispatcher + peer pump are spawned.
    /// Idempotent by `OnceLock` — a second attach is a no-op returning `false`
    /// (the first host wins); the hot read path (`net()`) never blocks on it.
    // [impl->REQ-DAEMON-9]
    pub fn attach_net(&self, host: NetHost) -> bool {
        self.net.set(host).is_ok()
    }


        Ok(Arc::new(Broker {
            listener: LocalSocketTransport::bind(name)?,
            sessions: Arc::new(Mutex::new(HashMap::new())),
            next_id: AtomicU64::new(1),
            log_cap_chunks: DEFAULT_LOG_CHUNKS,
            journal: Arc::new(EffectJournal::open(journal_path)?),
            net,
        }))
    }

        let net_cell = std::sync::OnceLock::new();
        if let Some(host) = net {
            let _ = net_cell.set(host);
        }
        Ok(Arc::new(Broker {
            listener: LocalSocketTransport::bind(name)?,
            sessions: Arc::new(Mutex::new(HashMap::new())),
            next_id: AtomicU64::new(1),
            log_cap_chunks: DEFAULT_LOG_CHUNKS,
            journal: Arc::new(EffectJournal::open(journal_path)?),
            net: net_cell,
        }))
    }

    /// Attach a network host to a broker that was bound net-less (REQ-DAEMON-9):
    /// the boot-race self-heal path calls this once the background retry brings
    /// the net endpoint up, after which the dispatcher + peer pump are spawned.
    /// Idempotent by `OnceLock` — a second attach is a no-op returning `false`
    /// (the first host wins); the hot read path (`net()`) never blocks on it.
    // [impl->REQ-DAEMON-9]
    pub fn attach_net(&self, host: NetHost) -> bool {
        self.net.set(host).is_ok()
    }

+        let net_cell = std::sync::OnceLock::new();

+            let _ = net_cell.set(host);

+    /// Attach a network host to a broker that was bound net-less (REQ-DAEMON-9):

+    /// the boot-race self-heal path calls this once the background retry brings

+    /// the net endpoint up, after which the dispatcher + peer pump are spawned.

+    /// Idempotent by `OnceLock` — a second attach is a no-op returning `false`

+    /// (the first host wins); the hot read path (`net()`) never blocks on it.

+    pub fn attach_net(&self, host: NetHost) -> bool {

/// The broker-owned exactly-once anchor (for introspection / tests).

pub fn journal(&self) -> &Arc<EffectJournal> {


    /// The broker-owned network host, if this broker was bound with one.
    pub fn net(&self) -> Option<&NetHost> {
        self.net.as_ref()
    }

    /// The broker-owned network host, if one is attached (at bind or via the
    /// REQ-DAEMON-9 self-heal `attach_net`). Lock-free.
    pub fn net(&self) -> Option<&NetHost> {
        self.net.get()
    }


    /// The broker-owned network host, if this broker was bound with one.
    pub fn net(&self) -> Option<&NetHost> {
        self.net.as_ref()
    }

    /// The broker-owned network host, if one is attached (at bind or via the
    /// REQ-DAEMON-9 self-heal `attach_net`). Lock-free.
    pub fn net(&self) -> Option<&NetHost> {
        self.net.get()
    }

-    /// The broker-owned network host, if this broker was bound with one.

+    /// The broker-owned network host, if one is attached (at bind or via the

+    /// REQ-DAEMON-9 self-heal `attach_net`). Lock-free.

/// Accept connections forever, handling each on its own thread. The broker


Now the read sites. Let me see each exact form:


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && grep -nE "self\.net\.as_ref\(\)|if let Some\(host\) = &self\.net|match &self\.net|&self\.net\b|self\.net$" crates/spt-daemon/src/broker.rs

exact net read forms


390:        if let Some(host) = &self.net {
567:        let reply = match &self.net {
713:        let host = self.net.as_ref().ok_or("net disabled")?;
763:        let host = self.net.as_ref().ok_or("net disabled")?;
793:        let host = self.net.as_ref().ok_or("net disabled")?;
815:        let host = self.net.as_ref().ok_or("net disabled")?;
832:        let host = self.net.as_ref().ok_or("net disabled")?;


Edit

let host = self.net.as_ref().ok_or("net disabled")?;

let host = self.net.get().ok_or("net disabled")?;


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

fn dispatch_net_stream_open(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {

let req: NetStreamOpenReq = serde_json::from_value(env.payload)

.map_err(|e| format!("bad net-stream-open payload: {e}"))?;

-        let host = self.net.as_ref().ok_or("net disabled")?;

+        let host = self.net.get().ok_or("net disabled")?;

let build = |stream_id: u64, conn_id: u64, op_id: Option<u64>, applied_now: bool| {

.map_err(|e| format!("bad net-stream-send payload: {e}"))?;

decode_bytes(&req.data_b64).map_err(|e| format!("bad net-stream-send base64: {e}"))?;

/// List every stream the broker holds (D4b) — the query a re-attaching

/// brain uses to find streams that arrived while it was down.

fn dispatch_net_streams(&self, send: &SharedSend) -> Result<(), String> {

let req: NetStreamSubscribeReq = serde_json::from_value(env.payload)

.map_err(|e| format!("bad net-stream-subscribe payload: {e}"))?;

let req: NetPresenceSubscribeReq = serde_json::from_value(env.payload)

.map_err(|e| format!("bad net-presence-subscribe payload: {e}"))?;


// Same for net streams: the streams + their rings persist (D4b).

-        if let Some(host) = &self.net {

+        if let Some(host) = self.net.get() {


/// capability without treating absence as an error.

fn dispatch_net_status(&self, send: &SharedSend) {

+        let reply = match self.net.get() {


Now the daemon.rs restructure — the trickier part. Read the full current net + spawn block precisely:


146	        // [impl->REQ-DAEMON-2]
147	        let mut node_hex: Option<String> = None;
148	        let net = {
149	            let cfg = DaemonConfig::load();
150	            let relay = match cfg.relay {
151	                RelayChoice::N0 => RelayPolicy::N0Default,
152	                RelayChoice::Disabled => RelayPolicy::Disabled,
153	            };
154	            match spt_store::nodeid::load_or_create() {
155	                Ok(identity) => {
156	                    node_hex = Some(identity.public_key().to_hex());
157	                    let net_cfg = NetConfig {
158	                        identity,
159	                        relay,
160	                        discovery: LocalDiscovery::Mdns,
161	                        scope: BindScope::All, // production: real interfaces
162	                        // Mesh-D2: enforce seed-proof on every dial + accept,
163	                        // reading the live subnet store each connect.
164	                        membership: Some(crate::seedproofx::production_membership()),
165	                    };
166	                    match NetHost::start(net_cfg) {
167	                        Ok(host) => {
168	                            // Mesh-D4 (REQ-MESH-2): wire on-connect roster
169	                            // propagation onto the live host (off NetConfig by
170	                            // design — see NetHost::roster).
171	                            host.set_roster_exchange(
172	                                crate::seedproofx::production_roster_exchange(),
173	                            );
174	                            Some(host)
175	                        }
176	                        Err(e) => {
177	                            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
178	                            None
179	                        }
180	                    }
181	                }
182	                Err(e) => {
183	                    eprintln!("NODE_KEY_FAIL: {e} — broker runs net-less");
184	                    None
185	                }
186	            }
187	        };
188
189	        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
190	        // background thread; a bind failure there must not stop the seed channel
191	        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
192	        let net_up = net.is_some();
193	        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
194	            Ok(broker) => {
195	                thread::spawn(move || {
196	                    let _ = broker.serve();
197	                });
198	                // Inbound net dispatch + outbound peer pump (D9-1): the
199	                // production trigger loops. Only meaningful with a net host —
200	                // a net-less broker accepts nothing and dials nobody.
201	                if net_up {
202	                    let hex = node_hex.clone().unwrap_or_default();
203	                    let registry = Arc::new(crate::registryhost::RegistryHost::new(&hex));
204	                    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
205	                    let _ = crate::dispatch::spawn_dispatcher(
206	                        broker_socket_name(),
207	                        Arc::clone(&registry),
208	                        Arc::clone(&stop),
209	                    );
210	                    let cfg = DaemonConfig::load();
211	                    if cfg.peer_pump {
212	                        let _ = crate::peerloop::spawn_peer_pump(
213	                            broker_socket_name(),
214	                            registry,
215	                            cfg,
216	                            stop,
217	                        );
218	                    }
219	                }
220	            }
221	            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),

        // [impl->REQ-DAEMON-2]
        let mut node_hex: Option<String> = None;
        let net = {
            let cfg = DaemonConfig::load();
            let relay = match cfg.relay {
                RelayChoice::N0 => RelayPolicy::N0Default,
                RelayChoice::Disabled => RelayPolicy::Disabled,
            };
            match spt_store::nodeid::load_or_create() {
                Ok(identity) => {
                    node_hex = Some(identity.public_key().to_hex());
                    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) => {
                            // Mesh-D4 (REQ-MESH-2): wire on-connect roster
                            // propagation onto the live host (off NetConfig by
                            // design — see NetHost::roster).
                            host.set_roster_exchange(
                                crate::seedproofx::production_roster_exchange(),
                            );
                            Some(host)
                        }
                        Err(e) => {
                            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
                            None
                        }
                    }
                }
                Err(e) => {
                    eprintln!("NODE_KEY_FAIL: {e} — broker runs net-less");
                    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();
        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
            Ok(broker) => {
                thread::spawn(move || {
                    let _ = broker.serve();
                });
                // Inbound net dispatch + outbound peer pump (D9-1): the
                // production trigger loops. Only meaningful with a net host —
                // a net-less broker accepts nothing and dials nobody.
                if net_up {
                    let hex = node_hex.clone().unwrap_or_default();
                    let registry = Arc::new(crate::registryhost::RegistryHost::new(&hex));
                    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
                    let _ = crate::dispatch::spawn_dispatcher(
                        broker_socket_name(),
                        Arc::clone(&registry),
                        Arc::clone(&stop),
                    );
                    let cfg = DaemonConfig::load();
                    if cfg.peer_pump {
                        let _ = crate::peerloop::spawn_peer_pump(
                            broker_socket_name(),
                            registry,
                            cfg,
                            stop,
                        );
                    }
                }
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),


Restructure `Daemon::run` for the self-heal path + extract the helpers:


        let mut node_hex: Option<String> = None;
        let net = {
            let cfg = DaemonConfig::load();
            let relay = match cfg.relay {
                RelayChoice::N0 => RelayPolicy::N0Default,
                RelayChoice::Disabled => RelayPolicy::Disabled,
            };
            match spt_store::nodeid::load_or_create() {
                Ok(identity) => {
                    node_hex = Some(identity.public_key().to_hex());
                    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) => {
                            // Mesh-D4 (REQ-MESH-2): wire on-connect roster
                            // propagation onto the live host (off NetConfig by
                            // design — see NetHost::roster).
                            host.set_roster_exchange(
                                crate::seedproofx::production_roster_exchange(),
                            );
                            Some(host)
                        }
                        Err(e) => {
                            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
                            None
                        }
                    }
                }
                Err(e) => {
                    eprintln!("NODE_KEY_FAIL: {e} — broker runs net-less");
                    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();
        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
            Ok(broker) => {
                thread::spawn(move || {
                    let _ = broker.serve();
                });
                // Inbound net dispatch + outbound peer pump (D9-1): the
                // production trigger loops. Only meaningful with a net host —
                // a net-less broker accepts nothing and dials nobody.
                if net_up {
                    let hex = node_hex.clone().unwrap_or_default();
                    let registry = Arc::new(crate::registryhost::RegistryHost::new(&hex));
                    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
                    let _ = crate::dispatch::spawn_dispatcher(
                        broker_socket_name(),
                        Arc::clone(&registry),
                        Arc::clone(&stop),
                    );
                    let cfg = DaemonConfig::load();
                    if cfg.peer_pump {
                        let _ = crate::peerloop::spawn_peer_pump(
                            broker_socket_name(),
                            registry,
                            cfg,
                            stop,
                        );
                    }
                }
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),

        // 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();
        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): only
                // meaningful with a net host — a net-less broker accepts nothing
                // and dials nobody.
                let hex = node_hex.clone().unwrap_or_default();
                if net_up {
                    spawn_net_consumers(&hex);
                } else if 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; attach it
                    // to the broker + spawn the consumers when it succeeds, so
                    // the node becomes reachable WITHOUT a manual restart.
                    // [impl->REQ-DAEMON-9]
                    let broker = Arc::clone(&broker);
                    thread::spawn(move || net_retry_attach(broker, hex));
                }
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),