Write

//! M11-W4 Gateway-owner shell capstone E2E (REQ-SHELL-5) — proves shell
//! ownership is **owner-type-agnostic** through the REAL `spt` surfaces: a
//! GATEWAY-typed owner (state="gateway", the gateway_e2e mock-gateway type) owns
//! a shell and succeeds on EVERY ownership path same-node, and control-
//! exclusivity keys on the owner ENDPOINT-ID, never the type.
//!
//! PROOF-NOT-CHANGE (M11-T0 audit found no type-gate on any ownership path) — if
//! ANY path here refused a non-agent owner, that would surface a real type-gate
//! to fix loud. None do.
//!
//! POSITIVE (gateway-A, a state="gateway" owner):
//!   spawn → `api bind-shell` onlines + opens the tunnel → `shell cmd` (durable,
//!   spools) → `shell drive` (latest-wins live slot) → `shell tunnel` (opaque
//!   byte round-trip, both directions) → a class-keyed act-gate (REQ-CONSENT-3)
//!   refuses ungranted then flips through on a grant keyed to the GATEWAY's
//!   endpoint-id → `shell relink` (rotates the token).
//!
//! NEGATIVE (gateway-B — SAME type="gateway", DIFFERENT endpoint-id):
//!   cmd / drive / tunnel for gateway-A's alias all refuse under B (NO_SHELL /
//!   NO_TUNNEL). Same type, different id ⇒ refused — proving the key is the
//!   owner id, not the type.
//
// [int->REQ-SHELL-5]

use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

mod common;
use common::CommandNoWindowExt;

use spt_daemon::endpoint::{broker_socket_name, tunnel_socket_name};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::{Broker, DriveHub, TunnelHub};
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_proto::identity::Identity;
use spt_store::perch::{self, ParentHint};

fn hermetic(identity: Identity) -> NetConfig {
    NetConfig {
        identity,
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
        membership: None,
    }
}

/// Stand up every daemon surface this capstone exercises on the current
/// `SPT_HOME`'s sockets: **seed** control (subprocess `ensure_running` pings),
/// a **broker that owns a hermetic NetHost** (the tunnel relay opens loopback
/// streams on it), the **tunnel** control bound to that broker, and the
/// **drive** control (the live drive slot). Serve threads leak; they die with
/// the test process. (The union of the tunnel_e2e + drive_e2e harnesses — this
/// E2E walks both the tunnel and the drive paths under one owner.)
fn start_inproc_daemon(dir: &std::path::Path) {
    let reg = Arc::new(spt_daemon::SeedRegistry::new());
    let seed = spt_daemon::seed_socket_name();
    thread::spawn(move || {
        let _ = spt_daemon::seedmap::serve_seed_control(&seed, reg);
    });

    let host = NetHost::start(hermetic(Identity::generate())).expect("net host start");
    let broker = Broker::bind_in_with_net(&broker_socket_name(), dir.join("effects.log"), Some(host))
        .expect("bind broker with net");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });

    let hub = Arc::new(TunnelHub::new());
    let tsock = tunnel_socket_name();
    thread::spawn(move || {
        let _ = spt_daemon::serve_tunnel_control(&tsock, hub, broker);
    });

    let dhub = Arc::new(DriveHub::new());
    let dsock = spt_daemon::drive_socket_name();
    thread::spawn(move || {
        let _ = spt_daemon::serve_drive_control(&dsock, dhub);
    });

    for _ in 0..400 {
        if spt_daemon::is_running() {
            return;
        }
        thread::sleep(Duration::from_millis(5));
    }
    panic!("in-process seed daemon did not come up");
}

/// `Command::output()` with a deadline, off-thread (the drive_e2e pattern).
fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
    let (tx, rx) = std::sync::mpsc::channel();
    thread::spawn(move || {
        let _ = tx.send(cmd.output());
    });
    rx.recv_timeout(deadline)
        .expect("captured spt call must complete")
        .expect("run spt")
}

/// Spawn `cmd` with `input` on stdin, capture output, bounded.
fn output_with_stdin(mut cmd: Command, input: Vec<u8>, deadline: Duration) -> Output {
    cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
    let (tx, rx) = std::sync::mpsc::channel();
    thread::spawn(move || {
        let res = (|| {
            let mut child = cmd.spawn()?;
            child.stdin.take().unwrap().write_all(&input)?;
            child.wait_with_output()
        })();
        let _ = tx.send(res);
    });
    rx.recv_timeout(deadline)
        .expect("captured spt stdin call must complete")
        .expect("run spt with stdin")
}

fn register_adapter(name: &str, manifest: &str) {
    let src = perch::spt_home().join("srcs").join(name);
    std::fs::create_dir_all(&src).unwrap();
    std::fs::write(src.join("manifest.toml"), manifest).unwrap();
    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
}

/// Bind a Self perch tagged with an explicit endpoint `state` (the open-type
/// tag) — `gateway` here, the same non-agent type the gateway_e2e mock-gateway
/// binds with.
fn bind_typed_owner(id: &str, state: &str) {
    let perch = perch::resolve_perch_path(id, ParentHint::Infer);
    std::fs::create_dir_all(&perch).unwrap();
    let rec = spt_store::info::InfoJson::new(id, "0", std::process::id(), "sid", state);
    spt_store::info::write_info(&perch, &rec).unwrap();
}

#[test]
fn gateway_typed_owner_owns_a_shell_on_every_path_keyed_on_id() {
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());
    start_inproc_daemon(home.path());

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let spt = |args: &[&str]| {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window().args(args).env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(60))
    };
    let spt_stdin = |args: &[&str], input: Vec<u8>| {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window().args(args).env("SPT_HOME", home.path());
        output_with_stdin(cmd, input, Duration::from_secs(60))
    };

    // A shell adapter exercising every owner-controlled path: durable command
    // (`press`), a class-keyed act-gate (`attach`/hid, REQ-CONSENT-3), a drive
    // vocabulary (`stick`), and the opaque tunnel. The spawn is a noop that exits
    // immediately (never binds) — the perch stays offline until we online it by
    // token, so we own the online/offline edges.
    #[cfg(windows)]
    let noop = "cmd /c exit 0";
    #[cfg(unix)]
    let noop = "true";
    register_adapter(
        "mock-shell",
        &format!(
            "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{noop} {{link_token}} {{id}}'\n\n\
             [shell.capabilities.press]\nargs = [\"button\"]\n\n\
             [shell.capabilities.attach]\nargs = [\"busid\"]\n\
             require_approval = \"remembered\"\nclass_key = \"hid\"\n\n\
             [shell.drive]\ntypes = [\"stick\"]\n\n\
             [shell.tunnel]\nenable = true\nprotocol = \"usbip-urb\"\n"
        ),
    );

    // The owner is a GATEWAY-typed endpoint (state="gateway"), NOT an agent.
    let gw_a = "playdate-gw-a";
    bind_typed_owner(gw_a, "gateway");
    spt_store::nodeid::load_or_create().expect("node identity");
    let rec = spt_store::info::read_info(&perch::resolve_perch_path(gw_a, ParentHint::Infer))
        .expect("owner info");
    assert_eq!(rec.state, "gateway", "the owner is a Gateway-typed endpoint, not an agent");

    let owlery = perch::owlery_dir();
    let shell_perch = perch::resolve_shell_perch_path_in(&owlery, gw_a, "mock-shell-0");
    let token_of =
        || spt_daemon::shellhost::read_link_token(&shell_perch).expect("a link token is parked");
    let online_by_token = |token: &str| {
        let out = spt(&["api", "--adapter", "mock-shell", "bind-shell", "--link", token]);
        assert!(out.status.success(), "bind-shell: {}", String::from_utf8_lossy(&out.stderr));
        assert!(
            String::from_utf8_lossy(&out.stderr).contains("SHELL_TUNNEL_OPEN"),
            "bind-shell opens the tunnel for a Gateway owner too: {}",
            String::from_utf8_lossy(&out.stderr)
        );
    };

    // ── spawn (offline) under the GATEWAY owner → online by token → tunnel opens.
    let out = spt(&["shell", "spawn", "mock-shell", "--alias", "Scout", "--owner", gw_a]);
    assert!(out.status.success(), "gateway spawn: {}", String::from_utf8_lossy(&out.stderr));
    let token_a = token_of();
    online_by_token(&token_a);

    // ── cmd (durable): a gateway-owned command vocab-checks + spools to the
    // durable shell channel — no agent-family gate on the command path.
    let before = spt_store::spool::pending_count_at(&shell_perch).unwrap();
    let out = spt(&["shell", "cmd", "--owner", gw_a, "Scout", "press", "A"]);
    assert!(out.status.success(), "gateway cmd: {}", String::from_utf8_lossy(&out.stderr));
    assert_eq!(
        spt_store::spool::pending_count_at(&shell_perch).unwrap(),
        before + 1,
        "the gateway's command landed in the durable channel"
    );

    // ── drive (latest-wins live slot): a gateway-owned drive writes the slot;
    // the shell drains it via the link.
    assert!(
        spt(&["shell", "drive", "Scout", "--type", "stick", "x=0.9", "--owner", gw_a])
            .status
            .success(),
        "gateway drive"
    );
    let out = spt(&[
        "api", "--adapter", "mock-shell", "drive-poll", "mock-shell-0", "--link", &token_a,
    ]);
    let frame = String::from_utf8_lossy(&out.stdout);
    assert!(
        frame.contains("type=\"drive\"") && frame.contains("x=0.9"),
        "gateway drive reached the live slot: {frame}"
    );

    // ── tunnel: opaque bytes round-trip BOTH directions under the gateway owner.
    // A payload the <EVENT> grammar would mangle (NULs + an `<EVENT`-looking run).
    let owner_send = |bytes: Vec<u8>| {
        let out =
            spt_stdin(&["shell", "tunnel", "Scout", "send", "--owner", gw_a], bytes);
        assert!(out.status.success(), "gateway tunnel send: {}", String::from_utf8_lossy(&out.stderr));
    };
    let shell_recv_until = |want: usize| -> Vec<u8> {
        let mut got = Vec::new();
        for _ in 0..400 {
            let out = spt(&[
                "api", "--adapter", "mock-shell", "tunnel", "mock-shell-0", "recv", "--link",
                &token_a,
            ]);
            got.extend_from_slice(&out.stdout);
            if got.len() >= want {
                break;
            }
            thread::sleep(Duration::from_millis(10));
        }
        got
    };
    let owner_recv_until = |want: usize| -> Vec<u8> {
        let mut got = Vec::new();
        for _ in 0..400 {
            let out = spt(&["shell", "tunnel", "Scout", "recv", "--owner", gw_a]);
            got.extend_from_slice(&out.stdout);
            if got.len() >= want {
                break;
            }
            thread::sleep(Duration::from_millis(10));
        }
        got
    };
    let blob: Vec<u8> = b"\x00<EVENT type=\"msg\" from=\"x\">opaque\xff\xfe\x00".to_vec();
    owner_send(blob.clone());
    let got = shell_recv_until(blob.len());
    assert_eq!(got, blob, "owner→shell opaque bytes round-trip byte-exact under a gateway owner");
    let reply: Vec<u8> = b"\x00\x01<EVENT/>\xaa reply".to_vec();
    let out = spt_stdin(
        &["api", "--adapter", "mock-shell", "tunnel", "mock-shell-0", "send", "--link", &token_a],
        reply.clone(),
    );
    assert!(out.status.success(), "shell tunnel send: {}", String::from_utf8_lossy(&out.stderr));
    assert_eq!(owner_recv_until(reply.len()), reply, "shell→owner round-trip byte-exact");

    // ── act-gate (REQ-CONSENT-3): the class-keyed `attach` refuses ungranted,
    // then a grant KEYED ON THE GATEWAY'S ENDPOINT-ID flips it through — the
    // same id-not-type invariant on the consent surface.
    let out = spt(&["shell", "cmd", "--owner", gw_a, "Scout", "attach", "busid-1"]);
    assert!(!out.status.success(), "ungranted gated attach refuses under the gateway");
    assert!(
        String::from_utf8_lossy(&out.stderr).contains("CONSENT_PENDING"),
        "refused as a pending act-gate: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    // The grant target IS the gateway's endpoint-id (not a type, not an agent id).
    let out = spt(&["grant", "add", "shell-act:attach", gw_a, "--qualifier", "hid"]);
    assert!(out.status.success(), "grant add: {}", String::from_utf8_lossy(&out.stderr));
    let out = spt(&["shell", "cmd", "--owner", gw_a, "Scout", "attach", "busid-1"]);
    assert!(
        out.status.success(),
        "the grant keyed on the gateway endpoint-id flips the gated cmd through: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    // ── relink: a gateway owner relinks identically (rotates the token).
    let out = spt(&["shell", "relink", "Scout", "--owner", gw_a]);
    assert!(out.status.success(), "gateway relink: {}", String::from_utf8_lossy(&out.stderr));
    assert_ne!(token_a, token_of(), "relink rotates the link token for a gateway owner");

    // ── NEGATIVE: gateway-B (SAME type="gateway", DIFFERENT id) is refused on
    // every control path for gateway-A's shell — exclusivity keys on the owner
    // ENDPOINT-ID, not the type.
    let gw_b = "playdate-gw-b";
    bind_typed_owner(gw_b, "gateway");
    let out = spt(&["shell", "cmd", "--owner", gw_b, "Scout", "press", "A"]);
    assert!(!out.status.success(), "gateway-B cmd refused");
    assert!(
        String::from_utf8_lossy(&out.stderr).contains("NO_SHELL"),
        "same-type different-id owner: cmd is NO_SHELL: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let out = spt(&["shell", "drive", "Scout", "--type", "stick", "x=0.1", "--owner", gw_b]);
    assert!(!out.status.success(), "gateway-B drive refused");
    assert!(
        String::from_utf8_lossy(&out.stderr).contains("NO_SHELL"),
        "same-type different-id owner: drive is NO_SHELL: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let out = spt(&["shell", "tunnel", "Scout", "recv", "--owner", gw_b]);
    assert!(
        String::from_utf8_lossy(&out.stderr).contains("NO_TUNNEL"),
        "same-type different-id owner: tunnel resolves no live tunnel: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    // Best-effort hygiene: reap any real daemon an `ensure_running` race spawned
    // against this throwaway home (workspace-scoped, never machine-wide).
    if let Ok(pid) = std::fs::read_to_string(home.path().join("daemon.pid")) {
        if let Ok(pid) = pid.trim().parse::<u32>() {
            #[cfg(windows)]
            let _ = Command::new("taskkill")
                .no_window()
                .args(["/PID", &pid.to_string(), "/F"])
                .output();
            #[cfg(unix)]
            let _ = Command::new("kill").args(["-9", &pid.to_string()]).output();
        }
    }
}


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

//! M11-W4 Gateway-owner shell capstone E2E (REQ-SHELL-5) — proves shell
//! ownership is **owner-type-agnostic** through the REAL `spt` surfaces: a
//! GATEWAY-typed owner (state="gateway", the gateway_e2e mock-gateway type) owns
//! a shell and succeeds on EVERY ownership path same-node, and control-
//! exclusivity keys on the owner ENDPOINT-ID, never the type.
//!
//! PROOF-NOT-CHANGE (M11-T0 audit found no type-gate on any ownership path) — if
//! ANY path here refused a non-agent owner, that would surface a real type-gate
//! to fix loud. None do.
//!
//! POSITIVE (gateway-A, a state="gateway" owner):
//!   spawn → `api bind-shell` onlines + opens the tunnel → `shell cmd` (durable,
//!   spools) → `shell drive` (latest-wins live slot) → `shell tunnel` (opaque
//!   byte round-trip, both directions) → a class-keyed act-gate (REQ-CONSENT-3)
//!   refuses ungranted then flips through on a grant keyed to the GATEWAY's
//!   endpoint-id → `shell relink` (rotates the token).
//!
//! NEGATIVE (gateway-B — SAME type="gateway", DIFFERENT endpoint-id):
//!   cmd / drive / tunnel for gateway-A's alias all refuse under B (NO_SHELL /
//!   NO_TUNNEL). Same type, different id ⇒ refused — proving the key is the
//!   owner id, not the type.
//
// [int->REQ-SHELL-5]

use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

mod common;
use common::CommandNoWindowExt;

use spt_daemon::endpoint::{broker_socket_name, tunnel_socket_name};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::{Broker, DriveHub, TunnelHub};
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_proto::identity::Identity;
use spt_store::perch::{self, ParentHint};

fn hermetic(identity: Identity) -> NetConfig {
    NetConfig {
        identity,
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
        membership: None,
    }
}

/// Stand up every daemon surface this capstone exercises on the current
/// `SPT_HOME`'s sockets: **seed** control (subprocess `ensure_running` pings),
/// a **broker that owns a hermetic NetHost** (the tunnel relay opens loopback
/// streams on it), the **tunnel** control bound to that broker, and the
/// **drive** control (the live drive slot). Serve threads leak; they die with
/// the test process. (The union of the tunnel_e2e + drive_e2e harnesses — this
/// E2E walks both the tunnel and the drive paths under one owner.)
fn start_inproc_daemon(dir: &std::path::Path) {
    let reg = Arc::new(spt_daemon::SeedRegistry::new());
    let seed = spt_daemon::seed_socket_name();
    thread::spawn(move || {
        let _ = spt_daemon::seedmap::serve_seed_control(&seed, reg);
    });

    let host = NetHost::start(hermetic(Identity::generate())).expect("net host start");
    let broker = Broker::bind_in_with_net(&broker_socket_name(), dir.join("effects.log"), Some(host))
        .expect("bind broker with net");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });

    let hub = Arc::new(TunnelHub::new());
    let tsock = tunnel_socket_name();
    thread::spawn(move || {
        let _ = spt_daemon::serve_tunnel_control(&tsock, hub, broker);
    });

    let dhub = Arc::new(DriveHub::new());
    let dsock = spt_daemon::drive_socket_name();
    thread::spawn(move || {
        let _ = spt_daemon::serve_drive_control(&dsock, dhub);
    });

    for _ in 0..400 {
        if spt_daemon::is_running() {
            return;
        }
        thread::sleep(Duration::from_millis(5));
    }
    panic!("in-process seed daemon did not come up");
}

/// `Command::output()` with a deadline, off-thread (the drive_e2e pattern).
fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
    let (tx, rx) = std::sync::mpsc::channel();
    thread::spawn(move || {
        let _ = tx.send(cmd.output());
    });
    rx.recv_timeout(deadline)
        .expect("captured spt call must complete")
        .expect("run spt")
}

/// Spawn `cmd` with `input` on stdin, capture output, bounded.
fn output_with_stdin(mut cmd: Command, input: Vec<u8>, deadline: Duration) -> Output {
    cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
    let (tx, rx) = std::sync::mpsc::channel();
    thread::spawn(move || {
        let res = (|| {
            let mut child = cmd.spawn()?;
            child.stdin.take().unwrap().write_all(&input)?;
            child.wait_with_output()
        })();
        let _ = tx.send(res);
    });
    rx.recv_timeout(deadline)
        .expect("captured spt stdin call must complete")
        .expect("run spt with stdin")
}

fn register_adapter(name: &str, manifest: &str) {
    let src = perch::spt_home().join("srcs").join(name);
    std::fs::create_dir_all(&src).unwrap();
    std::fs::write(src.join("manifest.toml"), manifest).unwrap();
    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
}

/// Bind a Self perch tagged with an explicit endpoint `state` (the open-type
/// tag) — `gateway` here, the same non-agent type the gateway_e2e mock-gateway
/// binds with.
fn bind_typed_owner(id: &str, state: &str) {
    let perch = perch::resolve_perch_path(id, ParentHint::Infer);
    std::fs::create_dir_all(&perch).unwrap();
    let rec = spt_store::info::InfoJson::new(id, "0", std::process::id(), "sid", state);
    spt_store::info::write_info(&perch, &rec).unwrap();
}

#[test]
fn gateway_typed_owner_owns_a_shell_on_every_path_keyed_on_id() {
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());
    start_inproc_daemon(home.path());

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let spt = |args: &[&str]| {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window().args(args).env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(60))
    };
    let spt_stdin = |args: &[&str], input: Vec<u8>| {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window().args(args).env("SPT_HOME", home.path());
        output_with_stdin(cmd, input, Duration::from_secs(60))
    };

    // A shell adapter exercising every owner-controlled path: durable command
    // (`press`), a class-keyed act-gate (`attach`/hid, REQ-CONSENT-3), a drive
    // vocabulary (`stick`), and the opaque tunnel. The spawn is a noop that exits
    // immediately (never binds) — the perch stays offline until we online it by
    // token, so we own the online/offline edges.
    #[cfg(windows)]
    let noop = "cmd /c exit 0";
    #[cfg(unix)]
    let noop = "true";
    register_adapter(
        "mock-shell",
        &format!(
            "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{noop} {{link_token}} {{id}}'\n\n\
             [shell.capabilities.press]\nargs = [\"button\"]\n\n\
             [shell.capabilities.attach]\nargs = [\"busid\"]\n\
             require_approval = \"remembered\"\nclass_key = \"hid\"\n\n\
             [shell.drive]\ntypes = [\"stick\"]\n\n\
             [shell.tunnel]\nenable = true\nprotocol = \"usbip-urb\"\n"
        ),
    );

    // The owner is a GATEWAY-typed endpoint (state="gateway"), NOT an agent.
    let gw_a = "playdate-gw-a";
    bind_typed_owner(gw_a, "gateway");
    spt_store::nodeid::load_or_create().expect("node identity");
    let rec = spt_store::info::read_info(&perch::resolve_perch_path(gw_a, ParentHint::Infer))
        .expect("owner info");
    assert_eq!(rec.state, "gateway", "the owner is a Gateway-typed endpoint, not an agent");

    let owlery = perch::owlery_dir();
    let shell_perch = perch::resolve_shell_perch_path_in(&owlery, gw_a, "mock-shell-0");
    let token_of =
        || spt_daemon::shellhost::read_link_token(&shell_perch).expect("a link token is parked");
    let online_by_token = |token: &str| {
        let out = spt(&["api", "--adapter", "mock-shell", "bind-shell", "--link", token]);
        assert!(out.status.success(), "bind-shell: {}", String::from_utf8_lossy(&out.stderr));
        assert!(
            String::from_utf8_lossy(&out.stderr).contains("SHELL_TUNNEL_OPEN"),
            "bind-shell opens the tunnel for a Gateway owner too: {}",
            String::from_utf8_lossy(&out.stderr)
        );
    };

    // ── spawn (offline) under the GATEWAY owner → online by token → tunnel opens.
    let out = spt(&["shell", "spawn", "mock-shell", "--alias", "Scout", "--owner", gw_a]);
    assert!(out.status.success(), "gateway spawn: {}", String::from_utf8_lossy(&out.stderr));
    let token_a = token_of();
    online_by_token(&token_a);

    // ── cmd (durable): a gateway-owned command vocab-checks + spools to the
    // durable shell channel — no agent-family gate on the command path.
    let before = spt_store::spool::pending_count_at(&shell_perch).unwrap();
    let out = spt(&["shell", "cmd", "--owner", gw_a, "Scout", "press", "A"]);
    assert!(out.status.success(), "gateway cmd: {}", String::from_utf8_lossy(&out.stderr));
    assert_eq!(
        spt_store::spool::pending_count_at(&shell_perch).unwrap(),
        before + 1,
        "the gateway's command landed in the durable channel"
    );

    // ── drive (latest-wins live slot): a gateway-owned drive writes the slot;
    // the shell drains it via the link.
    assert!(
        spt(&["shell", "drive", "Scout", "--type", "stick", "x=0.9", "--owner", gw_a])
            .status
            .success(),
        "gateway drive"
    );
    let out = spt(&[
        "api", "--adapter", "mock-shell", "drive-poll", "mock-shell-0", "--link", &token_a,
    ]);
    let frame = String::from_utf8_lossy(&out.stdout);
    assert!(
        frame.contains("type=\"drive\"") && frame.contains("x=0.9"),
        "gateway drive reached the live slot: {frame}"
    );

    // ── tunnel: opaque bytes round-trip BOTH directions under the gateway owner.
    // A payload the <EVENT> grammar would mangle (NULs + an `<EVENT`-looking run).
    let owner_send = |bytes: Vec<u8>| {
        let out =
            spt_stdin(&["shell", "tunnel", "Scout", "send", "--owner", gw_a], bytes);
        assert!(out.status.success(), "gateway tunnel send: {}", String::from_utf8_lossy(&out.stderr));
    };
    let shell_recv_until = |want: usize| -> Vec<u8> {
        let mut got = Vec::new();
        for _ in 0..400 {
            let out = spt(&[
                "api", "--adapter", "mock-shell", "tunnel", "mock-shell-0", "recv", "--link",
                &token_a,
            ]);
            got.extend_from_slice(&out.stdout);
            if got.len() >= want {
                break;
            }
            thread::sleep(Duration::from_millis(10));
        }
        got
    };
    let owner_recv_until = |want: usize| -> Vec<u8> {
        let mut got = Vec::new();
        for _ in 0..400 {
            let out = spt(&["shell", "tunnel", "Scout", "recv", "--owner", gw_a]);
            got.extend_from_slice(&out.stdout);
            if got.len() >= want {
                break;
            }
            thread::sleep(Duration::from_millis(10));
        }
        got
    };
    let blob: Vec<u8> = b"\x00<EVENT type=\"msg\" from=\"x\">opaque\xff\xfe\x00".to_vec();
    owner_send(blob.clone());
    let got = shell_recv_until(blob.len());
    assert_eq!(got, blob, "owner→shell opaque bytes round-trip byte-exact under a gateway owner");
    let reply: Vec<u8> = b"\x00\x01<EVENT/>\xaa reply".to_vec();
    let out = spt_stdin(
        &["api", "--adapter", "mock-shell", "tunnel", "mock-shell-0", "send", "--link", &token_a],
        reply.clone(),
    );
    assert!(out.status.success(), "shell tunnel send: {}", String::from_utf8_lossy(&out.stderr));
    assert_eq!(owner_recv_until(reply.len()), reply, "shell→owner round-trip byte-exact");

    // ── act-gate (REQ-CONSENT-3): the class-keyed `attach` refuses ungranted,
    // then a grant KEYED ON THE GATEWAY'S ENDPOINT-ID flips it through — the
    // same id-not-type invariant on the consent surface.
    let out = spt(&["shell", "cmd", "--owner", gw_a, "Scout", "attach", "busid-1"]);
    assert!(!out.status.success(), "ungranted gated attach refuses under the gateway");
    assert!(
        String::from_utf8_lossy(&out.stderr).contains("CONSENT_PENDING"),
        "refused as a pending act-gate: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    // The grant target IS the gateway's endpoint-id (not a type, not an agent id).
    let out = spt(&["grant", "add", "shell-act:attach", gw_a, "--qualifier", "hid"]);
    assert!(out.status.success(), "grant add: {}", String::from_utf8_lossy(&out.stderr));
    let out = spt(&["shell", "cmd", "--owner", gw_a, "Scout", "attach", "busid-1"]);
    assert!(
        out.status.success(),
        "the grant keyed on the gateway endpoint-id flips the gated cmd through: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    // ── relink: a gateway owner relinks identically (rotates the token).
    let out = spt(&["shell", "relink", "Scout", "--owner", gw_a]);
    assert!(out.status.success(), "gateway relink: {}", String::from_utf8_lossy(&out.stderr));
    assert_ne!(token_a, token_of(), "relink rotates the link token for a gateway owner");

    // ── NEGATIVE: gateway-B (SAME type="gateway", DIFFERENT id) is refused on
    // every control path for gateway-A's shell — exclusivity keys on the owner
    // ENDPOINT-ID, not the type.
    let gw_b = "playdate-gw-b";
    bind_typed_owner(gw_b, "gateway");
    let out = spt(&["shell", "cmd", "--owner", gw_b, "Scout", "press", "A"]);
    assert!(!out.status.success(), "gateway-B cmd refused");
    assert!(
        String::from_utf8_lossy(&out.stderr).contains("NO_SHELL"),
        "same-type different-id owner: cmd is NO_SHELL: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let out = spt(&["shell", "drive", "Scout", "--type", "stick", "x=0.1", "--owner", gw_b]);
    assert!(!out.status.success(), "gateway-B drive refused");
    assert!(
        String::from_utf8_lossy(&out.stderr).contains("NO_SHELL"),
        "same-type different-id owner: drive is NO_SHELL: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let out = spt(&["shell", "tunnel", "Scout", "recv", "--owner", gw_b]);
    assert!(
        String::from_utf8_lossy(&out.stderr).contains("NO_TUNNEL"),
        "same-type different-id owner: tunnel resolves no live tunnel: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    // Best-effort hygiene: reap any real daemon an `ensure_running` race spawned
    // against this throwaway home (workspace-scoped, never machine-wide).
    if let Ok(pid) = std::fs::read_to_string(home.path().join("daemon.pid")) {
        if let Ok(pid) = pid.trim().parse::<u32>() {
            #[cfg(windows)]
            let _ = Command::new("taskkill")
                .no_window()
                .args(["/PID", &pid.to_string(), "/F"])
                .output();
            #[cfg(unix)]
            let _ = Command::new("kill").args(["-9", &pid.to_string()]).output();
        }
    }
}


Activate REQ-SHELL-5 stages.


Edit

required_stages = ["doc", "impl"]  # M11-T0 activated (the code-only ownership audit — proof not change): doc = CONTEXT §Shell model "Owner-linked, exclusive" owner-type-agnostic note; impl = shellhost::find_shell_by_token (the "owner from the link" resolution returns an opaque owner-id with NO type inspection — the load-bearing type-agnostic path). unit/int activate at the W4 Gateway-owner capstone (a Gateway-typed owner drives/cmds/tunnels/links a shell identically)

required_stages = ["doc", "impl", "unit", "int"]  # M11-W4 capstone (proof-not-change — the T0 audit found no type-gate on any ownership path; W4 proves it end-to-end): doc = CONTEXT §Shell model "Owner-linked, exclusive" owner-type-agnostic note; impl = shellhost::find_shell_by_token (the "owner from the link" resolution returns an opaque owner-id with NO type inspection — the load-bearing type-agnostic path); unit = linkhost::gateway_typed_owner_resolves_and_drives_keyed_on_id_not_type (resolve_link_target/run_action resolve + drive a gateway-typed owner identically; a same-type different-id owner is NoShell — exclusivity keys on the id, not the type); int = gateway_owner_shell_e2e (a state="gateway" owner succeeds on EVERY path same-node — spawn/bind/cmd/drive/tunnel/act-gate/relink — and a same-type different-id gateway-B is refused on cmd/drive/tunnel)


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


978	id = "REQ-SHELL-5"
979	title = "Shell ownership is owner-type-agnostic: any non-Shell endpoint type may own/spawn/drive/command/link a shell (Gateway the named first) — control-exclusivity keys on the owner endpoint_id, NEVER on the owner's endpoint type. No ownership path (mint, launch, owner-from-link, cmd, drive, tunnel, sleep/wake, owner-shutdown) inspects the owner's type (CONTEXT:264, ratified 2026-06-11 Gateway grill)."
980	required_stages = ["doc", "impl"]  # M11-T0 activated (the code-only ownership audit — proof not change): doc = CONTEXT §Shell model "Owner-linked, exclusive" owner-type-agnostic note; impl = shellhost::find_shell_by_token (the "owner from the link" resolution returns an opaque owner-id with NO type inspection — the load-bearing type-agnostic path). unit/int activate at the W4 Gateway-owner capstone (a Gateway-typed owner drives/cmds/tunnels/links a shell identically)

id = "REQ-SHELL-5"
title = "Shell ownership is owner-type-agnostic: any non-Shell endpoint type may own/spawn/drive/command/link a shell (Gateway the named first) — control-exclusivity keys on the owner endpoint_id, NEVER on the owner's endpoint type. No ownership path (mint, launch, owner-from-link, cmd, drive, tunnel, sleep/wake, owner-shutdown) inspects the owner's type (CONTEXT:264, ratified 2026-06-11 Gateway grill)."
required_stages = ["doc", "impl"]  # M11-T0 activated (the code-only ownership audit — proof not change): doc = CONTEXT §Shell model "Owner-linked, exclusive" owner-type-agnostic note; impl = shellhost::find_shell_by_token (the "owner from the link" resolution returns an opaque owner-id with NO type inspection — the load-bearing type-agnostic path). unit/int activate at the W4 Gateway-owner capstone (a Gateway-typed owner drives/cmds/tunnels/links a shell identically)


required_stages = ["doc", "impl"]  # M11-T0 activated (the code-only ownership audit — proof not change): doc = CONTEXT §Shell model "Owner-linked, exclusive" owner-type-agnostic note; impl = shellhost::find_shell_by_token (the "owner from the link" resolution returns an opaque owner-id with NO type inspection — the load-bearing type-agnostic path). unit/int activate at the W4 Gateway-owner capstone (a Gateway-typed owner drives/cmds/tunnels/links a shell identically)

required_stages = ["doc", "impl", "unit", "int"]  # M11-W4 capstone (proof-not-change — the T0 audit found no type-gate on any ownership path; W4 proves it end-to-end): doc = CONTEXT §Shell model "Owner-linked, exclusive" owner-type-agnostic note; impl = shellhost::find_shell_by_token (the "owner from the link" resolution returns an opaque owner-id with NO type inspection — the load-bearing type-agnostic path); unit = linkhost::gateway_typed_owner_resolves_and_drives_keyed_on_id_not_type (resolve_link_target/run_action resolve + drive a gateway-typed owner identically; a same-type different-id owner is NoShell — exclusivity keys on the id, not the type); int = gateway_owner_shell_e2e (a state="gateway" owner succeeds on EVERY path same-node — spawn/bind/cmd/drive/tunnel/act-gate/relink — and a same-type different-id gateway-B is refused on cmd/drive/tunnel)


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)

title = "Shell ownership is owner-type-agnostic: any non-Shell endpoint type may own/spawn/drive/command/link a shell (Gateway the named first) — control-exclusivity keys on the owner endpoint_id, NEVER on the owner's endpoint type. No ownership path (mint, launch, owner-from-link, cmd, drive, tunnel, sleep/wake, owner-shutdown) inspects the owner's type (CONTEXT:264, ratified 2026-06-11 Gateway grill)."

-required_stages = ["doc", "impl"]  # M11-T0 activated (the code-only ownership audit — proof not change): doc = CONTEXT §Shell model "Owner-linked, exclusive" owner-type-agnostic note; impl = shellhost::find_shell_by_token (the "owner from the link" resolution returns an opaque owner-id with NO type inspection — the load-bearing type-agnostic path). unit/int activate at the W4 Gateway-owner capstone (a Gateway-typed owner drives/cmds/tunnels/links a shell identically)

+required_stages = ["doc", "impl", "unit", "int"]  # M11-W4 capstone (proof-not-change — the T0 audit found no type-gate on any ownership path; W4 proves it end-to-end): doc = CONTEXT §Shell model "Owner-linked, exclusive" owner-type-agnostic note; impl = shellhost::find_shell_by_token (the "owner from the link" resolution returns an opaque owner-id with NO type inspection — the load-bearing type-agnostic path); unit = linkhost::gateway_typed_owner_resolves_and_drives_keyed_on_id_not_type (resolve_link_target/run_action resolve + drive a gateway-typed owner identically; a same-type different-id owner is NoShell — exclusivity keys on the id, not the type); int = gateway_owner_shell_e2e (a state="gateway" owner succeeds on EVERY path same-node — spawn/bind/cmd/drive/tunnel/act-gate/relink — and a same-type different-id gateway-B is refused on cmd/drive/tunnel)


Verify doc tag exists, then build+run unit test.


Bash

grep -rn "doc->REQ-SHELL-5" CONTEXT.md docs/ 2>/dev/null | head

Confirm doc tag for REQ-SHELL-5


CONTEXT.md:265:<!-- [doc->REQ-SHELL-5] owner-type-agnostic: control-exclusivity keys on the owner endpoint_id, never the owner's endpoint type -->


PowerShell

cargo test -p spt-daemon --lib gateway_typed_owner_resolves_and_drives_keyed_on_id_not_type 2>&1 | Select-Object -Last 30

Run new linkhost unit test


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

running 1 test
test linkhost::tests::gateway_typed_owner_resolves_and_drives_keyed_on_id_not_type ... ok

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


Unit green, doc tag confirmed. Run the E2E (needs built spt binary).


cargo test -p spt --test gateway_owner_shell_e2e 2>&1 | Select-Object -Last 40

Build + run the gateway-owner E2E


Error: Exit code 101
    Blocking waiting for file lock on package cache
   Compiling spt-daemon v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
   Compiling spt v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
error: failed to remove file `C:\Users\decid\Documents\projects\spt-core\target\debug\spt.exe`

Caused by:
  Access is denied. (os error 5)


spt.exe locked — a leftover dev daemon holds it. Find + kill the specific PID (not machine-wide).


Get-Process spt -ErrorAction SilentlyContinue | Where-Object { $_.Path -like '*spt-core\target\debug\spt.exe' } | Select-Object Id, Path

Find dev-target spt.exe processes


    Id Path
    -- ----
 73168 C:\Users\decid\Documents\projects\spt-core\target\debug\spt.exe
131700 C:\Users\decid\Documents\projects\spt-core\target\debug\spt.exe


Both are dev-target artifacts (not live owl.exe). Kill those two PIDs only.


Stop-Process -Id 73168,131700 -Force -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 500; Get-Process spt -ErrorAction SilentlyContinue | Where-Object { $_.Path -like '*spt-core\target\debug\spt.exe' } | Select-Object Id

Kill leftover dev daemons, confirm gone


(PowerShell completed with no output)


cargo test -p spt --test gateway_owner_shell_e2e 2>&1 | Select-Object -Last 40

Build + run the gateway-owner E2E


Error: Exit code 101
   Compiling spt v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 5.98s
     Running tests\gateway_owner_shell_e2e.rs (target\debug\deps\gateway_owner_shell_e2e-22de0871c533e5de.exe)

running 1 test
test gateway_typed_owner_owns_a_shell_on_every_path_keyed_on_id ... FAILED

failures:

---- gateway_typed_owner_owns_a_shell_on_every_path_keyed_on_id stdout ----

thread 'gateway_typed_owner_owns_a_shell_on_every_path_keyed_on_id' (57476) panicked at crates\spt\tests\gateway_owner_shell_e2e.rs:304:5:
gateway relink: SHELL_ALREADY_ONLINE:Scout: relink is the online switch — nothing to do

note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    gateway_typed_owner_owns_a_shell_on_every_path_keyed_on_id

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

error: test failed, to rerun pass `-p spt --test gateway_owner_shell_e2e`


relink needs an offline instance — must break the link first (the real lifecycle, as in drive/tunnel E2Es). Fix.


Edit

    // ── relink: a gateway owner relinks identically (rotates the token).
    let out = spt(&["shell", "relink", "Scout", "--owner", gw_a]);
    assert!(out.status.success(), "gateway relink: {}", String::from_utf8_lossy(&out.stderr));
    assert_ne!(token_a, token_of(), "relink rotates the link token for a gateway owner");

    // ── relink: break the link (the close flips the instance offline), then a
    // gateway owner relinks identically to an agent — minting a FRESH token.
    let shell = spt_runtime::registry::resolve_option(&perch::adapters_dir(), "mock-shell")
        .unwrap()
        .1
        .shell
        .unwrap();
    spt_daemon::shellhost::close_shell(&owlery, gw_a, "mock-shell-0", Some(&shell))
        .expect("link break closes the gateway-owned shell");
    let out = spt(&["shell", "relink", "Scout", "--owner", gw_a]);
    assert!(out.status.success(), "gateway relink: {}", String::from_utf8_lossy(&out.stderr));
    assert_ne!(token_a, token_of(), "relink rotates the link token for a gateway owner");


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

    // ── relink: a gateway owner relinks identically (rotates the token).
    let out = spt(&["shell", "relink", "Scout", "--owner", gw_a]);
    assert!(out.status.success(), "gateway relink: {}", String::from_utf8_lossy(&out.stderr));
    assert_ne!(token_a, token_of(), "relink rotates the link token for a gateway owner");

    // ── relink: break the link (the close flips the instance offline), then a
    // gateway owner relinks identically to an agent — minting a FRESH token.
    let shell = spt_runtime::registry::resolve_option(&perch::adapters_dir(), "mock-shell")
        .unwrap()
        .1
        .shell
        .unwrap();
    spt_daemon::shellhost::close_shell(&owlery, gw_a, "mock-shell-0", Some(&shell))
        .expect("link break closes the gateway-owned shell");
    let out = spt(&["shell", "relink", "Scout", "--owner", gw_a]);
    assert!(out.status.success(), "gateway relink: {}", String::from_utf8_lossy(&out.stderr));
    assert_ne!(token_a, token_of(), "relink rotates the link token for a gateway owner");

-    // ── relink: a gateway owner relinks identically (rotates the token).

+    // ── relink: break the link (the close flips the instance offline), then a

+    // gateway owner relinks identically to an agent — minting a FRESH token.

+    let shell = spt_runtime::registry::resolve_option(&perch::adapters_dir(), "mock-shell")

+    spt_daemon::shellhost::close_shell(&owlery, gw_a, "mock-shell-0", Some(&shell))

+        .expect("link break closes the gateway-owned shell");


PowerShell

cargo test -p spt --test gateway_owner_shell_e2e 2>&1 | Select-Object -Last 25

Re-run the gateway-owner E2E


   Compiling spt v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 3.94s
     Running tests\gateway_owner_shell_e2e.rs (target\debug\deps\gateway_owner_shell_e2e-22de0871c533e5de.exe)

running 1 test
test gateway_typed_owner_owns_a_shell_on_every_path_keyed_on_id ... ok

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


E2E green. Pre-flight: clippy workspace + traceable + xtask check.


cargo clippy --workspace --all-targets -- -D warnings 2>&1 | Select-Object -Last 15

Workspace clippy, deny warnings


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


Clippy clean. Run traceable + xtask check.


traceable-reqs check 2>&1 | Select-Object -Last 20; "EXIT=$LASTEXITCODE"

traceable-reqs evidence check


cargo run -p xtask -- check 2>&1 | Select-Object -Last 20; "EXIT=$LASTEXITCODE"

xtask check (docs drift etc.)


  [OK] REQ-TERM-7  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Two-origin digest merge: spt-owned context-injection entries (psyche_download | echo_mirror | owl_message) appended by spt to the endpoint `digest.log`, timestamp-interleaved with the adapter's extracted activity records into one ordered timeline, via a distinct context-injection record category. Data model only this milestone; GUI collapse/expand and the echo-reads-digest delta loop are deferred to the surfaces that consume them.
  [OK] REQ-UPD-1  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Peer-propagated update over P2P
  [OK] REQ-UPD-2  required: [impl, unit]  stages: -doc +impl +unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-core ripple-updates registered adapters
  [OK] REQ-UPD-6  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       Platform-targeted update sets and debug rollout: signed multi-platform update metadata, recipient platform selection, channel-scoped monotonic counters, debug-channel opt-in via release-key overlay, local staging plus pull-based peer propagation, and maintainer-only convergence tooling (ADR-0016)
  [OK] REQ-UPD-7  required: [impl, unit]  stages: -doc +impl +unit -int
       Origin-source update bootstrap (`spt update fetch`): pull the latest signed release directly from the GitHub release origin (`SaberMage/spt-releases`) — the per-platform artifact + its `<asset>.release.json` SignedRelease metadata — and stage it through the EXISTING verify→stage pipeline (the same `plan_verified` gate: two-key signature + channel + monotonic rollback floor + SHA-256), after which the normal consent-notif / `spt update apply` flow is unchanged. Closes the peer-only-discovery gap (REQ-UPD-1): a first-in-fleet / isolated node can update with no peer to pull from. The signed-release anchor keeps the GitHub transport untrusted-but-verified.
  [OK] REQ-UPD-8  required: [impl, unit]  stages: -doc +impl +unit -int
       Platform-safe `spt update fetch` + apply platform-guard (v0.3.1 cross-OS brick fix): `spt update fetch` stages the signed multi-platform `SignedUpdateSet` (`update-set.json` + every platform artifact it names), never a platform-blind single `SignedRelease`, so local apply selects `current_platform()` and P2P re-serve lets each peer select ITS own platform. Defense-in-depth: `apply_staged` REFUSES a staged single-release artifact unless it is platform-stamped for THIS node (an unstamped pre-v0.3.2 single, or a single stamped for another OS, fail-safe refuses — the guard that alone prevents the v0.3.1 brick where a Linux ELF was applied as `spt.exe`). UX: a friendly post-apply message (`Updated spt-core to vX.Y.Z.` + changelog URL) driven by an additive `product_version` metadata field, with a release-counter fallback when absent.
  [OK] REQ-WHOAMI-1  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       `spt whoami` is a thin ALIAS for `spt endpoint list` (full output: the SELF pin + the subnet roster) — the standalone bare-id command is dropped (the `id=$(spt whoami)` capture was never a real pattern: env vars don't persist between agent tool calls). The one new render: the `endpoint list` SELF pin carries the Self endpoint's authored `endpoint description` (info::read_info(...).resources) when present, inline after the liveness state. whoami stays a top-level hot-path verb (parse unchanged, REQ-MSG-9).
EXIT=0


   Compiling spt-daemon v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
   Compiling xtask v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.06s
     Running `target\debug\xtask.exe check`
xtask check: OK
EXIT=0


All pre-flight green. Confirm REQ-SHELL-5 line specifically.


traceable-reqs check 2>&1 | Select-String -Pattern 'REQ-SHELL-5' -Context 0,1

Confirm REQ-SHELL-5 four-stage coverage


>   [OK] REQ-SHELL-5  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
         Shell ownership is owner-type-agnostic: any non-Shell endpoint type may own/spawn/drive/command/link a shell (Gateway the
named first) — control-exclusivity keys on the owner endpoint_id, NEVER on the owner's endpoint type. No ownership path (mint, launch,
owner-from-link, cmd, drive, tunnel, sleep/wake, owner-shutdown) inspects the owner's type (CONTEXT:264, ratified 2026-06-11 Gateway
grill).


REQ-SHELL-5 fully evidenced `[doc,impl,unit,int]`. **Proof-not-change confirmed** — zero production diff (only test files + traceable toml). No type-gate surfaced on any path. Commit.


Bash

Stage + show changes


warning: in the working copy of 'crates/spt/tests/gateway_owner_shell_e2e.rs', LF will be replaced by CRLF the next time Git touches it
M  crates/spt-daemon/src/linkhost.rs
A  crates/spt/tests/gateway_owner_shell_e2e.rs
M  traceable-reqs.toml


git commit -q -m "$(cat <<'EOF'
M11-W4 T4.2: gateway-owner shell capstone E2E + unit (REQ-SHELL-5, close W4 build)

Proof-not-change: the T0 audit found no type-gate on any ownership path, so
W4 only proves it end-to-end + activates unit/int — ZERO production diff.

- int: gateway_owner_shell_e2e — a state="gateway" owner succeeds on EVERY path
  same-node (spawn/bind+tunnel-open/cmd/drive/tunnel byte round-trip/act-gate/
  relink); a SAME-type DIFFERENT-id gateway-B is refused on cmd/drive/tunnel
  (NO_SHELL/NO_TUNNEL) — exclusivity keys on the owner endpoint_id, not the type.
- unit: linkhost gateway_typed_owner_resolves_and_drives_keyed_on_id_not_type —
  resolve_link_target/run_action resolve+drive a gateway owner opaquely; a
  same-type different-id owner is NoShell at the resolution layer.
- REQ-SHELL-5 activated [doc, impl, unit, int].

Co-authored by: todlando
EOF
)" && git log --oneline -1

Commit W4 build


81eed80 M11-W4 T4.2: gateway-owner shell capstone E2E + unit (REQ-SHELL-5, close W4 build)


Push to PR #16 (rides W3's local FLAKE-LEDGER #7 commit too).


git push origin m11-shell-substrate 2>&1 | tail -8

Push branch


To https://github.com/SaberMage/spt-core.git
   221c0e7..81eed80  m11-shell-substrate -> m11-shell-substrate


Pushed both commits (W3 FLAKE-LEDGER #7 + W4). Check CI + notify doyle.


gh run list --branch m11-shell-substrate --limit 3 2>&1 | head -5

Latest CI runs for branch


completed	success	M11 shell-substrate extensions (W1: per-capability act-gates, REQ-CONSENT-3)	ci	m11-shell-substrate	pull_request	27595180782	11m57s	2026-06-16T05:06:54Z
completed	success	M11 shell-substrate extensions (W1: per-capability act-gates, REQ-CONSENT-3)	ci	m11-shell-substrate	pull_request	27590716942	7m18s	2026-06-16T02:49:26Z
completed	failure	M11 shell-substrate extensions (W1: per-capability act-gates, REQ-CONSENT-3)	ci	m11-shell-substrate	pull_request	27590332525	7m36s	2026-06-16T02:38:18Z


New run should be triggering for HEAD 81eed80. Notify doyle + commune while CI runs.


$OWL send doyle todlando <<'EOF'
M11-W4 BUILD COMPLETE + PUSHED (PR #16, HEAD 81eed80). Ready to gate.

REQ-SHELL-5 = [doc,impl,unit,int] (traceable EXIT=0). PROOF-NOT-CHANGE held: T0 audit was right — NO type-gate on any ownership path surfaced. ZERO production diff (only new tests + traceable stages + the linkhost test-module unit).

- int: crates/spt/tests/gateway_owner_shell_e2e.rs — a state="gateway" owner succeeds on EVERY path same-node: spawn -> bind-shell(onlines + SHELL_TUNNEL_OPEN) -> cmd(durable spool) -> drive(latest-wins slot) -> tunnel(opaque byte round-trip BOTH dirs, EVENT-looking+NUL bytes) -> act-gate(REQ-CONSENT-3 grant keyed on the gateway endpoint-id) -> relink(close->relink, token rotates).
- NEGATIVE (your gate refinement): gateway-B = SAME type="gateway", DIFFERENT id -> refused on cmd/drive/tunnel (NO_SHELL/NO_TUNNEL). Proves key = owner endpoint_id, not type.
- unit (distinct): linkhost gateway_typed_owner_resolves_and_drives_keyed_on_id_not_type — resolve_link_target/run_action resolve+drive a gateway owner opaquely; same-type different-id -> NoShell at the resolution layer.

Pre-flight all green: clippy --workspace --all-targets -D warnings, traceable EXIT=0, xtask check OK. Local unit+e2e green. Push also carried W3's FLAKE-LEDGER #7 (9ebb1c6). CI triggering on 81eed80 now — will confirm green. W4 build closed; W5 = rig [twohost] rungs + docs.
EOF
echo "sent rc=$?"

Notify doyle W4 complete


➜ SENT:doyle
sent rc=0


Commune to my Psyche, then confirm CI registered.


Write

# Commune — M11-W4 BUILD COMPLETE

Gen 46+. Head 81eed80 on m11-shell-substrate (PR #16). Pushed.

## W4: BUILT + PUSHED, awaiting doyle gate
REQ-SHELL-5 (Gateway-as-owner capstone) = [doc,impl,unit,int], traceable EXIT=0.
PROOF-NOT-CHANGE held — T0 audit correct, NO type-gate surfaced on any ownership
path. ZERO production diff (only new tests + traceable stage activation + the
linkhost test-module unit). No "fix loud" needed.

Commits pushed this wave:
- 81eed80 = W4 build (gateway_owner_shell_e2e.rs int + linkhost unit + toml stages)
- 9ebb1c6 = W3 FLAKE-LEDGER #7 (was local-unpushed, rode this push)

E2E (crates/spt/tests/gateway_owner_shell_e2e.rs): state="gateway" owner succeeds
on EVERY path same-node (spawn->bind+TUNNEL_OPEN->cmd(durable)->drive(latest-wins)
->tunnel(byte round-trip both dirs)->act-gate(grant keyed on gw endpoint-id)->
relink). NEGATIVE = gateway-B SAME type, DIFFERENT id refused on cmd/drive/tunnel
(NO_SHELL/NO_TUNNEL) — doyle's gate-blocking refinement satisfied.
Unit (linkhost): gateway_typed_owner_resolves_and_drives_keyed_on_id_not_type.

Harness: combined tunnel_e2e + drive_e2e start_inproc_daemon (seed + broker-with-
hermetic-NetHost + tunnel control + drive control). relink needs offline instance
-> close_shell before relink (the real link-break->relink lifecycle).

Sent doyle the gate-submit. CI triggering on 81eed80 — confirm green then doyle gates.

## NEXT after gate
W5 (M11-PLAN T5.x): drive [twohost] rung + tunnel [twohost] rung + how-to subnet
topic (REQ-DOCS-6) + ADR-0020 tunnel EVENT-exemption enumeration + on-LAN posture
docs. Last build wave done; W5 = rig + docs.

## Pre-flight (binding, every commit)
clippy --workspace --all-targets -D warnings + traceable EXIT 0 + xtask check;
CLI add -> xtask gen + no milestone/REQ codes in clap help.


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

# Commune — M11-W4 BUILD COMPLETE

Gen 46+. Head 81eed80 on m11-shell-substrate (PR #16). Pushed.

## W4: BUILT + PUSHED, awaiting doyle gate
REQ-SHELL-5 (Gateway-as-owner capstone) = [doc,impl,unit,int], traceable EXIT=0.
PROOF-NOT-CHANGE held — T0 audit correct, NO type-gate surfaced on any ownership
path. ZERO production diff (only new tests + traceable stage activation + the
linkhost test-module unit). No "fix loud" needed.

Commits pushed this wave:
- 81eed80 = W4 build (gateway_owner_shell_e2e.rs int + linkhost unit + toml stages)
- 9ebb1c6 = W3 FLAKE-LEDGER #7 (was local-unpushed, rode this push)

E2E (crates/spt/tests/gateway_owner_shell_e2e.rs): state="gateway" owner succeeds
on EVERY path same-node (spawn->bind+TUNNEL_OPEN->cmd(durable)->drive(latest-wins)
->tunnel(byte round-trip both dirs)->act-gate(grant keyed on gw endpoint-id)->
relink). NEGATIVE = gateway-B SAME type, DIFFERENT id refused on cmd/drive/tunnel
(NO_SHELL/NO_TUNNEL) — doyle's gate-blocking refinement satisfied.
Unit (linkhost): gateway_typed_owner_resolves_and_drives_keyed_on_id_not_type.

Harness: combined tunnel_e2e + drive_e2e start_inproc_daemon (seed + broker-with-
hermetic-NetHost + tunnel control + drive control). relink needs offline instance
-> close_shell before relink (the real link-break->relink lifecycle).

Sent doyle the gate-submit. CI triggering on 81eed80 — confirm green then doyle gates.

## NEXT after gate
W5 (M11-PLAN T5.x): drive [twohost] rung + tunnel [twohost] rung + how-to subnet
topic (REQ-DOCS-6) + ADR-0020 tunnel EVENT-exemption enumeration + on-LAN posture
docs. Last build wave done; W5 = rig + docs.

## Pre-flight (binding, every commit)
clippy --workspace --all-targets -D warnings + traceable EXIT 0 + xtask check;
CLI add -> xtask gen + no milestone/REQ codes in clap help.


Bash

sleep 8; gh run list --branch m11-shell-substrate --limit 1 2>&1 | head -2

Check CI registered for new HEAD


completed	success	M11 shell-substrate extensions (W1: per-capability act-gates, REQ-CONSENT-3)	ci	m11-shell-substrate	pull_request	27595180782	11m57s	2026-06-16T05:06:54Z


CI not yet registered for 81eed80. Check by HEAD sha + queued runs.


gh run list --branch m11-shell-substrate --limit 5 --json databaseId,status,headSha,createdAt 2>&1 | head -40

Runs with head sha


Run not yet created for the push (webhook lag). Wait briefly, re-check once.


sleep 25; gh run list --branch m11-shell-substrate --limit 1 --json databaseId,status,headSha 2>&1

Re-check for new CI run


<tool_use_error>Blocked: sleep 25 followed by: gh run list --branch m11-shell-substrate --limit 1 --json databaseId,status,headSha. To wait for a condition, use Monitor with an until-loop (e.g. `until <check>; do sleep 2; done`). To wait for a command you started, use run_in_background: true. Do not chain shorter sleeps to work around this block.</tool_use_error>

Error: Blocked: sleep 25 followed by: gh run list --branch m11-shell-substrate --limit 1 --json databaseId,status,headSha. To wait for a condition, use Monitor with an until-loop (e.g. `until <check>; do sleep 2; done`). To wait for a command you started, use run_in_background: true. Do not chain shorter sleeps to work around this block.


gh run list --branch m11-shell-substrate --limit 1 --json databaseId,status,headSha 2>&1

Check for new CI run now


CI webhook lagging. Launch a background watcher that waits for the 81eed80 run to appear and finish, then reports.


target=81eed80
for i in $(seq 1 90); do
  row=$(gh run list --branch m11-shell-substrate --limit 5 --json databaseId,status,conclusion,headSha 2>/dev/null \
        | tr ',' '\n' | grep -A0 "$target" >/dev/null 2>&1; gh run list --branch m11-shell-substrate --limit 5 --json databaseId,status,conclusion,headSha 2>/dev/null)
  match=$(echo "$row" | python -c "import sys,json;[print(r['databaseId'],r['status'],r['conclusion']) for r in json.load(sys.stdin) if r['headSha'].startswith('$target')]" 2>/dev/null)
  if [ -n "$match" ]; then
    st=$(echo "$match" | awk '{print $2}')
    if [ "$st" = "completed" ]; then echo "CI DONE for $target: $match"; exit 0; fi
    echo "CI in progress for $target: $match"
  else
    echo "no run yet for $target (poll $i)"
  fi
  sleep 20
done
echo "TIMEOUT waiting for CI run on $target"

Background-watch CI for HEAD 81eed80


Command running in background with ID: biq8iww9y. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\30df62c3-0b22-4b14-88de-fef422a5554f\tasks\biq8iww9y.output. You will be notified when it completes. To check interim output, use Read on that file path.


W4 build done and pushed. Summary while CI watcher runs in background:

## M11-W4 complete — REQ-SHELL-5 capstone

**Proof-not-change held.** T0 audit was right: no type-gate on any ownership path surfaced. **Zero production diff** — only new tests + traceable stage activation.

**Delivered:**
- **int** `crates/spt/tests/gateway_owner_shell_e2e.rs` — `state="gateway"` owner wins every path same-node: spawn → bind (onlines + `SHELL_TUNNEL_OPEN`) → cmd (durable spool) → drive (latest-wins slot) → tunnel (opaque bytes both dirs, EVENT-looking+NUL payload) → act-gate (REQ-CONSENT-3, grant keyed on the gateway endpoint-id) → relink (token rotates).
- **NEGATIVE** (doyle's gate-blocking refinement): gateway-B = **same type, different id** → refused on cmd/drive/tunnel (`NO_SHELL`/`NO_TUNNEL`). Key = owner endpoint_id, not type.
- **unit** `linkhost::gateway_typed_owner_resolves_and_drives_keyed_on_id_not_type` — resolution-layer mirror.
- REQ-SHELL-5 activated `[doc,impl,unit,int]`.

**Pre-flight all green:** clippy `--workspace --all-targets -D warnings`, traceable `EXIT=0`, xtask check OK. Local unit+e2e pass.

**Pushed** `221c0e7..81eed80` to PR #16 (also carried W3's local FLAKE-LEDGER #7 @9ebb1c6). Notified doyle for gate. CI webhook is lagging — background watcher `biq8iww9y` will report when the 81eed80 run finishes.

Next after gate: **W5** = drive/tunnel `[twohost]` rungs + how-to subnet topic + ADR-0020 EVENT-exemption enumeration + on-LAN posture docs.


<task-notification>
<task-id>be1did0ug</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/5" id="b09c6528" type="msg" from="doyle">W4 CODE-READ LEG: PASS — verified @81eed80 myself, every condition holds:
- PROOF-NOT-CHANGE confirmed at the diff: linkhost.rs +72 is ENTIRELY inside #[cfg(test)] mod tests (one #[test] fn); the only other changes are the E2E test, the FLAKE-LEDGER doc, and the traceable-reqs stage activation. ZERO production-logi</EVENT-PART>
<EVENT-PART seq="2/5" id="b09c6528">c diff. The T0 audit held.
- POSITIVE (gateway-A, state=&quot;gateway&quot;, asserted non-agent): spawn → bind-shell onlines + tunnel opens → cmd durable-spools (pending_count) → drive latest-wins slot → tunnel opaque bytes byte-exact BOTH directions → act-gate class-keyed attach refuses ungranted then flips through on a grant KEY</EVENT-PART>
<EVENT-PART seq="3/5" id="b09c6528">ED ON THE GATEWAY endpoint-id → relink rotates the token. Every ownership path succeeds for a non-agent owner = no type-gate anywhere.
- NEGATIVE (your refinement, the crux): gateway-B SAME type=&quot;gateway&quot; DIFFERENT id refused on cmd (NO_SHELL) + drive (NO_SHELL) + tunnel (NO_TUNNEL). Same-type-different-id refused proves exclusi</EVENT-PART>
<EVENT-PART seq="4/5" id="b09c6528">vity keys on the endpoint-id, not the type.
- DISTINCT UNIT: resolve_link_target/run_action resolve+drive a gateway owner opaquely; gateway-B → NoShell at the resolution layer. Proper unit evidence beside the int.

The ONLY thing between W4 and PASS is CI green on 81eed80. HEADS-UP: my docs-publish work has been firing ci.yml runs o</EVENT-PART>
<EVENT-PART seq="5/5" id="b09c6528">n kitsubito too — if the attach_survives test times out again on the shared box, it's the SAME confirmed flake (FLAKE-LEDGER #7), not W4. Ping me on green (or rerun --failed if attach flakes) and I stamp the W4 GATE PASS immediately — code is already cleared.</EVENT-PART></event>
</task-notification>


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


doyle: W4 code-read PASS @81eed80. Only CI green remains. Flake heads-up noted (attach_survives = FLAKE-LEDGER #7, rerun --failed not a regression). My watcher `biq8iww9y` is on CI. Ack doyle.


$OWL send doyle todlando <<'EOF'
ACK — code-read PASS received, thanks. Watcher armed on CI for 81eed80 (webhook lagged ~min, no run yet last check). On completion: green -> I ping you to stamp W4 GATE PASS. If attach_survives times out on kitsubito = FLAKE-LEDGER #7, I `gh run rerun <id> --failed` and re-confirm (won't mistake it for a W4 regression). Will report either way.
EOF
echo "rc=$?"

Ack doyle code-read pass


➜ SENT:doyle
rc=0


Acked. Watcher `biq8iww9y` polling CI for 81eed80. Standing by — will ping doyle on green (or rerun --failed if attach_survives flakes per FLAKE-LEDGER #7).


<task-notification>
<task-id>be1did0ug</task-id>
<summary>Monitor event: "« spt event »"</summary>
<event><EVENT type="msg" from="doyle">Ack — code-read PASS stands, W4 is pre-cleared. Ping me on green and I stamp the GATE PASS same-turn. If attach_survives times out, rerun --failed (FLAKE-LEDGER #7, W4-independent) and re-confirm — no further code review needed either way. Standing by.</EVENT></event>
</task-notification>


doyle standing by, W4 pre-cleared. Nothing to do but await CI — watcher `biq8iww9y` will re-invoke me on completion. Holding.


<task-notification>
<task-id>biq8iww9y</task-id>
<tool-use-id>toolu_016DgCXavEyTV7SXzaGQ4URw</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\30df62c3-0b22-4b14-88de-fef422a5554f\tasks\biq8iww9y.output</output-file>
<status>completed</status>
<summary>Background command "Background-watch CI for HEAD 81eed80" completed (exit code 0)</summary>
</task-notification>


Watcher done. Read result.


1	no run yet for 81eed80 (poll 1)
2	no run yet for 81eed80 (poll 2)
3	no run yet for 81eed80 (poll 3)
4	no run yet for 81eed80 (poll 4)
5	no run yet for 81eed80 (poll 5)
6	no run yet for 81eed80 (poll 6)
7	no run yet for 81eed80 (poll 7)
8	no run yet for 81eed80 (poll 8)
9	no run yet for 81eed80 (poll 9)
10	no run yet for 81eed80 (poll 10)
11	no run yet for 81eed80 (poll 11)
12	no run yet for 81eed80 (poll 12)
13	no run yet for 81eed80 (poll 13)
14	no run yet for 81eed80 (poll 14)
15	no run yet for 81eed80 (poll 15)
16	no run yet for 81eed80 (poll 16)
17	no run yet for 81eed80 (poll 17)
18	no run yet for 81eed80 (poll 18)
19	no run yet for 81eed80 (poll 19)
20	no run yet for 81eed80 (poll 20)
21	no run yet for 81eed80 (poll 21)
22	no run yet for 81eed80 (poll 22)
23	no run yet for 81eed80 (poll 23)
24	no run yet for 81eed80 (poll 24)
25	no run yet for 81eed80 (poll 25)
26	no run yet for 81eed80 (poll 26)
27	no run yet for 81eed80 (poll 27)
28	no run yet for 81eed80 (poll 28)
29	no run yet for 81eed80 (poll 29)
30	no run yet for 81eed80 (poll 30)
31	no run yet for 81eed80 (poll 31)
32	no run yet for 81eed80 (poll 32)
33	no run yet for 81eed80 (poll 33)
34	no run yet for 81eed80 (poll 34)
35	no run yet for 81eed80 (poll 35)
36	no run yet for 81eed80 (poll 36)
37	no run yet for 81eed80 (poll 37)
38	no run yet for 81eed80 (poll 38)
39	no run yet for 81eed80 (poll 39)
40	no run yet for 81eed80 (poll 40)
41	no run yet for 81eed80 (poll 41)
42	no run yet for 81eed80 (poll 42)
43	no run yet for 81eed80 (poll 43)
44	no run yet for 81eed80 (poll 44)
45	no run yet for 81eed80 (poll 45)
46	no run yet for 81eed80 (poll 46)
47	no run yet for 81eed80 (poll 47)
48	no run yet for 81eed80 (poll 48)
49	no run yet for 81eed80 (poll 49)
50	no run yet for 81eed80 (poll 50)
51	no run yet for 81eed80 (poll 51)
52	no run yet for 81eed80 (poll 52)
53	no run yet for 81eed80 (poll 53)
54	no run yet for 81eed80 (poll 54)
55	no run yet for 81eed80 (poll 55)
56	no run yet for 81eed80 (poll 56)
57	no run yet for 81eed80 (poll 57)
58	no run yet for 81eed80 (poll 58)
59	no run yet for 81eed80 (poll 59)
60	no run yet for 81eed80 (poll 60)
61	no run yet for 81eed80 (poll 61)
62	no run yet for 81eed80 (poll 62)
63	no run yet for 81eed80 (poll 63)
64	no run yet for 81eed80 (poll 64)
65	no run yet for 81eed80 (poll 65)
66	no run yet for 81eed80 (poll 66)
67	no run yet for 81eed80 (poll 67)
68	no run yet for 81eed80 (poll 68)
69	no run yet for 81eed80 (poll 69)
70	no run yet for 81eed80 (poll 70)
71	no run yet for 81eed80 (poll 71)
72	no run yet for 81eed80 (poll 72)
73	no run yet for 81eed80 (poll 73)
74	no run yet for 81eed80 (poll 74)
75	no run yet for 81eed80 (poll 75)
76	no run yet for 81eed80 (poll 76)
77	no run yet for 81eed80 (poll 77)
78	no run yet for 81eed80 (poll 78)
79	no run yet for 81eed80 (poll 79)
80	no run yet for 81eed80 (poll 80)
81	no run yet for 81eed80 (poll 81)
82	no run yet for 81eed80 (poll 82)
83	no run yet for 81eed80 (poll 83)
84	no run yet for 81eed80 (poll 84)
85	no run yet for 81eed80 (poll 85)
86	no run yet for 81eed80 (poll 86)
87	no run yet for 81eed80 (poll 87)
88	no run yet for 81eed80 (poll 88)
89	no run yet for 81eed80 (poll 89)
90	no run yet for 81eed80 (poll 90)
91	TIMEOUT waiting for CI run on 81eed80
92

no run yet for 81eed80 (poll 1)
no run yet for 81eed80 (poll 2)
no run yet for 81eed80 (poll 3)
no run yet for 81eed80 (poll 4)
no run yet for 81eed80 (poll 5)
no run yet for 81eed80 (poll 6)
no run yet for 81eed80 (poll 7)
no run yet for 81eed80 (poll 8)
no run yet for 81eed80 (poll 9)
no run yet for 81eed80 (poll 10)
no run yet for 81eed80 (poll 11)
no run yet for 81eed80 (poll 12)
no run yet for 81eed80 (poll 13)
no run yet for 81eed80 (poll 14)
no run yet for 81eed80 (poll 15)
no run yet for 81eed80 (poll 16)
no run yet for 81eed80 (poll 17)
no run yet for 81eed80 (poll 18)
no run yet for 81eed80 (poll 19)
no run yet for 81eed80 (poll 20)
no run yet for 81eed80 (poll 21)
no run yet for 81eed80 (poll 22)
no run yet for 81eed80 (poll 23)
no run yet for 81eed80 (poll 24)
no run yet for 81eed80 (poll 25)
no run yet for 81eed80 (poll 26)
no run yet for 81eed80 (poll 27)
no run yet for 81eed80 (poll 28)
no run yet for 81eed80 (poll 29)
no run yet for 81eed80 (poll 30)
no run yet for 81eed80 (poll 31)
no run yet for 81eed80 (poll 32)
no run yet for 81eed80 (poll 33)
no run yet for 81eed80 (poll 34)
no run yet for 81eed80 (poll 35)
no run yet for 81eed80 (poll 36)
no run yet for 81eed80 (poll 37)
no run yet for 81eed80 (poll 38)
no run yet for 81eed80 (poll 39)
no run yet for 81eed80 (poll 40)
no run yet for 81eed80 (poll 41)
no run yet for 81eed80 (poll 42)
no run yet for 81eed80 (poll 43)
no run yet for 81eed80 (poll 44)
no run yet for 81eed80 (poll 45)
no run yet for 81eed80 (poll 46)
no run yet for 81eed80 (poll 47)
no run yet for 81eed80 (poll 48)
no run yet for 81eed80 (poll 49)
no run yet for 81eed80 (poll 50)
no run yet for 81eed80 (poll 51)
no run yet for 81eed80 (poll 52)
no run yet for 81eed80 (poll 53)
no run yet for 81eed80 (poll 54)
no run yet for 81eed80 (poll 55)
no run yet for 81eed80 (poll 56)
no run yet for 81eed80 (poll 57)
no run yet for 81eed80 (poll 58)
no run yet for 81eed80 (poll 59)
no run yet for 81eed80 (poll 60)
no run yet for 81eed80 (poll 61)
no run yet for 81eed80 (poll 62)
no run yet for 81eed80 (poll 63)
no run yet for 81eed80 (poll 64)
no run yet for 81eed80 (poll 65)
no run yet for 81eed80 (poll 66)
no run yet for 81eed80 (poll 67)
no run yet for 81eed80 (poll 68)
no run yet for 81eed80 (poll 69)
no run yet for 81eed80 (poll 70)
no run yet for 81eed80 (poll 71)
no run yet for 81eed80 (poll 72)
no run yet for 81eed80 (poll 73)
no run yet for 81eed80 (poll 74)
no run yet for 81eed80 (poll 75)
no run yet for 81eed80 (poll 76)
no run yet for 81eed80 (poll 77)
no run yet for 81eed80 (poll 78)
no run yet for 81eed80 (poll 79)
no run yet for 81eed80 (poll 80)
no run yet for 81eed80 (poll 81)
no run yet for 81eed80 (poll 82)
no run yet for 81eed80 (poll 83)
no run yet for 81eed80 (poll 84)
no run yet for 81eed80 (poll 85)
no run yet for 81eed80 (poll 86)
no run yet for 81eed80 (poll 87)
no run yet for 81eed80 (poll 88)
no run yet for 81eed80 (poll 89)
no run yet for 81eed80 (poll 90)
TIMEOUT waiting for CI run on 81eed80


30min, no run detected — but my watcher used `python` which may not exist (false "no run"). Check CI directly.


echo "=== latest 4 runs ==="; gh run list --branch m11-shell-substrate --limit 4 --json databaseId,status,conclusion,headSha,createdAt 2>&1
echo "=== remote HEAD ==="; git ls-remote origin m11-shell-substrate
echo "=== python? ==="; which python python3 2>&1

Check CI runs + remote head + python availability


=== latest 4 runs ===
[{"conclusion":"success","createdAt":"2026-06-16T04:57:24Z","databaseId":27595180782,"headSha":"221c0e7ff427f5a001ad47991c21c5e4aa18dd98","status":"completed"},{"conclusion":"success","createdAt":"2026-06-16T02:49:26Z","databaseId":27590716942,"headSha":"9d5fb634004e0e8a6c72eff024e8c6eb55351b56","status":"completed"},{"conclusion":"failure","createdAt":"2026-06-16T02:38:18Z","databaseId":27590332525,"headSha":"13fa53efc17778ce8f8f62fdd023e888bb26c0ce","status":"completed"},{"conclusion":"success","createdAt":"2026-06-15T15:44:46Z","databaseId":27558164525,"headSha":"e637729a3a5a630300f212fd4be245e666aa6a5c","status":"completed"}]
=== remote HEAD ===
81eed80db7671ce2812159311934c6c3abd74660	refs/heads/m11-shell-substrate
=== python? ===
/c/program files/python312/python
/c/Users/decid/AppData/Local/Microsoft/WindowsApps/python3


Python fine — genuinely no run for 81eed80. Push didn't trigger CI. Check PR #16 state.


gh pr view 16 --json number,state,isDraft,headRefName,baseRefName,headRefOid,title,mergeable 2>&1

PR #16 state


M11 shell-substrate extensions (W1: per-capability act-gates, REQ-CONSENT-3)


PR open, head correct, draft (draft didn't block prior runs). Check ci.yml triggers/paths filter.


CI triggers + paths


name: ci

on:
  push:
    branches: [main, dev-freeform]
  pull_request:

# Cancel superseded runs on the same ref.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ---- Rust build + test + lint (cross-platform: exercises both the
  # cfg(windows) OpenProcess path and the cfg(unix) libc path in proc.rs) ----
  #
  # CI-SELFHOST-PLAN.md: fully self-hosted (2026-06-03) — hfenduleam carries
  # the Windows leg, kitsubito the Linux leg + traceability (replaced
  # gravity-linux 2026-06-07; same Ubuntu 22.04 / glibc 2.35 baseline). Free
  # minutes;
  # persistent cargo/target = warm builds (the multi-exe iroh link cost is
  # paid once per machine); hung tests debuggable on-box. Both boxes own
  # their rust toolchains (rustup-managed); no toolchain action, no
  # rust-cache (the persistent target dir IS the cache).
  test:
    strategy:
      fail-fast: false
      matrix:
        runner: [[self-hosted, Linux, kitsubito], [self-hosted, Windows, hfenduleam]]
    runs-on: ${{ matrix.runner }}
    # A wedged test must not block the single self-hosted runner slot (the
    # 2026-06-03 handoff.rs ConPTY stall burned 22 hosted minutes unbounded).
    # 50 (was 25): the slow Linux runner (kitsubito) needs more than 25min for a
    # full build + the whole suite (the int tests carry fixed wall-clock sleeps);
    # a version-bump full rebuild pushed v0.7.0 over. The real fix is the build
    # speedup (mold/nextest — ci-kitsubito-speedup-plan); this keeps the wedge
    # guard generous, not unbounded.
    timeout-minutes: 50
    steps:
      - uses: actions/checkout@v5


`pull_request` (default synchronize) + no paths filter → should've fired. Maybe queued behind doyle's runs, or branch filter hides it. List all recent runs by head sha.


echo "=== all recent runs (any branch/event) ==="; gh run list --limit 8 --json databaseId,status,conclusion,headSha,event,workflowName,createdAt 2>&1
echo; echo "=== runs for sha 81eed80 ==="; gh api "repos/SaberMage/spt-core/actions/runs?head_sha=81eed80db7671ce2812159311934c6c3abd74660" --jq '.workflow_runs[] | {id,status,conclusion,event,name}' 2>&1

All recent runs + runs for exact sha


Confirmed: synchronize event never fired — zero runs for 81eed80 (GitHub webhook miss; `mergeable:UNKNOWN`). ci.yml has no `workflow_dispatch`, so close+reopen PR #16 re-triggers CI while keeping HEAD at the pre-cleared 81eed80.


gh pr close 16 && sleep 2 && gh pr reopen 16 && echo "--- reopened ---" && sleep 6 && gh run list --limit 3 --json databaseId,status,headSha,event,workflowName 2>&1

Close+reopen PR #16 to re-trigger CI


✓ Closed pull request SaberMage/spt-core#16 (M11 shell-substrate extensions (W1: per-capability act-gates, REQ-CONSENT-3))
✓ Reopened pull request SaberMage/spt-core#16 (M11 shell-substrate extensions (W1: per-capability act-gates, REQ-CONSENT-3))
--- reopened ---
[{"databaseId":27597608612,"event":"workflow_dispatch","headSha":"94bda1e4fcb6b33c7103dc1a6895a17e003438a1","status":"completed","workflowName":"docs-publish"},{"databaseId":27597603701,"event":"push","headSha":"94bda1e4fcb6b33c7103dc1a6895a17e003438a1","status":"in_progress","workflowName":"ci"},{"databaseId":27596391539,"event":"push","headSha":"3b458b1c850bd7c372c4d4d8a3a967a4365408d4","status":"completed","workflowName":"ci"}]


Reopen registered. Check for the 81eed80 run via sha API (lag-tolerant).


gh api "repos/SaberMage/spt-core/actions/runs?head_sha=81eed80db7671ce2812159311934c6c3abd74660" --jq '.workflow_runs[] | "\(.id) \(.status) \(.conclusion) \(.event) \(.name)"' 2>&1

Runs for 81eed80 after reopen


(Bash completed with no output)


gh run list --branch m11-shell-substrate --limit 3 --json databaseId,status,headSha,event 2>&1; echo "---sha api---"; gh api "repos/SaberMage/spt-core/actions/runs?head_sha=81eed80db7671ce2812159311934c6c3abd74660&event=pull_request" --jq '.total_count' 2>&1

Re-check branch runs + pull_request count for sha


Close+reopen also didn't trigger. Reliable remedy = fresh commit (new synchronize). Empty commit keeps the tree identical (doyle's code-read holds), new HEAD. Push it.


git commit --allow-empty -q -m "ci: nudge CI for W4 (synchronize webhook missed on 81eed80; tree identical)

Co-authored by: todlando" && git push origin m11-shell-substrate 2>&1 | tail -3 && echo "NEWHEAD=$(git rev-parse --short HEAD)"

Empty commit + push to re-trigger CI


To https://github.com/SaberMage/spt-core.git
   81eed80..c4dbeb4  m11-shell-substrate -> m11-shell-substrate
NEWHEAD=c4dbeb4


gh api "repos/SaberMage/spt-core/actions/runs?head_sha=$(git rev-parse HEAD)" --jq '.workflow_runs[] | "\(.id) \(.status) \(.conclusion) \(.event) \(.name)"' 2>&1; echo "(empty = lag, will re-check)"

Check run for c4dbeb4


(empty = lag, will re-check)


Launch fixed background watcher (gh/jq, no python) for HEAD c4dbeb4 — wait until a run appears AND completes.


sha=$(git rev-parse HEAD)
seen=0
for i in $(seq 1 100); do
  line=$(gh api "repos/SaberMage/spt-core/actions/runs?head_sha=$sha" \
         --jq '.workflow_runs[] | select(.name=="ci") | "\(.id) \(.status) \(.conclusion)"' 2>/dev/null)
  if [ -n "$line" ]; then
    seen=1
    st=$(echo "$line" | awk '{print $2}')
    cc=$(echo "$line" | awk '{print $3}')
    if [ "$st" = "completed" ]; then echo "CI DONE: $line"; exit 0; fi
    echo "poll $i: ci $st"
  else
    echo "poll $i: no ci run yet for $sha"
  fi
  sleep 20
done
echo "TIMEOUT (seen=$seen) for $sha"

Watch CI for new HEAD until complete


Command running in background with ID: bw2ttqkzd. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\30df62c3-0b22-4b14-88de-fef422a5554f\tasks\bw2ttqkzd.output. You will be notified when it completes. To check interim output, use Read on that file path.


Watcher `bw2ttqkzd` armed. Notify doyle of the webhook miss + new HEAD.


$OWL send doyle todlando <<'EOF'
CI HEADS-UP: GitHub dropped the pull_request synchronize webhook for 81eed80 — after 30min ZERO ci runs created (confirmed via head_sha API, not a watcher bug; close+reopen PR also didn't fire). Remedied with an EMPTY commit c4dbeb4 (tree IDENTICAL to 81eed80 — your code-read PASS still holds verbatim, the diff is unchanged) to force a fresh synchronize. New HEAD = c4dbeb4. Watcher re-armed. Will ping you on green for the W4 GATE PASS stamp. If attach_survives times out = FLAKE-LEDGER #7, I rerun --failed.
EOF
echo "rc=$?"

Tell doyle about webhook miss + new head


➜ SENT:doyle
rc=0


Notified doyle. New HEAD `c4dbeb4` (empty commit, tree identical to pre-cleared `81eed80`). Watcher `bw2ttqkzd` waiting for CI to appear + complete — will report on green (or rerun --failed if attach_survives flakes). Holding.


<task-notification>
<task-id>be1did0ug</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/2" id="77db76dc" type="msg" from="doyle">Ack — verified myself: git diff 81eed80..c4dbeb4 is EMPTY, both trees = c8f4ad66… IDENTICAL. So c4dbeb4 is a pure no-op commit; my W4 code-read PASS holds verbatim, no re-read needed. Empty-commit-to-force-synchronize is the right fix for a dropped webhook (same class as the v0.7.1 transient-rerun handling).

</EVENT-PART>
<EVENT-PART seq="2/2" id="77db76dc">W4 is fully pre-cleared: code-read PASS + tree-identity confirmed. The ONLY remaining gate input is c4dbeb4's CI going green on both runners. Ping me on green and I stamp the W4 GATE PASS same-turn. If attach_survives times out = FLAKE-LEDGER #7, rerun --failed (W4-independent). Standing by.</EVENT-PART></event>
</task-notification>


doyle confirmed tree-identity (both = c8f4ad66), W4 fully pre-cleared. Only c4dbeb4 CI green remains. Watcher `bw2ttqkzd` on it. Holding for CI.


<task-notification>
<task-id>bw2ttqkzd</task-id>
<tool-use-id>toolu_012rr9Su3znDUUXUcuv1HDP1</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\30df62c3-0b22-4b14-88de-fef422a5554f\tasks\bw2ttqkzd.output</output-file>
<status>completed</status>
<summary>Background command "Watch CI for new HEAD until complete" completed (exit code 0)</summary>
</task-notification>


Watcher done. Read result.


Tail watcher output


poll 97: no ci run yet for c4dbeb45ee8855475a781cca91161867cf81860f
poll 98: no ci run yet for c4dbeb45ee8855475a781cca91161867cf81860f
poll 99: no ci run yet for c4dbeb45ee8855475a781cca91161867cf81860f
poll 100: no ci run yet for c4dbeb45ee8855475a781cca91161867cf81860f
TIMEOUT (seen=0) for c4dbeb45ee8855475a781cca91161867cf81860f


Empty commit also didn't trigger — pull_request CI hasn't fired for ANY commit since W3's 221c0e7. Broader look: all repo runs since my push, plus PR #16's check-runs.


echo "=== all ci runs last hour ==="; gh run list --workflow ci --limit 8 --json databaseId,status,conclusion,headSha,event,createdAt --jq '.[] | "\(.createdAt) \(.databaseId) \(.event) \(.status)/\(.conclusion) \(.headSha[0:7])"' 2>&1
echo; echo "=== PR16 statusCheckRollup ==="; gh pr view 16 --json statusCheckRollup,headRefOid --jq '{head:.headRefOid, checks:(.statusCheckRollup|length)}' 2>&1
echo; echo "=== runner status ==="; gh api repos/SaberMage/spt-core/actions/runners --jq '.runners[] | "\(.name) \(.status) busy=\(.busy)"' 2>&1

All ci runs + PR16 checks + runner status


=== all ci runs last hour ===
2026-06-16T06:21:46Z 27598414868 push completed/success 22a9c58
2026-06-16T06:02:32Z 27597603701 push completed/success 94bda1e
2026-06-16T05:31:24Z 27596391539 push completed/success 3b458b1
2026-06-16T04:59:36Z 27595251419 push completed/success ede1932
2026-06-16T04:57:24Z 27595180782 pull_request completed/success 221c0e7
2026-06-16T04:46:59Z 27594825095 push completed/success 9375440
2026-06-16T04:20:00Z 27593890870 push completed/success 44ed949
2026-06-16T02:49:26Z 27590716942 pull_request completed/success 9d5fb63

=== PR16 statusCheckRollup ===
{"checks":0,"head":"c4dbeb45ee8855475a781cca91161867cf81860f"}

=== runner status ===
hfenduleam online busy=false
kitsubito online busy=false


Runners free, PR #16 has **0 checks** at c4dbeb4. `push` CI fires (main), but `pull_request` hasn't since W3. Likely cause: main advanced (doyle merged work) → PR unmergeable → GitHub can't compute the merge ref → no pull_request runs. Check mergeability + divergence.


git fetch origin main -q 2>&1
echo "=== mergeable ==="; gh pr view 16 --json mergeable,mergeStateStatus 2>&1
echo "=== branch vs main ==="; echo "behind main: $(git rev-list --count HEAD..origin/main)  ahead: $(git rev-list --count origin/main..HEAD)"
echo "=== merge conflict dry-run ==="; git merge-tree $(git merge-base HEAD origin/main) HEAD origin/main 2>&1 | grep -i "changed in both\|CONFLICT\|<<<<<<<" | head -10; echo "(empty grep = no textual conflict markers)"

Fetch main, check mergeability + conflicts


=== mergeable ===
{"mergeStateStatus":"UNKNOWN","mergeable":"UNKNOWN"}
=== branch vs main ===
behind main: 15  ahead: 30
=== merge conflict dry-run ===
changed in both
changed in both
changed in both
changed in both
changed in both
+<<<<<<< .our
changed in both
(empty grep = no textual conflict markers)


Real root cause found — **not** a webhook drop. PR #16 is **15 behind / 30 ahead** of main with **real conflicts** → unmergeable → GitHub can't compute the merge ref → it won't create `pull_request` runs at all. main advanced under the PR (doyle's release/docs merges). Get the conflicting file list before involving doyle.


base=$(git merge-base HEAD origin/main)
echo "=== files changed on BOTH sides (conflict candidates) ==="
comm -12 <(git diff --name-only $base HEAD | sort) <(git diff --name-only $base origin/main | sort)
echo; echo "=== main's 15 commits since base ==="
git log --oneline $base..origin/main | head -20

List files changed on both sides + main's new commits


=== files changed on BOTH sides (conflict candidates) ===
CONTEXT.md
Cargo.lock
crates/spt/src/cli.rs
docs-site/src/cli/reference.md
docs/FLAKE-LEDGER.md
traceable-reqs.toml

=== main's 15 commits since base ===
22a9c58 release: v0.7.3 — spt adapter add --release (GitHub release-archive acquisition)
94bda1e feat(adapter): spt adapter add --release <user/repo> — release-archive acquisition (REQ-INSTALL-9)
3b458b1 docs: document the --github distribution-repo shape (F-005 follow-up)
ede1932 docs: frame adapter guidance positively (state what to do)
9375440 docs: document the post-install adapter-activation step (F-005)
44ed949 docs: add adapter patterns & pitfalls field guide
b5f61ba docs(flake-ledger): log #5 attach re-serve gap + #6 stream backpressure (v0.7.2 PR #14 CI)
1582e32 release: v0.7.2 — digest-proof {session_id} key-fill (F-004)
63e837f Merge pull request #14 from SaberMage/fix/digest-proof-session-id-keys
b4275c3 Merge branch 'main' into fix/digest-proof-session-id-keys
1fc700b Merge pull request #15 from SaberMage/docs/profile-qualifier-at-bind
f1620df docs(plan): M11-W0 — daemon-hosted live-agent lifecycle completion plan
07a4e39 docs(harness-contract): profile qualifier is runtime selection + correct psyche_init keys
5bb675a fix(digest): digest-proof fills {id}+{session_id} matching runtime (F-004)
1ce9e98 docs(api): state bind's intrinsic-auth trust model for spt-hosted sessions


Diagnosis complete: 6 conflicting files, mostly additive (FLAKE-LEDGER/traceable/CONTEXT both append; cli.rs both add distinct commands; Cargo.lock + reference.md are derived). main advanced to v0.7.3 under the PR. This is the standard "branch fell behind during release churn" — resolvable. Tell doyle (he's blocked waiting on an impossible green), then execute the merge.


$OWL send doyle todlando <<'EOF'
CI ROOT CAUSE — NOT a webhook drop after all. PR #16 is UNMERGEABLE vs main, so GitHub refuses to create ANY pull_request run (can't compute refs/pull/16/merge). That's why 81eed80 AND c4dbeb4 got zero runs and PR16 shows 0 checks. Runners are online+idle; this is a mergeability block, not infra.

Branch is 15 behind / 30 ahead of main — your release churn advanced main under the PR (v0.7.2, v0.7.3, adapter add --release REQ-INSTALL-9, F-005 docs). merge-tree shows real conflicts in 6 files:
  CONTEXT.md, traceable-reqs.toml, docs/FLAKE-LEDGER.md  (all ADDITIVE — both sides append distinct entries)
  crates/spt/src/cli.rs  (main added `adapter add --release`; M11 added shell verbs — distinct subcommands)
  Cargo.lock, docs-site/src/cli/reference.md  (DERIVED — regen, don't hand-merge)

PLAN (executing now): merge origin/main into the branch, resolve the 3 additive files as UNION, take main's cli.rs adapter block + keep M11 shell verbs, regen Cargo.lock + `xtask gen` for reference.md, full pre-flight (clippy/traceable/xtask), push. That makes the PR mergeable -> CI fires -> green -> you gate. Tree changes (gains v0.7.3 etc.), so you'll re-verify the merge HEAD, but the W4 deltas are unchanged. If any conflict is ambiguous (esp. CONTEXT/traceable) I escalate that file to you rather than guess. Proceeding unless you say hold.
EOF
echo "rc=$?"; echo "=== starting merge ==="
git merge origin/main --no-edit 2>&1 | tail -25

Notify doyle + start merge of main


➜ SENT:doyle
rc=0
=== starting merge ===
Auto-merging CONTEXT.md
Auto-merging Cargo.lock
Auto-merging crates/spt/src/cli.rs
Auto-merging docs-site/src/cli/reference.md
Auto-merging docs/FLAKE-LEDGER.md
CONFLICT (content): Merge conflict in docs/FLAKE-LEDGER.md
Auto-merging traceable-reqs.toml
Automatic merge failed; fix conflicts and then commit the result.


Only **one** real conflict: `docs/FLAKE-LEDGER.md` (rest auto-merged). Resolve it (union of both append-blocks).


Locate conflict markers


14:<<<<<<< HEAD
16:=======
19:>>>>>>> origin/main


1	# Flake ledger
2
3	Tests that have failed nondeterministically, with mechanism notes and hardening
4	status. Discipline: **any flake gets an entry, not a shrug** — a second
5	occurrence upgrades the entry from "observed" to "harden it." Rig flakes from
6	the two-host ladder (docs/TWO-HOST-RUNBOOK.md) land here too.
7
8	| # | test | occurrences | mechanism | status |
9	|---|------|-------------|-----------|--------|
10	| 1 | `spt-daemon brain_restart_survives_gaplessly` | 1× hosted Windows (2026-06-03, hung) | ConPTY stall under hosted-runner load | job `timeout-minutes` bounds it; not seen self-hosted |
11	| 2 | `spt-term digest_parses_a_real_pty_stream` | 3× gravity (2026-06-03; 2026-06-04 runs 26957386850, 26966738231) | sprint-collapse assert (`digest.rs`) — PTY input echo and the child's write are **concurrent writers** into the master-read stream; back-to-back `send_line`s let a later line's echo interleave mid-`Write(…)`, breaking the tool regex for that turn | HARDENED (2026-06-04): sends serialized — each line pumped back (adaptive echo-copy count, 2s/line bound) before the next, so no concurrent writer races an earlier line; failure asserts now print the raw transcript |
12	| 3 | `spt-daemon sync.rs torn_pull` + `concurrent_writes` | 1× (2026-06-03) | `wait_for_stream` deadline too tight under load | HARDENED: `wait_for_stream_except` 2s→10s; no recurrence |
13	| 4 | `spt-live context::write_context_suppresses_stale_llm` | 1× gravity (2026-06-04 run 26967808321) | test calls `write_context` (reads SPT_HOME node identity + epoch stamp) WITHOUT the `with_home` lock — a parallel `with_home` test's env swap + temp-dir teardown races the stamp's epoch write into a deleted home (`NotFound`) | HARDENED (2026-06-04): test wrapped in `with_home` — serializes on the home lock like every other SPT_HOME-touching test |
14	<<<<<<< HEAD
15	| 7 | `spt-daemon::attach attach_survives_target_brain_restart_exactly_once` | 1× kitsubito (2026-06-16 run 27595180782 attempt 1, M11-W3) | **TIMEOUT, not an assertion fail** — SLOW>60>120>180>TERMINATING, nextest TIMEOUT at the 240s cap (`attach.rs:600`). A heavy multi-process brain-restart-exactly-once test on the slow shared kitsubito box under concurrent load (n1-gate on the same runner + a parallel `ci.yml` docs-publish run firing simultaneously). W3-INDEPENDENT: Windows green end-to-end; ALL tunnel tests passed fast on Linux too (tunnel_e2e 0.941s, loopback_tunnel_backpressures_without_loss 1.091s) — a logic bug fails both runners deterministically, this timed out on one under load | observed; cleared by `gh run rerun --failed` (builds reused, no re-tag). Harden if it recurs — candidate: raise this test's per-test nextest timeout, or serialize it off the concurrent n1-gate slot |
16	=======
17	| 5 | `spt-daemon attach.rs:600` re-serve (`output gap: got seq 1 want 0`) | 1× kitsubito Linux (2026-06-15 run 27545857429, PR #14 stale-base CI) | re-serve replays the buffered output stream on re-attach; under load the new subscriber observed `seq 1` before `seq 0` — an ordering/timing gap in the re-serve sequence assert, not a content error. Subsystem disjoint from the triggering change (PR #14 = digest-proof CLI key-fill, cannot touch attach sequencing) | OBSERVED 1×: did NOT reproduce on the clean-base rerun (`gh pr update-branch` onto post-#15 main → green both runners). Not hardened; a 2nd occurrence upgrades to harden |
18	| 6 | `spt-term tests/stream.rs:49 bounded_backpressure_stalls_an_unconsumed_reader` | 1× hfenduleam Windows (2026-06-15 run 27545857429, PR #14 stale-base CI) | asserts an unconsumed bounded stream stalls the writer-side reader; got `16 -> 82` (reader advanced past the bound) — a timing-sensitive backpressure assert racing the bound check under hosted-runner load. Subsystem disjoint from PR #14 (digest-proof CLI) | OBSERVED 1×: did NOT reproduce on the clean-base rerun (green both runners). Not hardened; a 2nd occurrence upgrades to harden |
19	>>>>>>> origin/main
20

# Flake ledger

Tests that have failed nondeterministically, with mechanism notes and hardening
status. Discipline: **any flake gets an entry, not a shrug** — a second
occurrence upgrades the entry from "observed" to "harden it." Rig flakes from
the two-host ladder (docs/TWO-HOST-RUNBOOK.md) land here too.

| # | test | occurrences | mechanism | status |
|---|------|-------------|-----------|--------|
| 1 | `spt-daemon brain_restart_survives_gaplessly` | 1× hosted Windows (2026-06-03, hung) | ConPTY stall under hosted-runner load | job `timeout-minutes` bounds it; not seen self-hosted |
| 2 | `spt-term digest_parses_a_real_pty_stream` | 3× gravity (2026-06-03; 2026-06-04 runs 26957386850, 26966738231) | sprint-collapse assert (`digest.rs`) — PTY input echo and the child's write are **concurrent writers** into the master-read stream; back-to-back `send_line`s let a later line's echo interleave mid-`Write(…)`, breaking the tool regex for that turn | HARDENED (2026-06-04): sends serialized — each line pumped back (adaptive echo-copy count, 2s/line bound) before the next, so no concurrent writer races an earlier line; failure asserts now print the raw transcript |
| 3 | `spt-daemon sync.rs torn_pull` + `concurrent_writes` | 1× (2026-06-03) | `wait_for_stream` deadline too tight under load | HARDENED: `wait_for_stream_except` 2s→10s; no recurrence |
| 4 | `spt-live context::write_context_suppresses_stale_llm` | 1× gravity (2026-06-04 run 26967808321) | test calls `write_context` (reads SPT_HOME node identity + epoch stamp) WITHOUT the `with_home` lock — a parallel `with_home` test's env swap + temp-dir teardown races the stamp's epoch write into a deleted home (`NotFound`) | HARDENED (2026-06-04): test wrapped in `with_home` — serializes on the home lock like every other SPT_HOME-touching test |
<<<<<<< HEAD
| 7 | `spt-daemon::attach attach_survives_target_brain_restart_exactly_once` | 1× kitsubito (2026-06-16 run 27595180782 attempt 1, M11-W3) | **TIMEOUT, not an assertion fail** — SLOW>60>120>180>TERMINATING, nextest TIMEOUT at the 240s cap (`attach.rs:600`). A heavy multi-process brain-restart-exactly-once test on the slow shared kitsubito box under concurrent load (n1-gate on the same runner + a parallel `ci.yml` docs-publish run firing simultaneously). W3-INDEPENDENT: Windows green end-to-end; ALL tunnel tests passed fast on Linux too (tunnel_e2e 0.941s, loopback_tunnel_backpressures_without_loss 1.091s) — a logic bug fails both runners deterministically, this timed out on one under load | observed; cleared by `gh run rerun --failed` (builds reused, no re-tag). Harden if it recurs — candidate: raise this test's per-test nextest timeout, or serialize it off the concurrent n1-gate slot |
=======
| 5 | `spt-daemon attach.rs:600` re-serve (`output gap: got seq 1 want 0`) | 1× kitsubito Linux (2026-06-15 run 27545857429, PR #14 stale-base CI) | re-serve replays the buffered output stream on re-attach; under load the new subscriber observed `seq 1` before `seq 0` — an ordering/timing gap in the re-serve sequence assert, not a content error. Subsystem disjoint from the triggering change (PR #14 = digest-proof CLI key-fill, cannot touch attach sequencing) | OBSERVED 1×: did NOT reproduce on the clean-base rerun (`gh pr update-branch` onto post-#15 main → green both runners). Not hardened; a 2nd occurrence upgrades to harden |
| 6 | `spt-term tests/stream.rs:49 bounded_backpressure_stalls_an_unconsumed_reader` | 1× hfenduleam Windows (2026-06-15 run 27545857429, PR #14 stale-base CI) | asserts an unconsumed bounded stream stalls the writer-side reader; got `16 -> 82` (reader advanced past the bound) — a timing-sensitive backpressure assert racing the bound check under hosted-runner load. Subsystem disjoint from PR #14 (digest-proof CLI) | OBSERVED 1×: did NOT reproduce on the clean-base rerun (green both runners). Not hardened; a 2nd occurrence upgrades to harden |
>>>>>>> origin/main


Union resolution — order #5, #6 (main) then #7 (mine):