Understood — regression, not ok, correcting it. Mapping the blast radius now. Reading the rest of `run()` to enumerate every spawned component:


259	            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
260	            None
261	        }
262	    }
263	}
264
265	/// Spawn the net consumers — the inbound dispatcher + the outbound peer pump
266	/// (D9-1) — against the now-net-attached broker. Called once at boot (net up
267	/// immediately) OR once from the self-heal retry when net attaches later
268	/// (REQ-DAEMON-9). Self-contained: mints its own registry + stop flag.
269	// [impl->REQ-DAEMON-9]
270	fn spawn_net_consumers(node_hex: &str) {
271	    let registry = Arc::new(crate::registryhost::RegistryHost::new(node_hex));
272	    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
273	    let _ = crate::dispatch::spawn_dispatcher(
274	        broker_socket_name(),
275	        Arc::clone(&registry),
276	        Arc::clone(&stop),
277	    );
278	    let cfg = DaemonConfig::load();
279	    if cfg.peer_pump {
280	        let _ = crate::peerloop::spawn_peer_pump(broker_socket_name(), registry, cfg, stop);
281	    }
282	}
283
284	/// Background net self-heal supervisor (REQ-DAEMON-9): a daemon that came up
285	/// net-less (NET_BIND_FAIL at boot — typically the autostart racing the network
286	/// up) keeps retrying `try_start_net` with capped backoff and, on success,
287	/// ATTACHES the host to the live broker (`OnceLock`, so exactly once) and spawns
288	/// the net consumers. The node becomes reachable without a manual restart. Runs
289	/// until net binds — the network always arrives eventually after boot.
290	// [impl->REQ-DAEMON-9]
291	fn net_retry_attach(broker: Arc<Broker>, node_hex: String) {
292	    let mut backoff = NET_RETRY_FIRST;
293	    loop {
294	        thread::sleep(backoff);
295	        if let Some(host) = try_start_net() {
296	            if broker.attach_net(host) {
297	                eprintln!("NET_ATTACHED: net endpoint bound on retry — peer pump starting");
298	                spawn_net_consumers(&node_hex);
299	            }
300	            return;
301	        }
302	        backoff = net_retry_backoff(backoff);
303	        eprintln!("NET_BIND_RETRY: net still unavailable, retrying in {}s", backoff.as_secs());
304	    }
305	}
306
307	const NET_RETRY_FIRST: Duration = Duration::from_secs(1);
308	const NET_RETRY_CAP: Duration = Duration::from_secs(30);
309
310	/// The net self-heal backoff ladder (REQ-DAEMON-9): double each retry, capped
311	/// at [`NET_RETRY_CAP`]. Pure for tests.
312	fn net_retry_backoff(cur: Duration) -> Duration {
313	    (cur * 2).min(NET_RETRY_CAP)
314	}
315
316	/// Whether a daemon is already serving the seed-control channel for this home.
317	pub fn is_running() -> bool {
318	    seedmap::ping(&seed_socket_name()).is_ok()
319	}
320
321	/// Gracefully stop the daemon serving this home (`spt daemon stop`, M8
322	/// decision 5 / REQ-CLI-2): the seed-control stop op — acked, then the
323	/// daemon's foreground loop returns and the process exits cleanly.
324	// [impl->REQ-CLI-2]
325	pub fn request_stop() -> io::Result<()> {
326	    seedmap::request_stop(&seed_socket_name())
327	}
328
329	/// Block (bounded) until a daemon answers the seed-control channel, or time
330	/// out. Shared by every start path (`ensure_running`, `start_daemon`):
331	/// race-tolerant — if two callers spawn at once, one wins the bind and this
332	/// passes either way.
333	fn wait_until_up() -> io::Result<()> {

            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
            None
        }
    }
}

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

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

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

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

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

/// Block (bounded) until a daemon answers the seed-control channel, or time
/// out. Shared by every start path (`ensure_running`, `start_daemon`):
/// race-tolerant — if two callers spawn at once, one wins the bind and this
/// passes either way.
fn wait_until_up() -> io::Result<()> {


it's wholly unintended drift. this is NOT ok. the model should have never changed from the original spec. we're correcting a regression. no, i have no recollection of in-process collapse being deliber…


Release publish process and signing procedure


## Blast-radius map — `spt daemon run` components

Every item below runs as a **thread in one process today.** Bucketed by where it must land once the split is restored (ownership per ADR-0004 §B: *broker-owned iff a live consumer loses continuity on brain restart*).

| Component (`run()` site) | Today | Must land | Notes |
|---|---|---|---|
| `Broker::bind_in_with_net` — PTY masters, harness children, client sockets | thread | **BROKER** | the core un-transferable resources (§B, spiked) |
| NetHost — Iroh endpoint + QUIC conns/streams + effect journal | thread *in* broker | **BROKER** | §B row, "implemented M4-D4a" — already lives in broker struct, good |
| `dispatch::spawn_dispatcher` — inbound net routing | thread | **BRAIN** | pure logic |
| `peerloop::spawn_peer_pump` — outbound pump, update offers | thread | **BRAIN** | logic + update orchestration |
| `RegistryHost::new` — registry state | thread (`Arc`) | **BRAIN** | §B says "rehydrate from disk" — see risk #3 |
| pulse/psyche loops | *not in `run()`* | **BRAIN** | §B lists them; need to locate (per-session? dispatch?) |
| `daemon_pid_path` write + boot sweeps + attachment reset | inline | **BRAIN**(setup) / breadcrumb | one-time |

### Hard cases — components that straddle the boundary

1. **Net self-heal (`net_retry_attach`)** — boot/brain code calls `broker.attach_net(host)` on the in-process `Arc`. But the NetHost is *broker-owned*. Net bind + the REQ-DAEMON-9 retry must move **into the broker process**; the brain must not own net bring-up.
2. **Digest hub** — the live projections + the `digest`-control subscribers need continuity → **broker-side**; the per-session **parse that feeds it is logic** → brain-side. Split: brain parses → pushes to a broker-held hub over IPC; broker serves subscribers. Today both are one `Arc<DigestHub>`.
3. **Shellwake (`spawn_wake_host`)** — the supervised `wake_command` **watcher child processes** must not be orphaned/reaped on a brain restart → **broker-side**; the reconcile *decision loop* is **brain-side**. Today tangled.
4. **Seed-control (`serve_seed_control`, the foreground loop)** — this is the **single-daemon-per-home lock + liveness anchor** (`is_running`/`ensure_running` ping it). If it lives in the brain, every brain restart drops the lock → a second daemon can win the bind in the gap, and `is_running` flaps. **Must anchor in the persistent process.**

### Cross-cutting risks (not per-component)

5. **In-process `Arc` → IPC surface gap.** Brain↔broker is *partly* versioned IPC already (`msg.rs`: SPAWN/INPUT/NET_*/SESSIONS…), but the management touchpoints — `attach_net`, digest-hub population, shellwake child supervision, seed-lock — are **in-process method calls with no IPC verb yet.** Restoring the split means giving each an IPC frame. This is the bulk of the work.
6. **Loop-timing continuity (your explicit ask).** `BrainState` carries only per-session resume cursors (`session_id`, `next_seq`, `generation`) — **not** resident-loop schedules (next pump tick, psyche-period phase). On restart the loops start fresh. To keep timings consistent you must either snapshot loop schedules into the handoff state, or make every loop catch-up/idempotent so a fresh start is harmless. Real decision, coming up.
7. **Registry rehydrate-from-disk** may itself be unrealized — `RegistryHost::new(node_hex)` looks like a fresh in-memory build each boot. If so, a brain restart loses registry state *regardless* of the split — a secondary drift to verify.
8. **Multi-session handoff.** `Brain::handoff` is **single-session** (one `session_id`). The real brain attaches N sessions + runs resident loops. The restore needs an all-sessions re-handoff, not the one-session primitive the spikes used.

I have not yet located the pulse/psyche loops (risk #6/#7) or confirmed registry persistence — flag for next.

---

This is a regression against an accepted ADR, so it also needs a `REQ-*` + KNOWN-HAZARD entry before code (per the traceability contract) — I'll line that up once the design settles.

**Q2 — Supervision topology: who is the long-lived parent, and who holds the seed lock?** (Hard case #4 forces this, and it shapes everything else.)

- **A. Broker is the stable anchor + parent.** Broker process binds the seed lock + liveness, spawns the brain as its child, and on update re-spawns the brain with the new binary. `ensure_running` pings the broker. Matches "broker never restarts / one supervisor."
- **B. Thin supervisor parent spawns both** broker and brain; holds the lock; restarts the brain.
- **C. Brain is parent, spawns broker.** Rejected — brain restart would orphan/kill its broker child.

**My recommendation: A.** The broker is *already* defined as the never-restarting, always-up layer with "one supervisor" intent (ADR-0004:34). Making it the seed-lock owner + brain-spawner means the single-instance invariant and liveness survive brain restarts for free, and the update path is simply "broker re-execs its brain child." B adds a third process for no invariant the broker can't already hold; C is incoherent.

Want me to locate the pulse/psyche loops + confirm whether `RegistryHost` persists (risks #6/#7) before we move to the loop-timing decision — or settle Q2 first?


i was always under the impression that there would be one broker per endpoint. is that not the case? that's how the model existed in my head up to this point. and if it IS that way, then it oesn't make sense for seed lock + liveness to live in something that only exists if an endpoint is online. --- yes, locate pulse/psyche loops, confirm RegistryHost


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


Important — that's a model mismatch worth nailing down, because if you're right it inverts Q2. Let me verify against the code, and run the two explorations you asked for.


Grep

(?i)fn broker_socket_name|fn seed_socket_name|per-home|per-machine|one broker|broker per


1://! Canonical per-machine daemon endpoint names + pid path (B6).
8://! per-home daemon. The auto-start spawn inherits `SPT_HOME`, so the spawned
32:pub fn seed_socket_name() -> String {
37:pub fn broker_socket_name() -> String {


Found 12 files
crates\spt-daemon\src\sync.rs
crates\spt-daemon\src\resting.rs
crates\spt-daemon\src\relay.rs
crates\spt-daemon\src\psyrelay.rs
crates\spt-daemon\src\reconcile.rs
crates\spt-daemon\src\peerloop.rs
crates\spt-daemon\src\notif.rs
crates\spt-daemon\src\lifecycle.rs
crates\spt-daemon\src\lib.rs
crates\spt-daemon\src\dispatch.rs
crates\spt-daemon\src\config.rs
crates\spt-daemon\src\attach.rs


1	//! The daemon's live subnet-registry holder (D9-1-2) — REQ-INST-7's
2	//! production wiring.
3	//!
4	//! D3 built the registry **model** (`spt_net::net::registry`: the epoch
5	//! lease, the join-time collision check, visibility enforcement) and D4d the
6	//! wire record (`RegistryUpdate`), both consumed only by tests. This type is
7	//! the daemon-side instance: one in-memory `SubnetRegistry` per member
8	//! subnet, fed from two directions —
9	//!
10	//! - **inbound**: the dispatcher's registry arm applies peer feeds under the
11	//!   same fail-closed gate as every replicated store (member subnet ∧ origin
12	//!   trusted *in that subnet*, subject = handshake-proven stream origin —
13	//!   KNOWN-HAZARDS 7.5);
14	//! - **local**: [`advertise_local`](RegistryHost::advertise_local) scans the
15	//!   owlery and (re-)advertises every visible self perch with a fresh
16	//!   [`EpochSource`] epoch — the per-node monotonic lease key, never
17	//!   wall-clock (KNOWN-HAZARDS 4.8).
18	//!
19	//! The registry is **eventually consistent by re-advertisement**: state is
20	//! in-memory (a daemon restart forgets peers' rows), and each pump tick
21	//! re-pushes the node's own rows — peers reconverge within one cadence, the
22	//! same replay-is-recovery posture as the D8 notif feed. For out-of-process
23	//! consumers (`spt send`'s WAN resolution, D9-1-4) the host mirrors each
24	//! subnet's registry to an atomic JSON snapshot under
25	//! `identity/registry/` — readers tolerate staleness (the 4.3
26	//! stale-clean posture), never block on the daemon.
27	// [impl->REQ-INST-7]
28
29	use std::collections::BTreeMap;
30	use std::io;
31	use std::path::{Path, PathBuf};
32	use std::sync::Mutex;
33
34	use spt_net::net::registry::{
35	    advertise_if_visible, Instance, MergeOutcome, Status, SubnetRegistry,
36	};
37	use spt_net::net::replicate::{NodeLabelUpdate, RegistryFeedRecord, RegistryUpdate};
38	use spt_store::epoch::EpochSource;
39	use spt_store::liveness::is_perch_alive;
40	use spt_store::perch::{self, ParentHint};
41	use spt_store::roster::RosterStore;
42	use spt_store::subnet::SubnetStore;
43	use spt_store::visibility::VisibilityStore;
44
45	/// What the host did with one inbound registry record — loud + typed.
46	#[derive(Debug, Clone, PartialEq, Eq)]
47	pub enum RegistryApplyVerdict {
48	    /// Admitted and merged with the stated outcome (the lease may still have
49	    /// dropped it as [`MergeOutcome::Stale`] — that IS a merge decision).
50	    Merged(MergeOutcome),
51	    /// Refused: this node is not a member of the record's subnet.
52	    NotMember,
53	    /// Refused: the origin is not trusted in the record's subnet.
54	    UntrustedOrigin,
55	}
56
57	/// The inbound gate's config — reloaded per feed (config edits take effect
58	/// without a restart; the access-gate stance shared with sync/notif).
59	pub struct RegistryGatePolicy {
60	    /// Names of every subnet this node is a member of.
61	    pub subnets: Vec<String>,
62	    /// The membership directory (Mesh-D5, REQ-MESH-5): authorization is now
63	    /// live roster membership (roster-listed ∧ ¬tombstoned), not a stored
64	    /// pairwise pin. A non-member can't even connect (D2 seed-proof drops it),
65	    /// so this only ever runs against a peer that already proved seed knowledge.
66	    pub roster: RosterStore,
67	}
68
69	impl RegistryGatePolicy {
70	    /// Load from the node's canonical stores.

//! The daemon's live subnet-registry holder (D9-1-2) — REQ-INST-7's
//! production wiring.
//!
//! D3 built the registry **model** (`spt_net::net::registry`: the epoch
//! lease, the join-time collision check, visibility enforcement) and D4d the
//! wire record (`RegistryUpdate`), both consumed only by tests. This type is
//! the daemon-side instance: one in-memory `SubnetRegistry` per member
//! subnet, fed from two directions —
//!
//! - **inbound**: the dispatcher's registry arm applies peer feeds under the
//!   same fail-closed gate as every replicated store (member subnet ∧ origin
//!   trusted *in that subnet*, subject = handshake-proven stream origin —
//!   KNOWN-HAZARDS 7.5);
//! - **local**: [`advertise_local`](RegistryHost::advertise_local) scans the
//!   owlery and (re-)advertises every visible self perch with a fresh
//!   [`EpochSource`] epoch — the per-node monotonic lease key, never
//!   wall-clock (KNOWN-HAZARDS 4.8).
//!
//! The registry is **eventually consistent by re-advertisement**: state is
//! in-memory (a daemon restart forgets peers' rows), and each pump tick
//! re-pushes the node's own rows — peers reconverge within one cadence, the
//! same replay-is-recovery posture as the D8 notif feed. For out-of-process
//! consumers (`spt send`'s WAN resolution, D9-1-4) the host mirrors each
//! subnet's registry to an atomic JSON snapshot under
//! `identity/registry/` — readers tolerate staleness (the 4.3
//! stale-clean posture), never block on the daemon.
// [impl->REQ-INST-7]

use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use spt_net::net::registry::{
    advertise_if_visible, Instance, MergeOutcome, Status, SubnetRegistry,
};
use spt_net::net::replicate::{NodeLabelUpdate, RegistryFeedRecord, RegistryUpdate};
use spt_store::epoch::EpochSource;
use spt_store::liveness::is_perch_alive;
use spt_store::perch::{self, ParentHint};
use spt_store::roster::RosterStore;
use spt_store::subnet::SubnetStore;
use spt_store::visibility::VisibilityStore;

/// What the host did with one inbound registry record — loud + typed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistryApplyVerdict {
    /// Admitted and merged with the stated outcome (the lease may still have
    /// dropped it as [`MergeOutcome::Stale`] — that IS a merge decision).
    Merged(MergeOutcome),
    /// Refused: this node is not a member of the record's subnet.
    NotMember,
    /// Refused: the origin is not trusted in the record's subnet.
    UntrustedOrigin,
}

/// The inbound gate's config — reloaded per feed (config edits take effect
/// without a restart; the access-gate stance shared with sync/notif).
pub struct RegistryGatePolicy {
    /// Names of every subnet this node is a member of.
    pub subnets: Vec<String>,
    /// The membership directory (Mesh-D5, REQ-MESH-5): authorization is now
    /// live roster membership (roster-listed ∧ ¬tombstoned), not a stored
    /// pairwise pin. A non-member can't even connect (D2 seed-proof drops it),
    /// so this only ever runs against a peer that already proved seed knowledge.
    pub roster: RosterStore,
}

impl RegistryGatePolicy {
    /// Load from the node's canonical stores.


i was always under the impression that there would be one broker per endpoint. is that not the case? that's how the model existed in my head up to this point. and if it IS that way, then it oesn't mak…


Code is decisive on the model question. Let me show you, then reconcile.

**One broker per *machine*, not per endpoint:**
- `endpoint.rs:1` — *"Canonical **per-machine** daemon endpoint names"*; `:8` *"**per-home** daemon."* `broker_socket_name()` resolves to one canonical name per `SPT_HOME`.
- One broker hosts **many** endpoints: `Brain::sessions()` returns a *list*, `broker.session_count()`, N PTY children under one broker.
- The bare-daemon case (`daemon.rs:196`): *"a bare daemon serves the channel and answers 'no live digest' until a session is hosted"* — the broker is up with **zero** endpoints online.

So the broker is the **always-up, per-machine kernel** — it exists whenever the daemon runs, independent of any endpoint. Your worry ("seed lock shouldn't live in something that only exists if an endpoint is online") was exactly right *as a principle* — it just doesn't bite here, because the thing isn't per-endpoint. The broker **is** the per-machine always-present layer, so it's the correct seat for the seed lock + liveness. Q2→A holds, and your concern actually *reinforces* it: the broker is the one component guaranteed present whenever the machine's daemon is alive.

That you held "one-per-endpoint" up to now is a glossary gap worth closing. CONTEXT.md:36 only says "one per-machine spt-daemon; the broker is internal" — it never states broker *cardinality* or that one broker hosts N endpoints. Proposed sharpening (glossary-only, no impl):


30	The single always-on, one-per-machine logical supervisor. Owns the PTYs for all hosted sessions, the node's network identity + WAN endpoint, the subnet registry, all spools, **all poll-listener logic, and all Psyche/pulse loops** — everything is consolidated here (no separate poll-listener or Psyche-wrapper processes; listeners already touch sessions directly under capsule/idle, and Psyche wrappers already invoke harness binaries directly, so they belong in the one supervisor). Collapses what the sister project planned as a separate `spt-node` daemon into one process — see Networking. The `spt-node` separate-deliverable concept is retired.
31
32	Internally the logical daemon is split into two implementation layers for seamless self-update (see Self-update):
33	- **broker** (stable "kernel") — holds *only* the un-transferable, must-not-die resources: PTY master fds, the spawned harness child processes, and listening network sockets. Minimal, dumb, versioned local IPC. Almost never updates.
34	- **daemon brain** ("userspace") — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely on update; rehydrates from disk state and re-attaches to the broker's held handles.
35
36	Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is an internal layer, not separately addressable.
37
38	**in-session relay**:
39	A thin, stateless `spt.exe` task that exists only in **harness-hosted** sessions (where the agent harness is the parent process and spt cannot reach into its process tree — today's Monitor model). It streams the daemon brain's events into the session's stdout. All *stateful* listener logic lives in the daemon; the relay is a dumb pipe, freely killable and respawnable. **spt-hosted** sessions need no relay — the daemon injects events straight into the PTY.
40
41	### Deliverable shape

The single always-on, one-per-machine logical supervisor. Owns the PTYs for all hosted sessions, the node's network identity + WAN endpoint, the subnet registry, all spools, **all poll-listener logic, and all Psyche/pulse loops** — everything is consolidated here (no separate poll-listener or Psyche-wrapper processes; listeners already touch sessions directly under capsule/idle, and Psyche wrappers already invoke harness binaries directly, so they belong in the one supervisor). Collapses what the sister project planned as a separate `spt-node` daemon into one process — see Networking. The `spt-node` separate-deliverable concept is retired.

Internally the logical daemon is split into two implementation layers for seamless self-update (see Self-update):
- **broker** (stable "kernel") — holds *only* the un-transferable, must-not-die resources: PTY master fds, the spawned harness child processes, and listening network sockets. Minimal, dumb, versioned local IPC. Almost never updates.
- **daemon brain** ("userspace") — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely on update; rehydrates from disk state and re-attaches to the broker's held handles.

Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is an internal layer, not separately addressable.

**in-session relay**:
A thin, stateless `spt.exe` task that exists only in **harness-hosted** sessions (where the agent harness is the parent process and spt cannot reach into its process tree — today's Monitor model). It streams the daemon brain's events into the session's stdout. All *stateful* listener logic lives in the daemon; the relay is a dumb pipe, freely killable and respawnable. **spt-hosted** sessions need no relay — the daemon injects events straight into the PTY.

### Deliverable shape


CONTEXT.md:30 actually *does* say "one-per-machine … owns the PTYs for **all** hosted sessions," and :33 lists "the spawned harness child **processes**" (plural) — so the spec was one-broker-per-machine all along. But it never says it *loudly* enough to dislodge a "per-endpoint" reading. Closing that glossary gap:


Edit

Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is an internal layer, not separately addressable.

Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is an internal layer, not separately addressable. There is exactly **one broker per machine** (per `SPT_HOME`) — *not* one per endpoint: a single broker holds every hosted endpoint's resources, and it is present whenever the daemon runs, even with zero endpoints online (the bare-daemon case). It is therefore the always-present per-machine layer, which is why the single-daemon lock + liveness anchor belong to it.


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

- **broker** (stable "kernel") — holds *only* the un-transferable, must-not-die resources: PTY master fds, the spawned harness child processes, and listening network sockets. Minimal, dumb, versioned local IPC. Almost never updates.

- **daemon brain** ("userspace") — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely on update; rehydrates from disk state and re-attaches to the broker's held handles.

-Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is an internal layer, not separately addressable.

+Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is an internal layer, not separately addressable. There is exactly **one broker per machine** (per `SPT_HOME`) — *not* one per endpoint: a single broker holds every hosted endpoint's resources, and it is present whenever the daemon runs, even with zero endpoints online (the bare-daemon case). It is therefore the always-present per-machine layer, which is why the single-daemon lock + liveness anchor belong to it.

A thin, stateless `spt.exe` task that exists only in **harness-hosted** sessions (where the agent harness is the parent process and spt cannot reach into its process tree — today's Monitor model). It streams the daemon brain's events into the session's stdout. All *stateful* listener logic lives in the daemon; the relay is a dumb pipe, freely killable and respawnable. **spt-hosted** sessions need no relay — the daemon injects events straight into the PTY.


Done — CONTEXT.md cardinality clause added.

**Exploration results you asked for:**

**Pulse/psyche loops — brain-side, distributed.** They're not in `run()`; they live across `psyrelay.rs`, `reconcile.rs`, `lifecycle.rs`, `dispatch.rs`, `peerloop.rs`, `resting.rs`, `sync.rs`, `notif.rs`, `attach.rs` — spawned off the dispatch/pump path, not the boot path. All brain-side logic per §B. (Exact spawn-site enumeration is an implementation-time follow-up; for the model it's settled: brain.)

**RegistryHost — not drift, by design.** `registryhost.rs:19-26`: state is *intentionally* in-memory — *"a daemon restart forgets peers' rows, and each pump tick re-pushes the node's own rows — peers reconverge within one cadence"* (replay-is-recovery, same posture as the notif feed). It mirrors each subnet to atomic JSON under `identity/registry/` for out-of-process readers (`spt send`'s WAN resolution). So risk #7 dissolves: across a brain restart there's a **one-cadence reconverge window** for peer rows (self-rows re-advertised each tick) — acceptable, not a blocker. No rehydrate-from-disk needed.

**Q2 resolved → A.** Model corrected (one broker per machine, always-present), so the broker is the seat for the seed lock + liveness. Your per-endpoint instinct was sound; it just doesn't apply.

---

**Q3 — The restart trigger: how does `apply` actually get new code running?**

Now that the broker is the supervising parent (Q2-A), the update flow needs a trigger. Three shapes:

- **A. Broker-cycles-brain on command.** `apply` verifies + swaps the binary on disk, then sends the broker an IPC "cycle brain." Broker drops the current brain, spawns the new binary, new brain re-attaches via `handoff`.
- **B. Brain snapshots + self-exits; broker auto-respawns from disk.** `apply` swaps the binary, signals the brain to snapshot + exit. The broker — already the brain's supervisor — sees the child exit and respawns from the executable path (now the new binary). New brain re-attaches via `handoff`.
- **C. CLI spawns the new brain directly.** Rejected — parentage wrong (brain would be a child of the transient `spt update apply` process, dies when it returns).

**My recommendation: B.** It reuses primitives that already exist. `apply_brain_only` already models exactly this: *"snapshot the outgoing brain, drop it — the exact crash boundary the broker already tolerates — re-attach via `Brain::handoff`."* The broker must already supervise+respawn the brain for crash recovery; an update is just a *planned* snapshot+exit that the existing respawn path handles, and "respawn from the executable path" naturally picks up the swapped binary (which is literally what `applyhost.rs:37` says happens at "next restart" — we're just triggering that restart immediately instead of waiting for logon). A is nearly identical but puts cycle logic in the broker (more code in the layer we want to keep dumb/stable); B keeps the broker's job as the dumb "supervise + respawn on exit" it already needs.

This sets up Q4 (your loop-timing-continuity ask) directly: under B the loops *do* restart, so the question becomes what schedule state rides the snapshot. Want to lock B and move to loop-timing, or push on the trigger first?


lock B, move to loop-timing


Locked B. For loop-timing, I need to see how the resident loops actually keep time today — deadline-based (survives restart) vs relative-sleep (phase resets). Inspecting:


4://! One daemon-hosted loop drives three cadenced pumps against every roster
10://!   one cadence (replay-is-recovery, the D4d/D8 push posture). Mesh-D6 widened
73:/// The pump's tick granularity — each cadence fires when its period elapsed.
74:const TICK: Duration = Duration::from_millis(200);
77:/// warning reads 3-cadence staleness, so a few seconds of heartbeat
79:const HEARTBEAT_PERIOD: Duration = Duration::from_secs(5);
82:const SUPERVISE_BACKOFF_BASE: Duration = Duration::from_secs(5);
86:const SUPERVISE_BACKOFF_CAP: Duration = Duration::from_secs(300);
91:const SUPERVISE_HEALTHY_RUN: Duration = Duration::from_secs(60);
132:    /// sync round NOW instead of waiting out `cadence.sync`
138:    /// `cadence.registry` ([`crate::registryhost::ADVERTISE_MARKER_FILE`]).
144:    /// The pump's last-tick heartbeat (M8 decision 23, REQ-DAEMON-5):
151:    /// registry tick ([`spt_store::rotation::RotationPending`]).
211:            return; // keep the pending entry — retry next tick rather than lose it
217:/// Pump cadences (config-driven in production: [`DaemonConfig`]).
219:pub struct PumpCadence {
225:    /// registry tick, before re-advertisement.
229:impl PumpCadence {
231:    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
232:        PumpCadence {
233:            registry: cfg.registry_pump_period,
234:            notif: cfg.notif_pump_period,
235:            sync: cfg.sync_pull_period,
236:            update: cfg.update_check_period,
242:/// The pump's resolved knobs: cadences + the REQ-UPD-4 consent stance.
245:    pub cadence: PumpCadence,
255:            cadence: PumpCadence::from_config(cfg),
286:/// row; the next tick redials — conns are rebuilt freely, nothing leaks
298:        cadence,
304:    // Stagger from "everything due now" — the first tick primes peers.

[Showing results with pagination = limit: 30]


crates\spt-daemon\src\notif.rs:1024:            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
crates\spt-daemon\src\notif.rs:1035:                std::thread::sleep(std::time::Duration::from_millis(50));
crates\spt-daemon\src\lifecycle.rs:484:/// Sleep `period`, waking early if `stop` is set (10ms slices). Keeps the pulse
crates\spt-daemon\src\lifecycle.rs:642:                pulse_period: Duration::from_millis(15),
crates\spt-daemon\src\lifecycle.rs:646:            assert_eq!(host.pulse_period(), Duration::from_millis(15));
crates\spt-daemon\src\lifecycle.rs:893:                pulse_period: Duration::from_millis(10),
crates\spt-daemon\src\lifecycle.rs:925:                pulse_period: Duration::from_millis(10),


Pump timing is clear; let me confirm the two contrasting patterns — the wall-clock-anchored one (alarms) and the phase-relative one (pulse) — to ground the recommendation:


478	            sleep_interruptible(self.cfg.pulse_period, stop);
479	        }
480	        None
481	    }
482	}
483
484	/// Sleep `period`, waking early if `stop` is set (10ms slices). Keeps the pulse
485	/// loop responsive to a kill without busy-spinning.
486	fn sleep_interruptible(period: Duration, stop: &AtomicBool) {
487	    const SLICE: Duration = Duration::from_millis(10);
488	    let mut left = period;
489	    while left > Duration::ZERO {
490	        if stop.load(Ordering::Relaxed) {
491	            return;
492	        }
493	        let nap = left.min(SLICE);
494	        std::thread::sleep(nap);
495	        left = left.saturating_sub(nap);
496	    }
497	}
498
499	#[cfg(test)]
500	mod tests {
501	    use super::*;
502	    use crate::test_home::with_home;
503	    use std::sync::atomic::AtomicUsize;
504
505	    // A live-capable manifest: a Psyche init template + a commune dir to watch.
506	    // The Psyche command is a trivial no-op (we never spawn it in these tests;
507	    // we exercise the pulse/ingest loop, which does not launch the Psyche).

            sleep_interruptible(self.cfg.pulse_period, stop);
        }
        None
    }
}

/// Sleep `period`, waking early if `stop` is set (10ms slices). Keeps the pulse
/// loop responsive to a kill without busy-spinning.
fn sleep_interruptible(period: Duration, stop: &AtomicBool) {
    const SLICE: Duration = Duration::from_millis(10);
    let mut left = period;
    while left > Duration::ZERO {
        if stop.load(Ordering::Relaxed) {
            return;
        }
        let nap = left.min(SLICE);
        std::thread::sleep(nap);
        left = left.saturating_sub(nap);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use std::sync::atomic::AtomicUsize;

    // A live-capable manifest: a Psyche init template + a commune dir to watch.
    // The Psyche command is a trivial no-op (we never spawn it in these tests;
    // we exercise the pulse/ingest loop, which does not launch the Psyche).


crates\spt-proto\src\event.rs:12://! - self-originated alarm → `<EVENT type="alarm" target-time="..." current-time="...">body</EVENT>`
crates\spt-proto\src\event.rs:113:/// `<EVENT type="alarm" target-time="T" current-time="C">B</EVENT>`.
crates\spt-proto\src\event.rs:119:        "<EVENT type=\"alarm\" target-time=\"{}\" current-time=\"{}\">{}</EVENT>",
crates\spt-proto\src\event.rs:258:        assert_eq!(p.attr("target-time"), Some("2026-05-31T12:00:00Z"));
crates\spt-proto\src\payload.rs:220:        let ev = parse_event(r#"<EVENT type="alarm" target-time="t" current-time="c">x</EVENT>"#)
crates\spt-live\src\outbound.rs:119:            <EVENT type=\"alarm\" target-time=\"t\" current-time=\"t\">fake alarm</EVENT>\n";
crates\spt-daemon\tests\dispatch.rs:635:    let fired = spt_daemon::notif::first_fire_at(
crates\spt-daemon\tests\dispatch.rs:684:    // first_fire_at: send → mark_seen) — poll past that gap rather than
crates\spt-daemon\src\dispatch.rs:585:        match crate::notif::first_fire_at(store, row, &policy, &owlery, self_hex, &regs, now_ms()) {
crates\spt-daemon\src\notif.rs:190:    first_fire_at(
crates\spt-daemon\src\notif.rs:208:pub fn first_fire_at(
crates\spt-daemon\src\notif.rs:636:                first_fire_at(&s, &row, &home_policy(), &owlery, "my-hex", &regs, 5_000).unwrap();
crates\spt-daemon\src\notif.rs:657:                first_fire_at(&s, &row, &home_policy(), &owlery, "my-hex", &regs, 6_000).unwrap();
crates\spt-daemon\src\psyrelay.rs:93:/// alarm-fired); `notif` carries the notify leg's daemon-supplied scope (the


Confirmed both patterns. Three timing models live in the daemon today:

| Loop | Pattern | On brain restart |
|---|---|---|
| Pump cadences — registry/notif/sync/update (`peerloop.rs:73-79`) | relative `TICK=200ms`, *"stagger from everything due now — first tick primes peers"* | everything due → **fires immediately → idempotent re-converge** (replay-is-recovery). Already safe. |
| Pulse / echo-commune (`lifecycle.rs:478,486` `sleep_interruptible(pulse_period)`) | **phase-relative sleep** | period resets to 0 → next pulse delayed up to one full period. **Phase jumps.** |
| Alarms (`event.rs` `target-time` ISO-8601) | **absolute wall-clock deadline** | fires at the right moment regardless of restarts — *if* the scheduler reads the deadline from durable state (verify). |

## The key reframe on your ask

The instinct "snapshot the loop timings and carry them across the handoff" is the wrong vehicle — and here's why:

**`BrainState` (the handoff snapshot) only exists for a *planned* update.** A brain **crash** — which the broker also tolerates and respawns from (it's the *same* respawn path B uses) — gets **no snapshot**. So anything timing-critical living in the handoff message is lost on crash. Timing that must survive has to survive *crash too*, which means **disk**, not a handoff frame. Plus: in-memory monotonic `Instant` phase can't even be transferred across a process boundary meaningfully — only absolute epoch-ms is portable.

So the clean model is **durable absolute-deadline state on disk, rehydrated on every brain start** (crash or update — one path, no special-casing). This is exactly the shape alarms already use (`target-time`), and it keeps the broker dumb (timing is brain logic; brain reads its own schedule from disk).

## Recommendation — classify per loop, don't blanket-snapshot

- **Idempotent cadences (the pumps): leave as catch-up.** Fire-now-on-start is *correct*, not a bug. No phase state needed. (Optional, low-priority: record `last_fire` epoch on disk so a rapid succession of updates doesn't force a redundant immediate re-advertise each time — purely an efficiency nicety.)
- **Phase-significant loops (pulse / echo-commune, alarms): anchor to a persisted absolute `next_fire` epoch.** Convert `sleep(period)` → `next_fire = last_fire + period` persisted to disk; on start, `now >= next_fire` ⇒ fire + reschedule, else sleep until `next_fire`. Phase survives restart with no jump.

Net: **don't extend `BrainState` with loop timing at all.** Per-loop, either it's idempotent-catch-up (no state) or wall-clock-anchored-on-disk (survives crash *and* update via one mechanism).

**Q4 — Do you accept "disk-anchored absolute deadlines + per-loop classification (catch-up vs phase-significant)" over "carry loop phase in the handoff snapshot"?**

My recommendation: **yes** — it's strictly more robust (covers crash, not just update), portable across the process boundary, and matches the alarm pattern already in the tree. The only real work is auditing each resident loop and converting the phase-significant ones (pulse/echo-commune the obvious one) to disk-anchored deadlines.

One thing I'd verify before locking: whether the alarm scheduler already reads `target-time` from durable state (so it survives restart) or holds it in an in-memory timer — that tells us if alarms are already a working reference implementation or themselves need the same fix. Want me to check that, then lock Q4?


disk-anchored is ok, but write frequency needs to be minimized. store a start time + cadence intervals when the brain starts normally. if the brain then restarts for an update, it continues deriving idempotent-next-fire from those two values, rather than refreshing them. start-after-crash should simply be considered a fresh start, as far as loops go. -yes, check how alarm scheduler works today.


Got it — and that's a cleaner formulation than mine. Let me restate to lock it precisely:

**Q4 — LOCKED (your refinement):**
- On a **normal/fresh start**, the brain writes **two values once**: an `anchor` (start epoch) + the per-loop `interval`s. That's the only write.
- Every loop derives its next fire **functionally**: `next_fire = anchor + interval × ⌈max(0, now−anchor) / interval⌉`. No per-fire writes — the grid is reconstructable from `(anchor, interval, now)` alone.
- An **update restart** re-reads `(anchor, interval)` and **keeps deriving from them** — never rewrites. Phase preserved across the update (the new brain lands on the same grid, mid-period).
- A **crash restart = fresh start**: rewrite `anchor = now`. Phase resets — crash isn't the continuity case, by design.

The one thing that makes this work: the brain must know **why** it started — update-respawn (keep anchor) vs crash/fresh (reset anchor). That signal rides the broker's respawn (Q2-A + Q3-B): the broker *initiated* the cycle on update but *observed* an unexpected exit on crash, so it tells the new brain which case it is (start arg/env). Clean fit with the locked trigger. Minimal writes: one per fresh/crash start, zero per update, zero per fire.

Now checking the alarm scheduler as you asked — does it already poll a durable `target-time`, or hold an in-memory timer?


Found 6 files
crates\spt\src\cli.rs
crates\spt-proto\src\payload.rs
crates\spt-proto\src\event.rs
crates\spt-msg\src\ready.rs
crates\spt-live\src\outbound.rs
crates\spt-daemon\src\psyrelay.rs


Alarm logic spans `cli.rs`, `ready.rs`, `outbound.rs`, `psyrelay.rs` — no dedicated scheduler/store file. Let me see how it's actually scheduled and fired:


6://! chunking, alarms, handoff, Psyche) is **not** M1 — it lands in M2/M3. M1's
162:    std::time::SystemTime::now()
163:        .duration_since(std::time::UNIX_EPOCH)
176:    let nanos = std::time::SystemTime::now()
177:        .duration_since(std::time::UNIX_EPOCH)
189:        let nanos = std::time::SystemTime::now()
190:            .duration_since(std::time::UNIX_EPOCH)
247:        let h = std::thread::spawn(move || {
248:            std::thread::sleep(Duration::from_millis(30));


57:        /// Target perch id. May be omitted when --reply-to is given.
58:        target: Option<String>,
62:        /// Reply to this sender; sets the target and labels output REPLIED.
73:        /// Target perch id.
74:        target: String,
227:        /// The fork's home subnet — the target (must be a member).
417:        /// Narrower target within the node (e.g. the shell-adapter name for
620:    /// within a roster round — then schedules one seed rotation at the
624:    /// locked out and must re-pair. Each target is a pubkey hex, an
663:    /// the trailing arg, or stdin when omitted. Targets the calling
664:    /// endpoint's HOME subnet unless `--target` names another (M8
665:    /// decision 25: no resolvable home + no `--target` = refuse).
669:        /// Target subnet (defaults to the calling endpoint's home subnet).
671:        target: Option<String>,
743:            target,
747:        } => cmd_send(target, from, reply_to, deferred),
749:            target,
752:        } => cmd_ring(target, from, timeout),
775:                Some(SubnetCmd::Notify { body, target, from }) => cmd_notify(body, target, from),
892:/// `spt fork <src> <new_id> --subnet <target>` — the cross-subnet move

[Showing results with pagination = limit: 20]


disk-anchored is ok, but write frequency needs to be minimized. store a start time + cadence intervals when the brain starts normally. if the brain then restarts for an update, it continues deriving i…


80	    pub epochs: &'a mut EpochSource,
81	    /// This node's identity hex — the notif-id mint prefix.
82	    pub node_hex: &'a str,
83	    /// The agent's own subnet (the notify scope).
84	    pub subnet: &'a str,
85	    pub policy: &'a NotifSurfacePolicy,
86	    pub now_ms: u64,
87	}
88
89	/// Relay parsed Psyche intents through the sanitize boundary (see module
90	/// docs). `psyche_id` is the daemon-known identity re-stamped as `from=` on
91	/// every relayed envelope; `reply_to` is the `__REPLY_TO__` sender of the
92	/// inbound being answered (`None` for a turn with no inbound sender — e.g.
93	/// alarm-fired); `notif` carries the notify leg's daemon-supplied scope (the
94	/// agent's own subnet — the only place a notify can land).
95	// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
96	// [impl->REQ-NOTIF-1]
97	pub fn relay_psyche_outbound(
98	    psyche_id: &str,
99	    notif: &mut PsycheNotifCtx<'_>,
100	    reply_to: Option<&str>,
101	    intents: &[PsycheIntent],
102	    owlery: &Path,
103	) -> Vec<RelayOutcome> {
104	    intents
105	        .iter()
106	        .map(|intent| {
107	            let body = intent.body().trim();
108	            if body.is_empty() {
109	                return RelayOutcome::DroppedEmptyBody;
110	            }
111	            if body.len() > PSYCHE_BODY_CAP {
112	                return RelayOutcome::DroppedOversize;
113	            }
114	            match intent {
115	                PsycheIntent::Reply { .. } => match reply_to {
116	                    Some(to) if !to.trim().is_empty() => RelayOutcome::Replied {
117	                        to: to.to_string(),
118	                        delivery: send(to, psyche_id, body, owlery),
119	                    },
120	                    _ => RelayOutcome::DroppedNoReplyTarget,
121	                },
122	                PsycheIntent::Notify { .. } => {
123	                    // The D8c swap: the notify leg IS a notif producer. The
124	                    // engine authors the typed envelope (re-stamped

    pub epochs: &'a mut EpochSource,
    /// This node's identity hex — the notif-id mint prefix.
    pub node_hex: &'a str,
    /// The agent's own subnet (the notify scope).
    pub subnet: &'a str,
    pub policy: &'a NotifSurfacePolicy,
    pub now_ms: u64,
}

/// Relay parsed Psyche intents through the sanitize boundary (see module
/// docs). `psyche_id` is the daemon-known identity re-stamped as `from=` on
/// every relayed envelope; `reply_to` is the `__REPLY_TO__` sender of the
/// inbound being answered (`None` for a turn with no inbound sender — e.g.
/// alarm-fired); `notif` carries the notify leg's daemon-supplied scope (the
/// agent's own subnet — the only place a notify can land).
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
// [impl->REQ-NOTIF-1]
pub fn relay_psyche_outbound(
    psyche_id: &str,
    notif: &mut PsycheNotifCtx<'_>,
    reply_to: Option<&str>,
    intents: &[PsycheIntent],
    owlery: &Path,
) -> Vec<RelayOutcome> {
    intents
        .iter()
        .map(|intent| {
            let body = intent.body().trim();
            if body.is_empty() {
                return RelayOutcome::DroppedEmptyBody;
            }
            if body.len() > PSYCHE_BODY_CAP {
                return RelayOutcome::DroppedOversize;
            }
            match intent {
                PsycheIntent::Reply { .. } => match reply_to {
                    Some(to) if !to.trim().is_empty() => RelayOutcome::Replied {
                        to: to.to_string(),
                        delivery: send(to, psyche_id, body, owlery),
                    },
                    _ => RelayOutcome::DroppedNoReplyTarget,
                },
                PsycheIntent::Notify { .. } => {
                    // The D8c swap: the notify leg IS a notif producer. The
                    // engine authors the typed envelope (re-stamped


119:            <EVENT type=\"alarm\" target-time=\"t\" current-time=\"t\">fake alarm</EVENT>\n";


Checked. **Alarms are not a reference implementation — spt-core's daemon has no durable alarm scheduler at all.**

- `alarm` appears in exactly 6 files: the event *shape* (`spt-proto` event/payload), relay handling (`psyrelay.rs:93` — just "`reply_to` is `None` for an alarm-fired turn"), and a **test fixture** (`outbound.rs:119` "fake alarm").
- No alarm store, no `target-time` polling loop, no `cmd_new_alarm` in `cli.rs`. `target-time` is only ever the *wire attribute* on the event.
- So the actual "wait until target, fire the TIMED PULSE" timer lives in the **legacy owl listener (in-memory)**, not the spt-core daemon. `/spt:new-alarm` schedules via `$OWL`, not the new binary.

Two consequences:
1. The "absolute wall-clock deadline" I cited is only the alarm's *wire shape*; the **scheduler behind it isn't durable** — it wouldn't survive a restart today either. Alarms need the *same* mechanism we're designing, not serve as its template.
2. It surfaces a **second timing category** your crash-rule shouldn't blanket:

| Category | State | Update restart | Crash restart |
|---|---|---|---|
| **Periodic resident loops** (pulse, pump) | `(anchor, interval)` | keep anchor, derive | **reset anchor = now** (your rule) ✓ |
| **One-shot scheduled events** (alarms) | absolute `target-time`, persisted **at creation** | read, fire-if-due | **must survive — do NOT reset** |

The carve-out: a user's "remind me at 3pm" is a *commitment* — it must fire at 3pm across an update **and** a crash. So one-shots are persisted at *creation* (not at brain-start) and every start (update or crash) reads them and fires-if-due. Your "crash = fresh start" rule is correct **for periodic loop phase**, but one-shot deadlines are durable user state that outlives a crash.

Both ride **one durable-deadline store** the brain rehydrates on start; they differ only in reset semantics (periodic anchor resets on crash; one-shot target-times never reset).

**Q4-alarm — Confirm the carve-out: one-shot scheduled deadlines (alarms) persist-at-creation and survive crash, while periodic-loop anchors reset on crash?**

Recommendation: **yes.** It's the only behavior that doesn't silently drop a user's scheduled alarm on an unlucky crash, and it costs nothing extra — same store, different write-trigger (creation-time vs fresh-start-time). And note as a side-finding: porting alarms into the spt-core daemon as a durable scheduler is its own gap (today they're legacy-listener-only) — worth a tracked item, separate from this regression.


agree


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


Q4 fully locked. Spine of the design is now settled: **split restored → broker supervises/respawns brain → durable absolute-deadline timing (periodic anchor+interval, one-shot persist-at-creation)**.

Remaining branches: **Q5 IPC boundary** (the bulk of the work), **Q6 multi-session handoff**, **Q7 rollback/crashloop**, **Q8 Windows/exec uniformity**. Taking the boundary next.

**Q5 — The brain↔broker IPC boundary: do we let the §B rule adjudicate the four hard cases, with every brain→broker touch becoming a versioned IPC verb (no in-process `Arc`)?**

The ADR-0004 §B rule — *broker-owned iff a live consumer loses continuity on brain restart* — already decides each straddle:

| Hard case | Today (in-process `Arc`) | §B verdict |
|---|---|---|
| **Net bring-up / self-heal** (`attach_net`) | brain-boot code mutates broker | NetHost is broker-owned → **net bind + REQ-DAEMON-9 retry move into the broker**; brain only queries/dials over existing net IPC |
| **Digest hub** | one `Arc<DigestHub>` | subscribers lose continuity on restart → **hub (projections + subscriber sockets) = broker**; the **parse that feeds it = brain**, pushes parsed digests via IPC |
| **Shellwake** | brain holds watcher children | orphaning a watcher loses continuity → **watcher child processes = broker** (held + reaped); **reconcile decision = brain**, commands spawn/stop via IPC |
| **Seed lock + liveness** | brain foreground loop | liveness pingers (`ensure_running`) must not flap across restart → **broker** (Q2-A) |

**Consequence to accept:** this *enlarges* the "dumb kernel" — the broker now supervises wake-watcher children and serves digest projections, not just PTYs/sockets. That's already-precedented: ADR-0004:63 accepted exactly this when QUIC ownership moved into the broker — *"the broker is 'stable' in update cadence, not in narrowness."*

**My recommendation: yes — let §B adjudicate, accept the enlargement, and make every brain→broker management call a versioned IPC verb** (the same versioned-IPC contract that already carries SPAWN/INPUT/NET_*, extended with: net-attach, digest-push, watcher-supervise, seed-lock-status). The in-process `Arc<Broker>` sharing is itself an artifact of the regression — in the two-process world there is no shared `Arc`, so *every* current direct call is a latent IPC verb. The §B rule keeps "what crosses" principled instead of case-by-case.

The one judgment call inside this: **shellwake**. Putting supervised wake-watcher *children* in the broker is the biggest single enlargement of the kernel. The alternative — let them die on brain restart and have the brain re-reconcile from disk on start — keeps the broker narrower but means a brain update briefly drops every offline-wake watcher (a window where an offline shell could miss its wake). I lean **broker** (continuity, per §B + precedent), but flag it as the place where "keep the kernel narrow" most tempts a different call. Your read on shellwake specifically?


<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>


<command-name>/effort</command-name>
            <command-message>effort</command-message>
            <command-args>max</command-args>


<local-command-stdout>Kept effort level as high</local-command-stdout>


ok to keep shellwake in the brain. updates don't happen often enough for that concern to be relevant. -otherwise agree with your recs


Q5 locked, with your shellwake carve-out: **watcher children stay brain-side, re-reconciled from disk on brain start** (the boot-sweep at `daemon.rs:206-217` already does orphan cleanup, so the new brain re-establishes them for ~free). Net→broker, digest-hub→broker/parse→brain, seed-lock→broker, every other brain→broker touch becomes a versioned IPC verb, kernel-enlargement accepted. Good.

Now **Q6 — multi-session handoff**. Grounding it first, because the current handoff has two concrete gaps. Checking how N sessions are driven and where resume cursors live:


19://!   in a bounded ring. The current subscriber (a brain connection) receives
20://!   chunks live; all subscriber writes happen *under the log lock*, so frames
22://! - **Resume from a cursor.** A reconnecting brain sends `subscribe` with its
29://! brain disconnect only detaches the subscriber; the child, its PTY, and the
46:use crate::effect::{EffectJournal, EffectKind, Outcome};
50:    InputReq, KillReq, NetDialReq, NetPresenceSubscribeReq, NetSent, NetStatusReply,
51:    NetStreamOpenReq, NetStreamOpened, NetStreamSendReq, NetStreamSubscribeReq, NetStreamsReply,
53:    SubscribeReq, KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL, KIND_NET_DIAL,
73:/// A bounded, sequenced output log for one session — the gapless-resume buffer.
76:/// a brain connection attaches as the subscriber ([`OutputLog::attach`]). Every
77:/// send to the subscriber happens while the enclosing `Mutex<OutputLog>` is
82:    next_seq: u64,
85:    subscriber: Option<SharedSend>,
92:            next_seq: 0,
95:            subscriber: None,
100:    /// in the bounded ring, and live-send it to the current subscriber (if any).
103:        let seq = self.next_seq;
104:        self.next_seq += 1;
109:        if let Some(sub) = &self.subscriber {
117:    /// Attach `sub` as the subscriber and replay every retained chunk with
118:    /// `seq >= from_seq` before live streaming resumes. Replaces any prior
119:    /// subscriber (a dead brain).
120:    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
121:        self.subscriber = Some(Arc::clone(&sub));
130:    /// Detach `sub` *iff* it is the current subscriber. A no-op if a newer brain
134:        if let Some(cur) = &self.subscriber {
136:                self.subscriber = None;
143:/// session, plus its gapless-resume output log.
150:    /// The sequenced, buffered output + current subscriber.
159:/// (ADR-0004 §B). The [`EffectJournal`] is a §B resource — the durable

[Showing results with pagination = limit: 30]


crates\spt-daemon\src\daemon.rs:363:            // Prefer the manager — but if it can't be driven (no session bus on
crates\spt-daemon\src\applyhost.rs:235:        match Brain::cold_start(broker_name, now_ms()) {
crates\spt-daemon\src\applyhost.rs:239:                        if let Err(e) = b.attach(session.session_id, 0) {
crates\spt-daemon\src\applyhost.rs:263:        match Brain::handoff(broker_name, prior.clone(), now_ms()) {
crates\spt-daemon\src\applyhost.rs:303:            if let Ok(b) = Brain::cold_start(broker_name, now_ms()) {
crates\spt-daemon\src\applyhost.rs:539:        let sid = seat.spawn_session(echo_spawn_req()).expect("spawn");
crates\spt-daemon\src\applyhost.rs:581:        let sid = seat.spawn_session(echo_spawn_req()).expect("spawn");
crates\spt-daemon\src\dispatch.rs:239:        match Brain::cold_start(broker_name, now_ms()) {
crates\spt-daemon\src\broker.rs:62:/// The shared, serialized send side of one brain connection. Output frames,
crates\spt-daemon\src\broker.rs:238:    /// threading is half the KH 7.4 isolation stance: one brain's blocking
crates\spt-daemon\src\broker.rs:277:    /// Drive one brain connection: handshake, then dispatch its command frames
crates\spt-daemon\src\broker.rs:425:        log.lock().unwrap().attach(Arc::clone(send), 0);
crates\spt-daemon\src\broker.rs:498:        log.lock().unwrap().attach(Arc::clone(send), req.from_seq);
crates\spt-daemon\src\effect.rs:421:    /// One brain life: re-drive the durable source `0..total` through the
crates\spt-daemon\src\brain.rs:5://! the stable [`crate::broker::Broker`] over the versioned IPC, drives sessions
crates\spt-daemon\src\brain.rs:14://!   the new generation. [`Brain::handoff`] takes the prior state by value yet
crates\spt-daemon\src\brain.rs:61:/// **deliberately not authoritative on restart** — [`Brain::handoff`] sets a
crates\spt-daemon\src\brain.rs:219:    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
crates\spt-daemon\src\brain.rs:220:        self.spawn_session_pid(req)
crates\spt-daemon\src\brain.rs:224:    /// [`spawn_session`](Self::spawn_session) that also surfaces the child's
crates\spt-daemon\src\brain.rs:227:    pub fn spawn_session_pid(&mut self, req: SpawnReq) -> io::Result<(u64, Option<u32>)> {
crates\spt-daemon\src\brain.rs:491:    /// [`Brain::handoff`] this does not touch the generation — it is a *second*
crates\spt-daemon\src\brain.rs:530:    /// until the reply (consuming interleaved events like [`Brain::spawn_session`]).
crates\spt-daemon\src\nethost.rs:829:        log.lock().unwrap().attach(sub, from_seq);
crates\spt-daemon\src\nethost.rs:849:        self.shared.presence.lock().unwrap().attach(sub, from_seq);
crates\spt-daemon\src\notif.rs:300:        .spawn_session(NOTIF_ROLE, &notif_keys(row))
crates\spt-daemon\src\attach.rs:68:/// [`mark_driven_by`]: a session served without a perch has no resting
crates\spt-daemon\src\attach.rs:133:                            brain.attach(session_id, from_seq)?;
crates\spt-daemon\src\peerloop.rs:284:/// Run the pump until `stop` is set. Owns one brain IPC connection and a
crates\spt-daemon\src\peerloop.rs:693:        match Brain::cold_start(broker_name, now_ms()) {

[Showing results with pagination = limit: 30]


Grounded. The real model is now clear, and it sharpens Q6 considerably:

**How sessions are actually driven:** the broker holds a per-session `OutputLog` — a bounded sequenced ring + **one subscriber slot** (`broker.rs:73-143`). A brain `attach(sub, from_seq)` replays retained chunks `≥ from_seq` then live-streams; "a brain disconnect only detaches the subscriber; the child + PTY persist." The logic process opens **many** brain connections (pump owns one `peerloop.rs:284`, dispatcher one `dispatch.rs:239`, per-session drivers via `attach.rs:133`) — so a brain-process cycle drops *all* of them; the broker keeps every child, ring, net conn, and the `EffectJournal`.

**Two concrete gaps in today's handoff:**
1. `applyhost.rs:239` re-attaches only `sessions.first()` — **one** session.
2. It attaches `from_seq = 0` — **re-replays the whole ring** (duplicate output).

**The model-B implication that resolves it:** under B the outgoing brain is *gone* before the new one starts, so there's **no brain→brain handoff message** — `BrainState`-by-value can't be passed. Therefore the **broker must be the cursor-of-record.** The broker already holds the ring; it should also track each subscriber's last-delivered seq, so a respawned brain re-attaches in *resume* mode and the broker picks up where the last subscriber left off — no disk writes (respects Q4's minimize-writes), no handoff frame.

**My recommendation for Q6:**
- **Broker tracks per-session subscriber delivered-cursor.** New brain queries `sessions()` and re-attaches **all** of them in resume mode (fix the `.first()` + `from_seq=0`).
- **Output = best-effort / at-least-once.** This *matches the existing terminal-stream contract* — SPIKE-05 already established "terminal-stream ≠ exactly-once transfer" (resize repaints reorder+dup anyway). A couple of re-sent in-flight chunks on an update is within the "dumb pipe" tolerance.
- **Input/effects stay exactly-once** via the already-broker-owned `EffectJournal` (`effect.rs:421` "re-drive the durable source 0..total", dedup by durable id) — unchanged.
- **The explicit `BrainState` handoff *message* retires for the production path.** It was the spike's two-binaries-pass-a-snapshot model; under B + broker-held cursors, the new brain just **cold-starts and re-attaches**, querying the broker for continuity. `Brain::handoff(prior)` collapses into `cold_start` + broker-resume. (Generation counter, if we still want it for observability, becomes broker-tracked.)

The one real trade-off to confirm: **output at-least-once (broker resumes from last-*sent*) vs exactly-once (add brain→broker acks so the broker resumes from last-*processed*).** I recommend **at-least-once** — exactly-once output would add a per-chunk ack protocol for a guarantee the terminal stream explicitly doesn't make, while effects (the thing that *must* be exactly-once) already have the journal. Your call on that, and on retiring the `BrainState` message.


agree with your recs


Q6 locked — broker becomes cursor-of-record, all sessions re-attach in resume mode, output at-least-once / effects exactly-once, the `BrainState` handoff *message* retires.

**Q7 — Failure atomicity: what happens when the *new* brain won't boot?**

The dangerous case isn't the binary swap (applyhost already rolls back a failed *land*, `applyhost.rs:167-170`), and it isn't IPC mismatch (the `classify` handshake `brain_ipc_version ≥ broker.min_compatible` is checked *pre-swap*). It's a **runtime bug**: binary swapped fine, new brain **panics on boot**. Under model B the broker respawns it → it panics again → **crashloop on the new binary**. Endpoints survive (broker holds them), but *no logic runs* — dead pump, dead routing, no session driving. Worst kind of "up but useless."

Three policies:
- **A. Crashloop forever** — endpoints alive, logic dead. Unacceptable.
- **B. Bounded retry → auto-rollback to the backup binary.** The broker (supervisor) gates on a **readiness signal** (new brain emits "ready" once it's re-attached all sessions + resumed loops). N failed boots / no-ready-within-window → broker respawns from the **last-known-good** binary (the `spt.exe.old-N` backup already exists), records the version as **quarantined** (don't auto-re-apply/re-fetch it), and surfaces a loud consent-style notif. Update fails *safe* to last-good; endpoints never dropped throughout.
- **C. Bounded retry → halt + alert**, wait for manual intervention. Safer-feeling but leaves logic dead until a human shows up.

**My recommendation: B.** Reasons:
1. The backup binary is *already produced* for exactly this (`spt.exe.old-N`), and the readiness+backoff machinery already exists — `peerloop.rs:82-91` has `SUPERVISE_BACKOFF_BASE/CAP` + `SUPERVISE_HEALTHY_RUN` (60s healthy resets backoff). Reuse that ladder for the brain: healthy-run promotes "new" → last-known-good; crashloop before healthy → revert.
2. Cleanest mechanism keeps the broker *dumb*: it holds two paths — **last-known-good** + **candidate** — and just *chooses which to spawn*. No file manipulation at failure time; the canonical-path swap already happened, the broker simply falls back to spawning `.old-N` on crashloop. (A later re-apply fixes the canonical file.)
3. Self-healing matches the whole pillar — a bad update that bricked *logic* (not just cross-OS bytes) should recover without a human, same spirit as the boot-race self-heal you already ship.

**One subtlety to fix regardless:** `applyhost.rs:176` writes `record_applied(version)` + `last-outcome=applied` **before** the brain handoff — i.e., before we know the new brain boots. On rollback that record is a lie. The convergence record must become **two-phase**: `applied-pending` at swap, promoted to `applied` only on the brain-ready signal, or corrected to `rolled-back(quarantine=N, running=N-1)` on failure. (This is the same `applied.json={version:6}` I saw on enlyzeam — written optimistically.)

**Q8 (quick confirm, not really a fork): model B is cross-platform uniform by construction.** The broker spawns the brain as a **child process** (`Command::spawn`) + socket IPC — **no `exec` needed**, so no Windows/Unix divergence (the thing ADR-0004:25 rejected whole-daemon FD-passing to avoid). ConPTY/forkpty handles stay broker-side, never cross to the brain. Spikes 01/04 already proved the handoff on both. Nothing platform-specific to decide.

Recommendation: **B for Q7, two-phase applied-record, Q8 confirmed uniform.** Agree, or do you want C's halt-and-wait instead of auto-rollback for a logic-bricking update?


agree with your rec + the subtelty fix


All branches resolved. Here's the full design ledger from this grill:

## Decision ledger — brain/broker split restoration

| # | Decision |
|---|---|
| **Root** | The in-process broker is **unintended drift** from accepted ADR-0004 (which decided a separate broker *process*). Correcting a regression, not designing new. |
| **Q2** | Broker = the always-up **per-machine** anchor (one per `SPT_HOME`, hosts N endpoints, present even bare). It owns the **seed lock + liveness** and **supervises/spawns the brain**. |
| **Q3** | Update trigger = **B**: `apply` swaps binary on disk → signals brain to snapshot + self-exit → broker auto-respawns from disk (now-new binary) → new brain re-attaches. |
| **Q4** | Timing = durable **absolute-deadline** state, not handoff-snapshot. Periodic loops: `(anchor, interval)` written once on fresh/crash start, **derived (no write)** on update restart; **crash = reset anchor**. One-shot (alarms): persist-at-creation, **survive crash + update**. Brain learns *why it started* (update vs crash/fresh) from the broker's respawn signal. |
| **Q5** | §B rule adjudicates: **net→broker**, **digest-hub→broker / parse→brain**, **seed-lock→broker**; every brain→broker touch becomes a **versioned IPC verb** (no shared `Arc`); kernel-enlargement accepted. **Exception: shellwake stays brain** (re-reconciles from disk on start; rare updates make the drop-window tolerable). |
| **Q6** | Broker = **cursor-of-record** per session; new brain re-attaches **all** sessions in resume mode. **Output at-least-once** (terminal-stream contract), **effects exactly-once** (existing `EffectJournal`). The `BrainState` handoff *message* **retires** — new brain cold-starts + queries broker. |
| **Q7** | Bounded-retry → **auto-rollback to last-known-good binary**, gated on a brain **ready** signal (reuse `peerloop` backoff + healthy-run). Quarantine the bad version, loud notif. Broker holds **last-good + candidate** paths and chooses which to spawn. **Two-phase applied-record** (`applied-pending` → `applied` on ready, or `rolled-back` on failure) — fixes today's optimistic `record_applied` before boot. |
| **Q8** | Cross-platform uniform by construction: broker **spawns brain as a child** (`Command::spawn`) + socket IPC, **no `exec`**. ConPTY/forkpty handles never leave the broker. |

## Implied decomposition (consequences, not new forks)
- `spt daemon run` → becomes the **broker** process entry (binds seed-control + broker socket + NetHost + digest hub; holds children + `EffectJournal`); spawns the brain child.
- New hidden `spt daemon brain` → the **brain** entry (connects to broker, runs pump/dispatch/psyche loops/session drivers, rehydrates from disk, emits `ready`).
- `ensure_running`/`is_running`/`daemon stop` contracts unchanged — they target the broker's seed channel.
- De-elevation guard applies at the **broker** entry; brain inherits unelevated.

## Scope reality
This is **milestone-sized**, not a patch: process decomposition, an IPC-verb surface for the four hard cases, broker-side cursor + delivered-tracking, durable-deadline timing across every resident loop, auto-rollback + readiness, two-phase records. Plus two **separate** tracked gaps surfaced along the way: alarms have **no durable daemon scheduler** today (legacy-listener-only), and registry has a one-cadence reconverge window (accepted).

---

This meets all three ADR bars — hard-to-reverse (process model), surprising (a future reader will ask "why two processes?"), real trade-offs (every Q had live alternatives). And per the traceability contract, a regression of an accepted ADR needs a `REQ-*` + a KNOWN-HAZARD entry before code.

**What do you want me to capture now?**
- **ADR amendment to 0004** (or a superseding ADR) recording the drift finding + the Q2–Q8 restoration decisions,
- **`REQ-*`** in `traceable-reqs.toml` (e.g. a `REQ-HAZARD-BROKER-PROCESS-ISOLATION` for "brain restart must not drop endpoints" + a realize-the-split functional REQ),
- **KNOWN-HAZARDS** entry for the in-process collapse,
- or hold all of it and just leave you the ledger above?

My rec: write the **ADR amendment + the KNOWN-HAZARD now** (they're the durable record of *why*), stub the `REQ-*` ids, and defer the implementation-phase planning to a roadmap pass — this is too big to fold into the current branch.


before ADR, reqs, or known hazards, please write a doc that details the reason for this grill session, the decisions that were made and the reasons for both. i will then have another agent verify that doc against the broader project goals first before going further.


On it. Writing a standalone design-rationale doc — origin, the regression, every decision with its reasoning and the alternatives rejected — self-contained so your verifier can check it against project goals without this transcript.


Write

# Broker/Brain Split Restoration — Design Rationale (pre-ADR)

**Status:** Proposed — decisions reached in a design grill on 2026-06-09, **pending independent verification against project goals.** Nothing here is ratified yet; no ADR, `REQ-*`, or KNOWN-HAZARD entry has been written. Those follow only if this document is validated.

**Audience:** dual (human reviewer + AI dev-agent). This is a *why* document, not an implementation plan.

---

## 1. Why this session happened

This came out of live operations, not a planning cycle.

While verifying the v0.3.2 cross-OS update fix end-to-end on the real fleet, the third node (`enlyzeam`, a Windows box hosting the `gerald` ready_agent) was updated:

- `spt update apply` returned `APPLIED:6`.
- The installed `spt.exe` on disk was the valid 0.3.2 Windows PE (magic `4d5a`, sha matched the published binary), `--version` reported `0.3.2`, `applied.json` = `{version:6}`, backup `spt.exe.old-6` present.
- **But the running daemon's pid was unchanged, and it was still executing 0.3.0 in memory.**

The operator's question: spt was specced with a seamless "hot swap" — why did `apply` change the on-disk binary but not the *running code*?

Investigation established that:

1. The hot-swap that *is* wired into `apply` is a **per-session brain handoff** (`apply_brain_only` → `Brain::handoff`), whose job is endpoint survival (REQ-UPD-3), not running-process replacement.
2. By explicit design, `applyhost.rs:36-38` states: *"The daemon's own resident loops keep running the old logic until its next restart (`ensure_running` spawns the executable path — now the new binary); that restart is routine lifecycle, never an endpoint cycle."*
3. So new code only goes live on a *subsequent, unrelated* daemon restart (next logon, or a manual restart — which is what made the *other* node, `hfenduleam`, show a new pid). The practical consequence on `enlyzeam`: `gerald`'s live daemon kept running 0.3.0 — including the message-envelope codec — so the box from the original bug report **was still reproducing the original `\r` corruption even though the fix sat on disk.**

The operator then asked the real question this document answers: **what would it take to make an update actually hand off to the new executable seamlessly — no dropped processes, internal loop timings consistent across the swap, plus any other angles that matter?**

Walking that question backward surfaced the actual root cause (Section 2), which reframed the whole task from "add a feature" to "correct a regression."

---

## 2. The root cause: unintended spec/impl drift

### 2.1 What the spec decided

ADR-0004 (*"Single consolidated daemon with broker/brain split; peer-propagated gated self-update"*, **accepted 2026-05-29**) decided a **two-layer split implemented as two processes**:

- **broker** (stable kernel) — holds *only* the un-transferable, must-not-die resources: PTY master fds, spawned harness child processes, listening network sockets, and (per the §B ownership table) the Iroh/QUIC endpoint + conn table. Minimal, versioned local IPC. Almost never updates.
- **daemon brain** (userspace) — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely on update; rehydrates from disk and re-attaches to broker-held handles.

Explicit evidence the split was meant to be a **process** boundary:
- ADR-0004 Consequences: *"A small internal broker **process** exists beneath the daemon — a deliberate, bounded walk-back of 'literally one process,' … guaranteeing endpoint survival across updates."*
- ADR-0004 rejected alternatives include **"whole-daemon live FD-passing"** and **"drain + restart"** — i.e. the chosen design is specifically *restart the brain, the broker process survives.*
- `M3a-PLAN.md`, `M3-PLAN.md`, `M3b-PLAN.md` all name "the broker **process** + IPC (M3b)."
- Spikes 01/03/04/05/06 each **PROVEN** with *two separate binaries* (`spt-spikes/spike-01-broker-handoff`): a PTY child + a live QUIC transfer survive 100× brain restarts, gapless and exactly-once. A spike that proves "survives a brain restart" presupposes the broker is a separate process.

### 2.2 What production actually does

The production daemon runs the broker as a **background thread inside the single `spt daemon` process**:

- `daemon.rs:41-44` — *"bind the broker (served on a background thread) + the seed-control channel (the foreground loop)."*
- `daemon.rs:165-170` — `Broker::bind_in_with_net(...)` returns an `Arc<Broker>`; the daemon `thread::spawn`s `broker.serve()`. `broker.rs:181/196` confirm `bind` returns `Arc<Self>` (an in-process object, not a spawned child).
- The pump (`daemon.rs:280`), dispatcher, digest hub, net consumers, psyche loops — **all threads in the same process.**
- A repo-wide search finds **no** `Command`/subcommand that spawns a broker child anywhere.

### 2.3 Why this is a regression, not a deferral

The operator confirmed there was **no deliberate decision** to collapse the two processes into one; it is unintended drift. The consequences are not cosmetic:

- The brain cannot restart onto a new binary without killing the in-process broker thread — which would close every PTY, orphan every harness child, and drop every listening socket. So **the no-endpoint-drop self-update pillar is silently unrealized.**
- `apply` therefore performs an in-process `Brain::handoff` that re-attaches a subscriber within the *same old process* — achieving nothing for a binary swap. `update.rs:234`'s statement that "the live daemon execs the verified new binary" is **aspirational; it was never wired** because the process split it depends on does not exist in production.
- Updates do not run until an unrelated restart/logon (observed live, Section 1).

This document's decisions restore ADR-0004's intended model.

> **Glossary note already applied:** `CONTEXT.md` was sharpened during the session to state explicitly that there is **one broker per machine** (per `SPT_HOME`), *not* one per endpoint — a single broker hosts every endpoint's resources and is present even with zero endpoints online. This corrected a latent "one broker per endpoint" misconception and is load-bearing for decision Q2.

---

## 3. Design goal

Restore the two-process model so that a routine **brain-only** update (the common case per ADR-0004 §A's class taxonomy) hands off to the new executable seamlessly:

1. no endpoint or harness child process is dropped or suspended (REQ-UPD-3),
2. internal loop timings stay consistent across the swap,
3. a failed update fails *safe*,
4. the mechanism is uniform across Windows and Linux.

The broker-touching update classes (broker-compatible / broker-breaking) remain as ADR-0004 left them — out of scope here; this work is the **brain-only** path's realization.

---

## 4. Decisions and their reasons

Each decision lists the alternatives considered and why they were rejected, so a verifier can check the reasoning, not just the conclusion.

### Q2 — Supervision topology: the broker is the always-up per-machine anchor

**Decision.** The broker process is the long-lived per-machine anchor. It owns the **seed-control lock + liveness** (the single-daemon-per-home invariant and the `ensure_running`/`is_running` ping target) and **supervises/spawns the brain** as its child. On update, the broker re-spawns the brain.

**Reason.** The broker is the one component guaranteed present whenever the machine's daemon is alive — it runs even with zero endpoints (the "bare daemon" case, `daemon.rs:196`). The seed lock and liveness *must* survive a brain restart, or a second daemon could win the bind during the restart window and `is_running` would flap. Anchoring them in the never-restarting layer makes the single-instance invariant and liveness survive brain restarts for free.

**Alternatives rejected.**
- *A thin third supervisor process spawning both broker and brain* — adds a process for no invariant the broker can't already hold.
- *Brain as parent, spawning the broker* — incoherent: a brain restart would orphan or kill its own broker child, dropping endpoints.

**Operator's challenge that strengthened this.** The operator initially held a "one broker per endpoint" model and correctly objected that liveness shouldn't live in something that only exists when an endpoint is online. The code shows one broker *per machine*, always present, so the objection's principle is satisfied and actually reinforces putting liveness in the broker.

### Q3 — Update trigger: brain self-exit, broker auto-respawn from disk

**Decision.** `apply` verifies + swaps the binary on disk, then signals the brain to **snapshot and self-exit**. The broker — already the brain's supervisor — observes the exit and **respawns from the executable path (now the new binary)**; the new brain re-attaches.

**Reason.** This reuses primitives that already exist. `apply_brain_only` already models exactly "snapshot the outgoing brain, drop it — the crash boundary the broker already tolerates — re-attach." The broker must already supervise and respawn the brain for crash recovery, so an update is just a *planned* snapshot+exit handled by the existing respawn path, and "respawn from the executable path" naturally picks up the swapped binary — which is literally what `applyhost.rs:37` says happens "at next restart." We simply trigger that restart immediately instead of waiting for logon.

**Alternatives rejected.**
- *Broker actively cycles the brain on command* — nearly identical, but puts cycle logic into the layer we want to keep dumb and stable.
- *The `spt update apply` CLI spawns the new brain directly* — parentage is wrong: the brain would be a child of the transient CLI process and die when the command returns.

### Q4 — Loop-timing continuity: durable absolute deadlines, not a handoff snapshot

**Decision.** Loop timing that must survive lives as **durable absolute-deadline state on disk**, rehydrated on every brain start. Two categories:
- **Periodic resident loops** (pump cadences, pulse/echo-commune): persist a `(anchor, interval)` pair **once** on a fresh/crash start. The next fire is derived functionally — `next_fire = anchor + interval × ⌈max(0, now − anchor) / interval⌉` — with **no per-fire writes**. An **update restart re-reads `(anchor, interval)` and keeps deriving** (phase preserved, lands mid-grid). A **crash restart is treated as a fresh start** (rewrite `anchor = now`; phase reset is acceptable).
- **One-shot scheduled events** (alarms): persist the absolute `target-time` **at creation**; every start (update *or* crash) reads it and fires-if-due. **These never reset on crash** — a user's "remind me at 3pm" is a commitment that must outlive any restart.

The brain distinguishes update-restart from crash/fresh-restart via a signal on the broker's respawn (the broker *initiated* the planned cycle but *observed* the unexpected crash). The `(anchor, interval)` write happens once per fresh/crash start, zero per update, zero per fire.

**Reason.**
- `BrainState` (the handoff snapshot) is the wrong vehicle: it only exists for a *planned* update, but a brain **crash** (same respawn path) carries no snapshot, so anything timing-critical in it is lost on crash. Timing that must survive has to survive crash too → it belongs on disk.
- In-memory monotonic `Instant` phase cannot be transferred across a process boundary meaningfully (new process = new monotonic epoch); only absolute epoch-ms is portable.
- The operator added the write-minimization refinement (store anchor+interval once, derive thereafter) so the disk is touched once per fresh start rather than per fire — important given a 200 ms tick across many loops.

**Current state that motivates it.** The pump cadences are already catch-up/idempotent ("stagger from everything due now," `peerloop.rs:304`) and are restart-safe as-is. The pulse loop is **phase-relative** (`lifecycle.rs:486` `sleep_interruptible(pulse_period)`), so its phase resets on restart — this is the loop that needs converting to a disk-anchored deadline. Idempotent catch-up loops need no phase state at all; only phase-significant loops are converted.

**Alternative rejected.** *Snapshot each loop's `Instant`/phase into the handoff message and restore it* — fragile (monotonic clocks don't cross processes), lost on crash, and incompatible with the model-B trigger (no brain→brain message exists).

### Q5 — IPC boundary: the §B rule adjudicates; everything brain→broker becomes a versioned IPC verb

**Decision.** Apply ADR-0004 §B's rule — *a resource is broker-owned iff a live consumer would lose continuity on a brain restart* — to every component that today shares the in-process `Arc<Broker>`:
- **Net bring-up + REQ-DAEMON-9 self-heal retry → broker** (the NetHost is broker-owned; the brain must not own net bring-up).
- **Digest hub → broker** for the live projections + subscriber sockets; **the parse that feeds it → brain** (pushes parsed digests over IPC).
- **Seed lock + liveness → broker** (Q2).
- **Every other brain→broker call becomes a versioned IPC verb** — in the two-process world there is no shared `Arc`, so each current direct call is a latent IPC verb on the same versioned contract that already carries `SPAWN`/`INPUT`/`NET_*`/`SESSIONS`.

**Accepted consequence.** This enlarges the "dumb kernel." That is already-precedented: ADR-0004:63 accepted exactly this when QUIC ownership moved into the broker — *"the broker is 'stable' in update cadence, not in narrowness."*

**Exception — shellwake stays brain-side.** The supervised `wake_command` watcher *children* are an exception to the §B continuity rule: they remain **brain-owned** and are re-reconciled from disk on brain start (the boot sweep at `daemon.rs:206-217` already does orphan cleanup).

**Reason for the exception.** Strict §B would put the watcher children in the broker for continuity, but the operator judged the continuity loss acceptable: updates are rare, the brain re-reconciles watchers on start, and keeping watcher-child *supervision logic* out of the kernel keeps the broker narrower. The only cost is a brief window during a (rare) brain update where an offline shell could miss a wake — explicitly accepted.

### Q6 — Multi-session handoff: the broker is cursor-of-record; the `BrainState` message retires

**Decision.** The broker tracks each session-subscriber's last-delivered cursor. On (re)start the new brain queries the broker for **all** hosted sessions and re-attaches each in **resume** mode. **Output is at-least-once** (the broker resumes from last-*sent*); **input/effects stay exactly-once** via the already-broker-owned `EffectJournal`. The explicit `BrainState` handoff *message* is **retired** for production — the new brain cold-starts and reconstructs continuity by querying the broker.

**Reason.**
- Two concrete gaps exist today: `applyhost.rs:239` re-attaches only `sessions.first()` (one session) and with `from_seq = 0` (re-replays the whole ring → duplicate output).
- Under the model-B trigger the outgoing brain is gone before the new one starts, so **no brain→brain message can be passed**; continuity must come from the persistent side. The broker already holds the per-session output ring (`broker.rs:73-143`); making it also track the delivered cursor lets a respawned brain resume gaplessly with **no disk writes** (respecting Q4) and **no handoff frame**.
- At-least-once output matches the *existing* terminal-stream contract: SPIKE-05 already established "terminal-stream ≠ exactly-once transfer" (a resize repaint reorders and duplicates the stream anyway). Exactly-once output would require a per-chunk ack protocol for a guarantee the terminal stream does not make; the thing that *must* be exactly-once (injected input/effects) already has the journal.

**Alternative rejected.** *Add brain→broker acks so the broker resumes from last-processed (exactly-once output)* — cost without benefit, given the terminal-stream contract.

### Q7 — Failure atomicity: bounded retry → auto-rollback to last-known-good

**Decision.** If the new brain fails to reach a **ready** signal (re-attached all sessions + resumed loops) within a bounded number of boots / a healthy-run window, the broker **rolls back to the last-known-good binary** (the `spt.exe.old-N` backup), **quarantines** the bad version (no auto re-apply/re-fetch), and surfaces a loud consent-style notification. The broker holds **last-known-good + candidate** paths and chooses which to spawn (no file manipulation at failure time). Endpoints survive throughout (the broker holds them). The **applied record becomes two-phase**: `applied-pending` at swap, promoted to `applied` on the ready signal, or corrected to `rolled-back(quarantine=N, running=N-1)` on failure.

**Reason.**
- The dangerous case is a *runtime* bug (new brain panics on boot), not the binary land (already rolled back at `applyhost.rs:167-170`) and not IPC mismatch (the `classify` handshake `brain_ipc_version ≥ broker.min_compatible` is checked pre-swap). A naive supervisor would crashloop the new binary: endpoints alive but logic dead — "up but useless."
- The backup binary is already produced for exactly this, and the readiness+backoff machinery already exists — `peerloop.rs:82-91` has `SUPERVISE_BACKOFF_BASE/CAP` + `SUPERVISE_HEALTHY_RUN` (60 s healthy resets backoff). A bad update that bricks *logic* should self-heal back to last-good without a human, the same spirit as the boot-race self-heal already shipped.
- The two-phase record fixes a real existing bug: `applyhost.rs:176` writes `record_applied(version)` + `last-outcome=applied` **before** the brain handoff — i.e. before we know the new brain boots. (This is the optimistic `applied.json={version:6}` observed on `enlyzeam`.)

**Alternative rejected.** *Bounded retry → halt and wait for manual intervention* — safer-feeling but leaves logic dead until a human shows up; inconsistent with the self-healing posture.

### Q8 — Cross-platform uniformity (confirmation, not a fork)

**Decision/observation.** The broker spawns the brain as a **child process** (`Command::spawn`) and they communicate over the existing local socket IPC. **No `exec` is required**, so there is no Windows/Unix divergence. ConPTY (Windows) and forkpty (Linux) handles stay broker-side and never cross to the brain. Spikes 01 and 04 already proved the handoff on both OSes.

**Reason.** This is precisely the uniformity ADR-0004:25 sought when it rejected whole-daemon live FD-passing as "hard and platform-divergent." Spawn-plus-socket sidesteps it entirely.

---

## 5. Implied decomposition (consequences of the above)

- `spt daemon run` becomes the **broker** process entry: binds the seed-control channel + broker socket + NetHost + digest hub; holds harness children + the `EffectJournal`; spawns the brain child.
- A new hidden `spt daemon brain` entry is the **brain** process: connects to the broker, runs pump/dispatch/psyche loops/session drivers, rehydrates from disk, emits a `ready` signal.
- `ensure_running` / `is_running` / `daemon stop` contracts are unchanged — they target the broker's seed channel.
- The de-elevation guard (`daemon.rs:60-103`) applies at the **broker** entry; the brain child inherits the unelevated identity.

---

## 6. Scope and sequencing

This is **milestone-sized**, not a patch. It includes: the process decomposition; an IPC-verb surface for the four hard cases (net bring-up, digest push, seed-lock/liveness, plus the general direct-call audit); broker-side per-session delivered-cursor tracking; durable absolute-deadline timing across every resident loop; the auto-rollback + readiness machinery; and the two-phase applied record. It should be planned as a roadmap phase, not folded into an unrelated branch.

---

## 7. Separate gaps surfaced (out of scope here, tracked separately)

- **Alarms have no durable daemon scheduler.** In the spt-core daemon, `alarm` exists only as an event *shape* (`spt-proto`) + relay handling (`psyrelay.rs:93`) + a test fixture; `target-time` is only a wire attribute. The actual "wait until target, fire the TIMED PULSE" timer lives in the legacy owl listener in-memory. Porting alarms into the daemon as a durable scheduler (which would then ride the Q4 one-shot mechanism) is its own gap.
- **Registry one-cadence reconverge window.** `RegistryHost` is intentionally in-memory (`registryhost.rs:19-26`): a restart forgets peers' rows and reconverges within one pump cadence (self-rows re-advertised each tick; mirrored to `identity/registry/` JSON for out-of-process readers). Across a brain restart this is a brief reconverge window, accepted by design — not a blocker, noted for completeness.

---

## 8. Cross-references for verification

- **ADR-0004** — `docs/adr/0004-single-daemon-broker-brain-split-and-self-update.md` (the decision being restored; §A update classes, §B ownership table, the spike amendments).
- **Requirements** — REQ-UPD-3 (no-endpoint-drop, brain-only), REQ-UPD-4 (staged + consent), REQ-UPD-6/8 (update-set / platform-safe), REQ-DAEMON-3/9 (auto-start / net self-heal), REQ-HAZARD-UPDATE-ROLLBACK, REQ-HAZARD-HANDOFF-ARGV-COMPAT.
- **Glossary/model** — `CONTEXT.md` daemon / broker / brain (incl. the one-broker-per-machine clause added this session).
- **Spikes** — `docs/spikes/SPIKE-01-broker-handoff.md`, `-03-quic-survival`, `-04-forkpty-parity`, `-05-restart-stress`, `-06-idempotent-boundary` (proofs that the two-process handoff works on both OSes).
- **Key source** — `daemon.rs` (`run`, net consumers, self-heal, digest hub, shellwake, seed-control), `broker.rs` (`OutputLog`, `bind`, `EffectJournal`), `brain.rs` (`BrainState`, `cold_start`/`handoff`/`snapshot`), `applyhost.rs` (`apply_staged`, `connect_retry`, record-before-handoff), `update.rs` (classes, `classify`, `apply_brain_only`), `peerloop.rs` (cadences, supervise-backoff), `lifecycle.rs` (pulse loop), `registryhost.rs`.

---

## 9. Artifacts pending (do not create until this document is verified)

- An **ADR amendment to 0004** (or a superseding ADR) recording the drift finding + the Q2–Q8 restoration decisions.
- **`REQ-*`** registry entries (e.g. a `REQ-HAZARD-*` for "a brain restart must never drop a hosted endpoint," and a functional REQ for realizing the two-process split).
- A **KNOWN-HAZARDS** entry for the in-process collapse (a real bug paid for once: silently unrealized no-endpoint-drop update).


before ADR, reqs, or known hazards, please write a doc that details the reason for this grill session, the decisions that were made and the reasons for both. i will then have another agent verify that…