Write

//! Inbound dispatch E2E (D9-1-1): the serve half of every wire protocol runs
//! **undriven** — no probe brain hand-wires `serve_*`; the daemon-side
//! dispatcher claims each peer-initiated stream, classifies it by first
//! record, and serves it. This is the production behavior the D5–D8 E2E
//! suites deferred ("the daemon dispatch").
//!
//! Topology per test: two hermetic loopback brokers (requester A, responder
//! B). B runs `run_dispatch_loop` like the real daemon; B's gates load from
//! the **canonical** SPT_HOME stores (the production path), so each test
//! seeds subnet/trust/visibility files first. Tests serialize on one lock —
//! they share the process's SPT_HOME.

use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::Duration;

use spt_daemon::brain::Brain;
use spt_daemon::broker::Broker;
use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::propagate::{request_update, UpdatePullOutcome};
use spt_daemon::relcache::ReleaseCache;
use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease, VerifyPolicy};
use spt_daemon::sync::request_sync;
use spt_daemon::update::{BrokerAbi, BROKER_RESOURCE_ABI, IPC_PROTOCOL_VERSION};
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::notif::NotifRecord;
use spt_net::net::wanmsg::WanMessage;
use spt_proto::identity::Identity;
use spt_store::contextstore::ContextStore;
use spt_store::info::{self, InfoJson};
use spt_store::notif::{NotifRow, NotifStore};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;
use tempfile::TempDir;

/// One SPT_HOME for the whole binary; tests serialize on [`LOCK`] because the
/// dispatcher's gates read the canonical stores under it.
static HOME: OnceLock<TempDir> = OnceLock::new();
static LOCK: Mutex<()> = Mutex::new(());
fn init_home() {
    HOME.get_or_init(|| {
        let dir = TempDir::new().expect("temp dir");
        std::env::set_var("SPT_HOME", dir.path());
        dir
    });
}

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-d91-{}-{}.sock", std::process::id(), n)
}

static OP: AtomicU64 = AtomicU64::new(1);
fn op() -> u64 {
    OP.fetch_add(1, Ordering::Relaxed)
}

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

fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic()).expect("net host start");
    let broker =
        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

fn connect_retry(name: &str) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain could not connect");
}

/// Stand B's dispatcher up exactly as the daemon does, returning its stop
/// flag (set on test exit so the loop thread winds down).
fn spawn_dispatcher_for(broker_name: &str, scratch: &std::path::Path) -> Arc<AtomicBool> {
    let stop = Arc::new(AtomicBool::new(false));
    let flag = Arc::clone(&stop);
    let name = broker_name.to_string();
    let home = perch::spt_home();
    let paths = DispatchPaths {
        owlery: perch::owlery_dir(),
        releases: home.join("releases"),
        scratch: scratch.to_path_buf(),
    };
    thread::spawn(move || {
        let _ = run_dispatch_loop(&name, &paths, &flag, Duration::from_millis(25));
    });
    stop
}

/// Seed B's canonical gate stores: a subnet, A's pubkey trusted in it, and
/// (optionally) endpoint ids synced there. The dispatcher's serve arms load
/// these from SPT_HOME — the production read path.
fn seed_gates(subnet: &str, peer_hex: &str, synced_ids: &[&str]) {
    let mut subnets = SubnetStore::load();
    if !subnets.subnets.iter().any(|s| s.name == subnet) {
        subnets.create_subnet(subnet).expect("create subnet");
        subnets.save().expect("save subnets");
    }
    let mut trust = TrustStore::load();
    trust.record(subnet, peer_hex, None, None);
    trust.save().expect("save trust");
    let mut vis = VisibilityStore::load();
    for id in synced_ids {
        vis.set_sync_subnets(id, vec![subnet.to_string()]);
    }
    vis.save().expect("save visibility");
}

fn node_hex(brain: &mut Brain) -> String {
    brain.net_status().expect("status").node_id_hex.expect("net enabled")
}

/// Marker-stamped context body (the live write path stamps these in
/// production; the vector makes the merge driver's dominance decidable).
fn stamped(body: &str) -> String {
    format!("<!-- spt:source=llm spt:routed_at_ms=1000 spt:node=t spt:vector=t:1 -->\n{body}")
}

// [int->REQ-INST-5] a sync pull is served END TO END by the dispatcher: A
// dials, requests, and receives the branch with NOBODY hand-wiring
// serve_sync at B. The gate ran from B's canonical stores.
#[test]
fn dispatcher_serves_a_sync_pull_undriven() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    // B's canonical context store holds the mind A will pull.
    let id = "ling-d91-sync";
    let branch = format!("a-{id}");
    let cs_b = ContextStore::open_or_init().expect("B context store");
    let p = cs_b.live_context_path(id).unwrap();
    std::fs::write(&p, stamped("the synced mind")).unwrap();
    cs_b.commit_live(id, "seed").unwrap();

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    seed_gates("d91-sync", &node_hex(&mut a), &[id]);

    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");
    let cs_a_root = dir.path().join("a-ctx");
    let cs_a = ContextStore::open_or_init_in(&cs_a_root).expect("A context store");
    let report = request_sync(
        &mut a,
        conn.conn_id,
        &[branch.clone()],
        op(),
        &cs_a,
        &dir.path().join("scratch-a"),
    )
    .expect("pull");

    assert!(
        report.fetched.iter().any(|r| r == &branch),
        "the dispatcher served the pull undriven: {report:?}"
    );
    let tip = cs_a.branch_store().tip(&branch).expect("tip query");
    assert!(tip.is_some(), "A holds the pulled branch");
    stop.store(true, Ordering::Relaxed);
}

// [int->REQ-UPD-1] an update pull is served end to end by the dispatcher:
// B stages a release, A queries/fetches/verifies/stages — no hand-wired
// serve_update. The trust gate ran from B's canonical TrustStore.
#[test]
fn dispatcher_serves_an_update_pull_undriven() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    // Stage v6 at B (in the canonical releases dir the dispatcher opens).
    use ed25519_dalek::{Signer, SigningKey};
    let sk = SigningKey::from_bytes(&[7u8; 32]);
    let artifact = b"dispatch-served binary".to_vec();
    let meta = ReleaseMetadata {
        version: 6,
        channel: "stable".to_string(),
        expires_at_ms: u64::MAX,
        key_id: "k".to_string(),
        artifact_sha256: sha256_hex(&artifact),
        brain_ipc_version: IPC_PROTOCOL_VERSION,
        broker_resource_abi: BROKER_RESOURCE_ABI,
    };
    let metadata_json = serde_json::to_string(&meta).unwrap();
    let sig = sk.sign(metadata_json.as_bytes());
    let signed = SignedRelease {
        metadata_json,
        signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
    };
    let releases_b = perch::spt_home().join("releases");
    ReleaseCache::open(&releases_b).stage(&signed, &artifact).expect("stage at B");

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    seed_gates("d91-upd", &node_hex(&mut a), &[]);

    let mut trusted = std::collections::BTreeMap::new();
    trusted.insert("k".to_string(), sk.verifying_key());
    let policy = VerifyPolicy {
        trusted_keys: trusted,
        revoked_keys: Default::default(),
        pinned_channel: "stable".to_string(),
        current_version: 5,
        now_ms: 1_000,
    };

    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");
    let cache_a = ReleaseCache::open(&dir.path().join("releases-a"));
    let outcome = request_update(
        &mut a,
        conn.conn_id,
        op(),
        &BrokerAbi::current(),
        &policy,
        &cache_a,
        &dir.path().join("scratch-a"),
    )
    .expect("pull");

    assert!(
        matches!(outcome, UpdatePullOutcome::Updated(_)),
        "the dispatcher served the update undriven: {outcome:?}"
    );
    assert_eq!(
        cache_a.load_artifact().expect("staged bytes"),
        artifact,
        "byte-identical artifact staged at A through the full gate"
    );
    stop.store(true, Ordering::Relaxed);
}

// [int->REQ-NOTIF-1] a notif feed is applied end to end by the dispatcher:
// A pushes a row, B's canonical store converges — no hand-wired apply loop.
#[test]
fn dispatcher_applies_a_notif_feed_undriven() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    seed_gates("d91-notif", &node_hex(&mut a), &[]);

    let row = NotifRow {
        notif_id: "aabb:42".to_string(),
        subnet: "d91-notif".to_string(),
        kind: "agent".to_string(),
        from_id: "oak@aabb".to_string(),
        body: "dispatched hello".to_string(),
        created_ms: 1_000,
        dismissed: false,
        seen: Default::default(),
        last_surfaced_ms: 0,
    };
    let line = spt_net::net::ndjson::encode_line(&NotifRecord::Row { row });

    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");
    let opened = a.net_open_stream(conn.conn_id, Some(op())).expect("open");
    a.net_stream_send(opened.stream_id, &line, None, true).expect("send feed");

    // The dispatcher applies asynchronously — poll B's canonical store.
    let store_b = NotifStore::open().expect("B notif store");
    let mut found = false;
    for _ in 0..400 {
        let rows = store_b.list("d91-notif").expect("list");
        if rows.iter().any(|r| r.notif_id == "aabb:42" && r.body == "dispatched hello") {
            found = true;
            break;
        }
        thread::sleep(Duration::from_millis(25));
    }
    assert!(found, "the dispatcher applied the feed into B's canonical store");
    stop.store(true, Ordering::Relaxed);
}

// [int->REQ-NET-1] a WAN message feed lands in B's perch spool end to end —
// and the direction fact is recorded right: A's table shows its opened
// stream as locally-initiated; B's shows the same stream as peer-initiated
// (what the dispatcher claimed it by).
#[test]
fn dispatcher_funnels_wan_messages_undriven() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    // An offline perch at B so the funnel takes the atomic spool path.
    let target = "ling-d91-wan";
    let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).expect("perch dir");
    let rec = InfoJson::new(target, "now", std::process::id(), "sess", "ready_agent");
    info::write_info(&perch_path, &rec).expect("info.json");

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);

    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");
    let opened = a.net_open_stream(conn.conn_id, Some(op())).expect("open");
    let msg = WanMessage {
        target: target.to_string(),
        from: "doyle".to_string(),
        body: "undriven hello".to_string(),
        op_id: "d91-wan-1".to_string(),
    };
    a.net_stream_send(opened.stream_id, &spt_net::net::ndjson::encode_line(&msg), None, true)
        .expect("send msg");

    // Direction fact, both sides of the same stream.
    let a_rows = a.net_streams().expect("A streams").streams;
    assert!(
        a_rows.iter().any(|s| s.stream_id == opened.stream_id && s.initiated_locally),
        "A's own open is locally-initiated in A's table"
    );
    let b_rows = b_probe.net_streams().expect("B streams").streams;
    assert!(
        b_rows.iter().all(|s| !s.initiated_locally),
        "every row at B is peer-initiated (B opened nothing)"
    );

    let mut spooled = false;
    for _ in 0..400 {
        let pending = spool::peek_all_at(&perch_path).expect("peek");
        if pending.iter().any(|(_, _, body)| body == "undriven hello") {
            spooled = true;
            break;
        }
        thread::sleep(Duration::from_millis(25));
    }
    assert!(spooled, "the dispatcher funneled the WAN message into the spool");
    stop.store(true, Ordering::Relaxed);
}

// [unit->REQ-NET-1] an unclassifiable stream is dropped without wedging the
// dispatcher: garbage first, then a valid notif feed still converges.
#[test]
fn dispatcher_survives_an_unknown_stream() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    seed_gates("d91-unk", &node_hex(&mut a), &[]);

    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");

    // Garbage stream: not JSON at all.
    let junk = a.net_open_stream(conn.conn_id, Some(op())).expect("open junk");
    a.net_stream_send(junk.stream_id, b"this is not a protocol\n", None, true).expect("junk");

    // A valid feed afterward still lands — the loop did not die on the junk.
    let row = NotifRow {
        notif_id: "ccdd:7".to_string(),
        subnet: "d91-unk".to_string(),
        kind: "agent".to_string(),
        from_id: "oak@ccdd".to_string(),
        body: "after the junk".to_string(),
        created_ms: 1_000,
        dismissed: false,
        seen: Default::default(),
        last_surfaced_ms: 0,
    };
    let line = spt_net::net::ndjson::encode_line(&NotifRecord::Row { row });
    let opened = a.net_open_stream(conn.conn_id, Some(op())).expect("open feed");
    a.net_stream_send(opened.stream_id, &line, None, true).expect("send feed");

    let store_b = NotifStore::open().expect("B notif store");
    let mut found = false;
    for _ in 0..400 {
        if store_b.list("d91-unk").expect("list").iter().any(|r| r.notif_id == "ccdd:7") {
            found = true;
            break;
        }
        thread::sleep(Duration::from_millis(25));
    }
    assert!(found, "the dispatcher survived the unknown stream and served the next one");
    stop.store(true, Ordering::Relaxed);
}


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

//! Inbound dispatch E2E (D9-1-1): the serve half of every wire protocol runs
//! **undriven** — no probe brain hand-wires `serve_*`; the daemon-side
//! dispatcher claims each peer-initiated stream, classifies it by first
//! record, and serves it. This is the production behavior the D5–D8 E2E
//! suites deferred ("the daemon dispatch").
//!
//! Topology per test: two hermetic loopback brokers (requester A, responder
//! B). B runs `run_dispatch_loop` like the real daemon; B's gates load from
//! the **canonical** SPT_HOME stores (the production path), so each test
//! seeds subnet/trust/visibility files first. Tests serialize on one lock —
//! they share the process's SPT_HOME.

use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::Duration;

use spt_daemon::brain::Brain;
use spt_daemon::broker::Broker;
use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::propagate::{request_update, UpdatePullOutcome};
use spt_daemon::relcache::ReleaseCache;
use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease, VerifyPolicy};
use spt_daemon::sync::request_sync;
use spt_daemon::update::{BrokerAbi, BROKER_RESOURCE_ABI, IPC_PROTOCOL_VERSION};
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::notif::NotifRecord;
use spt_net::net::wanmsg::WanMessage;
use spt_proto::identity::Identity;
use spt_store::contextstore::ContextStore;
use spt_store::info::{self, InfoJson};
use spt_store::notif::{NotifRow, NotifStore};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;
use tempfile::TempDir;

/// One SPT_HOME for the whole binary; tests serialize on [`LOCK`] because the
/// dispatcher's gates read the canonical stores under it.
static HOME: OnceLock<TempDir> = OnceLock::new();
static LOCK: Mutex<()> = Mutex::new(());
fn init_home() {
    HOME.get_or_init(|| {
        let dir = TempDir::new().expect("temp dir");
        std::env::set_var("SPT_HOME", dir.path());
        dir
    });
}

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-d91-{}-{}.sock", std::process::id(), n)
}

static OP: AtomicU64 = AtomicU64::new(1);
fn op() -> u64 {
    OP.fetch_add(1, Ordering::Relaxed)
}

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

fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic()).expect("net host start");
    let broker =
        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

fn connect_retry(name: &str) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain could not connect");
}

/// Stand B's dispatcher up exactly as the daemon does, returning its stop
/// flag (set on test exit so the loop thread winds down).
fn spawn_dispatcher_for(broker_name: &str, scratch: &std::path::Path) -> Arc<AtomicBool> {
    let stop = Arc::new(AtomicBool::new(false));
    let flag = Arc::clone(&stop);
    let name = broker_name.to_string();
    let home = perch::spt_home();
    let paths = DispatchPaths {
        owlery: perch::owlery_dir(),
        releases: home.join("releases"),
        scratch: scratch.to_path_buf(),
    };
    thread::spawn(move || {
        let _ = run_dispatch_loop(&name, &paths, &flag, Duration::from_millis(25));
    });
    stop
}

/// Seed B's canonical gate stores: a subnet, A's pubkey trusted in it, and
/// (optionally) endpoint ids synced there. The dispatcher's serve arms load
/// these from SPT_HOME — the production read path.
fn seed_gates(subnet: &str, peer_hex: &str, synced_ids: &[&str]) {
    let mut subnets = SubnetStore::load();
    if !subnets.subnets.iter().any(|s| s.name == subnet) {
        subnets.create_subnet(subnet).expect("create subnet");
        subnets.save().expect("save subnets");
    }
    let mut trust = TrustStore::load();
    trust.record(subnet, peer_hex, None, None);
    trust.save().expect("save trust");
    let mut vis = VisibilityStore::load();
    for id in synced_ids {
        vis.set_sync_subnets(id, vec![subnet.to_string()]);
    }
    vis.save().expect("save visibility");
}

fn node_hex(brain: &mut Brain) -> String {
    brain.net_status().expect("status").node_id_hex.expect("net enabled")
}

/// Marker-stamped context body (the live write path stamps these in
/// production; the vector makes the merge driver's dominance decidable).
fn stamped(body: &str) -> String {
    format!("<!-- spt:source=llm spt:routed_at_ms=1000 spt:node=t spt:vector=t:1 -->\n{body}")
}

// [int->REQ-INST-5] a sync pull is served END TO END by the dispatcher: A
// dials, requests, and receives the branch with NOBODY hand-wiring
// serve_sync at B. The gate ran from B's canonical stores.
#[test]
fn dispatcher_serves_a_sync_pull_undriven() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    // B's canonical context store holds the mind A will pull.
    let id = "ling-d91-sync";
    let branch = format!("a-{id}");
    let cs_b = ContextStore::open_or_init().expect("B context store");
    let p = cs_b.live_context_path(id).unwrap();
    std::fs::write(&p, stamped("the synced mind")).unwrap();
    cs_b.commit_live(id, "seed").unwrap();

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    seed_gates("d91-sync", &node_hex(&mut a), &[id]);

    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");
    let cs_a_root = dir.path().join("a-ctx");
    let cs_a = ContextStore::open_or_init_in(&cs_a_root).expect("A context store");
    let report = request_sync(
        &mut a,
        conn.conn_id,
        &[branch.clone()],
        op(),
        &cs_a,
        &dir.path().join("scratch-a"),
    )
    .expect("pull");

    assert!(
        report.fetched.iter().any(|r| r == &branch),
        "the dispatcher served the pull undriven: {report:?}"
    );
    let tip = cs_a.branch_store().tip(&branch).expect("tip query");
    assert!(tip.is_some(), "A holds the pulled branch");
    stop.store(true, Ordering::Relaxed);
}

// [int->REQ-UPD-1] an update pull is served end to end by the dispatcher:
// B stages a release, A queries/fetches/verifies/stages — no hand-wired
// serve_update. The trust gate ran from B's canonical TrustStore.
#[test]
fn dispatcher_serves_an_update_pull_undriven() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    // Stage v6 at B (in the canonical releases dir the dispatcher opens).
    use ed25519_dalek::{Signer, SigningKey};
    let sk = SigningKey::from_bytes(&[7u8; 32]);
    let artifact = b"dispatch-served binary".to_vec();
    let meta = ReleaseMetadata {
        version: 6,
        channel: "stable".to_string(),
        expires_at_ms: u64::MAX,
        key_id: "k".to_string(),
        artifact_sha256: sha256_hex(&artifact),
        brain_ipc_version: IPC_PROTOCOL_VERSION,
        broker_resource_abi: BROKER_RESOURCE_ABI,
    };
    let metadata_json = serde_json::to_string(&meta).unwrap();
    let sig = sk.sign(metadata_json.as_bytes());
    let signed = SignedRelease {
        metadata_json,
        signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
    };
    let releases_b = perch::spt_home().join("releases");
    ReleaseCache::open(&releases_b).stage(&signed, &artifact).expect("stage at B");

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    seed_gates("d91-upd", &node_hex(&mut a), &[]);

    let mut trusted = std::collections::BTreeMap::new();
    trusted.insert("k".to_string(), sk.verifying_key());
    let policy = VerifyPolicy {
        trusted_keys: trusted,
        revoked_keys: Default::default(),
        pinned_channel: "stable".to_string(),
        current_version: 5,
        now_ms: 1_000,
    };

    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");
    let cache_a = ReleaseCache::open(&dir.path().join("releases-a"));
    let outcome = request_update(
        &mut a,
        conn.conn_id,
        op(),
        &BrokerAbi::current(),
        &policy,
        &cache_a,
        &dir.path().join("scratch-a"),
    )
    .expect("pull");

    assert!(
        matches!(outcome, UpdatePullOutcome::Updated(_)),
        "the dispatcher served the update undriven: {outcome:?}"
    );
    assert_eq!(
        cache_a.load_artifact().expect("staged bytes"),
        artifact,
        "byte-identical artifact staged at A through the full gate"
    );
    stop.store(true, Ordering::Relaxed);
}

// [int->REQ-NOTIF-1] a notif feed is applied end to end by the dispatcher:
// A pushes a row, B's canonical store converges — no hand-wired apply loop.
#[test]
fn dispatcher_applies_a_notif_feed_undriven() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    seed_gates("d91-notif", &node_hex(&mut a), &[]);

    let row = NotifRow {
        notif_id: "aabb:42".to_string(),
        subnet: "d91-notif".to_string(),
        kind: "agent".to_string(),
        from_id: "oak@aabb".to_string(),
        body: "dispatched hello".to_string(),
        created_ms: 1_000,
        dismissed: false,
        seen: Default::default(),
        last_surfaced_ms: 0,
    };
    let line = spt_net::net::ndjson::encode_line(&NotifRecord::Row { row });

    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");
    let opened = a.net_open_stream(conn.conn_id, Some(op())).expect("open");
    a.net_stream_send(opened.stream_id, &line, None, true).expect("send feed");

    // The dispatcher applies asynchronously — poll B's canonical store.
    let store_b = NotifStore::open().expect("B notif store");
    let mut found = false;
    for _ in 0..400 {
        let rows = store_b.list("d91-notif").expect("list");
        if rows.iter().any(|r| r.notif_id == "aabb:42" && r.body == "dispatched hello") {
            found = true;
            break;
        }
        thread::sleep(Duration::from_millis(25));
    }
    assert!(found, "the dispatcher applied the feed into B's canonical store");
    stop.store(true, Ordering::Relaxed);
}

// [int->REQ-NET-1] a WAN message feed lands in B's perch spool end to end —
// and the direction fact is recorded right: A's table shows its opened
// stream as locally-initiated; B's shows the same stream as peer-initiated
// (what the dispatcher claimed it by).
#[test]
fn dispatcher_funnels_wan_messages_undriven() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    // An offline perch at B so the funnel takes the atomic spool path.
    let target = "ling-d91-wan";
    let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).expect("perch dir");
    let rec = InfoJson::new(target, "now", std::process::id(), "sess", "ready_agent");
    info::write_info(&perch_path, &rec).expect("info.json");

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);

    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");
    let opened = a.net_open_stream(conn.conn_id, Some(op())).expect("open");
    let msg = WanMessage {
        target: target.to_string(),
        from: "doyle".to_string(),
        body: "undriven hello".to_string(),
        op_id: "d91-wan-1".to_string(),
    };
    a.net_stream_send(opened.stream_id, &spt_net::net::ndjson::encode_line(&msg), None, true)
        .expect("send msg");

    // Direction fact, both sides of the same stream.
    let a_rows = a.net_streams().expect("A streams").streams;
    assert!(
        a_rows.iter().any(|s| s.stream_id == opened.stream_id && s.initiated_locally),
        "A's own open is locally-initiated in A's table"
    );
    let b_rows = b_probe.net_streams().expect("B streams").streams;
    assert!(
        b_rows.iter().all(|s| !s.initiated_locally),
        "every row at B is peer-initiated (B opened nothing)"
    );

    let mut spooled = false;
    for _ in 0..400 {
        let pending = spool::peek_all_at(&perch_path).expect("peek");
        if pending.iter().any(|(_, _, body)| body == "undriven hello") {
            spooled = true;
            break;
        }
        thread::sleep(Duration::from_millis(25));
    }
    assert!(spooled, "the dispatcher funneled the WAN message into the spool");
    stop.store(true, Ordering::Relaxed);
}

// [unit->REQ-NET-1] an unclassifiable stream is dropped without wedging the
// dispatcher: garbage first, then a valid notif feed still converges.
#[test]
fn dispatcher_survives_an_unknown_stream() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    seed_gates("d91-unk", &node_hex(&mut a), &[]);

    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");

    // Garbage stream: not JSON at all.
    let junk = a.net_open_stream(conn.conn_id, Some(op())).expect("open junk");
    a.net_stream_send(junk.stream_id, b"this is not a protocol\n", None, true).expect("junk");

    // A valid feed afterward still lands — the loop did not die on the junk.
    let row = NotifRow {
        notif_id: "ccdd:7".to_string(),
        subnet: "d91-unk".to_string(),
        kind: "agent".to_string(),
        from_id: "oak@ccdd".to_string(),
        body: "after the junk".to_string(),
        created_ms: 1_000,
        dismissed: false,
        seen: Default::default(),
        last_surfaced_ms: 0,
    };
    let line = spt_net::net::ndjson::encode_line(&NotifRecord::Row { row });
    let opened = a.net_open_stream(conn.conn_id, Some(op())).expect("open feed");
    a.net_stream_send(opened.stream_id, &line, None, true).expect("send feed");

    let store_b = NotifStore::open().expect("B notif store");
    let mut found = false;
    for _ in 0..400 {
        if store_b.list("d91-unk").expect("list").iter().any(|r| r.notif_id == "ccdd:7") {
            found = true;
            break;
        }
        thread::sleep(Duration::from_millis(25));
    }
    assert!(found, "the dispatcher survived the unknown stream and served the next one");
    stop.store(true, Ordering::Relaxed);
}


Bash

cargo test -p spt-daemon --test dispatch --no-run 2>&1 | grep -E "^error|warning: unused" | head -30

Compile dispatch test


error[E0603]: constant `IPC_PROTOCOL_VERSION` is private
error[E0609]: no field `fetched` on type `SyncPullReport`
error[E0308]: mismatched types
error: could not compile `spt-daemon` (test "dispatch") due to 3 previous errors


D9-1: production trigger loops + VerifyPolicy + spt send WAN CLI

peerloop.rs pump host (sync/update/notif/reconcile thin calls over existing drivers), DaemonConfig knobs, production VerifyPolicy assembly, spt send WAN leg, loopback E2E proving loops fire undriven. Gotchas #1/#9.

D9-2: resting-state model + transition echo + wake-edge resurface

State machine (active→dormant→suspended, wake), Status::Suspended, transition echo commune (KH 3.3), resurface_at_boundary(Wake), freshness pull trigger. REQ-INST-3/4 → [impl,unit]; KH 7.4 if multi-agent echo scheduling.

D9-3: dormancy budget measurement + warm/cold policy lock

SPT_BUDGET=1 harness on real adapters (both rig hosts), docs/DORMANCY-BUDGET.md, policy lock, ADR-0003 #9 closed. doc→REQ-INST-3.

D9-4: REQ-INST-14 resource advertisement

resources field on Instance, spt resources set/show/list projection (visibility-gated). [doc,impl,unit].

D9-5: REQ-INST-15 immutable home subnet + spt fork

Home-at-creation (auto/ask/local-until-join, no setter), sync_subnets=[home] seed, adapter-at-creation rule, spt fork copy-then-diverge (--delete-source; same-node v1). [doc,impl,unit].

D9-6: two-host E2E + runbook + gh-repo interim retirement

SPT_TWO_HOST=1 int tests (full ladder), docs/TWO-HOST-RUNBOOK.md, run on rig, int stage activations, retire gh-repo-sync interim.

D9-7: closeout sweep

required_stages audit, traceable-reqs green, ROADMAP M4-delivered, CONTEXT amendments, plan-status COMPLETE, CI matrix FINAL green.


cargo test -p spt-daemon --test dispatch --no-run 2>&1 | grep -B2 -A8 "^error" | head -50; echo ===; grep -n "pub struct SyncPullReport" -A 10 crates/spt-daemon/src/sync.rs; grep -rn "IPC_PROTOCOL_VERSION\|BROKER_RESOURCE_ABI" crates/spt-daemon/tests/propagate.rs | head -4

Error details + correct field/const names


   Compiling spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
error[E0603]: constant `IPC_PROTOCOL_VERSION` is private
  --> crates\spt-daemon\tests\dispatch.rs:26:58
   |
26 | use spt_daemon::update::{BrokerAbi, BROKER_RESOURCE_ABI, IPC_PROTOCOL_VERSION};
   |                                                          ^^^^^^^^^^^^^^^^^^^^ private constant
   |
note: the constant `IPC_PROTOCOL_VERSION` is defined here
  --> C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\update.rs:31:20
   |
--
   |                                                          +++++++++++++++++++

error[E0609]: no field `fetched` on type `SyncPullReport`
   --> crates\spt-daemon\tests\dispatch.rs:181:16
    |
181 |         report.fetched.iter().any(|r| r == &branch),
    |                ^^^^^^^ unknown field
    |
    = note: available fields are: `applied`, `denied`

error[E0308]: mismatched types
   --> crates\spt-daemon\tests\dispatch.rs:364:32
    |
364 |         if pending.iter().any(|(_, _, body)| body == "undriven hello") {
    |                                ^^^^^^^^^^^^
    |                                |
    |                                expected a tuple with 4 elements, found one with 3 elements
    |                                expected due to this
    |
--
Some errors have detailed explanations: E0308, E0603, E0609.
For more information about an error, try `rustc --explain E0308`.
error: could not compile `spt-daemon` (test "dispatch") due to 3 previous errors
===
124:pub struct SyncPullReport {
125-    /// Per-branch apply verdicts (accept/drop/conflict per file).
126-    pub applied: Vec<ApplyReport>,
127-    /// Refs the responder's gate refused (fail-closed on its side).
128-    pub denied: Vec<String>,
129-}
130-
131-impl SyncPullReport {
132-    /// Every `(branch, file)` surfaced as a concurrent conflict by this pull —
133-    /// the reconcile wiring's input.
134-    pub fn conflicts(&self) -> Vec<(String, String)> {
24:use spt_daemon::update::{BrokerAbi, UpdateClass, BROKER_RESOURCE_ABI};
105:        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
106:        broker_resource_abi: BROKER_RESOURCE_ABI,


grep -n "pub struct ApplyReport" -A 8 crates/spt-store/src/syncmerge.rs | head -12; grep -n "pub fn peek_all_at" -A 6 crates/spt-store/src/spool.rs | head -10

ApplyReport + peek_all_at shapes


39:pub struct ApplyReport {
40-    pub branch: String,
41-    /// Files whose incoming version landed (worktree-relative).
42-    pub accepted: Vec<String>,
43-    /// Files whose incoming version was dropped (stale).
44-    pub dropped: Vec<String>,
45-    /// Files surfaced as concurrent — artifacts recorded, local untouched.
46-    pub conflicted: Vec<String>,
47-    /// The merge/adopt commit minted, `None` for a no-op or fast-forward.
259:pub fn peek_all_at(perch_path: &Path) -> rusqlite::Result<Vec<(i64, String, String, i64)>> {
260-    let conn = open_spool_at(perch_path)?;
261-    purge_expired(&conn)?;
262-    let mut stmt = conn
263-        .prepare("SELECT id, from_id, body, created_at FROM messages WHERE delivered = 0 ORDER BY id ASC")?;
264-    let rows = stmt
265-        .query_map([], |row| {


Edit

use spt_daemon::update::{BrokerAbi, BROKER_RESOURCE_ABI, IPC_PROTOCOL_VERSION};


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

use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease, VerifyPolicy};

-use spt_daemon::update::{BrokerAbi, BROKER_RESOURCE_ABI, IPC_PROTOCOL_VERSION};

use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};


-        brain_ipc_version: IPC_PROTOCOL_VERSION,

+        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,

let metadata_json = serde_json::to_string(&meta).unwrap();


    assert!(
        report.fetched.iter().any(|r| r == &branch),
        "the dispatcher served the pull undriven: {report:?}"
    );

    assert!(
        report.applied.iter().any(|r| r.branch == branch && !r.accepted.is_empty()),
        "the dispatcher served the pull undriven: {report:?}"
    );


    assert!(
        report.fetched.iter().any(|r| r == &branch),
        "the dispatcher served the pull undriven: {report:?}"
    );

    assert!(
        report.applied.iter().any(|r| r.branch == branch && !r.accepted.is_empty()),
        "the dispatcher served the pull undriven: {report:?}"
    );

-        report.fetched.iter().any(|r| r == &branch),

+        report.applied.iter().any(|r| r.branch == branch && !r.accepted.is_empty()),

let tip = cs_a.branch_store().tip(&branch).expect("tip query");


if pending.iter().any(|(_, _, body)| body == "undriven hello") {

if pending.iter().any(|(_, _, body, _)| body == "undriven hello") {


let pending = spool::peek_all_at(&perch_path).expect("peek");

-        if pending.iter().any(|(_, _, body)| body == "undriven hello") {

+        if pending.iter().any(|(_, _, body, _)| body == "undriven hello") {


Bash

cargo test -p spt-daemon --test dispatch 2>&1 | tail -25

Run dispatch E2E suite


   Compiling spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 3.04s
     Running tests\dispatch.rs (target\debug\deps\dispatch-e6294f73ed1fcc9f.exe)

running 5 tests
test dispatcher_survives_an_unknown_stream ... ok
test dispatcher_serves_an_update_pull_undriven ... ok
test dispatcher_funnels_wan_messages_undriven ... ok
test dispatcher_applies_a_notif_feed_undriven ... ok
test dispatcher_serves_a_sync_pull_undriven ... ok

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


All 5 E2E pass first run. Full gate before commit:


cargo test -p spt-daemon 2>&1 | grep -E "test result|FAILED|panicked" | head -25

Full spt-daemon test suite


test result: ok. 115 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.45s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.25s
test result: ok. 3 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.03s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.79s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.53s
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.13s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.44s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.26s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.22s
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.38s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.37s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.36s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.28s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


cargo clippy -p spt-daemon --all-targets 2>&1 | grep -E "^error|^warning" | head -10; echo CLIPPY_DONE; traceable-reqs check; echo TR:$?

Clippy + traceability gate


warning: this call to `clone` can be replaced with `std::slice::from_ref`
warning: `spt-daemon` (test "dispatch") generated 1 warning
CLIPPY_DONE
129 requirements: 129 complete, 0 incomplete, 0 findings
  [OK] REQ-API-1  required: [impl, unit, int]  stages: -doc +impl +unit +int
       api prefix and adapter_name on every machinery invocation
  [OK] REQ-API-2  required: [impl, unit, int]  stages: -doc +impl +unit +int
       The api subcommand surface (bind/listen/poll/state/worker/boundary/...)
  [OK] REQ-API-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       commune/signoff are file-drops, not commands
  [OK] REQ-ARCH-1  required: [impl]  stages: -doc +impl -unit -int
       Many small acyclically-layered crates
  [OK] REQ-ARCH-2  required: [impl]  stages: -doc +impl -unit -int
       Public SDK surface is spt-proto, spt-runtime, spt-msg
  [OK] REQ-ARCH-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Wire-protocol version independent of crate semver, N-1 compat window
  [OK] REQ-ARCH-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Copy-verbatim the commodity layer from the sister project
  [OK] REQ-DAEMON-1  required: [impl, unit, int]  stages: -doc +impl +unit +int
       One per-machine spt-daemon owning all per-machine state
  [OK] REQ-DAEMON-2  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Broker/brain split for seamless self-update
  [OK] REQ-DAEMON-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Any api invocation auto-starts the daemon if absent
  [OK] REQ-DAEMON-4  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Honor every KNOWN-HAZARDS invariant
  [OK] REQ-DOCS-1  required: []  stages: -doc -impl -unit -int
       Dual-audience docs (human + AI dev-agent), markdown once / two depths
  [OK] REQ-DOCS-2  required: []  stages: -doc -impl -unit -int
       Sub-10-minute runnable killer quickstart per audience
  [OK] REQ-DOCS-3  required: []  stages: -doc -impl -unit -int
       Diátaxis structure; one canonical way to do X
  [OK] REQ-DOCS-4  required: []  stages: -doc -impl -unit -int
       Agent-consumable layer (llms.txt, manifest schema, MCP, CLI help)
  [OK] REQ-DOCS-5  required: []  stages: -doc -impl -unit -int
       Anti-drift: rustdoc/schema/exports/CLI-help generated + CI-checked
  [OK] REQ-EP-1  required: [impl, unit]  stages: -doc +impl +unit -int
       Day-one endpoint types; open type system
  [OK] REQ-EP-2  required: [impl, unit]  stages: -doc +impl +unit -int
       Agent endpoints vs Shells distinction in the type model
  [OK] REQ-EP-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Messaging payloads carry typed operation commands + file blobs
  [OK] REQ-EP-4  required: [impl, unit]  stages: -doc +impl +unit -int
       PresenceChannel broker endpoint (seam day-one)
  [OK] REQ-EP-5  required: []  stages: -doc -impl -unit -int
       Concrete shell instantiation model: spawn-mints-instance (vs relink/online), registered-on-node permission + broadcast-is-discovery, per-shell require_approval gate, max_instances_per_owner + over_cap, instance aliasing, discovery scope
  [OK] REQ-FRONT-1  required: []  stages: -doc -impl -unit -int
       Day-one launcher/manager frontend (list/launch/attach/init)
  [OK] REQ-HAZARD-CASCADE-WIPE-GUARD  required: [impl, unit]  stages: -doc +impl +unit -int
       No hard-delete of a parent hosting non-empty children (6.3)
  [OK] REQ-HAZARD-CONFLICT-BOTH-PRESERVED  required: [impl, unit]  stages: -doc +impl +unit -int
       A surfaced concurrent context pair is durably preserved (both versions, tracked artifacts) until a strictly dominating write clears it; no reconcile failure path discards an unmerged version (6.6, ADR-0013)
  [OK] REQ-HAZARD-CONPTY-DSR  required: [impl, unit]  stages: -doc +impl +unit +int
       ConPTY reader must auto-answer DSR (ESC[6n) or all child output stalls (5.5)
  [OK] REQ-HAZARD-DAEMON-HOSTED-LIVENESS  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)
  [OK] REQ-HAZARD-DAEMON-SCHED-NONBLOCKING  required: []  stages: -doc -impl -unit -int
       Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)
  [OK] REQ-HAZARD-DEFERRED-DRAIN  required: [impl, unit]  stages: -doc +impl +unit -int
       Deferred spool rows excluded from the event-stream drain (1.4)
  [OK] REQ-HAZARD-DEFERRED-SURVIVE-DRAIN  required: [impl, unit]  stages: -doc +impl +unit -int
       Deferred rows survive poll drain (4.4)
  [OK] REQ-HAZARD-DIRECT-WRITE-PRECEDENCE  required: [impl, unit]  stages: -doc +impl +unit -int
       Direct-write precedence marker (with node id) guards stale overwrite (6.5)
  [OK] REQ-HAZARD-DROP-FILE-SINGLE-WRITER  required: [impl, unit]  stages: -doc +impl +unit -int
       Drop files are daemon-owned single-writer (6.4)
  [OK] REQ-HAZARD-EBUSY-RENAME  required: [impl, unit]  stages: -doc +impl +unit -int
       tmp-write + atomic-rename + retry on Windows EBUSY (5.2)
  [OK] REQ-HAZARD-ECHO-BEFORE-SIGNOFF  required: [impl, unit]  stages: -doc +impl +unit -int
       Echo-commune fires before INIT_SIGNOFF on orphan teardown (3.3)
  [OK] REQ-HAZARD-ENVELOPE-DECODE-ORDER  required: [impl, unit]  stages: -doc +impl +unit -int
       Envelope decode order, ampersand decoded last (4.1)
  [OK] REQ-HAZARD-ENVELOPE-PARSER-SAFE  required: [impl, unit]  stages: -doc +impl +unit -int
       Two-slice envelope parser is panic-free and tolerant (4.2)
  [OK] REQ-HAZARD-EPHEMERAL-CLEANUP  required: [impl, unit]  stages: -doc +impl +unit -int
       Ephemeral perch cleanup on every ring exit path (3.1)
  [OK] REQ-HAZARD-EVENTPART-REASSEMBLY  required: [impl, unit]  stages: -doc +impl +unit -int
       EVENT-PART split/reassembly is byte-exact; orphan parts dropped silently
  [OK] REQ-HAZARD-GEN-START-NOW  required: [impl, int]  stages: -doc +impl -unit +int
       gen_start = now() on cold-start and handoff (2.4)
  [OK] REQ-HAZARD-GRACE-BEFORE-SIGNOFF  required: [impl, unit]  stages: -doc +impl +unit -int
       Grace-period wait completes before composing INIT_SIGNOFF (1.1)
  [OK] REQ-HAZARD-HANDOFF-ARGV-COMPAT  required: [impl, unit]  stages: -doc +impl +unit -int
       Broker/brain IPC + handoff argv version-tolerant (2.3)
  [OK] REQ-HAZARD-ID-CHARSET  required: [impl, unit]  stages: +doc +impl +unit -int
       Addressable-id charset reserves :/@ delimiters; validated at every creation seam (4.6)
  [OK] REQ-HAZARD-INBOX-NO-DOUBLE  required: [impl, unit]  stages: -doc +impl +unit -int
       No double-delivery via legacy inbox (4.5)
  [OK] REQ-HAZARD-INFO-JSON-TORN-READ  required: [impl, unit]  stages: -doc +impl +unit -int
       State-file reads tolerate concurrent writes (1.2)
  [OK] REQ-HAZARD-LOCAL-API-AUTH  required: [impl, unit]  stages: -doc +impl +unit -int
       Every local `api` mutation authenticated to an endpoint/session (codex #13)
  [OK] REQ-HAZARD-PAIR-RATE-LIMIT  required: [impl, unit]  stages: -doc +impl +unit -int
       Subnet-global pairing rate limit: one active ceremony per subnet, shared attempt counter, exponential backoff — a public pre-trust relay + multiple seed-holders otherwise enables distributed SPAKE2 guessing (and ±1 TOTP window triples the valid-password space) (ADR-0005 #11)
  [OK] REQ-HAZARD-PAIR-SEED-ROTATION  required: [impl, unit]  stages: -doc +impl +unit -int
       Removing a node rotates the subnet seed (epoch bump) so an old node/old seed cannot rejoin; trust-store delete alone is NOT revocation because the seed is replicated to every trusted node (ADR-0005 #10)
  [OK] REQ-HAZARD-PAIR-TRANSCRIPT-BIND  required: [impl, unit]  stages: -doc +impl +unit -int
       Pairing transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, and confirmation MACs — or unknown-key-share/reflection/wrong-subnet/replay pairing remain possible (ADR-0005 #12)
  [OK] REQ-HAZARD-PARENT-PID-PREFER  required: []  stages: -doc -impl -unit -int
       Prefer stable parent PID / broker handle over ephemeral PID (2.1)
  [OK] REQ-HAZARD-PSYCHE-OUTBOUND-PROXY  required: [impl, unit]  stages: -doc +impl +unit -int
       Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)
  [OK] REQ-HAZARD-REGISTRY-CONCURRENT  required: [impl, unit]  stages: +doc +impl +unit -int
       Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)
  [OK] REQ-HAZARD-REGISTRY-DIR-CREATE  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       SQLite store opens create their parent dir themselves — a fresh-home registry op must not SQLITE_CANTOPEN (4.9)
  [OK] REQ-HAZARD-REGISTRY-EPOCH-LEASE  required: [impl, unit]  stages: +doc +impl +unit -int
       Registry merge ordered by per-node monotonic epoch, never wall-clock — a stale Active can't clobber a newer Offline (4.8, red-team #8)
  [OK] REQ-HAZARD-REGISTRY-STALE-CLEAN  required: [impl, unit]  stages: -doc +impl +unit -int
       Stale registry entries degrade to fallback, never hard-fail (4.3)
  [OK] REQ-HAZARD-RESTART-IDEMPOTENT  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Idempotent/exactly-once delivery across brain restart at every broker boundary (codex #14)
  [OK] REQ-HAZARD-SINGLE-PATH-SOURCE  required: [impl, unit]  stages: -doc +impl +unit -int
       Single path/registry source of truth; no layout ambiguity (6.1)
  [OK] REQ-HAZARD-SOFT-CLEANUP  required: [impl, unit]  stages: -doc +impl +unit -int
       Soft-cleanup preserves state, removes only the ready marker (6.2)
  [OK] REQ-HAZARD-STALE-INDEX-LOCK  required: []  stages: -doc -impl -unit -int
       Sweep stale lockfiles on daemon boot (1.3)
  [OK] REQ-HAZARD-STALE-SIGNOFF-SENTINEL  required: [impl, unit]  stages: -doc +impl +unit -int
       Stale signoff sentinel does not kill a fresh start (3.2)
  [OK] REQ-HAZARD-STDIN-SESSION-ID  required: []  stages: -doc -impl -unit -int
       Stdin session_id precedence over env (2.2)
  [OK] REQ-HAZARD-SUBPROCESS-TIMEOUT  required: [impl, unit]  stages: -doc +impl +unit -int
       Every harness/git subprocess has a timeout (5.3)
  [OK] REQ-HAZARD-UNC-PATH-STRIP  required: [impl, unit]  stages: -doc +impl +unit -int
       Strip Windows UNC prefix on serialized paths (5.4)
  [OK] REQ-HAZARD-UPDATE-ROLLBACK  required: [impl, unit]  stages: -doc +impl +unit -int
       Self-update rejects version rollback; metadata expiry + adapter content signing (codex #5)
  [OK] REQ-HAZARD-WAN-ORIGIN-AUTH  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       WAN-inbound origin is transport truth, never payload: the access gate's subject (ADR-0009 origin-node whitelist) is the QUIC handshake-proven remote node id from the broker's conn/stream table — a forged origin/node field inside record bytes is inert (7.5)
  [OK] REQ-HAZARD-WINDOWS-PID-RECYCLE  required: [impl, unit]  stages: -doc +impl +unit -int
       Windows PID-recycling false positives guarded (5.1)
  [OK] REQ-HAZARD-WORKER-PATH  required: [impl, unit]  stages: -doc +impl +unit -int
       Single source of truth for Worker/Psyche perch location (1.5)
  [OK] REQ-INFRA-1  required: []  stages: -doc -impl -unit -int
       GitHub issue tracking for v1; tangled.org as migration target
  [OK] REQ-INST-1  required: []  stages: -doc -impl -unit -int
       endpoint ID vs instance split (adapter-agnostic ID)
  [OK] REQ-INST-10  required: [impl, unit]  stages: -doc +impl +unit -int
       Qualified addressing [subnet:]id[@node] + ambiguity forces qualification
  [OK] REQ-INST-11  required: [impl, unit]  stages: -doc +impl +unit -int
       spt rename <id> rippled to all instances (collision-checked, 6.5-reconciled)
  [OK] REQ-INST-12  required: [impl, unit]  stages: -doc +impl +unit -int
       Endpoint visibility per-(endpoint,subnet): excluded semantics, OR-of-defaults + override, gates sync
  [OK] REQ-INST-13  required: [impl, unit]  stages: -doc +impl +unit -int
       Subnet-exclusive sync + per-endpoint subnet-membership list
  [OK] REQ-INST-14  required: []  stages: -doc -impl -unit -int
       Resource advertisement (subnet resource registry): free-text blurb, both-authored, registry projection, visibility/whitelist-gated
  [OK] REQ-INST-15  required: []  stages: -doc -impl -unit -int
       Immutable home subnet (assigned at creation: auto-if-one/ask-if-many) + spt fork (cross-subnet clone to a new identity, copy-then-diverge, not re-home); adapter chosen at creation from registered hostable adapters, changed only via launch/resume-under-new (ADR-0010)
  [OK] REQ-INST-2  required: [impl, unit]  stages: -doc +impl +unit -int
       Per-node files, synced Psyche mind
  [OK] REQ-INST-3  required: []  stages: -doc -impl -unit -int
       Dormant (warm) / suspended (cold) resting states
  [OK] REQ-INST-4  required: []  stages: -doc -impl -unit -int
       active to dormant/suspended fires a transition echo commune
  [OK] REQ-INST-5  required: [impl, unit]  stages: -doc +impl +unit +int
       Two-tier context sync (live to all, project to same-project)
  [OK] REQ-INST-6  required: []  stages: -doc -impl -unit -int
       Deferred messages not delivered to dormant/suspended instances
  [OK] REQ-INST-7  required: [impl, unit]  stages: -doc +impl +unit -int
       Subnet registry + bare-id resolution policy
  [OK] REQ-INST-8  required: [impl, unit]  stages: -doc +impl +unit -int
       Remote-control mode distinct from local operation
  [OK] REQ-INST-9  required: [impl, unit]  stages: -doc +impl +unit -int
       Multi-subnet membership (same-user N subnets; cross-user seam)
  [OK] REQ-INSTALL-1  required: []  stages: -doc -impl -unit -int
       Two install paths; signed one-line script; OS-service registration
  [OK] REQ-INSTALL-2  required: []  stages: -doc -impl -unit -int
       Marketplace-repackaging-friendly install
  [OK] REQ-INSTALL-3  required: []  stages: -doc -impl -unit -int
       Idempotent + interactive-optional first run
  [OK] REQ-INSTALL-4  required: []  stages: -doc -impl -unit -int
       Adapter registration lifecycle: spt adapter add (--github, manifest-first, install-is-first-update) + soft-deregister remove + optional manifest uninstall template; node-local registered-adapter set self-update ripples over
  [OK] REQ-MANIFEST-1  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       Per-adapter manifest with adapter_name and min_spt_core_version
  [OK] REQ-MIGRATE-1  required: []  stages: -doc -impl -unit -int
       Auto-detect and migrate a legacy claude_skill_owl install
  [OK] REQ-MSG-1  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Local message delivery: TCP-first to a registered address, spool fallback when offline; id->address via registry (stale-clean first); reply routing (__REPLY_TO__)
  [OK] REQ-MSG-2  required: [impl, unit]  stages: -doc +impl +unit -int
       spt binary CLI surface: send/ring/ready/poll/list/stop/whoami, stable arg shapes + exit codes
  [OK] REQ-MSG-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Ready-agent lifecycle: register perch (info.json + listener + registry address) on ready, drain spooled backlog on startup, clean teardown
  [OK] REQ-NET-1  required: [impl, unit]  stages: -doc +impl +unit +int
       WAN messaging first-class, behind default-on net feature flag
  [OK] REQ-NET-2  required: [impl]  stages: -doc +impl +unit -int
       n0 relay default + self-host knob + plain-language disclosure
  [OK] REQ-NET-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Cross-node Psyche sync over P2P replaces gh-repo-sync
  [OK] REQ-NODE-IDENTITY  required: [impl, unit]  stages: -doc +impl +unit -int
       Ed25519 identity primitive: keypair, detached sign/verify, stable pubkey<->hex
  [OK] REQ-NOTIF-1  required: [impl, unit]  stages: -doc +impl +unit +int
       Notification primitive: per-subnet replicated spool, seen/dismissed, resurface-at-boundary, subsumes update+consent prompts
  [OK] REQ-NOTIF-2  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       spt notify (agent-issued subnet notif) + notif_command manifest seam (harness + shell adapters)
  [OK] REQ-PAIR-1  required: [impl, unit]  stages: -doc +impl +unit -int
       TOTP-seeded SPAKE2 pairing
  [OK] REQ-PAIR-2  required: [impl, unit]  stages: -doc +impl +unit -int
       Local trust store with TOFU + warn-on-change
  [OK] REQ-PAIR-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Fetch current pairing code from any paired node
  [OK] REQ-PAIR-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Subnet naming on first pairing
  [OK] REQ-PAIR-5  required: [impl, unit]  stages: -doc +impl +unit -int
       Multi-subnet pairing: subnet-name discovery input, create-new-names-up-front, rendezvous-token hashing
  [OK] REQ-PAIR-6  required: [impl, unit]  stages: -doc +impl +unit -int
       Elevation-gated per-subnet code fetch (UAC/root or elevated agent; else authenticator app)
  [OK] REQ-PAIR-7  required: []  stages: -doc -impl -unit -int
       Subnet icon (inline image metadata, GUI-only consumer)
  [OK] REQ-REACH-1  required: [impl, unit]  stages: -doc +impl +unit -int
       Off-node remote-drive detection + file transfer
  [OK] REQ-REACH-2  required: []  stages: -doc -impl -unit -int
       Remote command execution (deferred, consent-gated)
  [OK] REQ-SEAM-ACTIVITY  required: [impl, unit]  stages: -doc +impl +unit -int
       Activity/idle reported via api sentinels, not PTY quiescence
  [OK] REQ-SEAM-CAPABILITY  required: [impl, unit]  stages: -doc +impl +unit -int
       Hostable endpoint-types capability declaration
  [OK] REQ-SEAM-HISTORY  required: [impl, unit, int]  stages: -doc +impl +unit +int
       History subsystem (fetcher / locate-normalize / native store)
  [OK] REQ-SEAM-INJECT  required: [impl, unit]  stages: -doc +impl +unit -int
       inject-input methods configurable per activity-state
  [OK] REQ-SEAM-POSTSPAWN  required: [impl, unit]  stages: -doc +impl +unit -int
       post-spawn / api bind seam with boot nonce
  [OK] REQ-SEAM-PSYCHE  required: [impl, unit, int]  stages: -doc +impl +unit +int
       spawn-psyche seam (fresh + resume templates)
  [OK] REQ-SEAM-RESUME  required: [impl, unit]  stages: -doc +impl +unit -int
       resume-session seam (fresh-with-preload / continue-existing)
  [OK] REQ-SEAM-SPAWN  required: [impl, unit]  stages: -doc +impl +unit -int
       spawn-session seam
  [OK] REQ-SEAM-UPDATE  required: [impl, unit]  stages: -doc +impl +unit -int
       Adapter-update avenue (file-pull / delegated command)
  [OK] REQ-SEC-1  required: [impl, unit]  stages: -doc +impl +unit -int
       Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants
  [OK] REQ-START-1  required: [impl, unit]  stages: -doc +impl +unit -int
       Adapters never resolve SPT_HOME; binary on PATH; api bridging only
  [OK] REQ-START-2  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Harness-hosted startup: api seed then listen
  [OK] REQ-START-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       spt-hosted startup: spawn-session then api bind (no file)
  [OK] REQ-START-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Adapter-injected env aliases (SPT/OWL/LIVE)
  [OK] REQ-STORE-1  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-store::BranchStore (git branch as versioned KV; commit=checkpoint/tip=resume, atomic multi-key, merge-native sync) is the substrate for coarse/durable/audited state (context, registry snapshot+distribution, daemon checkpoint); hot paths (B5 fsync journal) + indexed queries (SQLite spool) excluded (ADR-0011)
  [OK] REQ-TERM-1  required: [impl, unit]  stages: -doc +impl +unit -int
       Process-supervisor terminal wrapper hosting broker PTYs
  [OK] REQ-TERM-2  required: [impl, unit]  stages: -doc +impl +unit -int
       session-surface abstraction; send-keys + send-line injection
  [OK] REQ-TERM-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Byte-stream remote terminal streaming for v1
  [OK] REQ-TERM-4  required: [impl, unit, int]  stages: +doc +impl +unit +int
       Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log
  [OK] REQ-UPD-1  required: [impl, unit]  stages: -doc +impl +unit +int
       Peer-propagated update over P2P
  [OK] REQ-UPD-2  required: [impl, unit]  stages: -doc +impl +unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-core ripple-updates registered adapters
TR:0


D9-7: closeout sweep


Clippy warning in the test (`-D warnings` is real on Linux CI) — fixing:


Edit

    let report = request_sync(
        &mut a,
        conn.conn_id,
        &[branch.clone()],
        op(),
        &cs_a,
        &dir.path().join("scratch-a"),
    )
    .expect("pull");

    let report = request_sync(
        &mut a,
        conn.conn_id,
        std::slice::from_ref(&branch),
        op(),
        &cs_a,
        &dir.path().join("scratch-a"),
    )
    .expect("pull");


    let report = request_sync(
        &mut a,
        conn.conn_id,
        &[branch.clone()],
        op(),
        &cs_a,
        &dir.path().join("scratch-a"),
    )
    .expect("pull");

    let report = request_sync(
        &mut a,
        conn.conn_id,
        std::slice::from_ref(&branch),
        op(),
        &cs_a,
        &dir.path().join("scratch-a"),
    )
    .expect("pull");


Bash

cargo clippy -p spt-daemon --all-targets 2>&1 | grep -cE "^warning|^error"; cargo test -p spt-daemon --test dispatch 2>&1 | grep "test result"

Re-verify clippy + dispatch tests


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


Clean. Committing D9-1-1 and pushing for CI:


git add crates/spt-daemon/src/dispatch.rs crates/spt-daemon/src/nethost.rs crates/spt-daemon/src/msg.rs crates/spt-daemon/src/daemon.rs crates/spt-daemon/src/lib.rs crates/spt-daemon/tests/dispatch.rs && git commit -m "feat(daemon): M4-D9-1-1 inbound net-stream dispatch (production serve loops)

The daemon-side acceptor every D5-D8 E2E deferred: a dispatch loop watches
the broker stream table for peer-initiated streams, classifies each by its
first NDJSON record's shape (kind collisions resolved by field
discriminants), and hands it to the right serve loop on its own worker
brain connection. Sync, update, notif, and WAN-message arms are live;
registry/attach/xfer classify but route to a typed Unwired drop until
D9-1-2/-3 wire them.

The stream table grows initiated_locally (broker-recorded at registration,
open_stream vs accept loop) so the dispatcher can claim only peer-initiated
rows - the gotcha-#9 ambiguity made first-class instead of inferred.

E2E (tests/dispatch.rs): sync pull, update pull, notif feed, and WAN
message all converge UNDRIVEN over two loopback brokers with the gates
loading from canonical SPT_HOME stores; an unclassifiable stream drops
loud without wedging the loop.

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

Commit + push D9-1-1


warning: in the working copy of 'crates/spt-daemon/src/daemon.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/msg.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/nethost.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/dispatch.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/dispatch.rs', LF will be replaced by CRLF the next time Git touches it
[dev-freeform ea73c9a] feat(daemon): M4-D9-1-1 inbound net-stream dispatch (production serve loops)
 6 files changed, 869 insertions(+), 2 deletions(-)
 create mode 100644 crates/spt-daemon/src/dispatch.rs
 create mode 100644 crates/spt-daemon/tests/dispatch.rs
To https://github.com/SaberMage/spt-core.git
   b8d75af..ea73c9a  dev-freeform -> dev-freeform
ea73c9a feat(daemon): M4-D9-1-1 inbound net-stream dispatch (production serve loops)


Pushed `ea73c9a`. Watching CI in background while starting D9-1-2:


sleep 30; for i in $(seq 1 60); do out=$(gh run list --branch dev-freeform --limit 3 --json headSha,conclusion,status,workflowName 2>/dev/null); echo "$out" | grep -q "ea73c9a" || { sleep 30; continue; }; pending=$(echo "$out" | python -c "import json,sys; rs=[r for r in json.load(sys.stdin) if r['headSha'].startswith('ea73c9a')]; print(sum(1 for r in rs if r['status']!='completed'))" 2>/dev/null); if [ "$pending" = "0" ]; then echo "FINAL:"; echo "$out" | python -c "import json,sys; rs=[r for r in json.load(sys.stdin) if r['headSha'].startswith('ea73c9a')]; [print(r['workflowName'], r['conclusion']) for r in rs]"; exit 0; fi; sleep 30; done; echo TIMEOUT

Poll CI until FINAL conclusion for ea73c9a


Command running in background with ID: b5ue1kvut. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\b5ue1kvut.output. You will be notified when it completes. To check interim output, use Read on that file path.


While CI runs — D9-1-2 (registry host + outbound pumps). Checking remaining APIs:


grep -n "pub fn\|pub struct" crates/spt-store/src/epoch.rs | head; echo ===; grep -n "pub fn advertise_if_visible" -A 14 crates/spt-net/src/net/registry.rs; echo ===; sed -n '390,440p' crates/spt-daemon/src/sync.rs; echo ===; grep -n "pub fn peers_in" -A 10 crates/spt-store/src/trust.rs

Epoch, advertise, reconcile wiring, peers_in APIs


23:pub struct EpochSource {
30:    pub fn load() -> EpochSource {
38:    pub fn load_from(path: &Path) -> EpochSource {
47:    pub fn current(&self) -> u64 {
55:    pub fn next_epoch(&mut self) -> std::io::Result<u64> {
===
306:pub fn advertise_if_visible(
307-    registry: &mut SubnetRegistry,
308-    visibility: &VisibilityStore,
309-    subnet_name: &str,
310-    subnet_hide_new: bool,
311-    endpoint_id: &str,
312-    incoming: Instance,
313-) -> Result<Option<MergeOutcome>, JoinCollision> {
314-    if visibility.hidden(endpoint_id, subnet_name, subnet_hide_new) {
315-        return Ok(None);
316-    }
317-    registry.join_endpoint(endpoint_id, incoming).map(Some)
318-}
319-
320-/// The outcome of resolving a bare/qualified [`Address`] against the registry.
===
}

/// The inputs the reconcile wiring needs beyond the pull report (the Psyche
/// turn's knobs — see [`spt_live::reconcile::ReconcileRequest`]).
pub struct ReconcileWiring<'a> {
    /// The endpoint's registry rows (every instance, every node) — the
    /// election input. Empty = local-only endpoint, reconcile here.
    pub rows: &'a [Instance],
    /// This node's id (pubkey hex).
    pub self_node: &'a str,
    /// Substitution keys for the Psyche turn (typically `session_id`).
    pub keys: &'a BTreeMap<String, String>,
    /// Turn budget (5.3).
    pub timeout: Duration,
    /// Wall-clock stamp for the merged write's marker.
    pub now_ms: u64,
    /// The 6.5 protection window for the merged (llm) write.
    pub window_ms: u64,
}

/// Post-pull reconcile wiring (ADR-0013): when this node is the **elected
/// reconciler** for the synced mind ([`is_reconciler`] — Active instance's
/// node wins, lowest-id fallback), run the bounded Psyche reconcile over every
/// conflict the pull surfaced. A non-elected node returns empty — its
/// artifacts wait for the reconciler's dominating merge to arrive and clear
/// them. Failures stay safe by construction: each file's outcome is reported,
/// artifacts persist on any failure (hazard 6.6).
// [impl->REQ-HAZARD-CONFLICT-BOTH-PRESERVED]
pub fn reconcile_after_sync(
    runtime: &dyn AgentRuntime,
    cs: &ContextStore,
    report: &SyncPullReport,
    wiring: &ReconcileWiring<'_>,
) -> io::Result<Vec<(String, String, ReconcileOutcome)>> {
    let mut outcomes = Vec::new();
    if !is_reconciler(wiring.rows, wiring.self_node) {
        return Ok(outcomes); // stand down — the elected node merges
    }
    for (branch, file) in report.conflicts() {
        let worktree = worktree_for_branch(cs.root(), &branch)?;
        let out = reconcile_file(
            runtime,
            cs,
            &ReconcileRequest {
                worktree: &worktree,
                file_rel: &file,
                keys: wiring.keys,
                timeout: wiring.timeout,
                now_ms: wiring.now_ms,
                window_ms: wiring.window_ms,
            },
===
159:    pub fn peers_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a TrustedPeer> {
160-        self.peers.iter().filter(move |p| p.subnet == subnet)
161-    }
162-
163-    /// Record (pin) a peer after a successful pairing ceremony — the TOFU write.
164-    ///
165-    /// Returns the [`TrustOutcome`]: [`Pinned`](TrustOutcome::Pinned) for a first use,
166-    /// [`AlreadyTrusted`](TrustOutcome::AlreadyTrusted) for an idempotent re-pair, or
167-    /// [`KeyChanged`](TrustOutcome::KeyChanged) if a peer under the same `(subnet,
168-    /// label)` already holds a different key — in which case **nothing is written** and
169-    /// the caller must warn + decide ([`repin`](Self::repin)). The `(subnet, user)`


grep -n "pub struct InfoJson" -A 20 crates/spt-store/src/info.rs | head -26; echo ===; grep -n "pub struct TrustedPeer" -A 12 crates/spt-store/src/trust.rs; echo ===; grep -n "from_hex\|EndpointId\|pub fn addr_for\|to_endpoint_id" crates/spt-net/src/net/endpoint.rs crates/spt-proto/src/identity.rs | head -12

InfoJson, TrustedPeer, id-addr conversion


38:pub struct InfoJson {
39-    /// The perch / agent id.
40-    pub id: String,
41-    /// Creation timestamp (opaque string; producer's format).
42-    pub started: String,
43-    /// Owning process pid, or the `"BUSY"` sentinel.
44-    pub pid: PidValue,
45-    /// Harness session id bound to this perch.
46-    pub session_id: String,
47-    /// Endpoint type tag (`spt_proto::endpoint::EndpointType::as_tag`).
48-    pub state: String,
49-    #[serde(skip_serializing_if = "Option::is_none")]
50-    pub parent_pid: Option<u32>,
51-    /// Working directory the perch was created in (forward-slash normalized).
52-    #[serde(skip_serializing_if = "Option::is_none")]
53-    pub cwd: Option<String>,
54-    /// Daemon-managed liveness status (`online`/`offline`) for **daemon-hosted**
55-    /// perches (Psyche, spt-hosted Self). Present ⇒ the daemon owns this perch's
56-    /// liveness and it is read authoritatively, **never** `is_process_alive(pid)`
57-    /// (KNOWN-HAZARDS 2.5). Absent ⇒ an external/interim perch whose liveness is
58-    /// the per-pid probe. The single liveness resolver
===
90:pub struct TrustedPeer {
91-    /// The peer's Ed25519 node pubkey, lowercase hex (the iroh NodeId). The identity.
92-    pub pubkey_hex: String,
93-    /// The subnet this peer paired into / is trusted within.
94-    pub subnet: String,
95-    /// Optional human node label. When present it is the stable handle warn-on-change
96-    /// keys against; absent (v1 default) means the pubkey is the sole identity.
97-    #[serde(skip_serializing_if = "Option::is_none")]
98-    pub label: Option<String>,
99-    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
100-    #[serde(skip_serializing_if = "Option::is_none")]
101-    pub user: Option<String>,
102-    /// First-trust stamp (epoch-seconds string; opaque).
===
crates/spt-net/src/net/endpoint.rs:4://! identity (`spt-proto`'s [`Identity`]): the iroh `EndpointId` *is* the node's
crates/spt-net/src/net/endpoint.rs:21:use iroh::{Endpoint, EndpointId, RelayMap, RelayMode, SecretKey};
crates/spt-net/src/net/endpoint.rs:120:    /// the resulting `EndpointId` equals the node's [`PublicKey`] — the address and
crates/spt-net/src/net/endpoint.rs:210:            .expect("an iroh EndpointId is always a valid ed25519 public key")
crates/spt-net/src/net/endpoint.rs:272:        let id = EndpointId::from_bytes(&peer.to_bytes())
crates/spt-net/src/net/endpoint.rs:332:    // iroh EndpointId equals the node's public key (same seed → same keypair).
crates/spt-proto/src/identity.rs:128:    pub fn from_hex(s: &str) -> Result<PublicKey, IdentityError> {
crates/spt-proto/src/identity.rs:225:        let parsed = PublicKey::from_hex(&hex).expect("round-trip parse");
crates/spt-proto/src/identity.rs:235:        assert_eq!(PublicKey::from_hex("xyz"), Err(IdentityError::BadHex)); // odd len + non-hex
crates/spt-proto/src/identity.rs:236:        assert_eq!(PublicKey::from_hex("zz"), Err(IdentityError::BadHex));
crates/spt-proto/src/identity.rs:238:            PublicKey::from_hex("00"),


grep -rn "single liveness resolver" -A 3 crates/spt-store/src/info.rs | head -6; grep -n "pub fn .*live\|pub fn liveness\|pub fn is_online" crates/spt-store/src/info.rs crates/spt-store/src/proc.rs | head -8; echo ===; grep -rn "pub fn list_perches\|pub fn perch_ids\|read_dir" crates/spt-store/src/perch.rs crates/spt-daemon/src/notif.rs | head -8

Liveness resolver + owlery scan helpers


58:    /// the per-pid probe. The single liveness resolver
59-    /// ([`crate::liveness::is_perch_alive`]) is the only consumer.
60-    #[serde(skip_serializing_if = "Option::is_none")]
61-    pub status: Option<String>,
crates/spt-store/src/proc.rs:16:pub fn is_process_alive(pid: u32) -> bool {
===
crates/spt-store/src/perch.rs:322:    let Ok(entries) = std::fs::read_dir(owlery) else {


grep -n "pub fn is_perch_alive" -A 12 crates/spt-store/src/liveness.rs | head -16; sed -n '90,130p' crates/spt-daemon/src/notif.rs

Liveness resolver + owlery scan pattern in notif.rs


80:pub fn is_perch_alive(perch_path: &Path) -> bool {
81-    match read_raw(perch_path) {
82-        Some(val) => {
83-            if let Some(status) = val.get("status").and_then(|v| v.as_str()) {
84-                return status == STATUS_ONLINE; // daemon-authoritative; never pid
85-            }
86-            match val.get("pid").and_then(|v| v.as_u64()) {
87-                Some(pid) => is_process_alive(pid as u32),
88-                None => true, // BUSY sentinel / unparseable pid — interim parity
89-            }
90-        }
91-        None => true, // unreadable info.json — interim parity (read_pid None ⇒ alive)
92-    }
}

/// Resolve the most-recently-active **live** Self perch in `owlery` whose id
/// satisfies `admit` — the shared resolver core behind both the unscoped
/// consent target ([`crate::consent::most_recently_active`] delegates here)
/// and the subnet-scoped notif target. Recency = `last_active_ms` (absent
/// ranks 0); unreadable perches are skipped, never a panic.
// [impl->REQ-NOTIF-1]
// [impl->REQ-UPD-4]
pub fn most_recently_active_where(
    owlery: &Path,
    admit: impl Fn(&str) -> bool,
) -> Option<ConsentTarget> {
    let mut best: Option<ConsentTarget> = None;
    for id in perch::list_self_perch_ids(owlery) {
        if !admit(&id) {
            continue;
        }
        let perch_path = perch::resolve_perch_path_in(owlery, &id, ParentHint::Infer);
        if !is_perch_alive(&perch_path) {
            continue; // only a *live* session can surface anything
        }
        let last_active_ms = read_info(&perch_path).and_then(|i| i.last_active_ms).unwrap_or(0);
        let better = match &best {
            Some(cur) => last_active_ms > cur.last_active_ms,
            None => true,
        };
        if better {
            best = Some(ConsentTarget { id, perch_path, last_active_ms });
        }
    }
    best
}

/// The first-fire target: the most-recently-active live endpoint **visible in
/// `subnet`** (a hidden endpoint is never the target, even when most recent).
// [impl->REQ-NOTIF-1]
pub fn most_recently_active_visible(
    owlery: &Path,
    policy: &NotifSurfacePolicy,
    subnet: &str,


CI still pending. Building D9-1-2 — config shape first:


1	//! Durable daemon configuration (B3) — the configurable pulse period that
2	//! replaces the interim 5s constant.
3	//!
4	//! In the no-daemon interim model the pulse cadence was a hard-coded
5	//! `spt_live::pulse::DEFAULT_PULSE_PERIOD` (5s). Consolidation (REQ-DAEMON-1)
6	//! folds the pulse loop into the brain as a *scheduled* in-process loop whose
7	//! period is **durable and configurable**, sourced from a small JSON file under
8	//! the spt home (`<spt_home>/daemon.json`) and falling back to the default when
9	//! absent. Kept deliberately tiny — one struct, JSON via the crate's existing
10	//! `serde_json` dep (no new TOML edge) — so the brain's loop reads its cadence
11	//! from config, never a constant.
12	// [impl->REQ-DAEMON-1]
13
14	use std::path::{Path, PathBuf};
15	use std::time::Duration;
16
17	use serde::{Deserialize, Serialize};
18
19	/// The interim default pulse period carried forward as the config default. The
20	/// daemon now treats this as a *fallback*, not a constant: `daemon.json` may
21	/// override it per machine.
22	pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);
23
24	/// Default drop-file precedence protection window (epoch-ms), mirroring the
25	/// interim `spt_live` guard. Surfaced here so the one config source owns it.
26	pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;
27
28	/// Default orphan-watch grace window (B7): how long the supervised-crash teardown
29	/// waits after observing a hosted Self's harness owner gone before it rechecks
30	/// liveness and signs off (KNOWN-HAZARDS 1.1). Sized to cover a transient recovery
31	/// — a binary handoff relaunch or a brief stale poll — without delaying a real
32	/// teardown for long. Configurable per machine via `crash_grace_ms`.
33	pub const DEFAULT_CRASH_GRACE: Duration = Duration::from_secs(2);
34
35	/// Which relay stance the broker's net endpoint runs (ADR-0002 / REQ-NET-2) —
36	/// the `"relay"` knob in `daemon.json`. Maps onto `spt-net`'s `RelayPolicy` in
37	/// `Daemon::run`; kept as a local enum so the config layer stays transport-free.
38	/// The self-hosted variant (`RelayPolicy::SelfHosted`) needs a relay-map URL
39	/// list and gets its config plumbing when a self-host consumer exists.
40	// [impl->REQ-NET-2]
41	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42	pub enum RelayChoice {
43	    /// n0's public relays — the zero-config default (`"relay": "n0"`).
44	    N0,
45	    /// No relays: direct/LAN only (`"relay": "disabled"`).
46	    Disabled,
47	}
48
49	/// The on-disk shape (`<spt_home>/daemon.json`). Every field is optional so a
50	/// partial file still loads (forward/back-compat) — a missing field takes the
51	/// default. Stored as plain integers (ms) to keep the file human-editable.
52	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53	struct DaemonConfigFile {
54	    #[serde(default, skip_serializing_if = "Option::is_none")]
55	    pulse_period_ms: Option<u64>,
56	    #[serde(default, skip_serializing_if = "Option::is_none")]
57	    protection_window_ms: Option<u64>,
58	    #[serde(default, skip_serializing_if = "Option::is_none")]
59	    crash_grace_ms: Option<u64>,
60	    #[serde(default, skip_serializing_if = "Option::is_none")]
61	    full_auto_update: Option<bool>,
62	    #[serde(default, skip_serializing_if = "Option::is_none")]
63	    relay: Option<String>,
64	}
65
66	/// Resolved daemon configuration. The brain's scheduled loops read their cadence
67	/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
68	#[derive(Debug, Clone, Copy, PartialEq, Eq)]

//! Durable daemon configuration (B3) — the configurable pulse period that
//! replaces the interim 5s constant.
//!
//! In the no-daemon interim model the pulse cadence was a hard-coded
//! `spt_live::pulse::DEFAULT_PULSE_PERIOD` (5s). Consolidation (REQ-DAEMON-1)
//! folds the pulse loop into the brain as a *scheduled* in-process loop whose
//! period is **durable and configurable**, sourced from a small JSON file under
//! the spt home (`<spt_home>/daemon.json`) and falling back to the default when
//! absent. Kept deliberately tiny — one struct, JSON via the crate's existing
//! `serde_json` dep (no new TOML edge) — so the brain's loop reads its cadence
//! from config, never a constant.
// [impl->REQ-DAEMON-1]

use std::path::{Path, PathBuf};
use std::time::Duration;

use serde::{Deserialize, Serialize};

/// The interim default pulse period carried forward as the config default. The
/// daemon now treats this as a *fallback*, not a constant: `daemon.json` may
/// override it per machine.
pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);

/// Default drop-file precedence protection window (epoch-ms), mirroring the
/// interim `spt_live` guard. Surfaced here so the one config source owns it.
pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;

/// Default orphan-watch grace window (B7): how long the supervised-crash teardown
/// waits after observing a hosted Self's harness owner gone before it rechecks
/// liveness and signs off (KNOWN-HAZARDS 1.1). Sized to cover a transient recovery
/// — a binary handoff relaunch or a brief stale poll — without delaying a real
/// teardown for long. Configurable per machine via `crash_grace_ms`.
pub const DEFAULT_CRASH_GRACE: Duration = Duration::from_secs(2);

/// Which relay stance the broker's net endpoint runs (ADR-0002 / REQ-NET-2) —
/// the `"relay"` knob in `daemon.json`. Maps onto `spt-net`'s `RelayPolicy` in
/// `Daemon::run`; kept as a local enum so the config layer stays transport-free.
/// The self-hosted variant (`RelayPolicy::SelfHosted`) needs a relay-map URL
/// list and gets its config plumbing when a self-host consumer exists.
// [impl->REQ-NET-2]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelayChoice {
    /// n0's public relays — the zero-config default (`"relay": "n0"`).
    N0,
    /// No relays: direct/LAN only (`"relay": "disabled"`).
    Disabled,
}

/// The on-disk shape (`<spt_home>/daemon.json`). Every field is optional so a
/// partial file still loads (forward/back-compat) — a missing field takes the
/// default. Stored as plain integers (ms) to keep the file human-editable.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct DaemonConfigFile {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pulse_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    protection_window_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    crash_grace_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    full_auto_update: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    relay: Option<String>,
}

/// Resolved daemon configuration. The brain's scheduled loops read their cadence
/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]


99	}
100
101	impl DaemonConfig {
102	    /// The canonical config path: `<spt_home>/daemon.json`.
103	    pub fn default_path() -> PathBuf {
104	        spt_store::perch::spt_home().join("daemon.json")
105	    }
106
107	    /// Load the daemon config from the canonical path, falling back to the
108	    /// default if the file is absent or unreadable.
109	    pub fn load() -> Self {
110	        Self::load_from(&Self::default_path())
111	    }
112
113	    /// Load from an explicit path. A missing file → default; a present-but-partial
114	    /// file fills only the named fields and defaults the rest; a malformed file
115	    /// degrades to the default (a broken config must never wedge the daemon).
116	    pub fn load_from(path: &Path) -> Self {
117	        let Ok(raw) = std::fs::read_to_string(path) else {
118	            return Self::default();
119	        };
120	        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
121	            Ok(f) => f,
122	            Err(_) => return Self::default(),
123	        };
124	        Self::from_file(file)
125	    }
126
127	    fn from_file(file: DaemonConfigFile) -> Self {
128	        let d = DaemonConfig::default();
129	        DaemonConfig {
130	            pulse_period: file
131	                .pulse_period_ms
132	                .map(Duration::from_millis)
133	                .unwrap_or(d.pulse_period),
134	            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
135	            crash_grace: file
136	                .crash_grace_ms
137	                .map(Duration::from_millis)
138	                .unwrap_or(d.crash_grace),
139	            full_auto_update: file.full_auto_update.unwrap_or(d.full_auto_update),
140	            relay: match file.relay.as_deref() {
141	                Some("disabled") => RelayChoice::Disabled,
142	                // "n0", absent, or an unrecognized future value → the default.
143	                _ => d.relay,
144	            },
145	        }
146	    }
147
148	    /// Persist this config to an explicit path (for `spt` to write the knob).
149	    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
150	        let file = DaemonConfigFile {
151	            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
152	            protection_window_ms: Some(self.protection_window_ms),
153	            crash_grace_ms: Some(self.crash_grace.as_millis() as u64),
154	            full_auto_update: Some(self.full_auto_update),
155	            relay: Some(
156	                match self.relay {
157	                    RelayChoice::N0 => "n0",
158	                    RelayChoice::Disabled => "disabled",
159	                }
160	                .to_string(),
161	            ),
162	        };
163	        let json = serde_json::to_string_pretty(&file)
164	            .map_err(|e| std::io::Error::other(e.to_string()))?;
165	        std::fs::write(path, json)
166	    }
167	}
168
169	#[cfg(test)]
170	mod tests {
171	    use super::*;
172
173	    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.

}

impl DaemonConfig {
    /// The canonical config path: `<spt_home>/daemon.json`.
    pub fn default_path() -> PathBuf {
        spt_store::perch::spt_home().join("daemon.json")
    }

    /// Load the daemon config from the canonical path, falling back to the
    /// default if the file is absent or unreadable.
    pub fn load() -> Self {
        Self::load_from(&Self::default_path())
    }

    /// Load from an explicit path. A missing file → default; a present-but-partial
    /// file fills only the named fields and defaults the rest; a malformed file
    /// degrades to the default (a broken config must never wedge the daemon).
    pub fn load_from(path: &Path) -> Self {
        let Ok(raw) = std::fs::read_to_string(path) else {
            return Self::default();
        };
        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
            Ok(f) => f,
            Err(_) => return Self::default(),
        };
        Self::from_file(file)
    }

    fn from_file(file: DaemonConfigFile) -> Self {
        let d = DaemonConfig::default();
        DaemonConfig {
            pulse_period: file
                .pulse_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.pulse_period),
            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
            crash_grace: file
                .crash_grace_ms
                .map(Duration::from_millis)
                .unwrap_or(d.crash_grace),
            full_auto_update: file.full_auto_update.unwrap_or(d.full_auto_update),
            relay: match file.relay.as_deref() {
                Some("disabled") => RelayChoice::Disabled,
                // "n0", absent, or an unrecognized future value → the default.
                _ => d.relay,
            },
        }
    }

    /// Persist this config to an explicit path (for `spt` to write the knob).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        let file = DaemonConfigFile {
            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
            protection_window_ms: Some(self.protection_window_ms),
            crash_grace_ms: Some(self.crash_grace.as_millis() as u64),
            full_auto_update: Some(self.full_auto_update),
            relay: Some(
                match self.relay {
                    RelayChoice::N0 => "n0",
                    RelayChoice::Disabled => "disabled",
                }
                .to_string(),
            ),
        };
        let json = serde_json::to_string_pretty(&file)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        std::fs::write(path, json)
    }
}

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

    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.


Adding pump knobs to config:


Edit

/// Which relay stance the broker's net endpoint runs (ADR-0002 / REQ-NET-2) —

/// Default peer-pump cadences (D9-1): how often the daemon pushes its
/// registry/notif feeds to trusted peers and pulls context syncs from them.
/// Conservative defaults — every loop is a thin call over an idempotent
/// driver, so a missed tick costs only latency, never correctness.
pub const DEFAULT_REGISTRY_PUMP_PERIOD: Duration = Duration::from_secs(30);
pub const DEFAULT_NOTIF_PUMP_PERIOD: Duration = Duration::from_secs(15);
pub const DEFAULT_SYNC_PULL_PERIOD: Duration = Duration::from_secs(60);
pub const DEFAULT_UPDATE_CHECK_PERIOD: Duration = Duration::from_secs(3_600);

/// Which relay stance the broker's net endpoint runs (ADR-0002 / REQ-NET-2) —


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

/// Default peer-pump cadences (D9-1): how often the daemon pushes its
/// registry/notif feeds to trusted peers and pulls context syncs from them.
/// Conservative defaults — every loop is a thin call over an idempotent
/// driver, so a missed tick costs only latency, never correctness.
pub const DEFAULT_REGISTRY_PUMP_PERIOD: Duration = Duration::from_secs(30);
pub const DEFAULT_NOTIF_PUMP_PERIOD: Duration = Duration::from_secs(15);
pub const DEFAULT_SYNC_PULL_PERIOD: Duration = Duration::from_secs(60);
pub const DEFAULT_UPDATE_CHECK_PERIOD: Duration = Duration::from_secs(3_600);

/// Which relay stance the broker's net endpoint runs (ADR-0002 / REQ-NET-2) —

/// teardown for long. Configurable per machine via `crash_grace_ms`.

pub const DEFAULT_CRASH_GRACE: Duration = Duration::from_secs(2);

+/// Default peer-pump cadences (D9-1): how often the daemon pushes its

+/// registry/notif feeds to trusted peers and pulls context syncs from them.

+/// Conservative defaults — every loop is a thin call over an idempotent

+/// driver, so a missed tick costs only latency, never correctness.

+pub const DEFAULT_REGISTRY_PUMP_PERIOD: Duration = Duration::from_secs(30);

+pub const DEFAULT_NOTIF_PUMP_PERIOD: Duration = Duration::from_secs(15);

+pub const DEFAULT_SYNC_PULL_PERIOD: Duration = Duration::from_secs(60);

+pub const DEFAULT_UPDATE_CHECK_PERIOD: Duration = Duration::from_secs(3_600);

/// the `"relay"` knob in `daemon.json`. Maps onto `spt-net`'s `RelayPolicy` in

/// `Daemon::run`; kept as a local enum so the config layer stays transport-free.


    #[serde(default, skip_serializing_if = "Option::is_none")]
    full_auto_update: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    relay: Option<String>,
}

    #[serde(default, skip_serializing_if = "Option::is_none")]
    full_auto_update: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    relay: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    peer_pump: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    registry_pump_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    notif_pump_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    sync_pull_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    update_check_period_ms: Option<u64>,
}


    #[serde(default, skip_serializing_if = "Option::is_none")]
    full_auto_update: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    relay: Option<String>,
}

    #[serde(default, skip_serializing_if = "Option::is_none")]
    full_auto_update: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    relay: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    peer_pump: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    registry_pump_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    notif_pump_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    sync_pull_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    update_check_period_ms: Option<u64>,
}

+    #[serde(default, skip_serializing_if = "Option::is_none")]

+    registry_pump_period_ms: Option<u64>,

+    update_check_period_ms: Option<u64>,

/// Resolved daemon configuration. The brain's scheduled loops read their cadence


    /// The broker net endpoint's relay stance (D4a, REQ-NET-2). Default
    /// [`RelayChoice::N0`] — the ADR-0002 zero-config path. An unrecognized
    /// string degrades to the default (forward-compat: a future value must not
    /// wedge an older daemon).
    pub relay: RelayChoice,
}

    /// The broker net endpoint's relay stance (D4a, REQ-NET-2). Default
    /// [`RelayChoice::N0`] — the ADR-0002 zero-config path. An unrecognized
    /// string degrades to the default (forward-compat: a future value must not
    /// wedge an older daemon).
    pub relay: RelayChoice,
    /// Whether the peer pump runs at all (D9-1). Default `true` — the subnet
    /// self-drives; `false` is the kill switch for debugging/hermetic setups.
    pub peer_pump: bool,
    /// Registry feed push cadence (D9-1).
    pub registry_pump_period: Duration,
    /// Notif feed push cadence (D9-1).
    pub notif_pump_period: Duration,
    /// Context sync pull cadence — the anti-entropy loop (D9-1).
    pub sync_pull_period: Duration,
    /// Update check cadence (D9-1; the pull rides the same trusted conns).
    pub update_check_period: Duration,
}