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

- **Migration ledger (loop → verb → moves-at-commit).** Each commit moves
  exactly ONE consumer: define its versioned verb, reroute the direct call
  over the socket, move the loop into the brain child, prove the N-1 window.
  Keep the ledger current in-plan so "which loop runs where" is answerable at
  every mid-migration commit. **D2-close invariant:** every logic loop runs in
  the brain; the broker holds only continuity resources + the verb server.

  **Live ledger** (full task detail: `RESTORATION-D2-PLAN.md`):

  | # | Loop / consumer | Verb needed | Runs-where now | Status |
  |---|---|---|---|---|
  | D2-1 | Net consumers — inbound dispatcher (`dispatch.rs`) + outbound peer pump (`peerloop.rs`) | **none** — already pure IPC clients (`Brain::cold_start` over `broker_socket_name()`); gated on the existing `net-status` verb | **brain** ✅ | DONE |
  | D2-2 | Digest **parse** feed (`brain.run_digest_feed`) — the per-session projection that populates the hub | existing `subscribe`/output stream; the parse is already brain-side logic, only its *spawn site* (broker boot) moves | broker | TODO |
  | D2-3 | Shellwake reconcile (`shellwake::spawn_wake_host`) | **stays brain-owned** by exception (Q5): watcher children re-reconciled from disk on brain start | broker | TODO (move spawn) |
  | D2-4 | Psyche / pulse loops (`lifecycle.rs`) | per-loop verbs, additive (KH-2.3) | broker | TODO |
  | — | Net **bring-up** + `net_retry_attach` self-heal (REQ-DAEMON-9) | n/a | **broker** (stays — Q5, NetHost is broker-owned) | KEEP |
  | — | Digest **hub serve** (`digesthub::serve_digest_control`) | n/a | **broker** (stays — Q5, live projections + subscriber sockets) | KEEP |
  | — | Seed-control lock + liveness (`seedmap::serve_seed_control`) | n/a | **broker** (stays — Q2 anchor; D3) | KEEP |

  **D2-1 N-1 note (no-verb migration).** The consumers spoke pure IPC already;
  this commit only relocates their *spawn site* (broker process → brain child).
  No verb signature changed, so the verb-surface N-1 window is untouched. The
  one transient hazard — an old broker (pre-D2-1, spawns consumers in-process)
  paired with a new brain (also spawns them) → **double-spawn** — cannot occur:
  broker and brain are the same binary and swap together until D3 introduces
  brain-only swaps, by which point both halves are post-migration. Recorded so
  D3's first brain-only swap lands on an already-relocated consumer set.

+  **Live ledger** (full task detail: `RESTORATION-D2-PLAN.md`):

+  | # | Loop / consumer | Verb needed | Runs-where now | Status |

+  | D2-1 | Net consumers — inbound dispatcher (`dispatch.rs`) + outbound peer pump (`peerloop.rs`) | **none** — already pure IPC clients (`Brain::cold_start` over `broker_socket_name()`); gated on the existing `net-status` verb | **brain** ✅ | DONE |

+  | D2-2 | Digest **parse** feed (`brain.run_digest_feed`) — the per-session projection that populates the hub | existing `subscribe`/output stream; the parse is already brain-side logic, only its *spawn site* (broker boot) moves | broker | TODO |

+  | D2-3 | Shellwake reconcile (`shellwake::spawn_wake_host`) | **stays brain-owned** by exception (Q5): watcher children re-reconciled from disk on brain start | broker | TODO (move spawn) |

+  | D2-4 | Psyche / pulse loops (`lifecycle.rs`) | per-loop verbs, additive (KH-2.3) | broker | TODO |

+  | — | Net **bring-up** + `net_retry_attach` self-heal (REQ-DAEMON-9) | n/a | **broker** (stays — Q5, NetHost is broker-owned) | KEEP |

+  | — | Digest **hub serve** (`digesthub::serve_digest_control`) | n/a | **broker** (stays — Q5, live projections + subscriber sockets) | KEEP |

+  | — | Seed-control lock + liveness (`seedmap::serve_seed_control`) | n/a | **broker** (stays — Q2 anchor; D3) | KEEP |

+  **D2-1 N-1 note (no-verb migration).** The consumers spoke pure IPC already;

+  this commit only relocates their *spawn site* (broker process → brain child).

+  No verb signature changed, so the verb-surface N-1 window is untouched. The

+  one transient hazard — an old broker (pre-D2-1, spawns consumers in-process)

+  paired with a new brain (also spawns them) → **double-spawn** — cannot occur:

+  broker and brain are the same binary and swap together until D3 introduces

+  brain-only swaps, by which point both halves are post-migration. Recorded so

+  D3's first brain-only swap lands on an already-relocated consumer set.

- **Audit** every current in-process `Arc<Broker>` consumer; convert each

direct call to a versioned verb. Per §Q5:

- **Net bring-up + REQ-DAEMON-9 self-heal retry → broker** — *near-free*:


Write

# Restoration D2 — IPC verb surface + loop migration (task plan)

> Working doc for RESTORATION-PLAN.md **D2** (ADR-0018 Q5/V6). The canonical
> loop→verb→moves-at-commit ledger lives in RESTORATION-PLAN.md D2; this doc
> holds the per-commit task detail. D1 (process-split skeleton) is DONE +
> cross-OS CI-green on main (0c95435).

## Goal (D2-close invariant)

Every logic loop runs in the **brain** child; the broker holds only continuity
resources (PTY masters / harness children / `EffectJournal` / NetHost / digest
hub / seed-control) + the verb server. D1 left every loop in the broker process;
D2 relocates them ONE PER COMMIT.

## Per-commit discipline

1. Define the loop's versioned verb (additive/defaulted — KH-2.3) **if** it made
   a direct `Arc<Broker>` call. A consumer that already spoke pure IPC needs no
   new verb — only its spawn site moves.
2. Reroute the direct call over the socket.
3. Move the loop into the brain child.
4. Prove the N-1 window (old-broker × new-brain) holds for the verb.

Gates every commit: `cargo build` · `cargo test` · `cargo clippy` ·
`cargo build --no-default-features` · `traceable-reqs check` (EXIT=0) ·
`xtask check`. Push to a dev-freeform branch → CI both runners before any tag.

---

## D2-1 — Net consumers → brain  ✅ DONE

**Cheapest first** (D1 discovery): the inbound dispatcher (`dispatch.rs`) and
the outbound peer pump (`peerloop.rs`) are ALREADY standalone IPC clients — each
`Brain::cold_start`s its own connection over `broker_socket_name()`, holds no
shared `Arc<Broker>`. They were merely *spawned* in the broker process for
convenience (`daemon.rs::spawn_net_consumers`). The migration is moving that
spawn site into the brain. **No new verb.**

Net-gating: consumers are meaningful only with a NetHost up. The brain polls the
existing `net-status` verb (answered `enabled:false` on a net-less broker, and
it carries `node_id_hex`) and starts the consumers exactly once, when net first
reports enabled — which also covers the boot-race self-heal (REQ-DAEMON-9): the
broker still owns the NetHost bind retry + `attach_net`, and the brain notices
`enabled` flip and starts consumers then. The broker's `net_retry_attach` keeps
the bind retry, drops the consumer spawn.

Why this is strictly better than today: consumers used to live in the broker, so
a (future) brain restart would not touch them and a broker restart killed them;
now they live with the brain, restart with it, and a brain respawn re-spawns
them from the swapped binary — the intended topology.

Changes:
- `brainproc.rs`: brain heartbeat polls `net-status`; on the first `enabled`
  reply, spawn the net consumers (moved `spawn_net_consumers` here, brain-side)
  with `node_id_hex` from the reply. `net-status` doubles as the broker-liveness
  probe (replaces the `sessions()` probe). `consumer_gate` pure helper enforces
  spawn-exactly-once.
- `daemon.rs`: drop both `spawn_net_consumers` call sites (boot `net_up` branch
  + inside `net_retry_attach`); `net_retry_attach` keeps the NetHost bind retry
  + `attach_net`, no longer spawns consumers (param `node_hex` retired).
- unit: `consumer_gate` once-only + net-gating table.

Evidence: `[impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]` (brain hosts the
consumers; broker no longer does) · `[unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]`
(the gate table). REQ already `[doc,impl,unit]` active — no toml change.

---

## D2-2..D2-4 — remaining loops (TODO)

- **D2-2 digest parse feed** → brain. The parse (`Brain::run_digest_feed`) is
  already brain-side logic; the hub serve stays broker-side (Q5). Move the
  spawn that wires a hosted session's feed into the brain. The hub itself is a
  broker continuity resource — the brain pushes parsed digests over IPC.
- **D2-3 shellwake** → brain by exception (Q5): watcher children stay
  brain-owned, re-reconciled from disk on brain start (the boot sweep already
  does orphan cleanup). Move `spawn_wake_host` into the brain.
- **D2-4 psyche / pulse loops** (`lifecycle.rs`) → brain, each new direct call a
  versioned additive verb. The pulse loop's phase-significant timing is D5's
  durable-deadline conversion — D2 only moves the loop; D5 makes its clock
  swap-durable.

## V6 — N-1 compat test scaffold (build in D2, gate in D7)

A CI-real old-broker-binary × new-brain-binary exercise across the whole verb
surface. Scaffold the harness here (it has no teeth until a verb signature
changes); D7 makes it a green gate. D2-1 added no verb, so the harness has
nothing to assert yet — stand it up against the existing verb set so each later
D2 verb extends it.


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

# Restoration D2 — IPC verb surface + loop migration (task plan)

> Working doc for RESTORATION-PLAN.md **D2** (ADR-0018 Q5/V6). The canonical
> loop→verb→moves-at-commit ledger lives in RESTORATION-PLAN.md D2; this doc
> holds the per-commit task detail. D1 (process-split skeleton) is DONE +
> cross-OS CI-green on main (0c95435).

## Goal (D2-close invariant)

Every logic loop runs in the **brain** child; the broker holds only continuity
resources (PTY masters / harness children / `EffectJournal` / NetHost / digest
hub / seed-control) + the verb server. D1 left every loop in the broker process;
D2 relocates them ONE PER COMMIT.

## Per-commit discipline

1. Define the loop's versioned verb (additive/defaulted — KH-2.3) **if** it made
   a direct `Arc<Broker>` call. A consumer that already spoke pure IPC needs no
   new verb — only its spawn site moves.
2. Reroute the direct call over the socket.
3. Move the loop into the brain child.
4. Prove the N-1 window (old-broker × new-brain) holds for the verb.

Gates every commit: `cargo build` · `cargo test` · `cargo clippy` ·
`cargo build --no-default-features` · `traceable-reqs check` (EXIT=0) ·
`xtask check`. Push to a dev-freeform branch → CI both runners before any tag.

---

## D2-1 — Net consumers → brain  ✅ DONE

**Cheapest first** (D1 discovery): the inbound dispatcher (`dispatch.rs`) and
the outbound peer pump (`peerloop.rs`) are ALREADY standalone IPC clients — each
`Brain::cold_start`s its own connection over `broker_socket_name()`, holds no
shared `Arc<Broker>`. They were merely *spawned* in the broker process for
convenience (`daemon.rs::spawn_net_consumers`). The migration is moving that
spawn site into the brain. **No new verb.**

Net-gating: consumers are meaningful only with a NetHost up. The brain polls the
existing `net-status` verb (answered `enabled:false` on a net-less broker, and
it carries `node_id_hex`) and starts the consumers exactly once, when net first
reports enabled — which also covers the boot-race self-heal (REQ-DAEMON-9): the
broker still owns the NetHost bind retry + `attach_net`, and the brain notices
`enabled` flip and starts consumers then. The broker's `net_retry_attach` keeps
the bind retry, drops the consumer spawn.

Why this is strictly better than today: consumers used to live in the broker, so
a (future) brain restart would not touch them and a broker restart killed them;
now they live with the brain, restart with it, and a brain respawn re-spawns
them from the swapped binary — the intended topology.

Changes:
- `brainproc.rs`: brain heartbeat polls `net-status`; on the first `enabled`
  reply, spawn the net consumers (moved `spawn_net_consumers` here, brain-side)
  with `node_id_hex` from the reply. `net-status` doubles as the broker-liveness
  probe (replaces the `sessions()` probe). `consumer_gate` pure helper enforces
  spawn-exactly-once.
- `daemon.rs`: drop both `spawn_net_consumers` call sites (boot `net_up` branch
  + inside `net_retry_attach`); `net_retry_attach` keeps the NetHost bind retry
  + `attach_net`, no longer spawns consumers (param `node_hex` retired).
- unit: `consumer_gate` once-only + net-gating table.

Evidence: `[impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]` (brain hosts the
consumers; broker no longer does) · `[unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]`
(the gate table). REQ already `[doc,impl,unit]` active — no toml change.

---

## D2-2..D2-4 — remaining loops (TODO)

- **D2-2 digest parse feed** → brain. The parse (`Brain::run_digest_feed`) is
  already brain-side logic; the hub serve stays broker-side (Q5). Move the
  spawn that wires a hosted session's feed into the brain. The hub itself is a
  broker continuity resource — the brain pushes parsed digests over IPC.
- **D2-3 shellwake** → brain by exception (Q5): watcher children stay
  brain-owned, re-reconciled from disk on brain start (the boot sweep already
  does orphan cleanup). Move `spawn_wake_host` into the brain.
- **D2-4 psyche / pulse loops** (`lifecycle.rs`) → brain, each new direct call a
  versioned additive verb. The pulse loop's phase-significant timing is D5's
  durable-deadline conversion — D2 only moves the loop; D5 makes its clock
  swap-durable.

## V6 — N-1 compat test scaffold (build in D2, gate in D7)

A CI-real old-broker-binary × new-brain-binary exercise across the whole verb
surface. Scaffold the harness here (it has no teeth until a verb signature
changes); D7 makes it a green gate. D2-1 added no verb, so the harness has
nothing to assert yet — stand it up against the existing verb set so each later
D2 verb extends it.


Updated task #1 status


Updated task #2 status


Implementation. First brainproc.rs — host the net consumers in the brain, gated on `net-status`.


Edit

/// The `spt daemon brain` child entry. Connect to the broker over the existing
/// socket IPC, signal readiness, then idle — holding the connection and probing
/// broker liveness each heartbeat. A dropped connection (broker gone, or a
/// future planned snapshot+self-exit, ADR-0018 Q3) returns `Err`, exiting the
/// process so the broker-side supervisor respawns it from the (possibly
/// swapped) binary.
///
/// D1 deliberately does NOTHING else — no logic loops run here yet. It inherits
/// the broker's already-unelevated token as a plain child, so it runs no
/// de-elevation guard of its own.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
pub fn run_brain() -> io::Result<()> {
    let name = broker_socket_name();
    let mut brain = connect_retry(&name, BRAIN_CONNECT_TRIES)?;
    eprintln!("BRAIN_UP: connected to broker {name} (pid {})", std::process::id());
    write_ready();
    // Idle loop: refresh the breadcrumb and probe the broker with a cheap
    // `sessions` query. The query erroring is how we learn the broker is gone.
    loop {
        thread::sleep(BRAIN_HEARTBEAT);
        if let Err(e) = brain.sessions() {
            eprintln!("BRAIN_BROKER_LOST: {e} — exiting for supervised respawn");
            return Err(e);
        }
        write_ready();
    }
}

/// The `spt daemon brain` child entry. Connect to the broker over the existing
/// socket IPC, signal readiness, host the brain-side logic loops, and idle —
/// holding the connection and probing broker liveness each heartbeat. A dropped
/// connection (broker gone, or a future planned snapshot+self-exit, ADR-0018 Q3)
/// returns `Err`, exiting the process so the broker-side supervisor respawns it
/// from the (possibly swapped) binary.
///
/// **D2-1 — net consumers run here.** The inbound dispatcher + outbound peer
/// pump are pure IPC clients (each `Brain::cold_start`s its own connection; no
/// shared `Arc<Broker>`), so they belong with the restartable brain, not the
/// broker — a brain respawn re-spawns them from the swapped binary. They are
/// meaningful only once the broker-owned NetHost is up, so we gate on the
/// existing `net-status` verb (which also serves as the broker-liveness probe)
/// and start them exactly once, on the first `enabled` reply. That single gate
/// also covers the boot-race self-heal (REQ-DAEMON-9): the broker retries the
/// NetHost bind in the background and `net-status` flips `enabled` when it
/// attaches, at which point the brain starts the consumers — no extra signal.
///
/// It inherits the broker's already-unelevated token as a plain child, so it
/// runs no de-elevation guard of its own.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
pub fn run_brain() -> io::Result<()> {
    let name = broker_socket_name();
    let mut brain = connect_retry(&name, BRAIN_CONNECT_TRIES)?;
    eprintln!("BRAIN_UP: connected to broker {name} (pid {})", std::process::id());
    write_ready();
    let mut consumers_started = false;
    loop {
        thread::sleep(BRAIN_HEARTBEAT);
        // `net-status` is both the net-capability probe and the broker-liveness
        // probe: an `Err` is the broker gone (or a future planned
        // snapshot+self-exit, Q3) — exit for a supervised respawn.
        let status = match brain.net_status() {
            Ok(s) => s,
            Err(e) => {
                eprintln!("BRAIN_BROKER_LOST: {e} — exiting for supervised respawn");
                return Err(e);
            }
        };
        if consumer_gate(status.enabled, consumers_started) {
            let hex = status.node_id_hex.clone().unwrap_or_default();
            spawn_net_consumers(&hex);
            consumers_started = true;
            eprintln!(
                "BRAIN_NET_CONSUMERS_UP: net enabled (node {hex}) — dispatcher + peer pump started"
            );
        }
        write_ready();
    }
}

/// Whether to spawn the net consumers this heartbeat: only once the broker
/// reports a net endpoint up (`net_enabled`) AND we have not already started
/// them. Pure for the unit table — the spawn-exactly-once + net-gating
/// semantics that keep a single dispatcher/peer-pump set running.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
fn consumer_gate(net_enabled: bool, already_started: bool) -> bool {
    net_enabled && !already_started
}

/// Spawn the net consumers — the inbound dispatcher + the outbound peer pump
/// (D9-1) — against the broker's socket. **Relocated from the broker process to
/// the brain child (restoration D2-1):** both are standalone IPC clients
/// (`Brain::cold_start`, no shared `Arc<Broker>`), so they belong with the
/// restartable brain. Each mints its own registry + stop flag and connects
/// independently; when the brain process exits these in-process threads die with
/// it, and the respawned brain re-spawns them from the current binary. Mirrors
/// the wiring the broker boot used to do, `node_hex` now sourced from the
/// `net-status` reply rather than the broker's identity load.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
// [impl->REQ-DAEMON-9]
fn spawn_net_consumers(node_hex: &str) {
    let registry = Arc::new(crate::registryhost::RegistryHost::new(node_hex));
    let stop = Arc::new(AtomicBool::new(false));
    let _ = crate::dispatch::spawn_dispatcher(
        broker_socket_name(),
        Arc::clone(&registry),
        Arc::clone(&stop),
    );
    let cfg = crate::config::DaemonConfig::load();
    if cfg.peer_pump {
        let _ = crate::peerloop::spawn_peer_pump(broker_socket_name(), registry, cfg, stop);
    }
}


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

/// The `spt daemon brain` child entry. Connect to the broker over the existing
/// socket IPC, signal readiness, then idle — holding the connection and probing
/// broker liveness each heartbeat. A dropped connection (broker gone, or a
/// future planned snapshot+self-exit, ADR-0018 Q3) returns `Err`, exiting the
/// process so the broker-side supervisor respawns it from the (possibly
/// swapped) binary.
///
/// D1 deliberately does NOTHING else — no logic loops run here yet. It inherits
/// the broker's already-unelevated token as a plain child, so it runs no
/// de-elevation guard of its own.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
pub fn run_brain() -> io::Result<()> {
    let name = broker_socket_name();
    let mut brain = connect_retry(&name, BRAIN_CONNECT_TRIES)?;
    eprintln!("BRAIN_UP: connected to broker {name} (pid {})", std::process::id());
    write_ready();
    // Idle loop: refresh the breadcrumb and probe the broker with a cheap
    // `sessions` query. The query erroring is how we learn the broker is gone.
    loop {
        thread::sleep(BRAIN_HEARTBEAT);
        if let Err(e) = brain.sessions() {
            eprintln!("BRAIN_BROKER_LOST: {e} — exiting for supervised respawn");
            return Err(e);
        }
        write_ready();
    }
}

/// The `spt daemon brain` child entry. Connect to the broker over the existing
/// socket IPC, signal readiness, host the brain-side logic loops, and idle —
/// holding the connection and probing broker liveness each heartbeat. A dropped
/// connection (broker gone, or a future planned snapshot+self-exit, ADR-0018 Q3)
/// returns `Err`, exiting the process so the broker-side supervisor respawns it
/// from the (possibly swapped) binary.
///
/// **D2-1 — net consumers run here.** The inbound dispatcher + outbound peer
/// pump are pure IPC clients (each `Brain::cold_start`s its own connection; no
/// shared `Arc<Broker>`), so they belong with the restartable brain, not the
/// broker — a brain respawn re-spawns them from the swapped binary. They are
/// meaningful only once the broker-owned NetHost is up, so we gate on the
/// existing `net-status` verb (which also serves as the broker-liveness probe)
/// and start them exactly once, on the first `enabled` reply. That single gate
/// also covers the boot-race self-heal (REQ-DAEMON-9): the broker retries the
/// NetHost bind in the background and `net-status` flips `enabled` when it
/// attaches, at which point the brain starts the consumers — no extra signal.
///
/// It inherits the broker's already-unelevated token as a plain child, so it
/// runs no de-elevation guard of its own.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
pub fn run_brain() -> io::Result<()> {
    let name = broker_socket_name();
    let mut brain = connect_retry(&name, BRAIN_CONNECT_TRIES)?;
    eprintln!("BRAIN_UP: connected to broker {name} (pid {})", std::process::id());
    write_ready();
    let mut consumers_started = false;
    loop {
        thread::sleep(BRAIN_HEARTBEAT);
        // `net-status` is both the net-capability probe and the broker-liveness
        // probe: an `Err` is the broker gone (or a future planned
        // snapshot+self-exit, Q3) — exit for a supervised respawn.
        let status = match brain.net_status() {
            Ok(s) => s,
            Err(e) => {
                eprintln!("BRAIN_BROKER_LOST: {e} — exiting for supervised respawn");
                return Err(e);
            }
        };
        if consumer_gate(status.enabled, consumers_started) {
            let hex = status.node_id_hex.clone().unwrap_or_default();
            spawn_net_consumers(&hex);
            consumers_started = true;
            eprintln!(
                "BRAIN_NET_CONSUMERS_UP: net enabled (node {hex}) — dispatcher + peer pump started"
            );
        }
        write_ready();
    }
}

/// Whether to spawn the net consumers this heartbeat: only once the broker
/// reports a net endpoint up (`net_enabled`) AND we have not already started
/// them. Pure for the unit table — the spawn-exactly-once + net-gating
/// semantics that keep a single dispatcher/peer-pump set running.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
fn consumer_gate(net_enabled: bool, already_started: bool) -> bool {
    net_enabled && !already_started
}

/// Spawn the net consumers — the inbound dispatcher + the outbound peer pump
/// (D9-1) — against the broker's socket. **Relocated from the broker process to
/// the brain child (restoration D2-1):** both are standalone IPC clients
/// (`Brain::cold_start`, no shared `Arc<Broker>`), so they belong with the
/// restartable brain. Each mints its own registry + stop flag and connects
/// independently; when the brain process exits these in-process threads die with
/// it, and the respawned brain re-spawns them from the current binary. Mirrors
/// the wiring the broker boot used to do, `node_hex` now sourced from the
/// `net-status` reply rather than the broker's identity load.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
// [impl->REQ-DAEMON-9]
fn spawn_net_consumers(node_hex: &str) {
    let registry = Arc::new(crate::registryhost::RegistryHost::new(node_hex));
    let stop = Arc::new(AtomicBool::new(false));
    let _ = crate::dispatch::spawn_dispatcher(
        broker_socket_name(),
        Arc::clone(&registry),
        Arc::clone(&stop),
    );
    let cfg = crate::config::DaemonConfig::load();
    if cfg.peer_pump {
        let _ = crate::peerloop::spawn_peer_pump(broker_socket_name(), registry, cfg, stop);
    }
}

const BRAIN_HEARTBEAT: Duration = Duration::from_millis(500);

-/// socket IPC, signal readiness, then idle — holding the connection and probing

-/// broker liveness each heartbeat. A dropped connection (broker gone, or a

-/// future planned snapshot+self-exit, ADR-0018 Q3) returns `Err`, exiting the

-/// process so the broker-side supervisor respawns it from the (possibly

+/// socket IPC, signal readiness, host the brain-side logic loops, and idle —

+/// holding the connection and probing broker liveness each heartbeat. A dropped

+/// connection (broker gone, or a future planned snapshot+self-exit, ADR-0018 Q3)

+/// returns `Err`, exiting the process so the broker-side supervisor respawns it

+/// from the (possibly swapped) binary.

-/// D1 deliberately does NOTHING else — no logic loops run here yet. It inherits

-/// the broker's already-unelevated token as a plain child, so it runs no

+/// **D2-1 — net consumers run here.** The inbound dispatcher + outbound peer

+/// pump are pure IPC clients (each `Brain::cold_start`s its own connection; no

+/// shared `Arc<Broker>`), so they belong with the restartable brain, not the

+/// broker — a brain respawn re-spawns them from the swapped binary. They are

+/// meaningful only once the broker-owned NetHost is up, so we gate on the

+/// existing `net-status` verb (which also serves as the broker-liveness probe)

+/// and start them exactly once, on the first `enabled` reply. That single gate

+/// also covers the boot-race self-heal (REQ-DAEMON-9): the broker retries the

+/// NetHost bind in the background and `net-status` flips `enabled` when it

+/// attaches, at which point the brain starts the consumers — no extra signal.

+/// It inherits the broker's already-unelevated token as a plain child, so it

+/// runs no de-elevation guard of its own.

-    // Idle loop: refresh the breadcrumb and probe the broker with a cheap

-    // `sessions` query. The query erroring is how we learn the broker is gone.

-        if let Err(e) = brain.sessions() {

-            eprintln!("BRAIN_BROKER_LOST: {e} — exiting for supervised respawn");

+        // `net-status` is both the net-capability probe and the broker-liveness

+        // probe: an `Err` is the broker gone (or a future planned

+        // snapshot+self-exit, Q3) — exit for a supervised respawn.

+        let status = match brain.net_status() {

+                eprintln!("BRAIN_BROKER_LOST: {e} — exiting for supervised respawn");

+        if consumer_gate(status.enabled, consumers_started) {

+            let hex = status.node_id_hex.clone().unwrap_or_default();

+                "BRAIN_NET_CONSUMERS_UP: net enabled (node {hex}) — dispatcher + peer pump started"

+/// Whether to spawn the net consumers this heartbeat: only once the broker

+/// reports a net endpoint up (`net_enabled`) AND we have not already started

+/// them. Pure for the unit table — the spawn-exactly-once + net-gating

+/// semantics that keep a single dispatcher/peer-pump set running.

+fn consumer_gate(net_enabled: bool, already_started: bool) -> bool {

+/// Spawn the net consumers — the inbound dispatcher + the outbound peer pump

+/// (D9-1) — against the broker's socket. **Relocated from the broker process to

+/// the brain child (restoration D2-1):** both are standalone IPC clients

+/// (`Brain::cold_start`, no shared `Arc<Broker>`), so they belong with the

+/// restartable brain. Each mints its own registry + stop flag and connects

+/// independently; when the brain process exits these in-process threads die with

+/// it, and the respawned brain re-spawns them from the current binary. Mirrors

+/// the wiring the broker boot used to do, `node_hex` now sourced from the

+/// `net-status` reply rather than the broker's identity load.

+fn spawn_net_consumers(node_hex: &str) {

+    let registry = Arc::new(crate::registryhost::RegistryHost::new(node_hex));

+    let stop = Arc::new(AtomicBool::new(false));

+    let _ = crate::dispatch::spawn_dispatcher(

+    let cfg = crate::config::DaemonConfig::load();

+        let _ = crate::peerloop::spawn_peer_pump(broker_socket_name(), registry, cfg, stop);

/// Connect to the broker, retrying briefly while it finishes binding.

fn connect_retry(name: &str, tries: u32) -> io::Result<Brain> {


Update brainproc module header (no longer "no logic moves yet").


//! `spt daemon brain` child. The child connects over the existing broker socket
//! IPC, signals `ready`, and idles — **no logic moves yet** (the net consumers,
//! digest hub, and shellwake stay in the broker process; D2 migrates them
//! loop-by-loop as each gets a versioned verb).

//! `spt daemon brain` child. The child connects over the existing broker socket
//! IPC, signals `ready`, hosts the brain-side logic loops, and idles. D2
//! migrates the broker-resident loops here one per commit; **D2-1 moved the net
//! consumers** (inbound dispatcher + outbound peer pump — pure IPC clients, no
//! shared `Arc<Broker>`) into the brain, gated on the existing `net-status`
//! verb. The digest hub serve + seed-control stay broker-side continuity
//! resources (ADR-0018 Q2/Q5); later D2 commits move the digest parse + psyche
//! loops.


//! `spt daemon brain` child. The child connects over the existing broker socket
//! IPC, signals `ready`, and idles — **no logic moves yet** (the net consumers,
//! digest hub, and shellwake stay in the broker process; D2 migrates them
//! loop-by-loop as each gets a versioned verb).

//! `spt daemon brain` child. The child connects over the existing broker socket
//! IPC, signals `ready`, hosts the brain-side logic loops, and idles. D2
//! migrates the broker-resident loops here one per commit; **D2-1 moved the net
//! consumers** (inbound dispatcher + outbound peer pump — pure IPC clients, no
//! shared `Arc<Broker>`) into the brain, gated on the existing `net-status`
//! verb. The digest hub serve + seed-control stay broker-side continuity
//! resources (ADR-0018 Q2/Q5); later D2 commits move the digest parse + psyche
//! loops.

//! ADR-0018 corrects). D1 restores the process boundary as a *true minimal

//! skeleton*: `spt daemon run` is the broker process and spawns a supervised

-//! IPC, signals `ready`, and idles — **no logic moves yet** (the net consumers,

-//! digest hub, and shellwake stay in the broker process; D2 migrates them

-//! loop-by-loop as each gets a versioned verb).

+//! IPC, signals `ready`, hosts the brain-side logic loops, and idles. D2

+//! migrates the broker-resident loops here one per commit; **D2-1 moved the net

+//! consumers** (inbound dispatcher + outbound peer pump — pure IPC clients, no

+//! shared `Arc<Broker>`) into the brain, gated on the existing `net-status`

+//! verb. The digest hub serve + seed-control stay broker-side continuity

+//! resources (ADR-0018 Q2/Q5); later D2 commits move the digest parse + psyche

//! What D1 proves (de-risk spike #1, the design's hardest invariant): the

//! broker spawns + supervises a brain child over the socket, and the broker


Now daemon.rs: drop both broker-side consumer spawn sites + retire `node_hex` param from `net_retry_attach` + remove the moved `spawn_net_consumers`.


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

                let serve_broker = Arc::clone(&broker);
                thread::spawn(move || {
                    let _ = serve_broker.serve();
                });
                // Inbound net dispatch + outbound peer pump (D9-1) now run in
                // the BRAIN child (restoration D2-1): they are pure IPC clients,
                // so they live with the restartable brain and respawn with it.
                // The broker keeps only the NetHost bring-up and the boot-race
                // self-heal that binds it; the brain polls `net-status` and
                // starts the consumers once net reports enabled.
                // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
                if !net_up && node_hex.is_some() {
                    // Boot-race self-heal (REQ-DAEMON-9): net failed to bind but
                    // identity is sound — almost always the autostart racing the
                    // network up at boot. Retry net in the background and attach
                    // it to the broker when it succeeds, so the node becomes
                    // reachable WITHOUT a manual restart; the brain notices
                    // `net-status` flip enabled and starts the consumers then.
                    // [impl->REQ-DAEMON-9]
                    let broker = Arc::clone(&broker);
                    thread::spawn(move || net_retry_attach(broker));
                }


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

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

                let serve_broker = Arc::clone(&broker);
                thread::spawn(move || {
                    let _ = serve_broker.serve();
                });
                // Inbound net dispatch + outbound peer pump (D9-1) now run in
                // the BRAIN child (restoration D2-1): they are pure IPC clients,
                // so they live with the restartable brain and respawn with it.
                // The broker keeps only the NetHost bring-up and the boot-race
                // self-heal that binds it; the brain polls `net-status` and
                // starts the consumers once net reports enabled.
                // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
                if !net_up && node_hex.is_some() {
                    // Boot-race self-heal (REQ-DAEMON-9): net failed to bind but
                    // identity is sound — almost always the autostart racing the
                    // network up at boot. Retry net in the background and attach
                    // it to the broker when it succeeds, so the node becomes
                    // reachable WITHOUT a manual restart; the brain notices
                    // `net-status` flip enabled and starts the consumers then.
                    // [impl->REQ-DAEMON-9]
                    let broker = Arc::clone(&broker);
                    thread::spawn(move || net_retry_attach(broker));
                }

-                // Inbound net dispatch + outbound peer pump (D9-1): only

-                // meaningful with a net host — a net-less broker accepts nothing

-                let hex = node_hex.clone().unwrap_or_default();

-                    spawn_net_consumers(&hex);

-                } else if node_hex.is_some() {

+                // Inbound net dispatch + outbound peer pump (D9-1) now run in

+                // the BRAIN child (restoration D2-1): they are pure IPC clients,

+                // so they live with the restartable brain and respawn with it.

+                // The broker keeps only the NetHost bring-up and the boot-race

+                // self-heal that binds it; the brain polls `net-status` and

+                // starts the consumers once net reports enabled.

+                // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]

+                if !net_up && node_hex.is_some() {

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

+                    // network up at boot. Retry net in the background and attach

+                    // it to the broker when it succeeds, so the node becomes

+                    // reachable WITHOUT a manual restart; the brain notices

+                    // `net-status` flip enabled and starts the consumers then.

-                    thread::spawn(move || net_retry_attach(broker, hex));

+                    thread::spawn(move || net_retry_attach(broker));

Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),


/// Spawn the net consumers — the inbound dispatcher + the outbound peer pump
/// (D9-1) — against the now-net-attached broker. Called once at boot (net up
/// immediately) OR once from the self-heal retry when net attaches later
/// (REQ-DAEMON-9). Self-contained: mints its own registry + stop flag.
// [impl->REQ-DAEMON-9]
fn spawn_net_consumers(node_hex: &str) {
    let registry = Arc::new(crate::registryhost::RegistryHost::new(node_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);
    }
}

/// Background net self-heal supervisor (REQ-DAEMON-9): a daemon that came up
/// net-less (NET_BIND_FAIL at boot — typically the autostart racing the network
/// up) keeps retrying `try_start_net` with capped backoff and, on success,
/// ATTACHES the host to the live broker (`OnceLock`, so exactly once) and spawns
/// the net consumers. The node becomes reachable without a manual restart. Runs
/// until net binds — the network always arrives eventually after boot.
// [impl->REQ-DAEMON-9]
fn net_retry_attach(broker: Arc<Broker>, node_hex: String) {
    let mut backoff = NET_RETRY_FIRST;
    loop {
        thread::sleep(backoff);
        if let Some(host) = try_start_net() {
            if broker.attach_net(host) {
                eprintln!("NET_ATTACHED: net endpoint bound on retry — peer pump starting");
                spawn_net_consumers(&node_hex);
            }
            return;
        }
        backoff = net_retry_backoff(backoff);
        eprintln!("NET_BIND_RETRY: net still unavailable, retrying in {}s", backoff.as_secs());
    }
}

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


/// Spawn the net consumers — the inbound dispatcher + the outbound peer pump
/// (D9-1) — against the now-net-attached broker. Called once at boot (net up
/// immediately) OR once from the self-heal retry when net attaches later
/// (REQ-DAEMON-9). Self-contained: mints its own registry + stop flag.
// [impl->REQ-DAEMON-9]
fn spawn_net_consumers(node_hex: &str) {
    let registry = Arc::new(crate::registryhost::RegistryHost::new(node_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);
    }
}

/// Background net self-heal supervisor (REQ-DAEMON-9): a daemon that came up
/// net-less (NET_BIND_FAIL at boot — typically the autostart racing the network
/// up) keeps retrying `try_start_net` with capped backoff and, on success,
/// ATTACHES the host to the live broker (`OnceLock`, so exactly once) and spawns
/// the net consumers. The node becomes reachable without a manual restart. Runs
/// until net binds — the network always arrives eventually after boot.
// [impl->REQ-DAEMON-9]
fn net_retry_attach(broker: Arc<Broker>, node_hex: String) {
    let mut backoff = NET_RETRY_FIRST;
    loop {
        thread::sleep(backoff);
        if let Some(host) = try_start_net() {
            if broker.attach_net(host) {
                eprintln!("NET_ATTACHED: net endpoint bound on retry — peer pump starting");
                spawn_net_consumers(&node_hex);
            }
            return;
        }
        backoff = net_retry_backoff(backoff);
        eprintln!("NET_BIND_RETRY: net still unavailable, retrying in {}s", backoff.as_secs());
    }
}

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

-/// Spawn the net consumers — the inbound dispatcher + the outbound peer pump

-/// (D9-1) — against the now-net-attached broker. Called once at boot (net up

-/// immediately) OR once from the self-heal retry when net attaches later

-/// (REQ-DAEMON-9). Self-contained: mints its own registry + stop flag.

-fn spawn_net_consumers(node_hex: &str) {

-    let registry = Arc::new(crate::registryhost::RegistryHost::new(node_hex));

-    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));

-    let _ = crate::dispatch::spawn_dispatcher(

-        let _ = crate::peerloop::spawn_peer_pump(broker_socket_name(), registry, cfg, stop);

-/// ATTACHES the host to the live broker (`OnceLock`, so exactly once) and spawns

-/// the net consumers. The node becomes reachable without a manual restart. Runs

-/// until net binds — the network always arrives eventually after boot.

+/// ATTACHES the host to the live broker (`OnceLock`, so exactly once). The node

+/// becomes reachable without a manual restart. Runs until net binds — the

+/// network always arrives eventually after boot. The net consumers are no longer

+/// spawned here (restoration D2-1: they moved to the brain child, which starts

+/// them when `net-status` reports the just-attached endpoint enabled).

-fn net_retry_attach(broker: Arc<Broker>, node_hex: String) {

+fn net_retry_attach(broker: Arc<Broker>) {

-                eprintln!("NET_ATTACHED: net endpoint bound on retry — peer pump starting");

-                spawn_net_consumers(&node_hex);

+                eprintln!("NET_ATTACHED: net endpoint bound on retry — brain starts the consumers");


Now add the `consumer_gate` unit test to brainproc.rs tests.


    /// Backoff: a fast death doubles (capped), a healthy run resets to the floor.
    // [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    #[test]
    fn backoff_doubles_on_fast_death_resets_when_healthy() {

    /// The net-consumer gate (D2-1): the brain spawns the dispatcher + peer pump
    /// exactly once, only after the broker reports net `enabled`. A net-less
    /// broker (`enabled=false`) never spawns them; once started they are never
    /// re-spawned (the `already_started` latch) — so a single consumer set runs,
    /// no matter how many heartbeats poll `net-status`.
    // [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    #[test]
    fn consumer_gate_spawns_once_when_net_enabled() {
        // Net down: never spawn, regardless of the latch.
        assert!(!consumer_gate(false, false));
        assert!(!consumer_gate(false, true));
        // Net up, not yet started: spawn this tick.
        assert!(consumer_gate(true, false));
        // Net up, already started: do not re-spawn (exactly-once).
        assert!(!consumer_gate(true, true));
    }

    /// Backoff: a fast death doubles (capped), a healthy run resets to the floor.
    // [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    #[test]
    fn backoff_doubles_on_fast_death_resets_when_healthy() {


    /// The net-consumer gate (D2-1): the brain spawns the dispatcher + peer pump
    /// exactly once, only after the broker reports net `enabled`. A net-less
    /// broker (`enabled=false`) never spawns them; once started they are never
    /// re-spawned (the `already_started` latch) — so a single consumer set runs,
    /// no matter how many heartbeats poll `net-status`.
    // [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    #[test]
    fn consumer_gate_spawns_once_when_net_enabled() {
        // Net down: never spawn, regardless of the latch.
        assert!(!consumer_gate(false, false));
        assert!(!consumer_gate(false, true));
        // Net up, not yet started: spawn this tick.
        assert!(consumer_gate(true, false));
        // Net up, already started: do not re-spawn (exactly-once).
        assert!(!consumer_gate(true, true));
    }

    /// Backoff: a fast death doubles (capped), a healthy run resets to the floor.
    // [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    #[test]
    fn backoff_doubles_on_fast_death_resets_when_healthy() {

+    /// The net-consumer gate (D2-1): the brain spawns the dispatcher + peer pump

+    /// exactly once, only after the broker reports net `enabled`. A net-less

+    /// broker (`enabled=false`) never spawns them; once started they are never

+    /// re-spawned (the `already_started` latch) — so a single consumer set runs,

+    /// no matter how many heartbeats poll `net-status`.

+    // [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]

+    fn consumer_gate_spawns_once_when_net_enabled() {

+        // Net down: never spawn, regardless of the latch.

+        assert!(!consumer_gate(false, false));

+        assert!(!consumer_gate(false, true));

+        // Net up, not yet started: spawn this tick.

+        assert!(consumer_gate(true, false));

+        // Net up, already started: do not re-spawn (exactly-once).

+        assert!(!consumer_gate(true, true));


Now build + test. Marking gate task in progress.


Updated task #3 status


Bash

cargo build -p spt-daemon 2>&1 | tail -25

Build spt-daemon


   Compiling spt-daemon v0.3.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 7.21s


Builds clean. Run the daemon tests + clippy + no-default-features in parallel.


cargo test -p spt-daemon --lib brainproc 2>&1 | tail -25

Run brainproc unit tests


cargo clippy -p spt-daemon 2>&1 | tail -20

Clippy spt-daemon


   Compiling spt-daemon v0.3.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 5.97s
     Running unittests src\lib.rs (target\debug\deps\spt_daemon-aeb3cddcb7fefcf8.exe)

running 3 tests
test brainproc::tests::backoff_doubles_on_fast_death_resets_when_healthy ... ok
test brainproc::tests::consumer_gate_spawns_once_when_net_enabled ... ok
test brainproc::tests::supervisor_respawns_brain_until_stopped ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 224 filtered out; finished in 0.44s


    Checking rustls-webpki v0.103.13
    Checking rustls v0.23.40
    Checking spt-msg v0.3.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-msg)
    Checking spt-runtime v0.3.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime)
    Checking spt-term v0.3.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Checking spt-live v0.3.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Checking tokio-rustls v0.26.4
    Checking rustls-platform-verifier v0.7.0
    Checking noq-proto v0.17.0
    Checking hickory-net v0.26.0-beta.4
    Checking hyper-rustls v0.27.9
    Checking tokio-websockets v0.13.2
    Checking reqwest v0.13.4
    Checking hickory-resolver v0.26.0-beta.4
    Checking noq v0.18.0
    Checking iroh-relay v0.98.0
    Checking iroh v0.98.2
    Checking spt-net v0.3.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
    Checking spt-daemon v0.3.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 12.91s


Clean. Now full daemon lib suite + brain_split smoke + no-default-features build.


cargo test -p spt-daemon --lib 2>&1 | tail -8

Full daemon lib test suite


cargo test -p spt --test brain_split 2>&1 | tail -15

brain_split process smoke


test seedmap::tests::take_absent_is_none ... ok
test serveprobe::tests::is_serving_subnet_honors_membership_and_detach ... ok
test shellwake::tests::watcher_crash_exits_respawn_then_give_up ... ok
test shellwake::tests::resolve_wake_revives_a_suspended_owner_without_double_launch ... ok
test sync::tests::select_refs_scopes_two_tiers ... ok

test result: ok. 227 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.62s


   Compiling spt-daemon v0.3.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
   Compiling spt v0.3.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 8.64s
     Running tests\brain_split.rs (target\debug\deps\brain_split-e1fcf78ea34f952b.exe)

running 1 test
BRAIN_UP: connected to broker spt-daemon-broker-a7f4a11e3831c1ff.sock (pid 39816)
BRAIN_EXIT: brain child exited (exit code: 1) — respawning
BRAIN_RESTART: supervised respawn in 4s
BRAIN_UP: connected to broker spt-daemon-broker-a7f4a11e3831c1ff.sock (pid 45564)
test broker_survives_brain_kill_and_respawns_it ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.08s


227 lib tests (+1) and brain_split smoke green. Now no-default-features + traceability + doc-drift gates.


cargo build --no-default-features 2>&1 | tail -8

no-default-features build


traceable-reqs check 2>&1 | tail -20; echo "EXIT=$?"

traceable-reqs check


   Compiling iroh-relay v0.98.0
   Compiling iroh v0.98.2
   Compiling spt-net v0.3.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
   Compiling spt-daemon v0.3.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
   Compiling spt v0.3.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
   Compiling xtask v0.3.2 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 21.97s


  [OK] REQ-TERM-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Byte-stream remote terminal streaming for v1
  [OK] REQ-TERM-4  required: [impl, unit, int]  stages: +doc +impl +unit +int
       Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log
  [OK] REQ-UPD-1  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Peer-propagated update over P2P
  [OK] REQ-UPD-2  required: [impl, unit]  stages: -doc +impl +unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-core ripple-updates registered adapters
  [OK] REQ-UPD-6  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       Platform-targeted update sets and debug rollout: signed multi-platform update metadata, recipient platform selection, channel-scoped monotonic counters, debug-channel opt-in via release-key overlay, local staging plus pull-based peer propagation, and maintainer-only convergence tooling (ADR-0016)
  [OK] REQ-UPD-7  required: [impl, unit]  stages: -doc +impl +unit -int
       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.
  [OK] REQ-UPD-8  required: [impl, unit]  stages: -doc +impl +unit -int
       Platform-safe `spt update fetch` + apply platform-guard (v0.3.1 cross-OS brick fix): `spt update fetch` stages the signed multi-platform `SignedUpdateSet` (`update-set.json` + every platform artifact it names), never a platform-blind single `SignedRelease`, so local apply selects `current_platform()` and P2P re-serve lets each peer select ITS own platform. Defense-in-depth: `apply_staged` REFUSES a staged single-release artifact unless it is platform-stamped for THIS node (an unstamped pre-v0.3.2 single, or a single stamped for another OS, fail-safe refuses — the guard that alone prevents the v0.3.1 brick where a Linux ELF was applied as `spt.exe`). UX: a friendly post-apply message (`Updated spt-core to vX.Y.Z.` + changelog URL) driven by an additive `product_version` metadata field, with a release-counter fallback when absent.
EXIT=0


Write D2 plan + migration ledger

RESTORATION-D2-PLAN.md task breakdown + loop→verb→moves-at-commit ledger in RESTORATION-PLAN.md D2 section. Establish per-commit migration discipline.

Migrate net consumers (dispatch+peer pump) into brain

Move spawn_net_consumers from broker process to brain child, gated on net_status().enabled (existing verb, no new verb). Broker keeps net bring-up + net_retry_attach but drops consumer spawn. Brain polls net_status, spawns dispatcher+peer pump once net enabled. Pure once-only gate helper + unit test. First D2 commit.

Run gates + commit net-consumer migration

cargo build/test/clippy + --no-default-features + traceable-reqs check + xtask check. Atomic commit with in-commit evidence tags. Push to dev-freeform branch for CI before any release.


no-default-features green, traceability EXIT=0. Now doc-drift gate.


cargo run -q -p xtask -- check 2>&1 | tail -25; echo "EXIT=$?"

xtask check doc drift


xtask check: OK
EXIT=0


All six gates green. Let me check the commit trailer + stage.


git log -1 --format='%(trailers)' HEAD; echo "---STATUS---"; git status --short

Get trailer format + status


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

---STATUS---
 M RESTORATION-PLAN.md
 M crates/spt-daemon/src/brainproc.rs
 M crates/spt-daemon/src/daemon.rs
?? RESTORATION-D2-PLAN.md


Stage all + show stat


warning: in the working copy of 'RESTORATION-PLAN.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/brainproc.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'RESTORATION-D2-PLAN.md', LF will be replaced by CRLF the next time Git touches it
 RESTORATION-D2-PLAN.md             |  89 ++++++++++++++++++++++++++++++
 RESTORATION-PLAN.md                |  21 +++++++
 crates/spt-daemon/src/brainproc.rs | 110 +++++++++++++++++++++++++++++++------
 crates/spt-daemon/src/daemon.rs    |  56 +++++++------------
 4 files changed, 224 insertions(+), 52 deletions(-)


git commit -q -F - <<'EOF'
feat(daemon): restoration D2-1 — net consumers move broker→brain (REQ-HAZARD-BROKER-PROCESS-ISOLATION)

First loop migration of the ADR-0018 D2 verb/loop relocation. D1 left every
logic loop in the broker process; D2 moves them into the brain child one per
commit. The cheapest first (D1 discovery): the inbound dispatcher (dispatch.rs)
and outbound peer pump (peerloop.rs) are ALREADY standalone IPC clients — each
Brain::cold_starts its own connection over broker_socket_name(), holds no shared
Arc<Broker>. They were merely *spawned* in the broker process for convenience.
The migration is moving that spawn site into the brain. NO new verb.

Why it's strictly better: the consumers used to live in the broker, so a broker
restart killed them and a brain restart never touched them. Now they live with
the restartable brain — a brain respawn re-spawns them from the swapped binary,
the intended topology.

Net-gating with no new signal: consumers are meaningful only with a NetHost up,
so the brain polls the EXISTING net-status verb (answered enabled:false on a
net-less broker; carries node_id_hex) and starts them exactly once, on the first
enabled reply. That single gate also covers the boot-race self-heal
(REQ-DAEMON-9): the broker still owns the NetHost bind retry + attach_net, and
net-status flips enabled when it attaches — the brain starts the consumers then.

Changes:
- brainproc.rs: run_brain heartbeat polls net-status (now the broker-liveness
  probe too, replacing the sessions() probe); on the first enabled reply spawns
  the net consumers (spawn_net_consumers relocated here, brain-side) with
  node_id_hex from the reply. consumer_gate pure helper enforces spawn-once.
- daemon.rs: drop both broker-side spawn_net_consumers call sites (boot net_up
  branch + inside net_retry_attach); net_retry_attach keeps the NetHost bind
  retry + attach_net, no longer spawns consumers (node_hex param retired).
- unit: consumer_gate once-only + net-gating table.

N-1 note (no-verb migration): no verb signature changed, so the verb-surface
N-1 window is untouched. The one transient hazard — an old broker (spawns
consumers in-process) × new brain (also spawns them) → double-spawn — cannot
occur: broker and brain are the same binary and swap together until D3 adds
brain-only swaps, by which point both halves are post-migration.

Plans: RESTORATION-D2-PLAN.md (task detail) + live ledger in RESTORATION-PLAN.md
D2. REQ-HAZARD-BROKER-PROCESS-ISOLATION already [doc,impl,unit] active — no toml
change; this commit adds impl/unit evidence under it.

Gates: cargo build/test (227 daemon lib, +1) / clippy / build --no-default-features
/ traceable-reqs check EXIT=0 / xtask check OK. brain_split process-smoke green.

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

Commit D2-1 migration


f2a17a9 feat(daemon): restoration D2-1 — net consumers move broker→brain (REQ-HAZARD-BROKER-PROCESS-ISOLATION)


Updated task #3 status


Committed `f2a17a9` on main, all six gates green. Per project practice (single-home dev box is blind to socket/service issues — CI catches what it can't), pushing for cross-OS CI verification is the next step. Checking remote state.


git status -sb | head -3; git rev-list --count @{u}..HEAD 2>&1

Branch ahead/behind vs upstream


## main...origin/main [ahead 1]
1


Push D2-1 to origin/main


To https://github.com/SaberMage/spt-core.git
   0c95435..f2a17a9  main -> main


Pushed to origin/main — cross-OS CI now running on both self-hosted runners. Let me confirm it kicked off.


gh run list --branch main --limit 2 2>&1 | head -5

Check CI run kicked off


queued		feat(daemon): restoration D2-1 — net consumers move broker→brain (REQ…	ci	main	push	27248426315	4s	2026-06-10T02:10:11Z
completed	success	feat(daemon): restoration D1 — broker/brain process-split skeleton (R…	ci	main	push	27247812980	6m13s	2026-06-10T01:53:02Z


D2-1 complete. Status:

**Done — restoration D2-1: net consumers broker→brain** (`f2a17a9`, on main)

First D2 loop migration. Inbound dispatcher + outbound peer pump were already pure IPC clients (own `Brain::cold_start` conn, no shared `Arc<Broker>`) — just *spawned* in the broker. Moved spawn site into the brain child. **No new verb.**

- Brain heartbeat now polls `net-status` (doubles as broker-liveness probe), spawns consumers exactly once on first `enabled` reply. Single gate also covers boot-race self-heal (REQ-DAEMON-9) — broker keeps NetHost bind+attach, `net-status` flips enabled, brain starts consumers.
- Strictly better topology: consumers live with restartable brain now → brain respawn re-spawns them from swapped binary. Broker restart used to kill them.
- `daemon.rs`: dropped both broker-side spawn sites; `net_retry_attach` retired `node_hex` param.
- `consumer_gate` pure helper + once-only/net-gating unit test.

**Gates all green:** build · 227 daemon lib tests (+1) · clippy · `--no-default-features` · `traceable-reqs check` EXIT=0 · `xtask check` OK · brain_split process-smoke. REQ already `[doc,impl,unit]` — no toml change.

**Plans:** `RESTORATION-D2-PLAN.md` + live ledger in `RESTORATION-PLAN.md` D2.

**CI:** pushed to origin/main, run `27248426315` queued (both runners). D1 lesson — confirm cross-OS green before stacking D2-2 (digest parse → brain).

Watching CI to confirm green, then D2-2 next.


gh run watch 27248426315 --exit-status 2>&1 | tail -20; echo "EXIT=$?"

Watch CI run to completion


✓ test (self-hosted, Linux, kitsubito) in 4m6s (ID 80467453608)
  ✓ Set up job
  ✓ Run actions/checkout@v5
  ✓ Build
  ✓ Test
  ✓ Clippy (deny warnings)
  ✓ Checkout spt-shell-notify (real-shell dogfood)
  ✓ Build notify-shell
  ✓ Real-shell E2E (notify) — linux
  - Real-shell E2E (notify) — windows
  ✓ Installer E2E (staged-release rung)
  ✓ Docs drift gate (CLI ref + llms links)
  - Dormancy budget (shell seats)
  - Dormancy budget (real adapter seats, when installed) — linux
  - Dormancy budget (real adapter seats, when installed) — windows
  ✓ Post Checkout spt-shell-notify (real-shell dogfood)
  ✓ Post Run actions/checkout@v5
  ✓ Complete job
- twohost-b in 0s (ID 80468097252)
- twohost-a in 0s (ID 80468097314)
EXIT=0


gh run view 27248426315 --json conclusion,jobs -q '.conclusion, (.jobs[] | "\(.name): \(.conclusion)")' 2>&1 | head -10

Confirm both-runner conclusions


success
test (self-hosted, Windows, hfenduleam): success
traceability: success
test (self-hosted, Linux, kitsubito): success
twohost-b: skipped
twohost-a: skipped


CI confirmed green cross-OS: **Windows hfenduleam ✓ · Linux kitsubito ✓ · traceability ✓** (twohost skipped, expected — no `[twohost]` tag). D2-1 is locked in on main.

Let me scope D2-2 (digest parse → brain) before committing to it — the plan notes the feed is tied to session-spawn, not a standalone loop.


crates\spt-daemon\src\brain.rs:508:    pub fn run_digest_feed(
crates\spt-daemon\src\digest.rs:219:/// ([`crate::brain::Brain::run_digest_feed`]) and any in-process daemon feed


`run_digest_feed` has **no production spawn site** — only the brain-side primitive + a doc ref. So there may be no broker-resident digest parse loop to migrate. Let me verify what populates the hub in the broker.


crates\spt-daemon\src\brain.rs:32:use crate::digest::{ingest_chunk, DigestEngine};
crates\spt-daemon\src\brain.rs:33:use crate::digesthub::DigestHub;
crates\spt-daemon\src\brain.rs:502:    /// [`ingest_chunk`]. This is the brain-side digest projection — the parse is
crates\spt-daemon\src\brain.rs:510:        engine: &mut DigestEngine,
crates\spt-daemon\src\brain.rs:511:        hub: &DigestHub,
crates\spt-daemon\src\brain.rs:520:                BrokerEvent::Output { bytes, .. } => ingest_chunk(engine, hub, endpoint_id, &bytes),
crates\spt-daemon\src\daemon.rs:27:use crate::digesthub::{self, DigestHub};
crates\spt-daemon\src\daemon.rs:200:        let digest_hub = Arc::new(DigestHub::new());
crates\spt-daemon\src\daemon.rs:202:            let hub = Arc::clone(&digest_hub);
crates\spt-daemon\src\digest.rs:20://! [`DigestEngine::feed`] returns a [`DigestUpdate`] only when the digest changed,
crates\spt-daemon\src\digest.rs:49:use crate::digesthub::DigestHub;
crates\spt-daemon\src\digest.rs:73:pub struct DigestEngine {
crates\spt-daemon\src\digest.rs:86:impl DigestEngine {
crates\spt-daemon\src\digest.rs:113:        Ok(DigestEngine {
crates\spt-daemon\src\digest.rs:222:pub fn ingest_chunk(engine: &mut DigestEngine, hub: &DigestHub, endpoint_id: &str, bytes: &[u8]) {
crates\spt-daemon\src\digest.rs:231:    hub.publish(endpoint_id, engine.snapshot(), update);
crates\spt-daemon\src\digest.rs:263:        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
crates\spt-daemon\src\digest.rs:281:        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
crates\spt-daemon\src\digest.rs:293:        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
crates\spt-daemon\src\digest.rs:308:        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3
crates\spt-daemon\src\digest.rs:324:        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3
crates\spt-daemon\src\digest.rs:350:        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
crates\spt-daemon\src\digest.rs:367:        let mut e = DigestEngine::from_manifest(&s).expect("compile");
crates\spt-daemon\src\digest.rs:394:        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // persist=false
crates\spt-daemon\src\digesthub.rs:3://! The brain runs a [`crate::digest::DigestEngine`] per daemon-hosted session and
crates\spt-daemon\src\digesthub.rs:82:pub struct DigestHub {
crates\spt-daemon\src\digesthub.rs:86:impl DigestHub {
crates\spt-daemon\src\digesthub.rs:89:        DigestHub {
crates\spt-daemon\src\digesthub.rs:97:    /// feed loop after each [`DigestEngine::feed`](crate::digest::DigestEngine::feed).
crates\spt-daemon\src\digesthub.rs:146:pub fn serve_digest_control(name: &str, hub: Arc<DigestHub>) -> io::Result<()> {
crates\spt-daemon\src\digesthub.rs:159:fn handle_conn<C: Read + Write>(mut conn: C, hub: &DigestHub) -> io::Result<()> {
crates\spt-daemon\src\digesthub.rs:368:        let hub = DigestHub::new();
crates\spt-daemon\src\digesthub.rs:376:        hub.publish("doyle", snap.clone(), an_update(1));
crates\spt-daemon\src\digesthub.rs:386:        let hub = DigestHub::new();
crates\spt-daemon\src\digesthub.rs:390:        hub.publish(
crates\spt-daemon\src\digesthub.rs:401:        hub.publish(
crates\spt-daemon\src\digesthub.rs:415:        let hub = DigestHub::new();
crates\spt-daemon\src\digesthub.rs:416:        hub.publish(
crates\spt-daemon\src\lib.rs:176:pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
crates\spt-daemon\src\lib.rs:179:    update_to_json, DigestHub,


Major finding: the `DigestHub` in the broker is **empty in production** — `ingest_chunk` is called only from `run_digest_feed`, which has **no spawn site**. The digest parse was never broker-resident; it's an unwired brain-side primitive. So D2-2 as "migrate a broker-resident digest loop" is a **null migration** — nothing to move. Same likely for psyche/pulse loops (not spawned in `Daemon::run`).

Let me verify the *actual* remaining broker-resident loop — shellwake.


Grep

pub fn spawn_wake_host|fn spawn_wake_host|Arc<Broker>|broker_socket_name|Brain::cold_start


367-        .ok_or_else(|| format!("no dialable address for {node}"))?;
368-    let mut brain =
369:        crate::brain::Brain::cold_start(&crate::endpoint::broker_socket_name(), now_ms())
370-            .map_err(|e| format!("broker connect: {e}"))?;
371-    let conn = brain
--
531-/// is re-read each sweep (adapter add/remove lands between ticks).
532-// [impl->REQ-SHELL-2]
533:pub fn spawn_wake_host(stop: Arc<AtomicBool>) -> JoinHandle<()> {
534-    std::thread::spawn(move || {
535-        let set = Arc::new(WakeSet::new());


Confirmed — `spawn_wake_host` takes only a `stop` flag, no shared `Arc<Broker>`, and already uses `Brain::cold_start` for its remote path. **Same cheap pure-IPC shape as the net consumers.** This reframes D2 honestly:

- **Genuine broker-resident logic loops** = net consumers (done) + shellwake. That's the whole list in `Daemon::run`.
- **Digest parse** = unwired primitive (`run_digest_feed` has no spawn site; broker's hub is empty in prod). Not a migration — cross-process publish is future new-verb work.
- **Psyche/pulse loops** = not hosted in the spt-core daemon yet (legacy listener owns live loops). Nothing to relocate.

So D2's structural relocation = two cheap spawn-site moves. Net consumers done; shellwake next closes it. Let me do the shellwake migration.


TaskCreate

Migrate shellwake wake-host into brain (D2-2)

Move shellwake::spawn_wake_host from broker process to brain child (pure IPC, no shared Arc, Q5 brain-owned exception). Unconditional spawn at brain start; boot sweep re-reconciles orphans on respawn. Then correct the D2 ledger: digest-parse + psyche are NOT broker-resident (unwired/legacy), so D2 structural relocation = net consumers + shellwake only.


Task #4 created successfully: Migrate shellwake wake-host into brain (D2-2)


505	                let shell = shell.clone();
506	                let params = *params;
507	                std::thread::spawn(move || {
508	                    watcher_run(&owlery, &owner, &shell_id, &tokens, &params, &stop, || {
509	                        resolve_wake(&owlery, &owner, &shell_id, &adapter_name, &shell)
510	                    });
511	                })
512	            };
513	            set.insert(
514	                &owner,
515	                &shell_id,
516	                WatcherHandle {
517	                    stop,
518	                    thread: handle,
519	                },
520	            );
521	        }
522	    }
523	}
524
525	/// The reconcile cadence (ms): lifecycle flips land in CLI processes, so the
526	/// daemon's loop is the invariant holder — a flip is at most one tick stale.
527	pub const RECONCILE_INTERVAL_MS: u64 = 5_000;
528
529	/// Spawn the daemon's wake host: one thread sweeping [`reconcile_once`] at
530	/// boot and every [`RECONCILE_INTERVAL_MS`] until `stop`. The registered set
531	/// is re-read each sweep (adapter add/remove lands between ticks).
532	// [impl->REQ-SHELL-2]
533	pub fn spawn_wake_host(stop: Arc<AtomicBool>) -> JoinHandle<()> {
534	    std::thread::spawn(move || {
535	        let set = Arc::new(WakeSet::new());
536	        let params = WakeParams::default();
537	        while !stop.load(Ordering::SeqCst) {
538	            let owlery = spt_store::perch::owlery_dir();
539	            let registered = spt_runtime::registry::registered(&spt_store::perch::adapters_dir());
540	            reconcile_once(&owlery, &registered, &set, &params);
541	            // Sleep in slices so a stop lands promptly.
542	            let mut left = RECONCILE_INTERVAL_MS;
543	            while left > 0 && !stop.load(Ordering::SeqCst) {
544	                let step = left.min(100);
545	                std::thread::sleep(Duration::from_millis(step));
546	                left -= step;
547	            }
548	        }
549	    })
550	}
551
552	#[cfg(test)]
553	mod tests {
554	    use super::*;
555	    use spt_store::shellinfo::{spawn_record, SHELL_STATUS_ONLINE};
556
557	    fn fast_params() -> WakeParams {
558	        WakeParams {
559	            backoff_base_ms: 10,
560	            backoff_cap_ms: 40,
561	            give_up_after: 3,
562	        }
563	    }
564

                let shell = shell.clone();
                let params = *params;
                std::thread::spawn(move || {
                    watcher_run(&owlery, &owner, &shell_id, &tokens, &params, &stop, || {
                        resolve_wake(&owlery, &owner, &shell_id, &adapter_name, &shell)
                    });
                })
            };
            set.insert(
                &owner,
                &shell_id,
                WatcherHandle {
                    stop,
                    thread: handle,
                },
            );
        }
    }
}

/// The reconcile cadence (ms): lifecycle flips land in CLI processes, so the
/// daemon's loop is the invariant holder — a flip is at most one tick stale.
pub const RECONCILE_INTERVAL_MS: u64 = 5_000;

/// Spawn the daemon's wake host: one thread sweeping [`reconcile_once`] at
/// boot and every [`RECONCILE_INTERVAL_MS`] until `stop`. The registered set
/// is re-read each sweep (adapter add/remove lands between ticks).
// [impl->REQ-SHELL-2]
pub fn spawn_wake_host(stop: Arc<AtomicBool>) -> JoinHandle<()> {
    std::thread::spawn(move || {
        let set = Arc::new(WakeSet::new());
        let params = WakeParams::default();
        while !stop.load(Ordering::SeqCst) {
            let owlery = spt_store::perch::owlery_dir();
            let registered = spt_runtime::registry::registered(&spt_store::perch::adapters_dir());
            reconcile_once(&owlery, &registered, &set, &params);
            // Sleep in slices so a stop lands promptly.
            let mut left = RECONCILE_INTERVAL_MS;
            while left > 0 && !stop.load(Ordering::SeqCst) {
                let step = left.min(100);
                std::thread::sleep(Duration::from_millis(step));
                left -= step;
            }
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_store::shellinfo::{spawn_record, SHELL_STATUS_ONLINE};

    fn fast_params() -> WakeParams {
        WakeParams {
            backoff_base_ms: 10,
            backoff_cap_ms: 40,
            give_up_after: 3,
        }
    }


Process-agnostic — reads owlery/adapters from disk, reconciles. Clean move. Implementing: spawn the wake host at brain start, remove the broker-side spawn.


Edit

    eprintln!("BRAIN_UP: connected to broker {name} (pid {})", std::process::id());
    write_ready();
    let mut consumers_started = false;

    eprintln!("BRAIN_UP: connected to broker {name} (pid {})", std::process::id());
    write_ready();

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

    let mut consumers_started = false;


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

    eprintln!("BRAIN_UP: connected to broker {name} (pid {})", std::process::id());
    write_ready();
    let mut consumers_started = false;

    eprintln!("BRAIN_UP: connected to broker {name} (pid {})", std::process::id());
    write_ready();

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

    let mut consumers_started = false;

let mut brain = connect_retry(&name, BRAIN_CONNECT_TRIES)?;

+    // Shell wake-watchers (restoration D2-2): the offline half of the shell

+    // online/offline exclusivity — a reconcile loop that runs each offline

+    // instance's `wake_command` as a supervised watcher child. Relocated from

+    // the broker process to the brain child: shellwake is brain-owned logic

+    // (ADR-0018 Q5 exception), a pure disk-reconciler that only reaches the

+    // broker over the same socket IPC for the remote-wake dial. Its boot sweep

+    // doubles as orphan cleanup, so a brain respawn re-reconciles the watcher

+    // children from disk — exactly the Q5 "re-reconciled on brain start"

+    // posture. The stop flag is held for symmetry, never raised (the watchers

+    // die with the brain process and the next brain re-sweeps them).

+    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]

+    let _ = crate::shellwake::spawn_wake_host(Arc::new(AtomicBool::new(false)));


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

        // Brain process supervisor (ADR-0018 Q2/Q3 — broker/brain process

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

        // Brain process supervisor (ADR-0018 Q2/Q3 — broker/brain process


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

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

        // Brain process supervisor (ADR-0018 Q2/Q3 — broker/brain process

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

        // Brain process supervisor (ADR-0018 Q2/Q3 — broker/brain process

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

-            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));

-            let _ = crate::shellwake::spawn_wake_host(stop);

+        // Shell wake-watchers (M5-D4b) — the offline half of the shell

+        // online/offline exclusivity — now run in the BRAIN child (restoration

+        // D2-2): shellwake is brain-owned logic (ADR-0018 Q5 exception), a pure

+        // disk-reconciler whose boot sweep re-reconciles the watcher children on

+        // every brain start. The broker no longer hosts it; see

+        // [`crate::brainproc::run_brain`].

+        // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]

// restoration, D1 skeleton): the broker is the always-up per-machine


Build to verify the shellwake move compiles.


Bash

cargo build -p spt-daemon 2>&1 | tail -8

Build after shellwake move


   Compiling spt-daemon v0.3.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.11s


Compiles. Now correct the ledger to reflect the honest finding.


Edit

  **Live ledger** (full task detail: `RESTORATION-D2-PLAN.md`):

  | # | Loop / consumer | Verb needed | Runs-where now | Status |
  |---|---|---|---|---|
  | D2-1 | Net consumers — inbound dispatcher (`dispatch.rs`) + outbound peer pump (`peerloop.rs`) | **none** — already pure IPC clients (`Brain::cold_start` over `broker_socket_name()`); gated on the existing `net-status` verb | **brain** ✅ | DONE |
  | D2-2 | Digest **parse** feed (`brain.run_digest_feed`) — the per-session projection that populates the hub | existing `subscribe`/output stream; the parse is already brain-side logic, only its *spawn site* (broker boot) moves | broker | TODO |
  | D2-3 | Shellwake reconcile (`shellwake::spawn_wake_host`) | **stays brain-owned** by exception (Q5): watcher children re-reconciled from disk on brain start | broker | TODO (move spawn) |
  | D2-4 | Psyche / pulse loops (`lifecycle.rs`) | per-loop verbs, additive (KH-2.3) | broker | TODO |
  | — | Net **bring-up** + `net_retry_attach` self-heal (REQ-DAEMON-9) | n/a | **broker** (stays — Q5, NetHost is broker-owned) | KEEP |
  | — | Digest **hub serve** (`digesthub::serve_digest_control`) | n/a | **broker** (stays — Q5, live projections + subscriber sockets) | KEEP |
  | — | Seed-control lock + liveness (`seedmap::serve_seed_control`) | n/a | **broker** (stays — Q2 anchor; D3) | KEEP |

  **D2-1 N-1 note (no-verb migration).** The consumers spoke pure IPC already;
  this commit only relocates their *spawn site* (broker process → brain child).
  No verb signature changed, so the verb-surface N-1 window is untouched. The
  one transient hazard — an old broker (pre-D2-1, spawns consumers in-process)
  paired with a new brain (also spawns them) → **double-spawn** — cannot occur:
  broker and brain are the same binary and swap together until D3 introduces
  brain-only swaps, by which point both halves are post-migration. Recorded so
  D3's first brain-only swap lands on an already-relocated consumer set.

  **Live ledger** (full task detail: `RESTORATION-D2-PLAN.md`).

  *Scope correction (D2-2 investigation, 2026-06-09).* An audit of what
  actually runs in the broker process (`Daemon::run`) found the migration list
  is **shorter than the design's loop inventory suggested**: only two logic
  loops are genuinely broker-resident in the production spt-core daemon. The
  rest of the design's list (digest parse, psyche/pulse) was never wired into
  this daemon — they are unwired primitives / legacy-listener-owned, so there
  is nothing to *relocate*. D2 therefore = two cheap pure-IPC spawn-site moves,
  both now done.

  | # | Loop / consumer | Verb needed | Runs-where | Status |
  |---|---|---|---|---|
  | D2-1 | Net consumers — inbound dispatcher (`dispatch.rs`) + outbound peer pump (`peerloop.rs`) | **none** — already pure IPC clients (`Brain::cold_start` over `broker_socket_name()`); gated on the existing `net-status` verb | **brain** ✅ | DONE |
  | D2-2 | Shellwake reconcile (`shellwake::spawn_wake_host`) — offline-shell wake watchers | **none** — pure disk-reconciler + `Brain::cold_start` remote-wake dial; brain-owned by Q5 exception (re-reconciled from disk on brain start) | **brain** ✅ | DONE |
  | — | Net **bring-up** + `net_retry_attach` self-heal (REQ-DAEMON-9) | n/a | **broker** (stays — Q5, NetHost is broker-owned) | KEEP |
  | — | Digest **hub serve** (`digesthub::serve_digest_control`) | n/a | **broker** (stays — Q5, live projections + subscriber sockets) | KEEP |
  | — | Seed-control lock + liveness (`seedmap::serve_seed_control`) | n/a | **broker** (stays — Q2 anchor; D3) | KEEP |
  | n/a | Digest **parse** feed (`brain.run_digest_feed`) | future new verb (brain→broker hub publish over IPC) | **neither** — unwired primitive, no production spawn site; broker's hub is empty in prod | NOT-RESIDENT (future wiring, not a D2 relocation) |
  | n/a | Psyche / pulse loops (`lifecycle.rs`) | n/a | **legacy listener** — not hosted in the spt-core daemon yet (arrives with the live-agent adapter) | NOT-RESIDENT |

  **D2-close invariant (reframed, satisfied):** every logic loop *that actually
  runs in the broker process* now runs in the brain; the broker holds only
  continuity resources (NetHost / digest hub serve / seed-control / PTY masters
  / `EffectJournal`) + the verb server. The digest-parse → hub cross-process
  publish (a new additive verb) and the daemon-hosted psyche loops are
  **forward** wiring for the live-agent buildout, not relocations of D1-left
  loops — tracked there, out of D2.

  **N-1 note (no-verb migrations).** Both D2-1 and D2-2 only relocate a *spawn
  site*; no verb signature changed, so the verb-surface N-1 window is untouched
  (V6's CI compat test has nothing new to assert yet — it stands up against the
  existing verb set). The one transient hazard — an old broker that still
  spawns these loops in-process paired with a new brain that also spawns them →
  **double-spawn** — cannot occur: broker and brain are the same binary and swap
  together until D3 introduces brain-only swaps, by which point both halves are
  post-migration. Recorded so D3's first brain-only swap lands on an
  already-relocated loop set.