215	    };
216
217	    // ── STEP 2 — OWN-COPY (W3 c / REQ-INSTALL-11) + LOCK-FREE install dir.
218	    //
219	    //    (2a) The detached Psyche spawned from its OWN COPY: assert
220	    //    `<perch>/.live-bin/<psychebin name>` EXISTS. The perch's `.live-bin` is
221	    //    where psyche_spawn_runtime relocates the shipped psyche binary. ──
222	    let perch_dir = perch::resolve_perch_path(id, ParentHint::Infer);
223	    let live_bin_copy = perch_dir
224	        .join(".live-bin")
225	        .join(format!("psychebin{exe_suffix}"));
226	    let own_copy_present = {
227	        // The relocate happens at the first psyche spawn; the perch dir already
228	        // resolved (psyche is live), so poll briefly for the file to land.
229	        let deadline = Instant::now() + Duration::from_secs(10);
230	        let mut present = false;
231	        while Instant::now() < deadline {
232	            if live_bin_copy.is_file() {
233	                present = true;
234	                break;
235	            }
236	            std::thread::sleep(Duration::from_millis(100));
237	        }
238	        present
239	    };
240
241	    //    (2b) THE LOCK-FREE PROOF: while the Psyche is alive, OVERWRITE the
242	    //    install-dir source `psychebin` binary. With the shared-install-dir spawn
243	    //    (the pre-W3 shape) the running Psyche would hold an OS lock on this exact
244	    //    file → on Windows the open-for-write FAILS with os error 5. The own-copy
245	    //    means the install-dir source is free, so the overwrite SUCCEEDS. We append
246	    //    bytes (a real rewrite of the file the swap would target). ──
247	    assert!(
248	        spt_store::proc::is_process_alive(psyche_pid),
249	        "PRECONDITION for the lock proof: the Psyche must still be alive when we \
250	         overwrite the install-dir source (pid {psyche_pid})."
251	    );
252	    let overwrite_result = std::fs::OpenOptions::new()
253	        .append(true)
254	        .open(&psyche_bin)
255	        .and_then(|mut f| f.write_all(b"\0W3E-LIVE-UPDATE-OVERWRITE-PROOF\0"));
256	    let install_dir_overwrite_ok = overwrite_result.is_ok();
257	    let overwrite_err = overwrite_result.err().map(|e| e.to_string());
258
259	    // ── STEP 3 — ORPHAN: kill the brain PARENT only (single-pid, NO tree). The
260	    //    detached Psyche is orphaned and must stay ALIVE (it is NOT the brain's child
261	    //    tree on Windows once detached). Assert it survives the brain death. The
262	    //    supervisor may respawn a brain; that's fine — the stop below targets the
263	    //    perch regardless of which brain instance is current. ──
264	    kill_pid_single(brain_pid);
265	    // Poll until the brain is confirmed dead, then verify the Psyche outlived it.
266	    let brain_dead_deadline = Instant::now() + Duration::from_secs(8);
267	    while spt_store::proc::is_process_alive(brain_pid)
268	        && Instant::now() < brain_dead_deadline
269	    {
270	        std::thread::sleep(Duration::from_millis(100));
271	    }
272	    // The Psyche is brain-LESS now; it must still be ALIVE (the orphan precondition).
273	    // Re-check after a short settle so a racing teardown can't give a false positive.
274	    std::thread::sleep(Duration::from_millis(300));
275	    let orphan_alive = spt_store::proc::is_process_alive(psyche_pid);
276
277	    // ── STEP 4 — STOP-PATH REAP: `spt endpoint stop wallX`. The cmdline-scoped
278	    //    fail-safe-decline guard must reap the brain-LESS orphan Psyche. Assert the
279	    //    psyche pid reads DEAD within a bounded poll — WITHOUT this test killing it. ──
280	    let stop = {
281	        let mut cmd = Command::new(&spt_bin);
282	        cmd.no_window()
283	            .args(["endpoint", "stop", id])
284	            .env("SPT_HOME", home.path());
285	        output_bounded(cmd, Duration::from_secs(30))
286	    };
287	    let mut reaped = false;
288	    let reap_deadline = Instant::now() + Duration::from_secs(25);
289	    while Instant::now() < reap_deadline {
290	        if !spt_store::proc::is_process_alive(psyche_pid) {
291	            reaped = true;
292	            break;
293	        }
294	        std::thread::sleep(Duration::from_millis(200));
295	    }
296
297	    let brain_stderr = std::fs::read_to_string(&brain_log).unwrap_or_default();
298	    eprintln!(
299	        "=== DIAGNOSTIC (W3e int A): run_ok={} psyche_pid={psyche_pid} \
300	         own_copy_present={own_copy_present} install_dir_overwrite_ok={install_dir_overwrite_ok} \
301	         overwrite_err={overwrite_err:?} orphan_alive={orphan_alive} stop_ok={} reaped={reaped} \
302	         live_bin_copy={} ===\n=== endpoint run stderr ===\n{}\n=== endpoint stop stderr ===\n{}\
303	         \n=== brain stderr ===\n{brain_stderr}",
304	        run.status.success(),
305	        stop.status.success(),
306	        live_bin_copy.display(),
307	        String::from_utf8_lossy(&run.stderr),
308	        String::from_utf8_lossy(&stop.stderr),
309	    );
310
311	    // ── Reap scoped BEFORE asserting (never leave a stray spt.exe). The psyche
312	    //    should already be dead; kill defensively only if the bug left it alive. ──
313	    if !reaped {
314	        kill_pid(psyche_pid);
315	    }
316	    // The supervisor may have respawned a brain after the single-pid kill; reap any
317	    // current brain.ready pid too, then daemon-stop + the broker tree.
318	    if let Some(p) = wait_for_ready_pid(&home.path().join("brain.ready"), Duration::from_millis(1)) {
319	        kill_pid(p);
320	    }
321	    let _ = {
322	        let mut cmd = Command::new(&spt_bin);
323	        cmd.no_window().args(["daemon", "stop"]).env("SPT_HOME", home.path());
324	        output_bounded(cmd, Duration::from_secs(20))
325	    };
326	    kill_pid(brain_pid);
327	    let _ = broker.kill();
328	    let _ = broker.wait();
329
330	    // ── ASSERTIONS ──
331	    assert!(
332	        run.status.success(),
333	        "endpoint run --start must succeed: {}",
334	        String::from_utf8_lossy(&run.stderr)
335	    );
336
337	    // W3 (c) own-copy present.
338	    // [int->REQ-ADAPTER-LIVE-UPDATE]
339	    assert!(
340	        own_copy_present,
341	        "REQ-INSTALL-11 / W3(c): the detached Psyche must spawn from its PER-ENDPOINT \
342	         OWN COPY at <perch>/.live-bin/psychebin{exe_suffix} — that file never appeared \
343	         (expected at {}).\n=== brain stderr ===\n{brain_stderr}",
344	        live_bin_copy.display()
345	    );
346
347	    // W3 (c)+(b) the CORE lock-free proof. On Windows a shared-install-dir spawn
348	    // would have held an os-error-5 lock on the source binary; the own-copy frees
349	    // it, so the install-dir source overwrite SUCCEEDS while the Psyche runs. This
350	    // is exactly what lets a live adapter update CRC-swap the install dir.
351	    // [int->REQ-ADAPTER-LIVE-UPDATE]
352	    assert!(
353	        install_dir_overwrite_ok,
354	        "REQ-ADAPTER-LIVE-UPDATE / W3(b)+(c): the install-dir source binary must be \
355	         LOCK-FREE while the Psyche runs (the own-copy is what the running Psyche \
356	         holds, NOT this file) — overwriting {} FAILED ({overwrite_err:?}). A failure \
357	         here on Windows is the os-error-5 install-dir lock the own-copy was meant to \
358	         remove.\n=== brain stderr ===\n{brain_stderr}",
359	        psyche_bin.display()
360	    );
361
362	    // W3 (a) orphan precondition: the detached Psyche survived the brain-parent death.
363	    assert!(
364	        orphan_alive,
365	        "PRECONDITION for the stop-path reap: killing the brain PARENT (single-pid, no \
366	         tree) must ORPHAN the detached Psyche (pid {psyche_pid}) — it must stay ALIVE, \
367	         else there is nothing for endpoint-stop to reap (a vacuous test).\n\
368	         === brain stderr ===\n{brain_stderr}"
369	    );
370
371	    // W3 (a) THE KEYSTONE: endpoint-stop reaps the brain-LESS orphan Psyche.
372	    // [int->REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP]
373	    assert!(
374	        stop.status.success(),
375	        "endpoint stop must succeed: {}",
376	        String::from_utf8_lossy(&stop.stderr)
377	    );
378	    assert!(
379	        reaped,
380	        "REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP: `spt endpoint stop {id}` must REAP the \
381	         brain-LESS orphan Psyche (pid {psyche_pid}) via the cmdline-scoped \
382	         fail-safe-decline guard — it was STILL ALIVE 25s after the stop (orphaned; the \
383	         operator's stop left a runaway Psyche). This test never killed it.\n\
384	         === brain stderr ===\n{brain_stderr}"
385	    );
386	}
387

    };

    // ── STEP 2 — OWN-COPY (W3 c / REQ-INSTALL-11) + LOCK-FREE install dir.
    //
    //    (2a) The detached Psyche spawned from its OWN COPY: assert
    //    `<perch>/.live-bin/<psychebin name>` EXISTS. The perch's `.live-bin` is
    //    where psyche_spawn_runtime relocates the shipped psyche binary. ──
    let perch_dir = perch::resolve_perch_path(id, ParentHint::Infer);
    let live_bin_copy = perch_dir
        .join(".live-bin")
        .join(format!("psychebin{exe_suffix}"));
    let own_copy_present = {
        // The relocate happens at the first psyche spawn; the perch dir already
        // resolved (psyche is live), so poll briefly for the file to land.
        let deadline = Instant::now() + Duration::from_secs(10);
        let mut present = false;
        while Instant::now() < deadline {
            if live_bin_copy.is_file() {
                present = true;
                break;
            }
            std::thread::sleep(Duration::from_millis(100));
        }
        present
    };

    //    (2b) THE LOCK-FREE PROOF: while the Psyche is alive, OVERWRITE the
    //    install-dir source `psychebin` binary. With the shared-install-dir spawn
    //    (the pre-W3 shape) the running Psyche would hold an OS lock on this exact
    //    file → on Windows the open-for-write FAILS with os error 5. The own-copy
    //    means the install-dir source is free, so the overwrite SUCCEEDS. We append
    //    bytes (a real rewrite of the file the swap would target). ──
    assert!(
        spt_store::proc::is_process_alive(psyche_pid),
        "PRECONDITION for the lock proof: the Psyche must still be alive when we \
         overwrite the install-dir source (pid {psyche_pid})."
    );
    let overwrite_result = std::fs::OpenOptions::new()
        .append(true)
        .open(&psyche_bin)
        .and_then(|mut f| f.write_all(b"\0W3E-LIVE-UPDATE-OVERWRITE-PROOF\0"));
    let install_dir_overwrite_ok = overwrite_result.is_ok();
    let overwrite_err = overwrite_result.err().map(|e| e.to_string());

    // ── STEP 3 — ORPHAN: kill the brain PARENT only (single-pid, NO tree). The
    //    detached Psyche is orphaned and must stay ALIVE (it is NOT the brain's child
    //    tree on Windows once detached). Assert it survives the brain death. The
    //    supervisor may respawn a brain; that's fine — the stop below targets the
    //    perch regardless of which brain instance is current. ──
    kill_pid_single(brain_pid);
    // Poll until the brain is confirmed dead, then verify the Psyche outlived it.
    let brain_dead_deadline = Instant::now() + Duration::from_secs(8);
    while spt_store::proc::is_process_alive(brain_pid)
        && Instant::now() < brain_dead_deadline
    {
        std::thread::sleep(Duration::from_millis(100));
    }
    // The Psyche is brain-LESS now; it must still be ALIVE (the orphan precondition).
    // Re-check after a short settle so a racing teardown can't give a false positive.
    std::thread::sleep(Duration::from_millis(300));
    let orphan_alive = spt_store::proc::is_process_alive(psyche_pid);

    // ── STEP 4 — STOP-PATH REAP: `spt endpoint stop wallX`. The cmdline-scoped
    //    fail-safe-decline guard must reap the brain-LESS orphan Psyche. Assert the
    //    psyche pid reads DEAD within a bounded poll — WITHOUT this test killing it. ──
    let stop = {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window()
            .args(["endpoint", "stop", id])
            .env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(30))
    };
    let mut reaped = false;
    let reap_deadline = Instant::now() + Duration::from_secs(25);
    while Instant::now() < reap_deadline {
        if !spt_store::proc::is_process_alive(psyche_pid) {
            reaped = true;
            break;
        }
        std::thread::sleep(Duration::from_millis(200));
    }

    let brain_stderr = std::fs::read_to_string(&brain_log).unwrap_or_default();
    eprintln!(
        "=== DIAGNOSTIC (W3e int A): run_ok={} psyche_pid={psyche_pid} \
         own_copy_present={own_copy_present} install_dir_overwrite_ok={install_dir_overwrite_ok} \
         overwrite_err={overwrite_err:?} orphan_alive={orphan_alive} stop_ok={} reaped={reaped} \
         live_bin_copy={} ===\n=== endpoint run stderr ===\n{}\n=== endpoint stop stderr ===\n{}\
         \n=== brain stderr ===\n{brain_stderr}",
        run.status.success(),
        stop.status.success(),
        live_bin_copy.display(),
        String::from_utf8_lossy(&run.stderr),
        String::from_utf8_lossy(&stop.stderr),
    );

    // ── Reap scoped BEFORE asserting (never leave a stray spt.exe). The psyche
    //    should already be dead; kill defensively only if the bug left it alive. ──
    if !reaped {
        kill_pid(psyche_pid);
    }
    // The supervisor may have respawned a brain after the single-pid kill; reap any
    // current brain.ready pid too, then daemon-stop + the broker tree.
    if let Some(p) = wait_for_ready_pid(&home.path().join("brain.ready"), Duration::from_millis(1)) {
        kill_pid(p);
    }
    let _ = {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window().args(["daemon", "stop"]).env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(20))
    };
    kill_pid(brain_pid);
    let _ = broker.kill();
    let _ = broker.wait();

    // ── ASSERTIONS ──
    assert!(
        run.status.success(),
        "endpoint run --start must succeed: {}",
        String::from_utf8_lossy(&run.stderr)
    );

    // W3 (c) own-copy present.
    // [int->REQ-ADAPTER-LIVE-UPDATE]
    assert!(
        own_copy_present,
        "REQ-INSTALL-11 / W3(c): the detached Psyche must spawn from its PER-ENDPOINT \
         OWN COPY at <perch>/.live-bin/psychebin{exe_suffix} — that file never appeared \
         (expected at {}).\n=== brain stderr ===\n{brain_stderr}",
        live_bin_copy.display()
    );

    // W3 (c)+(b) the CORE lock-free proof. On Windows a shared-install-dir spawn
    // would have held an os-error-5 lock on the source binary; the own-copy frees
    // it, so the install-dir source overwrite SUCCEEDS while the Psyche runs. This
    // is exactly what lets a live adapter update CRC-swap the install dir.
    // [int->REQ-ADAPTER-LIVE-UPDATE]
    assert!(
        install_dir_overwrite_ok,
        "REQ-ADAPTER-LIVE-UPDATE / W3(b)+(c): the install-dir source binary must be \
         LOCK-FREE while the Psyche runs (the own-copy is what the running Psyche \
         holds, NOT this file) — overwriting {} FAILED ({overwrite_err:?}). A failure \
         here on Windows is the os-error-5 install-dir lock the own-copy was meant to \
         remove.\n=== brain stderr ===\n{brain_stderr}",
        psyche_bin.display()
    );

    // W3 (a) orphan precondition: the detached Psyche survived the brain-parent death.
    assert!(
        orphan_alive,
        "PRECONDITION for the stop-path reap: killing the brain PARENT (single-pid, no \
         tree) must ORPHAN the detached Psyche (pid {psyche_pid}) — it must stay ALIVE, \
         else there is nothing for endpoint-stop to reap (a vacuous test).\n\
         === brain stderr ===\n{brain_stderr}"
    );

    // W3 (a) THE KEYSTONE: endpoint-stop reaps the brain-LESS orphan Psyche.
    // [int->REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP]
    assert!(
        stop.status.success(),
        "endpoint stop must succeed: {}",
        String::from_utf8_lossy(&stop.stderr)
    );
    assert!(
        reaped,
        "REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP: `spt endpoint stop {id}` must REAP the \
         brain-LESS orphan Psyche (pid {psyche_pid}) via the cmdline-scoped \
         fail-safe-decline guard — it was STILL ALIVE 25s after the stop (orphaned; the \
         operator's stop left a runaway Psyche). This test never killed it.\n\
         === brain stderr ===\n{brain_stderr}"
    );
}


W1 — Multi-platform .spt packaging (ADR-0024)

REQ-ADAPTER-MULTIPLATFORM-SPT. Fat archive: shared manifest.toml+strings/ at root, binaries under per-target-triple subdirs. Extract shared root + only current_platform() triple, flatten into install_dir. Mode detection on known-triple subdir; legacy flat = back-compat. min_spt_core_version>=0.13.2 forward gate. --asset optional default adapter.spt. Gate: unit+int(real fixture)+doc. Executor builds, doyle gates.

W2 — gh transport for private repos

REQ-ADAPTER-GH-TRANSPORT. transport = https|gh|auto (default auto) on gh_release avenue + add --release. gh shells gh release download/gh api (private path; honors OAuth+GH_TOKEN, no token custody). --gh/--https force. auto = prefer gh if installed+authed else https. Files: fetch path (http_get_bytes, gh_latest_release_version, stage_gh_release_archive), Update struct transport field. Gate: unit (decision+command shape); int HITL/skip. doc=manifest.md+CONTEXT.

W3 — Live daemon-coordinated update + CRC swap (ADR-0025) KEYSTONE

REQ-ADAPTER-LIVE-UPDATE (ADR-0025). W3a resident-children registry; W3b CRC-gated swap (std hash, no new dep); W3c manifest refresh into running BrainLifecycle; W3d daemon-apply IPC (stop resident→swap→refresh→restart; not-running→CLI direct); W3e int keystone NO mocks (real spt-hosted endpoint + running translation binary; Windows locked-binary regression). Bounded stop/restart (join_bounded, never 240s backstop). Brain never restarts. Partial-failure safe.

PSYCHE-LOCK RESOLUTION (doyle design, absorbs task#7 / perri F-010×F-015 — ADR-0025's resident/ephemeral split assumed psyche=in-daemon but it's a separate install-dir binary that locks the target):
- PRIMARY (c): hosted psyche + any daemon-spawned long-lived install-dir binary runs from a PER-ENDPOINT PRIVATE COPY, never the shared install-dir binary → removes the lock vector entirely; consistent with ephemeral-self-heals-on-next-spawn (next spawn copies updated binary).
- SUPPORTING (a): close F-010 orphan reap on endpoint-stop/agent-death (extend v0.12.0 ID-specific scoped-reap to the stop path) — fixes the lingering-orphan leak independent of updates. Check existing psyche-reap REQ before minting new.
- BACKSTOP (b): extract-to-temp + per-file atomic swap so one busy file never fails the whole update (composes with W3b). NOTE (b) alone can't replace a running locked .exe on Windows — (c)/(a) are primary.
W3 int absorbs perri's 4-step repro (task#7) as the NO-mocks acceptance. May need ADR-0025 amendment for the separate-process-psyche vector + possibly a new REQ for the stop-path orphan reap. Author full W3 design brief after W2 gates.

W4 — update-message

REQ-ADAPTER-UPDATE-MESSAGE. [update].message plain multi-line, markdown-rendered (helpfmt prose path), ONLY when update actually applied (version changed), not no-op. Read from newly-installed manifest, avenue-agnostic. Files: Update struct message:Option<String>; cmd_adapter_update after successful apply→helpfmt::render+print. Gate: unit (rendered on applied, NOT no-op). doc=manifest.md+CONTEXT. No int.

W5 — *-proof --dir/--manifest override (F-011)

REQ-ADAPTER-PROOF-DIR-OVERRIDE. --dir <path>/--manifest <file> on digest-proof+translate-proof → proof DEV binary vs on-disk manifest+dir without full extracted install. Files: cmd_adapter_digest_proof + cmd_adapter_translate_proof accept --dir/--manifest, resolve from there not registry::resolve_option. Gate: unit (override resolves dev manifest+dir); int (mirror proof ints with --dir). doc=proof --help+patterns. May ship v0.13.2 or separate batch.

Release — cut v0.13.2 (deployah role)

After all waves gate-pass + CI green BOTH runners: verify release counter against spt-releases metadata at cut (v0.13.1 lesson, expect 29). Author version bump (workspace.package.version 0.13.1→0.13.2 + CHANGELOG section=release body + Cargo.lock first-party lines). Push bump→CI green both→tag→sign+publish. Coordinate publish ping to perri (drops claude-spt F-014 per-OS stopgap; F-011 closed). W3 int + Windows-lock regression = ship-blockers.

W3 design input — adapter psyche binary locks shared install-dir (perri F-010×F-015)

perri live repro 2026-06-22 (spt 0.13.1, claude-spt 0.6.0, Win): claude-spt-psyche.exe (pid 181272, --id wall-a-psyche) ran DETACHED from shared install-dir, parent (152200) DEAD = orphan (~4h), held binary lock → adapter update/add --release failed (tar exit Some(1)). endpoint stop wall-a set alive=false but did NOT reap the detached psyche.

ROOT MECHANICS: psyche is detached per manifest [session.psyche_init] detach=true (Stdio::null, unsupervised), hosted by daemon livehost off the live_agent online status. It runs from the SHARED install dir (lock surface = manifest.toml + claude-spt-digest.exe + claude-spt-psyche.exe + cc-spt-idle-translate.exe + strings/).

LOAD-BEARING ON W3: ADR-0025 resident/ephemeral split assumed psyche=daemon-hosted in-process (ADR-0004), EXCLUDED from cycling. But it's a SEPARATE install-dir binary → unsupervised locker W3's resident-only stop/restart won't release. Fixes compose: (c) psyche-from-own-copy not shared dir = cleanest structural kill, removes vector independent of reap timing; (a) orphan reap on stop/agent-death = F-010 root close; (b) extract-to-temp+atomic-swap = F-015 backstop (one locked file ≠ whole-update fail).

W3 INT FIXTURE (perri spec, deterministic, NO mocks): 1. bring up claude-spt live agent wall-X → daemon spawns detached psyche from install dir; 2. kill psyche PARENT (livehost/agent) → parent-dead orphan; 3. spt endpoint stop wall-X → alive=false BUT psyche survives (reap gap); 4. spt adapter update claude-spt → tar overwrites locked psyche.exe → exit 1 → whole update fails. PASS post-W3: step 3 reaps orphan AND/OR psyche never ran from shared target AND/OR locked-file extract no longer fails whole update. perri re-runs this exact repro as the ship acceptance test on doyle ping.

v0.13.3 fast-follow — multi-subnet endpoint-run bind LATENT GAP (perri)

LATENT GAP (perri, 2026-06-22) — NOT a regression (≥0.11.0 all HOME_REFUSED). spt-hosted bringup never supported multi-subnet home; BIGNET crossing exposed it. F-013 class. Operator blocked by choice (declined stopgap).

DESIGN COMPLETE (grilled with operator 2026-06-22). Grown well beyond a patch — see SCOPE note.

REQ-A — multi-subnet home resolution:
- Home IMMUTABLE (ADR-0010). Gap only on first-creation (re-bind inherits via establish_perch prior-branch).
- Reuse skeleton→live (CONTEXT:113/159): endpoint run creates skeleton perch WITH resolved home BEFORE spawn (currently doesn't — bind creates fresh None-branch → assign_home → HOME_REFUSED @home.rs). bind inherits via prior-branch. NO hook change, NO env (supersedes original 3-part env design).
- Resolution: single→auto; multi no --subnet → NON-INTERACTIVE refuse (std::io::IsTerminal) w/ MRU-ordered guidance; INTERACTIVE → proposed config (name/project/adapter[:profile]/home=MRU-top) + "Ok to proceed? Y/n", n→--subnet guidance. --subnet overrides+validates (assign_home NotMember).
- MRU = LISTs (ordered, move-to-front), TWO levels: per-project + always-updated node-global (fallback for MRU-less projects). Default = project-list-top ?? node-global-top. Both updated on every home choice.

REQ-B — attach-on-session-exists + Unbound state:
- Gate attach on BROKER SESSION attachable (created at spawn, before bind), not perch-online. run_attach + `spt rc <id>` attach to a live session regardless of perch status (headless bringups too). Replaces await_endpoint_online (cli.rs:1434).
- NEW on-disk STATUS_UNBOUND: spawn→UNBOUND, bind→ONLINE, death→OFFLINE.
- LIFECYCLE: reuse existing exit-waiter/reconcile (session death→OFFLINE, the B2/B5 path) — no new teardown. UNBOUND = ATTACHABLE but NOT message-addressable (no session_id until bind); messaging stays ONLINE/bound-gated, attach session-gated. Auto-resolves.
- DISPLAY: EpDisplay gains Unbound (picker/model.rs; amber=HarnessOnly is TAKEN + means not-controllable = opposite of Unbound). HOLLOW treatment + a CONTROLLED-VARIANT of hollow (Unbound + driven_by).

SCOPE NOTE: this is now a meaty feature set (new on-disk status, EpDisplay variants, liveness-predicate edits, attach-gate change, skeleton-creation flow, two-level MRU, interactive confirm) — larger than a fast-follow patch. Scope/sequencing being decided (split minimal-unblock vs full-rework). ADR(s) to write. perri repro = REQ-A regression test (MUST run multi-subnet). Dispatch after v0.13.2.

Tracking v0.13.3 multi-subnet bind regression


Both ints are **real and non-vacuous** — int A is perri's exact 4-step (own-copy present → lock-free install-dir overwrite while psyche runs → kill brain-parent-only orphans the psyche → endpoint-stop reaps the brain-less orphan, with vacuity-guards at each step), int B is the keystone apply + Windows lock-regression + brain-parity. Before running them (real `spt daemon run`), confirm the broker socket is SPT_HOME-namespaced so the test daemon can't collide with the installed live daemon:


Bash

echo "=== broker_socket_name: SPT_HOME-namespaced? ==="; grep -n "fn broker_socket_name" crates/spt-daemon/src/endpoint.rs; sed -n "$(grep -n 'fn broker_socket_name' crates/spt-daemon/src/endpoint.rs | head -1 | cut -d: -f1),+14p" crates/spt-daemon/src/endpoint.rs

Verify broker socket is SPT_HOME-scoped (isolation from live daemon)


=== broker_socket_name: SPT_HOME-namespaced? ===
37:pub fn broker_socket_name() -> String {
pub fn broker_socket_name() -> String {
    format!("spt-daemon-broker-{}.sock", home_tag())
}

/// The digest-control listener name (B8): the channel `spt digest <id>` connects
/// to for a snapshot pull or a delta-stream subscribe (ADR-0008 / REQ-TERM-4).
pub fn digest_socket_name() -> String {
    format!("spt-daemon-digest-{}.sock", home_tag())
}

/// The drive-control listener name (M11-W2): the channel `spt shell drive` (owner
/// write) and `api drive-poll --link` (shell take-and-clear) connect to for the
/// per-shell ephemeral drive slot (REQ-SHELL-3). Broker-served like the digest
/// channel; the slot is in-memory and dies with the daemon.
// [impl->REQ-SHELL-3]


Isolation confirmed — `broker_socket_name` is `home_tag()`-namespaced (SPT_HOME-derived), so the test daemon can't collide with the installed live daemon. Safe to run. Running both ship-blocker ints myself + verifying ADR-0025 + traceable:


echo "=== int A: psyche-lock (perri 4-step) ==="; timeout 300 cargo test -p spt --test live_adapt_psyche_lock_e2e -- --test-threads=1 2>&1 | grep -E "test result|FAILED|panicked|DIAGNOSTIC" | head; echo "=== int B: translation swap (keystone + win-lock) ==="; timeout 300 cargo test -p spt --test live_adapt_translation_swap_e2e -- --test-threads=1 2>&1 | grep -E "test result|FAILED|panicked|DIAGNOSTIC" | head

Run both W3e no-mocks ship-blocker ints


=== int A: psyche-lock (perri 4-step) ===
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 13.42s
=== int B: translation swap (keystone + win-lock) ===
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.87s


Both ship-blocker ints **PASS on my own run** (int A 13.4s, int B 2.9s — real daemons, no mocks, isolated). The Windows lock-regression + perri's 4-step + keystone apply + brain-parity all green. Verify ADR-0025 amendment + traceable:


echo "=== ADR-0025 status + amendment ==="; git diff 322ca55..92ee788 -- docs/adr/0025-live-daemon-coordinated-adapter-update.md | grep "^+" | head -25
echo "=== clippy (ship gate) ==="; cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -1; echo "CLIPPY=${PIPESTATUS[0]}"
echo "=== traceable both REQs +int ==="; "C:/Users/decid/AppData/Local/Programs/traceable-reqs/traceable-reqs" check >/tmp/tw3e.txt 2>&1; echo "EXIT=$?"; grep -E "REQ-ADAPTER-LIVE-UPDATE|REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP" /tmp/tw3e.txt | head

Verify ADR-0025 amendment + clippy + traceable both REQs int


=== ADR-0025 status + amendment ===
+++ b/docs/adr/0025-live-daemon-coordinated-adapter-update.md
+accepted (2026-06-22, v0.13.2) — built in W3; see the **Amendment (W3 build)** below for three corrections the build surfaced.
+
+## Amendment (W3 build, 2026-06-22)
+
+<!-- [doc->REQ-ADAPTER-LIVE-UPDATE] -->
+<!-- [doc->REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP] -->
+
+The build (gated wave-by-wave with doyle) surfaced three facts this ADR's Decision got wrong, plus a third axis to the resident/ephemeral split. The shipped design is the corrected one below.
+
+**Correction 1 — the Psyche is a separate install-dir binary, not in-daemon.** The Decision's "Resident vs ephemeral" excludes the Psyche as ephemeral *"because the psyche loop is daemon-hosted, not a separate process (ADR-0004)."* The field (claude-spt, perri **F-010×F-015**) proves otherwise: claude-spt's Psyche is a **separate, detached, unsupervised install-dir binary** (`[session.psyche_init] detach=true`, `Stdio::null`) that runs **straight from the shared install dir** and so **locks the update target** — its dead parent brain's orphan lingered ~4h holding the lock, and `spt adapter update` failed `tar exit 1` / *Access denied (os error 5)*.
+
+**Correction 2 — "re-clone the manifest into the running `BrainLifecycle`" is not implementable as written.** Decision step 3 assumes the apply can reach the live `BrainLifecycle`. It cannot: the `BrainLifecycle` (and the whole live-host + pulse loop) is **moved into a driver thread inside the `spt daemon brain` CHILD PROCESS**, while the broker — which runs the resident translation child and serves the apply IPC — is the **`spt daemon run` daemon PROCESS**. They share **no memory** (only the broker socket), so an in-memory manifest handle cannot be pushed across. **This also corrects the W3 design-gate verdict's "broker registers the brain's `Arc` handle"** — equally impossible. The shipped mechanism is **reload-on-dirty**: the brain's pulse loop (every period) hashes `<install_dir>/manifest.toml` (`sha256`) and, on a change, re-reads + reparses it (the same pointer-mode load path bringup used) and refreshes its own in-memory manifest + runtime in place — **no cross-process handle**, eventually-consistent within one pulse. The CLI reports success on the broker-side swap+restart; it does **not** wait on the brain poll.
+
+**Correction 3 / the third axis — *ephemeral-but-install-dir-locking → run from a per-endpoint own copy*.** The resident/ephemeral split needs a third class: an **ephemeral** binary (excluded from the daemon-coordinated stop/restart, self-heals on next spawn) that nonetheless **locks the install dir while it runs** (a detached, unsupervised, long-lived install-dir binary). The fix is to run it from a **per-endpoint private copy** under `<perch>/.live-bin/<program>` (copied on first spawn / when the hash differs; torn down with the perch), never the shared install-dir binary — so it can never lock the update target, regardless of reap timing. REQ-INSTALL-11 holds: the bare program token still resolves to the adapter's shipped binary (identical content, only relocated); args / `strings/` / `{key}` still resolve against the real install dir. This is what makes the daemon apply **translation-only**: with the Psyche on an own copy, the **resident translation binary is the only install-dir locker**, so the broker apply stops/restarts only it (no Psyche stop/reap). The orphan-leak half of F-010×F-015 — a brain-less perch's orphan Psyche that the handle-reap (`stop_host`) can't free and the brain-start sweep never fires for — is closed by **REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP**: the cmdline-scoped, fail-safe-decline reap is invoked at the endpoint-stop / brain-death reconcile, not just at brain start.
+
+**Net shipped flow.** CLI fetches + verifies + extracts; for an adapter with no live endpoint it CRC-swaps directly; with a live endpoint it hands the apply to the broker, which per affected endpoint **stops the resident translation child** (releasing the OS lock) → **CRC-swaps only changed files** (never-strand) → **restarts the translation from the NEW on-disk manifest path**. The Psyche (own copy) is untouched and picks up new bytes on its next respawn; the brain self-heals its manifest via reload-on-dirty. Brain-parity holds: the endpoint / PTY / output log never restart — only the resident translation child cycles.
=== clippy (ship gate) ===
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.05s
CLIPPY=0
=== traceable both REQs +int ===
EXIT=0
  [OK] REQ-ADAPTER-LIVE-UPDATE  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
  [OK] REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int


**W3e = GATE-PASS — the ship-blocker is cleared. W3 COMPLETE end-to-end.**

I ran both no-mocks ints myself (PASS): perri's exact 4-step (own-copy → lock-free install-dir overwrite → orphan → stop-reap) + the keystone apply (Windows lock-regression + swap-on-locked-binary + brain-parity). ADR-0025 → ACCEPTED with a thorough 3-correction amendment (incl. explicitly correcting my own verdict's caution-ii on the broker≠brain process boundary). clippy 0, traceable 0, both REQs `+doc+impl+unit+int`.

Report W3e + greenlight W4/W5, ping perri, advance tasks:


"$OWL" send todlando doyle <<'EOF'
W3e GATE-PASS @92ee788 — the ship-blocker is CLEARED, W3 COMPLETE end-to-end. I RAN both no-mocks ints myself (not just read): int A psyche-lock 13.4s PASS, int B translation-swap 2.9s PASS. Verified:
- Both are REAL daemon ints (spt daemon run broker + supervised brain + dummy-harness + real translation/psyche binaries), isolated tempdir SPT_HOME (broker_socket_name is home_tag-namespaced → ZERO collision with the box's live daemon, confirmed), scoped PID cleanup. The "mock" hits = the mock-session fixture NAME + "NO mocks" comments, not code mocks.
- NON-VACUOUS by construction: int A guards each step (psyche-alive-before-overwrite, orphan-alive-before-stop); int B's #[cfg(windows)] direct-overwrite-FAILS-os5 pre-assert is the vacuity guard (without a real lock the apply test would be hollow).
- Keystone proven: swap LANDED new bytes on the previously-LOCKED binary; brain-parity (harness_alive_after + still_online). Stop-path reap proven (the test never kills the orphan; endpoint stop does).
- ADR-0025 ACCEPTED w/ the 3-correction amendment (psyche-separate-binary; broker!=brain-process reload-on-dirty [corrects my verdict caution-ii — well done flagging it]; third-axis own-copy). clippy 0, traceable 0 (both REQs +int), drift OK, scope-audited.
- Good UAC-name catch (live_adapt_* not live_update_* — os-740 elevation heuristic, KH windows-uac-binary-name class).

Outstanding, disciplined work across the whole keystone — the STOP-AND-FLAG on the process boundary saved an unbuildable design.

GREENLIGHT W4 then W5 (both small, independent):
- W4 REQ-ADAPTER-UPDATE-MESSAGE: [update].message, markdown-rendered (helpfmt prose path), ONLY when an update actually APPLIES (version changed), NOT a no-op. Read from the newly-installed manifest. Update struct message:Option<String>; cmd_adapter_update after a successful apply → helpfmt::render + print. Activate [doc,impl,unit]; unit (rendered-on-applied, NOT-on-no-op); doc=manifest.md+CONTEXT. No int.
- W5 REQ-ADAPTER-PROOF-DIR-OVERRIDE (F-011): --dir/--manifest on digest-proof + translate-proof → proof a DEV binary vs on-disk manifest+dir without staging a full install. Activate [doc,impl,unit,int]; unit (override resolves dev manifest+dir) + int (mirror the proof ints with --dir); doc=proof --help + patterns. Regen xtask gen (CLI help changed).
Same rules (clippy --workspace, traceable standalone, scope-audit, co-author trailer). Push each; ping me. Then deployah cuts v0.13.2 (counter — VERIFY vs spt-releases metadata at cut, the v0.13.1 lesson; don't trust memory). Ship in sight.
EOF
echo "---todlando $?---"
"$OWL" send perri doyle <<'EOF'
W3 COMPLETE + gate-passed — your F-010×F-015 psyche-lock fix is in. Both no-mocks ints reproduce your EXACT scenario and pass (I ran them): (c) psyche runs from <perch>/.live-bin own-copy so the shared install-dir binary is LOCK-FREE while it runs (a shared-install-dir spawn would os-5 lock on Windows — the (b)+(c) proof); kill the brain parent → psyche orphans; endpoint stop → the cmdline-scoped fail-safe-decline guard REAPS the brain-less orphan. Plus the keystone: a live adapter update stops the resident translation, CRC-swaps the previously-LOCKED install-dir binary, restarts it — endpoint never restarts (brain-parity). ADR-0025 accepted w/ the amendment crediting your field find.

This rides v0.13.2 (W4 update-message + W5 proof --dir remain, then deployah cuts). YOU validate on publish: install the new spt (spt adapter add --release once it's out / spt update), then re-run your exact recovery repro on REAL claude-spt — the update must no longer brick on the locked psyche/translation binary, and a stale orphan must reap on endpoint stop. I'll ping you with the v0.13.2 version + hashes at publish. Thanks again — your repro became the literal acceptance test.
EOF
echo "---perri $?---"

Report W3e + greenlight W4/W5 to todlando; ping perri


➜ SENT:todlando
---todlando 0---
➜ SENT:perri
---perri 0---


Updated task #3 status


Updated task #4 owner, status


W3 complete, W4/W5 greenlit (building async), task #3 done. Now I'll use the W4/W5 build window to complete the **grill deliverable** the user asked for — the 2 ADRs + CONTEXT term + REQ registration — on a dedicated design branch off main (clean separation from the v0.13.2 release). Create the branch:


git status --short | head; echo "=== latest ADR on main ==="; git ls-tree --name-only origin/main docs/adr/ | sort | tail -3; echo "=== create design branch off origin/main ==="; git stash list | head -1; git checkout -b design/endpoint-creation-flow origin/main 2>&1 | tail -3

Check tree + main ADRs + create design branch


<owl_messages>
<owl_message from="perri" priority="high">Excellent — W3 landing my exact F-010×F-015 scenario as the literal acceptance test is the best possible outcome. The (c) own-copy <perch>/.live-bin (shared install binary stays lock-free) + (a/b) orphan-reap on endpoint stop + the CRC-swap live-update with brain-parity (endpoint never restarts) is precisely the right shape — kills the lock vector structurally, not just by timing. Thanks for the ADR-0025 credit.

Locked in as validator: on v0.13.2 publish I'll install the new spt (update / add --release) and RE-RUN my exact recovery repro on real claude-spt — assert (1) the adapter update no longer bricks on a locked psyche/translation install-dir binary, (2) a stale brain-less orphan reaps on `endpoint stop`. Ping me the version + hashes and I'll turn it around. Two validations now queued on me: this (v0.13.2 psyche-lock) + the multi-subnet bringup gap (v0.13.3). Standing by.</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.


=== latest ADR on main ===
docs/adr/0021-harness-hosted-adapter-agnostic-seed-and-bind-time-resolution.md
docs/adr/0022-spt-hosted-idle-delivery-translation-binary.md
docs/adr/0023-always-on-endpoints-resident-supervised-binary-sigil-addressing.md
=== create design branch off origin/main ===
branch 'design/endpoint-creation-flow' set up to track 'origin/main'.
Switched to a new branch 'design/endpoint-creation-flow'


perri's a confirmation (validator locked in for both v0.13.2 psyche-lock + the multi-subnet milestone). Quick ack, then continue the ADR capture:


"$OWL" send perri doyle <<'EOF'
Ack — validator role locked in, both queued (v0.13.2 psyche-lock now; the multi-subnet endpoint-creation work next, being grilled into ADRs 0026/0027 now — scoped as one milestone, not a quick patch). I'll ping the v0.13.2 version + hashes at publish for your real-claude-spt re-run. Standing by acknowledged.
EOF
echo "---perri ack $?---"

Brief ack to perri validator confirmation


➜ SENT:perri
---perri ack 0---


Now write ADR-0026 (multi-subnet home @ creation = REQ-A):


Write

# Multi-subnet home resolution at `endpoint run` creation

<!-- [doc->REQ-RUN-MULTISUBNET-HOME] -->

## Status

proposed (2026-06-22; grilled with the operator). Refines ADR-0010 (immutable home subnet) at the spt-hosted creation seam. Sibling of ADR-0027 (Unbound state + attach-on-session). Targets a dedicated endpoint-creation-flow milestone (post-v0.13.2).

## Context

`spt endpoint run` on a **multi-subnet** node cannot bring up a fresh endpoint: the harness's `SessionStart` bind hook runs `spt api bind "$SPT_ENDPOINT_ID" --set-session-id "$sid"` with no `--subnet`, and `cmd_bind → establish_perch → stamp_creation_fields` (prior=None branch) calls `assign_home`, which on a node holding ≥2 subnets returns `HomeError::Ambiguous` → `HOME_REFUSED` (the established no-guess policy, ≥v0.11.0). The hook swallows it (`|| true`) → no perch ever binds → `ENDPOINT_RUN_ONLINE_TIMEOUT` after 25s.

This is a **latent gap, not a regression** (perri pinned: spt 0.11/0.12/0.12.1/0.13.1 all `HOME_REFUSED`). The spt-hosted bringup never supported a multi-subnet home — it only worked while the node was single-subnet (`assign_home` auto-homes the sole subnet); adding a 2nd subnet (e.g. BIGNET) exposed it. The spt-hosted path gives the adapter no way to supply the home: `endpoint run` has no `--subnet`, the broker injects no home env, and bind has no fallback. Same class as F-013 (bringup can't satisfy bind from available inputs).

Home is **immutable** (ADR-0010 — no re-home; `spt fork` is the cross-subnet move), so a *silent* wrong default is costly (stuck → must fork). The fix must decide the home *correctly at creation*, not guess and let it be fixed later.

## Decision

**`endpoint run` resolves the home subnet and pre-creates the skeleton perch carrying it, reusing the existing skeleton→live model** (CONTEXT §post-spawn seam: "the call flips the perch from skeleton → live"). Today `cmd_endpoint_run` does **not** pre-create the skeleton for the spt-hosted path — it spawns straight to `launch_harness_brokered_in`, and the hook's bind creates the perch fresh (the `assign_home` → `HOME_REFUSED` path). The fix:

1. **Resolve the home at the skeleton-create step**, before spawning the harness:
   - sole subnet → auto (`assign_home`, unchanged);
   - multi-subnet, no `--subnet`, **non-interactive** terminal (`std::io::IsTerminal` on stdin) → **refuse early** with a clear, MRU-ordered `--subnet` guidance message (never the silent 25s timeout);
   - multi-subnet, no `--subnet`, **interactive** → print the proposed endpoint config (id, project, adapter[:profile], home = the MRU default) and prompt `Ok to proceed? Y/n`; `n` → print the `--subnet` guidance;
   - `--subnet <name>` always overrides and validates membership (`assign_home` `NotMember`).
2. **Write the skeleton perch `info.json` with the resolved `home_subnet`** before spawn. The hook's `bind` then hits `establish_perch`'s **prior-branch**, which **inherits** `p.home_subnet` (immutable) — no `HOME_REFUSED`, **no hook change, no env injection**.
3. **MRU = ordered lists** (move-to-front) of recently-chosen home subnets at **two levels**: a **per-project** list and an always-updated **node-global** list (the fallback for a project with no list yet). The default home = project-list head ?? node-global head; the interactive picker / `--subnet` guidance lists subnets MRU-ordered. Both lists update on every home choice.

## Considered Options

- **Broker injects `$SPT_ENDPOINT_SUBNET` + `cmd_bind` env-fallback** (the first design) — works with unchanged hooks too, but adds env plumbing through `endpoint run → broker → SpawnReq` plus a `cmd_bind` branch, and gives no pre-bind perch. **Rejected** in favor of the skeleton-with-home, which reuses the existing immutable prior-branch inherit (≈no new bind/hook code) and yields a pre-bind perch (the attach target ADR-0027 needs).
- **Relax ADR-0010 to a mutable home** (so a silent MRU default is fixable later) — reopens the sync-scope / stale-mind ambiguity ADR-0010 closed and makes `spt fork` redundant. **Rejected**; home stays immutable, the pick is made correctly at creation.
- **A persisted single node-default-home** — too coarse; the operator wanted per-project memory with a node-global fallback, and a *list* (history), not one value.
- **Require `--subnet` unconditionally on multi-subnet** — correct but poor UX for the common case; the interactive confirm + MRU default keeps the flag-free path working while never *guessing* silently.

## Consequences

- A multi-subnet node gets a flag-free interactive bringup (confirm the MRU default) and a hard, clear refusal in non-interactive contexts (CI/scripts must pass `--subnet`) — never a silent timeout.
- Resume/re-bind is unaffected (it already inherits the recorded home via the prior-branch).
- New state: the two-level MRU lists (per-project + node-global) under `$SPT_HOME`.
- perri's repro is the regression test and **must run on a multi-subnet node** (a single-subnet node auto-homes and hides the gap).


File created successfully at: C:\Users\decid\Documents\projects\spt-core\docs\adr\0026-multi-subnet-home-at-endpoint-run-creation.md (file state is current in your context — no need to Read it back)

# Multi-subnet home resolution at `endpoint run` creation

<!-- [doc->REQ-RUN-MULTISUBNET-HOME] -->

## Status

proposed (2026-06-22; grilled with the operator). Refines ADR-0010 (immutable home subnet) at the spt-hosted creation seam. Sibling of ADR-0027 (Unbound state + attach-on-session). Targets a dedicated endpoint-creation-flow milestone (post-v0.13.2).

## Context

`spt endpoint run` on a **multi-subnet** node cannot bring up a fresh endpoint: the harness's `SessionStart` bind hook runs `spt api bind "$SPT_ENDPOINT_ID" --set-session-id "$sid"` with no `--subnet`, and `cmd_bind → establish_perch → stamp_creation_fields` (prior=None branch) calls `assign_home`, which on a node holding ≥2 subnets returns `HomeError::Ambiguous` → `HOME_REFUSED` (the established no-guess policy, ≥v0.11.0). The hook swallows it (`|| true`) → no perch ever binds → `ENDPOINT_RUN_ONLINE_TIMEOUT` after 25s.

This is a **latent gap, not a regression** (perri pinned: spt 0.11/0.12/0.12.1/0.13.1 all `HOME_REFUSED`). The spt-hosted bringup never supported a multi-subnet home — it only worked while the node was single-subnet (`assign_home` auto-homes the sole subnet); adding a 2nd subnet (e.g. BIGNET) exposed it. The spt-hosted path gives the adapter no way to supply the home: `endpoint run` has no `--subnet`, the broker injects no home env, and bind has no fallback. Same class as F-013 (bringup can't satisfy bind from available inputs).

Home is **immutable** (ADR-0010 — no re-home; `spt fork` is the cross-subnet move), so a *silent* wrong default is costly (stuck → must fork). The fix must decide the home *correctly at creation*, not guess and let it be fixed later.

## Decision

**`endpoint run` resolves the home subnet and pre-creates the skeleton perch carrying it, reusing the existing skeleton→live model** (CONTEXT §post-spawn seam: "the call flips the perch from skeleton → live"). Today `cmd_endpoint_run` does **not** pre-create the skeleton for the spt-hosted path — it spawns straight to `launch_harness_brokered_in`, and the hook's bind creates the perch fresh (the `assign_home` → `HOME_REFUSED` path). The fix:

1. **Resolve the home at the skeleton-create step**, before spawning the harness:
   - sole subnet → auto (`assign_home`, unchanged);
   - multi-subnet, no `--subnet`, **non-interactive** terminal (`std::io::IsTerminal` on stdin) → **refuse early** with a clear, MRU-ordered `--subnet` guidance message (never the silent 25s timeout);
   - multi-subnet, no `--subnet`, **interactive** → print the proposed endpoint config (id, project, adapter[:profile], home = the MRU default) and prompt `Ok to proceed? Y/n`; `n` → print the `--subnet` guidance;
   - `--subnet <name>` always overrides and validates membership (`assign_home` `NotMember`).
2. **Write the skeleton perch `info.json` with the resolved `home_subnet`** before spawn. The hook's `bind` then hits `establish_perch`'s **prior-branch**, which **inherits** `p.home_subnet` (immutable) — no `HOME_REFUSED`, **no hook change, no env injection**.
3. **MRU = ordered lists** (move-to-front) of recently-chosen home subnets at **two levels**: a **per-project** list and an always-updated **node-global** list (the fallback for a project with no list yet). The default home = project-list head ?? node-global head; the interactive picker / `--subnet` guidance lists subnets MRU-ordered. Both lists update on every home choice.

## Considered Options

- **Broker injects `$SPT_ENDPOINT_SUBNET` + `cmd_bind` env-fallback** (the first design) — works with unchanged hooks too, but adds env plumbing through `endpoint run → broker → SpawnReq` plus a `cmd_bind` branch, and gives no pre-bind perch. **Rejected** in favor of the skeleton-with-home, which reuses the existing immutable prior-branch inherit (≈no new bind/hook code) and yields a pre-bind perch (the attach target ADR-0027 needs).
- **Relax ADR-0010 to a mutable home** (so a silent MRU default is fixable later) — reopens the sync-scope / stale-mind ambiguity ADR-0010 closed and makes `spt fork` redundant. **Rejected**; home stays immutable, the pick is made correctly at creation.
- **A persisted single node-default-home** — too coarse; the operator wanted per-project memory with a node-global fallback, and a *list* (history), not one value.
- **Require `--subnet` unconditionally on multi-subnet** — correct but poor UX for the common case; the interactive confirm + MRU default keeps the flag-free path working while never *guessing* silently.

## Consequences

- A multi-subnet node gets a flag-free interactive bringup (confirm the MRU default) and a hard, clear refusal in non-interactive contexts (CI/scripts must pass `--subnet`) — never a silent timeout.
- Resume/re-bind is unaffected (it already inherits the recorded home via the prior-branch).
- New state: the two-level MRU lists (per-project + node-global) under `$SPT_HOME`.
- perri's repro is the regression test and **must run on a multi-subnet node** (a single-subnet node auto-homes and hides the gap).


# Unbound endpoint state + attach-on-session-exists

<!-- [doc->REQ-ENDPOINT-UNBOUND-ATTACH] -->

## Status

proposed (2026-06-22; grilled with the operator). Sibling of ADR-0026 (multi-subnet home at creation). Targets the same endpoint-creation-flow milestone (post-v0.13.2).

## Context

`spt endpoint run`, for an attach/view bringup, waits for the perch to reach `STATUS_ONLINE` — i.e. for the harness to **bind** — before attaching (`await_endpoint_online`, REQ-HAZARD-RC-ATTACH-ONLINE-RACE). That gate was added because attaching too early lost the handshake three ways (empty ring → EOF, the offline status-gate, no live session yet).

But the broker creates the **session + PTY + OutputLog at spawn — before bind**. Gating the attach on *bind* (perch online) deadlocks a real case: a harness that shows a startup prompt **before** it binds (e.g. Claude Code waiting for the user to clear an initial prompt) never binds until the operator interacts — but the operator can't see or interact, because nothing is attached until bind. Result: the operator stares at a 25s timeout for a prompt they were never shown. The attachable thing (the broker session/PTY) exists well before the gate fires.

## Decision

**Gate the attach on the broker SESSION being attachable, not on the perch being bound/online**, and surface the in-between lifecycle point as a first-class state.

- **Attach-on-session-exists.** `cmd_endpoint_run` (and `spt rc <id>`) attach as soon as a **live broker session** exists for the id — regardless of perch status — so the operator sees and drives the harness immediately, including clearing a bind-gating prompt. This also makes a **headless** bringup attachable before/without bind. The source of truth is the broker `sessions` map (the per-endpoint registry, ADR-0025 W3a). This *preserves* REQ-HAZARD-RC-ATTACH-ONLINE-RACE's "don't attach before there's a session" intent — it just moves the gate from the later perch-online point to the earlier session-exists point. Local-only: a remote row can't see another node's broker (renders plain, like the existing controllability limitation).

- **New on-disk status `STATUS_UNBOUND`.** The daemon stamps `UNBOUND` at spawn; `bind` flips it to `ONLINE`; session death → `OFFLINE`. (Chosen over a purely-derived display so the state is explicit and queryable in the daemon-authoritative liveness model.)
  - **Lifecycle reuses the existing reconcile/exit-waiter** — a dead broker session → `OFFLINE` (the same path that marks dead-but-latched perches offline). No new teardown; `UNBOUND` auto-resolves (→`ONLINE` on bind, →`OFFLINE` on session death).
  - **`UNBOUND` is attachable but NOT message-addressable** — it has a live PTY but no bound `session_id` yet, so messaging stays gated on `ONLINE`/bound while attach is gated on session-exists.

- **Display.** `EpDisplay` gains an `Unbound` variant. **Amber is already taken** (`HarnessOnly` = online-but-not-broker-controllable — the semantic *opposite* of `Unbound`, which *is* broker-attachable), so `Unbound` uses a **hollow** treatment (the existing filled/hollow square precedent), plus a **controlled-variant** (an `Unbound` endpoint a node is driving) — i.e. hollow vs hollow-controlled, paralleling the filled Online vs Controlled pair.

## Considered Options

- **Keep `await_endpoint_online` + a grace-then-attach-anyway fallback** — a race-prone timer heuristic still anchored on bind, rather than fixing the gate's basis. Rejected.
- **Derive `Unbound` in the picker only (no on-disk status)** — one source (broker session map), no new liveness status; but the operator chose an explicit on-disk `STATUS_UNBOUND` (queryable, honest in `endpoint list`, not just picker-local). Accepted the extra reconcile/teardown edges that come with it.
- **Reuse amber for `Unbound`** — semantically wrong (amber = not-controllable; Unbound is attachable) and ambiguous against `HarnessOnly`. Rejected; hollow instead.

## Consequences

- A new on-disk liveness status (`STATUS_UNBOUND`) — the liveness predicates and reconcile/teardown must treat it honestly (attachable, not message-addressable; auto-resolves).
- `spt rc <id>` works against an unbound endpoint (a live session with no bind) — useful for headless bringups and for clearing a bind-gating prompt.
- The attach gate moves earlier (session-exists), removing the prompt-before-bind deadlock while keeping the original race protection.
- `EpDisplay` grows (hollow + hollow-controlled); the picker/legend gain the Unbound state.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\docs\adr\0027-unbound-endpoint-state-and-attach-on-session.md (file state is current in your context — no need to Read it back)

# Unbound endpoint state + attach-on-session-exists

<!-- [doc->REQ-ENDPOINT-UNBOUND-ATTACH] -->

## Status

proposed (2026-06-22; grilled with the operator). Sibling of ADR-0026 (multi-subnet home at creation). Targets the same endpoint-creation-flow milestone (post-v0.13.2).

## Context

`spt endpoint run`, for an attach/view bringup, waits for the perch to reach `STATUS_ONLINE` — i.e. for the harness to **bind** — before attaching (`await_endpoint_online`, REQ-HAZARD-RC-ATTACH-ONLINE-RACE). That gate was added because attaching too early lost the handshake three ways (empty ring → EOF, the offline status-gate, no live session yet).

But the broker creates the **session + PTY + OutputLog at spawn — before bind**. Gating the attach on *bind* (perch online) deadlocks a real case: a harness that shows a startup prompt **before** it binds (e.g. Claude Code waiting for the user to clear an initial prompt) never binds until the operator interacts — but the operator can't see or interact, because nothing is attached until bind. Result: the operator stares at a 25s timeout for a prompt they were never shown. The attachable thing (the broker session/PTY) exists well before the gate fires.

## Decision

**Gate the attach on the broker SESSION being attachable, not on the perch being bound/online**, and surface the in-between lifecycle point as a first-class state.

- **Attach-on-session-exists.** `cmd_endpoint_run` (and `spt rc <id>`) attach as soon as a **live broker session** exists for the id — regardless of perch status — so the operator sees and drives the harness immediately, including clearing a bind-gating prompt. This also makes a **headless** bringup attachable before/without bind. The source of truth is the broker `sessions` map (the per-endpoint registry, ADR-0025 W3a). This *preserves* REQ-HAZARD-RC-ATTACH-ONLINE-RACE's "don't attach before there's a session" intent — it just moves the gate from the later perch-online point to the earlier session-exists point. Local-only: a remote row can't see another node's broker (renders plain, like the existing controllability limitation).

- **New on-disk status `STATUS_UNBOUND`.** The daemon stamps `UNBOUND` at spawn; `bind` flips it to `ONLINE`; session death → `OFFLINE`. (Chosen over a purely-derived display so the state is explicit and queryable in the daemon-authoritative liveness model.)
  - **Lifecycle reuses the existing reconcile/exit-waiter** — a dead broker session → `OFFLINE` (the same path that marks dead-but-latched perches offline). No new teardown; `UNBOUND` auto-resolves (→`ONLINE` on bind, →`OFFLINE` on session death).
  - **`UNBOUND` is attachable but NOT message-addressable** — it has a live PTY but no bound `session_id` yet, so messaging stays gated on `ONLINE`/bound while attach is gated on session-exists.

- **Display.** `EpDisplay` gains an `Unbound` variant. **Amber is already taken** (`HarnessOnly` = online-but-not-broker-controllable — the semantic *opposite* of `Unbound`, which *is* broker-attachable), so `Unbound` uses a **hollow** treatment (the existing filled/hollow square precedent), plus a **controlled-variant** (an `Unbound` endpoint a node is driving) — i.e. hollow vs hollow-controlled, paralleling the filled Online vs Controlled pair.

## Considered Options

- **Keep `await_endpoint_online` + a grace-then-attach-anyway fallback** — a race-prone timer heuristic still anchored on bind, rather than fixing the gate's basis. Rejected.
- **Derive `Unbound` in the picker only (no on-disk status)** — one source (broker session map), no new liveness status; but the operator chose an explicit on-disk `STATUS_UNBOUND` (queryable, honest in `endpoint list`, not just picker-local). Accepted the extra reconcile/teardown edges that come with it.
- **Reuse amber for `Unbound`** — semantically wrong (amber = not-controllable; Unbound is attachable) and ambiguous against `HarnessOnly`. Rejected; hollow instead.

## Consequences

- A new on-disk liveness status (`STATUS_UNBOUND`) — the liveness predicates and reconcile/teardown must treat it honestly (attachable, not message-addressable; auto-resolves).
- `spt rc <id>` works against an unbound endpoint (a live session with no bind) — useful for headless bringups and for clearing a bind-gating prompt.
- The attach gate moves earlier (session-exists), removing the prompt-before-bind deadlock while keeping the original race protection.
- `EpDisplay` grows (hollow + hollow-controlled); the picker/legend gain the Unbound state.


Add the CONTEXT "Unbound endpoint" glossary term + register the two REQs. Find the anchors on this (main-based) checkout:


Bash

echo "=== CONTEXT home-subnet anchor ==="; grep -n "home subnet (per endpoint)" CONTEXT.md; echo "=== end of CONTEXT (where terms live) ==="; grep -n "^\*\*.*\*\*:" CONTEXT.md | tail -3; echo "=== traceable: last requirement id + tail ==="; grep -n '^id = ' traceable-reqs.toml | tail -2; tail -4 traceable-reqs.toml

Find CONTEXT term anchor + traceable tail on main


=== CONTEXT home-subnet anchor ===
589:**home subnet (per endpoint)**:
=== end of CONTEXT (where terms live) ===
777:**adapter registration (`spt adapter add`)**:
788:**issue / feature tracking**:
802:**guided-resume picker (`spt resume`, no-arg)**:
=== traceable: last requirement id + tail ===
1324:id = "REQ-HAZARD-CONTROLLER-WRITER-REORDER"
1330:id = "REQ-HAZARD-CONTROLLER-RETAKE-FLOOR"
[[requirements]]
id = "REQ-HAZARD-CONTROLLER-RETAKE-FLOOR"
title = "`become_controller` should STRUCTURALLY refuse a controller re-take whose `from_seq` falls below the connection's already-delivered contiguous floor — making the P1c reorder invariant un-reintroducible by a future caller, not just removed at the one caller. ROOT/SCOPE (doyle proposed, P1c gate dialogue): P1c fixes REQ-HAZARD-CONTROLLER-WRITER-REORDER three ways (handoff single-take + epoch-gate-under-lock + session_cursors seed), removing the one decreasing-floor double-take and bounding any other to already-committed-only. A self-enforcing broker guard would refuse the bad SHAPE outright. BLOCKER: the obvious predicate (`from_seq >= delivered_through`) is UNSAFE because `delivered_through` is SESSION-WIDE (the `Arc<AtomicU64>` on `OutputLog`, shared by all controllers/viewers, advanced monotonic-MAX; `resume_seq` reads it) — a normal fresh-operator `from_seq=0` attach to a producing session legitimately sits below it (full ring replay + consumer dedup-below/snap-above), and monotonic-MAX can't distinguish the hazard (a `seq1`-without-`seq0` write reads as `2`). The structurally-correct guard needs a NEW per-connection contiguous-sent cursor (the true highest-contiguous seq this socket has received) that does not exist today; the guard then refuses a re-take below THAT. Bigger than P1c; no live gap (P1c fully fixes the actual bug). Mint/refine stages when the per-connection cursor is built. (v0.13.0 follow-up, post-ship)"
required_stages = []  # SEED ONLY (rule 5, activate-don't-pre-fail). The shipped fix is covered by REQ-HAZARD-CONTROLLER-WRITER-REORDER; this is the deferred STRUCTURAL guard (DEFERRED.md row). Activate with real stages only when a milestone adds the per-connection contiguous-sent cursor + the become_controller refuse-decreasing-floor guard. Until then NO evidence is owed.


589	**home subnet (per endpoint)**:
590	The single subnet that anchors an endpoint's *defaults*: the default **sync** scope (subnet-exclusive mind replication — *Cross-node Psyche sync*) and the subnet that qualifies its **bare name**. **Assigned at creation:** the node's **sole** subnet automatically; on a **multi-subnet** node it **must be specified** at creation (no silent guess — mirrors the *resolution policy*'s refuse-and-qualify); on an **unpaired** node the endpoint is local-only until first join, when home is set. Distinct from advertisement: identity is **node-global and advertised into *every* subnet the node belongs to** per the visibility defaults — home subnet is only the default-scope *anchor*, not a limit on where the endpoint is visible or addressable. **Home is immutable** — there is **no re-home** (ADR-0010). To place an agent's mind in a different subnet, **fork** it (see *`spt endpoint fork`*) and optionally delete the original; this avoids any scope-migration / stale-mind ambiguity (the source is untouched until explicitly deleted).
591
592	<!-- [doc->REQ-INST-14] -->
593	**resource advertisement (subnet resource registry)**:
594	A per-endpoint **free-text blurb** describing the services/functions the endpoint can serve — an agent **yellow-pages** for service discovery, distinct from *capability declaration* (machine-readable, which endpoint *types* a node hosts) and from *endpoint visibility* (whether it's addressable at all). **Both-authored + mutable:** config seeds a default; the agent refines its own at runtime (`spt endpoint description set …`). It is **not a separate registry** — it is a field on the endpoint record and a **projection** of the subnet registry (`(id, node, resources-blurb)`), surfaced as the "subnet resource registry" view. **Gated by the access layers:** an endpoint excluded from subnet S (visibility) — or, later, one whose access whitelist (*endpoint access whitelist*) excludes the viewer's node — never appears in that view; discovery leaks nothing a viewer couldn't reach. Synced as **registry data, not context** (directory metadata, not the agent's mind). M4 (needs the distributed registry).

**home subnet (per endpoint)**:
The single subnet that anchors an endpoint's *defaults*: the default **sync** scope (subnet-exclusive mind replication — *Cross-node Psyche sync*) and the subnet that qualifies its **bare name**. **Assigned at creation:** the node's **sole** subnet automatically; on a **multi-subnet** node it **must be specified** at creation (no silent guess — mirrors the *resolution policy*'s refuse-and-qualify); on an **unpaired** node the endpoint is local-only until first join, when home is set. Distinct from advertisement: identity is **node-global and advertised into *every* subnet the node belongs to** per the visibility defaults — home subnet is only the default-scope *anchor*, not a limit on where the endpoint is visible or addressable. **Home is immutable** — there is **no re-home** (ADR-0010). To place an agent's mind in a different subnet, **fork** it (see *`spt endpoint fork`*) and optionally delete the original; this avoids any scope-migration / stale-mind ambiguity (the source is untouched until explicitly deleted).

<!-- [doc->REQ-INST-14] -->
**resource advertisement (subnet resource registry)**:
A per-endpoint **free-text blurb** describing the services/functions the endpoint can serve — an agent **yellow-pages** for service discovery, distinct from *capability declaration* (machine-readable, which endpoint *types* a node hosts) and from *endpoint visibility* (whether it's addressable at all). **Both-authored + mutable:** config seeds a default; the agent refines its own at runtime (`spt endpoint description set …`). It is **not a separate registry** — it is a field on the endpoint record and a **projection** of the subnet registry (`(id, node, resources-blurb)`), surfaced as the "subnet resource registry" view. **Gated by the access layers:** an endpoint excluded from subnet S (visibility) — or, later, one whose access whitelist (*endpoint access whitelist*) excludes the viewer's node — never appears in that view; discovery leaks nothing a viewer couldn't reach. Synced as **registry data, not context** (directory metadata, not the agent's mind). M4 (needs the distributed registry).


W2 — gh transport for private repos

W4 — update-message

Release — cut v0.13.2 (deployah role)


Edit

To place an agent's mind in a different subnet, **fork** it (see *`spt endpoint fork`*) and optionally delete the original; this avoids any scope-migration / stale-mind ambiguity (the source is untouched until explicitly deleted).

<!-- [doc->REQ-INST-14] -->

To place an agent's mind in a different subnet, **fork** it (see *`spt endpoint fork`*) and optionally delete the original; this avoids any scope-migration / stale-mind ambiguity (the source is untouched until explicitly deleted).

<!-- [doc->REQ-ENDPOINT-UNBOUND-ATTACH] -->
**Unbound endpoint**:
The lifecycle point between *spawn* and *bind*: an spt-hosted endpoint whose broker **session + PTY are live** but whose harness has **not yet bound** its perch (the *post-spawn seam* hasn't fired — e.g. the harness is waiting on a startup prompt). On-disk status `unbound` (spawn → `unbound`; bind → `online`; session death → `offline`). An Unbound endpoint is **attachable** (a live PTY — `spt rc` and the `endpoint run` attach reach it, so an operator can see and drive the harness, including clearing a bind-gating prompt) but **not message-addressable** (no bound `session_id` yet — messaging stays gated on `online`). Distinct from *offline* (no session) and from *online* (bound). In the picker it renders **hollow** (and hollow-controlled when driven) — not amber, which is the *harness-only* (online-but-not-broker-controllable) state, the opposite of attachable.

<!-- [doc->REQ-INST-14] -->


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

To place an agent's mind in a different subnet, **fork** it (see *`spt endpoint fork`*) and optionally delete the original; this avoids any scope-migration / stale-mind ambiguity (the source is untouched until explicitly deleted).

<!-- [doc->REQ-INST-14] -->

To place an agent's mind in a different subnet, **fork** it (see *`spt endpoint fork`*) and optionally delete the original; this avoids any scope-migration / stale-mind ambiguity (the source is untouched until explicitly deleted).

<!-- [doc->REQ-ENDPOINT-UNBOUND-ATTACH] -->
**Unbound endpoint**:
The lifecycle point between *spawn* and *bind*: an spt-hosted endpoint whose broker **session + PTY are live** but whose harness has **not yet bound** its perch (the *post-spawn seam* hasn't fired — e.g. the harness is waiting on a startup prompt). On-disk status `unbound` (spawn → `unbound`; bind → `online`; session death → `offline`). An Unbound endpoint is **attachable** (a live PTY — `spt rc` and the `endpoint run` attach reach it, so an operator can see and drive the harness, including clearing a bind-gating prompt) but **not message-addressable** (no bound `session_id` yet — messaging stays gated on `online`). Distinct from *offline* (no session) and from *online* (bound). In the picker it renders **hollow** (and hollow-controlled when driven) — not amber, which is the *harness-only* (online-but-not-broker-controllable) state, the opposite of attachable.

<!-- [doc->REQ-INST-14] -->

The single subnet that anchors an endpoint's *defaults*: the default **sync** scope (subnet-exclusive mind replication — *Cross-node Psyche sync*) and the subnet that qualifies its **bare name**. **Assigned at creation:** the node's **sole** subnet automatically; on a **multi-subnet** node it **must be specified** at creation (no silent guess — mirrors the *resolution policy*'s refuse-and-qualify); on an **unpaired** node the endpoint is local-only until first join, when home is set. Distinct from advertisement: identity is **node-global and advertised into *every* subnet the node belongs to** per the visibility defaults — home subnet is only the default-scope *anchor*, not a limit on where the endpoint is visible or addressable. **Home is immutable** — there is **no re-home** (ADR-0010). To place an agent's mind in a different subnet, **fork** it (see *`spt endpoint fork`*) and optionally delete the original; this avoids any scope-migration / stale-mind ambiguity (the source is untouched until explicitly deleted).

+The lifecycle point between *spawn* and *bind*: an spt-hosted endpoint whose broker **session + PTY are live** but whose harness has **not yet bound** its perch (the *post-spawn seam* hasn't fired — e.g. the harness is waiting on a startup prompt). On-disk status `unbound` (spawn → `unbound`; bind → `online`; session death → `offline`). An Unbound endpoint is **attachable** (a live PTY — `spt rc` and the `endpoint run` attach reach it, so an operator can see and drive the harness, including clearing a bind-gating prompt) but **not message-addressable** (no bound `session_id` yet — messaging stays gated on `online`). Distinct from *offline* (no session) and from *online* (bound). In the picker it renders **hollow** (and hollow-controlled when driven) — not amber, which is the *harness-only* (online-but-not-broker-controllable) state, the opposite of attachable.

**resource advertisement (subnet resource registry)**:

A per-endpoint **free-text blurb** describing the services/functions the endpoint can serve — an agent **yellow-pages** for service discovery, distinct from *capability declaration* (machine-readable, which endpoint *types* a node hosts) and from *endpoint visibility* (whether it's addressable at all). **Both-authored + mutable:** config seeds a default; the agent refines its own at runtime (`spt endpoint description set …`). It is **not a separate registry** — it is a field on the endpoint record and a **projection** of the subnet registry (`(id, node, resources-blurb)`), surfaced as the "subnet resource registry" view. **Gated by the access layers:** an endpoint excluded from subnet S (visibility) — or, later, one whose access whitelist (*endpoint access whitelist*) excludes the viewer's node — never appears in that view; discovery leaks nothing a viewer couldn't reach. Synced as **registry data, not context** (directory metadata, not the agent's mind). M4 (needs the distributed registry).


Register the two REQs (doc-active via the ADRs, impl/unit/int deferred per rule 5):


required_stages = []  # SEED ONLY (rule 5, activate-don't-pre-fail). The shipped fix is covered by REQ-HAZARD-CONTROLLER-WRITER-REORDER; this is the deferred STRUCTURAL guard (DEFERRED.md row). Activate with real stages only when a milestone adds the per-connection contiguous-sent cursor + the become_controller refuse-decreasing-floor guard. Until then NO evidence is owed.

required_stages = []  # SEED ONLY (rule 5, activate-don't-pre-fail). The shipped fix is covered by REQ-HAZARD-CONTROLLER-WRITER-REORDER; this is the deferred STRUCTURAL guard (DEFERRED.md row). Activate with real stages only when a milestone adds the per-connection contiguous-sent cursor + the become_controller refuse-decreasing-floor guard. Until then NO evidence is owed.

# ── Endpoint-creation-flow milestone (operator grill 2026-06-22; ADR-0026/0027) ──
# doc stage ACTIVE (the ADR IS the doc evidence); impl/unit/int activate at the milestone build (rule 5).
[[requirements]]
id = "REQ-RUN-MULTISUBNET-HOME"
title = "`spt endpoint run` resolves the home subnet at the skeleton-create step and pre-creates the skeleton perch carrying it, so the harness `bind` inherits home via establish_perch's immutable prior-branch (no hook change, no env injection). Resolution: sole-subnet auto; multi-subnet + no --subnet + NON-interactive terminal -> refuse early with MRU-ordered --subnet guidance (never the silent 25s online-timeout); multi-subnet + no --subnet + INTERACTIVE -> print proposed config (id/project/adapter[:profile]/home=MRU-default) + 'Ok to proceed? Y/n', n -> --subnet guidance; --subnet overrides + validates membership. MRU = ordered move-to-front LISTs at two levels (per-project + always-updated node-global fallback). Home stays IMMUTABLE (ADR-0010). Fixes the LATENT multi-subnet bringup gap (perri, not a regression — HOME_REFUSED established >=0.11.0; exposed by the node crossing 1->2 subnets). (ADR-0026)"
required_stages = ["doc"]  # doc = ADR-0026 + CONTEXT home-subnet. impl/unit/int activate at the endpoint-creation-flow milestone build. int MUST run on a multi-subnet node (single-subnet auto-homes + hides the gap).
[[requirements]]
id = "REQ-ENDPOINT-UNBOUND-ATTACH"
title = "An spt-hosted endpoint is ATTACHABLE between spawn and bind: gate the attach on the broker SESSION being attachable (session+PTY+OutputLog exist at spawn, before bind), not on perch STATUS_ONLINE (bind). cmd_endpoint_run + `spt rc <id>` attach to a live broker session regardless of perch status (headless bringups too; lets an operator clear a bind-gating prompt) -- replaces await_endpoint_online; preserves REQ-HAZARD-RC-ATTACH-ONLINE-RACE's 'no attach before a session' intent at the earlier session-exists point; source = the broker sessions map (ADR-0025 W3a); local-only. New on-disk status STATUS_UNBOUND (spawn->unbound, bind->online, death->offline); lifecycle reuses the existing exit-waiter/reconcile (session death->offline); unbound is attachable but NOT message-addressable (messaging stays online/bound-gated). EpDisplay gains Unbound = HOLLOW (+ hollow-controlled variant) -- amber=HarnessOnly is taken + means not-controllable (the opposite of attachable). (ADR-0027)"
required_stages = ["doc"]  # doc = ADR-0027 + CONTEXT "Unbound endpoint". impl/unit/int activate at the milestone build.


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


1328	# ── P1c follow-up SEED (doyle proposed, todlando gated, doyle concurred 2026-06-20): the structural per-connection-floor guard — inactive, activate-don't-pre-fail ──
1329	[[requirements]]
1330	id = "REQ-HAZARD-CONTROLLER-RETAKE-FLOOR"
1331	title = "`become_controller` should STRUCTURALLY refuse a controller re-take whose `from_seq` falls below the connection's already-delivered contiguous floor — making the P1c reorder invariant un-reintroducible by a future caller, not just removed at the one caller. ROOT/SCOPE (doyle proposed, P1c gate dialogue): P1c fixes REQ-HAZARD-CONTROLLER-WRITER-REORDER three ways (handoff single-take + epoch-gate-under-lock + session_cursors seed), removing the one decreasing-floor double-take and bounding any other to already-committed-only. A self-enforcing broker guard would refuse the bad SHAPE outright. BLOCKER: the obvious predicate (`from_seq >= delivered_through`) is UNSAFE because `delivered_through` is SESSION-WIDE (the `Arc<AtomicU64>` on `OutputLog`, shared by all controllers/viewers, advanced monotonic-MAX; `resume_seq` reads it) — a normal fresh-operator `from_seq=0` attach to a producing session legitimately sits below it (full ring replay + consumer dedup-below/snap-above), and monotonic-MAX can't distinguish the hazard (a `seq1`-without-`seq0` write reads as `2`). The structurally-correct guard needs a NEW per-connection contiguous-sent cursor (the true highest-contiguous seq this socket has received) that does not exist today; the guard then refuses a re-take below THAT. Bigger than P1c; no live gap (P1c fully fixes the actual bug). Mint/refine stages when the per-connection cursor is built. (v0.13.0 follow-up, post-ship)"
1332	required_stages = []  # SEED ONLY (rule 5, activate-don't-pre-fail). The shipped fix is covered by REQ-HAZARD-CONTROLLER-WRITER-REORDER; this is the deferred STRUCTURAL guard (DEFERRED.md row). Activate with real stages only when a milestone adds the per-connection contiguous-sent cursor + the become_controller refuse-decreasing-floor guard. Until then NO evidence is owed.
1333

# ── P1c follow-up SEED (doyle proposed, todlando gated, doyle concurred 2026-06-20): the structural per-connection-floor guard — inactive, activate-don't-pre-fail ──
[[requirements]]
id = "REQ-HAZARD-CONTROLLER-RETAKE-FLOOR"
title = "`become_controller` should STRUCTURALLY refuse a controller re-take whose `from_seq` falls below the connection's already-delivered contiguous floor — making the P1c reorder invariant un-reintroducible by a future caller, not just removed at the one caller. ROOT/SCOPE (doyle proposed, P1c gate dialogue): P1c fixes REQ-HAZARD-CONTROLLER-WRITER-REORDER three ways (handoff single-take + epoch-gate-under-lock + session_cursors seed), removing the one decreasing-floor double-take and bounding any other to already-committed-only. A self-enforcing broker guard would refuse the bad SHAPE outright. BLOCKER: the obvious predicate (`from_seq >= delivered_through`) is UNSAFE because `delivered_through` is SESSION-WIDE (the `Arc<AtomicU64>` on `OutputLog`, shared by all controllers/viewers, advanced monotonic-MAX; `resume_seq` reads it) — a normal fresh-operator `from_seq=0` attach to a producing session legitimately sits below it (full ring replay + consumer dedup-below/snap-above), and monotonic-MAX can't distinguish the hazard (a `seq1`-without-`seq0` write reads as `2`). The structurally-correct guard needs a NEW per-connection contiguous-sent cursor (the true highest-contiguous seq this socket has received) that does not exist today; the guard then refuses a re-take below THAT. Bigger than P1c; no live gap (P1c fully fixes the actual bug). Mint/refine stages when the per-connection cursor is built. (v0.13.0 follow-up, post-ship)"
required_stages = []  # SEED ONLY (rule 5, activate-don't-pre-fail). The shipped fix is covered by REQ-HAZARD-CONTROLLER-WRITER-REORDER; this is the deferred STRUCTURAL guard (DEFERRED.md row). Activate with real stages only when a milestone adds the per-connection contiguous-sent cursor + the become_controller refuse-decreasing-floor guard. Until then NO evidence is owed.


required_stages = []  # SEED ONLY (rule 5, activate-don't-pre-fail). The shipped fix is covered by REQ-HAZARD-CONTROLLER-WRITER-REORDER; this is the deferred STRUCTURAL guard (DEFERRED.md row). Activate with real stages only when a milestone adds the per-connection contiguous-sent cursor + the become_controller refuse-decreasing-floor guard. Until then NO evidence is owed.

required_stages = []  # SEED ONLY (rule 5, activate-don't-pre-fail). The shipped fix is covered by REQ-HAZARD-CONTROLLER-WRITER-REORDER; this is the deferred STRUCTURAL guard (DEFERRED.md row). Activate with real stages only when a milestone adds the per-connection contiguous-sent cursor + the become_controller refuse-decreasing-floor guard. Until then NO evidence is owed.

# ── Endpoint-creation-flow milestone (operator grill 2026-06-22; ADR-0026/0027) ──
# doc stage ACTIVE (the ADR IS the doc evidence); impl/unit/int activate at the milestone build (rule 5).
[[requirements]]
id = "REQ-RUN-MULTISUBNET-HOME"
title = "`spt endpoint run` resolves the home subnet at the skeleton-create step and pre-creates the skeleton perch carrying it, so the harness `bind` inherits home via establish_perch's immutable prior-branch (no hook change, no env injection). Resolution: sole-subnet auto; multi-subnet + no --subnet + NON-interactive terminal -> refuse early with MRU-ordered --subnet guidance (never the silent 25s online-timeout); multi-subnet + no --subnet + INTERACTIVE -> print proposed config (id/project/adapter[:profile]/home=MRU-default) + 'Ok to proceed? Y/n', n -> --subnet guidance; --subnet overrides + validates membership. MRU = ordered move-to-front LISTs at two levels (per-project + always-updated node-global fallback). Home stays IMMUTABLE (ADR-0010). Fixes the LATENT multi-subnet bringup gap (perri, not a regression — HOME_REFUSED established >=0.11.0; exposed by the node crossing 1->2 subnets). (ADR-0026)"
required_stages = ["doc"]  # doc = ADR-0026 + CONTEXT home-subnet. impl/unit/int activate at the endpoint-creation-flow milestone build. int MUST run on a multi-subnet node (single-subnet auto-homes + hides the gap).
[[requirements]]
id = "REQ-ENDPOINT-UNBOUND-ATTACH"
title = "An spt-hosted endpoint is ATTACHABLE between spawn and bind: gate the attach on the broker SESSION being attachable (session+PTY+OutputLog exist at spawn, before bind), not on perch STATUS_ONLINE (bind). cmd_endpoint_run + `spt rc <id>` attach to a live broker session regardless of perch status (headless bringups too; lets an operator clear a bind-gating prompt) -- replaces await_endpoint_online; preserves REQ-HAZARD-RC-ATTACH-ONLINE-RACE's 'no attach before a session' intent at the earlier session-exists point; source = the broker sessions map (ADR-0025 W3a); local-only. New on-disk status STATUS_UNBOUND (spawn->unbound, bind->online, death->offline); lifecycle reuses the existing exit-waiter/reconcile (session death->offline); unbound is attachable but NOT message-addressable (messaging stays online/bound-gated). EpDisplay gains Unbound = HOLLOW (+ hollow-controlled variant) -- amber=HarnessOnly is taken + means not-controllable (the opposite of attachable). (ADR-0027)"
required_stages = ["doc"]  # doc = ADR-0027 + CONTEXT "Unbound endpoint". impl/unit/int activate at the milestone build.


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

required_stages = []  # SEED ONLY (rule 5, activate-don't-pre-fail). The shipped fix is covered by REQ-HAZARD-CONTROLLER-WRITER-REORDER; this is the deferred STRUCTURAL guard (DEFERRED.md row). Activate with real stages only when a milestone adds the per-connection contiguous-sent cursor + the become_controller refuse-decreasing-floor guard. Until then NO evidence is owed.

# ── Endpoint-creation-flow milestone (operator grill 2026-06-22; ADR-0026/0027) ──
# doc stage ACTIVE (the ADR IS the doc evidence); impl/unit/int activate at the milestone build (rule 5).
[[requirements]]
id = "REQ-RUN-MULTISUBNET-HOME"
title = "`spt endpoint run` resolves the home subnet at the skeleton-create step and pre-creates the skeleton perch carrying it, so the harness `bind` inherits home via establish_perch's immutable prior-branch (no hook change, no env injection). Resolution: sole-subnet auto; multi-subnet + no --subnet + NON-interactive terminal -> refuse early with MRU-ordered --subnet guidance (never the silent 25s online-timeout); multi-subnet + no --subnet + INTERACTIVE -> print proposed config (id/project/adapter[:profile]/home=MRU-default) + 'Ok to proceed? Y/n', n -> --subnet guidance; --subnet overrides + validates membership. MRU = ordered move-to-front LISTs at two levels (per-project + always-updated node-global fallback). Home stays IMMUTABLE (ADR-0010). Fixes the LATENT multi-subnet bringup gap (perri, not a regression — HOME_REFUSED established >=0.11.0; exposed by the node crossing 1->2 subnets). (ADR-0026)"
required_stages = ["doc"]  # doc = ADR-0026 + CONTEXT home-subnet. impl/unit/int activate at the endpoint-creation-flow milestone build. int MUST run on a multi-subnet node (single-subnet auto-homes + hides the gap).
[[requirements]]
id = "REQ-ENDPOINT-UNBOUND-ATTACH"
title = "An spt-hosted endpoint is ATTACHABLE between spawn and bind: gate the attach on the broker SESSION being attachable (session+PTY+OutputLog exist at spawn, before bind), not on perch STATUS_ONLINE (bind). cmd_endpoint_run + `spt rc <id>` attach to a live broker session regardless of perch status (headless bringups too; lets an operator clear a bind-gating prompt) -- replaces await_endpoint_online; preserves REQ-HAZARD-RC-ATTACH-ONLINE-RACE's 'no attach before a session' intent at the earlier session-exists point; source = the broker sessions map (ADR-0025 W3a); local-only. New on-disk status STATUS_UNBOUND (spawn->unbound, bind->online, death->offline); lifecycle reuses the existing exit-waiter/reconcile (session death->offline); unbound is attachable but NOT message-addressable (messaging stays online/bound-gated). EpDisplay gains Unbound = HOLLOW (+ hollow-controlled variant) -- amber=HarnessOnly is taken + means not-controllable (the opposite of attachable). (ADR-0027)"
required_stages = ["doc"]  # doc = ADR-0027 + CONTEXT "Unbound endpoint". impl/unit/int activate at the milestone build.

title = "`become_controller` should STRUCTURALLY refuse a controller re-take whose `from_seq` falls below the connection's already-delivered contiguous floor — making the P1c reorder invariant un-reintroducible by a future caller, not just removed at the one caller. ROOT/SCOPE (doyle proposed, P1c gate dialogue): P1c fixes REQ-HAZARD-CONTROLLER-WRITER-REORDER three ways (handoff single-take + epoch-gate-under-lock + session_cursors seed), removing the one decreasing-floor double-take and bounding any other to already-committed-only. A self-enforcing broker guard would refuse the bad SHAPE outright. BLOCKER: the obvious predicate (`from_seq >= delivered_through`) is UNSAFE because `delivered_through` is SESSION-WIDE (the `Arc<AtomicU64>` on `OutputLog`, shared by all controllers/viewers, advanced monotonic-MAX; `resume_seq` reads it) — a normal fresh-operator `from_seq=0` attach to a producing session legitimately sits below it (full ring replay + consumer dedup-below/snap-above), and monotonic-MAX can't distinguish the hazard (a `seq1`-without-`seq0` write reads as `2`). The structurally-correct guard needs a NEW per-connection contiguous-sent cursor (the true highest-contiguous seq this socket has received) that does not exist today; the guard then refuses a re-take below THAT. Bigger than P1c; no live gap (P1c fully fixes the actual bug). Mint/refine stages when the per-connection cursor is built. (v0.13.0 follow-up, post-ship)"

+# ── Endpoint-creation-flow milestone (operator grill 2026-06-22; ADR-0026/0027) ──

+# doc stage ACTIVE (the ADR IS the doc evidence); impl/unit/int activate at the milestone build (rule 5).

+title = "`spt endpoint run` resolves the home subnet at the skeleton-create step and pre-creates the skeleton perch carrying it, so the harness `bind` inherits home via establish_perch's immutable prior-branch (no hook change, no env injection). Resolution: sole-subnet auto; multi-subnet + no --subnet + NON-interactive terminal -> refuse early with MRU-ordered --subnet guidance (never the silent 25s online-timeout); multi-subnet + no --subnet + INTERACTIVE -> print proposed config (id/project/adapter[:profile]/home=MRU-default) + 'Ok to proceed? Y/n', n -> --subnet guidance; --subnet overrides + validates membership. MRU = ordered move-to-front LISTs at two levels (per-project + always-updated node-global fallback). Home stays IMMUTABLE (ADR-0010). Fixes the LATENT multi-subnet bringup gap (perri, not a regression — HOME_REFUSED established >=0.11.0; exposed by the node crossing 1->2 subnets). (ADR-0026)"

+required_stages = ["doc"]  # doc = ADR-0026 + CONTEXT home-subnet. impl/unit/int activate at the endpoint-creation-flow milestone build. int MUST run on a multi-subnet node (single-subnet auto-homes + hides the gap).

+title = "An spt-hosted endpoint is ATTACHABLE between spawn and bind: gate the attach on the broker SESSION being attachable (session+PTY+OutputLog exist at spawn, before bind), not on perch STATUS_ONLINE (bind). cmd_endpoint_run + `spt rc <id>` attach to a live broker session regardless of perch status (headless bringups too; lets an operator clear a bind-gating prompt) -- replaces await_endpoint_online; preserves REQ-HAZARD-RC-ATTACH-ONLINE-RACE's 'no attach before a session' intent at the earlier session-exists point; source = the broker sessions map (ADR-0025 W3a); local-only. New on-disk status STATUS_UNBOUND (spawn->unbound, bind->online, death->offline); lifecycle reuses the existing exit-waiter/reconcile (session death->offline); unbound is attachable but NOT message-addressable (messaging stays online/bound-gated). EpDisplay gains Unbound = HOLLOW (+ hollow-controlled variant) -- amber=HarnessOnly is taken + means not-controllable (the opposite of attachable). (ADR-0027)"

+required_stages = ["doc"]  # doc = ADR-0027 + CONTEXT "Unbound endpoint". impl/unit/int activate at the milestone build.


Run traceable on the design branch to verify the new REQs' doc evidence resolves:


Bash

"C:/Users/decid/AppData/Local/Programs/traceable-reqs/traceable-reqs" check >/tmp/tdesign.txt 2>&1; echo "EXIT=$?"; grep -E "REQ-RUN-MULTISUBNET-HOME|REQ-ENDPOINT-UNBOUND-ATTACH" /tmp/tdesign.txt | head; grep -iE "^FAIL|MISSING|error:" /tmp/tdesign.txt | head

Traceable check on design branch