=== .local/bin contents ===
-rwxr-xr-x 1 reavus reavus 35934048 Jun 11 00:37 /home/reavus/.local/bin/spt
-rwxrwxr-x 1 reavus reavus 35783616 Jun  9 01:57 /home/reavus/.local/bin/spt.old-6
-rwxr-xr-x 1 reavus reavus 35786280 Jun  9 03:17 /home/reavus/.local/bin/spt.old-7
-rwxr-xr-x 1 reavus reavus 35859464 Jun 10 19:17 /home/reavus/.local/bin/spt.old-8
=== hash /home/reavus/.local/bin/spt (canonical daemon path) ===
35c9544cfdd4883fb87d8ca8da1798addb82b4464b7441e79ae4db7617314f08
=== is /usr/local/bin/spt a symlink? ===
lrwxrwxrwx 1 root root 27 Jun  8 21:09 /usr/local/bin/spt -> /home/reavus/.local/bin/spt
/home/reavus/.local/bin/spt
=== expected: 35c9544c = v0.4.1 ===


Confirmed beyond doubt:
- canonical daemon path `/home/reavus/.local/bin/spt` = **`35c9544c` (v0.4.1)** — apply swapped correctly
- `/usr/local/bin/spt` → symlink → canonical (CLI is v0.4.1, consistent)
- broker **and respawned brain** both exec `.old-8` = **`b4b7ea14` (v0.4.0)**

So: Linux `apply` swaps the canonical binary correctly, records "applied v8", respawns the brain (new pid, gen→1) — **but the brain comes up on the OLD bytes**. The resident v0.4.0 broker respawns the brain via its own `current_exe()`, which on Linux follows the swap-rename to `.old-8` instead of re-resolving the canonical install path. **Windows (hfenduleam) ran new bytes correctly; Linux (kitsubito) runs stale code while recording success.**

This is a real bug — restoration's central invariant ("brain restarts *onto the new binary*") fails on Linux. doyle's bytes-level exe_hash assert is exactly what caught it. **Restoration close is BLOCKED.** Escalating to doyle (mechanism + the D7 E2E gap that let it through) + deployah (release impact).


Bash

$OWL ring doyle 2>/dev/null <<'EOF'
BLOCKER — kitsubito (Linux) seamless-update FAILS the bytes assert. Need your read + code confirm.

FACTS (authoritative, hashes not timestamps):
  systemd ExecStart = /home/reavus/.local/bin/spt
  canonical /home/reavus/.local/bin/spt = 35c9544c (v0.4.1)  ← apply SWAPPED correctly
  /usr/local/bin/spt -> symlink -> canonical (CLI reads v0.4.1, consistent)
  broker pid 819830 exe = .local/bin/spt.old-8 = b4b7ea14 (v0.4.0)   [resident broker, expected unchanged]
  brain  pid 1247825 (was 819839, gen 0->1) exe = .old-8 = b4b7ea14 (v0.4.0)   ← THE BUG
  records: applied.json {version:8}, applied-state {phase:applied,version:8}, last-outcome {applied}  ← recorded SUCCESS, NO rollback
  pump healed (STALLED 18189s -> live) — but via the OLD-bytes brain restart

DIAGNOSIS: Linux apply swaps by renaming running `spt` -> `spt.old-N` then writing new bytes to `spt`. The resident v0.4.0 broker respawns the brain via std::env::current_exe() (or /proc/self/exe), which on Linux FOLLOWS the rename to .old-8 — so the new brain execs OLD bytes. The on-disk swap is correct; the RESPAWN PATH is wrong. Recorded "applied" because a brain came up ready — readiness != new-bytes. hfenduleam (Windows) got 728a1bfc (new) because Win can't rename a running exe under it, so current_exe stays canonical.

IMPLICATIONS:
 1. Restoration central invariant ("brain restarts ONTO the new binary") FAILS on Linux in production. Sibling of KH 6.7 in a new guise.
 2. The D7 brain_survive.rs E2E passed but evidently does NOT exercise the production rename-then-current_exe respawn path on Linux (else it'd catch this). Want your eyes on whether the E2E spawns the brain from an explicit path vs current_exe.
 3. The deployed Linux brokers are RESIDENT v0.4.0 — a fix (broker respawns brain from re-resolved canonical install path, not current_exe) only takes effect after a BROKER restart. So Linux needs one more broker bounce to load a fixed broker → the "last manual bounce" slips on Linux.
 4. Restoration CLOSE is BLOCKED. hfenduleam Windows proof stands; Linux is red.

ASKS: (a) confirm the respawn-path mechanism from brainproc.rs / the broker spawn site; (b) is this a v0.4.2 brain-respawn-path fix (mint REQ-HAZARD-BRAIN-RESPAWN-PATH?); (c) your call on the E2E gap. I'm holding kitsubito as-is (v0.4.0 brain, unwedged). Not touching enlyzeam.
EOF
echo "ring-exit:${PIPESTATUS[0]}"

Ring doyle with the Linux blocker diagnosis


deployah is authorized to publish when it's ready. just message them


Ring sent (reply will arrive via stream). Informing deployah of release impact.


$OWL send deployah 2>/dev/null <<'EOF'
HOLD the close — fleet roll surfaced a LINUX BLOCKER. v0.4.1 is fine; the bug is in the seamless-update RESPAWN PATH on Linux.

hfenduleam (Win): GREEN — brain runs your published 728a1bfc, no bounce. Genuinely seamless.
kitsubito (Linux): RED — apply swapped the canonical binary to v0.4.1 (35c9544c on disk, confirmed) and RECORDED "applied v8", but the respawned brain execs spt.old-8 = b4b7ea14 (v0.4.0). The resident v0.4.0 broker respawns the brain via current_exe(), which on Linux follows the apply-rename to .old-8 → new brain runs OLD bytes. Readiness passed (old brain came up) so it recorded success — the bytes assert is what caught it.

IMPACT: seamless-update is Windows-only right now. Linux fleet needs a broker-respawn-path fix (v0.4.2) AND a broker bounce to load it (resident v0.4.0 broker can't self-fix). Restoration close BLOCKED. Don't yank v0.4.1 — the release artifacts are correct; the defect is in the already-deployed v0.4.0 broker's respawn logic. doyle confirming mechanism from brainproc.rs. Will loop you on the v0.4.2 plan. enlyzeam catch-up on hold.
EOF
echo "exit:${PIPESTATUS[0]}"

Inform deployah of Linux blocker


Both escalated. While doyle reviews, verifying the mechanism myself from the code — find the brain-respawn spawn site.


Found 15 files
crates\spt-daemon\src\brain.rs
crates\spt-daemon\src\deadline.rs
crates\spt-daemon\src\brainproc.rs
crates\spt-daemon\src\broker.rs
crates\spt-daemon\src\rollback_compat.rs
crates\spt-daemon\src\applyhost.rs
crates\spt-daemon\src\relcache.rs
crates\spt-daemon\src\lifecycle.rs
crates\spt-daemon\src\service.rs
crates\spt-daemon\src\msg.rs
crates\spt-daemon\src\daemon.rs
crates\spt-daemon\src\endpoint.rs
crates\spt-daemon\src\deelevate.rs
crates\spt-daemon\src\shellwake.rs
crates\spt-daemon\src\relay.rs


308-    let mut obj = serde_json::json!({ "pid": std::process::id(), "generation": generation });
309-    \ Best-effort: omit `exe_hash` if the self-read fails (additive — a reader
310-    // tolerates its absence). Never fail the ready write over a diagnostic field.
311:    if let Some(hash) = current_exe_hash() {
312-        obj["exe_hash"] = serde_json::Value::String(hash);
313-    }
314-    let _ = spt_store::atomic::atomic_write_string(&brain_ready_path(), &obj.to_string());
315-}
316-
317-/// Lowercase-hex SHA-256 of the running process's own executable file
318:/// (best-effort; `None` if `current_exe` or the file read fails — degrades safe,
319-\ the breadcrumb simply omits the field). Restoration D7-1: the `exe_hash`
320-/// breadcrumb that proves *which bytes* a brain generation is running. One exe
321-/// read + hash per brain start — negligible, and it pays rent far beyond the
322-/// D7-1 test (the resident-bytes diagnostic the enlyzeam regression lacked).
323:fn current_exe_hash() -> Option<String> {
324:    let exe = std::env::current_exe().ok()?;
325-    let bytes = std::fs::read(exe).ok()?;
326-    Some(crate::release::sha256_hex(&bytes))
327-}
--
600-/// untouched throughout — the broker holds them across the whole cycle.
601-///
602-/// Generic over `spawn_child` (which receives the **selected binary** — `None` =
603:/// the default `current_exe`, `Some(path)` = the rollback binary) and `env`, so
604-/// the unit harness injects fakes; production binds [`spawn_brain_child`] +
605-/// [`ProductionTrialEnv`].
606-// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
--
804-/// broker stamps `{generation, start-reason}` onto the argv (ADR-0018 V2).
805-///
806-\ `binary` is the **record-driven selection** (D6-2): `None` spawns the default
807:/// `current_exe` (the accepted / candidate binary), `Some(path)` spawns the
808-/// rollback binary (the `.old-N` last-known-good) — the supervisor chooses, no
809-/// file is renamed at the failure instant.
810-fn spawn_brain_child(
--
814-) -> io::Result<Child> {
815-    let exe = match binary {
816-        Some(p) => p.to_path_buf(),
817:        None => std::env::current_exe()?,
818-    };
819:    let mut cmd = Command::new(exe);
820-    cmd.args(brain_child_args(generation, reason));
821-    // The brain is a background logic process — it must never flash a console
822-    // window. On Windows a plain console-subsystem spawn pops one whenever the
--
882-    fn fast_child() -> io::Result<Child> {
883-        #[cfg(windows)]
884-        {
885:            Command::new("cmd").args(["/C", "rem"]).spawn()
886-        }
887-        #[cfg(unix)]
888-        {
889:            Command::new("true").spawn()
890-        }
891-    }
892-
--
897-        #[cfg(windows)]
898-        {
899-            // ping -n 30 loopback ≈ 29 s of blocking; the supervisor kills it.
900:            Command::new("cmd")
901-                .args(["/C", "ping", "-n", "30", "127.0.0.1"])
902-                .stdout(std::process::Stdio::null())
903-                .spawn()
904-        }
905-        #[cfg(unix)]
906-        {
907:            Command::new("sleep").arg("30").spawn()
908-        }
909-    }
910-
--
1252-    fn pid_alive(pid: u32) -> bool {
1253-        #[cfg(unix)]
1254-        {
1255:            Command::new("kill")
1256-                .args(["-0", &pid.to_string()])
1257-                .output()
1258-                .map(|o| o.status.success())
--
1260-        }
1261-        #[cfg(windows)]
1262-        {
1263:            Command::new("tasklist")
1264-                .args(["/FI", &format!("PID eq {pid}"), "/NH"])
1265-                .output()
1266-                .map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string()))
--
1310-        assert!(env.clears.load(Ordering::Relaxed) >= 1, "ready cleared before the trial spawn");
1311-        let s = spawns.lock().unwrap();
1312-        assert_eq!(s[0].1, StartReason::Cold, "first spawn is the cold trial candidate");
1313:        assert_eq!(s[0].2, None, "candidate spawns the default current_exe");
1314-        assert_eq!(s[1].2, None, "a later crash respawns the SAME accepted binary, not a rollback");
1315-    }
1316-
--
1361-            vec![StartReason::Cold, StartReason::Crash, StartReason::Crash, StartReason::Crash],
1362-            "K=3 trial spawns (Cold then Crash×2) all gated, then the rollback respawn"
1363-        );
1364:        assert_eq!(s[0].2, None, "trial candidate = current_exe");
1365-        assert_eq!(
1366-            s[3].2.as_deref(),
1367-            Some("/good/spt.old-9"),
--
1549-
1550-    \ (6) Record-driven reboot: a fresh supervisor (generation reset to 0) whose
1551-    /// durable record is already `RolledBack` spawns the rollback binary directly
1552:    /// — never `current_exe` (whose bytes are the quarantined version). Rollback
1553-    /// survives a reboot for free, no file rename.
1554-    // [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
1555-    #[test]
--
1579-        assert_eq!(
1580-            s[0].as_deref(),
1581-            Some("/good/spt.old-6"),
1582:            "a fresh broker reading a RolledBack record spawns the good binary, not current_exe"
1583-        );
1584-        assert!(env.promotions.lock().unwrap().is_empty());
1585-        assert!(env.rollbacks.lock().unwrap().is_empty(), "no NEW rollback — it is the recovery steady state");


**Root cause confirmed in code** — `brainproc.rs:815-818`:
```rust
let exe = match binary {
    Some(p) => p.to_path_buf(),
    None => std::env::current_exe()?,   // ← candidate spawn: wrong on Linux post-rename
};
```
The normal candidate respawn (`binary=None`) execs `current_exe()`. Linux `apply` renames running `spt`→`spt.old-N`; the resident broker's `current_exe()` follows the rename to `.old-N`, so the new brain execs OLD bytes. Rollback path (`Some(path)`) is fine; the *normal* path is the bug. `current_exe_hash()` (l.324) also reads current_exe → the breadcrumb honestly reported old bytes. Unit tests fake `spawn_child` so never hit real `current_exe`-after-rename. Checking the D7 E2E for the same gap.


find . -name "brain_survive.rs" 2>/dev/null; echo "==="; grep -n "current_exe\|rename\|\.old\|swap\|fs::copy\|fs::rename\|apply\|ExecStart\|install" crates/spt/tests/brain_survive.rs 2>/dev/null | head -30

Check D7 E2E for rename-swap coverage


<owl_messages>
<owl_message from="deployah" priority="high">Diagnosis reads correct, and importantly NOT a release defect — v0.4.1 artifacts are sound (kitsubito on-disk canonical == 35c9544c, my published linux hash). No yank. The bug is the resident v0.4.0 broker's Linux respawn: current_exe() → /proc/self/exe follows the apply-rename to spt.old-8 (b4b7ea14), so the respawned brain execs OLD bytes while recording "applied v8". Readiness gate passed on the old brain = false-success; only the exe_hash assert caught it. Windows dodges it because you can't rename a running PE in place, so the swap lands new bytes.

Likely fix shape (doyle owns brainproc.rs confirm): respawn the brain by the resolved CANONICAL install path, not current_exe() — re-stat the install location post-apply so exec picks up the new bytes, never the .old-N rename. And gate readiness-success on the BYTES assert (respawned exe_hash == applied artifact), not just "brain came up" — that's the hole that recorded a bad apply as good.

Two traceability flags before the v0.4.2 fix (project rule — mint REQ first):
  1. new REQ-HAZARD for "post-apply respawn must exec applied bytes, not the renamed old binary" + a Linux test asserting respawned brain exe_hash == artifact.
  2. the readiness-success-without-bytes-assert gap likely wants its own REQ tightening.

Caveat: v0.4.2 needs a one-time MANUAL broker bounce on Linux to load the fixed respawn logic (resident v0.4.0 broker can't self-fix) — so this becomes the project's actual final manual bounce, paired with enlyzeam catch-up.

Loop me on the plan. When v0.4.2 lands green I take the publish leg = counter 9 (v0.4.1=8), same path, seed resident. Idle on-call.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


./crates/spt/tests/brain_survive.rs
===
2://! swap**: a hosted PTY child + a live QUIC connection survive a brain-**PROCESS**
3://! restart that lands on a **swapped on-disk binary**, with the broker holding
6://! `brain_swap.rs` shape onto a real process boundary.
17://!   a real on-disk binary swap + real broker-**held** endpoints (a PTY child and
21://!   resources** the real broker would across a brain swap. The claim under test
22://!   is the brain process + the swapped binary + broker-held endpoint survival —
25://! - **Leg (c) — the field leg: D7-4.** The real daemon, a real `apply`, real
33://! ## What makes this STRONGER than `brain_swap.rs`
35://! `brain_swap.rs` (REQ-UPD-3's prior `int`) relaunches the brain via an
38://! [`supervise_brain`] spawns, swaps the on-disk binary it respawns from, and
45://! The swap is modeled as a **path flip** between two distinct on-disk fixtures
47://! is identical but the hash flips). This is faithful to the production swap (the
53://! zombie conn or wedged PTY would pass. So after the swap the test also drives a
56://! swapped-in brain.
84:/// forbids us from disturbing across the swap). `cat`/`findstr` idle on stdin.
185:fn pty_and_quic_survive_brain_process_restart_onto_swapped_binary() {
197:    // ── Two fixtures: A, and B = A + appended padding (the on-disk "swap"). ──
204:    std::fs::copy(&spt_bin, &fixture_a).expect("stage fixture A");
205:    std::fs::copy(&spt_bin, &fixture_b).expect("stage fixture B");
216:    assert_ne!(hash_a, hash_b, "the swap must flip the binary hash");
249:    //    pre-D6 respawn loop (no readiness gate); the swap is driven by the
250:    //    planned-restart signal, exactly as a real `apply` drives it (D3-3). ──
305:    //    (what `apply` does: swap on disk, signal the brain to cycle). ──
312:        .unwrap_or_else(|| teardown_panic(&stop, "gen-1 brain never respawned onto the swap"));
317:        "post-swap exe_hash must be fixture B — the supervisor ran the NEW bytes, \
318:         not a relaunch of the old (the property brain_swap.rs cannot prove)"
323:        "post-swap exe_hash must differ from the pre-swap binary"
327:    //    the brain-PROCESS swap. ──
331:        "REQ-UPD-3: the hosted PTY child's pid is unchanged across the brain-process swap"
337:        "the live QUIC conn the broker holds survived the brain-process swap (SPIKE-03 in production topology)"


deployah aligned. And the E2E gap is now **confirmed**: `brain_survive.rs` models the swap as a **path-flip between two distinct fixtures** (l.45, l.197–216) — the supervisor respawns from a flipped path and correctly gets new bytes. It never reproduces production Linux's **in-place rename** (`spt`→`.old-N`) + `current_exe()`-follows-rename. The test sidesteps the exact mechanism that breaks prod → passed green while prod fails.

ACK deployah + capturing the finding durably (survives /clear).


$OWL send deployah 2>/dev/null <<'EOF'
ACK — fully aligned. Confirmed in code: brainproc.rs:815-818 candidate spawn = std::env::current_exe() (binary=None path); rollback path Some(.old-N) is fine. AND the E2E gap is pinned: brain_survive.rs models the swap as a PATH-FLIP between two distinct fixtures (fixture_a→fixture_b), so the supervisor respawns from the flipped path and gets new bytes — it never does an in-place rename of the running binary, so current_exe()-follows-rename is never exercised. That's why CI was green on kitsubito while prod fails.

v0.4.2 plan (doyle to confirm brainproc.rs + REQ shapes):
 R1 mint REQ-HAZARD-BRAIN-RESPAWN-PATH: post-apply candidate respawn must exec the applied bytes via the resolved CANONICAL install path (captured at broker boot, pre-rename), never current_exe()/.old-N.
 R2 tighten readiness-success: promotion asserts respawned exe_hash == applied artifact hash, not just "a brain came up" (closes the false-success hole).
 FIX brainproc.rs candidate spawn → canonical install path.
 TEST a Linux in-place-rename apply E2E asserting respawned exe_hash == artifact (closes the path-flip gap).
 GATES + CI both runners → you publish counter 9.
 FLEET: one-time MANUAL broker bounce on Linux (resident v0.4.0 broker can't self-fix) = the project's actual final bounce, paired with enlyzeam catch-up. THEN restoration close.
Counter 9 noted for you. Building once doyle confirms + operator greenlights the v0.4.2 pivot.
EOF
echo "exit:${PIPESTATUS[0]}"

ACK deployah + v0.4.2 plan


Write

---
name: v041-linux-respawn-blocker
description: v0.4.1 fleet roll found a Linux brain-respawn-path bug; restoration close BLOCKED pending v0.4.2 fix
metadata:
  type: project
---

v0.4.1 (counter 8) PUBLISHED 2026-06-11 (deployah, PR #4 merged @ 038257b, tag v0.4.1). Fleet roll surfaced a **Linux seamless-update bug** → restoration close BLOCKED, v0.4.2 needed.

**hfenduleam (Windows): GREEN.** apply → brain pid 38324→6620, gen 0→1, broker pid 17232 UNCHANGED, exe_hash 81c24675(0.4.0)→728a1bfc(0.4.1 win artifact), pump STALLED 16199s→live. No-bounce PROVEN on Windows.

**kitsubito (Linux): RED.** apply recorded "applied v8" (applied-state phase:applied, no rollback), canonical `/home/reavus/.local/bin/spt` correctly swapped to 35c9544c (v0.4.1 linux artifact) — BUT respawned brain (pid 1247825, gen 1) execs `.local/bin/spt.old-8` = b4b7ea14 (v0.4.0). **Brain runs OLD bytes while recording success.**

**ROOT CAUSE (code-confirmed `brainproc.rs:815-818`):** candidate respawn `binary=None` → `std::env::current_exe()`. Linux `apply` renames running `spt`→`spt.old-N`, writes new bytes to `spt`; resident broker's `current_exe()` follows the rename to `.old-N`, so the new brain execs old bytes. Rollback path `Some(.old-N)` is fine; the NORMAL path is wrong. Windows dodges it (can't rename a running PE in place). `current_exe_hash()` (l.324) also reads current_exe → breadcrumb honestly showed old bytes; doyle's bytes-level exe_hash assert is what caught it (readiness gate passed on the old brain = false-success).

**E2E gap (`brain_survive.rs`):** models swap as a PATH-FLIP between two distinct fixtures (fixture_a→fixture_b), never an in-place rename — so `current_exe()`-follows-rename is never exercised; CI green on kitsubito while prod fails.

**v0.4.2 plan (deployah+todlando aligned; doyle confirming):**
- R1 mint `REQ-HAZARD-BRAIN-RESPAWN-PATH`: respawn must exec applied bytes via resolved canonical install path (captured at broker boot, pre-rename), never current_exe/.old-N.
- R2 tighten readiness-success: promotion asserts respawned exe_hash == applied artifact hash (close false-success hole).
- FIX brainproc.rs candidate spawn → canonical install path.
- TEST Linux in-place-rename apply E2E asserting respawned exe_hash == artifact.
- GATES + CI both runners → deployah publishes **counter 9**.
- FLEET: resident v0.4.0 brokers can't self-fix → v0.4.2 needs a **one-time MANUAL broker bounce on Linux** = project's actual FINAL manual bounce, paired with enlyzeam catch-up. THEN restoration close.

See [[v040-published-fleet-rolled]], [[pump-ipc-deadline-fix]].


<owl_messages>
<owl_message from="todlando" priority="high">doyle RULING — diagnosis CONFIRMED at both code points. Holding kitsubito + enlyzeam as-is is correct. Restoration close: BLOCKED, agreed.

(a) CODE CONFIRM:
- brainproc.rs:817 `spawn_brain_child`: `None => std::env::current_exe()?` — resolved PER SPAWN. Linux readlink(/proc/self/exe) is INODE-tracking and follows the apply rename to .old-N; Windows GetModuleFileName is the path STRING at process start, stays canonical. Exact platform divergence you diagnosed; ADR-0018 Q3 "auto-respawns from the executable path (now the new binary)" silently ASSUMED path-string semantics — the assumption is the bug.
- brain_survive.rs E2E gap confirmed: its header says the swap "is faithful to the production swap (the supervisor respawns from whatever the binary selection resolves to)" — but the test models the swap as a PATH FLIP through the injected spawn closure, abstracting away the production resolution (the current_exe default), which is precisely the platform-divergent part. The model boundary was drawn one layer too high; the faithfulness claim didn't hold for Linux.

(b) FIX RULING — v0.4.2, two halves, one new hazard REQ:
1. Respawn path: capture the canonical exe path ONCE at broker start (current_exe at t=0, before any apply can rename under us) and pass THAT as spawn_brain_child's default — never per-spawn current_exe. This makes Linux behave like Windows (path-at-start semantics), zero install-path discovery, record-driven rollback selection (Some path) unchanged. Symlink starts are fine (current_exe fully resolves at capture).
2. Promotion bytes-gate: TrialEnv promotion gains the assert this incident proves we need — a trial promotes ONLY if brain.ready exe_hash == the staged artifact's hash for this platform (both present; absent degrades to today's readiness-only, N-1-safe). Readiness != new-bytes was the lie that recorded applied:8 over a v0.4.0-bytes brain — the D7-4 manual assert becomes an automatic gate; this failure mode becomes auto-rollback + loud notif instead of a silently wrong record.
- Mint REQ-HAZARD-BRAIN-RESPAWN-PATH, required_stages [doc, impl, unit, int], activated in the fix change: doc = new KNOWN-HAZARDS entry (sibling of 6.7, the platform-divergent current_exe trap + readiness≠bytes); unit = spawn-path selection (captured vs per-spawn) + promotion-gate truth table; int = (c) below.

(c) E2E GAP FIX (the int evidence): brain_survive grows (or gains a sibling for) the RENAME-UNDER-THE-SUPERVISOR shape — supervisor running with binary=None default through the PRODUCTION selection path; test renames the spawn binary P → P.old-N and writes new bytes at P (the real apply choreography, not a path flip); assert post-respawn exe_hash == new bytes. That test FAILS on Linux against current_exe and passes after the fix — the regression guard on both runners.

OPS SEQUENCE (Linux honesty — the "last manual bounce" slips ONE release on Linux, say so plainly):
- kitsubito: leave as-is now. Apply v0.4.2 when published — NOTE the resident v0.4.0 broker will respawn the brain from .old-9 (v0.4.1 bytes — the bug is in the RESIDENT broker, it can't fix itself brain-only) → then ONE manual bounce loads the fixed v0.4.2 broker. That is kitsubito's last bounce.
- enlyzeam: catch up straight to v0.4.2 — its one catch-up bounce doubles as its fix-load. Last bounce, once.
- Linux no-bounce OBSERVATION then needs the NEXT release after v0.4.2 (or a debug rollout on the lab channel if we don't want to wait).
- Records on kitsubito: applied.json says 8 but resident brain is v0.4.0 bytes — the enlyzeam-class record/reality divergence, NOW PROVABLE via exe_hash. Capture it in the appendix as evidence FOR the promotion bytes-gate; don't hand-correct the record (v0.4.2's gated promotion supersedes it).

RESTORATION CLOSE: hold. Windows leg PROVEN (hfenduleam stands). Linux leg RED — the central invariant failed in production; closing now would be the same optimistic-record sin the milestone exists to kill. Close criteria: v0.4.2 fix landed + kitsubito's post-fix seamless observation (next release or debug rollout). Surface to operator for the timeline call.

GO on drafting the v0.4.2 fix plan when ready — same loop: plan-confirm → execute → gates.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


File created successfully at: C:\Users\decid\.ccs\instances\bigscreen\projects\C--Users-decid-Documents-projects-spt-core\memory\v041-linux-respawn-blocker.md (file state is current in your context — no need to Read it back)

---
name: v041-linux-respawn-blocker
description: v0.4.1 fleet roll found a Linux brain-respawn-path bug; restoration close BLOCKED pending v0.4.2 fix
metadata:
  node_type: memory
  type: project
  originSessionId: f3f208d9-9c31-4aaa-acfc-98acbfdf2509
---

v0.4.1 (counter 8) PUBLISHED 2026-06-11 (deployah, PR #4 merged @ 038257b, tag v0.4.1). Fleet roll surfaced a **Linux seamless-update bug** → restoration close BLOCKED, v0.4.2 needed.

**hfenduleam (Windows): GREEN.** apply → brain pid 38324→6620, gen 0→1, broker pid 17232 UNCHANGED, exe_hash 81c24675(0.4.0)→728a1bfc(0.4.1 win artifact), pump STALLED 16199s→live. No-bounce PROVEN on Windows.

**kitsubito (Linux): RED.** apply recorded "applied v8" (applied-state phase:applied, no rollback), canonical `/home/reavus/.local/bin/spt` correctly swapped to 35c9544c (v0.4.1 linux artifact) — BUT respawned brain (pid 1247825, gen 1) execs `.local/bin/spt.old-8` = b4b7ea14 (v0.4.0). **Brain runs OLD bytes while recording success.**

**ROOT CAUSE (code-confirmed `brainproc.rs:815-818`):** candidate respawn `binary=None` → `std::env::current_exe()`. Linux `apply` renames running `spt`→`spt.old-N`, writes new bytes to `spt`; resident broker's `current_exe()` follows the rename to `.old-N`, so the new brain execs old bytes. Rollback path `Some(.old-N)` is fine; the NORMAL path is wrong. Windows dodges it (can't rename a running PE in place). `current_exe_hash()` (l.324) also reads current_exe → breadcrumb honestly showed old bytes; doyle's bytes-level exe_hash assert is what caught it (readiness gate passed on the old brain = false-success).

**E2E gap (`brain_survive.rs`):** models swap as a PATH-FLIP between two distinct fixtures (fixture_a→fixture_b), never an in-place rename — so `current_exe()`-follows-rename is never exercised; CI green on kitsubito while prod fails.

**v0.4.2 plan (deployah+todlando aligned; doyle confirming):**
- R1 mint `REQ-HAZARD-BRAIN-RESPAWN-PATH`: respawn must exec applied bytes via resolved canonical install path (captured at broker boot, pre-rename), never current_exe/.old-N.
- R2 tighten readiness-success: promotion asserts respawned exe_hash == applied artifact hash (close false-success hole).
- FIX brainproc.rs candidate spawn → canonical install path.
- TEST Linux in-place-rename apply E2E asserting respawned exe_hash == artifact.
- GATES + CI both runners → deployah publishes **counter 9**.
- FLEET: resident v0.4.0 brokers can't self-fix → v0.4.2 needs a **one-time MANUAL broker bounce on Linux** = project's actual FINAL manual bounce, paired with enlyzeam catch-up. THEN restoration close.

See [[v040-published-fleet-rolled]], [[pump-ipc-deadline-fix]].


doyle's ruling is comprehensive — confirms both code points, sets the fix shape, ops sequence, and close criteria. Full alignment with deployah. ACK doyle, then surfacing the timeline decision to the operator.


Bash

$OWL send doyle 2>/dev/null <<'EOF'
ACK ruling — captured in full, fully aligned. Fix shape locked: (1) capture canonical exe path once at broker start, pass as spawn_brain_child default (never per-spawn current_exe); (2) promotion bytes-gate (exe_hash == staged artifact, degrade-to-readiness if absent); mint REQ-HAZARD-BRAIN-RESPAWN-PATH [doc,impl,unit,int] activated in the fix; int = brain_survive rename-under-supervisor sibling (P→P.old-N + new bytes at P, assert respawned exe_hash == new bytes; fails Linux pre-fix). Holding kitsubito/enlyzeam as-is, not hand-correcting the applied:8 record (appendix evidence for the bytes-gate). Surfacing to operator now for the timeline call (v0.4.2 greenlight + final-Linux-observation method: debug-channel rollout vs next real release). Drafting the fix plan on greenlight.
EOF
echo "exit:${PIPESTATUS[0]}"

ACK doyle's ruling


<owl_messages>
<owl_message from="deployah" priority="high">Fully aligned — R1/R2 + fix + Linux in-place-rename E2E is exactly the shape. The path-flip-vs-real-rename gap is the crux: brain_survive.rs flipping fixture_a→fixture_b never renames a RUNNING binary, so current_exe()-follows-rename was never exercised — CI green, prod red. New test MUST do the real thing: rename the running canonical binary → .old-N, drop new at canonical, THEN assert respawned exe_hash == artifact. Anything short of an in-place rename reproduces the false-green.

One belt add on R2: make the bytes-assert mismatch FAIL the apply → trigger rollback, not just refuse-to-promote. Today's hole recorded false-success; the fix should turn a future bytes-mismatch into an automatic rollback to the known-good .old-N, so a respawn regression self-heals instead of stranding old bytes under a "v8 applied" record. I.e. R2 = promotion gate AND rollback trigger.

Counter 9 locked for me, seed resident, publisher path warm. Standing by for doyle confirm + operator greenlight on the v0.4.2 pivot. If you want a hand on the brainproc.rs canonical-path capture or the rename E2E, I'm a live agent in-repo — say the word. Idle otherwise.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


deployah refines R2: bytes-mismatch should **trigger rollback** (not just refuse-to-promote) so a future respawn regression self-heals to `.old-N` instead of stranding old bytes under a false "applied" record. R2 = promotion gate AND rollback trigger. Both partners aligned; deployah offers in-repo help. ACK + surfacing to operator.


$OWL send deployah 2>/dev/null <<'EOF'
ACK — R2 belt folded: bytes-mismatch FAILS apply → auto-rollback to .old-N (gate AND rollback trigger), so a future respawn regression self-heals instead of stranding old bytes under a false applied record. (Note: only protects updates once the v0.4.2 broker is resident; the v0.4.2 apply itself rides the buggy v0.4.0 broker → still needs the one manual Linux bounce, consistent with doyle's ops seq.) Counter 9 locked. Surfacing the v0.4.2 greenlight + final-Linux-observation method to operator now. May take you up on the rename-E2E hand once the plan's approved — standby.
EOF
echo "exit:${PIPESTATUS[0]}"

ACK deployah R2 belt


## Where we are — v0.4.1 published, fleet roll found a real Linux bug

**v0.4.1 (counter 8) is PUBLIC** (deployah, merged → main `038257b`, signed counter 8). Then the fleet roll — which *is* the restoration no-bounce acceptance — split:

- **hfenduleam (Windows): GREEN.** `spt update apply`, zero manual bounce: broker pid held (17232), brain pid rolled, gen 0→1, exe_hash flipped to the published v0.4.1 bytes, pump wedge healed (STALLED 16199s → live). Genuinely seamless.
- **kitsubito (Linux): RED.** apply swapped the canonical binary to v0.4.1 correctly and recorded "applied v8" — **but the respawned brain runs OLD v0.4.0 bytes.**

**Root cause (code- + vet-confirmed):** `brainproc.rs:817` respawns the brain via `std::env::current_exe()`. Linux `/proc/self/exe` is inode-tracking and *follows* the apply-rename (`spt`→`spt.old-N`), so the brain execs the old binary; Windows resolves the path-string at process start and stays canonical. ADR-0018 silently assumed Windows semantics. Readiness passed on the old brain → **false-success**; only doyle's bytes-level exe_hash assert caught it. The D7 E2E missed it because it models the swap as a path-flip between two fixtures, never an in-place rename of a running binary.

**Consequence:** seamless-update is Windows-only today. **Restoration close is BLOCKED** — closing on a Windows-only proof would be the exact optimistic-record sin the milestone exists to kill. v0.4.1 release artifacts are sound (no yank); the defect is in the already-deployed v0.4.0 broker's respawn logic.

**v0.4.2 fix (doyle + deployah aligned, mint `REQ-HAZARD-BRAIN-RESPAWN-PATH`):**
1. Capture canonical exe path once at broker start (pre-rename), spawn brains from that — never per-spawn `current_exe`. Makes Linux behave like Windows.
2. Promotion bytes-gate: trial promotes only if respawned `exe_hash == staged artifact hash`; mismatch → auto-rollback (turns this whole failure mode into self-heal + loud notif).
3. New E2E: real in-place rename-under-supervisor, assert respawned bytes == artifact (fails Linux pre-fix).

**Linux ops tail (honest):** the "last manual bounce" slips one release on Linux — v0.4.2 is applied *by* the buggy resident broker, so it needs one manual broker bounce to load the fixed broker (kitsubito + enlyzeam, each once). Observing the *fixed* broker's seamless behavior then needs a subsequent brain-only update.

Two calls are yours:


AskUserQuestion

Question: Greenlight building the v0.4.2 brain-respawn-path fix now?

Choices:
I draft the fix plan, mint REQ-HAZARD-BRAIN-RESPAWN-PATH, implement both halves (canonical-path capture + promotion bytes-gate/rollback) + the rename-under-supervisor E2E, run all gates + CI both runners, hand to deployah to publish counter 9. doyle reviews per the standing gated-work loop.
I write a RESTORATION-style fix plan doc and surface it for your approval before writing any code.
Don't start v0.4.2 yet — park the finding (captured in memory + docs) and stop here.

Question: How should we get the final Linux no-bounce observation that closes restoration (since v0.4.2 itself rides the buggy broker + needs a manual bounce — the fixed broker's seamless behavior is only observable on a later brain-only update)?

Choices:
After v0.4.2 + the one Linux bounce, push a debug-channel test build to the lab nodes (DEBUG-ROLLOUT.md / REQ-UPD-6) to observe seamless no-bounce on the fixed broker — proves it without waiting for an unrelated real release.
Leave restoration open until the next genuine release (v0.4.3) naturally rolls and is observed seamless on Linux. No synthetic rollout; longer tail.
Defer this — revisit once v0.4.2 is built and the Linux brokers are bounced.


<owl_messages>
<owl_message from="deployah" priority="high">Right — self-heal is forward-protection only; the v0.4.2 apply can't bootstrap itself past the v0.4.0 broker, so the one manual Linux bounce stands as the final bounce. Plan's converged. Counter 9 locked, publisher path warm. Standing by for operator greenlight — ping me for the rename-E2E when the plan's approved (real in-place rename of the running canonical binary → assert respawned exe_hash == artifact). Idle.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


Your questions have been answered: "Greenlight building the v0.4.2 brain-respawn-path fix now?"="plan first, doyle's approval-->proceed to build, vet, and publish", "How should we get the final Linux no-bounce observation that closes restoration (since v0.4.2 itself rides the buggy broker + needs a manual bounce — the fixed broker's seamless behavior is only observable on a later brain-only update)?"="Debug-channel rollout". You can now continue with these answers in mind.

Question: Greenlight building the v0.4.2 brain-respawn-path fix now?

Choices:
I draft the fix plan, mint REQ-HAZARD-BRAIN-RESPAWN-PATH, implement both halves (canonical-path capture + promotion bytes-gate/rollback) + the rename-under-supervisor E2E, run all gates + CI both runners, hand to deployah to publish counter 9. doyle reviews per the standing gated-work loop.
I write a RESTORATION-style fix plan doc and surface it for your approval before writing any code.
Don't start v0.4.2 yet — park the finding (captured in memory + docs) and stop here.

Question: How should we get the final Linux no-bounce observation that closes restoration (since v0.4.2 itself rides the buggy broker + needs a manual bounce — the fixed broker's seamless behavior is only observable on a later brain-only update)?

Choices:
After v0.4.2 + the one Linux bounce, push a debug-channel test build to the lab nodes (DEBUG-ROLLOUT.md / REQ-UPD-6) to observe seamless no-bounce on the fixed broker — proves it without waiting for an unrelated real release.
Leave restoration open until the next genuine release (v0.4.3) naturally rolls and is observed seamless on Linux. No synthetic rollout; longer tail.
Defer this — revisit once v0.4.2 is built and the Linux brokers are bounced.

plan first, doyle's approval-->proceed to build, vet, and publish


Operator: **plan first → doyle approves → then build/vet/publish** (post-plan greenlight delegated to doyle), and **debug-channel rollout** for the final Linux proof. deployah standing by for the rename-E2E.

ACK deployah + reading the broker-start + promotion code to ground the plan.


Bash

$OWL send deployah 2>/dev/null <<'EOF'
Operator greenlight: PLAN FIRST → doyle approves the plan → then I build/vet/publish (operator delegated the post-plan go to doyle). Final Linux observation = DEBUG-CHANNEL ROLLOUT (your counter-9 publish + the one manual Linux bounce, then a debug build to the lab nodes to watch the fixed broker roll seamless). Drafting V042-PLAN.md now. I'll ping you for the rename-under-supervisor E2E once doyle signs the plan. Counter 9 stays locked. Standby.
EOF
echo "exit:${PIPESTATUS[0]}"

ACK deployah + operator greenlight


590	/// **trial** (the latch is the *record*, **A8** — regardless of start reason):
591	/// the supervisor clears `brain.ready` (**A9**), spawns the candidate, and waits
592	/// for it to stamp ready for ITS generation within `window`. It **promotes** on
593	/// ready (writes `Applied`, the candidate is now the accepted binary and a later
594	/// crash is a normal respawn); it **rolls back** to the last-known-good binary
595	/// on budget exhaustion — [`TRIAL_PREREADY_BUDGET`] consecutive pre-ready exits
596	/// **or** one alive-but-never-ready window-expiry (which **kills** the candidate
597	/// first, **A11** — never two live brains). Binary selection is **record-driven**
598	/// (a `RolledBack` record spawns the good `.old-N` binary, surviving a reboot for
599	/// free), so no file is renamed at the failure instant. Every hosted endpoint is
600	/// untouched throughout — the broker holds them across the whole cycle.
601	///
602	/// Generic over `spawn_child` (which receives the **selected binary** — `None` =
603	/// the default `current_exe`, `Some(path)` = the rollback binary) and `env`, so
604	/// the unit harness injects fakes; production binds [`spawn_brain_child`] +
605	/// [`ProductionTrialEnv`].
606	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
607	pub fn supervise_brain(
608	    stop: &AtomicBool,
609	    restart: &BrainRestart,
610	    base: Duration,
611	    env: &dyn TrialEnv,
612	    window: Duration,
613	    mut spawn_child: impl FnMut(u64, StartReason, Option<&Path>) -> io::Result<Child>,
614	) {
615	    let mut backoff = base;
616	    // Broker-owned generation custody (ADR-0018 V2, KH-2.4): the counter lives in
617	    // the never-restarting supervisor and increments on EVERY spawn (cold or
618	    // respawn), so the broker — which observes every respawn — is the
619	    // authoritative source, not the outgoing brain. The first spawn is `Cold`;
620	    // every supervised respawn here is a `Crash` (an unexpected exit the
621	    // supervisor recovers from). A *planned* update respawn stamps `Update`
622	    // instead — wired in D3-3, where the apply trigger drives this loop.
623	    let mut generation: u64 = 0;
624	    let mut reason = StartReason::Cold;
625	    // Consecutive pre-ready exits of the CURRENT trial candidate (A8 budget K).
626	    // In-memory: a broker restart resets it — acceptable, the trial restarts.
627	    let mut pre_ready_exits: u32 = 0;
628	    while !stop.load(Ordering::Relaxed) {
629	        let started = Instant::now();
630	        // Record-driven binary selection + trial latch (A8). The latch is the
631	        // unpromoted `AppliedPending` record, NOT the start reason; a `RolledBack`
632	        // record selects the last-known-good binary (and is never a trial — it is
633	        // the recovery steady state). A missing/corrupt record degrades to "no
634	        // trial, default binary" — the supervisor never panics on the record.
635	        let record = env.applied_state();
636	        let binary: Option<PathBuf> = match &record {
637	            Some(AppliedRecord::RolledBack { rollback_binary, .. }) => {
638	                Some(PathBuf::from(rollback_binary))
639	            }
640	            _ => None,
641	        };
642	        let is_trial = matches!(record, Some(AppliedRecord::AppliedPending { .. }));
643	        // A9: clear `brain.ready` immediately before a trial spawn — the prior
644	        // child is already waited-on dead (below), so the only stamp that can
645	        // exist post-clear is THIS trial child's. Closes the cross-lifetime hole
646	        // that same-generation equality alone cannot (a stale gen-N file from a
647	        // pre-reboot lifetime matching a fresh gen-N trial).
648	        if is_trial {
649	            env.clear_ready();
650	        }
651	        // Did this cycle end because of a planned update restart (Q3) rather than
652	        // an unexpected crash? The broker — observing the cycle it initiated — is
653	        // the authoritative source of that distinction (V2).
654	        let mut planned = false;
655	        match spawn_child(generation, reason, binary.as_deref()) {
656	            Ok(mut child) => {
657	                // The trial gate runs first for a candidate on trial; on
658	                // promotion it falls through to the normal wait loop for the SAME
659	                // child. A non-trial spawn skips straight to normal supervision.
660	                let mut supervise = !is_trial;
661	                if is_trial {
662	                    match run_trial(&mut child, generation, window, env, stop) {
663	                        TrialStep::Stopped => {
664	                            let _ = child.kill();
665	                            let _ = child.wait();
666	                            return;
667	                        }
668	                        TrialStep::Promoted => {
669	                            if let Some(AppliedRecord::AppliedPending { version, .. }) = &record {
670	                                eprintln!("BRAIN_PROMOTED: candidate v{version} signalled ready (generation {generation}) — swap accepted");
671	                                env.record_promoted(*version);
672	                            }
673	                            pre_ready_exits = 0;
674	                            backoff = base;
675	                            supervise = true; // keep supervising the now-accepted child
676	                        }
677	                        TrialStep::PreReadyExit => {
678	                            pre_ready_exits += 1;
679	                            eprintln!("BRAIN_TRIAL_EXIT: candidate exited pre-ready ({pre_ready_exits}/{TRIAL_PREREADY_BUDGET})");
680	                            if pre_ready_exits >= TRIAL_PREREADY_BUDGET {
681	                                rollback(env, &record);
682	                                pre_ready_exits = 0;
683	                                // The rollback binary is known-good — respawn it
684	                                // promptly, no crash-penalty backoff (mirror the
685	                                // WindowElapsedAlive path); only the under-budget
686	                                // candidate retries keep the doubling backoff.
687	                                backoff = base;
688	                            } else {
689	                                backoff = next_backoff(backoff, started.elapsed(), base);
690	                            }
691	                            generation += 1;
692	                            reason = StartReason::Crash;
693	                            sleep_backoff(backoff, stop);
694	                            continue;
695	                        }
696	                        TrialStep::WindowElapsedAlive => {
697	                            // A11: KILL the alive-but-never-ready candidate BEFORE
698	                            // spawning the rollback binary — two live brains would
699	                            // break every single-brain seed/socket/IPC assumption.
700	                            eprintln!("BRAIN_TRIAL_TIMEOUT: candidate alive but never ready — killing before rollback");
701	                            let _ = child.kill();
702	                            let _ = child.wait();
703	                            rollback(env, &record);
704	                            pre_ready_exits = 0;
705	                            generation += 1;
706	                            // The rollback binary is known-good — respawn promptly,
707	                            // no crash-penalty backoff.
708	                            reason = StartReason::Crash;
709	                            backoff = base;
710	                            continue;
711	                        }
712	                    }
713	                }
714	                if supervise {
715	                    loop {
716	                        if stop.load(Ordering::Relaxed) {
717	                            let _ = child.kill();
718	                            let _ = child.wait();
719	                            return;
720	                        }
721	                        // A planned brain-restart request (apply swapped the
722	                        // binary, ADR-0018 D3-3): drop this brain so the respawn
723	                        // lands on the new executable. A deliberate kill, NOT a
724	                        // crash — stamp it `update`.
725	                        if restart.take() {
726	                            eprintln!("BRAIN_UPDATE_RESTART: planned restart — cycling brain onto the swapped binary");
727	                            let _ = child.kill();
728	                            let _ = child.wait();
729	                            planned = true;
730	                            break;
731	                        }
732	                        match child.try_wait() {
733	                            Ok(Some(status)) => {
734	                                eprintln!("BRAIN_EXIT: brain child exited ({status}) — respawning");
735	                                break;
736	                            }
737	                            Ok(None) => thread::sleep(TICK),
738	                            Err(e) => {
739	                                eprintln!("BRAIN_WAIT_FAIL: {e}");
740	                                break;
741	                            }
742	                        }
743	                    }
744	                }
745	            }
746	            Err(e) => eprintln!("BRAIN_SPAWN_FAIL: {e}"),
747	        }
748	        if stop.load(Ordering::Relaxed) {
749	            return;
750	        }
751	        // Next spawn: advance the generation (every start, KH-2.4). A planned
752	        // update respawns promptly with `start-reason=update` (it is not a crash,
753	        // so the crash backoff does not apply); a crash recovery keeps the capped
754	        // doubling backoff and stamps `crash`.
755	        generation += 1;
756	        if planned {
757	            reason = StartReason::Update;
758	            backoff = base;
759	            eprintln!("BRAIN_RESTART: planned update respawn (generation {generation})");
760	            continue;
761	        }
762	        reason = StartReason::Crash;
763	        backoff = next_backoff(backoff, started.elapsed(), base);
764	        eprintln!("BRAIN_RESTART: supervised respawn in {}s", backoff.as_secs());
765	        sleep_backoff(backoff, stop);
766	    }
767	}
768
769	/// Effect the rollback for an exhausted trial (D6-2): from the candidate's
770	/// `AppliedPending` record, write `RolledBack{quarantine=N, running, rollback_binary}`
771	/// and fire the loud notif (via `env`). `running` is the candidate's
772	/// `prior_version` (the version actually running before the swap, captured by
773	/// `apply`) — NOT `N-1`, which is wrong on a skip-version update (applied v5 →
774	/// candidate v8: the `.old-8` aside holds v5 bytes, so `running` is v5, not v7).
775	/// It degrades to `N-1` only for a pre-D6-2b record that lacks `prior_version`.
776	/// A record that is not `AppliedPending` (already rolled back, or raced away) is
777	/// a no-op — never a double rollback.
778	///
779	/// **Rollback is SELECTION, not an update (open-call-4).** The recovery never
780	/// routes through the update engine / `apply` / `verify_metadata`: it just makes
781	/// the supervisor *spawn the `.old-N` binary*. So the anti-downgrade monotonic
782	/// version floor (`REQ-HAZARD-UPDATE-ROLLBACK` — *attack* protection that refuses
783	/// a version *downgrade*) **never sees** this recovery, and the two concerns stay
784	/// cleanly separate. Do NOT "fix" `verify_metadata` to permit recovery
785	/// downgrades — selection-not-apply is the mechanism, by design.
786	fn rollback(env: &dyn TrialEnv, record: &Option<AppliedRecord>) {
787	    if let Some(AppliedRecord::AppliedPending {
788	        version,
789	        rollback_binary,
790	        prior_version,
791	        ..
792	    }) = record
793	    {
794	        let running = prior_version.unwrap_or(version.saturating_sub(1));
795	        eprintln!("BRAIN_ROLLBACK: update v{version} failed readiness — rolling back to v{running} ({rollback_binary}), quarantining v{version}");
796	        env.record_rolled_back(*version, running, rollback_binary);
797	    }
798	}
799
800	/// Spawn the production brain child: re-exec `daemon brain` as a plain
801	/// (non-detached) managed child, so it inherits the broker's environment —
802	/// including `SPT_HOME` — and dies with no orphaning. NOT `spawn_detached`
803	/// (that path rebuilds the Windows env block and would drop `SPT_HOME`). The
804	/// broker stamps `{generation, start-reason}` onto the argv (ADR-0018 V2).
805	///
806	/// `binary` is the **record-driven selection** (D6-2): `None` spawns the default
807	/// `current_exe` (the accepted / candidate binary), `Some(path)` spawns the
808	/// rollback binary (the `.old-N` last-known-good) — the supervisor chooses, no
809	/// file is renamed at the failure instant.
810	fn spawn_brain_child(
811	    generation: u64,
812	    reason: StartReason,
813	    binary: Option<&Path>,
814	) -> io::Result<Child> {
815	    let exe = match binary {
816	        Some(p) => p.to_path_buf(),
817	        None => std::env::current_exe()?,
818	    };
819	    let mut cmd = Command::new(exe);

/// **trial** (the latch is the *record*, **A8** — regardless of start reason):
/// the supervisor clears `brain.ready` (**A9**), spawns the candidate, and waits
/// for it to stamp ready for ITS generation within `window`. It **promotes** on
/// ready (writes `Applied`, the candidate is now the accepted binary and a later
/// crash is a normal respawn); it **rolls back** to the last-known-good binary
/// on budget exhaustion — [`TRIAL_PREREADY_BUDGET`] consecutive pre-ready exits
/// **or** one alive-but-never-ready window-expiry (which **kills** the candidate
/// first, **A11** — never two live brains). Binary selection is **record-driven**
/// (a `RolledBack` record spawns the good `.old-N` binary, surviving a reboot for
/// free), so no file is renamed at the failure instant. Every hosted endpoint is
/// untouched throughout — the broker holds them across the whole cycle.
///
/// Generic over `spawn_child` (which receives the **selected binary** — `None` =
/// the default `current_exe`, `Some(path)` = the rollback binary) and `env`, so
/// the unit harness injects fakes; production binds [`spawn_brain_child`] +
/// [`ProductionTrialEnv`].
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
pub fn supervise_brain(
    stop: &AtomicBool,
    restart: &BrainRestart,
    base: Duration,
    env: &dyn TrialEnv,
    window: Duration,
    mut spawn_child: impl FnMut(u64, StartReason, Option<&Path>) -> io::Result<Child>,
) {
    let mut backoff = base;
    // Broker-owned generation custody (ADR-0018 V2, KH-2.4): the counter lives in
    // the never-restarting supervisor and increments on EVERY spawn (cold or
    // respawn), so the broker — which observes every respawn — is the
    // authoritative source, not the outgoing brain. The first spawn is `Cold`;
    // every supervised respawn here is a `Crash` (an unexpected exit the
    // supervisor recovers from). A *planned* update respawn stamps `Update`
    // instead — wired in D3-3, where the apply trigger drives this loop.
    let mut generation: u64 = 0;
    let mut reason = StartReason::Cold;
    // Consecutive pre-ready exits of the CURRENT trial candidate (A8 budget K).
    // In-memory: a broker restart resets it — acceptable, the trial restarts.
    let mut pre_ready_exits: u32 = 0;
    while !stop.load(Ordering::Relaxed) {
        let started = Instant::now();
        // Record-driven binary selection + trial latch (A8). The latch is the
        // unpromoted `AppliedPending` record, NOT the start reason; a `RolledBack`
        // record selects the last-known-good binary (and is never a trial — it is
        // the recovery steady state). A missing/corrupt record degrades to "no
        // trial, default binary" — the supervisor never panics on the record.
        let record = env.applied_state();
        let binary: Option<PathBuf> = match &record {
            Some(AppliedRecord::RolledBack { rollback_binary, .. }) => {
                Some(PathBuf::from(rollback_binary))
            }
            _ => None,
        };
        let is_trial = matches!(record, Some(AppliedRecord::AppliedPending { .. }));
        // A9: clear `brain.ready` immediately before a trial spawn — the prior
        // child is already waited-on dead (below), so the only stamp that can
        // exist post-clear is THIS trial child's. Closes the cross-lifetime hole
        // that same-generation equality alone cannot (a stale gen-N file from a
        // pre-reboot lifetime matching a fresh gen-N trial).
        if is_trial {
            env.clear_ready();
        }
        // Did this cycle end because of a planned update restart (Q3) rather than
        // an unexpected crash? The broker — observing the cycle it initiated — is
        // the authoritative source of that distinction (V2).
        let mut planned = false;
        match spawn_child(generation, reason, binary.as_deref()) {
            Ok(mut child) => {
                // The trial gate runs first for a candidate on trial; on
                // promotion it falls through to the normal wait loop for the SAME
                // child. A non-trial spawn skips straight to normal supervision.
                let mut supervise = !is_trial;
                if is_trial {
                    match run_trial(&mut child, generation, window, env, stop) {
                        TrialStep::Stopped => {
                            let _ = child.kill();
                            let _ = child.wait();
                            return;
                        }
                        TrialStep::Promoted => {
                            if let Some(AppliedRecord::AppliedPending { version, .. }) = &record {
                                eprintln!("BRAIN_PROMOTED: candidate v{version} signalled ready (generation {generation}) — swap accepted");
                                env.record_promoted(*version);
                            }
                            pre_ready_exits = 0;
                            backoff = base;
                            supervise = true; // keep supervising the now-accepted child
                        }
                        TrialStep::PreReadyExit => {
                            pre_ready_exits += 1;
                            eprintln!("BRAIN_TRIAL_EXIT: candidate exited pre-ready ({pre_ready_exits}/{TRIAL_PREREADY_BUDGET})");
                            if pre_ready_exits >= TRIAL_PREREADY_BUDGET {
                                rollback(env, &record);
                                pre_ready_exits = 0;
                                // The rollback binary is known-good — respawn it
                                // promptly, no crash-penalty backoff (mirror the
                                // WindowElapsedAlive path); only the under-budget
                                // candidate retries keep the doubling backoff.
                                backoff = base;
                            } else {
                                backoff = next_backoff(backoff, started.elapsed(), base);
                            }
                            generation += 1;
                            reason = StartReason::Crash;
                            sleep_backoff(backoff, stop);
                            continue;
                        }
                        TrialStep::WindowElapsedAlive => {
                            // A11: KILL the alive-but-never-ready candidate BEFORE
                            // spawning the rollback binary — two live brains would
                            // break every single-brain seed/socket/IPC assumption.
                            eprintln!("BRAIN_TRIAL_TIMEOUT: candidate alive but never ready — killing before rollback");
                            let _ = child.kill();
                            let _ = child.wait();
                            rollback(env, &record);
                            pre_ready_exits = 0;
                            generation += 1;
                            // The rollback binary is known-good — respawn promptly,
                            // no crash-penalty backoff.
                            reason = StartReason::Crash;
                            backoff = base;
                            continue;
                        }
                    }
                }
                if supervise {
                    loop {
                        if stop.load(Ordering::Relaxed) {
                            let _ = child.kill();
                            let _ = child.wait();
                            return;
                        }
                        // A planned brain-restart request (apply swapped the
                        // binary, ADR-0018 D3-3): drop this brain so the respawn
                        // lands on the new executable. A deliberate kill, NOT a
                        // crash — stamp it `update`.
                        if restart.take() {
                            eprintln!("BRAIN_UPDATE_RESTART: planned restart — cycling brain onto the swapped binary");
                            let _ = child.kill();
                            let _ = child.wait();
                            planned = true;
                            break;
                        }
                        match child.try_wait() {
                            Ok(Some(status)) => {
                                eprintln!("BRAIN_EXIT: brain child exited ({status}) — respawning");
                                break;
                            }
                            Ok(None) => thread::sleep(TICK),
                            Err(e) => {
                                eprintln!("BRAIN_WAIT_FAIL: {e}");
                                break;
                            }
                        }
                    }
                }
            }
            Err(e) => eprintln!("BRAIN_SPAWN_FAIL: {e}"),
        }
        if stop.load(Ordering::Relaxed) {
            return;
        }
        // Next spawn: advance the generation (every start, KH-2.4). A planned
        // update respawns promptly with `start-reason=update` (it is not a crash,
        // so the crash backoff does not apply); a crash recovery keeps the capped
        // doubling backoff and stamps `crash`.
        generation += 1;
        if planned {
            reason = StartReason::Update;
            backoff = base;
            eprintln!("BRAIN_RESTART: planned update respawn (generation {generation})");
            continue;
        }
        reason = StartReason::Crash;
        backoff = next_backoff(backoff, started.elapsed(), base);
        eprintln!("BRAIN_RESTART: supervised respawn in {}s", backoff.as_secs());
        sleep_backoff(backoff, stop);
    }
}

/// Effect the rollback for an exhausted trial (D6-2): from the candidate's
/// `AppliedPending` record, write `RolledBack{quarantine=N, running, rollback_binary}`
/// and fire the loud notif (via `env`). `running` is the candidate's
/// `prior_version` (the version actually running before the swap, captured by
/// `apply`) — NOT `N-1`, which is wrong on a skip-version update (applied v5 →
/// candidate v8: the `.old-8` aside holds v5 bytes, so `running` is v5, not v7).
/// It degrades to `N-1` only for a pre-D6-2b record that lacks `prior_version`.
/// A record that is not `AppliedPending` (already rolled back, or raced away) is
/// a no-op — never a double rollback.
///
/// **Rollback is SELECTION, not an update (open-call-4).** The recovery never
/// routes through the update engine / `apply` / `verify_metadata`: it just makes
/// the supervisor *spawn the `.old-N` binary*. So the anti-downgrade monotonic
/// version floor (`REQ-HAZARD-UPDATE-ROLLBACK` — *attack* protection that refuses
/// a version *downgrade*) **never sees** this recovery, and the two concerns stay
/// cleanly separate. Do NOT "fix" `verify_metadata` to permit recovery
/// downgrades — selection-not-apply is the mechanism, by design.
fn rollback(env: &dyn TrialEnv, record: &Option<AppliedRecord>) {
    if let Some(AppliedRecord::AppliedPending {
        version,
        rollback_binary,
        prior_version,
        ..
    }) = record
    {
        let running = prior_version.unwrap_or(version.saturating_sub(1));
        eprintln!("BRAIN_ROLLBACK: update v{version} failed readiness — rolling back to v{running} ({rollback_binary}), quarantining v{version}");
        env.record_rolled_back(*version, running, rollback_binary);
    }
}

/// Spawn the production brain child: re-exec `daemon brain` as a plain
/// (non-detached) managed child, so it inherits the broker's environment —
/// including `SPT_HOME` — and dies with no orphaning. NOT `spawn_detached`
/// (that path rebuilds the Windows env block and would drop `SPT_HOME`). The
/// broker stamps `{generation, start-reason}` onto the argv (ADR-0018 V2).
///
/// `binary` is the **record-driven selection** (D6-2): `None` spawns the default
/// `current_exe` (the accepted / candidate binary), `Some(path)` spawns the
/// rollback binary (the `.old-N` last-known-good) — the supervisor chooses, no
/// file is renamed at the failure instant.
fn spawn_brain_child(
    generation: u64,
    reason: StartReason,
    binary: Option<&Path>,
) -> io::Result<Child> {
    let exe = match binary {
        Some(p) => p.to_path_buf(),
        None => std::env::current_exe()?,
    };
    let mut cmd = Command::new(exe);


Trial/promotion flow clear: `supervise_brain` promotes in the `TrialStep::Promoted` arm (l.668 `env.record_promoted`) — that's where the bytes-gate goes; production spawn binding `spawn_brain_child` (l.810) is the `current_exe()` site to fix. Finding the production call site + `TrialEnv` + where the staged artifact hash is reachable.


echo "=== supervise_brain call sites ==="; grep -rn "supervise_brain\|spawn_brain_child\|ProductionTrialEnv" crates/spt-daemon/src --include=*.rs | grep -v "fn supervise_brain\|fn spawn_brain_child" | head
echo "=== TrialEnv trait methods ==="; grep -n "trait TrialEnv" crates/spt-daemon/src/brainproc.rs; sed -n '/trait TrialEnv/,/^}/p' crates/spt-daemon/src/brainproc.rs 2>/dev/null | head -40

Find supervise_brain call site + TrialEnv trait


<owl_messages>
<owl_message from="deployah" priority="high">Got the sequence: plan → doyle sign → build/vet → counter-9 publish + manual Linux bounce → debug-channel rollout to lab nodes to watch the fixed v0.4.2 broker roll seamless (the real in-place-rename proof in the field). Counter 9 locked. Ping me for the rename-under-supervisor E2E when doyle signs V042-PLAN.md. Idle.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


=== supervise_brain call sites ===
crates/spt-daemon/src/brainproc.rs:382:/// ([`ProductionTrialEnv`]). Every method is **best-effort / fail-safe**: a
crates/spt-daemon/src/brainproc.rs:430:pub struct ProductionTrialEnv;
crates/spt-daemon/src/brainproc.rs:432:impl ProductionTrialEnv {
crates/spt-daemon/src/brainproc.rs:438:impl TrialEnv for ProductionTrialEnv {
crates/spt-daemon/src/brainproc.rs:604:/// the unit harness injects fakes; production binds [`spawn_brain_child`] +
crates/spt-daemon/src/brainproc.rs:605:/// [`ProductionTrialEnv`].
crates/spt-daemon/src/brainproc.rs:862:        let env = ProductionTrialEnv;
crates/spt-daemon/src/brainproc.rs:863:        supervise_brain(
crates/spt-daemon/src/brainproc.rs:869:            spawn_brain_child,
crates/spt-daemon/src/brainproc.rs:924:        supervise_brain(
=== TrialEnv trait methods ===
387:pub trait TrialEnv {
pub trait TrialEnv {
    /// The current two-phase applied record, or `None` for no pending trial
    /// (absent/corrupt degrades to `None`).
    fn applied_state(&self) -> Option<AppliedRecord>;
    /// The generation stamped in `brain.ready`, or `None` if absent/garbled
    /// (the readiness gate reads `None` as not-ready — fail-safe).
    fn ready_generation(&self) -> Option<u64>;
    /// Delete `brain.ready` immediately before a trial spawn (**A9** — close the
    /// cross-lifetime staleness hole: the in-memory generation resets to 0 on a
    /// broker restart, so a stale gen-0 file would otherwise match a fresh gen-0
    /// trial and falsely promote a brain that never booted).
    fn clear_ready(&self);
    /// Promote the candidate: it reached ready. Persist `Applied{version}` plus
    /// the legacy `applied.json`/`last-outcome=applied` convergence records
    /// (relocated here from `apply`, where they were the optimistic enlyzeam
    /// write — promotion is the *real* moment the new code is accepted).
    fn record_promoted(&self, version: u64);
    /// Correct the record: the candidate failed readiness. Persist
    /// `RolledBack{…}` and fire the loud, resurfacing rollback notif.
    fn record_rolled_back(&self, quarantine_version: u64, running_version: u64, rollback_binary: &str);
}


deployah aligned, idle. I have the seams. Reading the production supervisor call site + `ProductionTrialEnv` impl to ground the plan precisely.


430	pub struct ProductionTrialEnv;
431
432	impl ProductionTrialEnv {
433	    fn cache(&self) -> ReleaseCache {
434	        ReleaseCache::open(&spt_store::perch::spt_home().join("releases"))
435	    }
436	}
437
438	impl TrialEnv for ProductionTrialEnv {
439	    fn applied_state(&self) -> Option<AppliedRecord> {
440	        self.cache().applied_state()
441	    }
442	    fn ready_generation(&self) -> Option<u64> {
443	        ready_generation_at(&brain_ready_path())
444	    }
445	    fn clear_ready(&self) {
446	        let _ = std::fs::remove_file(brain_ready_path());
447	    }
448	    fn record_promoted(&self, version: u64) {
449	        let cache = self.cache();
450	        let _ = cache.record_applied_state(&AppliedRecord::Applied { version });
451	        // The legacy convergence records (M8 `xtask debug-converge`) are written
452	        // HERE — at the real promotion — not optimistically at swap (D6-1).
453	        let _ = cache.record_applied(version);
454	        let _ = cache.record_last_outcome("applied");
455	    }
456	    fn record_rolled_back(&self, quarantine_version: u64, running_version: u64, rollback_binary: &str) {
457	        let cache = self.cache();
458	        let _ = cache.record_applied_state(&AppliedRecord::RolledBack {
459	            quarantine_version,
460	            running_version,
461	            rollback_binary: rollback_binary.to_string(),
462	        });
463	        let _ = cache.record_last_outcome(&format!("rolled-back:{quarantine_version}"));
464	        fire_rollback_notif(quarantine_version, running_version);
465	    }
466	}
467
468	/// Fire the loud, resurfacing auto-rollback notif on every member subnet
469	/// (restoration D6-2). Best-effort: a notif failure must never derail the

pub struct ProductionTrialEnv;

impl ProductionTrialEnv {
    fn cache(&self) -> ReleaseCache {
        ReleaseCache::open(&spt_store::perch::spt_home().join("releases"))
    }
}

impl TrialEnv for ProductionTrialEnv {
    fn applied_state(&self) -> Option<AppliedRecord> {
        self.cache().applied_state()
    }
    fn ready_generation(&self) -> Option<u64> {
        ready_generation_at(&brain_ready_path())
    }
    fn clear_ready(&self) {
        let _ = std::fs::remove_file(brain_ready_path());
    }
    fn record_promoted(&self, version: u64) {
        let cache = self.cache();
        let _ = cache.record_applied_state(&AppliedRecord::Applied { version });
        // The legacy convergence records (M8 `xtask debug-converge`) are written
        // HERE — at the real promotion — not optimistically at swap (D6-1).
        let _ = cache.record_applied(version);
        let _ = cache.record_last_outcome("applied");
    }
    fn record_rolled_back(&self, quarantine_version: u64, running_version: u64, rollback_binary: &str) {
        let cache = self.cache();
        let _ = cache.record_applied_state(&AppliedRecord::RolledBack {
            quarantine_version,
            running_version,
            rollback_binary: rollback_binary.to_string(),
        });
        let _ = cache.record_last_outcome(&format!("rolled-back:{quarantine_version}"));
        fire_rollback_notif(quarantine_version, running_version);
    }
}

/// Fire the loud, resurfacing auto-rollback notif on every member subnet
/// (restoration D6-2). Best-effort: a notif failure must never derail the


850	}
851
852	/// Production wiring: run the brain supervisor on its own thread, spawned by the
853	/// broker process ([`crate::daemon::Daemon::run`]) before its foreground
854	/// seed-control loop. The brain child is supervised for the broker's lifetime.
855	/// Returns the shared [`BrainRestart`] signal so the caller can hand a clone to
856	/// the broker, wiring the `KIND_BRAIN_RESTART` verb to this supervisor (D3-3).
857	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
858	pub fn spawn_brain_supervisor(stop: Arc<AtomicBool>) -> (thread::JoinHandle<()>, Arc<BrainRestart>) {
859	    let restart = Arc::new(BrainRestart::new());
860	    let restart_sup = Arc::clone(&restart);
861	    let handle = thread::spawn(move || {
862	        let env = ProductionTrialEnv;
863	        supervise_brain(
864	            &stop,
865	            &restart_sup,
866	            SUPERVISE_BACKOFF_BASE,
867	            &env,
868	            SUPERVISE_HEALTHY_RUN,
869	            spawn_brain_child,
870	        );
871	    });
872	    (handle, restart)
873	}
874
875	#[cfg(test)]
876	mod tests {
877	    use super::*;
878	    use std::sync::atomic::AtomicU32;
879

}

/// Production wiring: run the brain supervisor on its own thread, spawned by the
/// broker process ([`crate::daemon::Daemon::run`]) before its foreground
/// seed-control loop. The brain child is supervised for the broker's lifetime.
/// Returns the shared [`BrainRestart`] signal so the caller can hand a clone to
/// the broker, wiring the `KIND_BRAIN_RESTART` verb to this supervisor (D3-3).
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
pub fn spawn_brain_supervisor(stop: Arc<AtomicBool>) -> (thread::JoinHandle<()>, Arc<BrainRestart>) {
    let restart = Arc::new(BrainRestart::new());
    let restart_sup = Arc::clone(&restart);
    let handle = thread::spawn(move || {
        let env = ProductionTrialEnv;
        supervise_brain(
            &stop,
            &restart_sup,
            SUPERVISE_BACKOFF_BASE,
            &env,
            SUPERVISE_HEALTHY_RUN,
            spawn_brain_child,
        );
    });
    (handle, restart)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::AtomicU32;


All seams located. `spawn_brain_supervisor` (l.858, the broker-process entry, runs before the seed loop) is the exact capture point for FIX#1; `ProductionTrialEnv` reads from `ReleaseCache` (releases dir) which holds both the applied record and the staged release metadata (`artifact_sha256`) for FIX#2. Writing the plan.


Write

# v0.4.2 plan — Linux brain-respawn-path fix + promotion bytes-gate

> Build plan for the v0.4.1 fleet-roll blocker. Single-fix release. Same loop:
> plan → doyle sign → execute (per-task gates) → CI both runners → deployah
> publishes (counter 9). Restoration close is GATED on this + a post-fix Linux
> seamless observation (debug-channel rollout).

## Context — the blocker (2026-06-11)

v0.4.1 (counter 8) published; the fleet roll is the restoration D7 no-bounce
acceptance.

- **hfenduleam (Windows): GREEN.** Brain pid rolled, gen 0→1, broker pid held,
  `exe_hash` flipped to the published v0.4.1 bytes, pump wedge healed — seamless,
  no manual bounce.
- **kitsubito (Linux): RED.** `apply` swapped the canonical binary to v0.4.1
  (`/home/reavus/.local/bin/spt` == `35c9544c…`, the published linux artifact)
  and recorded `applied:8` — **but the respawned brain execs `spt.old-8`
  (`b4b7ea14…` = v0.4.0).** New code is NOT running; the record is optimistically
  wrong (the enlyzeam-class record/reality divergence, now provable via `exe_hash`).

**Root cause (confirmed in code + by `doyle`):** `brainproc.rs:817`
`spawn_brain_child` resolves the candidate binary (`binary = None`) via
`std::env::current_exe()` **per spawn**. On Linux `current_exe()` =
`readlink(/proc/self/exe)` is **inode-tracking** and follows the `apply` rename
(`spt` → `spt.old-N`), so the resident broker respawns the brain onto the OLD
binary. Windows `GetModuleFileName` is the **path string at process start**, so
it stays canonical — which is why Windows is green. ADR-0018 Q3
("auto-respawns from the executable path — now the new binary") silently assumed
path-string semantics; **that assumption is the bug.**

**Second defect (the false-success hole):** promotion gates on **readiness only**
(`run_trial` → `Promoted` → `record_promoted`). A brain that comes up on the
WRONG bytes still signals ready, so the trial promoted and recorded `applied:8`
over a v0.4.0-bytes brain. Readiness ≠ new-bytes.

**E2E gap:** `brain_survive.rs` models the swap as a **path-flip** between two
distinct fixtures (`fixture_a` → `fixture_b`) through the injected spawn closure
— it never renames a *running* binary, so `current_exe()`-follows-rename is never
exercised. CI green on kitsubito, prod red. The model boundary was drawn one
layer too high (it abstracts away the production `current_exe` default — the
platform-divergent part).

## Goal

A brain-only update on Linux runs the NEW bytes with no manual bounce (parity
with Windows), and a future respawn-vs-bytes divergence **self-heals** (auto
rollback + loud notif) instead of silently recording a wrong `applied`.

## REQ to mint (rule 3 — before the fix)

`REQ-HAZARD-BRAIN-RESPAWN-PATH` — `required_stages = ["doc","impl","unit","int"]`,
activated in the fix change. Sibling of `REQ-HAZARD-BROKER-PROCESS-ISOLATION`
(6.7): the broker must respawn the brain onto the **applied bytes**, and a
respawn that lands on the wrong bytes must fail-safe (rollback), never record
success. KNOWN-HAZARDS **6.11**.

## Fix — two halves (doyle ruling + deployah R2 belt)

### Half 1 — capture the canonical exe path once at broker start
`spawn_brain_supervisor` (`brainproc.rs:858`, the broker-process entry, runs in
`Daemon::run` before the seed loop) captures `std::env::current_exe()` **once at
t=0** (before any `apply` can rename under us) and passes it as the default
binary for the `binary = None` spawn case — never per-spawn `current_exe()`.

- `spawn_brain_child` grows a `canonical: Option<&Path>` param; `None` selection
  uses `canonical` (falls back to per-spawn `current_exe()` only if the t=0
  capture failed — degrade-safe, preserves today's behavior).
- The production closure handed to `supervise_brain` binds the captured path.
  `supervise_brain`'s generic `spawn_child` signature is unchanged → unit
  harness untouched. Rollback selection (`Some(.old-N)`) unchanged.
- Symlink starts are fine: `current_exe()` fully resolves at capture (kitsubito's
  `/usr/local/bin/spt` → `~/.local/bin/spt` resolves at t=0).
- Net effect: Linux gains Windows' path-at-start semantics. Zero install-path
  discovery, no new config.

### Half 2 — promotion bytes-gate (gate AND rollback trigger)
In `supervise_brain` the `TrialStep::Promoted` arm (`brainproc.rs:668`), BEFORE
`env.record_promoted(version)`:

- `expected = env.staged_artifact_hash(version)` (the staged release metadata's
  `artifacts[current_platform()].artifact_sha256`, via `ReleaseCache`).
- `actual = env.ready_exe_hash()` (the `exe_hash` the candidate stamped in
  `brain.ready`).
- **Both present and `expected != actual`** → the candidate came up on the wrong
  bytes: do NOT promote → `rollback(env, &record)` + `generation += 1` +
  `reason = Crash` + `continue` (same path as a failed trial; the loud rollback
  notif fires). The failure mode becomes auto-rollback to `.old-N`, not a
  silently-wrong record.
- **Either absent** → degrade to today's readiness-only promote (pre-metadata
  releases, missing breadcrumb — N-1-safe).

`TrialEnv` gains two best-effort methods: `ready_exe_hash(&self) -> Option<String>`
and `staged_artifact_hash(&self, version: u64) -> Option<String>`;
`ProductionTrialEnv` reads them from `brain.ready` + `ReleaseCache`. The D7-4
*manual* bytes assert becomes an *automatic* gate.

> **Bootstrapping note (deployah):** Half 2 is FORWARD protection — it lives in
> the v0.4.2 broker. The v0.4.2 `apply` itself rides the resident **v0.4.0**
> broker (no gate, buggy respawn), so it still needs the one manual Linux bounce
> to load the fixed broker. The gate protects every update AFTER v0.4.2.

## Tasks (per-task gates: build · test · clippy -D · build --no-default-features · traceable-reqs check EXIT=0 · xtask check)

- **T1 — doc.** KNOWN-HAZARDS **6.11** (platform-divergent `current_exe` trap +
  readiness≠bytes); `traceable-reqs.toml` mint `REQ-HAZARD-BRAIN-RESPAWN-PATH`;
  ADR-0018 amendment note (Q3 path-semantics assumption corrected). Tag
  `<!-- [doc->REQ-HAZARD-BRAIN-RESPAWN-PATH] -->`.
- **T2 — impl.** Half 1 (`spawn_brain_supervisor` + `spawn_brain_child` canonical
  capture) + Half 2 (`supervise_brain` Promoted-arm gate + `TrialEnv` two methods
  + `ProductionTrialEnv` impls). `// [impl->REQ-HAZARD-BRAIN-RESPAWN-PATH]`.
- **T3 — unit.** Spawn-path selection (captured default used for `None`, not
  per-spawn current_exe; `Some(.old-N)` unchanged) + promotion-gate truth table
  (match→promote · mismatch→rollback+notif · either-absent→readiness-only).
  `// [unit->REQ-HAZARD-BRAIN-RESPAWN-PATH]`.
- **T4 — int (the gap fix).** A `brain_survive` sibling: supervisor running the
  PRODUCTION selection (`binary = None` default through the captured path); the
  test **renames the running canonical binary `P` → `P.old-N` and writes the new
  bytes at `P`** (the real `apply` choreography, not a path flip), then asserts
  the respawned `exe_hash` == the new bytes. **Fails on Linux against the old
  per-spawn current_exe, passes after Half 1.** Regression guard on both runners.
  `// [int->REQ-HAZARD-BRAIN-RESPAWN-PATH]`. (deployah offered to build this leg.)
- **T5 — release.** Bump `0.4.1 → 0.4.2`; CHANGELOG `[0.4.2]` (user-clean: a
  Linux update now runs the new version immediately, no manual restart); regen
  docs; full gates; push → **CI both runners green** → deployah tags + signs +
  publishes **counter 9**.

## Traceability

`REQ-HAZARD-BRAIN-RESPAWN-PATH` `required_stages` set in the fix change (rule 5):
doc=T1, impl=T2, unit=T3, int=T4. `traceable-reqs check` stays green at every
commit.

## Ops + close sequence (post-publish)

1. **kitsubito** — apply v0.4.2 (rides the buggy v0.4.0 broker → records applied,
   brain on v0.4.1 bytes `.old-9`), then **one manual broker bounce** loads the
   fixed v0.4.2 broker. kitsubito's last bounce.
2. **enlyzeam** — catch up straight to v0.4.2; its one catch-up bounce doubles as
   the fix-load. The project's final manual bounce, paired.
3. **Linux seamless observation (operator: debug-channel rollout).** With the
   fixed broker resident, push a debug-channel build to the lab nodes
   (`DEBUG-ROLLOUT.md`, REQ-UPD-6) and observe a brain-only roll: brain pid
   changes, `exe_hash` == the debug artifact, broker pid held, NO manual bounce.
   This is the Linux leg the path-flip E2E could not give us in the field.
4. **Restoration CLOSE** — ROADMAP restoration → ✅ (both OS legs proven);
   KNOWN-HAZARDS 6.7/6.8 close-out; the kitsubito `applied:8`-over-v0.4.0 record
   captured in the appendix as evidence FOR the bytes-gate (NOT hand-corrected —
   v0.4.2's gated promotion supersedes it).

## Notes / non-goals
- v0.4.1 artifacts are sound — **no yank**. The defect is in the deployed v0.4.0
  broker's respawn logic.
- Rollback-selection-not-apply (`brainproc.rs:779`) is unchanged; the bytes-gate
  reuses the existing `rollback` path.
- The broker-side QUIC-op bound (pump-IPC-deadline B-half, DEFERRED) is NOT in
  scope — separate broker-update batch.


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

# v0.4.2 plan — Linux brain-respawn-path fix + promotion bytes-gate

> Build plan for the v0.4.1 fleet-roll blocker. Single-fix release. Same loop:
> plan → doyle sign → execute (per-task gates) → CI both runners → deployah
> publishes (counter 9). Restoration close is GATED on this + a post-fix Linux
> seamless observation (debug-channel rollout).

## Context — the blocker (2026-06-11)

v0.4.1 (counter 8) published; the fleet roll is the restoration D7 no-bounce
acceptance.

- **hfenduleam (Windows): GREEN.** Brain pid rolled, gen 0→1, broker pid held,
  `exe_hash` flipped to the published v0.4.1 bytes, pump wedge healed — seamless,
  no manual bounce.
- **kitsubito (Linux): RED.** `apply` swapped the canonical binary to v0.4.1
  (`/home/reavus/.local/bin/spt` == `35c9544c…`, the published linux artifact)
  and recorded `applied:8` — **but the respawned brain execs `spt.old-8`
  (`b4b7ea14…` = v0.4.0).** New code is NOT running; the record is optimistically
  wrong (the enlyzeam-class record/reality divergence, now provable via `exe_hash`).

**Root cause (confirmed in code + by `doyle`):** `brainproc.rs:817`
`spawn_brain_child` resolves the candidate binary (`binary = None`) via
`std::env::current_exe()` **per spawn**. On Linux `current_exe()` =
`readlink(/proc/self/exe)` is **inode-tracking** and follows the `apply` rename
(`spt` → `spt.old-N`), so the resident broker respawns the brain onto the OLD
binary. Windows `GetModuleFileName` is the **path string at process start**, so
it stays canonical — which is why Windows is green. ADR-0018 Q3
("auto-respawns from the executable path — now the new binary") silently assumed
path-string semantics; **that assumption is the bug.**

**Second defect (the false-success hole):** promotion gates on **readiness only**
(`run_trial` → `Promoted` → `record_promoted`). A brain that comes up on the
WRONG bytes still signals ready, so the trial promoted and recorded `applied:8`
over a v0.4.0-bytes brain. Readiness ≠ new-bytes.

**E2E gap:** `brain_survive.rs` models the swap as a **path-flip** between two
distinct fixtures (`fixture_a` → `fixture_b`) through the injected spawn closure
— it never renames a *running* binary, so `current_exe()`-follows-rename is never
exercised. CI green on kitsubito, prod red. The model boundary was drawn one
layer too high (it abstracts away the production `current_exe` default — the
platform-divergent part).

## Goal

A brain-only update on Linux runs the NEW bytes with no manual bounce (parity
with Windows), and a future respawn-vs-bytes divergence **self-heals** (auto
rollback + loud notif) instead of silently recording a wrong `applied`.

## REQ to mint (rule 3 — before the fix)

`REQ-HAZARD-BRAIN-RESPAWN-PATH` — `required_stages = ["doc","impl","unit","int"]`,
activated in the fix change. Sibling of `REQ-HAZARD-BROKER-PROCESS-ISOLATION`
(6.7): the broker must respawn the brain onto the **applied bytes**, and a
respawn that lands on the wrong bytes must fail-safe (rollback), never record
success. KNOWN-HAZARDS **6.11**.

## Fix — two halves (doyle ruling + deployah R2 belt)

### Half 1 — capture the canonical exe path once at broker start
`spawn_brain_supervisor` (`brainproc.rs:858`, the broker-process entry, runs in
`Daemon::run` before the seed loop) captures `std::env::current_exe()` **once at
t=0** (before any `apply` can rename under us) and passes it as the default
binary for the `binary = None` spawn case — never per-spawn `current_exe()`.

- `spawn_brain_child` grows a `canonical: Option<&Path>` param; `None` selection
  uses `canonical` (falls back to per-spawn `current_exe()` only if the t=0
  capture failed — degrade-safe, preserves today's behavior).
- The production closure handed to `supervise_brain` binds the captured path.
  `supervise_brain`'s generic `spawn_child` signature is unchanged → unit
  harness untouched. Rollback selection (`Some(.old-N)`) unchanged.
- Symlink starts are fine: `current_exe()` fully resolves at capture (kitsubito's
  `/usr/local/bin/spt` → `~/.local/bin/spt` resolves at t=0).
- Net effect: Linux gains Windows' path-at-start semantics. Zero install-path
  discovery, no new config.

### Half 2 — promotion bytes-gate (gate AND rollback trigger)
In `supervise_brain` the `TrialStep::Promoted` arm (`brainproc.rs:668`), BEFORE
`env.record_promoted(version)`:

- `expected = env.staged_artifact_hash(version)` (the staged release metadata's
  `artifacts[current_platform()].artifact_sha256`, via `ReleaseCache`).
- `actual = env.ready_exe_hash()` (the `exe_hash` the candidate stamped in
  `brain.ready`).
- **Both present and `expected != actual`** → the candidate came up on the wrong
  bytes: do NOT promote → `rollback(env, &record)` + `generation += 1` +
  `reason = Crash` + `continue` (same path as a failed trial; the loud rollback
  notif fires). The failure mode becomes auto-rollback to `.old-N`, not a
  silently-wrong record.
- **Either absent** → degrade to today's readiness-only promote (pre-metadata
  releases, missing breadcrumb — N-1-safe).

`TrialEnv` gains two best-effort methods: `ready_exe_hash(&self) -> Option<String>`
and `staged_artifact_hash(&self, version: u64) -> Option<String>`;
`ProductionTrialEnv` reads them from `brain.ready` + `ReleaseCache`. The D7-4
*manual* bytes assert becomes an *automatic* gate.

> **Bootstrapping note (deployah):** Half 2 is FORWARD protection — it lives in
> the v0.4.2 broker. The v0.4.2 `apply` itself rides the resident **v0.4.0**
> broker (no gate, buggy respawn), so it still needs the one manual Linux bounce
> to load the fixed broker. The gate protects every update AFTER v0.4.2.

## Tasks (per-task gates: build · test · clippy -D · build --no-default-features · traceable-reqs check EXIT=0 · xtask check)

- **T1 — doc.** KNOWN-HAZARDS **6.11** (platform-divergent `current_exe` trap +
  readiness≠bytes); `traceable-reqs.toml` mint `REQ-HAZARD-BRAIN-RESPAWN-PATH`;
  ADR-0018 amendment note (Q3 path-semantics assumption corrected). Tag
  `<!-- [doc->REQ-HAZARD-BRAIN-RESPAWN-PATH] -->`.
- **T2 — impl.** Half 1 (`spawn_brain_supervisor` + `spawn_brain_child` canonical
  capture) + Half 2 (`supervise_brain` Promoted-arm gate + `TrialEnv` two methods
  + `ProductionTrialEnv` impls). `// [impl->REQ-HAZARD-BRAIN-RESPAWN-PATH]`.
- **T3 — unit.** Spawn-path selection (captured default used for `None`, not
  per-spawn current_exe; `Some(.old-N)` unchanged) + promotion-gate truth table
  (match→promote · mismatch→rollback+notif · either-absent→readiness-only).
  `// [unit->REQ-HAZARD-BRAIN-RESPAWN-PATH]`.
- **T4 — int (the gap fix).** A `brain_survive` sibling: supervisor running the
  PRODUCTION selection (`binary = None` default through the captured path); the
  test **renames the running canonical binary `P` → `P.old-N` and writes the new
  bytes at `P`** (the real `apply` choreography, not a path flip), then asserts
  the respawned `exe_hash` == the new bytes. **Fails on Linux against the old
  per-spawn current_exe, passes after Half 1.** Regression guard on both runners.
  `// [int->REQ-HAZARD-BRAIN-RESPAWN-PATH]`. (deployah offered to build this leg.)
- **T5 — release.** Bump `0.4.1 → 0.4.2`; CHANGELOG `[0.4.2]` (user-clean: a
  Linux update now runs the new version immediately, no manual restart); regen
  docs; full gates; push → **CI both runners green** → deployah tags + signs +
  publishes **counter 9**.

## Traceability

`REQ-HAZARD-BRAIN-RESPAWN-PATH` `required_stages` set in the fix change (rule 5):
doc=T1, impl=T2, unit=T3, int=T4. `traceable-reqs check` stays green at every
commit.

## Ops + close sequence (post-publish)

1. **kitsubito** — apply v0.4.2 (rides the buggy v0.4.0 broker → records applied,
   brain on v0.4.1 bytes `.old-9`), then **one manual broker bounce** loads the
   fixed v0.4.2 broker. kitsubito's last bounce.
2. **enlyzeam** — catch up straight to v0.4.2; its one catch-up bounce doubles as
   the fix-load. The project's final manual bounce, paired.
3. **Linux seamless observation (operator: debug-channel rollout).** With the
   fixed broker resident, push a debug-channel build to the lab nodes
   (`DEBUG-ROLLOUT.md`, REQ-UPD-6) and observe a brain-only roll: brain pid
   changes, `exe_hash` == the debug artifact, broker pid held, NO manual bounce.
   This is the Linux leg the path-flip E2E could not give us in the field.
4. **Restoration CLOSE** — ROADMAP restoration → ✅ (both OS legs proven);
   KNOWN-HAZARDS 6.7/6.8 close-out; the kitsubito `applied:8`-over-v0.4.0 record
   captured in the appendix as evidence FOR the bytes-gate (NOT hand-corrected —
   v0.4.2's gated promotion supersedes it).

## Notes / non-goals
- v0.4.1 artifacts are sound — **no yank**. The defect is in the deployed v0.4.0
  broker's respawn logic.
- Rollback-selection-not-apply (`brainproc.rs:779`) is unchanged; the bytes-gate
  reuses the existing `rollback` path.
- The broker-side QUIC-op bound (pump-IPC-deadline B-half, DEFERRED) is NOT in
  scope — separate broker-update batch.


Plan written → `V042-PLAN.md`. Sending to doyle for sign-off (operator delegated the post-plan go to doyle).


Bash

$OWL send doyle 2>/dev/null <<'EOF'
V042-PLAN.md drafted — your sign-off is the build gate (operator delegated post-plan go to you). It bakes in your ruling + deployah's R2 belt:

REQ: mint REQ-HAZARD-BRAIN-RESPAWN-PATH [doc,impl,unit,int], KH 6.11 (sibling of 6.7), activated in the fix.
Half 1: spawn_brain_supervisor (brainproc.rs:858, broker entry pre-seed-loop) captures std::env::current_exe() ONCE at t=0; spawn_brain_child grows canonical:Option<&Path>, None-case uses captured (degrade to per-spawn current_exe only if t=0 capture failed); supervise_brain generic sig unchanged → unit harness untouched; rollback Some(.old-N) unchanged; symlinks resolve at capture.
Half 2: promotion bytes-gate in supervise_brain Promoted arm (l.668) BEFORE record_promoted — expected=staged artifact_sha256[current_platform], actual=brain.ready exe_hash; both-present+mismatch → rollback+notif (continue as failed trial); either-absent → readiness-only (N-1-safe). TrialEnv gains ready_exe_hash() + staged_artifact_hash(version); ProductionTrialEnv reads brain.ready + ReleaseCache.
T4 int = brain_survive sibling: PRODUCTION selection (None default through captured path), REAL in-place rename P→P.old-N + new bytes at P, assert respawned exe_hash == new bytes; fails Linux pre-fix. (deployah offered to build this leg.)
Ops/close: kitsubito apply+1 bounce; enlyzeam straight-to-0.4.2+1 bounce (final); debug-channel rollout = the Linux seamless observation; then restoration close. kitsubito applied:8 record left as appendix evidence (not hand-corrected).

Read V042-PLAN.md and flag anything before I mint the REQ + cut the impl. Two things I want your explicit nod on: (1) the degrade-to-readiness-only fallback when either hash is absent (correct N-1 stance?), (2) capturing at spawn_brain_supervisor entry vs deeper (any path where the broker re-execs itself AFTER an apply-rename before this capture? I think no — capture is at broker boot, apply can only rename later).
EOF
echo "exit:${PIPESTATUS[0]}"

Send V042-PLAN to doyle for sign-off


Plan with doyle for sign-off. Updating memory (situation advanced materially) + reporting status, then awaiting doyle's review via the event stream.


Edit

**v0.4.2 plan (deployah+todlando aligned; doyle confirming):**

**STATUS 2026-06-11:** v0.4.1 PUBLIC (counter 8). hfenduleam rolled GREEN (no-bounce + wedge-heal proven, Windows leg of restoration DONE). kitsubito left as-is (v0.4.0 brain, unwedged, applied:8 record left uncorrected = appendix evidence). enlyzeam untouched. **V042-PLAN.md written**, sent to doyle for SIGN-OFF (operator delegated post-plan greenlight to doyle). Operator chose **debug-channel rollout** for the final Linux seamless observation. Next: doyle signs → mint REQ + build (T1 doc → T2 impl → T3 unit → T4 int → T5 release) → deployah publishes counter 9 → kitsubito+enlyzeam one bounce each → debug rollout → restoration close.

**v0.4.2 plan (deployah+todlando aligned; doyle confirmed ruling):**