    Failed(String),
    /// The requester closed its send side before fetching (e.g. its own gate
    /// rejected the offer, or it detached mid-protocol).
    Detached,
}

/// Serve one update pull from this node's staged release, blocking until the
/// pull completes, the requester detaches, or the request fails.
///
/// `origin_node` MUST be the handshake-proven remote id from the broker's
/// stream table (`NetStreamInfo::remote_id_hex`) — never payload bytes
/// (REQ-HAZARD-WAN-ORIGIN-AUTH). The origin is gated against `trust`
/// **here**, fail-closed: an untrusted peer gets the up-to-date shape and
/// learns nothing (not even whether a release is staged).
// [impl->REQ-UPD-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
pub fn serve_update(
    brain: &mut Brain,
    stream_id: u64,
    origin_node: &str,
    net_from_seq: u64,
    cache: &ReleaseCache,
    trust: &TrustStore,
) -> io::Result<UpdateServeOutcome> {
    brain.net_stream_subscribe(stream_id, net_from_seq)?;
    let mut decoder = UpdDecoder::new();
    // Set once an Offer goes out; the Fetch must echo the same pull id.
    let mut offered: Option<String> = None;

    loop {
        match brain.read_event()? {
===
/// The notif apply gate's config: this node's subnet memberships + its pinned
/// peers. Loaded from the canonical stores in production (re-read per feed —
/// config edits take effect without a daemon restart, the access-gate
/// stance); tests construct it directly.
// [impl->REQ-NOTIF-1]
pub struct NotifPolicy {
    /// Names of every subnet this node is a member of.
    pub subnets: Vec<String>,
    pub trust: TrustStore,
}

impl NotifPolicy {
    /// Load from the node's canonical stores (`subnet.json`, `trust/peers.json`).
    pub fn load() -> NotifPolicy {
        let subnets = spt_store::subnet::SubnetStore::load()
            .subnets
            .iter()
            .map(|s| s.name.clone())
            .collect();
        NotifPolicy { subnets, trust: TrustStore::load() }
    }

    /// Whether a record in `subnet` from the handshake-proven `origin_node`
    /// is admitted: member subnet ∧ origin trusted **in that subnet** —
    /// fail-closed on both axes.
    // [impl->REQ-NOTIF-1]
    pub fn admits(&self, subnet: &str, origin_node: &str) -> bool {
        self.subnets.iter().any(|s| s == subnet) && self.trust.is_trusted(subnet, origin_node)
    }
}

/// What the apply did with one inbound record — loud + typed, never silent.
#[derive(Debug, Clone, PartialEq, Eq)]
===
crates/spt-store/src/notif.rs:89:    pub fn open() -> rusqlite::Result<NotifStore> {
crates/spt-store/src/notif.rs:95:    pub fn open_at(path: &Path) -> rusqlite::Result<NotifStore> {
crates/spt-store/src/contextstore.rs:86:    pub fn open_or_init() -> std::io::Result<ContextStore> {
crates/spt-store/src/contextstore.rs:93:    pub fn open_or_init_in(root: &Path) -> std::io::Result<ContextStore> {
===
/// Raw bytes per [`SyncRecord::Chunk`] (base64 inside the JSON line — the
/// shared NDJSON posture, no binary fork of the codec).
const CHUNK_BYTES: usize = 64 * 1024;

/// The responder's per-ref sync gate: the node's subnet membership (name +
/// `hide_new_endpoints` flag), its pinned peers, and its visibility/sync
/// config. Loaded from the canonical stores in production; tests construct it
/// directly.
// [impl->REQ-INST-5]
pub struct SyncPolicy {
    /// `(subnet name, hide_new_endpoints)` for every subnet this node is in.
    pub subnets: Vec<(String, bool)>,
    pub trust: TrustStore,
    pub vis: VisibilityStore,
}

impl SyncPolicy {
    /// Load from the node's canonical stores (`subnet.json`, `trust/peers.json`,
    /// `visibility.json`). Re-read per pull — config edits take effect on the
    /// next request without a daemon restart (the access-gate stance).
    pub fn load() -> SyncPolicy {
        let subnets = spt_store::subnet::SubnetStore::load()
            .subnets
            .iter()
            .map(|s| (s.name.clone(), s.hide_new_endpoints))
            .collect();
        SyncPolicy { subnets, trust: TrustStore::load(), vis: VisibilityStore::load() }
    }

    /// Whether `branch` may sync with `origin_node` (handshake-proven). The
    /// fail-closed decision table in the module docs. Used on **both** sides:
    /// the responder's serve gate, and the requester's ref selection (don't
    /// ask for what the peer shouldn't get — symmetric scoping).
    // [impl->REQ-INST-5]
    // [impl->REQ-INST-13]
    pub fn allows(&self, branch: &str, origin_node: &str) -> bool {


Bash

grep -n "pub fn\|pub struct ReleaseCache" crates/spt-daemon/src/relcache.rs | head -8; grep -rn "fn scratch\|scratch_dir\|net_scratch" crates/spt-store/src/perch.rs crates/spt-daemon/src/*.rs | grep -v test | grep "fn \|join" | head -8

ReleaseCache constructor + scratch dir conventions


41:pub struct ReleaseCache {
47:    pub fn open(dir: &Path) -> ReleaseCache {
52:    pub fn artifact_path(&self) -> PathBuf {
68:    pub fn stage(&self, signed: &SignedRelease, artifact: &[u8]) -> io::Result<()> {
81:    pub fn staged(&self) -> Option<SignedRelease> {
92:    pub fn load_artifact(&self) -> io::Result<Vec<u8>> {
crates/spt-daemon/src/propagate.rs:234:    let artifact_path = scratch_dir.join(format!("pull-{open_op}.artifact"));
crates/spt-daemon/src/sync.rs:202:                let bundle = scratch_dir.join(format!("serve-{}.bundle", safe_stem(&sync_id)));
crates/spt-daemon/src/sync.rs:319:    let bundle: PathBuf = scratch_dir.join(format!("recv-{}.bundle", safe_stem(&sync_id)));


grep -rn "ReleaseCache::open" crates/ --include=*.rs | head -6; grep -rn "release_cache_dir\|releases\b" crates/spt-store/src/perch.rs crates/spt-daemon/src/relcache.rs | head -6; echo ===; grep -n "pub fn identity_dir\|pub fn owlery_dir\|pub fn home_dir\|pub fn spt_home" crates/spt-store/src/perch.rs

Release cache + home dir path conventions


crates/spt-daemon/src/relcache.rs:113:        let cache = ReleaseCache::open(dir.path());
crates/spt-daemon/src/relcache.rs:130:        let cache = ReleaseCache::open(dir.path());
crates/spt-daemon/tests/propagate.rs:147:        Node { name, cache: ReleaseCache::open(&dir.join(tag).join("releases")) }
===
34:pub fn spt_home() -> PathBuf {
62:pub fn owlery_dir() -> PathBuf {
80:pub fn identity_dir() -> PathBuf {


Conventions set. Checking last imports (BrokerEvent path, WanMessage decoder), then writing `dispatch.rs`:


sed -n '1,60p' crates/spt-daemon/src/sync.rs | grep -n "^use\|^pub use"; echo ===; grep -n "Decoder\|pub type" crates/spt-net/src/net/wanmsg.rs | head -5; grep -n "enum BrokerEvent" crates/spt-daemon/src/*.rs | head -3; echo ===; grep -n "NotifDecoder\|pub type" crates/spt-net/src/net/notif.rs | head -4

Import paths for dispatch.rs


42:use std::collections::BTreeMap;
43:use std::io::{self, Seek, SeekFrom, Write};
44:use std::path::{Path, PathBuf};
45:use std::time::Duration;
47:use spt_live::reconcile::{reconcile_file, ReconcileOutcome, ReconcileRequest};
48:use spt_net::net::registry::Instance;
49:use spt_net::net::sync::{SyncDecoder, SyncRecord};
50:use spt_runtime::AgentRuntime;
51:use spt_store::contextstore::ContextStore;
52:use spt_store::syncmerge::{apply_fetched, worktree_for_branch, ApplyReport};
53:use spt_store::trust::TrustStore;
54:use spt_store::visibility::VisibilityStore;
56:use crate::brain::{Brain, BrokerEvent};
57:use crate::msg::{decode_bytes, encode_bytes};
58:use crate::reconcile::is_reconciler;
===
26:use crate::net::ndjson::{self, NdjsonDecoder};
59:/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
61:pub type WanDecoder = NdjsonDecoder<WanMessage>;
crates/spt-daemon/src/brain.rs:76:pub enum BrokerEvent {
===
51:pub type NotifDecoder = NdjsonDecoder<NotifRecord>;
85:        let mut dec = NotifDecoder::new();
101:        let mut dec = NotifDecoder::new();


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.


Writing `dispatch.rs`:


Write

//! Inbound net-stream dispatch (D9-1) — the daemon-side acceptor that turns
//! the hand-wired serve loops of D5–D8 into production behavior.
//!
//! Every E2E test so far drove the serve half manually (a probe brain calling
//! `serve_sync` / `serve_update` / `apply_notif_feed` / `receive_wan` against
//! a known stream). In production nothing did — a peer could dial and write
//! and the bytes sat in the broker's stream log unread. This module is the
//! missing consumer: a daemon-hosted loop that watches the broker's stream
//! table for **peer-initiated** streams, classifies each by its first NDJSON
//! record, and hands it to the right serve loop on its own worker thread.
//!
//! ## Direction (the gotcha-#9 fact, now first-class)
//!
//! The stream table holds locally-opened outbound streams too. The dispatcher
//! claims only rows with `initiated_locally == false` — serving our own
//! requester stream would deadlock the requester against ourselves. The flag
//! is broker-recorded at registration (`open_stream` vs the accept loop),
//! never inferred.
//!
//! ## Classification (first-record demux)
//!
//! Streams are single-purpose; the opener's first record names the protocol.
//! The kind-tagged families collide on variant names (`request` is both
//! sync's and attach's opener; `chunk`/`done`/`err` recur everywhere), so the
//! demux keys on the first line's **shape**, not its kind alone:
//!
//! | first line | family |
//! |---|---|
//! | `kind=request` + `sync_id` | sync pull ([`crate::sync::serve_sync`]) |
//! | `kind=request` + `session_id` | remote-drive attach (D9-1-3) |
//! | `kind=query` | update pull ([`crate::propagate::serve_update`]) |
//! | `kind=row` | notif feed ([`crate::notifsync::apply_notif_feed`]) |
//! | `kind=fetch` / `kind=push` | file transfer (D9-1-3) |
//! | untagged + `target`+`op_id` | WAN message feed ([`crate::wan::receive_wan`]) |
//! | untagged + `subnet`+`instance` | registry feed (D9-1-2) |
//! | anything else | unknown — dropped, loud |
//!
//! A family whose production wiring lands in a later slice is still
//! classified here (the table is complete) but routed to a typed
//! [`Unwired`](DispatchOutcome::Unwired) drop — loud, never silent.
//!
//! ## Worker model
//!
//! Each claimed stream gets its own worker thread with its **own** brain IPC
//! connection (the broker is multi-client by design; the probe-brain pattern
//! every E2E test uses). The peek and the serve use **separate** connections:
//! the peek brain subscribes from 0 to read the first line and is dropped;
//! the serve loop then subscribes from 0 on a fresh connection so it owns its
//! stream's event feed alone (no double-subscription interleave). Origin is
//! the table's `remote_id_hex` — handshake-proven, never payload
//! (REQ-HAZARD-WAN-ORIGIN-AUTH); every family's own gate then runs unchanged.
// [impl->REQ-NET-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]

use std::collections::HashSet;
use std::io;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_net::net::notif::NotifDecoder;
use spt_net::net::wanmsg::WanDecoder;
use spt_store::contextstore::ContextStore;
use spt_store::notif::NotifStore;
use spt_store::perch;
use spt_store::trust::TrustStore;

use crate::brain::{Brain, BrokerEvent};
use crate::notifsync::{apply_notif_feed, NotifPolicy};
use crate::propagate::serve_update;
use crate::relcache::ReleaseCache;
use crate::sync::{serve_sync, SyncPolicy};
use crate::wan::receive_wan;

/// Poll cadence for the stream-table watch (the dispatcher's only busy-wait;
/// workers block on their stream's event feed).
pub const DEFAULT_DISPATCH_POLL: Duration = Duration::from_millis(100);

/// What protocol a peer-initiated stream speaks, by its first record's shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamFamily {
    /// A context sync pull (`SyncRecord::Request`).
    Sync,
    /// An update pull (`UpdRecord::Query`).
    Update,
    /// A notif replication feed (`NotifRecord::Row`).
    Notif,
    /// A WAN message feed (untagged `WanMessage` lines).
    WanMsg,
    /// A registry replication feed (untagged `RegistryUpdate` lines).
    Registry,
    /// A remote-drive attach (`AttachRecord::Request`).
    Attach,
    /// A file transfer (`XferRecord::Fetch`/`Push`).
    Xfer,
    /// Unclassifiable — dropped.
    Unknown,
}

/// Classify one decoded first line by shape (module-docs demux table).
pub fn classify_first_line(line: &serde_json::Value) -> StreamFamily {
    match line.get("kind").and_then(|k| k.as_str()) {
        Some("request") if line.get("sync_id").is_some() => StreamFamily::Sync,
        Some("request") if line.get("session_id").is_some() => StreamFamily::Attach,
        Some("query") => StreamFamily::Update,
        Some("row") => StreamFamily::Notif,
        Some("fetch") | Some("push") => StreamFamily::Xfer,
        Some(_) => StreamFamily::Unknown,
        None => {
            if line.get("target").is_some() && line.get("op_id").is_some() {
                StreamFamily::WanMsg
            } else if line.get("subnet").is_some() && line.get("instance").is_some() {
                StreamFamily::Registry
            } else {
                StreamFamily::Unknown
            }
        }
    }
}

/// Why a dispatched stream's worker ended — loud + typed, never silent.
#[derive(Debug)]
pub enum DispatchOutcome {
    /// The family's serve loop ran to its own outcome (rendered for the log).
    Served(String),
    /// Classified, but the family's production wiring lands in a later slice.
    Unwired(StreamFamily),
    /// The first line never arrived / didn't parse — dropped.
    Unknown,
    /// The worker failed (IO against broker or store).
    Failed(String),
}

/// The SPT_HOME-anchored paths the serve arms need. One value, cloned per
/// worker (policies/stores re-load from the canonical paths per request —
/// the access-gate stance, config edits take effect without a restart).
#[derive(Debug, Clone)]
pub struct DispatchPaths {
    /// The perch tree (WAN message funnel target).
    pub owlery: PathBuf,
    /// The staged-release cache dir ([`ReleaseCache::open`]).
    pub releases: PathBuf,
    /// Transient bundle/artifact scratch.
    pub scratch: PathBuf,
}

impl DispatchPaths {
    /// The production layout under [`perch::spt_home`].
    pub fn production() -> DispatchPaths {
        let home = perch::spt_home();
        DispatchPaths {
            owlery: perch::owlery_dir(),
            releases: home.join("releases"),
            scratch: home.join("scratch").join("net"),
        }
    }
}

/// Watch the broker's stream table and dispatch every **peer-initiated**
/// stream to a family worker, until `stop` is set. Blocks the calling thread
/// (the daemon spawns it on its own); workers detach (each ends with its
/// stream).
pub fn run_dispatch_loop(
    broker_name: &str,
    paths: &DispatchPaths,
    stop: &AtomicBool,
    poll: Duration,
) -> io::Result<()> {
    let mut brain = connect(broker_name)?;
    let mut claimed: HashSet<u64> = HashSet::new();

    while !stop.load(Ordering::Relaxed) {
        let reply = brain.net_streams()?;
        for s in reply.streams {
            // Claim only peer-initiated rows with bytes (a QUIC stream is
            // invisible until its first write anyway — gotcha #6 — but the
            // next_seq guard also skips a row whose pump hasn't appended yet).
            if s.initiated_locally || s.next_seq == 0 || !claimed.insert(s.stream_id) {
                continue;
            }
            let name = broker_name.to_string();
            let origin = s.remote_id_hex.clone();
            let stream_id = s.stream_id;
            let p = paths.clone();
            thread::spawn(move || {
                let outcome = worker(&name, stream_id, &origin, &p);
                match outcome {
                    DispatchOutcome::Served(_) => {}
                    other => eprintln!("DISPATCH:{stream_id}:{other:?}"),
                }
            });
        }
        thread::sleep(poll);
    }
    Ok(())
}

/// Connect a fresh brain IPC client (retrying through broker startup).
fn connect(broker_name: &str) -> io::Result<Brain> {
    let mut last = io::Error::other("broker never answered");
    for _ in 0..300 {
        match Brain::cold_start(broker_name, now_ms()) {
            Ok(b) => return Ok(b),
            Err(e) => last = e,
        }
        thread::sleep(Duration::from_millis(10));
    }
    Err(last)
}

fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// Peek the stream's first NDJSON line on a throwaway brain connection.
fn peek_first_line(broker_name: &str, stream_id: u64) -> io::Result<Option<serde_json::Value>> {
    let mut brain = connect(broker_name)?;
    brain.net_stream_subscribe(stream_id, 0)?;
    let mut buf: Vec<u8> = Vec::new();
    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                buf.extend_from_slice(&bytes);
                if let Some(nl) = buf.iter().position(|&b| b == b'\n') {
                    return Ok(serde_json::from_slice(&buf[..nl]).ok());
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                // Finished without a complete line: classify what we have
                // (a line without a trailing newline before EOF still counts).
                return Ok(serde_json::from_slice(&buf).ok());
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}

/// One claimed stream's worker: peek → classify → run the family's serve
/// loop on a fresh brain connection.
fn worker(broker_name: &str, stream_id: u64, origin: &str, paths: &DispatchPaths) -> DispatchOutcome {
    let line = match peek_first_line(broker_name, stream_id) {
        Ok(Some(v)) => v,
        Ok(None) => return DispatchOutcome::Unknown,
        Err(e) => return DispatchOutcome::Failed(e.to_string()),
    };
    let family = classify_first_line(&line);

    let mut brain = match connect(broker_name) {
        Ok(b) => b,
        Err(e) => return DispatchOutcome::Failed(e.to_string()),
    };

    match family {
        StreamFamily::Sync => {
            let cs = match ContextStore::open_or_init() {
                Ok(cs) => cs,
                Err(e) => return DispatchOutcome::Failed(e.to_string()),
            };
            let policy = SyncPolicy::load();
            match serve_sync(&mut brain, stream_id, origin, 0, &cs, &policy, &paths.scratch) {
                Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
                Err(e) => DispatchOutcome::Failed(e.to_string()),
            }
        }
        StreamFamily::Update => {
            let cache = ReleaseCache::open(&paths.releases);
            let trust = TrustStore::load();
            match serve_update(&mut brain, stream_id, origin, 0, &cache, &trust) {
                Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
                Err(e) => DispatchOutcome::Failed(e.to_string()),
            }
        }
        StreamFamily::Notif => serve_notif_feed(&mut brain, stream_id, origin),
        StreamFamily::WanMsg => serve_wan_feed(&mut brain, stream_id, origin, paths),
        StreamFamily::Registry | StreamFamily::Attach | StreamFamily::Xfer => {
            DispatchOutcome::Unwired(family)
        }
        StreamFamily::Unknown => DispatchOutcome::Unknown,
    }
}

/// Notif feed arm: decode rows as they arrive, apply each batch under the
/// fail-closed gate, until the peer finishes. The policy re-loads per feed
/// (not per record) — the same freshness the other arms get per request.
// [impl->REQ-NOTIF-1]
fn serve_notif_feed(brain: &mut Brain, stream_id: u64, origin: &str) -> DispatchOutcome {
    let store = match NotifStore::open() {
        Ok(s) => s,
        Err(e) => return DispatchOutcome::Failed(e.to_string()),
    };
    let policy = NotifPolicy::load();
    if let Err(e) = brain.net_stream_subscribe(stream_id, 0) {
        return DispatchOutcome::Failed(e.to_string());
    }
    let mut decoder = NotifDecoder::new();
    let mut applied = 0usize;
    loop {
        match brain.read_event() {
            Ok(BrokerEvent::NetStreamData { stream_id: sid, bytes, .. }) if sid == stream_id => {
                let records = decoder.push(&bytes);
                if records.is_empty() {
                    continue;
                }
                match apply_notif_feed(&store, origin, &records, &policy) {
                    Ok(verdicts) => applied += verdicts.len(),
                    Err(e) => return DispatchOutcome::Failed(e),
                }
            }
            Ok(BrokerEvent::NetStreamEof { stream_id: sid, .. }) if sid == stream_id => {
                return DispatchOutcome::Served(format!("notif feed: {applied} record(s)"));
            }
            Ok(BrokerEvent::Error { message }) => return DispatchOutcome::Failed(message),
            Ok(_) => {}
            Err(e) => return DispatchOutcome::Failed(e.to_string()),
        }
    }
}

/// WAN message arm: decode `WanMessage` lines, funnel each through the
/// access-gate + op-id-dedup path, until the peer finishes.
// [impl->REQ-NET-1]
fn serve_wan_feed(
    brain: &mut Brain,
    stream_id: u64,
    origin: &str,
    paths: &DispatchPaths,
) -> DispatchOutcome {
    if let Err(e) = brain.net_stream_subscribe(stream_id, 0) {
        return DispatchOutcome::Failed(e.to_string());
    }
    let mut decoder = WanDecoder::new();
    let mut handled = 0usize;
    loop {
        match brain.read_event() {
            Ok(BrokerEvent::NetStreamData { stream_id: sid, bytes, .. }) if sid == stream_id => {
                for msg in decoder.push(&bytes) {
                    let _ = receive_wan(&msg, origin, &paths.owlery);
                    handled += 1;
                }
            }
            Ok(BrokerEvent::NetStreamEof { stream_id: sid, .. }) if sid == stream_id => {
                return DispatchOutcome::Served(format!("wan feed: {handled} record(s)"));
            }
            Ok(BrokerEvent::Error { message }) => return DispatchOutcome::Failed(message),
            Ok(_) => {}
            Err(e) => return DispatchOutcome::Failed(e.to_string()),
        }
    }
}

/// Spawn the dispatcher on its own thread against the canonical broker
/// socket — the daemon-boot entry ([`crate::daemon::Daemon::run`]).
pub fn spawn_dispatcher(broker_name: String, stop: Arc<AtomicBool>) -> thread::JoinHandle<()> {
    thread::spawn(move || {
        let paths = DispatchPaths::production();
        if let Err(e) = run_dispatch_loop(&broker_name, &paths, &stop, DEFAULT_DISPATCH_POLL) {
            eprintln!("DISPATCH_LOOP_FAIL: {e}");
        }
    })
}

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

    // [unit->REQ-NET-1] the demux table: every family's first record
    // classifies correctly, including the `request` kind collision.
    #[test]
    fn classifies_every_family_by_first_line_shape() {
        let sync = json!({"kind": "request", "sync_id": "pull-1", "refs": [], "have_tips": {}});
        assert_eq!(classify_first_line(&sync), StreamFamily::Sync);

        let attach = json!({"kind": "request", "session_id": 3, "from_seq": 0});
        assert_eq!(classify_first_line(&attach), StreamFamily::Attach);

        let update = json!({"kind": "query", "upd_id": "u1", "current_version": 5, "channel": "stable"});
        assert_eq!(classify_first_line(&update), StreamFamily::Update);

        let notif = json!({"kind": "row", "row": {}});
        assert_eq!(classify_first_line(&notif), StreamFamily::Notif);

        let fetch = json!({"kind": "fetch", "path": "a.txt", "xfer_id": "x1"});
        assert_eq!(classify_first_line(&fetch), StreamFamily::Xfer);
        let push = json!({"kind": "push", "path": "a.txt", "total": 4, "xfer_id": "x2"});
        assert_eq!(classify_first_line(&push), StreamFamily::Xfer);

        let wan = json!({"target": "ling", "from": "oak", "body": "hi", "op_id": "w1"});
        assert_eq!(classify_first_line(&wan), StreamFamily::WanMsg);

        let reg = json!({"subnet": "home", "endpoint_id": "ling",
            "instance": {"node": "n1", "status": "Active", "epoch": 4}});
        assert_eq!(classify_first_line(&reg), StreamFamily::Registry);
    }

    // [unit->REQ-NET-1] unclassifiable shapes are Unknown — dropped loud,
    // never routed to a gate-bearing serve loop on a guess.
    #[test]
    fn unknown_shapes_refuse_classification() {
        for v in [
            json!({"kind": "chunk", "sync_id": "s", "offset": 0, "data_b64": ""}), // mid-protocol, not an opener
            json!({"kind": "request"}), // request without either discriminant
            json!({"kind": "nonsense"}),
            json!({"target": "ling"}),       // wan-ish but no op_id
            json!({"subnet": "home"}),       // registry-ish but no instance
            json!({"completely": "other"}),
            json!(42),
        ] {
            assert_eq!(classify_first_line(&v), StreamFamily::Unknown, "for {v}");
        }
    }
}


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

//! Inbound net-stream dispatch (D9-1) — the daemon-side acceptor that turns
//! the hand-wired serve loops of D5–D8 into production behavior.
//!
//! Every E2E test so far drove the serve half manually (a probe brain calling
//! `serve_sync` / `serve_update` / `apply_notif_feed` / `receive_wan` against
//! a known stream). In production nothing did — a peer could dial and write
//! and the bytes sat in the broker's stream log unread. This module is the
//! missing consumer: a daemon-hosted loop that watches the broker's stream
//! table for **peer-initiated** streams, classifies each by its first NDJSON
//! record, and hands it to the right serve loop on its own worker thread.
//!
//! ## Direction (the gotcha-#9 fact, now first-class)
//!
//! The stream table holds locally-opened outbound streams too. The dispatcher
//! claims only rows with `initiated_locally == false` — serving our own
//! requester stream would deadlock the requester against ourselves. The flag
//! is broker-recorded at registration (`open_stream` vs the accept loop),
//! never inferred.
//!
//! ## Classification (first-record demux)
//!
//! Streams are single-purpose; the opener's first record names the protocol.
//! The kind-tagged families collide on variant names (`request` is both
//! sync's and attach's opener; `chunk`/`done`/`err` recur everywhere), so the
//! demux keys on the first line's **shape**, not its kind alone:
//!
//! | first line | family |
//! |---|---|
//! | `kind=request` + `sync_id` | sync pull ([`crate::sync::serve_sync`]) |
//! | `kind=request` + `session_id` | remote-drive attach (D9-1-3) |
//! | `kind=query` | update pull ([`crate::propagate::serve_update`]) |
//! | `kind=row` | notif feed ([`crate::notifsync::apply_notif_feed`]) |
//! | `kind=fetch` / `kind=push` | file transfer (D9-1-3) |
//! | untagged + `target`+`op_id` | WAN message feed ([`crate::wan::receive_wan`]) |
//! | untagged + `subnet`+`instance` | registry feed (D9-1-2) |
//! | anything else | unknown — dropped, loud |
//!
//! A family whose production wiring lands in a later slice is still
//! classified here (the table is complete) but routed to a typed
//! [`Unwired`](DispatchOutcome::Unwired) drop — loud, never silent.
//!
//! ## Worker model
//!
//! Each claimed stream gets its own worker thread with its **own** brain IPC
//! connection (the broker is multi-client by design; the probe-brain pattern
//! every E2E test uses). The peek and the serve use **separate** connections:
//! the peek brain subscribes from 0 to read the first line and is dropped;
//! the serve loop then subscribes from 0 on a fresh connection so it owns its
//! stream's event feed alone (no double-subscription interleave). Origin is
//! the table's `remote_id_hex` — handshake-proven, never payload
//! (REQ-HAZARD-WAN-ORIGIN-AUTH); every family's own gate then runs unchanged.
// [impl->REQ-NET-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]

use std::collections::HashSet;
use std::io;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_net::net::notif::NotifDecoder;
use spt_net::net::wanmsg::WanDecoder;
use spt_store::contextstore::ContextStore;
use spt_store::notif::NotifStore;
use spt_store::perch;
use spt_store::trust::TrustStore;

use crate::brain::{Brain, BrokerEvent};
use crate::notifsync::{apply_notif_feed, NotifPolicy};
use crate::propagate::serve_update;
use crate::relcache::ReleaseCache;
use crate::sync::{serve_sync, SyncPolicy};
use crate::wan::receive_wan;

/// Poll cadence for the stream-table watch (the dispatcher's only busy-wait;
/// workers block on their stream's event feed).
pub const DEFAULT_DISPATCH_POLL: Duration = Duration::from_millis(100);

/// What protocol a peer-initiated stream speaks, by its first record's shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamFamily {
    /// A context sync pull (`SyncRecord::Request`).
    Sync,
    /// An update pull (`UpdRecord::Query`).
    Update,
    /// A notif replication feed (`NotifRecord::Row`).
    Notif,
    /// A WAN message feed (untagged `WanMessage` lines).
    WanMsg,
    /// A registry replication feed (untagged `RegistryUpdate` lines).
    Registry,
    /// A remote-drive attach (`AttachRecord::Request`).
    Attach,
    /// A file transfer (`XferRecord::Fetch`/`Push`).
    Xfer,
    /// Unclassifiable — dropped.
    Unknown,
}

/// Classify one decoded first line by shape (module-docs demux table).
pub fn classify_first_line(line: &serde_json::Value) -> StreamFamily {
    match line.get("kind").and_then(|k| k.as_str()) {
        Some("request") if line.get("sync_id").is_some() => StreamFamily::Sync,
        Some("request") if line.get("session_id").is_some() => StreamFamily::Attach,
        Some("query") => StreamFamily::Update,
        Some("row") => StreamFamily::Notif,
        Some("fetch") | Some("push") => StreamFamily::Xfer,
        Some(_) => StreamFamily::Unknown,
        None => {
            if line.get("target").is_some() && line.get("op_id").is_some() {
                StreamFamily::WanMsg
            } else if line.get("subnet").is_some() && line.get("instance").is_some() {
                StreamFamily::Registry
            } else {
                StreamFamily::Unknown
            }
        }
    }
}

/// Why a dispatched stream's worker ended — loud + typed, never silent.
#[derive(Debug)]
pub enum DispatchOutcome {
    /// The family's serve loop ran to its own outcome (rendered for the log).
    Served(String),
    /// Classified, but the family's production wiring lands in a later slice.
    Unwired(StreamFamily),
    /// The first line never arrived / didn't parse — dropped.
    Unknown,
    /// The worker failed (IO against broker or store).
    Failed(String),
}

/// The SPT_HOME-anchored paths the serve arms need. One value, cloned per
/// worker (policies/stores re-load from the canonical paths per request —
/// the access-gate stance, config edits take effect without a restart).
#[derive(Debug, Clone)]
pub struct DispatchPaths {
    /// The perch tree (WAN message funnel target).
    pub owlery: PathBuf,
    /// The staged-release cache dir ([`ReleaseCache::open`]).
    pub releases: PathBuf,
    /// Transient bundle/artifact scratch.
    pub scratch: PathBuf,
}

impl DispatchPaths {
    /// The production layout under [`perch::spt_home`].
    pub fn production() -> DispatchPaths {
        let home = perch::spt_home();
        DispatchPaths {
            owlery: perch::owlery_dir(),
            releases: home.join("releases"),
            scratch: home.join("scratch").join("net"),
        }
    }
}

/// Watch the broker's stream table and dispatch every **peer-initiated**
/// stream to a family worker, until `stop` is set. Blocks the calling thread
/// (the daemon spawns it on its own); workers detach (each ends with its
/// stream).
pub fn run_dispatch_loop(
    broker_name: &str,
    paths: &DispatchPaths,
    stop: &AtomicBool,
    poll: Duration,
) -> io::Result<()> {
    let mut brain = connect(broker_name)?;
    let mut claimed: HashSet<u64> = HashSet::new();

    while !stop.load(Ordering::Relaxed) {
        let reply = brain.net_streams()?;
        for s in reply.streams {
            // Claim only peer-initiated rows with bytes (a QUIC stream is
            // invisible until its first write anyway — gotcha #6 — but the
            // next_seq guard also skips a row whose pump hasn't appended yet).
            if s.initiated_locally || s.next_seq == 0 || !claimed.insert(s.stream_id) {
                continue;
            }
            let name = broker_name.to_string();
            let origin = s.remote_id_hex.clone();
            let stream_id = s.stream_id;
            let p = paths.clone();
            thread::spawn(move || {
                let outcome = worker(&name, stream_id, &origin, &p);
                match outcome {
                    DispatchOutcome::Served(_) => {}
                    other => eprintln!("DISPATCH:{stream_id}:{other:?}"),
                }
            });
        }
        thread::sleep(poll);
    }
    Ok(())
}

/// Connect a fresh brain IPC client (retrying through broker startup).
fn connect(broker_name: &str) -> io::Result<Brain> {
    let mut last = io::Error::other("broker never answered");
    for _ in 0..300 {
        match Brain::cold_start(broker_name, now_ms()) {
            Ok(b) => return Ok(b),
            Err(e) => last = e,
        }
        thread::sleep(Duration::from_millis(10));
    }
    Err(last)
}

fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// Peek the stream's first NDJSON line on a throwaway brain connection.
fn peek_first_line(broker_name: &str, stream_id: u64) -> io::Result<Option<serde_json::Value>> {
    let mut brain = connect(broker_name)?;
    brain.net_stream_subscribe(stream_id, 0)?;
    let mut buf: Vec<u8> = Vec::new();
    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                buf.extend_from_slice(&bytes);
                if let Some(nl) = buf.iter().position(|&b| b == b'\n') {
                    return Ok(serde_json::from_slice(&buf[..nl]).ok());
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                // Finished without a complete line: classify what we have
                // (a line without a trailing newline before EOF still counts).
                return Ok(serde_json::from_slice(&buf).ok());
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}

/// One claimed stream's worker: peek → classify → run the family's serve
/// loop on a fresh brain connection.
fn worker(broker_name: &str, stream_id: u64, origin: &str, paths: &DispatchPaths) -> DispatchOutcome {
    let line = match peek_first_line(broker_name, stream_id) {
        Ok(Some(v)) => v,
        Ok(None) => return DispatchOutcome::Unknown,
        Err(e) => return DispatchOutcome::Failed(e.to_string()),
    };
    let family = classify_first_line(&line);

    let mut brain = match connect(broker_name) {
        Ok(b) => b,
        Err(e) => return DispatchOutcome::Failed(e.to_string()),
    };

    match family {
        StreamFamily::Sync => {
            let cs = match ContextStore::open_or_init() {
                Ok(cs) => cs,
                Err(e) => return DispatchOutcome::Failed(e.to_string()),
            };
            let policy = SyncPolicy::load();
            match serve_sync(&mut brain, stream_id, origin, 0, &cs, &policy, &paths.scratch) {
                Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
                Err(e) => DispatchOutcome::Failed(e.to_string()),
            }
        }
        StreamFamily::Update => {
            let cache = ReleaseCache::open(&paths.releases);
            let trust = TrustStore::load();
            match serve_update(&mut brain, stream_id, origin, 0, &cache, &trust) {
                Ok(out) => DispatchOutcome::Served(format!("{out:?}")),
                Err(e) => DispatchOutcome::Failed(e.to_string()),
            }
        }
        StreamFamily::Notif => serve_notif_feed(&mut brain, stream_id, origin),
        StreamFamily::WanMsg => serve_wan_feed(&mut brain, stream_id, origin, paths),
        StreamFamily::Registry | StreamFamily::Attach | StreamFamily::Xfer => {
            DispatchOutcome::Unwired(family)
        }
        StreamFamily::Unknown => DispatchOutcome::Unknown,
    }
}

/// Notif feed arm: decode rows as they arrive, apply each batch under the
/// fail-closed gate, until the peer finishes. The policy re-loads per feed
/// (not per record) — the same freshness the other arms get per request.
// [impl->REQ-NOTIF-1]
fn serve_notif_feed(brain: &mut Brain, stream_id: u64, origin: &str) -> DispatchOutcome {
    let store = match NotifStore::open() {
        Ok(s) => s,
        Err(e) => return DispatchOutcome::Failed(e.to_string()),
    };
    let policy = NotifPolicy::load();
    if let Err(e) = brain.net_stream_subscribe(stream_id, 0) {
        return DispatchOutcome::Failed(e.to_string());
    }
    let mut decoder = NotifDecoder::new();
    let mut applied = 0usize;
    loop {
        match brain.read_event() {
            Ok(BrokerEvent::NetStreamData { stream_id: sid, bytes, .. }) if sid == stream_id => {
                let records = decoder.push(&bytes);
                if records.is_empty() {
                    continue;
                }
                match apply_notif_feed(&store, origin, &records, &policy) {
                    Ok(verdicts) => applied += verdicts.len(),
                    Err(e) => return DispatchOutcome::Failed(e),
                }
            }
            Ok(BrokerEvent::NetStreamEof { stream_id: sid, .. }) if sid == stream_id => {
                return DispatchOutcome::Served(format!("notif feed: {applied} record(s)"));
            }
            Ok(BrokerEvent::Error { message }) => return DispatchOutcome::Failed(message),
            Ok(_) => {}
            Err(e) => return DispatchOutcome::Failed(e.to_string()),
        }
    }
}

/// WAN message arm: decode `WanMessage` lines, funnel each through the
/// access-gate + op-id-dedup path, until the peer finishes.
// [impl->REQ-NET-1]
fn serve_wan_feed(
    brain: &mut Brain,
    stream_id: u64,
    origin: &str,
    paths: &DispatchPaths,
) -> DispatchOutcome {
    if let Err(e) = brain.net_stream_subscribe(stream_id, 0) {
        return DispatchOutcome::Failed(e.to_string());
    }
    let mut decoder = WanDecoder::new();
    let mut handled = 0usize;
    loop {
        match brain.read_event() {
            Ok(BrokerEvent::NetStreamData { stream_id: sid, bytes, .. }) if sid == stream_id => {
                for msg in decoder.push(&bytes) {
                    let _ = receive_wan(&msg, origin, &paths.owlery);
                    handled += 1;
                }
            }
            Ok(BrokerEvent::NetStreamEof { stream_id: sid, .. }) if sid == stream_id => {
                return DispatchOutcome::Served(format!("wan feed: {handled} record(s)"));
            }
            Ok(BrokerEvent::Error { message }) => return DispatchOutcome::Failed(message),
            Ok(_) => {}
            Err(e) => return DispatchOutcome::Failed(e.to_string()),
        }
    }
}

/// Spawn the dispatcher on its own thread against the canonical broker
/// socket — the daemon-boot entry ([`crate::daemon::Daemon::run`]).
pub fn spawn_dispatcher(broker_name: String, stop: Arc<AtomicBool>) -> thread::JoinHandle<()> {
    thread::spawn(move || {
        let paths = DispatchPaths::production();
        if let Err(e) = run_dispatch_loop(&broker_name, &paths, &stop, DEFAULT_DISPATCH_POLL) {
            eprintln!("DISPATCH_LOOP_FAIL: {e}");
        }
    })
}

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

    // [unit->REQ-NET-1] the demux table: every family's first record
    // classifies correctly, including the `request` kind collision.
    #[test]
    fn classifies_every_family_by_first_line_shape() {
        let sync = json!({"kind": "request", "sync_id": "pull-1", "refs": [], "have_tips": {}});
        assert_eq!(classify_first_line(&sync), StreamFamily::Sync);

        let attach = json!({"kind": "request", "session_id": 3, "from_seq": 0});
        assert_eq!(classify_first_line(&attach), StreamFamily::Attach);

        let update = json!({"kind": "query", "upd_id": "u1", "current_version": 5, "channel": "stable"});
        assert_eq!(classify_first_line(&update), StreamFamily::Update);

        let notif = json!({"kind": "row", "row": {}});
        assert_eq!(classify_first_line(&notif), StreamFamily::Notif);

        let fetch = json!({"kind": "fetch", "path": "a.txt", "xfer_id": "x1"});
        assert_eq!(classify_first_line(&fetch), StreamFamily::Xfer);
        let push = json!({"kind": "push", "path": "a.txt", "total": 4, "xfer_id": "x2"});
        assert_eq!(classify_first_line(&push), StreamFamily::Xfer);

        let wan = json!({"target": "ling", "from": "oak", "body": "hi", "op_id": "w1"});
        assert_eq!(classify_first_line(&wan), StreamFamily::WanMsg);

        let reg = json!({"subnet": "home", "endpoint_id": "ling",
            "instance": {"node": "n1", "status": "Active", "epoch": 4}});
        assert_eq!(classify_first_line(&reg), StreamFamily::Registry);
    }

    // [unit->REQ-NET-1] unclassifiable shapes are Unknown — dropped loud,
    // never routed to a gate-bearing serve loop on a guess.
    #[test]
    fn unknown_shapes_refuse_classification() {
        for v in [
            json!({"kind": "chunk", "sync_id": "s", "offset": 0, "data_b64": ""}), // mid-protocol, not an opener
            json!({"kind": "request"}), // request without either discriminant
            json!({"kind": "nonsense"}),
            json!({"target": "ling"}),       // wan-ish but no op_id
            json!({"subnet": "home"}),       // registry-ish but no instance
            json!({"completely": "other"}),
            json!(42),
        ] {
            assert_eq!(classify_first_line(&v), StreamFamily::Unknown, "for {v}");
        }
    }
}


Bash

grep -n "pub mod" crates/spt-daemon/src/lib.rs | head -30

lib.rs module list


106:pub mod access;
107:pub mod adapter_update;
108:pub mod attach;
109:pub mod brain;
110:pub mod broker;
111:pub mod codec;
112:pub mod config;
113:pub mod consent;
114:pub mod daemon;
115:pub mod digest;
116:pub mod digesthub;
117:pub mod effect;
118:pub mod endpoint;
119:pub mod frame;
120:pub mod lifecycle;
121:pub mod msg;
122:pub mod nethost;
123:pub mod notif;
124:pub mod notifsync;
125:pub mod propagate;
126:pub mod psyrelay;
127:pub mod reconcile;
128:pub mod relay;
129:pub mod relcache;
130:pub mod release;
131:pub mod seedmap;
132:pub mod sync;
133:pub mod transport;
134:pub mod update;
135:pub mod wan;


grep -n "pub mod daemon" crates/spt-daemon/src/lib.rs

Locate insertion point


114:pub mod daemon;


112	pub mod config;
113	pub mod consent;
114	pub mod daemon;
115	pub mod digest;
116	pub mod digesthub;
117	pub mod effect;
118	pub mod endpoint;
119	pub mod frame;

pub mod config;
pub mod consent;
pub mod daemon;
pub mod digest;
pub mod digesthub;
pub mod effect;
pub mod endpoint;
pub mod frame;


Edit

pub mod daemon;
pub mod digest;
pub mod dispatch;


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

pub mod daemon;
pub mod digest;
pub mod dispatch;

//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
//!
//! ADR-0004 splits the logical daemon into two implementation layers so a
//! routine self-update swaps the logic without disturbing live endpoints:
//!
//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
//!   live consumer would lose continuity on if the logic restarted — PTY
//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
//!   sockets — and runs no logic;
//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
//!   **versioned local IPC** so a *newer brain talks to an older broker*.
//!
//! **B0 (this commit)** lands the crate's layer position plus that versioned
//! IPC seam — the load-bearing contract everything else rides:
//!
//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
//!
//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
//! half that hosts a real `spt-term` PTY child and relays its output/input over
//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
//! broker↔brain message catalog ([`msg`]).
//!
//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
//! constant; the only residue left in the harness-hosted session is the thin,
//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
//!
//! **B5 (this commit)** makes the broker↔brain boundary **exactly-once across a
//! brain crash** ([`effect::EffectJournal`]): an idempotent `input` carries a
//! durable `op_id`, and the broker dedups at the effect keyed by that id over a
//! broker-owned recovery anchor (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`];
//! Spike #6's two constraints — broker-owned anchor, dedup-at-effect-by-id —
//! instantiated on the real PTY-write boundary).
//!
//! **C0 (this commit)** opens M3c — the self-update engine ([`update`]): the
//! ADR-0004 §A update-class taxonomy ([`UpdateClass`] + [`classify`]) and the
//! **brain-only zero-interruption swap** ([`apply_brain_only`], [`REQ-UPD-3`]).
//! The brain-only apply *triggers* a handoff over the B2 substrate — snapshot →
//! drop → re-attach — so the live PTY child + its output stream survive the logic
//! swap untouched; it never re-implements the handoff and never touches a broker
//! resource.
//!
//! **C1 (this commit)** adds the verify-before-handoff gate ([`release`]): a
//! release is signature-verified ([`REQ-UPD-2`]) and hardened against rollback
//! ([`REQ-HAZARD-UPDATE-ROLLBACK`]) — Ed25519 over signed metadata, key
//! trust/revocation, channel pinning, metadata expiry, monotonic version, and a
//! SHA-256 binding the signature to the actual binary bytes. [`plan_verified`] is
//! the front door: it produces an [`UpdatePlan`] *only* for a release that passes
//! every check, so an unverified binary can never reach [`apply_brain_only`].
//!
//! **C3 (this commit)** conducts the **adapter ripple** ([`adapter_update`]):
//! after self-update, spt-core updates each registered adapter via its manifest
//! `[update]` avenue ([`REQ-UPD-5`], [`REQ-SEAM-UPDATE`]). `file_pull` payloads
//! are verified against the **adapter's own** key (manifest-declared) + SHA-256
//! (adapter content signing, ADR-0004 §D); `delegated` updaters are opaque, so
//! spt-core delegates the trust only when the adapter attests `self_verifies`,
//! else skips them as unverifiable.
//!
//! **D4b (this commit)** makes broker-held QUIC streams survive brain restarts
//! **gaplessly and exactly-once** (Spike #3 invariants A–D as real tests): each
//! stream's reads are buffered in a sequenced ring (the B2 `OutputLog`
//! discipline) a brain subscribes to with a resume cursor; sends/opens carry
//! durable `op_id`s journaled at the broker, so a restarted brain's re-drive
//! dedups instead of double-writing the wire.
//!
//! **D4a (this commit)** moves the node's network presence into the broker
//! ([`nethost::NetHost`], ADR-0004 §B): a dedicated tokio runtime hosts the
//! iroh endpoint + every accepted/dialed QUIC connection, so a brain restart
//! never interrupts a cross-node connection. The brain drives net operations
//! (endpoint-status, dial) over new IPC frames in the same forward-compatible
//! catalog; an `op_id`-carrying dial is exactly-once through the B5
//! [`effect::EffectJournal`]. The endpoint binds as the durable node identity
//! (`spt-store`'s `node.key`), with the relay stance from `daemon.json`.
//!
//! **D5a (this commit)** puts WAN message delivery on the D4 substrate: a
//! [`WanMessage`](spt_net::net::wanmsg::WanMessage) NDJSON feed over a
//! broker-owned QUIC stream funnels through [`wan::receive_wan`] — the ADR-0009
//! access-gate seam ([`access`], handshake-proven origin only, KNOWN-HAZARDS
//! 7.5) plus durable op-id dedup (7.2, wire-fed) — into the existing local
//! REQ-MSG delivery path.
//!
//! **D5b (this commit)** adds remote terminal attach ([`attach`], REQ-INST-8):
//! a byte-stream viewport onto a local session for an operator daemon on
//! another node — session output pumped as sequenced
//! [`AttachRecord`](spt_net::net::attach::AttachRecord)s (deduped at the
//! operator's render cursor), operator input through the B5 exactly-once PTY
//! path, the whole loop gated and restart-survivable purely from the D4/B5
//! machinery.
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod access;
pub mod adapter_update;
pub mod attach;
pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod consent;
pub mod daemon;
pub mod digest;
pub mod digesthub;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod nethost;
pub mod notif;
pub mod notifsync;
pub mod propagate;
pub mod psyrelay;
pub mod reconcile;
pub mod relay;
pub mod relcache;
pub mod release;
pub mod seedmap;
pub mod sync;
pub mod transport;
pub mod update;
pub mod wan;
pub mod xfer;

#[cfg(test)]
mod test_home;

pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};
pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
pub use adapter_update::{
    conduct_ripple, plan_adapter_update, AdapterRipple, AdapterUpdateOutcome, FilePullPayload,
    SkipReason,
};
pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::{DaemonConfig, RelayChoice};
pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{
    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
    update_to_json, DigestHub,
};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
pub use notif::{
    first_fire, most_recently_active_visible, produce_and_first_fire, produce_consent_notif,
    resurface_at_boundary, FirstFireOutcome, NotifSurfacePolicy, ResurfaceOutcome,
    NOTIF_KIND_AGENT, NOTIF_KIND_CONSENT, NOTIF_KIND_PSYCHE, SUPPRESSION_WINDOW_MS,
};
pub use notifsync::{apply_notif_feed, emit_notif_feed, NotifApplyVerdict, NotifPolicy};
pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};
pub use psyrelay::{
    psyche_turn_and_relay, relay_psyche_outbound, PsycheNotifCtx, RelayOutcome, PSYCHE_BODY_CAP,
};
pub use relay::Relay;
pub use relcache::ReleaseCache;
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
pub use transport::{DaemonTransport, LocalSocketTransport};
pub use release::{
    parse_verifying_key, sha256_hex, verify_artifact, verify_metadata, verify_signature,
    RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy,
};
pub use update::{
    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
};
pub use sync::{
    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
    SyncPullReport, SyncServeOutcome,
};
pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
pub use wan::{receive_wan, WanOutcome};

pub mod consent;
pub mod daemon;
pub mod digest;
+pub mod dispatch;
pub mod digesthub;
pub mod effect;
pub mod endpoint;


pub mod daemon;
pub mod digest;
pub mod dispatch;
pub mod digesthub;

pub mod daemon;
pub mod digest;
pub mod digesthub;
pub mod dispatch;


pub mod daemon;
pub mod digest;
pub mod digesthub;
pub mod dispatch;

//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
//!
//! ADR-0004 splits the logical daemon into two implementation layers so a
//! routine self-update swaps the logic without disturbing live endpoints:
//!
//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
//!   live consumer would lose continuity on if the logic restarted — PTY
//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
//!   sockets — and runs no logic;
//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
//!   **versioned local IPC** so a *newer brain talks to an older broker*.
//!
//! **B0 (this commit)** lands the crate's layer position plus that versioned
//! IPC seam — the load-bearing contract everything else rides:
//!
//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
//!
//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
//! half that hosts a real `spt-term` PTY child and relays its output/input over
//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
//! broker↔brain message catalog ([`msg`]).
//!
//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
//! constant; the only residue left in the harness-hosted session is the thin,
//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
//!
//! **B5 (this commit)** makes the broker↔brain boundary **exactly-once across a
//! brain crash** ([`effect::EffectJournal`]): an idempotent `input` carries a
//! durable `op_id`, and the broker dedups at the effect keyed by that id over a
//! broker-owned recovery anchor (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`];
//! Spike #6's two constraints — broker-owned anchor, dedup-at-effect-by-id —
//! instantiated on the real PTY-write boundary).
//!
//! **C0 (this commit)** opens M3c — the self-update engine ([`update`]): the
//! ADR-0004 §A update-class taxonomy ([`UpdateClass`] + [`classify`]) and the
//! **brain-only zero-interruption swap** ([`apply_brain_only`], [`REQ-UPD-3`]).
//! The brain-only apply *triggers* a handoff over the B2 substrate — snapshot →
//! drop → re-attach — so the live PTY child + its output stream survive the logic
//! swap untouched; it never re-implements the handoff and never touches a broker
//! resource.
//!
//! **C1 (this commit)** adds the verify-before-handoff gate ([`release`]): a
//! release is signature-verified ([`REQ-UPD-2`]) and hardened against rollback
//! ([`REQ-HAZARD-UPDATE-ROLLBACK`]) — Ed25519 over signed metadata, key
//! trust/revocation, channel pinning, metadata expiry, monotonic version, and a
//! SHA-256 binding the signature to the actual binary bytes. [`plan_verified`] is
//! the front door: it produces an [`UpdatePlan`] *only* for a release that passes
//! every check, so an unverified binary can never reach [`apply_brain_only`].
//!
//! **C3 (this commit)** conducts the **adapter ripple** ([`adapter_update`]):
//! after self-update, spt-core updates each registered adapter via its manifest
//! `[update]` avenue ([`REQ-UPD-5`], [`REQ-SEAM-UPDATE`]). `file_pull` payloads
//! are verified against the **adapter's own** key (manifest-declared) + SHA-256
//! (adapter content signing, ADR-0004 §D); `delegated` updaters are opaque, so
//! spt-core delegates the trust only when the adapter attests `self_verifies`,
//! else skips them as unverifiable.
//!
//! **D4b (this commit)** makes broker-held QUIC streams survive brain restarts
//! **gaplessly and exactly-once** (Spike #3 invariants A–D as real tests): each
//! stream's reads are buffered in a sequenced ring (the B2 `OutputLog`
//! discipline) a brain subscribes to with a resume cursor; sends/opens carry
//! durable `op_id`s journaled at the broker, so a restarted brain's re-drive
//! dedups instead of double-writing the wire.
//!
//! **D4a (this commit)** moves the node's network presence into the broker
//! ([`nethost::NetHost`], ADR-0004 §B): a dedicated tokio runtime hosts the
//! iroh endpoint + every accepted/dialed QUIC connection, so a brain restart
//! never interrupts a cross-node connection. The brain drives net operations
//! (endpoint-status, dial) over new IPC frames in the same forward-compatible
//! catalog; an `op_id`-carrying dial is exactly-once through the B5
//! [`effect::EffectJournal`]. The endpoint binds as the durable node identity
//! (`spt-store`'s `node.key`), with the relay stance from `daemon.json`.
//!
//! **D5a (this commit)** puts WAN message delivery on the D4 substrate: a
//! [`WanMessage`](spt_net::net::wanmsg::WanMessage) NDJSON feed over a
//! broker-owned QUIC stream funnels through [`wan::receive_wan`] — the ADR-0009
//! access-gate seam ([`access`], handshake-proven origin only, KNOWN-HAZARDS
//! 7.5) plus durable op-id dedup (7.2, wire-fed) — into the existing local
//! REQ-MSG delivery path.
//!
//! **D5b (this commit)** adds remote terminal attach ([`attach`], REQ-INST-8):
//! a byte-stream viewport onto a local session for an operator daemon on
//! another node — session output pumped as sequenced
//! [`AttachRecord`](spt_net::net::attach::AttachRecord)s (deduped at the
//! operator's render cursor), operator input through the B5 exactly-once PTY
//! path, the whole loop gated and restart-survivable purely from the D4/B5
//! machinery.
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod access;
pub mod adapter_update;
pub mod attach;
pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod consent;
pub mod daemon;
pub mod digest;
pub mod dispatch;
pub mod digesthub;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod nethost;
pub mod notif;
pub mod notifsync;
pub mod propagate;
pub mod psyrelay;
pub mod reconcile;
pub mod relay;
pub mod relcache;
pub mod release;
pub mod seedmap;
pub mod sync;
pub mod transport;
pub mod update;
pub mod wan;
pub mod xfer;

#[cfg(test)]
mod test_home;

pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};
pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
pub use adapter_update::{
    conduct_ripple, plan_adapter_update, AdapterRipple, AdapterUpdateOutcome, FilePullPayload,
    SkipReason,
};
pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::{DaemonConfig, RelayChoice};
pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{
    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
    update_to_json, DigestHub,
};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
pub use notif::{
    first_fire, most_recently_active_visible, produce_and_first_fire, produce_consent_notif,
    resurface_at_boundary, FirstFireOutcome, NotifSurfacePolicy, ResurfaceOutcome,
    NOTIF_KIND_AGENT, NOTIF_KIND_CONSENT, NOTIF_KIND_PSYCHE, SUPPRESSION_WINDOW_MS,
};
pub use notifsync::{apply_notif_feed, emit_notif_feed, NotifApplyVerdict, NotifPolicy};
pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};
pub use psyrelay::{
    psyche_turn_and_relay, relay_psyche_outbound, PsycheNotifCtx, RelayOutcome, PSYCHE_BODY_CAP,
};
pub use relay::Relay;
pub use relcache::ReleaseCache;
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
pub use transport::{DaemonTransport, LocalSocketTransport};
pub use release::{
    parse_verifying_key, sha256_hex, verify_artifact, verify_metadata, verify_signature,
    RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy,
};
pub use update::{
    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
};
pub use sync::{
    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
    SyncPullReport, SyncServeOutcome,
};
pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
pub use wan::{receive_wan, WanOutcome};

pub mod consent;
pub mod daemon;
pub mod digest;
-pub mod dispatch;
pub mod digesthub;
+pub mod dispatch;
pub mod effect;
pub mod endpoint;
pub mod frame;


Wire the dispatcher into `Daemon::run`:


        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
        // background thread; a bind failure there must not stop the seed channel
        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
            Ok(broker) => {
                thread::spawn(move || {
                    let _ = broker.serve();
                });
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
        }

        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
        // background thread; a bind failure there must not stop the seed channel
        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
        let net_up = net.is_some();
        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
            Ok(broker) => {
                thread::spawn(move || {
                    let _ = broker.serve();
                });
                // Inbound net dispatch (D9-1): the daemon-side consumer of
                // peer-initiated streams. Only meaningful with a net host —
                // a net-less broker accepts nothing to dispatch.
                if net_up {
                    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
                    let _ = crate::dispatch::spawn_dispatcher(broker_socket_name(), stop);
                }
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
        }


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

        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
        // background thread; a bind failure there must not stop the seed channel
        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
            Ok(broker) => {
                thread::spawn(move || {
                    let _ = broker.serve();
                });
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
        }

        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
        // background thread; a bind failure there must not stop the seed channel
        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
        let net_up = net.is_some();
        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
            Ok(broker) => {
                thread::spawn(move || {
                    let _ = broker.serve();
                });
                // Inbound net dispatch (D9-1): the daemon-side consumer of
                // peer-initiated streams. Only meaningful with a net host —
                // a net-less broker accepts nothing to dispatch.
                if net_up {
                    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
                    let _ = crate::dispatch::spawn_dispatcher(broker_socket_name(), stop);
                }
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
        }

+                // Inbound net dispatch (D9-1): the daemon-side consumer of

+                // peer-initiated streams. Only meaningful with a net host —

+                // a net-less broker accepts nothing to dispatch.

+                    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));

+                    let _ = crate::dispatch::spawn_dispatcher(broker_socket_name(), stop);


Bash

cargo check -p spt-daemon 2>&1 | grep -E "error|warning" | head -20; echo EXIT:$?

Check dispatch compiles


D9-7: closeout sweep


Clean. Now the E2E — checking how existing tests fake homes + policy stores:


sed -n '1,40p' crates/spt-daemon/src/test_home.rs; echo ===; grep -n "SyncPolicy\|with_home\|SPT_HOME\|set_sync_subnets\|TrustStore" crates/spt-daemon/tests/sync.rs | head -25

test_home helper + sync test policy setup


//! Shared `SPT_HOME` isolation for spt-daemon unit tests (mirrors the spt-live
//! `test_support` discipline).
//!
//! Every `SPT_HOME`-touching test in this binary races on the single global env
//! var, so they all serialize on one process-wide `Mutex`.

use std::path::Path;
use std::sync::Mutex;

static HOME_LOCK: Mutex<()> = Mutex::new(());

/// Run `body` with `SPT_HOME` pointed at a fresh temp dir, serialized against
/// every other `SPT_HOME` test in this binary and restored afterward.
pub(crate) fn with_home<F: FnOnce(&Path)>(body: F) {
    let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let prev = std::env::var("SPT_HOME").ok();
    let tmp = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", tmp.path());
    body(tmp.path());
    match prev {
        Some(v) => std::env::set_var("SPT_HOME", v),
        None => std::env::remove_var("SPT_HOME"),
    }
}
===
33:    reconcile_after_sync, request_sync, serve_sync, ReconcileWiring, SyncPolicy, SyncServeOutcome,
43:use spt_store::trust::TrustStore;
48:/// write stamps the node identity under SPT_HOME).
53:        std::env::set_var("SPT_HOME", dir.path());
129:fn policy_for(peer: &str, synced_ids: &[&str]) -> SyncPolicy {
130:    let mut trust = TrustStore::default();
134:        vis.set_sync_subnets(id, vec!["home".to_string()]);
136:    SyncPolicy { subnets: vec![("home".to_string(), false)], trust, vis }
159:        policy: &SyncPolicy,


40	use spt_proto::identity::Identity;
41	use spt_runtime::{Manifest, ManifestRuntime};
42	use spt_store::contextstore::{ContextStore, LIVE_CONTEXT_FILE};
43	use spt_store::trust::TrustStore;
44	use spt_store::visibility::VisibilityStore;
45	use tempfile::TempDir;
46
47	/// Hold a temp home for the whole test binary (the reconcile turn's merged
48	/// write stamps the node identity under SPT_HOME).
49	static HOME: OnceLock<TempDir> = OnceLock::new();
50	fn init_home() {
51	    HOME.get_or_init(|| {
52	        let dir = TempDir::new().expect("temp dir");
53	        std::env::set_var("SPT_HOME", dir.path());
54	        dir
55	    });
56	}
57
58	static SEQ: AtomicU32 = AtomicU32::new(0);
59	fn unique_name() -> String {
60	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
61	    format!("spt-daemon-d6c-{}-{}.sock", std::process::id(), n)
62	}
63
64	/// One op-id namespace across every brain in the binary — journaled ops must
65	/// never collide.
66	static OP: AtomicU64 = AtomicU64::new(1);
67	fn op() -> u64 {
68	    OP.fetch_add(1, Ordering::Relaxed)
69	}
70
71	fn hermetic() -> NetConfig {
72	    NetConfig {
73	        identity: Identity::generate(),
74	        relay: RelayPolicy::Disabled,
75	        discovery: LocalDiscovery::Off,
76	        scope: BindScope::Loopback,
77	    }
78	}
79
80	fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
81	    let host = NetHost::start(hermetic()).expect("net host start");
82	    let broker =
83	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
84	    let serve = Arc::clone(&broker);
85	    thread::spawn(move || {
86	        let _ = serve.serve();
87	    });
88	    broker
89	}
90
91	fn connect_retry(name: &str) -> Brain {
92	    for _ in 0..300 {
93	        if let Ok(b) = Brain::cold_start(name, 1) {
94	            return b;
95	        }
96	        thread::sleep(Duration::from_millis(10));
97	    }
98	    panic!("brain could not connect");
99	}
100
101	fn wait_for_stream_except(brain: &mut Brain, skip: &[u64]) -> (u64, String) {
102	    // 10s budget: polls exit early when the stream appears, so the long bound
103	    // only pays on a loaded runner (2s flaked on gravity under a parallel
104	    // full-workspace run — 2026-06-04).
105	    for _ in 0..400 {
106	        let reply = brain.net_streams().expect("net-streams");
107	        if let Some(s) = reply.streams.iter().find(|s| !skip.contains(&s.stream_id)) {
108	            return (s.stream_id, s.remote_id_hex.clone());
109	        }
110	        thread::sleep(Duration::from_millis(25));
111	    }
112	    panic!("sync stream never appeared at the responder's broker");
113	}
114
115	/// A marker-stamped context body (vectors hand-crafted per node so the two
116	/// in-process "nodes" can fork causally — the live write path stamps these in
117	/// production).
118	fn stamped(vector: &str, body: &str) -> String {
119	    format!("<!-- spt:source=llm spt:routed_at_ms=1000 spt:node=t spt:vector={vector} -->\n{body}")
120	}
121
122	fn write_live(cs: &ContextStore, id: &str, content: &str) {
123	    let p = cs.live_context_path(id).unwrap();
124	    std::fs::write(&p, content).unwrap();
125	    cs.commit_live(id, "write").unwrap();
126	}
127
128	/// A policy that trusts `peer` in subnet `home` and syncs `synced_ids` there.
129	fn policy_for(peer: &str, synced_ids: &[&str]) -> SyncPolicy {
130	    let mut trust = TrustStore::default();
131	    trust.record("home", peer, None, None);
132	    let mut vis = VisibilityStore::default();
133	    for id in synced_ids {
134	        vis.set_sync_subnets(id, vec!["home".to_string()]);
135	    }
136	    SyncPolicy { subnets: vec![("home".to_string(), false)], trust, vis }
137	}
138
139	/// One pull direction between two daemons: requester ← responder.
140	struct PullLink {
141	    requester: String,
142	    requester_root: std::path::PathBuf,
143	    responder: String,
144	    responder_root: std::path::PathBuf,
145	    scratch: std::path::PathBuf,
146	}
147
148	impl PullLink {
149	    /// Run one pull: requester thread + responder serve. Returns
150	    /// `(pull report, serve outcome)`.
151	    ///
152	    /// The broker's stream table holds BOTH inbound and locally-opened
153	    /// streams, so the responder must not grab one of its own (or an earlier
154	    /// pull's) rows: snapshot the table before the requester opens its
155	    /// stream, then wait for the genuinely new one.
156	    fn pull(
157	        &mut self,
158	        refs: &[&str],
159	        policy: &SyncPolicy,
160	    ) -> (spt_daemon::sync::SyncPullReport, SyncServeOutcome) {
161	        let mut serve_brain = connect_retry(&self.responder);
162	        let addr = serve_brain.net_status().expect("status").addr;
163	        let skip: Vec<u64> = serve_brain
164	            .net_streams()
165	            .expect("stream snapshot")
166	            .streams
167	            .iter()
168	            .map(|s| s.stream_id)
169	            .collect();
170
171	        let mut req_brain = connect_retry(&self.requester);
172	        let conn = req_brain.net_dial(addr, Some(op())).expect("dial");
173	        let refs_owned: Vec<String> = refs.iter().map(|r| r.to_string()).collect();
174	        let req_root = self.requester_root.clone();
175	        let scratch_req = self.scratch.join("req");
176	        let open_op = op();
177	        let puller = thread::spawn(move || {
178	            let cs = ContextStore::open_or_init_in(&req_root).unwrap();
179	            request_sync(&mut req_brain, conn.conn_id, &refs_owned, open_op, &cs, &scratch_req)
180	                .expect("pull")
181	        });
182
183	        let (stream, origin) = wait_for_stream_except(&mut serve_brain, &skip);
184	        let cs = ContextStore::open_or_init_in(&self.responder_root).unwrap();
185	        let outcome = serve_sync(
186	            &mut serve_brain,
187	            stream,
188	            &origin,
189	            0,
190	            &cs,
191	            policy,
192	            &self.scratch.join("serve"),
193	        )
194	        .expect("serve");
195	        (puller.join().expect("pull thread"), outcome)
196	    }
197	}
198
199	// [unit->REQ-INST-5] two-tier sync over the wire: a-<id> + p-<project> land

use spt_proto::identity::Identity;
use spt_runtime::{Manifest, ManifestRuntime};
use spt_store::contextstore::{ContextStore, LIVE_CONTEXT_FILE};
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;
use tempfile::TempDir;

/// Hold a temp home for the whole test binary (the reconcile turn's merged
/// write stamps the node identity under SPT_HOME).
static HOME: OnceLock<TempDir> = OnceLock::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-d6c-{}-{}.sock", std::process::id(), n)
}

/// One op-id namespace across every brain in the binary — journaled ops must
/// never collide.
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");
}

fn wait_for_stream_except(brain: &mut Brain, skip: &[u64]) -> (u64, String) {
    // 10s budget: polls exit early when the stream appears, so the long bound
    // only pays on a loaded runner (2s flaked on gravity under a parallel
    // full-workspace run — 2026-06-04).
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.iter().find(|s| !skip.contains(&s.stream_id)) {
            return (s.stream_id, s.remote_id_hex.clone());
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("sync stream never appeared at the responder's broker");
}

/// A marker-stamped context body (vectors hand-crafted per node so the two
/// in-process "nodes" can fork causally — the live write path stamps these in
/// production).
fn stamped(vector: &str, body: &str) -> String {
    format!("<!-- spt:source=llm spt:routed_at_ms=1000 spt:node=t spt:vector={vector} -->\n{body}")
}

fn write_live(cs: &ContextStore, id: &str, content: &str) {
    let p = cs.live_context_path(id).unwrap();
    std::fs::write(&p, content).unwrap();
    cs.commit_live(id, "write").unwrap();
}

/// A policy that trusts `peer` in subnet `home` and syncs `synced_ids` there.
fn policy_for(peer: &str, synced_ids: &[&str]) -> SyncPolicy {
    let mut trust = TrustStore::default();
    trust.record("home", peer, None, None);
    let mut vis = VisibilityStore::default();
    for id in synced_ids {
        vis.set_sync_subnets(id, vec!["home".to_string()]);
    }
    SyncPolicy { subnets: vec![("home".to_string(), false)], trust, vis }
}

/// One pull direction between two daemons: requester ← responder.
struct PullLink {
    requester: String,
    requester_root: std::path::PathBuf,
    responder: String,
    responder_root: std::path::PathBuf,
    scratch: std::path::PathBuf,
}

impl PullLink {
    /// Run one pull: requester thread + responder serve. Returns
    /// `(pull report, serve outcome)`.
    ///
    /// The broker's stream table holds BOTH inbound and locally-opened
    /// streams, so the responder must not grab one of its own (or an earlier
    /// pull's) rows: snapshot the table before the requester opens its
    /// stream, then wait for the genuinely new one.
    fn pull(
        &mut self,
        refs: &[&str],
        policy: &SyncPolicy,
    ) -> (spt_daemon::sync::SyncPullReport, SyncServeOutcome) {
        let mut serve_brain = connect_retry(&self.responder);
        let addr = serve_brain.net_status().expect("status").addr;
        let skip: Vec<u64> = serve_brain
            .net_streams()
            .expect("stream snapshot")
            .streams
            .iter()
            .map(|s| s.stream_id)
            .collect();

        let mut req_brain = connect_retry(&self.requester);
        let conn = req_brain.net_dial(addr, Some(op())).expect("dial");
        let refs_owned: Vec<String> = refs.iter().map(|r| r.to_string()).collect();
        let req_root = self.requester_root.clone();
        let scratch_req = self.scratch.join("req");
        let open_op = op();
        let puller = thread::spawn(move || {
            let cs = ContextStore::open_or_init_in(&req_root).unwrap();
            request_sync(&mut req_brain, conn.conn_id, &refs_owned, open_op, &cs, &scratch_req)
                .expect("pull")
        });

        let (stream, origin) = wait_for_stream_except(&mut serve_brain, &skip);
        let cs = ContextStore::open_or_init_in(&self.responder_root).unwrap();
        let outcome = serve_sync(
            &mut serve_brain,
            stream,
            &origin,
            0,
            &cs,
            policy,
            &self.scratch.join("serve"),
        )
        .expect("serve");
        (puller.join().expect("pull thread"), outcome)
    }
}

// [unit->REQ-INST-5] two-tier sync over the wire: a-<id> + p-<project> land


Harness conventions clear. Checking store save APIs + notif row insert + spool assert for the E2E:


grep -n "pub fn save\|pub fn load\|pub fn create_subnet" crates/spt-store/src/subnet.rs crates/spt-store/src/visibility.rs | head; echo ===; grep -n "pub fn insert\|pub fn upsert\|pub fn rows\|pub fn all\|pub fn list\|fn mint" crates/spt-store/src/notif.rs | head; echo ===; grep -n "spool\|assert" crates/spt-daemon/tests/wanmsg.rs | sed -n '1,16p'

Store APIs for E2E seeding/assertions


crates/spt-store/src/subnet.rs:111:    pub fn load() -> SubnetStore {
crates/spt-store/src/subnet.rs:118:    pub fn load_from(path: &Path) -> SubnetStore {
crates/spt-store/src/subnet.rs:126:    pub fn save(&self) -> Result<(), SubnetError> {
crates/spt-store/src/subnet.rs:133:    pub fn save_to(&self, path: &Path) -> Result<(), SubnetError> {
crates/spt-store/src/subnet.rs:155:    pub fn create_subnet(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
crates/spt-store/src/visibility.rs:61:    pub fn load() -> VisibilityStore {
crates/spt-store/src/visibility.rs:68:    pub fn load_from(path: &Path) -> VisibilityStore {
crates/spt-store/src/visibility.rs:76:    pub fn save(&self) -> Result<(), SubnetError> {
crates/spt-store/src/visibility.rs:81:    pub fn save_to(&self, path: &Path) -> Result<(), SubnetError> {
===
246:    pub fn list(&self, subnet: &str) -> rusqlite::Result<Vec<NotifRow>> {
===
2://! sent from daemon A lands in B's perch spool **exactly once**, gated on the
9://!   into the existing local spool path (`receive_wan` → `spool_wan_message_at`)
34:use spt_store::spool;
104:/// funnel takes the atomic spool path.
142:// [unit->REQ-NET-1] a WAN message from daemon A lands in B's perch spool
145:// receive_wan into the atomic op-id+spool insert; a replayed record (same wire
188:    assert_eq!(remote, a_node, "the stream table's origin IS the QUIC handshake identity");
192:    assert_eq!(
197:    let pending = spool::peek_all_at(&perch_path).expect("peek");
198:    assert_eq!(pending.len(), 2, "exactly two spool rows");
199:    assert_eq!(pending[0].2, "hello across the wire");
200:    assert_eq!(pending[1].2, "spoofed?", "forged-origin record delivered (gate saw the REAL origin)");
201:    assert_eq!(pending[1].1, "mallory", "from is reply metadata, not an authorization subject");
206:// durable op-id claims dedup everything life 1 already delivered, so the spool
242:                    assert_eq!(receive_wan(&rec, &remote, &owlery), WanOutcome::Spooled);
258:    assert_eq!(


grep -n "pub fn" crates/spt-store/src/notif.rs | head -20; echo ===; grep -n "pub struct NotifRow" -A 25 crates/spt-store/src/notif.rs | head -35; echo ===; sed -n '95,135p' crates/spt-daemon/tests/wanmsg.rs

NotifStore/NotifRow API + offline perch helper


89:    pub fn open() -> rusqlite::Result<NotifStore> {
95:    pub fn open_at(path: &Path) -> rusqlite::Result<NotifStore> {
128:    pub fn produce(
162:    pub fn merge_row(&self, incoming: &NotifRow) -> rusqlite::Result<MergeOutcome> {
198:    pub fn dismiss(&self, notif_id: &str) -> rusqlite::Result<bool> {
209:    pub fn mark_seen(&self, notif_id: &str, endpoint: &str) -> rusqlite::Result<bool> {
232:    pub fn touch_surfaced(&self, notif_id: &str, now_ms: u64) -> rusqlite::Result<bool> {
241:    pub fn get(&self, notif_id: &str) -> rusqlite::Result<Option<NotifRow>> {
246:    pub fn list(&self, subnet: &str) -> rusqlite::Result<Vec<NotifRow>> {
252:    pub fn undismissed(&self, subnet: &str) -> rusqlite::Result<Vec<NotifRow>> {
===
43:pub struct NotifRow {
44-    /// `<node-hex>:<epoch>` — unique subnet-wide, the idempotent insert key.
45-    pub notif_id: String,
46-    /// The subnet this notif belongs to (the replication + surfacing scope).
47-    pub subnet: String,
48-    /// Producer tag (`consent`, `update`, `psyche`, `agent`, …) — an open set
49-    /// (ADR-0007: one primitive, open producer set).
50-    pub kind: String,
51-    /// The issuer (ADR-0007 envelope `from`: endpoint id, with node + subnet
52-    /// qualification supplied by the producing daemon where it applies).
53-    pub from_id: String,
54-    /// The notif body (4.1-codec text; composed into a typed envelope at
55-    /// surfacing, not here).
56-    pub body: String,
57-    /// Produce-time wall-clock stamp (display/audit only).
58-    pub created_ms: u64,
59-    /// Explicit ack — the one-way latch. Replicates subnet-wide.
60-    pub dismissed: bool,
61-    /// Endpoint ids this notif has surfaced at (seen-per-endpoint).
62-    pub seen: BTreeSet<String>,
63-    /// Greatest surface time across all endpoints/nodes — the cross-endpoint
64-    /// suppression input. `0` = never surfaced.
65-    pub last_surfaced_ms: u64,
66-}
67-
68-/// What [`NotifStore::merge_row`] did with one incoming row — the replication
===
        if let Some(s) = reply.streams.first() {
            return (s.stream_id, s.remote_id_hex.clone());
        }
        thread::sleep(Duration::from_millis(5));
    }
    panic!("stream never appeared at the receiver's broker");
}

/// Create an offline perch (dir + info.json, no listener) for `id` so the
/// funnel takes the atomic spool path.
fn offline_perch(id: &str) -> std::path::PathBuf {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).expect("perch dir");
    let rec = InfoJson::new(id, "now", std::process::id(), "sess", "ready_agent");
    info::write_info(&perch_path, &rec).expect("info.json");
    perch_path
}

fn msg(target: &str, body: &str, op_id: &str) -> WanMessage {
    WanMessage { target: target.into(), from: "doyle".into(), body: body.into(), op_id: op_id.into() }
}

/// Drive the subscribed stream's events through the decoder + funnel until
/// `eof`, returning every funnel outcome in feed order.
fn funnel_until_eof(
    brain: &mut Brain,
    stream_id: u64,
    origin: &str,
    owlery: &std::path::Path,
) -> Vec<WanOutcome> {
    let mut decoder = WanDecoder::new();
    let mut outcomes = Vec::new();
    loop {
        match brain.read_event().expect("read event") {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    outcomes.push(receive_wan(&rec, origin, owlery));
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => break,
            BrokerEvent::Error { message } => panic!("broker error: {message}"),


grep -n "pub struct NetStatusReply" -A 10 crates/spt-daemon/src/msg.rs; echo ===; sed -n '184,260p' crates/spt-daemon/src/propagate.rs

NetStatusReply fields + request_update signature


214:pub struct NetStatusReply {
215-    /// Whether this broker hosts a net endpoint at all.
216-    pub enabled: bool,
217-    /// The node's stable name (lowercase-hex Ed25519 pubkey == iroh EndpointId).
218-    #[serde(default, skip_serializing_if = "Option::is_none")]
219-    pub node_id_hex: Option<String>,
220-    /// The endpoint's dialable address — a serde-serialized iroh `EndpointAddr`,
221-    /// opaque to this catalog (the net host owns its shape). `null` when disabled.
222-    #[serde(default)]
223-    pub addr: serde_json::Value,
224-    /// Live connections (accepted + dialed) in the broker's conn table.
===
pub enum UpdatePullOutcome {
    /// The peer's release passed the full per-node gate; the artifact is
    /// staged ([`ReleaseCache`]) and the classified plan is ready for the
    /// consent/apply path (C2/C0 — not triggered here).
    Updated(UpdatePlan),
    /// The peer had nothing newer on this node's channel.
    UpToDate,
    /// The peer's release failed this node's gate — loud + typed, never a
    /// silent accept (ADR-0004 §D). Nothing was staged. Pre-fetch rejects
    /// (rollback / expired / off-channel / bad signature on the offered
    /// metadata) carry zero artifact bytes; a post-fetch reject means the
    /// pulled bytes did not match the signed digest.
    Rejected(RejectReason),
}

/// Requester side: pull a newer release from the peer over `conn_id`, gate it
/// per-node, and stage it on success. `open_op` is the durable id for the
/// exactly-once stream open; everything after rides fire-and-forget. A torn
/// pull errors — recovery is a fresh `request_update` (the query is
/// idempotent, staging is atomic; FAULT-MATRIX stream-loss row).
///
/// The gate runs **twice by design** (REQ-UPD-2: per-node, never inherited):
/// [`verify_metadata`] on the offer before any bytes move, then the whole
/// [`plan_verified`] front door (metadata + artifact digest + classify) on
/// the real bytes before staging.
// [impl->REQ-UPD-1]
// [impl->REQ-UPD-2]
pub fn request_update(
    brain: &mut Brain,
    conn_id: u64,
    open_op: u64,
    running: &BrokerAbi,
    policy: &VerifyPolicy,
    cache: &ReleaseCache,
    scratch_dir: &Path,
) -> io::Result<UpdatePullOutcome> {
    let opened = brain.net_open_stream(conn_id, Some(open_op))?;
    let stream_id = opened.stream_id;
    brain.net_stream_subscribe(stream_id, 0)?;

    let upd_id = format!("upd-{open_op}");
    let query = UpdRecord::Query {
        upd_id: upd_id.clone(),
        current_version: policy.current_version,
        channel: policy.pinned_channel.clone(),
    }
    .encode_line();
    brain.net_stream_send(stream_id, &query, None, false)?;

    std::fs::create_dir_all(scratch_dir)?;
    let artifact_path = scratch_dir.join(format!("pull-{open_op}.artifact"));
    let mut file: Option<std::fs::File> = None;
    let mut signed: Option<SignedRelease> = None;
    let mut decoder = UpdDecoder::new();

    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        UpdRecord::UpToDate { upd_id: xid } if xid == upd_id => {
                            brain.net_stream_send(stream_id, &[], None, true)?;
                            return Ok(UpdatePullOutcome::UpToDate);
                        }
                        UpdRecord::Offer { upd_id: xid, metadata_json, signature_hex }
                            if xid == upd_id =>
                        {
                            let offer = SignedRelease { metadata_json, signature_hex };
                            // The pre-fetch gate: reject a bad offer before a
                            // single artifact byte moves (REQ-UPD-2,
                            // REQ-HAZARD-UPDATE-ROLLBACK — a rollback offer
                            // costs the subnet nothing).
                            // [impl->REQ-HAZARD-UPDATE-ROLLBACK]
                            if let Err(reason) = verify_metadata(&offer, policy) {
                                brain.net_stream_send(stream_id, &[], None, true)?;
                                return Ok(UpdatePullOutcome::Rejected(reason));
                            }


All confirmed. Writing the E2E: