1	# Broker/Brain Split Restoration — Design Rationale (pre-ADR)
2
3	**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.
4
5	**Audience:** dual (human reviewer + AI dev-agent). This is a *why* document, not an implementation plan.
6
7	---
8
9	## 1. Why this session happened
10
11	This came out of live operations, not a planning cycle.
12
13	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:
14
15	- `spt update apply` returned `APPLIED:6`.
16	- 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.
17	- **But the running daemon's pid was unchanged, and it was still executing 0.3.0 in memory.**
18
19	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*?
20
21	Investigation established that:
22
23	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.
24	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."*
25	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.**
26
27	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?**
28
29	Walking that question backward surfaced the actual root cause (Section 2), which reframed the whole task from "add a feature" to "correct a regression."
30
31	---
32
33	## 2. The root cause: unintended spec/impl drift
34
35	### 2.1 What the spec decided
36
37	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**:
38
39	- **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.
40	- **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.
41
42	Explicit evidence the split was meant to be a **process** boundary:
43	- 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."*
44	- 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.*
45	- `M3a-PLAN.md`, `M3-PLAN.md`, `M3b-PLAN.md` all name "the broker **process** + IPC (M3b)."
46	- 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.
47
48	### 2.2 What production actually does
49
50	The production daemon runs the broker as a **background thread inside the single `spt daemon` process**:
51
52	- `daemon.rs:41-44` — *"bind the broker (served on a background thread) + the seed-control channel (the foreground loop)."*
53	- `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).
54	- The pump (`daemon.rs:280`), dispatcher, digest hub, net consumers, psyche loops — **all threads in the same process.**
55	- A repo-wide search finds **no** `Command`/subcommand that spawns a broker child anywhere.
56
57	### 2.3 Why this is a regression, not a deferral
58
59	The operator confirmed there was **no deliberate decision** to collapse the two processes into one; it is unintended drift. The consequences are not cosmetic:
60
61	- 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.**
62	- `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.
63	- Updates do not run until an unrelated restart/logon (observed live, Section 1).
64
65	This document's decisions restore ADR-0004's intended model.
66
67	> **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.
68
69	---
70
71	## 3. Design goal
72
73	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:
74
75	1. no endpoint or harness child process is dropped or suspended (REQ-UPD-3),
76	2. internal loop timings stay consistent across the swap,
77	3. a failed update fails *safe*,
78	4. the mechanism is uniform across Windows and Linux.
79
80	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.
81
82	---
83
84	## 4. Decisions and their reasons
85
86	Each decision lists the alternatives considered and why they were rejected, so a verifier can check the reasoning, not just the conclusion.
87
88	### Q2 — Supervision topology: the broker is the always-up per-machine anchor
89
90	**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.
91
92	**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.
93
94	**Alternatives rejected.**
95	- *A thin third supervisor process spawning both broker and brain* — adds a process for no invariant the broker can't already hold.
96	- *Brain as parent, spawning the broker* — incoherent: a brain restart would orphan or kill its own broker child, dropping endpoints.
97
98	**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.
99
100	### Q3 — Update trigger: brain self-exit, broker auto-respawn from disk
101
102	**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.
103
104	**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.
105
106	**Alternatives rejected.**
107	- *Broker actively cycles the brain on command* — nearly identical, but puts cycle logic into the layer we want to keep dumb and stable.
108	- *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.
109
110	### Q4 — Loop-timing continuity: durable absolute deadlines, not a handoff snapshot
111
112	**Decision.** Loop timing that must survive lives as **durable absolute-deadline state on disk**, rehydrated on every brain start. Two categories:
113	- **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).
114	- **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.
115
116	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.
117
118	**Reason.**
119	- `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.
120	- In-memory monotonic `Instant` phase cannot be transferred across a process boundary meaningfully (new process = new monotonic epoch); only absolute epoch-ms is portable.
121	- 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.
122
123	**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.
124
125	**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).
126
127	### Q5 — IPC boundary: the §B rule adjudicates; everything brain→broker becomes a versioned IPC verb
128
129	**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>`:
130	- **Net bring-up + REQ-DAEMON-9 self-heal retry → broker** (the NetHost is broker-owned; the brain must not own net bring-up).
131	- **Digest hub → broker** for the live projections + subscriber sockets; **the parse that feeds it → brain** (pushes parsed digests over IPC).
132	- **Seed lock + liveness → broker** (Q2).
133	- **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`.
134
135	**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."*
136
137	**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).
138
139	**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.
140
141	### Q6 — Multi-session handoff: the broker is cursor-of-record; the `BrainState` message retires
142
143	**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.
144
145	**Reason.**
146	- 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).
147	- 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**.
148	- 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.
149
150	**Alternative rejected.** *Add brain→broker acks so the broker resumes from last-processed (exactly-once output)* — cost without benefit, given the terminal-stream contract.
151
152	### Q7 — Failure atomicity: bounded retry → auto-rollback to last-known-good
153
154	**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.
155
156	**Reason.**
157	- 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."
158	- 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.
159	- 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`.)
160
161	**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.
162
163	### Q8 — Cross-platform uniformity (confirmation, not a fork)
164
165	**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.
166
167	**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.
168
169	---
170
171	## 5. Implied decomposition (consequences of the above)
172
173	- `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.
174	- 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.
175	- `ensure_running` / `is_running` / `daemon stop` contracts are unchanged — they target the broker's seed channel.
176	- The de-elevation guard (`daemon.rs:60-103`) applies at the **broker** entry; the brain child inherits the unelevated identity.
177
178	---
179
180	## 6. Scope and sequencing
181
182	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.
183
184	---
185
186	## 7. Separate gaps surfaced (out of scope here, tracked separately)
187
188	- **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.
189	- **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.
190
191	---
192
193	## 8. Cross-references for verification
194
195	- **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).
196	- **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.
197	- **Glossary/model** — `CONTEXT.md` daemon / broker / brain (incl. the one-broker-per-machine clause added this session).
198	- **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).
199	- **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`.
200
201	---
202
203	## 9. Artifacts pending (do not create until this document is verified)
204
205	- An **ADR amendment to 0004** (or a superseding ADR) recording the drift finding + the Q2–Q8 restoration decisions.
206	- **`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).
207	- A **KNOWN-HAZARDS** entry for the in-process collapse (a real bug paid for once: silently unrealized no-endpoint-drop update).
208

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


Bash

git -C /c/Users/decid/Documents/projects/spt-core diff CONTEXT.md docs/DEFERRED.md | head -200

Diff uncommitted CONTEXT.md and DEFERRED.md changes


diff --git a/CONTEXT.md b/CONTEXT.md
index 140c22c..2d6bc1f 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -33,7 +33,7 @@ Internally the logical daemon is split into two implementation layers for seamle
 - **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.

 **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.
diff --git a/docs/DEFERRED.md b/docs/DEFERRED.md
index ffe0d5e..031e43b 100644
--- a/docs/DEFERRED.md
+++ b/docs/DEFERRED.md
@@ -35,3 +35,5 @@ Intended features explicitly cut from v1 scope but committed for later. Not back
 | **Subnet full-mesh visibility (PLANNED MILESTONE — `SUBNET-MESH-PLAN.md`)**: non-directly-paired members never see each other — a subnet is currently the PAIRING GRAPH, not a mesh (A↔B, C↔B; B offline → A and C invisible; even with B online, B doesn't relay). Root: pairwise TOFU trust (`trust.rs`) + own-rows-only gossip / no transitive relay (`peerloop.rs`, KH 4.10/7.5) + no member roster in the pairing seed transfer. FIX = member-AUTHENTICATED subnet data (seed-MAC and/or per-node signatures) unlocking transitive trust (seed-holder = member = trusted) + transitive gossip relay + roster propagation; MUST re-solve eviction/epoch-lease under relay (supersedes KH 4.10's "no transitive gossip" safety assumption). ADR-level; mint REQ-MESH-* first. Folds in the deferred pairing-time hostname capture + post-join address seeding (same roster/ceremony seam) | A→B→C invisibility test (user 2026-06-08) — confirmed two-gap design limitation, not a code defect | **DESIGN RATIFIED 2026-06-08** (grill): symmetric **seed-proof membership** + **roster-only relay** (rows stay own-authored, no third-party relay → KH 4.10/7.5 PRESERVED, not superseded). Full design = **ADR-0017**; reqs = **REQ-MESH-1..6** (inactive); plan = `SUBNET-MESH-PLAN.md`; glossary = CONTEXT §Pairing & trust. Folds in the pairing-time hostname-capture + post-join address-seeding rows (roster fields). Next: plan-phase over REQ-MESH-* | Next milestone — design done 2026-06-08; build pending plan-phase |
 | `xtask debug-converge` — automated convergence watcher for debug rollout (the REQ-UPD-6 first-slice item ADR-0016 names): watch every expected debug-pinned reachable lab node until all report the target version applied, else a per-node timeout table (Offline / StagedAwaitingConsent / BlockedByBrokerResources / Rejected{reason}). Activates REQ-UPD-6's `int` stage. Full build plan: `docs/DEBUG-CONVERGE-PLAN.md` | REQ-UPD-6 first slice (2026-06-06) | The first slice ships manual "Apply and observe" (`DEBUG-ROLLOUT.md`); the watcher's one genuinely new surface is a node status-query wire leg (no-fetch `{channel, applied_version, last_outcome}` over the existing update handshake) — assembly otherwise | First multi-node debug rollout where hand-walking nodes is the bottleneck, or M8 update polish |
 | **Origin-source update bootstrap (`spt update fetch` — pull the latest signed release from GitHub into the existing verify→stage→apply pipeline)**: today update DISCOVERY is **peer-only** (REQ-UPD-1: a daemon pulls offers from roster peers via `request_update`; `propagate.rs`/`peerloop.rs`). There is NO origin fetch, so the **first node in a fleet — or any isolated/solo node — cannot get a new release without a peer that already staged it** (the fleet converges by gossip, but only *after* a maintainer seeds the first node via the release-publish path). The installer DOES fetch from GitHub (`github.com/SaberMage/spt-releases/releases/latest/download/<asset>` + `SHA256SUMS`, `installer/install.sh:57-73`) but only at install time, outside the daemon's signed-release gate, and never re-runs. FIX = a new **origin-fetch leg** that downloads the per-platform artifact + its `<asset>.release.json` (the `SignedRelease` metadata) from the release origin and feeds them through the **SAME** `plan_verified` gate (`verify_metadata` two-key signature/channel/expiry/rollback + `verify_artifact` SHA-256, `update.rs:157`/`release.rs`), then `cache.stage()` — after which the EXISTING consent-notif / `spt update apply` flow is unchanged. So it reuses ~all the machinery; it only adds an HTTPS GET from the origin as an alternate SOURCE to the P2P `request_update` pull. Surface: `spt update fetch [--channel <ch>] [--tag vX.Y.Z]` (default: latest on the node's pinned channel); optionally a daemon-config `origin_check` cadence so a lone node self-bootstraps alongside P2P, then re-propagates to peers normally. **Key design note:** the daemon has **zero HTTP surface today** — all transport is iroh QUIC; `reqwest` is only a transitive dep (via iroh), not reachable. This leg needs a deliberate HTTPS-client decision (add `reqwest`/a minimal client as a direct dep, vs shell out to `curl`/`Invoke-WebRequest` like the installer — fragile). Honor the channel pin (`release-keys.json`) + the monotonic rollback floor; configurable repo (mirror `SPT_INSTALL_REPO`). The two-key signed-release anchor keeps the GitHub transport **untrusted-but-verified** (strictly stronger than the installer's HTTPS+SHA256SUMS-only first fetch). Mint **REQ-UPD-7** (origin-source fetch) first. | Update subsystem v1 (ADR-0016, REQ-UPD family) — P2P-pull only by design | v1 update flow is peer-propagation by design (seed one node, the fleet converges by gossip); origin-fetch is additive and the maintainer seed path covered bootstrap so far. No HTTP client is wired into the daemon yet | First isolated / first-in-fleet node that must update with no peer to pull from; or making `spt update` self-sufficient for solo / single-node users (user flagged the gap 2026-06-08) |
+| **Test broker socket-bind hardening (CI-flake removal)**: `applyhost` tests' `served_broker` helper does `Broker::bind(name).expect("bind broker")` (`crates/spt-daemon/src/applyhost.rs:293`) where `name` = `spt-daemon-d7b-{pid}-{seq}.sock`. Flakes intermittently on the **kitsubito** self-hosted Linux runner (likely AF_UNIX path collision / stale socket file under the runner's reused workspace — Windows named pipes don't hit it). Cost the v0.3.2 release a full red CI run + a `--failed` rerun (2026-06-09); same code passed clean on rerun → flake, not a logic bug. FIX = make the test bind deterministic: unlink-before-bind, OR abstract-namespace socket (Linux `@`-prefixed, no filesystem entry), OR per-test `TempDir` isolation so each bind has a guaranteed-fresh path. Applies to every `served_broker`/socket-name test helper, not just applyhost | Triaged during v0.3.2 release (2026-06-09) — flake, shipped over it | Non-blocking: a real flake that taxes CI (one red cycle + rerun per hit) but never gates a correct build; hardening is test-infra work, not product. Held to keep the v0.3.2 release moving | Next recurrence that blocks/slows a release, or M8 test-infra/CI-reliability polish |
+| **`spt update fetch` single→set shape-upgrade at same version (REQ-UPD-8 transitional trap)**: `cmd_update_fetch` sets the rollback floor = `staged_version` (the monotonic counter), and the gate is a plain `candidate > floor`. A node that fetched+staged version N as a platform **single** (pre-0.3.2 fetch behavior) can never re-fetch version N as a multi-platform **set** — same counter → `Rollback{current:N, candidate:N}`. Bites the **seed-node bootstrap**: the first node to origin-`fetch` a release applies the single for its OWN platform, then can't re-stage the same version as a set → has no Windows/other-platform artifact to SERVE peers over P2P. Surfaced live in the v0.3.2 cross-platform rig verify (2026-06-09): kitsubito staged+applied the v6 single, then its v6-set re-fetch was refused; unblock was a manual `releases/` cache-clear before re-fetch. Counter vs semver is working as designed (counter = trust spine, semver = cosmetic) — the gap is purely shape-blind: the floor compares version only, ignoring single-vs-set. FIX options: (a) allow a same-version re-fetch when staged shape is `single` and an available `set` is strictly-richer (shape-upgrade allowance), or (b) seed-server flow stages the SET before applying its own single, or (c) a `--force`/`--restage` flag. NOTE — only bites the one migration hop onto 0.3.2; once a node is on 0.3.2 with a fresh cache, future releases are vN+1 and stage clean. Mint a REQ before fixing | v0.3.2 cross-platform rig verify (2026-06-09; user-flagged via session watch) | Transitional-only: the workaround (cache-clear before staging the set) is reliable and was used to complete the v0.3.2 fleet roll; a code fix only saves the manual clear on same-version shape upgrades, which won't recur after the 0.3.2 hop | A future release that again changes staged-artifact SHAPE at a same/non-incrementing counter, or making `spt update fetch` self-sufficient for solo seed-nodes (pairs with the origin-source bootstrap row above) |


i recently ran a /grill-with-docs with another agent to plan out a necessary new milestone to reconcile some spec/impl drift in this project. please ground yourself using relevant docs, evaluate the p…


Review broker brain split restoration plan


Agent

Verify plan's source claims

Prompt:
Verify source-level claims made in a design doc for the repo at C:\Users\decid\Documents\projects\spt-core (Rust workspace, crates under crates/). For EACH numbered claim: report TRUE/FALSE/PARTIAL + 1-3 line evidence (file:line + brief quote). Be precise — these claims will be challenged in a design negotiation. Search breadth: thorough.

Claims:
1. daemon.rs (crates/spt-daemon/src/daemon.rs) around lines 165-170: production daemon creates broker via `Broker::bind_in_with_net(...)` returning `Arc<Broker>`, then `thread::spawn`s `broker.serve()` — broker is an in-process thread. Also: NO `Command`/process-spawn of a broker child exists anywhere in the repo (search for broker subcommand / spawn).
2. applyhost.rs:239 (crates/spt-daemon/src/applyhost.rs): post-apply re-attach uses only `sessions.first()` (single session) and `from_seq = 0` (full ring replay).
3. applyhost.rs:~176: `record_applied(version)` + last-outcome=applied written BEFORE the brain handoff completes (optimistic record). Also rollback of binary land failure at ~lines 167-170.
4. applyhost.rs:36-38: comment stating daemon's resident loops keep running old logic until next restart; `ensure_running` spawns executable path.
5. peerloop.rs:82-91 (crates/spt-daemon/src/peerloop.rs): SUPERVISE_BACKOFF_BASE/CAP + SUPERVISE_HEALTHY_RUN constants (60s healthy run resets backoff) — and WHAT do they currently supervise (which children/processes)?
6. lifecycle.rs:486 (crates/spt-daemon/src/lifecycle.rs): pulse loop uses `sleep_interruptible(pulse_period)` — phase-relative, phase resets on restart.
7. peerloop.rs:~304: pump cadences are catch-up/idempotent ("stagger from everything due now" or similar).
8. brain.rs (crates/spt-daemon/src/brain.rs): What does `BrainState` (handoff snapshot) contain TODAY — full field list. What do `cold_start`/`handoff`/`snapshot` do. Is there anything in BrainState beyond session re-attach info + timing that would be LOST if BrainState were retired?
9. update.rs:~234 (crates/spt-daemon/src/update.rs): a comment/doc claims "the live daemon execs the verified new binary" — confirm no exec is actually wired (no exec/execvp/Command replacing the running process on apply path).
10. broker.rs (crates/spt-daemon/src/broker.rs): lines ~73-143 per-session output ring (OutputLog). Does the broker track a per-subscriber DELIVERED cursor today (or only ring + from_seq replay)? Is EffectJournal broker-owned? Where does EffectJournal live?
11. daemon.rs:206-217: shellwake boot sweep does orphan cleanup of wake_command watcher children on start.
12. daemon.rs:60-103: de-elevation guard at daemon entry.
13. NetHost / iroh endpoint ownership TODAY: where is the iroh endpoint created and held? Is it inside the Broker object (note `Broker::bind_in_with_net` name) or in daemon outside broker? Where do net bring-up and the REQ-DAEMON-9 net self-heal retry live (daemon.rs? peerloop?)? Report the actual ownership chain (who holds NetHost: Broker struct field? daemon local? Arc shared?).
14. Update orchestration location: trace `spt update apply` CLI → how it reaches applyhost (seed-control channel? direct?). One paragraph.
15. State migration on startup: does the daemon mutate/migrate on-disk state file formats at startup (any schema/version upgrade code touching $SPT_HOME state)? Search for version fields/migration in state load paths (registry JSON, applied.json, manifest caches, journal). Brief findings — this matters for binary-rollback safety after a failed update.
16. traceable-reqs.toml (repo root): quote the entries for REQ-UPD-3, REQ-UPD-4, REQ-UPD-6, REQ-UPD-8, REQ-DAEMON-3, REQ-DAEMON-9 (id, title/desc, required_stages). Then grep the repo for evidence tags `[impl->REQ-UPD-3]` / `[unit->REQ-UPD-3]` / `[int->REQ-UPD-3]` — where is REQ-UPD-3 currently evidenced, and does the evidence actually exercise endpoint survival across a brain restart (or only the in-process handoff)?
17. Search for any historical note that the in-process broker was a DELIBERATE interim step: grep M3*-PLAN.md files, git log messages (git log --oneline --all | grep -i broker), code comments near Broker::bind / daemon.rs broker setup for words like "for now", "interim", "later", "process split", "single process", "in-process". Goal: determine whether the single-process collapse was documented as deferral anywhere, or truly silent drift.

Return a numbered verdict list matching claim numbers. Conclusions only, no file dumps.


<owl_reachability_notice>
You have an active owl perch. Launch this Agent with run_in_background: true so you stay reachable for incoming messages while it works.
</owl_reachability_notice>


Async agent launched successfully.
agentId: a0d76e6b7dce965c3 (internal ID - do not mention to user. Use SendMessage with to: 'a0d76e6b7dce965c3' to continue this agent.)
The agent is working in the background. You will be notified automatically when it completes.
Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.
output_file: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b29e3949-c0ca-4c40-8fca-109118764bd7\tasks\a0d76e6b7dce965c3.output
Do NOT Read or tail this file via the shell tool — it is the full subagent JSONL transcript and reading it will overflow your context. If the user asks for progress, say the agent is still running; you'll get a completion notification.

Verify plan's source claims

Prompt:
Verify source-level claims made in a design doc for the repo at C:\Users\decid\Documents\projects\spt-core (Rust workspace, crates under crates/). For EACH numbered claim: report TRUE/FALSE/PARTIAL + 1-3 line evidence (file:line + brief quote). Be precise — these claims will be challenged in a design negotiation. Search breadth: thorough.

Claims:
1. daemon.rs (crates/spt-daemon/src/daemon.rs) around lines 165-170: production daemon creates broker via `Broker::bind_in_with_net(...)` returning `Arc<Broker>`, then `thread::spawn`s `broker.serve()` — broker is an in-process thread. Also: NO `Command`/process-spawn of a broker child exists anywhere in the repo (search for broker subcommand / spawn).
2. applyhost.rs:239 (crates/spt-daemon/src/applyhost.rs): post-apply re-attach uses only `sessions.first()` (single session) and `from_seq = 0` (full ring replay).
3. applyhost.rs:~176: `record_applied(version)` + last-outcome=applied written BEFORE the brain handoff completes (optimistic record). Also rollback of binary land failure at ~lines 167-170.
4. applyhost.rs:36-38: comment stating daemon's resident loops keep running old logic until next restart; `ensure_running` spawns executable path.
5. peerloop.rs:82-91 (crates/spt-daemon/src/peerloop.rs): SUPERVISE_BACKOFF_BASE/CAP + SUPERVISE_HEALTHY_RUN constants (60s healthy run resets backoff) — and WHAT do they currently supervise (which children/processes)?
6. lifecycle.rs:486 (crates/spt-daemon/src/lifecycle.rs): pulse loop uses `sleep_interruptible(pulse_period)` — phase-relative, phase resets on restart.
7. peerloop.rs:~304: pump cadences are catch-up/idempotent ("stagger from everything due now" or similar).
8. brain.rs (crates/spt-daemon/src/brain.rs): What does `BrainState` (handoff snapshot) contain TODAY — full field list. What do `cold_start`/`handoff`/`snapshot` do. Is there anything in BrainState beyond session re-attach info + timing that would be LOST if BrainState were retired?
9. update.rs:~234 (crates/spt-daemon/src/update.rs): a comment/doc claims "the live daemon execs the verified new binary" — confirm no exec is actually wired (no exec/execvp/Command replacing the running process on apply path).
10. broker.rs (crates/spt-daemon/src/broker.rs): lines ~73-143 per-session output ring (OutputLog). Does the broker track a per-subscriber DELIVERED cursor today (or only ring + from_seq replay)? Is EffectJournal broker-owned? Where does EffectJournal live?
11. daemon.rs:206-217: shellwake boot sweep does orphan cleanup of wake_command watcher children on start.
12. daemon.rs:60-103: de-elevation guard at daemon entry.
13. NetHost / iroh endpoint ownership TODAY: where is the iroh endpoint created and held? Is it inside the Broker object (note `Broker::bind_in_with_net` name) or in daemon outside broker? Where do net bring-up and the REQ-DAEMON-9 net self-heal retry live (daemon.rs? peerloop?)? Report the actual ownership chain (who holds NetHost: Broker struct field? daemon local? Arc shared?).
14. Update orchestration location: trace `spt update apply` CLI → how it reaches applyhost (seed-control channel? direct?). One paragraph.
15. State migration on startup: does the daemon mutate/migrate on-disk state file formats at startup (any schema/version upgrade code touching $SPT_HOME state)? Search for version fields/migration in state load paths (registry JSON, applied.json, manifest caches, journal). Brief findings — this matters for binary-rollback safety after a failed update.
16. traceable-reqs.toml (repo root): quote the entries for REQ-UPD-3, REQ-UPD-4, REQ-UPD-6, REQ-UPD-8, REQ-DAEMON-3, REQ-DAEMON-9 (id, title/desc, required_stages). Then grep the repo for evidence tags `[impl->REQ-UPD-3]` / `[unit->REQ-UPD-3]` / `[int->REQ-UPD-3]` — where is REQ-UPD-3 currently evidenced, and does the evidence actually exercise endpoint survival across a brain restart (or only the in-process handoff)?
17. Search for any historical note that the in-process broker was a DELIBERATE interim step: grep M3*-PLAN.md files, git log messages (git log --oneline --all | grep -i broker), code comments near Broker::bind / daemon.rs broker setup for words like "for now", "interim", "later", "process split", "single process", "in-process". Goal: determine whether the single-process collapse was documented as deferral anywhere, or truly silent drift.

Return a numbered verdict list matching claim numbers. Conclusions only, no file dumps.


<owl_messages>
<owl_message from="doyle-w356" priority="info">[WORKING_PERCH_NOTICE] Subagent working perch created.
Perch: doyle-w356
Agent type: Explore
To message it: $OWL send doyle-w356 doyle</owl_message>
</owl_messages>

[OWL SYSTEM] Informational owl messages above — no acknowledgment needed. Continue your current task.


1	# Single consolidated daemon with broker/brain split; peer-propagated gated self-update
2
3	## Status
4
5	accepted (2026-05-29)
6
7	## Context
8
9	ADR-0002 collapsed PTY-hosting and network-hosting into one per-machine `spt-daemon`. Two further forces refine its shape:
10
11	1. **Consolidation.** The sister project runs poll listeners as ephemeral per-session background tasks and Psyche wrappers as detached per-live-agent supervisor processes. But poll listeners already interact directly with the agent session (capsule/idle), and Psyche wrappers already invoke harness binaries directly. Once the daemon owns every PTY, keeping these as separate processes is unjustified.
12
13	2. **Seamless self-update with a hard no-terminate constraint.** Self-update is a day-one pillar. The constraint: *no endpoint process may terminate or suspend during an spt-core update* — we cannot assume every endpoint can safely suspend. The naive "drain + restart the daemon" approach violates this for spt-hosted sessions (the daemon owns their PTY; killing the daemon SIGHUPs the child).
14
15	## Decision
16
17	**Consolidate all per-machine logic into the one daemon.** Poll-listener logic and Psyche/pulse loops move into the `spt-daemon` — no separate listener or wrapper processes. The only residue is a thin, stateless **in-session relay** for harness-hosted sessions (topology 1), where spt cannot reach into a process tree it doesn't own; the relay just pipes the daemon's events into the session's stdout and is freely killable.
18
19	**Split the logical daemon into two implementation layers** to satisfy the no-terminate invariant:
20	- **broker** (stable kernel) — holds *only* un-transferable, must-not-die resources: PTY master fds, spawned harness child processes, listening network sockets. Minimal, versioned local IPC. Almost never updates.
21	- **daemon brain** (userspace) — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely; rehydrates from disk state and re-attaches to broker-held handles.
22
23	Routine self-updates replace only the brain → endpoints never notice. The broker updates rarely (IPC-contract change, held-resource-type change, OS PTY/socket API change, or broker bugfix); the IPC is versioned so a newer brain talks to an older broker. Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is internal, not separately addressable.
24
25	Rejected alternatives for the invariant: **drain + restart** (violates no-terminate for spt-hosted sessions); **whole-daemon live FD-passing** (ConPTY handle transfer mid-swap is hard and platform-divergent — pushing that complexity into the rare broker update instead of every routine update is strictly better); **topology-1-only invariant with spt-hosted as fast-follow** (silently breaks the stated hard constraint for the exact new topology spt-core introduces).
26
27	**Self-update delivery:** peer-propagated over P2P, layered on self-fetch, out-of-band still supported. All binaries signature-verified before handoff (spt-core's own release key) regardless of source — peer-propagation otherwise lets one compromised node poison the subnet. spt-core conducts updates for the whole stack: self first, then ripple-update each registered adapter via the adapter manifest's update declaration (file-pull or delegated command). The plugin's role shrinks to initial bootstrap only.
28
29	**Cadence/consent:** not fully automatic by default; gated on user confirmation delivered to the most-recently-active live session, with an opt-in full-auto choice.
30
31	## Consequences
32
33	- The daemon is the single brain for a machine; crash-recovery and update logic must cover PTYs, networking, registry, spools, listeners, and psyche loops together.
34	- A small internal broker process exists beneath the daemon — a deliberate, bounded walk-back of "literally one process," preserving B1's *intent* (one network identity, one supervisor, one firewall prompt) while guaranteeing endpoint survival across updates.
35	- Peer-propagated updates make release signing mandatory, not optional.
36	- spt-core becomes the update conductor for adapters too; adapter manifests must declare an update avenue.
37	- The "deliver to most-recently-active session" mechanism is a v1 building block that the deferred PresenceChannel will later generalize.
38	- Whole-daemon live FD-passing (zero-interruption even for broker updates) remains a possible future polish but is explicitly not required for v1.
39
40	## Validation & amendments (2026-05-31 — Stage A red-team + Spike #1)
41
42	Codex adversarial review (`docs/reviews/STAGE-A-codex-redteam.md`) put 4 FATAL findings on this ADR; Spike #1 (`docs/spikes/SPIKE-01-broker-handoff.md`, `spt-spikes/spike-01-broker-handoff`) tested the hardest path. Resulting binding amendments:
43
44	### A. Update-class taxonomy (resolves FATAL #1 — R-UPD-3 self-contradiction)
45	R-UPD-3's "no endpoint terminates during update" is **only** absolute for the *brain-only* class. Three classes, each with its own invariant:
46	- **brain-only** (the routine case) — endpoints MUST survive untouched. **Spike #1: PROVEN on Windows ConPTY.**
47	- **broker-compatible** — broker binary swap behind a versioned IPC the running broker can hot-accept; endpoints survive; no spike yet.
48	- **broker-breaking** — held-resource-type or OS-API change; requires a *planned endpoint-cycle*. R-UPD-3 is explicitly weakened here: this class MAY suspend endpoints, with consent + scheduling. PRD R-UPD-3 must be reworded to scope the absolute guarantee to brain-only.
49
50	### B. Ownership table (resolves FATAL #4 — "any live stream owned by brain breaks the invariant")
51	The boundary is normative, not "broker holds fds, brain holds logic." A resource is broker-owned **iff** a live consumer would lose continuity on brain restart.
52
53	| Resource | Owner | Spiked? |
54	|---|---|---|
55	| PTY master read+write handle | **broker** | ✅ #1 |
56	| Spawned harness child process | **broker** | ✅ #1 |
57	| Accepted local client socket (`api listen`, relay) | **broker** | ✅ #1 (plain TCP) |
58	| Iroh endpoint + accepted QUIC streams | **broker** | ✅ #3 (loopback shape) → **implemented M4-D4a** (`spt-daemon::nethost::NetHost`: dedicated tokio runtime in the broker owns the endpoint + conn table; brain drives status/dial over IPC net frames) |
59	| mDNS socket / relay session | **broker** | ✅ closed by construction at M4-D4a (2026-06-03): `MdnsAddressLookup` and the relay session are constructed *inside* the broker-owned iroh endpoint — there is no separate socket to own |
60	| Listening sockets | **broker** | ✅ #1 |
61	| Routing, registry state, pulse/psyche loops, manifest parse, update orchestration | brain (rehydrate from disk) | n/a |
62
63	Consequence codex forced into the open: pushing live Iroh/QUIC ownership into the broker means networking stream-state lives in the "stable kernel," enlarging it. Accepted as the cost of the invariant; the broker is "stable" in *update cadence*, not in *narrowness*.
64
65	### C. ConPTY DSR gotcha (new hazard, discovered by Spike #1)
66	ConPTY withholds **all** child stdout until the terminal answers its startup cursor-position query (`ESC [ 6 n`). A broker reading a ConPTY master that does not reply `ESC [ <r>;<c> R` sees only the 4-byte query and then nothing — looks like a hung/silent child. Spike got 0 bytes of program output until the reader answered DSR, then full output. → `REQ-HAZARD-CONPTY-DSR`; every ConPTY reader must auto-answer DSR. Added to KNOWN-HAZARDS §5.
67
68	### D. Self-update delivery hardening (resolves SERIOUS #5)
69	Signature-verify alone is insufficient. v1 adds: monotonic version field (rollback rejection), release-metadata expiry, channel pinning, key rotation/revocation list, and **adapter content signing** (adapter file-pull / delegated-command updates were uncovered by "binary signature"). Track as `REQ-HAZARD-UPDATE-ROLLBACK`.
70
71	### E. Open spike gaps (must close before M3 builds the daemon)
72	1. ✅ **CLOSED (Spike #3, 2026-06-01).** Live Iroh + file-transfer stream survival across brain restart (FATAL #2). Spike #1 only proved PTY + plain TCP; Spike #3 stood up a broker-owned Iroh endpoint + uni QUIC transfer and proved a live peer download survives a brain restart gapless + exactly-once. Loopback shape validated; off-node transport + the QUIC-ownership *implementation* defer to M4. (`docs/spikes/SPIKE-03-quic-survival.md`.)
73	2. ✅ **CLOSED (Spike #4, 2026-06-01).** Linux `forkpty` parity. Spike #1's binary, unchanged, passed all four invariants on `gravity-linux` (Ubuntu 22.04) — `portable-pty` selects `forkpty` on Unix; the ConPTY-DSR branch is inert there. Invariant B passed with the *strict* contiguity check that ConPTY-under-resize fails (Spike #5): `forkpty` is a raw pipe, ConPTY a screen buffer — different stream contracts, promoted into M3a's `SessionSurface` design. (`docs/spikes/SPIKE-04-forkpty-parity.md`.)
74	3. ✅ **CLOSED (Spike #5, 2026-06-01).** 100× restart + resize-under-load stress (codex #3). The broker survived 100 rapid brain kill/restart cycles + a resize thread racing the reader with no leak (one child, clean reap), no hang (watchdog deadline), no lost byte (full value-set coverage in the client's observation window). Surfaced two binding ConPTY findings for M3a/REQ-TERM-3: resize triggers a repaint that reorders+dups the stream (terminal-stream contract ≠ exactly-once transfer contract); mid-session attach yields the viewport only, not scrollback. (`docs/spikes/SPIKE-05-restart-stress.md`.)
75	4. ✅ **CLOSED (Spike #6, 2026-06-01).** Idempotent/exactly-once delivery across brain restart (FATAL-adjacent #14). Durable-ID + WAL + dedup-at-effect proven exactly-once across a crash at any protocol point (before-intent / before-effect / after-effect). Two binding design constraints surfaced for `REQ-HAZARD-RESTART-IDEMPOTENT` / M3b-B5: broker-owned recovery anchor; dedup-at-effect keyed by durable ID. (`docs/spikes/SPIKE-06-idempotent-boundary.md`.)
76
77	**Gate status (2026-06-01): ALL FOUR §E GAPS CLOSED PASS** (#3, #4, #5, #6) → the M3-PLAN Phase-0 spike-gate is **complete**. The broker/brain split is validated on both OSes (ConPTY + `forkpty`), across QUIC-stream survival, restart+resize churn, and exactly-once idempotency. M3a and M3b are both unblocked; the QUIC-ownership *implementation* and off-node transport remain explicitly deferred to M4 (only the *shape* was spiked).
78

# Single consolidated daemon with broker/brain split; peer-propagated gated self-update

## Status

accepted (2026-05-29)

## Context

ADR-0002 collapsed PTY-hosting and network-hosting into one per-machine `spt-daemon`. Two further forces refine its shape:

1. **Consolidation.** The sister project runs poll listeners as ephemeral per-session background tasks and Psyche wrappers as detached per-live-agent supervisor processes. But poll listeners already interact directly with the agent session (capsule/idle), and Psyche wrappers already invoke harness binaries directly. Once the daemon owns every PTY, keeping these as separate processes is unjustified.

2. **Seamless self-update with a hard no-terminate constraint.** Self-update is a day-one pillar. The constraint: *no endpoint process may terminate or suspend during an spt-core update* — we cannot assume every endpoint can safely suspend. The naive "drain + restart the daemon" approach violates this for spt-hosted sessions (the daemon owns their PTY; killing the daemon SIGHUPs the child).

## Decision

**Consolidate all per-machine logic into the one daemon.** Poll-listener logic and Psyche/pulse loops move into the `spt-daemon` — no separate listener or wrapper processes. The only residue is a thin, stateless **in-session relay** for harness-hosted sessions (topology 1), where spt cannot reach into a process tree it doesn't own; the relay just pipes the daemon's events into the session's stdout and is freely killable.

**Split the logical daemon into two implementation layers** to satisfy the no-terminate invariant:
- **broker** (stable kernel) — holds *only* un-transferable, must-not-die resources: PTY master fds, spawned harness child processes, listening network sockets. Minimal, versioned local IPC. Almost never updates.
- **daemon brain** (userspace) — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely; rehydrates from disk state and re-attaches to broker-held handles.

Routine self-updates replace only the brain → endpoints never notice. The broker updates rarely (IPC-contract change, held-resource-type change, OS PTY/socket API change, or broker bugfix); the IPC is versioned so a newer brain talks to an older broker. Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is internal, not separately addressable.

Rejected alternatives for the invariant: **drain + restart** (violates no-terminate for spt-hosted sessions); **whole-daemon live FD-passing** (ConPTY handle transfer mid-swap is hard and platform-divergent — pushing that complexity into the rare broker update instead of every routine update is strictly better); **topology-1-only invariant with spt-hosted as fast-follow** (silently breaks the stated hard constraint for the exact new topology spt-core introduces).

**Self-update delivery:** peer-propagated over P2P, layered on self-fetch, out-of-band still supported. All binaries signature-verified before handoff (spt-core's own release key) regardless of source — peer-propagation otherwise lets one compromised node poison the subnet. spt-core conducts updates for the whole stack: self first, then ripple-update each registered adapter via the adapter manifest's update declaration (file-pull or delegated command). The plugin's role shrinks to initial bootstrap only.

**Cadence/consent:** not fully automatic by default; gated on user confirmation delivered to the most-recently-active live session, with an opt-in full-auto choice.

## Consequences

- The daemon is the single brain for a machine; crash-recovery and update logic must cover PTYs, networking, registry, spools, listeners, and psyche loops together.
- A small internal broker process exists beneath the daemon — a deliberate, bounded walk-back of "literally one process," preserving B1's *intent* (one network identity, one supervisor, one firewall prompt) while guaranteeing endpoint survival across updates.
- Peer-propagated updates make release signing mandatory, not optional.
- spt-core becomes the update conductor for adapters too; adapter manifests must declare an update avenue.
- The "deliver to most-recently-active session" mechanism is a v1 building block that the deferred PresenceChannel will later generalize.
- Whole-daemon live FD-passing (zero-interruption even for broker updates) remains a possible future polish but is explicitly not required for v1.

## Validation & amendments (2026-05-31 — Stage A red-team + Spike #1)

Codex adversarial review (`docs/reviews/STAGE-A-codex-redteam.md`) put 4 FATAL findings on this ADR; Spike #1 (`docs/spikes/SPIKE-01-broker-handoff.md`, `spt-spikes/spike-01-broker-handoff`) tested the hardest path. Resulting binding amendments:

### A. Update-class taxonomy (resolves FATAL #1 — R-UPD-3 self-contradiction)
R-UPD-3's "no endpoint terminates during update" is **only** absolute for the *brain-only* class. Three classes, each with its own invariant:
- **brain-only** (the routine case) — endpoints MUST survive untouched. **Spike #1: PROVEN on Windows ConPTY.**
- **broker-compatible** — broker binary swap behind a versioned IPC the running broker can hot-accept; endpoints survive; no spike yet.
- **broker-breaking** — held-resource-type or OS-API change; requires a *planned endpoint-cycle*. R-UPD-3 is explicitly weakened here: this class MAY suspend endpoints, with consent + scheduling. PRD R-UPD-3 must be reworded to scope the absolute guarantee to brain-only.

### B. Ownership table (resolves FATAL #4 — "any live stream owned by brain breaks the invariant")
The boundary is normative, not "broker holds fds, brain holds logic." A resource is broker-owned **iff** a live consumer would lose continuity on brain restart.

| Resource | Owner | Spiked? |
|---|---|---|
| PTY master read+write handle | **broker** | ✅ #1 |
| Spawned harness child process | **broker** | ✅ #1 |
| Accepted local client socket (`api listen`, relay) | **broker** | ✅ #1 (plain TCP) |
| Iroh endpoint + accepted QUIC streams | **broker** | ✅ #3 (loopback shape) → **implemented M4-D4a** (`spt-daemon::nethost::NetHost`: dedicated tokio runtime in the broker owns the endpoint + conn table; brain drives status/dial over IPC net frames) |
| mDNS socket / relay session | **broker** | ✅ closed by construction at M4-D4a (2026-06-03): `MdnsAddressLookup` and the relay session are constructed *inside* the broker-owned iroh endpoint — there is no separate socket to own |
| Listening sockets | **broker** | ✅ #1 |
| Routing, registry state, pulse/psyche loops, manifest parse, update orchestration | brain (rehydrate from disk) | n/a |

Consequence codex forced into the open: pushing live Iroh/QUIC ownership into the broker means networking stream-state lives in the "stable kernel," enlarging it. Accepted as the cost of the invariant; the broker is "stable" in *update cadence*, not in *narrowness*.

### C. ConPTY DSR gotcha (new hazard, discovered by Spike #1)
ConPTY withholds **all** child stdout until the terminal answers its startup cursor-position query (`ESC [ 6 n`). A broker reading a ConPTY master that does not reply `ESC [ <r>;<c> R` sees only the 4-byte query and then nothing — looks like a hung/silent child. Spike got 0 bytes of program output until the reader answered DSR, then full output. → `REQ-HAZARD-CONPTY-DSR`; every ConPTY reader must auto-answer DSR. Added to KNOWN-HAZARDS §5.

### D. Self-update delivery hardening (resolves SERIOUS #5)
Signature-verify alone is insufficient. v1 adds: monotonic version field (rollback rejection), release-metadata expiry, channel pinning, key rotation/revocation list, and **adapter content signing** (adapter file-pull / delegated-command updates were uncovered by "binary signature"). Track as `REQ-HAZARD-UPDATE-ROLLBACK`.

### E. Open spike gaps (must close before M3 builds the daemon)
1. ✅ **CLOSED (Spike #3, 2026-06-01).** Live Iroh + file-transfer stream survival across brain restart (FATAL #2). Spike #1 only proved PTY + plain TCP; Spike #3 stood up a broker-owned Iroh endpoint + uni QUIC transfer and proved a live peer download survives a brain restart gapless + exactly-once. Loopback shape validated; off-node transport + the QUIC-ownership *implementation* defer to M4. (`docs/spikes/SPIKE-03-quic-survival.md`.)
2. ✅ **CLOSED (Spike #4, 2026-06-01).** Linux `forkpty` parity. Spike #1's binary, unchanged, passed all four invariants on `gravity-linux` (Ubuntu 22.04) — `portable-pty` selects `forkpty` on Unix; the ConPTY-DSR branch is inert there. Invariant B passed with the *strict* contiguity check that ConPTY-under-resize fails (Spike #5): `forkpty` is a raw pipe, ConPTY a screen buffer — different stream contracts, promoted into M3a's `SessionSurface` design. (`docs/spikes/SPIKE-04-forkpty-parity.md`.)
3. ✅ **CLOSED (Spike #5, 2026-06-01).** 100× restart + resize-under-load stress (codex #3). The broker survived 100 rapid brain kill/restart cycles + a resize thread racing the reader with no leak (one child, clean reap), no hang (watchdog deadline), no lost byte (full value-set coverage in the client's observation window). Surfaced two binding ConPTY findings for M3a/REQ-TERM-3: resize triggers a repaint that reorders+dups the stream (terminal-stream contract ≠ exactly-once transfer contract); mid-session attach yields the viewport only, not scrollback. (`docs/spikes/SPIKE-05-restart-stress.md`.)
4. ✅ **CLOSED (Spike #6, 2026-06-01).** Idempotent/exactly-once delivery across brain restart (FATAL-adjacent #14). Durable-ID + WAL + dedup-at-effect proven exactly-once across a crash at any protocol point (before-intent / before-effect / after-effect). Two binding design constraints surfaced for `REQ-HAZARD-RESTART-IDEMPOTENT` / M3b-B5: broker-owned recovery anchor; dedup-at-effect keyed by durable ID. (`docs/spikes/SPIKE-06-idempotent-boundary.md`.)

**Gate status (2026-06-01): ALL FOUR §E GAPS CLOSED PASS** (#3, #4, #5, #6) → the M3-PLAN Phase-0 spike-gate is **complete**. The broker/brain split is validated on both OSes (ConPTY + `forkpty`), across QUIC-stream survival, restart+resize churn, and exactly-once idempotency. M3a and M3b are both unblocked; the QUIC-ownership *implementation* and off-node transport remain explicitly deferred to M4 (only the *shape* was spiked).