/// Epoch milliseconds (the notif surfacing clock).
fn now_ms() -> u64 {
    std::time::SystemTime::now()

/// The release-asset name for THIS platform — matches the release-publish
/// assets on spt-releases. Pure for tests.
fn platform_release_asset() -> &'static str {
    if cfg!(windows) {
        "spt-x86_64-windows.exe"
    } else {
        "spt-x86_64-linux"
    }
}

/// The two origin URLs (artifact, then its `.release.json` metadata) for a
/// release on the GitHub origin. `tag = None` ⇒ the `latest` release. Pure.
fn origin_release_urls(repo: &str, asset: &str, tag: Option<&str>) -> (String, String) {
    let base = match tag {
        Some(t) => format!("https://github.com/{repo}/releases/download/{t}"),
        None => format!("https://github.com/{repo}/releases/latest/download"),
    };
    (
        format!("{base}/{asset}"),
        format!("{base}/{asset}.release.json"),
    )
}

fn http_get_text(client: &reqwest::blocking::Client, url: &str) -> Result<String, String> {
    let resp = client.get(url).send().map_err(|e| e.to_string())?;
    if !resp.status().is_success() {
        return Err(format!("HTTP {}", resp.status()));
    }
    resp.text().map_err(|e| e.to_string())
}

fn http_get_bytes(client: &reqwest::blocking::Client, url: &str) -> Result<Vec<u8>, String> {
    let resp = client.get(url).send().map_err(|e| e.to_string())?;
    if !resp.status().is_success() {
        return Err(format!("HTTP {}", resp.status()));
    }
    resp.bytes().map(|b| b.to_vec()).map_err(|e| e.to_string())
}

/// `spt update fetch` (REQ-UPD-7) — pull the signed release from the GitHub
/// origin and stage it through the SAME verify gate the P2P pull uses
/// (`plan_verified`: two-key signature + channel + monotonic rollback floor +
/// SHA-256), after which the existing `spt update apply` flow takes over. Closes
/// the peer-only discovery gap: 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.
// [impl->REQ-UPD-7]
fn cmd_update_fetch(channel: Option<String>, tag: Option<String>) -> i32 {
    let repo =
        std::env::var("SPT_INSTALL_REPO").unwrap_or_else(|_| "SaberMage/spt-releases".to_string());
    let asset = platform_release_asset();
    let (art_url, meta_url) = origin_release_urls(&repo, asset, tag.as_deref());

    // Verify policy: the two-key anchor + the node's release-keys overlay; the
    // rollback floor is the staged version on the pinned channel (mirrors the
    // P2P path, peerloop.rs). `--channel` overrides the pin to deliberately
    // accept another channel's release.
    let cache = spt_daemon::ReleaseCache::open(&perch::spt_home().join("releases"));
    let keys = perch::identity_dir().join("release-keys.json");
    let mut policy = spt_daemon::release::VerifyPolicy::load_from(&keys, 0, now_ms());
    let pinned = policy.pinned_channel.clone();
    policy.current_version = if cache.staged_channel().as_deref() == Some(pinned.as_str()) {
        cache.staged_version().unwrap_or(0)
    } else {
        0
    };
    if let Some(c) = channel {
        policy.pinned_channel = c;
    }
    if policy.trusted_keys.is_empty() {
        eprintln!("UPDATE_FETCH_FAIL: no trusted release keys");
        return 1;
    }

    eprintln!("Fetching {asset} from {repo}…");
    let client = match reqwest::blocking::Client::builder()
        .user_agent(concat!("spt/", env!("CARGO_PKG_VERSION")))
        .build()
    {
        Ok(c) => c,
        Err(e) => {
            eprintln!("UPDATE_FETCH_FAIL: http client: {e}");
            return 1;
        }
    };
    let meta_text = match http_get_text(&client, &meta_url) {
        Ok(t) => t,
        Err(e) => {
            eprintln!("UPDATE_FETCH_FAIL: GET {meta_url}: {e}");
            return 1;
        }
    };
    let signed: spt_daemon::release::SignedRelease = match serde_json::from_str(&meta_text) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("UPDATE_FETCH_FAIL: bad release.json: {e}");
            return 1;
        }
    };
    let artifact = match http_get_bytes(&client, &art_url) {
        Ok(b) => b,
        Err(e) => {
            eprintln!("UPDATE_FETCH_FAIL: GET {art_url}: {e}");
            return 1;
        }
    };

    // The SAME gate the daemon's peer pull runs — no inherited trust from the
    // GitHub transport.
    match spt_daemon::update::plan_verified(
        &spt_daemon::update::BrokerAbi::current(),
        &signed,
        &artifact,
        &policy,
    ) {
        Ok(_plan) => {
            if let Err(e) = cache.stage(&signed, &artifact) {
                eprintln!("UPDATE_FETCH_FAIL: stage: {e}");
                return 1;
            }
            let v = cache
                .staged_version()
                .map(|v| v.to_string())
                .unwrap_or_else(|| "?".to_string());
            println!("UPDATE_STAGED:{v} (from {repo}) — run `spt update apply` to apply");
            0
        }
        Err(reason) => {
            eprintln!("UPDATE_FETCH_REJECTED:{reason:?}");
            1
        }
    }
}

/// Epoch milliseconds (the notif surfacing clock).
fn now_ms() -> u64 {
    std::time::SystemTime::now()


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

/// The release-asset name for THIS platform — matches the release-publish
/// assets on spt-releases. Pure for tests.
fn platform_release_asset() -> &'static str {
    if cfg!(windows) {
        "spt-x86_64-windows.exe"
    } else {
        "spt-x86_64-linux"
    }
}

/// The two origin URLs (artifact, then its `.release.json` metadata) for a
/// release on the GitHub origin. `tag = None` ⇒ the `latest` release. Pure.
fn origin_release_urls(repo: &str, asset: &str, tag: Option<&str>) -> (String, String) {
    let base = match tag {
        Some(t) => format!("https://github.com/{repo}/releases/download/{t}"),
        None => format!("https://github.com/{repo}/releases/latest/download"),
    };
    (
        format!("{base}/{asset}"),
        format!("{base}/{asset}.release.json"),
    )
}

fn http_get_text(client: &reqwest::blocking::Client, url: &str) -> Result<String, String> {
    let resp = client.get(url).send().map_err(|e| e.to_string())?;
    if !resp.status().is_success() {
        return Err(format!("HTTP {}", resp.status()));
    }
    resp.text().map_err(|e| e.to_string())
}

fn http_get_bytes(client: &reqwest::blocking::Client, url: &str) -> Result<Vec<u8>, String> {
    let resp = client.get(url).send().map_err(|e| e.to_string())?;
    if !resp.status().is_success() {
        return Err(format!("HTTP {}", resp.status()));
    }
    resp.bytes().map(|b| b.to_vec()).map_err(|e| e.to_string())
}

/// `spt update fetch` (REQ-UPD-7) — pull the signed release from the GitHub
/// origin and stage it through the SAME verify gate the P2P pull uses
/// (`plan_verified`: two-key signature + channel + monotonic rollback floor +
/// SHA-256), after which the existing `spt update apply` flow takes over. Closes
/// the peer-only discovery gap: 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.
// [impl->REQ-UPD-7]
fn cmd_update_fetch(channel: Option<String>, tag: Option<String>) -> i32 {
    let repo =
        std::env::var("SPT_INSTALL_REPO").unwrap_or_else(|_| "SaberMage/spt-releases".to_string());
    let asset = platform_release_asset();
    let (art_url, meta_url) = origin_release_urls(&repo, asset, tag.as_deref());

    // Verify policy: the two-key anchor + the node's release-keys overlay; the
    // rollback floor is the staged version on the pinned channel (mirrors the
    // P2P path, peerloop.rs). `--channel` overrides the pin to deliberately
    // accept another channel's release.
    let cache = spt_daemon::ReleaseCache::open(&perch::spt_home().join("releases"));
    let keys = perch::identity_dir().join("release-keys.json");
    let mut policy = spt_daemon::release::VerifyPolicy::load_from(&keys, 0, now_ms());
    let pinned = policy.pinned_channel.clone();
    policy.current_version = if cache.staged_channel().as_deref() == Some(pinned.as_str()) {
        cache.staged_version().unwrap_or(0)
    } else {
        0
    };
    if let Some(c) = channel {
        policy.pinned_channel = c;
    }
    if policy.trusted_keys.is_empty() {
        eprintln!("UPDATE_FETCH_FAIL: no trusted release keys");
        return 1;
    }

    eprintln!("Fetching {asset} from {repo}…");
    let client = match reqwest::blocking::Client::builder()
        .user_agent(concat!("spt/", env!("CARGO_PKG_VERSION")))
        .build()
    {
        Ok(c) => c,
        Err(e) => {
            eprintln!("UPDATE_FETCH_FAIL: http client: {e}");
            return 1;
        }
    };
    let meta_text = match http_get_text(&client, &meta_url) {
        Ok(t) => t,
        Err(e) => {
            eprintln!("UPDATE_FETCH_FAIL: GET {meta_url}: {e}");
            return 1;
        }
    };
    let signed: spt_daemon::release::SignedRelease = match serde_json::from_str(&meta_text) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("UPDATE_FETCH_FAIL: bad release.json: {e}");
            return 1;
        }
    };
    let artifact = match http_get_bytes(&client, &art_url) {
        Ok(b) => b,
        Err(e) => {
            eprintln!("UPDATE_FETCH_FAIL: GET {art_url}: {e}");
            return 1;
        }
    };

    // The SAME gate the daemon's peer pull runs — no inherited trust from the
    // GitHub transport.
    match spt_daemon::update::plan_verified(
        &spt_daemon::update::BrokerAbi::current(),
        &signed,
        &artifact,
        &policy,
    ) {
        Ok(_plan) => {
            if let Err(e) = cache.stage(&signed, &artifact) {
                eprintln!("UPDATE_FETCH_FAIL: stage: {e}");
                return 1;
            }
            let v = cache
                .staged_version()
                .map(|v| v.to_string())
                .unwrap_or_else(|| "?".to_string());
            println!("UPDATE_STAGED:{v} (from {repo}) — run `spt update apply` to apply");
            0
        }
        Err(reason) => {
            eprintln!("UPDATE_FETCH_REJECTED:{reason:?}");
            1
        }
    }
}

/// Epoch milliseconds (the notif surfacing clock).
fn now_ms() -> u64 {
    std::time::SystemTime::now()

+/// The release-asset name for THIS platform — matches the release-publish

+/// assets on spt-releases. Pure for tests.

+fn platform_release_asset() -> &'static str {

+/// The two origin URLs (artifact, then its `.release.json` metadata) for a

+/// release on the GitHub origin. `tag = None` ⇒ the `latest` release. Pure.

+fn origin_release_urls(repo: &str, asset: &str, tag: Option<&str>) -> (String, String) {

+        Some(t) => format!("https://github.com/{repo}/releases/download/{t}"),

+        None => format!("https://github.com/{repo}/releases/latest/download"),

+        format!("{base}/{asset}.release.json"),

+fn http_get_text(client: &reqwest::blocking::Client, url: &str) -> Result<String, String> {

+    let resp = client.get(url).send().map_err(|e| e.to_string())?;

+        return Err(format!("HTTP {}", resp.status()));

+    resp.text().map_err(|e| e.to_string())

+fn http_get_bytes(client: &reqwest::blocking::Client, url: &str) -> Result<Vec<u8>, String> {

+    resp.bytes().map(|b| b.to_vec()).map_err(|e| e.to_string())

+/// `spt update fetch` (REQ-UPD-7) — pull the signed release from the GitHub

+/// origin and stage it through the SAME verify gate the P2P pull uses

+/// (`plan_verified`: two-key signature + channel + monotonic rollback floor +

+/// SHA-256), after which the existing `spt update apply` flow takes over. Closes

+/// the peer-only discovery gap: a first-in-fleet / isolated node can update with

+/// no peer to pull from. The signed-release anchor keeps the GitHub transport

+fn cmd_update_fetch(channel: Option<String>, tag: Option<String>) -> i32 {

+        std::env::var("SPT_INSTALL_REPO").unwrap_or_else(|_| "SaberMage/spt-releases".to_string());

+    let asset = platform_release_asset();

+    let (art_url, meta_url) = origin_release_urls(&repo, asset, tag.as_deref());

+    // Verify policy: the two-key anchor + the node's release-keys overlay; the

+    // rollback floor is the staged version on the pinned channel (mirrors the

+    // P2P path, peerloop.rs). `--channel` overrides the pin to deliberately

+    // accept another channel's release.

+    let cache = spt_daemon::ReleaseCache::open(&perch::spt_home().join("releases"));

+    let keys = perch::identity_dir().join("release-keys.json");

+    let mut policy = spt_daemon::release::VerifyPolicy::load_from(&keys, 0, now_ms());

+    let pinned = policy.pinned_channel.clone();

+    policy.current_version = if cache.staged_channel().as_deref() == Some(pinned.as_str()) {

+        cache.staged_version().unwrap_or(0)

+    if policy.trusted_keys.is_empty() {

+        eprintln!("UPDATE_FETCH_FAIL: no trusted release keys");

+    eprintln!("Fetching {asset} from {repo}…");

+    let client = match reqwest::blocking::Client::builder()

+        .user_agent(concat!("spt/", env!("CARGO_PKG_VERSION")))

+            eprintln!("UPDATE_FETCH_FAIL: http client: {e}");

+    let meta_text = match http_get_text(&client, &meta_url) {

+            eprintln!("UPDATE_FETCH_FAIL: GET {meta_url}: {e}");

+    let signed: spt_daemon::release::SignedRelease = match serde_json::from_str(&meta_text) {

+            eprintln!("UPDATE_FETCH_FAIL: bad release.json: {e}");

+    let artifact = match http_get_bytes(&client, &art_url) {

+            eprintln!("UPDATE_FETCH_FAIL: GET {art_url}: {e}");

+    // The SAME gate the daemon's peer pull runs — no inherited trust from the

+    match spt_daemon::update::plan_verified(

+        &spt_daemon::update::BrokerAbi::current(),

+            if let Err(e) = cache.stage(&signed, &artifact) {

+                eprintln!("UPDATE_FETCH_FAIL: stage: {e}");

+                .map(|v| v.to_string())

+                .unwrap_or_else(|| "?".to_string());

+            println!("UPDATE_STAGED:{v} (from {repo}) — run `spt update apply` to apply");

+            eprintln!("UPDATE_FETCH_REJECTED:{reason:?}");


proceed to implement as recommended, then deploy as v0.3.1 when ready


Add the URL/asset unit test:


Edit

// [unit->REQ-DAEMON-9] the status connection classifier: a net-LESS broker

    // [unit->REQ-UPD-7] the origin-fetch URL/asset derivation: latest vs a
    // pinned tag, artifact + its .release.json, per the spt-releases layout.
    #[test]
    fn origin_release_urls_latest_and_tag() {
        let asset = platform_release_asset();
        assert!(asset == "spt-x86_64-linux" || asset == "spt-x86_64-windows.exe");
        let (art, meta) = origin_release_urls("Owner/repo", "spt-x86_64-linux", None);
        assert_eq!(art, "https://github.com/Owner/repo/releases/latest/download/spt-x86_64-linux");
        assert_eq!(
            meta,
            "https://github.com/Owner/repo/releases/latest/download/spt-x86_64-linux.release.json"
        );
        let (art_t, meta_t) =
            origin_release_urls("Owner/repo", "spt-x86_64-windows.exe", Some("v0.3.1"));
        assert_eq!(
            art_t,
            "https://github.com/Owner/repo/releases/download/v0.3.1/spt-x86_64-windows.exe"
        );
        assert!(meta_t.ends_with("/download/v0.3.1/spt-x86_64-windows.exe.release.json"));
    }

    // [unit->REQ-DAEMON-9] the status connection classifier: a net-LESS broker


    // [unit->REQ-UPD-7] the origin-fetch URL/asset derivation: latest vs a
    // pinned tag, artifact + its .release.json, per the spt-releases layout.
    #[test]
    fn origin_release_urls_latest_and_tag() {
        let asset = platform_release_asset();
        assert!(asset == "spt-x86_64-linux" || asset == "spt-x86_64-windows.exe");
        let (art, meta) = origin_release_urls("Owner/repo", "spt-x86_64-linux", None);
        assert_eq!(art, "https://github.com/Owner/repo/releases/latest/download/spt-x86_64-linux");
        assert_eq!(
            meta,
            "https://github.com/Owner/repo/releases/latest/download/spt-x86_64-linux.release.json"
        );
        let (art_t, meta_t) =
            origin_release_urls("Owner/repo", "spt-x86_64-windows.exe", Some("v0.3.1"));
        assert_eq!(
            art_t,
            "https://github.com/Owner/repo/releases/download/v0.3.1/spt-x86_64-windows.exe"
        );
        assert!(meta_t.ends_with("/download/v0.3.1/spt-x86_64-windows.exe.release.json"));
    }

    // [unit->REQ-DAEMON-9] the status connection classifier: a net-LESS broker

+    // [unit->REQ-UPD-7] the origin-fetch URL/asset derivation: latest vs a

+    // pinned tag, artifact + its .release.json, per the spt-releases layout.

+    fn origin_release_urls_latest_and_tag() {

+        let asset = platform_release_asset();

+        assert!(asset == "spt-x86_64-linux" || asset == "spt-x86_64-windows.exe");

+        let (art, meta) = origin_release_urls("Owner/repo", "spt-x86_64-linux", None);

+        assert_eq!(art, "https://github.com/Owner/repo/releases/latest/download/spt-x86_64-linux");

+            "https://github.com/Owner/repo/releases/latest/download/spt-x86_64-linux.release.json"

+            origin_release_urls("Owner/repo", "spt-x86_64-windows.exe", Some("v0.3.1"));

+            "https://github.com/Owner/repo/releases/download/v0.3.1/spt-x86_64-windows.exe"

+        assert!(meta_t.ends_with("/download/v0.3.1/spt-x86_64-windows.exe.release.json"));

// surfaces "no connection (retrying)" + a waiting pump and NEVER reads the

// heartbeat (so a stale pre-boot heartbeat can't render a bogus STALLED age


<task-notification>
<task-id>b6owpudhr</task-id>
<tool-use-id>toolu_01G1B7Y5TBSxMHfTd7AUZNgB</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b6owpudhr.output</output-file>
<status>completed</status>
<summary>Background command "build spt + update-fetch unit" completed (exit code 0)</summary>
</task-notification>


Build spt with the new fetch command (reqwest dep + verify wiring) + run the unit:


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && cargo test -p spt --bin spt origin_release_urls 2>&1 | grep -E "error\[|error:|-->|test result|origin_release" | head -25

build spt + update-fetch unit


Command running in background with ID: b6owpudhr. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b6owpudhr.output. You will be notified when it completes. To check interim output, use Read on that file path.


Prompt:
<task-notification>
<task-id>b6owpudhr</task-id>
<tool-use-id>toolu_01G1B7Y5TBSxMHfTd7AUZNgB</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b6owpudhr.output</output-file>
<status>completed</status>
<summary>Background command "build spt + update-fetch unit" completed (exit code 0)</summary>
</task-notification>


cat "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/b6owpudhr.output" | grep -E "error|test result|origin_release|warning: unused" | head

read fetch build result


error: failed to select a version for `reqwest`.


cat "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/b6owpudhr.output" | head -20

full reqwest version error


cd "C:/Users/decid/Documents/projects/spt-core" && cargo build -p spt 2>&1 | head -25

full reqwest resolution error


error: failed to select a version for `reqwest`.
    ... required by package `spt v0.3.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)`
versions that meet the requirements `^0.13` (locked to 0.13.4) are: 0.13.4

package `spt` depends on `reqwest` with feature `rustls-tls` but `reqwest` does not have that feature.

failed to select a version for `reqwest` which could resolve this conflict


`rustls-tls` isn't a feature in reqwest 0.13.4. Find the correct rustls feature name:


cd "C:/Users/decid/Documents/projects/spt-core" && find ~/.cargo/registry/src -maxdepth 2 -name "Cargo.toml" -path "*reqwest-0.13.4*" 2>/dev/null | head -1 | xargs grep -nE "^rustls|^default-tls|^blocking|^native-tls|tls.*=|webpki|^default " 2>/dev/null | head -25

reqwest 0.13.4 feature names


(Bash completed with no output)


cd "C:/Users/decid/Documents/projects/spt-core" && F=$(find ~/.cargo/registry/src -maxdepth 2 -type d -name "reqwest-0.13.4" 2>/dev/null | head -1); echo "dir=$F"; grep -nE "rustls|tls|blocking|webpki" "$F/Cargo.toml" 2>/dev/null | grep -iE "=|feature" | head -30

locate reqwest features


dir=/c/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/reqwest-0.13.4
70:__native-tls = [
76:__native-tls-alpn = [
80:__rustls = [
86:__rustls-aws-lc-rs = [
92:__tls = [
96:blocking = [
119:default-tls = ["rustls"]
151:native-tls = [
155:native-tls-no-alpn = ["__native-tls"]
156:native-tls-vendored = [
161:native-tls-vendored-no-alpn = [
169:rustls = [
174:rustls-no-provider = [
333:[target.'cfg(not(all(target_arch = "wasm32", any(target_os = "unknown", target_os = "none"))))'.dependencies.hyper-rustls]
342:[target.'cfg(not(all(target_arch = "wasm32", any(target_os = "unknown", target_os = "none"))))'.dependencies.hyper-tls]
363:[target.'cfg(not(all(target_arch = "wasm32", any(target_os = "unknown", target_os = "none"))))'.dependencies.native-tls-crate]
366:package = "native-tls"
384:[target.'cfg(not(all(target_arch = "wasm32", any(target_os = "unknown", target_os = "none"))))'.dependencies.rustls]
393:[target.'cfg(not(all(target_arch = "wasm32", any(target_os = "unknown", target_os = "none"))))'.dependencies.rustls-pki-types]
398:[target.'cfg(not(all(target_arch = "wasm32", any(target_os = "unknown", target_os = "none"))))'.dependencies.rustls-platform-verifier]
410:[target.'cfg(not(all(target_arch = "wasm32", any(target_os = "unknown", target_os = "none"))))'.dependencies.tokio-native-tls]
414:[target.'cfg(not(all(target_arch = "wasm32", any(target_os = "unknown", target_os = "none"))))'.dependencies.tokio-rustls]
416:features = ["tls12"]


reqwest 0.13's rustls feature is just `rustls` (default-tls = rustls — no OpenSSL). Fix the feature name:


Edit

reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls-tls"] }

reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }


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

[package]
name = "spt"
version.workspace = true
edition = "2021"
publish = false
description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
# The reference binary ships the WAN transport on by default (ADR-0002). D2g reuses its
# RFC-6238 TotpSeed (spt_net::net::pairing::totp) to render a subnet's local pairing
# code — reused, not reimplemented, so the CLI code matches the ceremony's exactly.
spt-net = { path = "../spt-net" }
# `wrap_help`: wrap long help text to the terminal width with a hanging indent
# (without it clap emits unwrapped lines and the terminal hard-wraps to col 0).
clap = { version = "4", features = ["derive", "wrap_help"] }
# The WAN send leg (D9-1-4) serializes the dial address + reads registry
# snapshots - same pinned shape as spt-daemon's.
serde_json = { version = "1", features = ["preserve_order"] }
# Terminal QR render for `spt subnet create` (M7 plan decision 11): the
# otpauth:// URI as unicode half-blocks. default-features off drops the
# image/svg renderers — the unicode renderer is all the CLI needs.
qrcode = { version = "0.14", default-features = false }
# Local-timezone rendering of the pairing code's expiry instant (std has no
# local tz). Clock feature only — no serde, no wasm.
chrono = { version = "0.4", default-features = false, features = ["clock"] }
# `spt update fetch` (REQ-UPD-7): HTTPS GET of the signed release from the
# GitHub origin. Blocking client + rustls (no OpenSSL); already in the tree
# transitively (iroh), so no new vendor. The fetch runs in the CLI, NOT the
# daemon — the daemon keeps zero HTTP surface (iroh QUIC only).
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls-tls"] }

# OS privilege-elevation probe for the node-bound code fetch (REQ-PAIR-6). Only the
# binary pulls the OS deps; the core stays dep-minimal. Matches the workspace's existing
# libc/windows-sys usage (spt-msg, spt-store) rather than the heavyweight `windows`.
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
    "Win32_Foundation",
    "Win32_Security",
    "Win32_System_Threading",
] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"
# install_e2e stages the built binary as a release asset (SHA256SUMS) for the
# D2 installer-script rung — same sha2 the workspace already carries.
sha2 = "0.10"

-reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls-tls"] }

+reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && cargo test -p spt --bin spt origin_release_urls 2>&1 | grep -E "error\[|error:|-->|test result|origin_release|select a version" | head -20

rebuild spt with rustls feature


Command running in background with ID: bcq7lcoeg. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bcq7lcoeg.output. You will be notified when it completes. To check interim output, use Read on that file path.


until grep -qE "test result|error\[|error:|select a version" "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bcq7lcoeg.output" 2>/dev/null; do sleep 6; done; cat "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bcq7lcoeg.output" | grep -E "error|test result|origin_release|-->" | head

read rebuild result


<task-notification>
<task-id>bcq7lcoeg</task-id>
<tool-use-id>toolu_01PFxA7nffxn5JPLExUAEAWW</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bcq7lcoeg.output</output-file>
<status>completed</status>
<summary>Background command "rebuild spt with rustls feature" completed (exit code 0)</summary>
</task-notification>


test cli::tests::origin_release_urls_latest_and_tag ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 106 filtered out; finished in 0.00s


Prompt:
<task-notification>
<task-id>bcq7lcoeg</task-id>
<tool-use-id>toolu_01PFxA7nffxn5JPLExUAEAWW</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bcq7lcoeg.output</output-file>
<status>completed</status>
<summary>Background command "rebuild spt with rustls feature" completed (exit code 0)</summary>
</task-notification>


All four features compile + unit-pass. Regenerate docs (new `update fetch` verb) + run the gate battery:


cd "C:/Users/decid/Documents/projects/spt-core" && cargo run -p xtask -- gen 2>&1 | tail -1 && git status -s docs-site/ ; echo "=== traceable-reqs ==="; traceable-reqs check >/tmp/tr3.out 2>&1; echo "TR=$?"; grep -E "REQ-DAEMON-9|REQ-UPD-7|FAIL|MISSING" /tmp/tr3.out | head

regen docs + traceable-reqs


Command running in background with ID: bay8awnw0. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bay8awnw0.output. You will be notified when it completes. To check interim output, use Read on that file path.


cd "C:/Users/decid/Documents/projects/spt-core" && cargo clippy -p spt -p spt-daemon --all-targets 2>&1 | grep -E "warning:|error" | grep -v "generated " | head -15; echo "CLIPPY_DONE"

clippy spt + spt-daemon


Command running in background with ID: bs4jbsfk6. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bs4jbsfk6.output. You will be notified when it completes. To check interim output, use Read on that file path.


until grep -qE "TR=" "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bay8awnw0.output" 2>/dev/null; do sleep 3; done; cat "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bay8awnw0.output"

read gen + tr


<task-notification>
<task-id>bay8awnw0</task-id>
<tool-use-id>toolu_01Npsmw3EEuVAXFD4ooFjywg</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bay8awnw0.output</output-file>
<status>completed</status>
<summary>Background command "regen docs + traceable-reqs" completed (exit code 0)</summary>
</task-notification>


xtask gen: wrote C:\Users\decid\Documents\projects\spt-core\docs-site/src/cli/reference.md
 M docs-site/src/cli/reference.md
=== traceable-reqs ===
TR=0
  [OK] REQ-DAEMON-9  required: [impl, unit]  stages: -doc +impl +unit -int
  [OK] REQ-UPD-7  required: [impl, unit]  stages: -doc +impl +unit -int


Prompt:
<task-notification>
<task-id>bay8awnw0</task-id>
<tool-use-id>toolu_01Npsmw3EEuVAXFD4ooFjywg</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bay8awnw0.output</output-file>
<status>completed</status>
<summary>Background command "regen docs + traceable-reqs" completed (exit code 0)</summary>
</task-notification>


TR=0, both new REQs OK, reference.md regenerated. Wait clippy + run `--no-default-features` (critical with the new reqwest dep):


until grep -qE "CLIPPY_DONE" "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bs4jbsfk6.output" 2>/dev/null; do sleep 5; done; echo "=== clippy ==="; grep -E "warning:|error" "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bs4jbsfk6.output" | head; echo "(empty above = clean)"

read clippy


<task-notification>
<task-id>bs4jbsfk6</task-id>
<tool-use-id>toolu_01MS9jcZjG4V5ig8Mp3gEJnY</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bs4jbsfk6.output</output-file>
<status>completed</status>
<summary>Background command "clippy spt + spt-daemon" completed (exit code 0)</summary>
</task-notification>


=== clippy ===
(empty above = clean)


Prompt:
<task-notification>
<task-id>bs4jbsfk6</task-id>
<tool-use-id>toolu_01MS9jcZjG4V5ig8Mp3gEJnY</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bs4jbsfk6.output</output-file>
<status>completed</status>
<summary>Background command "clippy spt + spt-daemon" completed (exit code 0)</summary>
</task-notification>


cd "C:/Users/decid/Documents/projects/spt-core" && cargo build --no-default-features 2>&1 | tail -4; echo "=== docs-drift ==="; cargo run -p xtask -- check 2>&1 | tail -1

no-default-features + docs-drift


Command running in background with ID: b7vdtj1rt. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b7vdtj1rt.output. You will be notified when it completes. To check interim output, use Read on that file path.


until grep -qE "xtask check|error|Finished.*release|drift" "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/b7vdtj1rt.output" 2>/dev/null && grep -qE "xtask check" "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/b7vdtj1rt.output"; do sleep 5; done; cat "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/b7vdtj1rt.output"

read ndf + drift


<task-notification>
<task-id>b7vdtj1rt</task-id>
<tool-use-id>toolu_01Tb5wJT1VRyRr7HTNSnoHwc</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b7vdtj1rt.output</output-file>
<status>completed</status>
<summary>Background command "no-default-features + docs-drift" completed (exit code 0)</summary>
</task-notification>


   Compiling spt-daemon v0.3.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
   Compiling spt v0.3.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
   Compiling xtask v0.3.0 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 28.04s
=== docs-drift ===
xtask check: OK


Prompt:
<task-notification>
<task-id>b7vdtj1rt</task-id>
<tool-use-id>toolu_01Tb5wJT1VRyRr7HTNSnoHwc</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b7vdtj1rt.output</output-file>
<status>completed</status>
<summary>Background command "no-default-features + docs-drift" completed (exit code 0)</summary>
</task-notification>


`--no-default-features` clean (reqwest fine), docs-drift OK. Now a live smoke-test of `spt update fetch` against the real published v0.3.0 release — validates the full HTTP→verify→stage path:


cd "C:/Users/decid/Documents/projects/spt-core" && SPT_HOME="$(mktemp -d)/spthome" SPT_INSTALL_REPO="SaberMage/spt-releases" ./target/debug/spt.exe update fetch 2>&1 | tail -8; echo "EXIT=$?"

live smoke-test update fetch


Command running in background with ID: bu8yu5gou. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bu8yu5gou.output. You will be notified when it completes. To check interim output, use Read on that file path.


<task-notification>
<task-id>bu8yu5gou</task-id>
<tool-use-id>toolu_01RwRTCC6RTRp6G4Sy32oV6H</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bu8yu5gou.output</output-file>
<status>completed</status>
<summary>Background command "live smoke-test update fetch" completed (exit code 0)</summary>
</task-notification>


until grep -qE "EXIT=|UPDATE_|error" "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bu8yu5gou.output" 2>/dev/null; do sleep 3; done; cat "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bu8yu5gou.output"

read smoke-test result


Fetching spt-x86_64-windows.exe from SaberMage/spt-releases…
UPDATE_STAGED:4 (from SaberMage/spt-releases) — run `spt update apply` to apply
EXIT=0


Prompt:
<task-notification>
<task-id>bu8yu5gou</task-id>
<tool-use-id>toolu_01RwRTCC6RTRp6G4Sy32oV6H</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bu8yu5gou.output</output-file>
<status>completed</status>
<summary>Background command "live smoke-test update fetch" completed (exit code 0)</summary>
</task-notification>


**`spt update fetch` works end-to-end** against the live release — fetched + verified + staged `UPDATE_STAGED:4` (counter 4 = v0.3.0, proving the metadata verified). All four features done + validated.

Run the full suites (the broker `OnceLock` change could touch contract_e2e/wansend) before committing #4:


cd "C:/Users/decid/Documents/projects/spt-core" && cargo test -p spt-daemon -p spt 2>&1 | grep -E "test result|error\[|error:|FAILED" | head -25

full spt-daemon + spt suites


Command running in background with ID: bt06n4irf. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bt06n4irf.output. You will be notified when it completes. To check interim output, use Read on that file path.


until grep -qE "error\[|error:" "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bt06n4irf.output" 2>/dev/null || [ $(grep -c "test result" "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bt06n4irf.output" 2>/dev/null) -ge 8 ] 2>/dev/null; do sleep 6; done; grep -E "test result|FAILED|error" "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bt06n4irf.output" | grep -vE "0 failed" | head; echo "=== fails above (empty=all pass) ==="; grep -c "0 failed" "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bt06n4irf.output"

read full suite result


Command running in background with ID: b58t7g2u3. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b58t7g2u3.output. You will be notified when it completes. To check interim output, use Read on that file path.


f="C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bt06n4irf.output"; until grep -qE "error\[|error:|Doc-tests|test result: ok\. .*finished in [0-9]" "$f" 2>/dev/null; do sleep 8; done; echo "=== any failures ==="; grep -E "FAILED|test result: FAILED|error\[|error:" "$f" | head; echo "=== summary lines ==="; grep -E "test result" "$f" | tail -12

wait + summarize full suite


<task-notification>
<task-id>bt06n4irf</task-id>
<tool-use-id>toolu_01RZ9DP5U94e9cZSmntEaEMx</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bt06n4irf.output</output-file>
<status>completed</status>
<summary>Background command "full spt-daemon + spt suites" completed (exit code 0)</summary>
</task-notification>


<task-notification>
<task-id>b58t7g2u3</task-id>
<tool-use-id>toolu_01T4mS6L47BfeswymoHjWEK6</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b58t7g2u3.output</output-file>
<status>completed</status>
<summary>Background command "read full suite result" completed (exit code 0)</summary>
</task-notification>


=== any failures ===
=== summary lines ===
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.35s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.14s
test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.66s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.71s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.29s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.10s


Prompt:
<task-notification>
<task-id>bt06n4irf</task-id>
<tool-use-id>toolu_01RZ9DP5U94e9cZSmntEaEMx</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bt06n4irf.output</output-file>
<status>completed</status>
<summary>Background command "full spt-daemon + spt suites" completed (exit code 0)</summary>
</task-notification>


Prompt:
<task-notification>
<task-id>b58t7g2u3</task-id>
<tool-use-id>toolu_01T4mS6L47BfeswymoHjWEK6</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b58t7g2u3.output</output-file>
<status>completed</status>
<summary>Background command "read full suite result" completed (exit code 0)</summary>
</task-notification>


All suites green; the broker `OnceLock` change regressed nothing. Commit #4:


cd "C:/Users/decid/Documents/projects/spt-core" && git add crates/spt/src/cli.rs crates/spt/Cargo.toml Cargo.lock docs-site/src/cli/reference.md && git commit -q -F - <<'EOF'
feat(update): `spt update fetch` — origin-source bootstrap from GitHub (REQ-UPD-7)

Closes the peer-only-discovery gap: a first-in-fleet / isolated node could not
get a release with no peer to pull from. `spt update fetch [--channel <ch>]
[--tag vX.Y.Z]` pulls the per-platform artifact + its <asset>.release.json
(SignedRelease) from github.com/SaberMage/spt-releases and feeds BOTH through
the SAME plan_verified gate the P2P pull uses (two-key signature + channel +
monotonic rollback floor + SHA-256), then cache.stage() — after which the
existing `spt update apply` flow is unchanged. No inherited trust from the
GitHub transport.

- HTTPS client: reqwest (blocking + rustls, no OpenSSL) as a direct dep on the
  `spt` CLI crate — the fetch runs in the CLI, NOT the daemon (the daemon keeps
  zero HTTP surface; iroh QUIC only). Already transitive via iroh, so no new
  vendor. --no-default-features stays clean.
- SPT_INSTALL_REPO override (mirrors the installer); default latest, --tag pins.

Units: origin URL/asset derivation (latest vs tag, artifact + .release.json).
Live-validated against the published v0.3.0 release: fetch→verify→stage
returned UPDATE_STAGED:4 (the verify gate accepted the real signed metadata).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git log --oneline -1

commit update fetch


warning: in the working copy of 'Cargo.lock', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs-site/src/cli/reference.md', LF will be replaced by CRLF the next time Git touches it
63fbbee feat(update): `spt update fetch` — origin-source bootstrap from GitHub (REQ-UPD-7)


`63fbbee`. Now push dev-freeform → let CI verify the fix on **both runners** (the boot-race lesson) before any version bump/tag:


cd "C:/Users/decid/Documents/projects/spt-core" && git push origin dev-freeform 2>&1 | tail -2 && sleep 6 && gh run list --branch dev-freeform --limit 1 --json databaseId,status -q '.[]|"\(.databaseId) \(.status)"'

push dev-freeform + find CI run


Command running in background with ID: bp7immuzc. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bp7immuzc.output. You will be notified when it completes. To check interim output, use Read on that file path.


While CI runs, prep the release commit. Bump version + write the v0.3.1 changelog:


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

# spt-core cargo workspace (M0). Acyclically-layered crates per R-ARCH-1:
#   spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime} -> spt-live -> spt-daemon -> spt
# M0 builds the bottom two: spt-proto (wire) + spt-store (persistence).
# M1 adds spt-msg (local messaging) — third layer, one-way above spt-store.
# M2a adds spt-runtime (harness contract: manifest schema + AgentRuntime) — fourth layer.
# M2b adds spt-live (live-agent lifecycle executor) — fifth layer, daemon-bound (not public SDK).
# M3a adds spt-term (session-surface mechanism: ConPTY/forkpty PTY layer) — sibling of spt-runtime, above spt-msg.
# M3b adds spt-daemon (broker/brain supervisor) — seventh layer, above spt-live, below spt; internal (not public SDK).
# M4 adds spt-net (P2P WAN/LAN transport) — sibling of spt-term/spt-runtime, above spt-msg; internal, default-on `net` feature.
[workspace]
resolver = "2"
members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-net", "crates/spt-term", "crates/spt-runtime", "crates/spt-live", "crates/spt-daemon", "crates/spt", "adapters/mock", "crates/xtask"]

# M6-D6 (grill decision 9): ONE shared version for every crate and the binary
# — the product version (v0.1.0 tag, `spt --version`). The wire-protocol
# version stays its own counter (REQ-ARCH-3) and the SignedRelease `version`
# is the separate monotonic release counter; neither conflates with this.
[workspace.package]
version = "0.3.0"


<task-notification>
<task-id>bp7immuzc</task-id>
<tool-use-id>toolu_01Xweb3LEbf63jaxKt8Yizb2</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bp7immuzc.output</output-file>
<status>completed</status>
<summary>Background command "push dev-freeform + find CI run" completed (exit code 0)</summary>
</task-notification>


Edit

## [Unreleased]

## [0.3.1] - 2026-06-08

### Added

- **`spt update fetch`** — pull and stage the latest signed release straight from the project's GitHub releases, then `spt update apply` to install it. This bootstraps the first machine in a fleet (or any machine with no peer to update from), which previously could only receive an update from another machine that already had it. The download is verified against the same signed-release keys as peer-to-peer updates. Add `--tag vX.Y.Z` to fetch a specific version.

### Fixed

- **A node is no longer stranded offline after a reboot.** If the daemon started before the machine's network was ready (common immediately after boot), it used to come up with no connection and stay that way until you manually restarted it — `spt daemon` would just report the peer pump as "STALLED". Now the daemon keeps retrying the network in the background and brings itself online once the network is up, with no restart needed. While it's waiting, `spt daemon` reports "no connection" honestly instead of a misleading stalled-pump message. (On Linux the installed service now also waits for the network at boot.)

## [0.3.0] - 2026-06-08


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

## [Unreleased]

## [0.3.1] - 2026-06-08

### Added

- **`spt update fetch`** — pull and stage the latest signed release straight from the project's GitHub releases, then `spt update apply` to install it. This bootstraps the first machine in a fleet (or any machine with no peer to update from), which previously could only receive an update from another machine that already had it. The download is verified against the same signed-release keys as peer-to-peer updates. Add `--tag vX.Y.Z` to fetch a specific version.

### Fixed

- **A node is no longer stranded offline after a reboot.** If the daemon started before the machine's network was ready (common immediately after boot), it used to come up with no connection and stay that way until you manually restarted it — `spt daemon` would just report the peer pump as "STALLED". Now the daemon keeps retrying the network in the background and brings itself online once the network is up, with no restart needed. While it's waiting, `spt daemon` reports "no connection" honestly instead of a misleading stalled-pump message. (On Linux the installed service now also waits for the network at boot.)

## [0.3.0] - 2026-06-08

+- **`spt update fetch`** — pull and stage the latest signed release straight from the project's GitHub releases, then `spt update apply` to install it. This bootstraps the first machine in a fleet (or any machine with no peer to update from), which previously could only receive an update from another machine that already had it. The download is verified against the same signed-release keys as peer-to-peer updates. Add `--tag vX.Y.Z` to fetch a specific version.

+- **A node is no longer stranded offline after a reboot.** If the daemon started before the machine's network was ready (common immediately after boot), it used to come up with no connection and stay that way until you manually restarted it — `spt daemon` would just report the peer pump as "STALLED". Now the daemon keeps retrying the network in the background and brings itself online once the network is up, with no restart needed. While it's waiting, `spt daemon` reports "no connection" honestly instead of a misleading stalled-pump message. (On Linux the installed service now also waits for the network at boot.)


Prompt:
<task-notification>
<task-id>bp7immuzc</task-id>
<tool-use-id>toolu_01Xweb3LEbf63jaxKt8Yizb2</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bp7immuzc.output</output-file>
<status>completed</status>
<summary>Background command "push dev-freeform + find CI run" completed (exit code 0)</summary>
</task-notification>


Bash

read push + CI run id


To https://github.com/SaberMage/spt-core.git
   9c825cf..63fbbee  dev-freeform -> dev-freeform
27184705181 queued


CI run `27184705181` queued. Watch it on both runners (gates the tag):


cd "C:/Users/decid/Documents/projects/spt-core" && gh run watch 27184705181 --exit-status 2>&1 | tail -6; echo "CI_EXIT=$?"

watch v0.3.1 fix CI


Command running in background with ID: bjxinic1k. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bjxinic1k.output. You will be notified when it completes. To check interim output, use Read on that file path.


While CI runs, refresh Cargo.lock for the 0.3.1 bump so the release-prep is ready:


cd "C:/Users/decid/Documents/projects/spt-core" && cargo build -p spt 2>&1 | tail -2; grep -A1 '^name = "spt"$' Cargo.lock | head -2

refresh Cargo.lock to 0.3.1


Command running in background with ID: biyxzcl6b. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\biyxzcl6b.output. You will be notified when it completes. To check interim output, use Read on that file path.


until grep -qE "version = " "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/biyxzcl6b.output" 2>/dev/null; do sleep 4; done; cat "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/biyxzcl6b.output"

confirm lock at 0.3.1


<task-notification>
<task-id>biyxzcl6b</task-id>
<tool-use-id>toolu_017skcnY5aX5BCbThvQjkfwP</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\biyxzcl6b.output</output-file>
<status>completed</status>
<summary>Background command "refresh Cargo.lock to 0.3.1" completed (exit code 0)</summary>
</task-notification>


   Compiling spt v0.3.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 37.30s
name = "spt"
version = "0.3.1"


Prompt:
<task-notification>
<task-id>biyxzcl6b</task-id>
<tool-use-id>toolu_017skcnY5aX5BCbThvQjkfwP</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\biyxzcl6b.output</output-file>
<status>completed</status>
<summary>Background command "refresh Cargo.lock to 0.3.1" completed (exit code 0)</summary>
</task-notification>


Lock at 0.3.1; release-prep staged-ready. Now wait for CI on both runners before committing the bump + tagging.


until grep -qE "CI_EXIT=" "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bjxinic1k.output" 2>/dev/null; do sleep 25; done; echo DONE; cd "C:/Users/decid/Documents/projects/spt-core"; gh run view 27184705181 --json conclusion,jobs -q '{c:.conclusion,jobs:[.jobs[]|{n:.name,c:.conclusion}]}' 2>&1

wait CI verdict both runners


Command running in background with ID: bmlngtgus. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bmlngtgus.output. You will be notified when it completes. To check interim output, use Read on that file path.


I'll wait for the CI verdict on both runners before proceeding to the version-bump commit and tag.


cat "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bjxinic1k.output" 2>/dev/null | tail -8

peek CI progress


(Bash completed with no output)


CI building on both runners. Waiting for the verdict before the bump commit + tag.


until grep -qE "CI_EXIT=" "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bjxinic1k.output" 2>/dev/null; do sleep 30; done; tail -3 "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bjxinic1k.output"

block for CI done


Command running in background with ID: b0seswgzc. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b0seswgzc.output. You will be notified when it completes. To check interim output, use Read on that file path.


I'll wait for the CI completion notification.


check verdict tasks


---watch---


CI still in progress on the self-hosted runners. I'll wait for the completion notification, then proceed to the bump commit, FF main, and tag once it's green on both runners.


<task-notification>
<task-id>bjxinic1k</task-id>
<tool-use-id>toolu_01GRGoyREdvuByiQYdvqzxw8</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bjxinic1k.output</output-file>
<status>completed</status>
<summary>Background command "watch v0.3.1 fix CI" completed (exit code 0)</summary>
</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.


cd "C:/Users/decid/Documents/projects/spt-core" && tail -4 "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bjxinic1k.output"; echo "=== verdict ==="; gh run view 27184705181 --json conclusion,jobs -q '{c:.conclusion,jobs:[.jobs[]|{n:.name,c:.conclusion}]}' 2>&1

read CI verdict


  ✓ Complete job
- twohost-a in 0s (ID 80251667052)
- twohost-b in 0s (ID 80251667167)
CI_EXIT=0
=== verdict ===
{"c":"success","jobs":[{"c":"success","n":"traceability"},{"c":"success","n":"test (self-hosted, Linux, kitsubito)"},{"c":"success","n":"test (self-hosted, Windows, hfenduleam)"},{"c":"skipped","n":"twohost-a"},{"c":"skipped","n":"twohost-b"}]}


<task-notification>
<task-id>bmlngtgus</task-id>
<tool-use-id>toolu_013iPwrYTUzG4V5VjSVURsK2</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bmlngtgus.output</output-file>
<status>completed</status>
<summary>Background command "wait CI verdict both runners" completed (exit code 0)</summary>
</task-notification>


**CI green on both runners.** All four features verified. Commit release-prep + FF main + tag v0.3.1:


cd "C:/Users/decid/Documents/projects/spt-core" && git add Cargo.toml Cargo.lock CHANGELOG.md && git commit -q -F - <<'EOF'
release: v0.3.1

Bump 0.3.0 → 0.3.1; promote CHANGELOG [Unreleased] → [0.3.1] - 2026-06-08.
Ships the boot-race net self-heal (REQ-DAEMON-9 — a node no longer stranded
offline after a reboot) and `spt update fetch` (REQ-UPD-7 — origin-source
bootstrap from GitHub).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git checkout main 2>&1 | tail -1 && git merge --ff-only dev-freeform 2>&1 | tail -1 && git push origin main 2>&1 | tail -1 && git push origin dev-freeform 2>&1 | tail -1 && git tag -a v0.3.1 -m "v0.3.1 — boot-race net self-heal + spt update fetch" && git push origin v0.3.1 2>&1 | tail -2

release-prep + FF main + tag v0.3.1


Command running in background with ID: bkxvbwyvq. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bkxvbwyvq.output. You will be notified when it completes. To check interim output, use Read on that file path.


Prompt:
<task-notification>
<task-id>bmlngtgus</task-id>
<tool-use-id>toolu_013iPwrYTUzG4V5VjSVURsK2</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bmlngtgus.output</output-file>
<status>completed</status>
<summary>Background command "wait CI verdict both runners" completed (exit code 0)</summary>
</task-notification>


<task-notification>
<task-id>bkxvbwyvq</task-id>
<tool-use-id>toolu_01RSfgfP8YMiNykZSQqVqKbH</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bkxvbwyvq.output</output-file>
<status>completed</status>
<summary>Background command "release-prep + FF main + tag v0.3.1" completed (exit code 0)</summary>
</task-notification>


until grep -qE "v0.3.1|rejected|error|fatal|new tag" "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bkxvbwyvq.output" 2>/dev/null; do sleep 3; done; cat "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/bkxvbwyvq.output"

read release-prep + tag push


warning: in the working copy of 'Cargo.lock', LF will be replaced by CRLF the next time Git touches it
Switched to branch 'main'
 create mode 100644 V031-PLAN.md
   9c825cf..516465c  main -> main
   63fbbee..516465c  dev-freeform -> dev-freeform
To https://github.com/SaberMage/spt-core.git
 * [new tag]         v0.3.1 -> v0.3.1


Prompt:
<task-notification>
<task-id>bkxvbwyvq</task-id>
<tool-use-id>toolu_01RSfgfP8YMiNykZSQqVqKbH</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bkxvbwyvq.output</output-file>
<status>completed</status>
<summary>Background command "release-prep + FF main + tag v0.3.1" completed (exit code 0)</summary>
</task-notification>


<task-notification>
<task-id>b0seswgzc</task-id>
<tool-use-id>toolu_013xCg5wL8VrimQTw2DNMiCe</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b0seswgzc.output</output-file>
<status>completed</status>
<summary>Background command "block for CI done" completed (exit code 0)</summary>
</task-notification>


main `→516465c`, v0.3.1 tagged + pushed → release build triggered. Watch the release run → draft:


cd "C:/Users/decid/Documents/projects/spt-core" && sleep 8 && RID=$(gh run list --workflow release.yml --limit 1 --json databaseId -q '.[].databaseId'); echo "release run=$RID"; gh run watch $RID --exit-status 2>&1 | tail -8; echo "REL_EXIT=$?"

watch v0.3.1 release build


Command running in background with ID: brl6t1ij2. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\brl6t1ij2.output. You will be notified when it completes. To check interim output, use Read on that file path.


Prompt:
<task-notification>
<task-id>b0seswgzc</task-id>
<tool-use-id>toolu_013xCg5wL8VrimQTw2DNMiCe</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b0seswgzc.output</output-file>
<status>completed</status>
<summary>Background command "block for CI done" completed (exit code 0)</summary>
</task-notification>


Release building on both runners (~several min). I'll report when the draft lands with the sign-off command.


until grep -qE "REL_EXIT=" "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/brl6t1ij2.output" 2>/dev/null; do sleep 30; done; echo DONE

block for release build


Command running in background with ID: b0kz41u4l. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b0kz41u4l.output. You will be notified when it completes. To check interim output, use Read on that file path.


I'll wait for the release build to finish, then verify the draft and hand off signing.


cat "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/afad552a-7216-48d3-ba81-3c8294f2a399/tasks/brl6t1ij2.output" 2>/dev/null | tail -6

peek release build


release run=27184928742


Release run `27184928742` building on both runners. Waiting for it to assemble the draft.


<task-notification>
<task-id>bfoz0tyn2</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/8" id="5d13f014" type="msg" from="todlando">HANDOFF — bug diagnosis for pending v0.3.1. From todlando (HFENDULEAM).

SYMPTOM: cross-node `spt send` from Windows renders malformed EVENT on receiver. Repro: `echo &quot;hey...&quot; | spt send gerald --from windows` → receiver shows `&lt;/EVENT&gt;ype=&quot;msg&quot; from=&quot;windows&quot;&gt;hey...` </EVENT-PART>
<EVENT-PART seq="2/8" id="5d13f014">— the `&lt;/EVENT&gt;` overwrote `&lt;EVENT t` (both exactly 8 chars). Classic carriage-return overwrite: a raw `\r` sits before `&lt;/EVENT&gt;` in the single-line envelope; receiver terminal does CR→col0 overwrite.

ROOT CAUSE (traced through source, 3 leak layers):
1. crates/spt-proto/src/envelope.rs:21 `event_body_escape` — </EVENT-PART>
<EVENT-PART seq="3/8" id="5d13f014">maps `\n`→`&lt;br&gt;` but does NOT neutralize `\r`. THE durable defect: envelope is line-framed, so any raw `\r` corrupts it. This is the one that must be fixed (covers interior CRLF in multi-line Windows messages too).
2. crates/spt-msg/src/emit.rs:30,34 `parse_frame` — `.trim_end_matches('\n')` strips `\n`, leaves trailing `\r`.
3</EVENT-PART>
<EVENT-PART seq="4/8" id="5d13f014">. crates/spt/src/cli.rs:1901 (cmd_send) + 2004 (cmd_ring) — read stdin with NO trim; Windows `echo` pipes `\r\n`. Note cmd_notify (cli.rs:1572) already does `.trim()` — send/ring just forgot. Asymmetry.

USER DECISION: fix scope = BOTH (codec normalization + send/ring stdin trim). User asked: can you roll this into v0.3.1?

PRO</EVENT-PART>
<EVENT-PART seq="5/8" id="5d13f014">POSED FIX:
- envelope.rs event_body_escape: before the `\n`→`&lt;br&gt;` replace, add `.replace(&quot;\r\n&quot;,&quot;\n&quot;).replace('\r',&quot;\n&quot;)`. CRLF/lone-CR → linebreak → `&lt;br&gt;`. Wire-compatible (decoder `&lt;br&gt;`→`\n` unchanged), amp-last invariant untouched, existing round-trip test (LF-only input) still p</EVENT-PART>
<EVENT-PART seq="6/8" id="5d13f014">asses. NOT a clean-room divergence from ADR-0001 — `\r` was never line-representable; we only normalize unrepresentable input.
- cli.rs cmd_send (1901) + cmd_ring (2004): `let body = read_stdin();` → trim like cmd_notify does (`body.trim()`), for parity + strip trailing blank.

TRACEABILITY: new + untracked (no CRLF/carriage prior</EVENT-PART>
<EVENT-PART seq="7/8" id="5d13f014"> art in KNOWN-HAZARDS / ADRs / traceable-reqs.toml). Needs a new REQ first — suggest REQ-HAZARD-ENVELOPE-CR-LINESAFE (codec must neutralize CR so a line-framed envelope can't be terminal-corrupted). Add to traceable-reqs.toml, then `[impl-&gt;...]` on the escape fn + `[unit-&gt;...]` on a regression test (body with `\r`/`\r\n` → rendered E</EVENT-PART>
<EVENT-PART seq="8/8" id="5d13f014">VENT line contains no raw `\r`; round-trips). `traceable-reqs check` must pass before ship.

Can you take this into v0.3.1, or want me to land it on dev-freeform and you cherry-pick? Your call.</EVENT-PART></event>
</task-notification>


todlando found a real bug and the user wants it in v0.3.1. v0.3.1 is still an **unsigned draft** (build in flight), so it's not too late — I own the release flow, so I'll take it directly rather than risk two agents landing on dev-freeform. Reply to todlando, cancel the in-flight release, and verify the trace in parallel:


cd "C:/Users/decid/Documents/projects/spt-core" && printf '%s' "Taking the CR fix into v0.3.1 directly — DO NOT land it on dev-freeform (I own the live release flow; v0.3.1 is still an unsigned draft, re-tagging it). Implementing both legs (envelope.rs CR-normalize + cmd_send/cmd_ring stdin trim) + REQ-HAZARD-ENVELOPE-CR-LINESAFE + regression test now. Thanks for the precise 3-layer trace. Stand down on this one." | spt send todlando 2>&1 | tail -3; echo "SENT=$?"

reply to todlando: I take it


NO_PERCH:todlando is not listening
SENT=0


cd "C:/Users/decid/Documents/projects/spt-core" && echo "=== cancel in-flight v0.3.1 release + docs-publish ==="; for id in $(gh run list --limit 4 --json databaseId,name,headBranch -q '.[] | select(.headBranch=="v0.3.1") | .databaseId'); do gh run cancel $id 2>&1 | tail -1; done

cancel v0.3.1 release runs


cd "C:/Users/decid/Documents/projects/spt-core" && echo "=== envelope.rs event_body_escape ==="; sed -n '1,55p' crates/spt-proto/src/envelope.rs | grep -nE "fn event_body_escape|replace|<br>|\\\\n|\\\\r" ; echo "=== full escape fn ==="; sed -n '10,45p' crates/spt-proto/src/envelope.rs

read envelope escape fn


=== cancel in-flight v0.3.1 release + docs-publish ===
Cannot cancel a workflow run that is completed
✓ Request to cancel workflow 27184928742 submitted.


=== envelope.rs event_body_escape ===
1://! Envelope grammar: the HTML-entity transport codec and the two-slice
2://! Psyche-context parser. **Copied verbatim** from `claude_skill_owl`
3://! (`src/owl/poll.rs` codec + `src/common/envelope.rs` parser) per ADR-0001 —
4://! this is the stable, public wire contract; do not clean-room it.
6://! ## Encoding contract (the one written-down rule)
7://! Producer encodes once at the source; body stays encoded through transport
8://! (spool / TCP / relay); consumer decodes once after framing-parse.
10://! ENCODE order: `&`→`&` FIRST, then `<` `>` `"`, then `\n`→`<br>` LAST.
11://! DECODE order: `<br>`→`\n` FIRST, then `<` `>` `"`, then
12://! `&`→`&` **LAST** — the amp-last invariant. If `&` decoded first, a
13://! user-prose literal like `&lt;EVENT&gt;` would double-decode into
14://! `<EVENT>` and become structurally significant. (KNOWN-HAZARDS 4.1.)
17:// Transport codec (KNOWN-HAZARDS 4.1 — `&` decoded LAST)
20:/// HTML-escape an EVENT body. Order: amp-first, `<br>`-last.
21:pub fn event_body_escape(s: &str) -> String {
22:    s.replace('&', "&")
23:        .replace('<', "<")
24:        .replace('>', ">")
25:        .replace('"', """)
26:        .replace('\n', "
")
29:/// Inverse of [`event_body_escape`]. Decode order MUST be `<br>` FIRST, then
30:/// tag-shaped entities, with `&`→`&` LAST. Call ONLY after EVENT framing
31:/// has been parsed and the body substring extracted; never on the full
32:/// envelope string (that would unescape outer framing tokens too).
34:/// Amp-last invariant: if `&` were decoded first, a body like
35:/// `&lt;EVENT&gt;` would double-decode into `<EVENT>`.
37:pub fn event_body_unescape(s: &str) -> String {
38:    s.replace("
", "\n")
39:        .replace("<", "<")
40:        .replace(">", ">")
41:        .replace(""", "\"")
42:        .replace("&", "&") // amp-last (Pitfall 3 inverse)
45:/// HTML-escape for attribute values. Same order as [`event_body_escape`] but
46:/// omits `\n`→`<br>` (attr values are line-safe by construction).
47:pub fn event_attr_escape(s: &str) -> String {
48:    s.replace('&', "&")
49:        .replace('<', "<")
50:        .replace('>', ">")
51:        .replace('"', """)
54:/// Inverse of [`event_attr_escape`]: decode an attribute value with the
55:/// amp-last invariant (`&`→`&` LAST). Each non-amp entity is unique so
=== full escape fn ===
//! ENCODE order: `&`→`&` FIRST, then `<` `>` `"`, then `\n`→`<br>` LAST.
//! DECODE order: `<br>`→`\n` FIRST, then `<` `>` `"`, then
//! `&`→`&` **LAST** — the amp-last invariant. If `&` decoded first, a
//! user-prose literal like `&lt;EVENT&gt;` would double-decode into
//! `<EVENT>` and become structurally significant. (KNOWN-HAZARDS 4.1.)

// ---------------------------------------------------------------------------
// Transport codec (KNOWN-HAZARDS 4.1 — `&` decoded LAST)
// ---------------------------------------------------------------------------

/// HTML-escape an EVENT body. Order: amp-first, `<br>`-last.
pub fn event_body_escape(s: &str) -> String {
    s.replace('&', "&")
        .replace('<', "<")
        .replace('>', ">")
        .replace('"', """)
        .replace('\n', "
")
}

/// Inverse of [`event_body_escape`]. Decode order MUST be `<br>` FIRST, then
/// tag-shaped entities, with `&`→`&` LAST. Call ONLY after EVENT framing
/// has been parsed and the body substring extracted; never on the full
/// envelope string (that would unescape outer framing tokens too).
///
/// Amp-last invariant: if `&` were decoded first, a body like
/// `&lt;EVENT&gt;` would double-decode into `<EVENT>`.
// [impl->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
pub fn event_body_unescape(s: &str) -> String {
    s.replace("
", "\n")
        .replace("<", "<")
        .replace(">", ">")
        .replace(""", "\"")
        .replace("&", "&") // amp-last (Pitfall 3 inverse)
}

/// HTML-escape for attribute values. Same order as [`event_body_escape`] but


<task-notification>
<task-id>brl6t1ij2</task-id>
<tool-use-id>toolu_013a1GL5mhND7WFGfvMGZxYe</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\brl6t1ij2.output</output-file>
<status>completed</status>
<summary>Background command "watch v0.3.1 release build" completed (exit code 0)</summary>
</task-notification>


<task-notification>
<task-id>b0kz41u4l</task-id>
<tool-use-id>toolu_01WNdQZyyPxg8j3QH2M9WEpp</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b0kz41u4l.output</output-file>
<status>completed</status>
<summary>Background command "block for release build" completed (exit code 0)</summary>
</task-notification>


Trace confirmed: `event_body_escape` maps `\n`→`<br>` but never neutralizes `\r` — a raw CR survives the line-framed envelope → terminal overwrite. (todlando isn't perched — my "stand down" reply bounced `NO_PERCH`; proceeding since I own the release.) Read the stdin sites + test mod + hazard doc to implement all legs:


cd "C:/Users/decid/Documents/projects/spt-core" && echo "=== cmd_notify stdin (the trim pattern, ~1572) ==="; sed -n '1568,1578p' crates/spt/src/cli.rs; echo "=== cmd_send stdin (~1901) ==="; sed -n '1895,1910p' crates/spt/src/cli.rs; echo "=== cmd_ring stdin (~2004) ==="; sed -n '1998,2012p' crates/spt/src/cli.rs

read send/ring/notify stdin handling


=== cmd_notify stdin (the trim pattern, ~1572) ===
            return 1;
        }
    };
    let body = body.unwrap_or_else(read_stdin);
    let body = body.trim();
    if body.is_empty() {
        eprintln!("EMPTY_BODY: nothing to notify");
        return 1;
    }
    let node_hex = match spt_store::nodeid::load_or_create() {
        Ok(identity) => identity.public_key().to_hex(),
=== cmd_send stdin (~1901) ===
        None => {
            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
            return 2;
        }
    };
    let from = resolve_from(from);
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    let outcome = if deferred {
        deliver::send_deferred(&target, &from, &body)
    } else {
        deliver::send(&target, &from, &body, &owlery)
    };

    match outcome {
=== cmd_ring stdin (~2004) ===
fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
    let from = resolve_from(from);
    if from.is_empty() {
        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
        return 1;
    }
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
        RingOutcome::Replied(reply) => {
            print!("{}", reply);
            let _ = std::io::stdout().flush();
            0
        }


Prompt:
<task-notification>
<task-id>brl6t1ij2</task-id>
<tool-use-id>toolu_013a1GL5mhND7WFGfvMGZxYe</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\brl6t1ij2.output</output-file>
<status>completed</status>
<summary>Background command "watch v0.3.1 release build" completed (exit code 0)</summary>
</task-notification>


Prompt:
<task-notification>
<task-id>b0kz41u4l</task-id>
<tool-use-id>toolu_01WNdQZyyPxg8j3QH2M9WEpp</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b0kz41u4l.output</output-file>
<status>completed</status>
<summary>Background command "block for release build" completed (exit code 0)</summary>
</task-notification>


Trace fully confirmed. Implement all legs. **Codec CR-normalize** (the durable fix):


Edit

/// HTML-escape an EVENT body. Order: amp-first, `<br>`-last.
pub fn event_body_escape(s: &str) -> String {
    s.replace('&', "&")
        .replace('<', "<")
        .replace('>', ">")
        .replace('"', """)
        .replace('\n', "<br>")
}

/// HTML-escape an EVENT body. Order: amp-first, `<br>`-last.
///
/// CR-linesafe (REQ-HAZARD-ENVELOPE-CR-LINESAFE): the envelope is line-framed,
/// so a raw `\r` in the body survives into the single-line EVENT and the
/// receiver terminal does a CR→column-0 overwrite that corrupts the frame
/// (observed cross-node from Windows, whose `echo`/CRLF text carries `\r`).
/// `\r` was NEVER line-representable in this codec (only `\n`→`<br>`), so
/// normalizing CRLF/lone-CR to the linebreak the codec CAN represent — BEFORE
/// `\n`→`<br>` — is robustness on unrepresentable input, not a wire-format
/// change: the decoder ([`event_body_unescape`]) is untouched and the amp-last
/// invariant holds.
// [impl->REQ-HAZARD-ENVELOPE-CR-LINESAFE]
pub fn event_body_escape(s: &str) -> String {
    s.replace('&', "&")
        .replace('<', "<")
        .replace('>', ">")
        .replace('"', """)
        .replace("\r\n", "\n")
        .replace('\r', "\n")
        .replace('\n', "<br>")
}


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


20	/// HTML-escape an EVENT body. Order: amp-first, `<br>`-last.
21	pub fn event_body_escape(s: &str) -> String {
22	    s.replace('&', "&")
23	        .replace('<', "<")
24	        .replace('>', ">")
25	        .replace('"', """)
26	        .replace('\n', "<br>")
27	}

/// HTML-escape an EVENT body. Order: amp-first, `<br>`-last.
pub fn event_body_escape(s: &str) -> String {
    s.replace('&', "&")
        .replace('<', "<")
        .replace('>', ">")
        .replace('"', """)
        .replace('\n', "<br>")
}


/// HTML-escape an EVENT body. Order: amp-first, `<br>`-last.
pub fn event_body_escape(s: &str) -> String {
    s.replace('&', "&")
        .replace('<', "<")
        .replace('>', ">")
        .replace('"', """)
        .replace('\n', "<br>")
}

/// HTML-escape an EVENT body. Order: amp-first, `<br>`-last.
///
/// CR-linesafe (REQ-HAZARD-ENVELOPE-CR-LINESAFE): the envelope is line-framed,
/// so a raw `\r` in the body survives into the single-line EVENT and the
/// receiver terminal does a CR→column-0 overwrite that corrupts the frame
/// (observed cross-node from Windows, whose `echo`/CRLF text carries `\r`).
/// `\r` was NEVER line-representable in this codec (only `\n`→`<br>`), so
/// normalizing CRLF/lone-CR to the linebreak the codec CAN represent — BEFORE
/// `\n`→`<br>` — is robustness on unrepresentable input, not a wire-format
/// change: the decoder ([`event_body_unescape`]) is untouched and the amp-last
/// invariant holds.
// [impl->REQ-HAZARD-ENVELOPE-CR-LINESAFE]
pub fn event_body_escape(s: &str) -> String {
    s.replace('&', "&")
        .replace('<', "<")
        .replace('>', ">")
        .replace('"', """)
        .replace("\r\n", "\n")
        .replace('\r', "\n")
        .replace('\n', "<br>")
}


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

/// HTML-escape an EVENT body. Order: amp-first, `<br>`-last.
pub fn event_body_escape(s: &str) -> String {
    s.replace('&', "&")
        .replace('<', "<")
        .replace('>', ">")
        .replace('"', """)
        .replace('\n', "<br>")
}

/// HTML-escape an EVENT body. Order: amp-first, `<br>`-last.
///
/// CR-linesafe (REQ-HAZARD-ENVELOPE-CR-LINESAFE): the envelope is line-framed,
/// so a raw `\r` in the body survives into the single-line EVENT and the
/// receiver terminal does a CR→column-0 overwrite that corrupts the frame
/// (observed cross-node from Windows, whose `echo`/CRLF text carries `\r`).
/// `\r` was NEVER line-representable in this codec (only `\n`→`<br>`), so
/// normalizing CRLF/lone-CR to the linebreak the codec CAN represent — BEFORE
/// `\n`→`<br>` — is robustness on unrepresentable input, not a wire-format
/// change: the decoder ([`event_body_unescape`]) is untouched and the amp-last
/// invariant holds.
// [impl->REQ-HAZARD-ENVELOPE-CR-LINESAFE]
pub fn event_body_escape(s: &str) -> String {
    s.replace('&', "&")
        .replace('<', "<")
        .replace('>', ">")
        .replace('"', """)
        .replace("\r\n", "\n")
        .replace('\r', "\n")
        .replace('\n', "<br>")
}

//! Envelope grammar: the HTML-entity transport codec and the two-slice
//! Psyche-context parser. **Copied verbatim** from `claude_skill_owl`
//! (`src/owl/poll.rs` codec + `src/common/envelope.rs` parser) per ADR-0001 —
//! this is the stable, public wire contract; do not clean-room it.
//!
//! ## Encoding contract (the one written-down rule)
//! Producer encodes once at the source; body stays encoded through transport
//! (spool / TCP / relay); consumer decodes once after framing-parse.
//!
//! ENCODE order: `&`→`&` FIRST, then `<` `>` `"`, then `\n`→`<br>` LAST.
//! DECODE order: `<br>`→`\n` FIRST, then `<` `>` `"`, then
//! `&`→`&` **LAST** — the amp-last invariant. If `&` decoded first, a
//! user-prose literal like `&lt;EVENT&gt;` would double-decode into
//! `<EVENT>` and become structurally significant. (KNOWN-HAZARDS 4.1.)

// ---------------------------------------------------------------------------
// Transport codec (KNOWN-HAZARDS 4.1 — `&` decoded LAST)
// ---------------------------------------------------------------------------

/// HTML-escape an EVENT body. Order: amp-first, `<br>`-last.
pub fn event_body_escape(s: &str) -> String {
    s.replace('&', "&")
        .replace('<', "<")
        .replace('>', ">")
        .replace('"', """)
        .replace('\n', "
")
}

/// Inverse of [`event_body_escape`]. Decode order MUST be `<br>` FIRST, then
/// tag-shaped entities, with `&`→`&` LAST. Call ONLY after EVENT framing
/// has been parsed and the body substring extracted; never on the full
/// envelope string (that would unescape outer framing tokens too).
///
/// Amp-last invariant: if `&` were decoded first, a body like
/// `&lt;EVENT&gt;` would double-decode into `<EVENT>`.
// [impl->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
pub fn event_body_unescape(s: &str) -> String {
    s.replace("
", "\n")
        .replace("<", "<")
        .replace(">", ">")
        .replace(""", "\"")
        .replace("&", "&") // amp-last (Pitfall 3 inverse)
}

/// HTML-escape for attribute values. Same order as [`event_body_escape`] but
/// omits `\n`→`<br>` (attr values are line-safe by construction).
pub fn event_attr_escape(s: &str) -> String {
    s.replace('&', "&")
        .replace('<', "<")
        .replace('>', ">")
        .replace('"', """)
}

/// Inverse of [`event_attr_escape`]: decode an attribute value with the
/// amp-last invariant (`&`→`&` LAST). Each non-amp entity is unique so
/// their relative order is free; only amp-last matters — an id like `&lt;`
/// must decode to the literal `<`, not double-decode into `<`.
pub fn event_attr_unescape(s: &str) -> String {
    s.replace("<", "<")
        .replace(">", ">")
        .replace(""", "\"")
        .replace("&", "&") // amp-last
}

/// Extract the `from="..."` attribute from an `<EVENT ... from="ATTR" ...>`
/// envelope, unescaping with the amp-last invariant. Returns `None` on: no
/// `<EVENT ` tag, no `from=` attribute, empty `from=""`, or no closing quote.
// [impl->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
pub fn parse_event_from_attr(msg: &str) -> Option<String> {
    let event_start = msg.find("<EVENT ")?;
    let after_event = &msg[event_start..];
    let from_marker = "from=\"";
    let from_pos = after_event.find(from_marker)?;
    let value_start = from_pos + from_marker.len();
    let rest = &after_event[value_start..];
    let close_quote = rest.find('"')?;
    let escaped = &rest[..close_quote];
    if escaped.is_empty() {
        return None;
    }
    // Order: non-amp entities first, & LAST.
    let unescaped = escaped
        .replace("<", "<")
        .replace(">", ">")
        .replace(""", "\"")
        .replace("&", "&");
    Some(unescaped)
}

// ---------------------------------------------------------------------------
// Two-slice payload parser (KNOWN-HAZARDS 4.2 — panic-free + tolerant)
// ---------------------------------------------------------------------------

/// The two-slice payload produced by [`parse_two_slice`]. `live` is the body
/// of `<live-context>` if present; `project` is the body of `<project-context>`
/// if present. Either or both may be `None`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TwoSlicePayload {
    pub live: Option<String>,
    pub project: Option<String>,
}

/// Parse a raw Psyche output string into the two-slice taxonomy. Never panics:
/// every extraction is `Option`-based. Robustness rules: both/either/neither
/// tag, unclosed tags, out-of-order tags, empty bodies, surrounding whitespace,
/// and nested unknown tags preserved verbatim.
// [impl->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
pub fn parse_two_slice(raw: &str) -> TwoSlicePayload {
    let live = extract_tag(raw, "live-context");
    let project = extract_tag(raw, "project-context");

    // Fallback: no tags at all → whole payload routes to the live slot (trimmed).
    if live.is_none() && project.is_none() {
        let trimmed = raw.trim();
        return TwoSlicePayload {
            live: Some(trimmed.to_string()),
            project: None,
        };
    }

    TwoSlicePayload { live, project }
}

/// Find the first `<{tag}>...</{tag}>` and return the trimmed body. `None` when
/// either delimiter is absent or the close does not follow the open.
fn extract_tag(haystack: &str, tag: &str) -> Option<String> {
    let open = format!("<{}>", tag);
    let close = format!("</{}>", tag);
    let open_idx = haystack.find(&open)?;
    let body_start = open_idx + open.len();
    let after_open = haystack.get(body_start..)?;
    let close_off = after_open.find(&close)?;
    let body = &after_open[..close_off];
    Some(body.trim().to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    // ---- codec: amp-last invariant (KNOWN-HAZARDS 4.1) ----

    // [unit->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
    #[test]
    fn decode_is_amp_last_so_literal_entities_do_not_double_decode() {
        // User prose containing a literal entity reference for `<EVENT>`.
        // Encoded once, it must round-trip back to the literal text, NOT to a
        // live `<EVENT>` framing token.
        let user_prose = "<EVENT>"; // literally the five chars ampersand-l-t...
        let encoded = event_body_escape(user_prose);
        // `&` -> `&` first, so the ampersands become `&`:
        assert_eq!(encoded, "&lt;EVENT&gt;");
        let decoded = event_body_unescape(&encoded);
        assert_eq!(
            decoded, user_prose,
            "amp-last decode must NOT collapse &lt; into <",
        );
    }

    // [unit->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
    #[test]
    fn body_escape_unescape_round_trips_html_and_newlines() {
        let original = "a  c & d \"e\"\nsecond line";
        let round = event_body_unescape(&event_body_escape(original));
        assert_eq!(round, original);
    }

    // [unit->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
    #[test]
    fn from_attr_unescape_is_amp_last() {
        let msg = "<EVENT from=\"&lt;weird&gt;\" type=\"msg\">body</EVENT>";
        assert_eq!(parse_event_from_attr(msg).as_deref(), Some("<weird>"));
    }

    #[test]
    fn from_attr_returns_none_on_garbage_or_empty() {
        assert_eq!(parse_event_from_attr("no event here"), None);
        assert_eq!(parse_event_from_attr("<EVENT from=\"\">x</EVENT>"), None);
        assert_eq!(parse_event_from_attr("<EVENT from=\"unterminated"), None);
    }

    // ---- parser: panic-free + tolerant (KNOWN-HAZARDS 4.2) ----

    // [unit->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
    #[test]
    fn parser_both_tags_present() {
        let raw = "<live-context>\nagent slice\n</live-context>\n<project-context>\nproject slice\n</project-context>";
        let p = parse_two_slice(raw);
        assert_eq!(p.live.as_deref(), Some("agent slice"));
        assert_eq!(p.project.as_deref(), Some("project slice"));
    }

    // [unit->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
    #[test]
    fn parser_no_tags_falls_back_whole_payload_to_live() {
        let p = parse_two_slice("  plain prose, no tags\n");
        assert_eq!(p.live.as_deref(), Some("plain prose, no tags"));
        assert_eq!(p.project, None);
    }

    // [unit->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
    #[test]
    fn parser_unclosed_live_tag_does_not_scoop_project() {
        let raw =
            "<live-context>\noops no close\n<project-context>\nproject body\n</project-context>";
        let p = parse_two_slice(raw);
        assert_eq!(p.live, None);
        assert_eq!(p.project.as_deref(), Some("project body"));
    }

    // [unit->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
    #[test]
    fn parser_out_of_order_tags_both_extracted() {
        let raw =
            "<project-context>P first</project-context>\n<live-context>L after</live-context>";
        let p = parse_two_slice(raw);
        assert_eq!(p.live.as_deref(), Some("L after"));
        assert_eq!(p.project.as_deref(), Some("P first"));
    }

    // [unit->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
    #[test]
    fn parser_empty_body_is_some_empty_and_nested_tags_preserved() {
        let p = parse_two_slice("<live-context></live-context><project-context></project-context>");
        assert_eq!(p.live.as_deref(), Some(""));
        assert_eq!(p.project.as_deref(), Some(""));

        let nested = "<live-context>\nrole: x\nfoo\n</live-context>";
        let p2 = parse_two_slice(nested);
        assert_eq!(
            p2.live.as_deref(),
            Some("role: x\nfoo")
        );
    }
}

+/// CR-linesafe (REQ-HAZARD-ENVELOPE-CR-LINESAFE): the envelope is line-framed,

+/// so a raw `\r` in the body survives into the single-line EVENT and the

+/// receiver terminal does a CR→column-0 overwrite that corrupts the frame

+/// (observed cross-node from Windows, whose `echo`/CRLF text carries `\r`).

+/// `\r` was NEVER line-representable in this codec (only `\n`→`<br>`), so

+/// normalizing CRLF/lone-CR to the linebreak the codec CAN represent — BEFORE

+/// `\n`→`<br>` — is robustness on unrepresentable input, not a wire-format

+/// change: the decoder ([`event_body_unescape`]) is untouched and the amp-last


Now the regression test. Find the envelope test mod:


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && grep -nE "mod tests|fn .*round|event_body_escape|event_body_unescape" crates/spt-proto/src/envelope.rs | tail -12

find envelope tests


29:/// change: the decoder ([`event_body_unescape`]) is untouched and the amp-last
32:pub fn event_body_escape(s: &str) -> String {
42:/// Inverse of [`event_body_escape`]. Decode order MUST be `<br>` FIRST, then
50:pub fn event_body_unescape(s: &str) -> String {
58:/// HTML-escape for attribute values. Same order as [`event_body_escape`] but
151:mod tests {
163:        let encoded = event_body_escape(user_prose);
166:        let decoded = event_body_unescape(&encoded);
175:    fn body_escape_unescape_round_trips_html_and_newlines() {
177:        let round = event_body_unescape(&event_body_escape(original));


174	    #[test]
175	    fn body_escape_unescape_round_trips_html_and_newlines() {
176	        let original = "a < b > c & d \"e\"\nsecond line";
177	        let round = event_body_unescape(&event_body_escape(original));
178	        assert_eq!(round, original);
179	    }
180
181	    // [unit->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
182	    #[test]
183	    fn from_attr_unescape_is_amp_last() {
184	        let msg = "<EVENT from=\"&lt;weird&gt;\" type=\"msg\">body</EVENT>";
185	        assert_eq!(parse_event_from_attr(msg).as_deref(), Some("<weird>"));
186	    }
187

    #[test]
    fn body_escape_unescape_round_trips_html_and_newlines() {
        let original = "a < b > c & d \"e\"\nsecond line";
        let round = event_body_unescape(&event_body_escape(original));
        assert_eq!(round, original);
    }

    // [unit->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
    #[test]
    fn from_attr_unescape_is_amp_last() {
        let msg = "<EVENT from=\"&lt;weird&gt;\" type=\"msg\">body</EVENT>";
        assert_eq!(parse_event_from_attr(msg).as_deref(), Some("<weird>"));
    }


Edit

    // [unit->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
    #[test]
    fn from_attr_unescape_is_amp_last() {

// [unit->REQ-HAZARD-ENVELOPE-CR-LINESAFE] a body carrying raw CR (CRLF from
    // Windows, or a lone `\r`) escapes to a line-framed EVENT body with NO raw
    // `\r` left to corrupt the receiver's terminal; CR/CRLF both fold to the
    // `<br>` linebreak and decode back to `\n` (CR was never representable, so
    // round-trip is to the normalized `\n` form).
    #[test]
    fn body_escape_is_cr_linesafe() {
        let escaped = event_body_escape("hey\r\nthere\rworld");
        assert!(!escaped.contains('\r'), "no raw CR survives into the EVENT line");
        assert_eq!(escaped, "hey
there
world");
        assert_eq!(event_body_unescape(&escaped), "hey\nthere\nworld");
        // A trailing CRLF (the `echo | spt send` case) doesn't leave a stray CR.
        assert_eq!(event_body_escape("done\r\n"), "done
");
    }

    // [unit->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
    #[test]
    fn from_attr_unescape_is_amp_last() {


// [unit->REQ-HAZARD-ENVELOPE-CR-LINESAFE] a body carrying raw CR (CRLF from
    // Windows, or a lone `\r`) escapes to a line-framed EVENT body with NO raw
    // `\r` left to corrupt the receiver's terminal; CR/CRLF both fold to the
    // `<br>` linebreak and decode back to `\n` (CR was never representable, so
    // round-trip is to the normalized `\n` form).
    #[test]
    fn body_escape_is_cr_linesafe() {
        let escaped = event_body_escape("hey\r\nthere\rworld");
        assert!(!escaped.contains('\r'), "no raw CR survives into the EVENT line");
        assert_eq!(escaped, "hey
there
world");
        assert_eq!(event_body_unescape(&escaped), "hey\nthere\nworld");
        // A trailing CRLF (the `echo | spt send` case) doesn't leave a stray CR.
        assert_eq!(event_body_escape("done\r\n"), "done
");
    }

    // [unit->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
    #[test]
    fn from_attr_unescape_is_amp_last() {

+    // [unit->REQ-HAZARD-ENVELOPE-CR-LINESAFE] a body carrying raw CR (CRLF from

+    // Windows, or a lone `\r`) escapes to a line-framed EVENT body with NO raw

+    // `\r` left to corrupt the receiver's terminal; CR/CRLF both fold to the

+    // `<br>` linebreak and decode back to `\n` (CR was never representable, so

+    // round-trip is to the normalized `\n` form).

+        let escaped = event_body_escape("hey\r\nthere\rworld");

+        assert!(!escaped.contains('\r'), "no raw CR survives into the EVENT line");

+        assert_eq!(escaped, "hey
there
world");

+        assert_eq!(event_body_unescape(&escaped), "hey\nthere\nworld");

+        // A trailing CRLF (the `echo | spt send` case) doesn't leave a stray CR.

+        assert_eq!(event_body_escape("done\r\n"), "done<br>");


Now the cmd_send + cmd_ring stdin trim (parity with cmd_notify):


    let from = resolve_from(from);
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    let outcome = if deferred {
        deliver::send_deferred(&target, &from, &body)
    } else {
        deliver::send(&target, &from, &body, &owlery)
    };

    let from = resolve_from(from);
    // Trim stdin like cmd_notify (REQ-HAZARD-ENVELOPE-CR-LINESAFE parity): a
    // Windows `echo | spt send` pipes a trailing CRLF — strip it so the message
    // has no trailing blank line (the codec also neutralizes any interior CR).
    let body = read_stdin();
    let body = body.trim();
    let owlery = perch::owlery_dir();

    let outcome = if deferred {
        deliver::send_deferred(&target, &from, body)
    } else {
        deliver::send(&target, &from, body, &owlery)
    };


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

    let from = resolve_from(from);
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    let outcome = if deferred {
        deliver::send_deferred(&target, &from, &body)
    } else {
        deliver::send(&target, &from, &body, &owlery)
    };

    let from = resolve_from(from);
    // Trim stdin like cmd_notify (REQ-HAZARD-ENVELOPE-CR-LINESAFE parity): a
    // Windows `echo | spt send` pipes a trailing CRLF — strip it so the message
    // has no trailing blank line (the codec also neutralizes any interior CR).
    let body = read_stdin();
    let body = body.trim();
    let owlery = perch::owlery_dir();

    let outcome = if deferred {
        deliver::send_deferred(&target, &from, body)
    } else {
        deliver::send(&target, &from, body, &owlery)
    };

+    // Trim stdin like cmd_notify (REQ-HAZARD-ENVELOPE-CR-LINESAFE parity): a

+    // Windows `echo | spt send` pipes a trailing CRLF — strip it so the message

+    // has no trailing blank line (the codec also neutralizes any interior CR).

-        deliver::send_deferred(&target, &from, &body)

+        deliver::send_deferred(&target, &from, body)

-        deliver::send(&target, &from, &body, &owlery)

+        deliver::send(&target, &from, body, &owlery)


    let body = read_stdin();
    let owlery = perch::owlery_dir();

    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {

    // Trim stdin (REQ-HAZARD-ENVELOPE-CR-LINESAFE parity with cmd_notify/send).
    let body = read_stdin();
    let body = body.trim();
    let owlery = perch::owlery_dir();

    match ring::ring(&target, &from, body, Duration::from_secs(timeout), &owlery) {


    let body = read_stdin();
    let owlery = perch::owlery_dir();

    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {

    // Trim stdin (REQ-HAZARD-ENVELOPE-CR-LINESAFE parity with cmd_notify/send).
    let body = read_stdin();
    let body = body.trim();
    let owlery = perch::owlery_dir();

    match ring::ring(&target, &from, body, Duration::from_secs(timeout), &owlery) {

eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");

+    // Trim stdin (REQ-HAZARD-ENVELOPE-CR-LINESAFE parity with cmd_notify/send).

-    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {

+    match ring::ring(&target, &from, body, Duration::from_secs(timeout), &owlery) {


Mint the REQ + KNOWN-HAZARDS entry. Locate the sibling hazard entries:


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && grep -nE "REQ-HAZARD-ENVELOPE-DECODE-ORDER" traceable-reqs.toml; echo "=== KH 4.1 region ==="; grep -nE "^### 4\.1|^## 4|amp.*last|EVENT.*codec|4\.1" docs/KNOWN-HAZARDS.md | head

find hazard req + KH doc anchor