Write

//! Outbound peer pump (D9-1-2) — the production trigger loops every D5–D8
//! slice deferred ("rides D9 with the daemon lifecycle").
//!
//! One daemon-hosted loop drives three cadenced pumps against every trusted
//! peer of every member subnet, each a thin call over an already-shipped,
//! idempotent driver — the pump owns *when*, the drivers own *how*:
//!
//! - **registry push** ([`RegistryHost::advertise_local`] → one feed stream
//!   per peer): the node re-advertises its own rows; peers reconverge within
//!   one cadence (replay-is-recovery, the D4d/D8 push posture). Own rows
//!   only — no transitive gossip in v1: trust is pairwise per subnet, and a
//!   relayed third-party row would carry an origin the receiver cannot gate
//!   honestly (KNOWN-HAZARDS 7.5).
//! - **notif push** ([`crate::notifsync::emit_notif_feed`]): the full
//!   semilattice spool per subnet; the receiver's join dedups.
//! - **sync pull** ([`crate::sync::request_sync`]): anti-entropy for the
//!   minds. Want-refs are the union of [`crate::sync::select_refs`] (locally
//!   tracked branches the policy allows toward the peer) and
//!   **registry-derived** `a-<id>` refs (endpoints known in the shared
//!   subnets — how a fresh instance bootstraps a mind it has never held),
//!   both gated by the same [`SyncPolicy::allows`] the responder enforces
//!   (symmetric scoping).
//!
//! Conflicts a pull surfaces are reported through [`PumpHooks::on_pull`] —
//! the default production hook logs them loud. The bounded Psyche reconcile
//! turn itself still runs at the activation paths (ADR-0013): the daemon
//! does not yet host per-agent manifest runtimes (the 7.4 fan-out), so an
//! in-pump turn has no runtime to ride; conflict artifacts persist safely
//! meanwhile (REQ-HAZARD-CONFLICT-BOTH-PRESERVED).
//!
//! ## Wire discipline (inherited from the test suites, now production)
//!
//! Every pump write is fire-and-forget (`op_id: None`) — a journaled
//! `net_stream_send` blocks on its ack and DISCARDS interleaved events
//! (gotcha #1). Stream opens for push feeds carry no op either: the feeds
//! are idempotent by construction. The sync pull's `open_op` IS durable
//! (exactly-once open identity), minted from a dedicated [`EpochSource`]
//! file so a daemon restart can never reuse one.
// [impl->REQ-INST-5]
// [impl->REQ-INST-7]
// [impl->REQ-NOTIF-1]

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

use spt_net::net::endpoint::addr_for_node_hex;
use spt_store::contextstore::ContextStore;
use spt_store::epoch::EpochSource;
use spt_store::notif::NotifStore;
use spt_store::perch;
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;

use crate::brain::Brain;
use crate::config::DaemonConfig;
use crate::notifsync::emit_notif_feed;
use crate::registryhost::RegistryHost;
use crate::sync::{request_sync, select_refs, SyncPolicy, SyncPullReport};

/// The pump's tick granularity — each cadence fires when its period elapsed.
const TICK: Duration = Duration::from_millis(200);

/// Resolve a trusted peer's stable name (lowercase-hex pubkey) to a dialable
/// address (the serde JSON `net-dial` carries). Production resolution lets
/// discovery do the work ([`production_resolver`]); hermetic tests inject a
/// concrete address map (loopback endpoints run no discovery).
pub type PeerResolver = Box<dyn Fn(&str) -> Option<serde_json::Value> + Send>;

/// The production resolver: an id-only iroh address — mDNS on the LAN, n0
/// DNS on the WAN (`addr_for_node_hex`).
pub fn production_resolver() -> PeerResolver {
    Box::new(|hex| addr_for_node_hex(hex).and_then(|a| serde_json::to_value(a).ok()))
}

/// Where this node's pump-side stores live. Production = the canonical
/// SPT_HOME layout; the loopback E2E gives its in-process "node" distinct
/// roots so two nodes can share one test process.
pub struct PumpPaths {
    /// The perch tree (registry advertisement scan).
    pub owlery: PathBuf,
    /// The context store root ([`ContextStore::open_or_init_in`]).
    pub ctx_root: PathBuf,
    /// The notif spool DB ([`NotifStore::open_at`]).
    pub notif_db: PathBuf,
    /// Transient pull scratch.
    pub scratch: PathBuf,
    /// `subnet.json` ([`SubnetStore::load_from`]).
    pub subnets: PathBuf,
    /// `trust/peers.json` ([`TrustStore::load_from`]).
    pub trust: PathBuf,
    /// `visibility.json` ([`VisibilityStore::load_from`]).
    pub vis: PathBuf,
    /// The pump's durable open-op counter (its own file — colliding with the
    /// registry's advertisement epochs would break neither, but a shared
    /// in-memory cursor pair over one file double-mints; separate files keep
    /// each `EpochSource` the sole writer of its counter).
    pub ops: PathBuf,
}

impl PumpPaths {
    /// The canonical SPT_HOME layout.
    pub fn production() -> PumpPaths {
        let home = perch::spt_home();
        let identity = perch::identity_dir();
        PumpPaths {
            owlery: perch::owlery_dir(),
            ctx_root: home.join("context"),
            notif_db: identity.join("notifs.db"),
            scratch: home.join("scratch").join("pump"),
            subnets: identity.join("subnet.json"),
            trust: identity.join("trust").join("peers.json"),
            vis: identity.join("visibility.json"),
            ops: identity.join("pump-ops.json"),
        }
    }
}

/// Pump cadences (config-driven in production: [`DaemonConfig`]).
#[derive(Debug, Clone, Copy)]
pub struct PumpCadence {
    pub registry: Duration,
    pub notif: Duration,
    pub sync: Duration,
}

impl PumpCadence {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
        PumpCadence {
            registry: cfg.registry_pump_period,
            notif: cfg.notif_pump_period,
            sync: cfg.sync_pull_period,
        }
    }
}

/// Observation hooks. `on_pull` fires after every completed sync pull —
/// the conflict surface (ADR-0013) and the E2E's probe.
pub struct PumpHooks {
    pub on_pull: Box<dyn FnMut(&str, &SyncPullReport) + Send>,
}

impl Default for PumpHooks {
    /// The production hook: conflicts are loud, never silent — the artifacts
    /// already persist (hazard 6.6); the reconcile turn runs at activation.
    fn default() -> PumpHooks {
        PumpHooks {
            on_pull: Box::new(|peer, report| {
                for (branch, file) in report.conflicts() {
                    eprintln!("SYNC_CONFLICT:{peer}:{branch}:{file}");
                }
            }),
        }
    }
}

/// Run the pump until `stop` is set. Owns one brain IPC connection and a
/// per-peer connection cache (an IO failure against a peer drops its cache
/// row; the next tick redials — conns are rebuilt freely, nothing leaks
/// because the broker's closed-watcher reaps dead conns).
pub fn run_peer_pump(
    broker_name: &str,
    registry: Arc<RegistryHost>,
    paths: &PumpPaths,
    resolver: PeerResolver,
    cadence: PumpCadence,
    mut hooks: PumpHooks,
    stop: &AtomicBool,
) -> io::Result<()> {
    let mut brain = connect(broker_name)?;
    let mut ops = EpochSource::load_from(&paths.ops);
    let mut conns: HashMap<String, u64> = HashMap::new();
    // Stagger from "everything due now" — the first tick primes peers.
    let far = Instant::now() - Duration::from_secs(86_400);
    let (mut last_reg, mut last_notif, mut last_sync) = (far, far, far);

    while !stop.load(Ordering::Relaxed) {
        let now = Instant::now();
        let due_reg = now.duration_since(last_reg) >= cadence.registry;
        let due_notif = now.duration_since(last_notif) >= cadence.notif;
        let due_sync = now.duration_since(last_sync) >= cadence.sync;
        if !(due_reg || due_notif || due_sync) {
            thread::sleep(TICK);
            continue;
        }

        // Re-load config-ish stores per round (the access-gate freshness
        // stance — edits take effect without a restart).
        let subnets = SubnetStore::load_from(&paths.subnets);
        let trust = TrustStore::load_from(&paths.trust);
        let vis = VisibilityStore::load_from(&paths.vis);
        let adverts = if due_reg { registry.advertise_local(&paths.owlery) } else { Vec::new() };

        for sub in &subnets.subnets {
            for peer in trust.peers_in(&sub.name) {
                if peer.pubkey_hex == registry.node_hex() {
                    continue; // never pump at ourselves
                }
                let Some(conn_id) =
                    ensure_conn(&mut brain, &mut conns, &resolver, &peer.pubkey_hex)
                else {
                    continue; // unresolvable/undialable now — next tick retries
                };

                let mut peer_io = || -> io::Result<()> {
                    if due_reg {
                        let lines: Vec<u8> = adverts
                            .iter()
                            .filter(|u| u.subnet == sub.name)
                            .flat_map(|u| u.encode_line())
                            .collect();
                        if !lines.is_empty() {
                            push_feed(&mut brain, conn_id, &lines)?;
                        }
                    }
                    if due_notif {
                        let store = NotifStore::open_at(&paths.notif_db)
                            .map_err(|e| io::Error::other(e.to_string()))?;
                        let feed = emit_notif_feed(&store, &sub.name).map_err(io::Error::other)?;
                        if !feed.is_empty() {
                            push_feed(&mut brain, conn_id, &feed)?;
                        }
                    }
                    if due_sync {
                        let policy = SyncPolicy {
                            subnets: subnets
                                .subnets
                                .iter()
                                .map(|s| (s.name.clone(), s.hide_new_endpoints))
                                .collect(),
                            trust: TrustStore::load_from(&paths.trust),
                            vis: VisibilityStore::load_from(&paths.vis),
                        };
                        let cs = ContextStore::open_or_init_in(&paths.ctx_root)?;
                        let mut want = select_refs(&policy, &cs, &peer.pubkey_hex)?;
                        // Registry-derived bootstrap refs: minds named in the
                        // shared subnet that this node never held locally.
                        if let Some(snap) = registry.snapshot(&sub.name) {
                            for id in snap.endpoint_ids() {
                                let r = format!("a-{id}");
                                if !want.contains(&r) && policy.allows(&r, &peer.pubkey_hex) {
                                    want.push(r);
                                }
                            }
                        }
                        if !want.is_empty() {
                            let open_op = ops.next_epoch()?;
                            let report = request_sync(
                                &mut brain,
                                conn_id,
                                &want,
                                open_op,
                                &cs,
                                &paths.scratch,
                            )?;
                            (hooks.on_pull)(&peer.pubkey_hex, &report);
                        }
                    }
                    Ok(())
                };
                if let Err(e) = peer_io() {
                    eprintln!("PUMP_PEER_FAIL:{}:{e}", peer.pubkey_hex);
                    conns.remove(&peer.pubkey_hex); // redial next tick
                }
            }
        }

        let now = Instant::now();
        if due_reg {
            last_reg = now;
        }
        if due_notif {
            last_notif = now;
        }
        if due_sync {
            last_sync = now;
        }
    }
    Ok(())
}

/// Dial (or reuse) the connection for a peer. `None` = unresolvable or
/// undialable right now — skip, don't fail the round.
fn ensure_conn(
    brain: &mut Brain,
    conns: &mut HashMap<String, u64>,
    resolver: &PeerResolver,
    peer_hex: &str,
) -> Option<u64> {
    if let Some(id) = conns.get(peer_hex) {
        return Some(*id);
    }
    let addr = resolver(peer_hex)?;
    match brain.net_dial(addr, None) {
        Ok(d) => {
            conns.insert(peer_hex.to_string(), d.conn_id);
            Some(d.conn_id)
        }
        Err(_) => None, // peer offline/unreachable — next tick retries
    }
}

/// Open a fresh stream on `conn_id`, write one whole feed fire-and-forget,
/// and finish it (the receiver's dispatcher classifies + applies). One
/// stream per feed: streams are single-purpose by the demux contract.
fn push_feed(brain: &mut Brain, conn_id: u64, feed: &[u8]) -> io::Result<()> {
    let opened = brain.net_open_stream(conn_id, None)?;
    brain.net_stream_send(opened.stream_id, feed, None, true)?;
    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)
}

/// Spawn the pump on its own thread with the production wiring — the
/// daemon-boot entry ([`crate::daemon::Daemon::run`]).
pub fn spawn_peer_pump(
    broker_name: String,
    registry: Arc<RegistryHost>,
    cfg: DaemonConfig,
    stop: Arc<AtomicBool>,
) -> thread::JoinHandle<()> {
    thread::spawn(move || {
        let paths = PumpPaths::production();
        if let Err(e) = run_peer_pump(
            &broker_name,
            registry,
            &paths,
            production_resolver(),
            PumpCadence::from_config(&cfg),
            PumpHooks::default(),
            &stop,
        ) {
            eprintln!("PEER_PUMP_FAIL: {e}");
        }
    })
}


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

//! Outbound peer pump (D9-1-2) — the production trigger loops every D5–D8
//! slice deferred ("rides D9 with the daemon lifecycle").
//!
//! One daemon-hosted loop drives three cadenced pumps against every trusted
//! peer of every member subnet, each a thin call over an already-shipped,
//! idempotent driver — the pump owns *when*, the drivers own *how*:
//!
//! - **registry push** ([`RegistryHost::advertise_local`] → one feed stream
//!   per peer): the node re-advertises its own rows; peers reconverge within
//!   one cadence (replay-is-recovery, the D4d/D8 push posture). Own rows
//!   only — no transitive gossip in v1: trust is pairwise per subnet, and a
//!   relayed third-party row would carry an origin the receiver cannot gate
//!   honestly (KNOWN-HAZARDS 7.5).
//! - **notif push** ([`crate::notifsync::emit_notif_feed`]): the full
//!   semilattice spool per subnet; the receiver's join dedups.
//! - **sync pull** ([`crate::sync::request_sync`]): anti-entropy for the
//!   minds. Want-refs are the union of [`crate::sync::select_refs`] (locally
//!   tracked branches the policy allows toward the peer) and
//!   **registry-derived** `a-<id>` refs (endpoints known in the shared
//!   subnets — how a fresh instance bootstraps a mind it has never held),
//!   both gated by the same [`SyncPolicy::allows`] the responder enforces
//!   (symmetric scoping).
//!
//! Conflicts a pull surfaces are reported through [`PumpHooks::on_pull`] —
//! the default production hook logs them loud. The bounded Psyche reconcile
//! turn itself still runs at the activation paths (ADR-0013): the daemon
//! does not yet host per-agent manifest runtimes (the 7.4 fan-out), so an
//! in-pump turn has no runtime to ride; conflict artifacts persist safely
//! meanwhile (REQ-HAZARD-CONFLICT-BOTH-PRESERVED).
//!
//! ## Wire discipline (inherited from the test suites, now production)
//!
//! Every pump write is fire-and-forget (`op_id: None`) — a journaled
//! `net_stream_send` blocks on its ack and DISCARDS interleaved events
//! (gotcha #1). Stream opens for push feeds carry no op either: the feeds
//! are idempotent by construction. The sync pull's `open_op` IS durable
//! (exactly-once open identity), minted from a dedicated [`EpochSource`]
//! file so a daemon restart can never reuse one.
// [impl->REQ-INST-5]
// [impl->REQ-INST-7]
// [impl->REQ-NOTIF-1]

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

use spt_net::net::endpoint::addr_for_node_hex;
use spt_store::contextstore::ContextStore;
use spt_store::epoch::EpochSource;
use spt_store::notif::NotifStore;
use spt_store::perch;
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;

use crate::brain::Brain;
use crate::config::DaemonConfig;
use crate::notifsync::emit_notif_feed;
use crate::registryhost::RegistryHost;
use crate::sync::{request_sync, select_refs, SyncPolicy, SyncPullReport};

/// The pump's tick granularity — each cadence fires when its period elapsed.
const TICK: Duration = Duration::from_millis(200);

/// Resolve a trusted peer's stable name (lowercase-hex pubkey) to a dialable
/// address (the serde JSON `net-dial` carries). Production resolution lets
/// discovery do the work ([`production_resolver`]); hermetic tests inject a
/// concrete address map (loopback endpoints run no discovery).
pub type PeerResolver = Box<dyn Fn(&str) -> Option<serde_json::Value> + Send>;

/// The production resolver: an id-only iroh address — mDNS on the LAN, n0
/// DNS on the WAN (`addr_for_node_hex`).
pub fn production_resolver() -> PeerResolver {
    Box::new(|hex| addr_for_node_hex(hex).and_then(|a| serde_json::to_value(a).ok()))
}

/// Where this node's pump-side stores live. Production = the canonical
/// SPT_HOME layout; the loopback E2E gives its in-process "node" distinct
/// roots so two nodes can share one test process.
pub struct PumpPaths {
    /// The perch tree (registry advertisement scan).
    pub owlery: PathBuf,
    /// The context store root ([`ContextStore::open_or_init_in`]).
    pub ctx_root: PathBuf,
    /// The notif spool DB ([`NotifStore::open_at`]).
    pub notif_db: PathBuf,
    /// Transient pull scratch.
    pub scratch: PathBuf,
    /// `subnet.json` ([`SubnetStore::load_from`]).
    pub subnets: PathBuf,
    /// `trust/peers.json` ([`TrustStore::load_from`]).
    pub trust: PathBuf,
    /// `visibility.json` ([`VisibilityStore::load_from`]).
    pub vis: PathBuf,
    /// The pump's durable open-op counter (its own file — colliding with the
    /// registry's advertisement epochs would break neither, but a shared
    /// in-memory cursor pair over one file double-mints; separate files keep
    /// each `EpochSource` the sole writer of its counter).
    pub ops: PathBuf,
}

impl PumpPaths {
    /// The canonical SPT_HOME layout.
    pub fn production() -> PumpPaths {
        let home = perch::spt_home();
        let identity = perch::identity_dir();
        PumpPaths {
            owlery: perch::owlery_dir(),
            ctx_root: home.join("context"),
            notif_db: identity.join("notifs.db"),
            scratch: home.join("scratch").join("pump"),
            subnets: identity.join("subnet.json"),
            trust: identity.join("trust").join("peers.json"),
            vis: identity.join("visibility.json"),
            ops: identity.join("pump-ops.json"),
        }
    }
}

/// Pump cadences (config-driven in production: [`DaemonConfig`]).
#[derive(Debug, Clone, Copy)]
pub struct PumpCadence {
    pub registry: Duration,
    pub notif: Duration,
    pub sync: Duration,
}

impl PumpCadence {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
        PumpCadence {
            registry: cfg.registry_pump_period,
            notif: cfg.notif_pump_period,
            sync: cfg.sync_pull_period,
        }
    }
}

/// Observation hooks. `on_pull` fires after every completed sync pull —
/// the conflict surface (ADR-0013) and the E2E's probe.
pub struct PumpHooks {
    pub on_pull: Box<dyn FnMut(&str, &SyncPullReport) + Send>,
}

impl Default for PumpHooks {
    /// The production hook: conflicts are loud, never silent — the artifacts
    /// already persist (hazard 6.6); the reconcile turn runs at activation.
    fn default() -> PumpHooks {
        PumpHooks {
            on_pull: Box::new(|peer, report| {
                for (branch, file) in report.conflicts() {
                    eprintln!("SYNC_CONFLICT:{peer}:{branch}:{file}");
                }
            }),
        }
    }
}

/// Run the pump until `stop` is set. Owns one brain IPC connection and a
/// per-peer connection cache (an IO failure against a peer drops its cache
/// row; the next tick redials — conns are rebuilt freely, nothing leaks
/// because the broker's closed-watcher reaps dead conns).
pub fn run_peer_pump(
    broker_name: &str,
    registry: Arc<RegistryHost>,
    paths: &PumpPaths,
    resolver: PeerResolver,
    cadence: PumpCadence,
    mut hooks: PumpHooks,
    stop: &AtomicBool,
) -> io::Result<()> {
    let mut brain = connect(broker_name)?;
    let mut ops = EpochSource::load_from(&paths.ops);
    let mut conns: HashMap<String, u64> = HashMap::new();
    // Stagger from "everything due now" — the first tick primes peers.
    let far = Instant::now() - Duration::from_secs(86_400);
    let (mut last_reg, mut last_notif, mut last_sync) = (far, far, far);

    while !stop.load(Ordering::Relaxed) {
        let now = Instant::now();
        let due_reg = now.duration_since(last_reg) >= cadence.registry;
        let due_notif = now.duration_since(last_notif) >= cadence.notif;
        let due_sync = now.duration_since(last_sync) >= cadence.sync;
        if !(due_reg || due_notif || due_sync) {
            thread::sleep(TICK);
            continue;
        }

        // Re-load config-ish stores per round (the access-gate freshness
        // stance — edits take effect without a restart).
        let subnets = SubnetStore::load_from(&paths.subnets);
        let trust = TrustStore::load_from(&paths.trust);
        let vis = VisibilityStore::load_from(&paths.vis);
        let adverts = if due_reg { registry.advertise_local(&paths.owlery) } else { Vec::new() };

        for sub in &subnets.subnets {
            for peer in trust.peers_in(&sub.name) {
                if peer.pubkey_hex == registry.node_hex() {
                    continue; // never pump at ourselves
                }
                let Some(conn_id) =
                    ensure_conn(&mut brain, &mut conns, &resolver, &peer.pubkey_hex)
                else {
                    continue; // unresolvable/undialable now — next tick retries
                };

                let mut peer_io = || -> io::Result<()> {
                    if due_reg {
                        let lines: Vec<u8> = adverts
                            .iter()
                            .filter(|u| u.subnet == sub.name)
                            .flat_map(|u| u.encode_line())
                            .collect();
                        if !lines.is_empty() {
                            push_feed(&mut brain, conn_id, &lines)?;
                        }
                    }
                    if due_notif {
                        let store = NotifStore::open_at(&paths.notif_db)
                            .map_err(|e| io::Error::other(e.to_string()))?;
                        let feed = emit_notif_feed(&store, &sub.name).map_err(io::Error::other)?;
                        if !feed.is_empty() {
                            push_feed(&mut brain, conn_id, &feed)?;
                        }
                    }
                    if due_sync {
                        let policy = SyncPolicy {
                            subnets: subnets
                                .subnets
                                .iter()
                                .map(|s| (s.name.clone(), s.hide_new_endpoints))
                                .collect(),
                            trust: TrustStore::load_from(&paths.trust),
                            vis: VisibilityStore::load_from(&paths.vis),
                        };
                        let cs = ContextStore::open_or_init_in(&paths.ctx_root)?;
                        let mut want = select_refs(&policy, &cs, &peer.pubkey_hex)?;
                        // Registry-derived bootstrap refs: minds named in the
                        // shared subnet that this node never held locally.
                        if let Some(snap) = registry.snapshot(&sub.name) {
                            for id in snap.endpoint_ids() {
                                let r = format!("a-{id}");
                                if !want.contains(&r) && policy.allows(&r, &peer.pubkey_hex) {
                                    want.push(r);
                                }
                            }
                        }
                        if !want.is_empty() {
                            let open_op = ops.next_epoch()?;
                            let report = request_sync(
                                &mut brain,
                                conn_id,
                                &want,
                                open_op,
                                &cs,
                                &paths.scratch,
                            )?;
                            (hooks.on_pull)(&peer.pubkey_hex, &report);
                        }
                    }
                    Ok(())
                };
                if let Err(e) = peer_io() {
                    eprintln!("PUMP_PEER_FAIL:{}:{e}", peer.pubkey_hex);
                    conns.remove(&peer.pubkey_hex); // redial next tick
                }
            }
        }

        let now = Instant::now();
        if due_reg {
            last_reg = now;
        }
        if due_notif {
            last_notif = now;
        }
        if due_sync {
            last_sync = now;
        }
    }
    Ok(())
}

/// Dial (or reuse) the connection for a peer. `None` = unresolvable or
/// undialable right now — skip, don't fail the round.
fn ensure_conn(
    brain: &mut Brain,
    conns: &mut HashMap<String, u64>,
    resolver: &PeerResolver,
    peer_hex: &str,
) -> Option<u64> {
    if let Some(id) = conns.get(peer_hex) {
        return Some(*id);
    }
    let addr = resolver(peer_hex)?;
    match brain.net_dial(addr, None) {
        Ok(d) => {
            conns.insert(peer_hex.to_string(), d.conn_id);
            Some(d.conn_id)
        }
        Err(_) => None, // peer offline/unreachable — next tick retries
    }
}

/// Open a fresh stream on `conn_id`, write one whole feed fire-and-forget,
/// and finish it (the receiver's dispatcher classifies + applies). One
/// stream per feed: streams are single-purpose by the demux contract.
fn push_feed(brain: &mut Brain, conn_id: u64, feed: &[u8]) -> io::Result<()> {
    let opened = brain.net_open_stream(conn_id, None)?;
    brain.net_stream_send(opened.stream_id, feed, None, true)?;
    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)
}

/// Spawn the pump on its own thread with the production wiring — the
/// daemon-boot entry ([`crate::daemon::Daemon::run`]).
pub fn spawn_peer_pump(
    broker_name: String,
    registry: Arc<RegistryHost>,
    cfg: DaemonConfig,
    stop: Arc<AtomicBool>,
) -> thread::JoinHandle<()> {
    thread::spawn(move || {
        let paths = PumpPaths::production();
        if let Err(e) = run_peer_pump(
            &broker_name,
            registry,
            &paths,
            production_resolver(),
            PumpCadence::from_config(&cfg),
            PumpHooks::default(),
            &stop,
        ) {
            eprintln!("PEER_PUMP_FAIL: {e}");
        }
    })
}


Edit

pub mod nethost;
pub mod notif;
pub mod peerloop;


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 nethost;
pub mod notif;
pub mod peerloop;

//! `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 dispatch;
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 registryhost;
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 msg;
pub mod nethost;
pub mod notif;
+pub mod peerloop;
pub mod notifsync;
pub mod propagate;
pub mod psyrelay;


pub mod nethost;
pub mod notif;
pub mod peerloop;
pub mod notifsync;

pub mod nethost;
pub mod notif;
pub mod notifsync;
pub mod peerloop;


pub mod nethost;
pub mod notif;
pub mod notifsync;
pub mod peerloop;

//! `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 dispatch;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod nethost;
pub mod notif;
pub mod peerloop;
pub mod notifsync;
pub mod propagate;
pub mod psyrelay;
pub mod reconcile;
pub mod registryhost;
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 msg;
pub mod nethost;
pub mod notif;
-pub mod peerloop;
pub mod notifsync;
+pub mod peerloop;
pub mod propagate;
pub mod psyrelay;
pub mod reconcile;


Bash

cargo check -p spt-daemon --all-targets 2>&1 | grep -E "^error" -A 8 | head -50

Check compile after peerloop


error[E0061]: this function takes 5 arguments but 4 arguments were supplied
   --> crates\spt-daemon\tests\dispatch.rs:107:17
    |
107 |         let _ = run_dispatch_loop(&name, &paths, &flag, Duration::from_millis(25));
    |                 ^^^^^^^^^^^^^^^^^                ----- argument #3 of type `Arc<RegistryHost>` is missing
    |
note: function defined here
   --> C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\dispatch.rs:167:8
    |
--
error: could not compile `spt-daemon` (test "dispatch") due to 1 previous error
warning: build failed, waiting for other jobs to finish...
warning: `spt-daemon` (lib test) generated 1 warning (1 duplicate)


Edit

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

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


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

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

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

+    let registry = Arc::new(spt_daemon::registryhost::RegistryHost::new("b-dispatch"));

-        let _ = run_dispatch_loop(&name, &paths, &flag, Duration::from_millis(25));

+        let _ = run_dispatch_loop(&name, &paths, registry, &flag, Duration::from_millis(25));


Bash

cargo check -p spt-daemon --all-targets 2>&1 | grep -E "^error|^warning" -A 6 | head -30

Re-check compile


warning: unused variable: `vis`
   --> crates\spt-daemon\src\peerloop.rs:197:13
    |
197 |         let vis = VisibilityStore::load_from(&paths.vis);
    |             ^^^ help: if this is intentional, prefix it with an underscore: `_vis`
    |
    = note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default
--
warning: `spt-daemon` (lib) generated 1 warning (run `cargo fix --lib -p spt-daemon` to apply 1 suggestion)
warning: `spt-daemon` (lib test) generated 1 warning (1 duplicate)
    Checking spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.70s


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.


Edit

        // Re-load config-ish stores per round (the access-gate freshness
        // stance — edits take effect without a restart).
        let subnets = SubnetStore::load_from(&paths.subnets);
        let trust = TrustStore::load_from(&paths.trust);
        let vis = VisibilityStore::load_from(&paths.vis);
        let adverts = if due_reg { registry.advertise_local(&paths.owlery) } else { Vec::new() };

        // Re-load config-ish stores per round (the access-gate freshness
        // stance — edits take effect without a restart).
        let subnets = SubnetStore::load_from(&paths.subnets);
        let trust = TrustStore::load_from(&paths.trust);
        let policy = SyncPolicy {
            subnets: subnets
                .subnets
                .iter()
                .map(|s| (s.name.clone(), s.hide_new_endpoints))
                .collect(),
            trust: TrustStore::load_from(&paths.trust),
            vis: VisibilityStore::load_from(&paths.vis),
        };
        let adverts = if due_reg { registry.advertise_local(&paths.owlery) } else { Vec::new() };


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

        // Re-load config-ish stores per round (the access-gate freshness
        // stance — edits take effect without a restart).
        let subnets = SubnetStore::load_from(&paths.subnets);
        let trust = TrustStore::load_from(&paths.trust);
        let vis = VisibilityStore::load_from(&paths.vis);
        let adverts = if due_reg { registry.advertise_local(&paths.owlery) } else { Vec::new() };

        // Re-load config-ish stores per round (the access-gate freshness
        // stance — edits take effect without a restart).
        let subnets = SubnetStore::load_from(&paths.subnets);
        let trust = TrustStore::load_from(&paths.trust);
        let policy = SyncPolicy {
            subnets: subnets
                .subnets
                .iter()
                .map(|s| (s.name.clone(), s.hide_new_endpoints))
                .collect(),
            trust: TrustStore::load_from(&paths.trust),
            vis: VisibilityStore::load_from(&paths.vis),
        };
        let adverts = if due_reg { registry.advertise_local(&paths.owlery) } else { Vec::new() };

-        let vis = VisibilityStore::load_from(&paths.vis);

+                .map(|s| (s.name.clone(), s.hide_new_endpoints))

+            trust: TrustStore::load_from(&paths.trust),

+            vis: VisibilityStore::load_from(&paths.vis),


                    if due_sync {
                        let policy = SyncPolicy {
                            subnets: subnets
                                .subnets
                                .iter()
                                .map(|s| (s.name.clone(), s.hide_new_endpoints))
                                .collect(),
                            trust: TrustStore::load_from(&paths.trust),
                            vis: VisibilityStore::load_from(&paths.vis),
                        };
                        let cs = ContextStore::open_or_init_in(&paths.ctx_root)?;
                        let mut want = select_refs(&policy, &cs, &peer.pubkey_hex)?;

                    if due_sync {
                        let cs = ContextStore::open_or_init_in(&paths.ctx_root)?;
                        let mut want = select_refs(&policy, &cs, &peer.pubkey_hex)?;


                    if due_sync {
                        let policy = SyncPolicy {
                            subnets: subnets
                                .subnets
                                .iter()
                                .map(|s| (s.name.clone(), s.hide_new_endpoints))
                                .collect(),
                            trust: TrustStore::load_from(&paths.trust),
                            vis: VisibilityStore::load_from(&paths.vis),
                        };
                        let cs = ContextStore::open_or_init_in(&paths.ctx_root)?;
                        let mut want = select_refs(&policy, &cs, &peer.pubkey_hex)?;

                    if due_sync {
                        let cs = ContextStore::open_or_init_in(&paths.ctx_root)?;
                        let mut want = select_refs(&policy, &cs, &peer.pubkey_hex)?;

-                        let policy = SyncPolicy {

-                            subnets: subnets

-                                .subnets

-                                .iter()

-                                .map(|s| (s.name.clone(), s.hide_new_endpoints))

-                                .collect(),

-                            trust: TrustStore::load_from(&paths.trust),

-                            vis: VisibilityStore::load_from(&paths.vis),

// Registry-derived bootstrap refs: minds named in the


Bash

cargo check -p spt-daemon --all-targets 2>&1 | grep -E "^error|^warning" -A 6 | head -30; echo DONE

Re-check compile


DONE


Compile clean. Writing the pump E2E:


Write

//! Peer-pump E2E (D9-1-2): the subnet self-drives. One in-process "node A"
//! runs the production pump loop against "node B" running the production
//! dispatcher — and registry rows, notif rows, and a mind A never held all
//! converge with NO test code driving a single protocol exchange.
//!
//! Topology: B owns the process's canonical SPT_HOME (its dispatcher's gates
//! load from it — the production read path); A's pump-side stores live under
//! explicit test roots ([`PumpPaths`]). The resolver is injected (hermetic
//! loopback endpoints run no discovery); production resolves by id.

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

use spt_daemon::brain::Brain;
use spt_daemon::broker::Broker;
use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::peerloop::{run_peer_pump, PeerResolver, PumpCadence, PumpHooks, PumpPaths};
use spt_daemon::registryhost::{RegistryGatePolicy, RegistryHost};
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::registry::{Instance, Status};
use spt_net::net::replicate::RegistryUpdate;
use spt_proto::identity::Identity;
use spt_store::contextstore::ContextStore;
use spt_store::epoch::EpochSource;
use spt_store::info::{self, InfoJson};
use spt_store::notif::{NotifRow, NotifStore};
use spt_store::perch::{self, ParentHint};
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;
use tempfile::TempDir;

static HOME: OnceLock<TempDir> = OnceLock::new();
static LOCK: Mutex<()> = Mutex::new(());
fn init_home() {
    HOME.get_or_init(|| {
        let dir = TempDir::new().expect("temp dir");
        std::env::set_var("SPT_HOME", dir.path());
        dir
    });
}

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

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

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

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

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

fn stamped(body: &str) -> String {
    format!("<!-- spt:source=llm spt:routed_at_ms=1000 spt:node=t spt:vector=t:1 -->\n{body}")
}

/// Poll `probe` until it returns true, panicking with `what` on timeout.
fn converge(what: &str, mut probe: impl FnMut() -> bool) {
    for _ in 0..600 {
        if probe() {
            return;
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("never converged: {what}");
}

// [int->REQ-INST-5] [int->REQ-INST-7] [int->REQ-NOTIF-1] the full self-drive:
// A's pump pushes its registry advertisement and notif spool to B and pulls
// from B a mind A NEVER HELD (registry-derived bootstrap ref) — all three
// converge undriven, A's pump against B's dispatcher, both production loops.
#[test]
fn pump_and_dispatch_self_drive_the_subnet() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let subnet = "d912";
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a_broker = net_broker(&a_name, dir.path());
    let _b_broker = net_broker(&b_name, dir.path());

    let mut a_probe = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    let a_hex = a_probe.net_status().expect("status").node_id_hex.expect("net");
    let b_hex = b_probe.net_status().expect("status").node_id_hex.expect("net");
    let b_addr = b_probe.net_status().expect("status").addr;

    // ── B (canonical home): gates + the mind A will bootstrap-pull. ──────
    let mut subnets = SubnetStore::load();
    subnets.create_subnet(subnet).expect("create subnet");
    subnets.save().expect("save subnets");
    let mut trust = TrustStore::load();
    trust.record(subnet, &a_hex, None, None);
    trust.save().expect("save trust");
    let mut vis = VisibilityStore::load();
    vis.set_sync_subnets("ling-pumped", vec![subnet.to_string()]);
    vis.save().expect("save visibility");

    let cs_b = ContextStore::open_or_init().expect("B context store");
    let p = cs_b.live_context_path("ling-pumped").unwrap();
    std::fs::write(&p, stamped("a mind worth following")).unwrap();
    cs_b.commit_live("ling-pumped", "seed").unwrap();

    let b_registry = Arc::new(RegistryHost::new_at(
        &b_hex,
        EpochSource::load_from(&dir.path().join("b-epoch.json")),
        dir.path().join("b-registry"),
    ));
    let stop = Arc::new(AtomicBool::new(false));
    {
        let name = b_name.clone();
        let reg = Arc::clone(&b_registry);
        let flag = Arc::clone(&stop);
        let paths = DispatchPaths {
            owlery: perch::owlery_dir(),
            releases: perch::spt_home().join("releases"),
            scratch: dir.path().join("b-scratch"),
        };
        thread::spawn(move || {
            let _ = run_dispatch_loop(&name, &paths, reg, &flag, Duration::from_millis(25));
        });
    }

    // ── A (explicit roots): a perch to advertise, a notif row to push, an
    //    empty context store that will receive the bootstrap pull. ────────
    let a_root = dir.path().join("a");
    let a_paths = PumpPaths {
        owlery: a_root.join("owlery"),
        ctx_root: a_root.join("context"),
        notif_db: a_root.join("notifs.db"),
        scratch: a_root.join("scratch"),
        subnets: a_root.join("subnet.json"),
        trust: a_root.join("trust.json"),
        vis: a_root.join("visibility.json"),
        ops: a_root.join("pump-ops.json"),
    };
    let perch_path = perch::resolve_perch_path_in(&a_paths.owlery, "pump-ag", ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).unwrap();
    let rec = InfoJson::new("pump-ag", "now", std::process::id(), "s", "ready_agent");
    info::write_info(&perch_path, &rec).unwrap();

    let mut a_subnets = SubnetStore::default();
    a_subnets.create_subnet(subnet).expect("A subnet");
    a_subnets.save_to(&a_paths.subnets).expect("save A subnets");
    let mut a_trust = TrustStore::default();
    a_trust.record(subnet, &b_hex, None, None);
    a_trust.save_to(&a_paths.trust).expect("save A trust");
    let mut a_vis = VisibilityStore::default();
    a_vis.set_sync_subnets("ling-pumped", vec![subnet.to_string()]);
    a_vis.save_to(&a_paths.vis).expect("save A visibility");

    let a_store = NotifStore::open_at(&a_paths.notif_db).expect("A notif store");
    a_store
        .merge_row(&NotifRow {
            notif_id: format!("{a_hex}:1"),
            subnet: subnet.to_string(),
            kind: "agent".to_string(),
            from_id: format!("pump-ag@{a_hex}"),
            body: "pumped across".to_string(),
            created_ms: 1_000,
            dismissed: false,
            seen: Default::default(),
            last_surfaced_ms: 0,
        })
        .expect("seed A notif row");

    let a_registry = Arc::new(RegistryHost::new_at(
        &a_hex,
        EpochSource::load_from(&a_root.join("epoch.json")),
        a_root.join("registry"),
    ));
    // A learned of B's endpoint via an earlier inbound feed (seeded directly
    // here — B runs no pump in this test): the bootstrap-ref source.
    {
        let mut t = TrustStore::default();
        t.record(subnet, &b_hex, None, None);
        let pol = RegistryGatePolicy { subnets: vec![subnet.to_string()], trust: t };
        a_registry.apply_feed(
            &b_hex,
            &[RegistryUpdate {
                subnet: subnet.to_string(),
                endpoint_id: "ling-pumped".to_string(),
                instance: Instance { node: b_hex.clone(), status: Status::Active, epoch: 1 },
            }],
            &pol,
        );
    }

    // ── The pump: production loop, injected resolver, fast cadences. ─────
    let resolver: PeerResolver = {
        let b_hex = b_hex.clone();
        let b_addr = b_addr.clone();
        Box::new(move |hex| if hex == b_hex { Some(b_addr.clone()) } else { None })
    };
    {
        let name = a_name.clone();
        let reg = Arc::clone(&a_registry);
        let flag = Arc::clone(&stop);
        let paths = PumpPaths {
            owlery: a_paths.owlery.clone(),
            ctx_root: a_paths.ctx_root.clone(),
            notif_db: a_paths.notif_db.clone(),
            scratch: a_paths.scratch.clone(),
            subnets: a_paths.subnets.clone(),
            trust: a_paths.trust.clone(),
            vis: a_paths.vis.clone(),
            ops: a_paths.ops.clone(),
        };
        thread::spawn(move || {
            let cadence = PumpCadence {
                registry: Duration::from_millis(100),
                notif: Duration::from_millis(100),
                sync: Duration::from_millis(100),
            };
            let _ =
                run_peer_pump(&name, reg, &paths, resolver, cadence, PumpHooks::default(), &flag);
        });
    }

    // 1. Registry converged at B: A's perch row arrived through the
    //    dispatcher's gated registry arm.
    converge("B registry holds pump-ag@A", || {
        b_registry.rows(subnet, "pump-ag").iter().any(|i| i.node == a_hex)
    });

    // 2. Notif converged at B's canonical store (the semilattice join).
    let store_b = NotifStore::open().expect("B notif store");
    converge("B notif store holds A's row", || {
        store_b
            .list(subnet)
            .map(|rows| rows.iter().any(|r| r.body == "pumped across"))
            .unwrap_or(false)
    });

    // 3. The bootstrap pull: A now holds a mind it NEVER had locally — the
    //    want-ref came from A's registry, the serve gate ran at B.
    let cs_a = ContextStore::open_or_init_in(&a_paths.ctx_root).expect("A context store");
    converge("A pulled the registry-derived mind", || {
        cs_a.branch_store().tip("a-ling-pumped").ok().flatten().is_some()
    });

    stop.store(true, Ordering::Relaxed);
    let _ = op(); // keep the shared op counter linked (other tests mint from it)
}


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

//! Peer-pump E2E (D9-1-2): the subnet self-drives. One in-process "node A"
//! runs the production pump loop against "node B" running the production
//! dispatcher — and registry rows, notif rows, and a mind A never held all
//! converge with NO test code driving a single protocol exchange.
//!
//! Topology: B owns the process's canonical SPT_HOME (its dispatcher's gates
//! load from it — the production read path); A's pump-side stores live under
//! explicit test roots ([`PumpPaths`]). The resolver is injected (hermetic
//! loopback endpoints run no discovery); production resolves by id.

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

use spt_daemon::brain::Brain;
use spt_daemon::broker::Broker;
use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::peerloop::{run_peer_pump, PeerResolver, PumpCadence, PumpHooks, PumpPaths};
use spt_daemon::registryhost::{RegistryGatePolicy, RegistryHost};
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::registry::{Instance, Status};
use spt_net::net::replicate::RegistryUpdate;
use spt_proto::identity::Identity;
use spt_store::contextstore::ContextStore;
use spt_store::epoch::EpochSource;
use spt_store::info::{self, InfoJson};
use spt_store::notif::{NotifRow, NotifStore};
use spt_store::perch::{self, ParentHint};
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;
use tempfile::TempDir;

static HOME: OnceLock<TempDir> = OnceLock::new();
static LOCK: Mutex<()> = Mutex::new(());
fn init_home() {
    HOME.get_or_init(|| {
        let dir = TempDir::new().expect("temp dir");
        std::env::set_var("SPT_HOME", dir.path());
        dir
    });
}

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

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

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

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

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

fn stamped(body: &str) -> String {
    format!("<!-- spt:source=llm spt:routed_at_ms=1000 spt:node=t spt:vector=t:1 -->\n{body}")
}

/// Poll `probe` until it returns true, panicking with `what` on timeout.
fn converge(what: &str, mut probe: impl FnMut() -> bool) {
    for _ in 0..600 {
        if probe() {
            return;
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("never converged: {what}");
}

// [int->REQ-INST-5] [int->REQ-INST-7] [int->REQ-NOTIF-1] the full self-drive:
// A's pump pushes its registry advertisement and notif spool to B and pulls
// from B a mind A NEVER HELD (registry-derived bootstrap ref) — all three
// converge undriven, A's pump against B's dispatcher, both production loops.
#[test]
fn pump_and_dispatch_self_drive_the_subnet() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let subnet = "d912";
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a_broker = net_broker(&a_name, dir.path());
    let _b_broker = net_broker(&b_name, dir.path());

    let mut a_probe = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    let a_hex = a_probe.net_status().expect("status").node_id_hex.expect("net");
    let b_hex = b_probe.net_status().expect("status").node_id_hex.expect("net");
    let b_addr = b_probe.net_status().expect("status").addr;

    // ── B (canonical home): gates + the mind A will bootstrap-pull. ──────
    let mut subnets = SubnetStore::load();
    subnets.create_subnet(subnet).expect("create subnet");
    subnets.save().expect("save subnets");
    let mut trust = TrustStore::load();
    trust.record(subnet, &a_hex, None, None);
    trust.save().expect("save trust");
    let mut vis = VisibilityStore::load();
    vis.set_sync_subnets("ling-pumped", vec![subnet.to_string()]);
    vis.save().expect("save visibility");

    let cs_b = ContextStore::open_or_init().expect("B context store");
    let p = cs_b.live_context_path("ling-pumped").unwrap();
    std::fs::write(&p, stamped("a mind worth following")).unwrap();
    cs_b.commit_live("ling-pumped", "seed").unwrap();

    let b_registry = Arc::new(RegistryHost::new_at(
        &b_hex,
        EpochSource::load_from(&dir.path().join("b-epoch.json")),
        dir.path().join("b-registry"),
    ));
    let stop = Arc::new(AtomicBool::new(false));
    {
        let name = b_name.clone();
        let reg = Arc::clone(&b_registry);
        let flag = Arc::clone(&stop);
        let paths = DispatchPaths {
            owlery: perch::owlery_dir(),
            releases: perch::spt_home().join("releases"),
            scratch: dir.path().join("b-scratch"),
        };
        thread::spawn(move || {
            let _ = run_dispatch_loop(&name, &paths, reg, &flag, Duration::from_millis(25));
        });
    }

    // ── A (explicit roots): a perch to advertise, a notif row to push, an
    //    empty context store that will receive the bootstrap pull. ────────
    let a_root = dir.path().join("a");
    let a_paths = PumpPaths {
        owlery: a_root.join("owlery"),
        ctx_root: a_root.join("context"),
        notif_db: a_root.join("notifs.db"),
        scratch: a_root.join("scratch"),
        subnets: a_root.join("subnet.json"),
        trust: a_root.join("trust.json"),
        vis: a_root.join("visibility.json"),
        ops: a_root.join("pump-ops.json"),
    };
    let perch_path = perch::resolve_perch_path_in(&a_paths.owlery, "pump-ag", ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).unwrap();
    let rec = InfoJson::new("pump-ag", "now", std::process::id(), "s", "ready_agent");
    info::write_info(&perch_path, &rec).unwrap();

    let mut a_subnets = SubnetStore::default();
    a_subnets.create_subnet(subnet).expect("A subnet");
    a_subnets.save_to(&a_paths.subnets).expect("save A subnets");
    let mut a_trust = TrustStore::default();
    a_trust.record(subnet, &b_hex, None, None);
    a_trust.save_to(&a_paths.trust).expect("save A trust");
    let mut a_vis = VisibilityStore::default();
    a_vis.set_sync_subnets("ling-pumped", vec![subnet.to_string()]);
    a_vis.save_to(&a_paths.vis).expect("save A visibility");

    let a_store = NotifStore::open_at(&a_paths.notif_db).expect("A notif store");
    a_store
        .merge_row(&NotifRow {
            notif_id: format!("{a_hex}:1"),
            subnet: subnet.to_string(),
            kind: "agent".to_string(),
            from_id: format!("pump-ag@{a_hex}"),
            body: "pumped across".to_string(),
            created_ms: 1_000,
            dismissed: false,
            seen: Default::default(),
            last_surfaced_ms: 0,
        })
        .expect("seed A notif row");

    let a_registry = Arc::new(RegistryHost::new_at(
        &a_hex,
        EpochSource::load_from(&a_root.join("epoch.json")),
        a_root.join("registry"),
    ));
    // A learned of B's endpoint via an earlier inbound feed (seeded directly
    // here — B runs no pump in this test): the bootstrap-ref source.
    {
        let mut t = TrustStore::default();
        t.record(subnet, &b_hex, None, None);
        let pol = RegistryGatePolicy { subnets: vec![subnet.to_string()], trust: t };
        a_registry.apply_feed(
            &b_hex,
            &[RegistryUpdate {
                subnet: subnet.to_string(),
                endpoint_id: "ling-pumped".to_string(),
                instance: Instance { node: b_hex.clone(), status: Status::Active, epoch: 1 },
            }],
            &pol,
        );
    }

    // ── The pump: production loop, injected resolver, fast cadences. ─────
    let resolver: PeerResolver = {
        let b_hex = b_hex.clone();
        let b_addr = b_addr.clone();
        Box::new(move |hex| if hex == b_hex { Some(b_addr.clone()) } else { None })
    };
    {
        let name = a_name.clone();
        let reg = Arc::clone(&a_registry);
        let flag = Arc::clone(&stop);
        let paths = PumpPaths {
            owlery: a_paths.owlery.clone(),
            ctx_root: a_paths.ctx_root.clone(),
            notif_db: a_paths.notif_db.clone(),
            scratch: a_paths.scratch.clone(),
            subnets: a_paths.subnets.clone(),
            trust: a_paths.trust.clone(),
            vis: a_paths.vis.clone(),
            ops: a_paths.ops.clone(),
        };
        thread::spawn(move || {
            let cadence = PumpCadence {
                registry: Duration::from_millis(100),
                notif: Duration::from_millis(100),
                sync: Duration::from_millis(100),
            };
            let _ =
                run_peer_pump(&name, reg, &paths, resolver, cadence, PumpHooks::default(), &flag);
        });
    }

    // 1. Registry converged at B: A's perch row arrived through the
    //    dispatcher's gated registry arm.
    converge("B registry holds pump-ag@A", || {
        b_registry.rows(subnet, "pump-ag").iter().any(|i| i.node == a_hex)
    });

    // 2. Notif converged at B's canonical store (the semilattice join).
    let store_b = NotifStore::open().expect("B notif store");
    converge("B notif store holds A's row", || {
        store_b
            .list(subnet)
            .map(|rows| rows.iter().any(|r| r.body == "pumped across"))
            .unwrap_or(false)
    });

    // 3. The bootstrap pull: A now holds a mind it NEVER had locally — the
    //    want-ref came from A's registry, the serve gate ran at B.
    let cs_a = ContextStore::open_or_init_in(&a_paths.ctx_root).expect("A context store");
    converge("A pulled the registry-derived mind", || {
        cs_a.branch_store().tip("a-ling-pumped").ok().flatten().is_some()
    });

    stop.store(true, Ordering::Relaxed);
    let _ = op(); // keep the shared op counter linked (other tests mint from it)
}


Bash

cargo test -p spt-daemon --test peerloop 2>&1 | tail -20

Run peerloop E2E


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

running 1 test
test pump_and_dispatch_self_drive_the_subnet ... ok

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


Passes first run. Removing the unused op counter + clippy:


Edit

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

fn hermetic() -> NetConfig {


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

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

fn hermetic() -> NetConfig {

-static OP: AtomicU64 = AtomicU64::new(1);


    stop.store(true, Ordering::Relaxed);
    let _ = op(); // keep the shared op counter linked (other tests mint from it)
}

    stop.store(true, Ordering::Relaxed);
}


    stop.store(true, Ordering::Relaxed);
    let _ = op(); // keep the shared op counter linked (other tests mint from it)
}

    stop.store(true, Ordering::Relaxed);
}

-    let _ = op(); // keep the shared op counter linked (other tests mint from it)


use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};

use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};


//! explicit test roots ([`PumpPaths`]). The resolver is injected (hermetic

//! loopback endpoints run no discovery); production resolves by id.

-use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};

+use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};


D9-7: closeout sweep


Bash

cargo clippy -p spt-daemon -p spt-net --all-targets 2>&1 | grep -E "^error|^warning" -A 5 | head -25; echo CLIPPY_DONE; cargo test -p spt-daemon 2>&1 | grep -E "test result: FAILED|failed" | head -5; echo TESTS_OK; traceable-reqs check >$null 2>&1; echo TR:$?

Clippy + full daemon tests + traceability


warning: very complex type used. Consider factoring parts into `type` definitions
   --> crates\spt-daemon\src\peerloop.rs:146:18
    |
146 |     pub on_pull: Box<dyn FnMut(&str, &SyncPullReport) + Send>,
    |                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    |
--
warning: `spt-daemon` (lib) generated 1 warning
warning: `spt-daemon` (lib test) generated 1 warning (1 duplicate)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.11s
CLIPPY_DONE
test effect::tests::failed_effect_is_not_recorded_applied ... ok
test result: ok. 117 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.43s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.14s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
TESTS_OK
/usr/bin/bash: line 4: $null: ambiguous redirect
TR:1


Edit

/// Observation hooks. `on_pull` fires after every completed sync pull —
/// the conflict surface (ADR-0013) and the E2E's probe.
pub struct PumpHooks {
    pub on_pull: Box<dyn FnMut(&str, &SyncPullReport) + Send>,
}

/// A pull observer: `(peer_hex, report)` after every completed sync pull.
pub type OnPull = Box<dyn FnMut(&str, &SyncPullReport) + Send>;

/// Observation hooks. `on_pull` fires after every completed sync pull —
/// the conflict surface (ADR-0013) and the E2E's probe.
pub struct PumpHooks {
    pub on_pull: OnPull,
}


/// Observation hooks. `on_pull` fires after every completed sync pull —
/// the conflict surface (ADR-0013) and the E2E's probe.
pub struct PumpHooks {
    pub on_pull: Box<dyn FnMut(&str, &SyncPullReport) + Send>,
}

/// A pull observer: `(peer_hex, report)` after every completed sync pull.
pub type OnPull = Box<dyn FnMut(&str, &SyncPullReport) + Send>;

/// Observation hooks. `on_pull` fires after every completed sync pull —
/// the conflict surface (ADR-0013) and the E2E's probe.
pub struct PumpHooks {
    pub on_pull: OnPull,
}

+/// A pull observer: `(peer_hex, report)` after every completed sync pull.

+pub type OnPull = Box<dyn FnMut(&str, &SyncPullReport) + Send>;

-    pub on_pull: Box<dyn FnMut(&str, &SyncPullReport) + Send>,


Bash

cargo clippy -p spt-daemon -p spt-net --all-targets 2>&1 | grep -cE "^warning|^error"; cargo test -p spt-daemon --test peerloop --test dispatch 2>&1 | grep "test result"; traceable-reqs check > /dev/null 2>&1; echo TR:$?

Re-verify clippy, E2Es, traceability


0
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.40s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.87s
TR:0


All gates green. Quick spt-net check, then commit:


cargo test -p spt-net 2>&1 | grep -E "test result|FAILED" | head -8

spt-net tests


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


git add crates/spt-daemon/src/registryhost.rs crates/spt-daemon/src/peerloop.rs crates/spt-daemon/src/dispatch.rs crates/spt-daemon/src/daemon.rs crates/spt-daemon/src/config.rs crates/spt-daemon/src/lib.rs crates/spt-daemon/tests/peerloop.rs crates/spt-daemon/tests/dispatch.rs crates/spt-net/src/net/endpoint.rs && git commit -m "feat(daemon,net): M4-D9-1-2 registry host + outbound peer pump (self-driving subnet)

RegistryHost: the daemon's live per-subnet registry (REQ-INST-7 production
wiring) - inbound feeds merge under the fail-closed gate (member subnet AND
origin trusted in that subnet, KH 7.5) with the epoch lease deciding (KH
4.8); advertise_local scans the owlery and re-advertises every visible self
perch with a fresh EpochSource epoch (own rows only - no transitive gossip,
a relayed row's origin can't be gated honestly). Atomic per-subnet JSON
snapshots under identity/registry/ for out-of-process readers (4.3
stale-tolerant). Dispatcher registry arm goes live.

peerloop: the production trigger loops every D5-D8 slice deferred. Three
cadenced pumps per trusted peer per subnet (daemon.json knobs + peer_pump
kill switch): registry push, notif feed push, and anti-entropy sync pull
whose want-refs union select_refs with REGISTRY-DERIVED a-<id> bootstrap
refs - how a fresh instance pulls a mind it never held. Pump writes are
fire-and-forget (gotcha #1); sync open_ops mint from a dedicated durable
EpochSource file. Conflicts surface loud via PumpHooks::on_pull (artifacts
persist per hazard 6.6; the reconcile turn stays at activation - no
per-agent runtime in-daemon yet, the 7.4 fan-out seam).

spt-net grows addr_for_node_hex: an id-only dialable EndpointAddr so the
production resolver dials trusted peers knowing only their stable name
(mDNS LAN / n0 DNS WAN); hermetic tests inject concrete addresses.

E2E (tests/peerloop.rs): A's pump against B's dispatcher - registry row,
notif row, and a bootstrap-pulled mind all converge with no test code
driving a single exchange.

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

Commit + push D9-1-2


warning: in the working copy of 'crates/spt-daemon/src/config.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/daemon.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/dispatch.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/dispatch.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/endpoint.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/peerloop.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/registryhost.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/peerloop.rs', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 822d3ce] feat(daemon,net): M4-D9-1-2 registry host + outbound peer pump (self-driving subnet)
 9 files changed, 1147 insertions(+), 12 deletions(-)
 create mode 100644 crates/spt-daemon/src/peerloop.rs
 create mode 100644 crates/spt-daemon/src/registryhost.rs
 create mode 100644 crates/spt-daemon/tests/peerloop.rs
To https://github.com/SaberMage/spt-core.git
   ea73c9a..822d3ce  dev-freeform -> dev-freeform
822d3ce feat(daemon,net): M4-D9-1-2 registry host + outbound peer pump (self-driving subnet)


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

Poll CI until FINAL for 822d3ce


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


grep -n "pub fn\|pub const\|pub struct\|pub enum" crates/spt-daemon/src/update.rs | head -30; echo ===; grep -rn "current_version\|running" crates/spt-daemon/src/update.rs | grep -v test | head -10

Update module surface + running version source


39:pub const BROKER_RESOURCE_ABI: u32 = 1;
46:pub struct BrokerAbi {
63:    pub fn current() -> Self {
76:pub struct ReleaseSpec {
87:pub enum UpdateClass {
112:pub fn classify(running: &BrokerAbi, release: &ReleaseSpec) -> UpdateClass {
127:pub struct UpdatePlan {
135:pub fn plan_update(running: &BrokerAbi, release: ReleaseSpec) -> UpdatePlan {
151:pub fn plan_verified(
164:pub enum ApplyError {
214:pub fn apply_brain_only<F>(
===
5://! invariant, decided on two axes against the *running* broker:
7://! - **brain-only** — the routine case. The running broker can already host the
12://! - **broker-compatible** — the release ships a newer broker that the running
41:/// What the currently-running broker provides — the fixed point an incoming
44:/// *running* broker may be older than the binary proposing the update.
47:    /// The IPC protocol version the running broker speaks.
49:    /// The oldest brain IPC version the running broker will handshake
51:    /// the running broker as-is.
59:    /// The ABI of *this* build's broker. In the live daemon the running broker's
61:    /// from this source reports (and the classifier's stand-in when the running


grep -rn "decide\|NeedsConsent\|AutoApproved" crates/spt-daemon/src/consent.rs | grep "pub fn\|pub enum" -A 6 | head -20; grep -n "pub fn decide" -A 25 crates/spt-daemon/src/consent.rs | head -35; echo ===; grep -rn "produce_consent_notif" -A 5 crates/spt-daemon/src/notif.rs | head -12; grep -rn "version" crates/spt-daemon/src/relcache.rs | head -5

Consent decide + produce_consent_notif surfaces


57:pub fn decide(full_auto: bool, target: Option<ConsentTarget>) -> ConsentDecision {
59:        ConsentDecision::AutoApproved
61:        ConsentDecision::NeedsConsent(target)
105:    fn decide_honors_full_auto_flag() {
108:        assert_eq!(decide(true, Some(target.clone())), ConsentDecision::AutoApproved);
110:            decide(false, Some(target.clone())),
111:            ConsentDecision::NeedsConsent(Some(target))
57:pub fn decide(full_auto: bool, target: Option<ConsentTarget>) -> ConsentDecision {
58-    if full_auto {
59-        ConsentDecision::AutoApproved
60-    } else {
61-        ConsentDecision::NeedsConsent(target)
62-    }
63-}
64-
65-/// Resolve the user's **most-recently-active live session** in `owlery` — the
66-/// consent-prompt target (REQ-UPD-4). Enumerates the top-level (`Self_`) perches,
67-/// keeps the ones that are **currently live** (the single liveness resolver), and
68-/// returns the one with the greatest `last_active_ms`. A perch never stamped
69-/// (`last_active_ms` absent) ranks as `0` (least-recent). `None` if no live
70-/// session exists.
71-///
72-/// This is the **v1 registry resolution** the consent flow rides — live perches +
73-/// their recency stamp, not the deferred PresenceChannel. The resolver core is
74-/// shared with the notif engine ([`crate::notif::most_recently_active_where`],
75-/// D8b — the subnet-scoped form); consent is the degenerate (unscoped) call.
76-/// Tolerant: an unreadable perch is skipped, never a panic.
77-// [impl->REQ-UPD-4]
78-pub fn most_recently_active(owlery: &Path) -> Option<ConsentTarget> {
79-    crate::notif::most_recently_active_where(owlery, |_| true)
80-}
81-
82-#[cfg(test)]
===
241:pub fn produce_consent_notif(
242-    decision: &crate::consent::ConsentDecision,
243-    version: &str,
244-    store: &NotifStore,
245-    node_hex: &str,
246-    epochs: &mut EpochSource,
--
555:            let auto = produce_consent_notif(
556-                &crate::consent::ConsentDecision::AutoApproved,
557-                "1.2.3", &s, "cafe", &mut e, "home", &policy, &owlery, 1_000,
558-            )
559-            .unwrap();
101:    fn signed(version: u64) -> SignedRelease {
103:            metadata_json: format!("{{\"version\":{version}}}"),


grep -rn "apply_brain_only\|plan_verified" crates/ --include=*.rs | grep -v "fn apply_brain_only\|fn plan_verified\|//" | grep -v tests | head; echo ===; grep -rn "produce_consent_notif" -B2 -A14 crates/spt-daemon/src/notif.rs | sed -n '1,30p'

Apply callers + produce_consent_notif signature


crates/spt-daemon/src/lib.rs:187:    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
crates/spt-daemon/src/propagate.rs:41:use crate::update::{plan_verified, BrokerAbi, UpdatePlan};
crates/spt-daemon/src/propagate.rs:295:                            return match plan_verified(running, &offer, &artifact, policy) {
crates/spt-daemon/src/update.rs:182:                write!(f, "apply_brain_only refused a {c:?} plan (would risk an endpoint)")
crates/spt-daemon/src/update.rs:334:        let plan = plan_verified(&BrokerAbi::current(), &signed, artifact, &gate_policy(&sk))
crates/spt-daemon/src/update.rs:349:        let got = plan_verified(&BrokerAbi::current(), &signed, artifact, &gate_policy(&sk));
crates/spt-daemon/src/update.rs:354:        let wrong = plan_verified(&BrokerAbi::current(), &signed_ok, b"different bytes", &gate_policy(&sk));
===
239-// [impl->REQ-NOTIF-1]
240-#[allow(clippy::too_many_arguments)]
241:pub fn produce_consent_notif(
242-    decision: &crate::consent::ConsentDecision,
243-    version: &str,
244-    store: &NotifStore,
245-    node_hex: &str,
246-    epochs: &mut EpochSource,
247-    subnet: &str,
248-    policy: &NotifSurfacePolicy,
249-    owlery: &Path,
250-    now_ms: u64,
251-) -> Result<Option<(NotifRow, FirstFireOutcome)>, String> {
252-    match decision {
253-        crate::consent::ConsentDecision::AutoApproved => Ok(None),
254-        crate::consent::ConsentDecision::NeedsConsent(_) => {
255-            // Real newline — the 4.1 codec turns it into `<br>` at compose.
--
553-
554-            // Full-auto: no prompt, no notif.
555:            let auto = produce_consent_notif(
556-                &crate::consent::ConsentDecision::AutoApproved,
557-                "1.2.3", &s, "cafe", &mut e, "home", &policy, &owlery, 1_000,
558-            )
559-            .unwrap();
560-            assert!(auto.is_none());
561-            assert!(s.list("home").unwrap().is_empty(), "seamless path writes nothing");
562-
563-            // Gated (target advisory — the engine re-resolves): one consent
564-            // notif, fired at the live endpoint.


grep -n "pub struct NotifSurfacePolicy" -B4 -A 18 crates/spt-daemon/src/notif.rs | head -30

NotifSurfacePolicy shape


59-/// `hide_new_endpoints`) and its visibility store. Loaded from the canonical
60-/// stores in production (re-read per surface — the access-gate stance);
61-/// tests construct it directly.
62-// [impl->REQ-NOTIF-1]
63:pub struct NotifSurfacePolicy {
64-    /// `(subnet name, hide_new_endpoints)` for every subnet this node is in.
65-    pub subnets: Vec<(String, bool)>,
66-    pub vis: VisibilityStore,
67-}
68-
69-impl NotifSurfacePolicy {
70-    /// Load from the node's canonical stores (`subnet.json`, `visibility.json`).
71-    pub fn load() -> NotifSurfacePolicy {
72-        let subnets = spt_store::subnet::SubnetStore::load()
73-            .subnets
74-            .iter()
75-            .map(|s| (s.name.clone(), s.hide_new_endpoints))
76-            .collect();
77-        NotifSurfacePolicy { subnets, vis: VisibilityStore::load() }
78-    }
79-
80-    /// Whether `endpoint` is a legitimate surface for notifs in `subnet`:
81-    /// this node is a member AND the endpoint is not hidden there