Write

//! Update peer-propagation over broker streams (M4-D7, REQ-UPD-1) — the
//! serve/request drivers for the [`UpdRecord`] protocol, plugged into the M3c
//! update-engine front door (ADR-0004 §D).
//!
//! One pull rides one bidi QUIC stream: the requester's daemon opens it and
//! sends [`UpdRecord::Query`] with its running version + pinned channel; the
//! responder's [`serve_update`] answers from its **staged, already-verified**
//! release ([`crate::relcache::ReleaseCache`]) — an offer carrying just the
//! signed metadata, or up-to-date when it has nothing newer. The requester
//! gates the offer's metadata **before fetching any bytes** (a rollback /
//! expired / off-channel / bad-signature offer is rejected with zero chunk
//! records on the wire), pulls the artifact, then runs the full per-node gate
//! ([`crate::update::plan_verified`]) on the real bytes. Only a release that
//! produced an [`UpdatePlan`] is staged — staging is what makes this node a
//! server for its peers, so propagation chains hop by hop with the gate at
//! every node (one compromised node must not poison the subnet, REQ-UPD-2).
//!
//! ## Serve gate + wire discipline
//!
//! A query is answered only when the **handshake-proven** origin (the QUIC
//! identity from the broker's stream table, never payload bytes —
//! KNOWN-HAZARDS 7.5) is a trusted peer in ≥1 subnet; an untrusted origin is
//! refused by not offering (the up-to-date shape — fail-closed, learns
//! nothing). Sends are fire-and-forget (`op_id: None` — a journaled send
//! starves a pump, the D5b lesson), chunks dedup by absolute offset, and a
//! torn pull needs no resume protocol: staging is atomic and the query is
//! idempotent, so the recovery is simply "query again" (FAULT-MATRIX row).
// [impl->REQ-UPD-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]

use std::io::{self, Seek, SeekFrom, Write};
use std::path::Path;

use spt_net::net::update::{UpdDecoder, UpdRecord};
use spt_store::trust::TrustStore;

use crate::brain::{Brain, BrokerEvent};
use crate::msg::{decode_bytes, encode_bytes};
use crate::relcache::ReleaseCache;
use crate::release::{verify_metadata, RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy};
use crate::update::{plan_verified, BrokerAbi, UpdatePlan};

/// Raw bytes per [`UpdRecord::Chunk`] (base64 inside the JSON line — the
/// shared NDJSON posture, no binary fork of the codec).
const CHUNK_BYTES: usize = 64 * 1024;

/// Why a responder-side serve loop returned.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateServeOutcome {
    /// The artifact was served (`bytes` on the wire).
    Served { bytes: u64 },
    /// Nothing newer than the requester's version on its channel (or nothing
    /// staged at all) — the honest up-to-date answer.
    UpToDate,
    /// The origin is not a trusted peer in any subnet — refused by not
    /// offering (the same wire shape as up-to-date; the outcome is distinct
    /// so callers/tests observe the gate fired).
    Refused,
    /// The serve failed; an `Err` record was sent to the peer.
    Failed(String),
    /// The requester closed its send side before fetching (e.g. its own gate
    /// rejected the offer, or it detached mid-protocol).
    Detached,
}

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

    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        UpdRecord::Query { upd_id, current_version, channel } => {
                            // The gate: only a peer trusted in ≥1 subnet is
                            // answered; everyone else gets the up-to-date
                            // shape (fail-closed, indistinguishable from
                            // having nothing).
                            let trusted =
                                trust.peers.iter().any(|p| p.pubkey_hex == origin_node);
                            let staged = if trusted { cache.staged() } else { None };
                            // Offer only a release that is actually newer on
                            // the requester's channel (an honest responder
                            // never proposes a rollback — the requester's own
                            // gate re-checks regardless).
                            let newer = staged.as_ref().and_then(|s| {
                                let meta: ReleaseMetadata =
                                    serde_json::from_str(&s.metadata_json).ok()?;
                                (meta.version > current_version && meta.channel == channel)
                                    .then_some(s.clone())
                            });
                            match newer {
                                Some(signed) => {
                                    let line = UpdRecord::Offer {
                                        upd_id: upd_id.clone(),
                                        metadata_json: signed.metadata_json,
                                        signature_hex: signed.signature_hex,
                                    }
                                    .encode_line();
                                    brain.net_stream_send(stream_id, &line, None, false)?;
                                    offered = Some(upd_id);
                                }
                                None => {
                                    let line = UpdRecord::UpToDate { upd_id }.encode_line();
                                    brain.net_stream_send(stream_id, &line, None, true)?;
                                    return Ok(if trusted {
                                        UpdateServeOutcome::UpToDate
                                    } else {
                                        UpdateServeOutcome::Refused
                                    });
                                }
                            }
                        }
                        UpdRecord::Fetch { upd_id } if offered.as_deref() == Some(&upd_id) => {
                            // Pump the staged artifact as positional chunks
                            // (fire-and-forget; the offset is the dedup).
                            let artifact = match cache.load_artifact() {
                                Ok(a) => a,
                                Err(e) => {
                                    let line = UpdRecord::Err {
                                        upd_id,
                                        message: format!("artifact unreadable: {e}"),
                                    }
                                    .encode_line();
                                    brain.net_stream_send(stream_id, &line, None, true)?;
                                    return Ok(UpdateServeOutcome::Failed(e.to_string()));
                                }
                            };
                            let mut offset = 0u64;
                            for chunk in artifact.chunks(CHUNK_BYTES) {
                                let line = UpdRecord::Chunk {
                                    upd_id: upd_id.clone(),
                                    offset,
                                    data_b64: encode_bytes(chunk),
                                }
                                .encode_line();
                                brain.net_stream_send(stream_id, &line, None, false)?;
                                offset += chunk.len() as u64;
                            }
                            let done =
                                UpdRecord::Done { upd_id, total: artifact.len() as u64 }
                                    .encode_line();
                            brain.net_stream_send(stream_id, &done, None, true)?;
                            return Ok(UpdateServeOutcome::Served {
                                bytes: artifact.len() as u64,
                            });
                        }
                        _ => {} // protocol noise — keep waiting
                    }
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                brain.net_stream_send(stream_id, &[], None, true)?;
                return Ok(UpdateServeOutcome::Detached);
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}

/// What one update pull concluded.
#[derive(Debug)]
pub enum UpdatePullOutcome {
    /// The peer's release passed the full per-node gate; the artifact is
    /// staged ([`ReleaseCache`]) and the classified plan is ready for the
    /// consent/apply path (C2/C0 — not triggered here).
    Updated(UpdatePlan),
    /// The peer had nothing newer on this node's channel.
    UpToDate,
    /// The peer's release failed this node's gate — loud + typed, never a
    /// silent accept (ADR-0004 §D). Nothing was staged. Pre-fetch rejects
    /// (rollback / expired / off-channel / bad signature on the offered
    /// metadata) carry zero artifact bytes; a post-fetch reject means the
    /// pulled bytes did not match the signed digest.
    Rejected(RejectReason),
}

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

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

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

    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        UpdRecord::UpToDate { upd_id: xid } if xid == upd_id => {
                            brain.net_stream_send(stream_id, &[], None, true)?;
                            return Ok(UpdatePullOutcome::UpToDate);
                        }
                        UpdRecord::Offer { upd_id: xid, metadata_json, signature_hex }
                            if xid == upd_id =>
                        {
                            let offer = SignedRelease { metadata_json, signature_hex };
                            // The pre-fetch gate: reject a bad offer before a
                            // single artifact byte moves (REQ-UPD-2,
                            // REQ-HAZARD-UPDATE-ROLLBACK — a rollback offer
                            // costs the subnet nothing).
                            // [impl->REQ-HAZARD-UPDATE-ROLLBACK]
                            if let Err(reason) = verify_metadata(&offer, policy) {
                                brain.net_stream_send(stream_id, &[], None, true)?;
                                return Ok(UpdatePullOutcome::Rejected(reason));
                            }
                            signed = Some(offer);
                            let fetch =
                                UpdRecord::Fetch { upd_id: upd_id.clone() }.encode_line();
                            brain.net_stream_send(stream_id, &fetch, None, true)?;
                        }
                        UpdRecord::Chunk { upd_id: xid, offset, data_b64 } if xid == upd_id => {
                            let chunk = decode_bytes(&data_b64)
                                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                            let f = match file.as_mut() {
                                Some(f) => f,
                                None => file.insert(
                                    std::fs::OpenOptions::new()
                                        .create(true)
                                        .truncate(true)
                                        .write(true)
                                        .open(&artifact_path)?,
                                ),
                            };
                            // Positional write — a replayed chunk rewrites the
                            // same bytes in place (idempotent by construction).
                            f.seek(SeekFrom::Start(offset))?;
                            f.write_all(&chunk)?;
                        }
                        UpdRecord::Done { upd_id: xid, .. } if xid == upd_id => {
                            drop(file.take()); // flush before reading back
                            let artifact = std::fs::read(&artifact_path).unwrap_or_default();
                            let _ = std::fs::remove_file(&artifact_path);
                            let offer = signed.take().ok_or_else(|| {
                                io::Error::other("peer sent Done before any Offer")
                            })?;
                            // The full per-node front door on the real bytes:
                            // no plan ⇒ nothing staged ⇒ this node never
                            // re-serves what it could not verify.
                            // [impl->REQ-UPD-2]
                            return match plan_verified(running, &offer, &artifact, policy) {
                                Ok(plan) => {
                                    cache.stage(&offer, &artifact)?;
                                    Ok(UpdatePullOutcome::Updated(plan))
                                }
                                Err(reason) => Ok(UpdatePullOutcome::Rejected(reason)),
                            };
                        }
                        UpdRecord::Err { upd_id: xid, message } if xid == upd_id => {
                            return Err(io::Error::other(format!("peer failed: {message}")));
                        }
                        _ => {} // protocol noise
                    }
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                return Err(io::Error::other("peer closed mid-pull (re-query to recover)"));
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}


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

//! Update peer-propagation over broker streams (M4-D7, REQ-UPD-1) — the
//! serve/request drivers for the [`UpdRecord`] protocol, plugged into the M3c
//! update-engine front door (ADR-0004 §D).
//!
//! One pull rides one bidi QUIC stream: the requester's daemon opens it and
//! sends [`UpdRecord::Query`] with its running version + pinned channel; the
//! responder's [`serve_update`] answers from its **staged, already-verified**
//! release ([`crate::relcache::ReleaseCache`]) — an offer carrying just the
//! signed metadata, or up-to-date when it has nothing newer. The requester
//! gates the offer's metadata **before fetching any bytes** (a rollback /
//! expired / off-channel / bad-signature offer is rejected with zero chunk
//! records on the wire), pulls the artifact, then runs the full per-node gate
//! ([`crate::update::plan_verified`]) on the real bytes. Only a release that
//! produced an [`UpdatePlan`] is staged — staging is what makes this node a
//! server for its peers, so propagation chains hop by hop with the gate at
//! every node (one compromised node must not poison the subnet, REQ-UPD-2).
//!
//! ## Serve gate + wire discipline
//!
//! A query is answered only when the **handshake-proven** origin (the QUIC
//! identity from the broker's stream table, never payload bytes —
//! KNOWN-HAZARDS 7.5) is a trusted peer in ≥1 subnet; an untrusted origin is
//! refused by not offering (the up-to-date shape — fail-closed, learns
//! nothing). Sends are fire-and-forget (`op_id: None` — a journaled send
//! starves a pump, the D5b lesson), chunks dedup by absolute offset, and a
//! torn pull needs no resume protocol: staging is atomic and the query is
//! idempotent, so the recovery is simply "query again" (FAULT-MATRIX row).
// [impl->REQ-UPD-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]

use std::io::{self, Seek, SeekFrom, Write};
use std::path::Path;

use spt_net::net::update::{UpdDecoder, UpdRecord};
use spt_store::trust::TrustStore;

use crate::brain::{Brain, BrokerEvent};
use crate::msg::{decode_bytes, encode_bytes};
use crate::relcache::ReleaseCache;
use crate::release::{verify_metadata, RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy};
use crate::update::{plan_verified, BrokerAbi, UpdatePlan};

/// Raw bytes per [`UpdRecord::Chunk`] (base64 inside the JSON line — the
/// shared NDJSON posture, no binary fork of the codec).
const CHUNK_BYTES: usize = 64 * 1024;

/// Why a responder-side serve loop returned.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateServeOutcome {
    /// The artifact was served (`bytes` on the wire).
    Served { bytes: u64 },
    /// Nothing newer than the requester's version on its channel (or nothing
    /// staged at all) — the honest up-to-date answer.
    UpToDate,
    /// The origin is not a trusted peer in any subnet — refused by not
    /// offering (the same wire shape as up-to-date; the outcome is distinct
    /// so callers/tests observe the gate fired).
    Refused,
    /// The serve failed; an `Err` record was sent to the peer.
    Failed(String),
    /// The requester closed its send side before fetching (e.g. its own gate
    /// rejected the offer, or it detached mid-protocol).
    Detached,
}

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

    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        UpdRecord::Query { upd_id, current_version, channel } => {
                            // The gate: only a peer trusted in ≥1 subnet is
                            // answered; everyone else gets the up-to-date
                            // shape (fail-closed, indistinguishable from
                            // having nothing).
                            let trusted =
                                trust.peers.iter().any(|p| p.pubkey_hex == origin_node);
                            let staged = if trusted { cache.staged() } else { None };
                            // Offer only a release that is actually newer on
                            // the requester's channel (an honest responder
                            // never proposes a rollback — the requester's own
                            // gate re-checks regardless).
                            let newer = staged.as_ref().and_then(|s| {
                                let meta: ReleaseMetadata =
                                    serde_json::from_str(&s.metadata_json).ok()?;
                                (meta.version > current_version && meta.channel == channel)
                                    .then_some(s.clone())
                            });
                            match newer {
                                Some(signed) => {
                                    let line = UpdRecord::Offer {
                                        upd_id: upd_id.clone(),
                                        metadata_json: signed.metadata_json,
                                        signature_hex: signed.signature_hex,
                                    }
                                    .encode_line();
                                    brain.net_stream_send(stream_id, &line, None, false)?;
                                    offered = Some(upd_id);
                                }
                                None => {
                                    let line = UpdRecord::UpToDate { upd_id }.encode_line();
                                    brain.net_stream_send(stream_id, &line, None, true)?;
                                    return Ok(if trusted {
                                        UpdateServeOutcome::UpToDate
                                    } else {
                                        UpdateServeOutcome::Refused
                                    });
                                }
                            }
                        }
                        UpdRecord::Fetch { upd_id } if offered.as_deref() == Some(&upd_id) => {
                            // Pump the staged artifact as positional chunks
                            // (fire-and-forget; the offset is the dedup).
                            let artifact = match cache.load_artifact() {
                                Ok(a) => a,
                                Err(e) => {
                                    let line = UpdRecord::Err {
                                        upd_id,
                                        message: format!("artifact unreadable: {e}"),
                                    }
                                    .encode_line();
                                    brain.net_stream_send(stream_id, &line, None, true)?;
                                    return Ok(UpdateServeOutcome::Failed(e.to_string()));
                                }
                            };
                            let mut offset = 0u64;
                            for chunk in artifact.chunks(CHUNK_BYTES) {
                                let line = UpdRecord::Chunk {
                                    upd_id: upd_id.clone(),
                                    offset,
                                    data_b64: encode_bytes(chunk),
                                }
                                .encode_line();
                                brain.net_stream_send(stream_id, &line, None, false)?;
                                offset += chunk.len() as u64;
                            }
                            let done =
                                UpdRecord::Done { upd_id, total: artifact.len() as u64 }
                                    .encode_line();
                            brain.net_stream_send(stream_id, &done, None, true)?;
                            return Ok(UpdateServeOutcome::Served {
                                bytes: artifact.len() as u64,
                            });
                        }
                        _ => {} // protocol noise — keep waiting
                    }
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                brain.net_stream_send(stream_id, &[], None, true)?;
                return Ok(UpdateServeOutcome::Detached);
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}

/// What one update pull concluded.
#[derive(Debug)]
pub enum UpdatePullOutcome {
    /// The peer's release passed the full per-node gate; the artifact is
    /// staged ([`ReleaseCache`]) and the classified plan is ready for the
    /// consent/apply path (C2/C0 — not triggered here).
    Updated(UpdatePlan),
    /// The peer had nothing newer on this node's channel.
    UpToDate,
    /// The peer's release failed this node's gate — loud + typed, never a
    /// silent accept (ADR-0004 §D). Nothing was staged. Pre-fetch rejects
    /// (rollback / expired / off-channel / bad signature on the offered
    /// metadata) carry zero artifact bytes; a post-fetch reject means the
    /// pulled bytes did not match the signed digest.
    Rejected(RejectReason),
}

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

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

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

    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        UpdRecord::UpToDate { upd_id: xid } if xid == upd_id => {
                            brain.net_stream_send(stream_id, &[], None, true)?;
                            return Ok(UpdatePullOutcome::UpToDate);
                        }
                        UpdRecord::Offer { upd_id: xid, metadata_json, signature_hex }
                            if xid == upd_id =>
                        {
                            let offer = SignedRelease { metadata_json, signature_hex };
                            // The pre-fetch gate: reject a bad offer before a
                            // single artifact byte moves (REQ-UPD-2,
                            // REQ-HAZARD-UPDATE-ROLLBACK — a rollback offer
                            // costs the subnet nothing).
                            // [impl->REQ-HAZARD-UPDATE-ROLLBACK]
                            if let Err(reason) = verify_metadata(&offer, policy) {
                                brain.net_stream_send(stream_id, &[], None, true)?;
                                return Ok(UpdatePullOutcome::Rejected(reason));
                            }
                            signed = Some(offer);
                            let fetch =
                                UpdRecord::Fetch { upd_id: upd_id.clone() }.encode_line();
                            brain.net_stream_send(stream_id, &fetch, None, true)?;
                        }
                        UpdRecord::Chunk { upd_id: xid, offset, data_b64 } if xid == upd_id => {
                            let chunk = decode_bytes(&data_b64)
                                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                            let f = match file.as_mut() {
                                Some(f) => f,
                                None => file.insert(
                                    std::fs::OpenOptions::new()
                                        .create(true)
                                        .truncate(true)
                                        .write(true)
                                        .open(&artifact_path)?,
                                ),
                            };
                            // Positional write — a replayed chunk rewrites the
                            // same bytes in place (idempotent by construction).
                            f.seek(SeekFrom::Start(offset))?;
                            f.write_all(&chunk)?;
                        }
                        UpdRecord::Done { upd_id: xid, .. } if xid == upd_id => {
                            drop(file.take()); // flush before reading back
                            let artifact = std::fs::read(&artifact_path).unwrap_or_default();
                            let _ = std::fs::remove_file(&artifact_path);
                            let offer = signed.take().ok_or_else(|| {
                                io::Error::other("peer sent Done before any Offer")
                            })?;
                            // The full per-node front door on the real bytes:
                            // no plan ⇒ nothing staged ⇒ this node never
                            // re-serves what it could not verify.
                            // [impl->REQ-UPD-2]
                            return match plan_verified(running, &offer, &artifact, policy) {
                                Ok(plan) => {
                                    cache.stage(&offer, &artifact)?;
                                    Ok(UpdatePullOutcome::Updated(plan))
                                }
                                Err(reason) => Ok(UpdatePullOutcome::Rejected(reason)),
                            };
                        }
                        UpdRecord::Err { upd_id: xid, message } if xid == upd_id => {
                            return Err(io::Error::other(format!("peer failed: {message}")));
                        }
                        _ => {} // protocol noise
                    }
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                return Err(io::Error::other("peer closed mid-pull (re-query to recover)"));
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}


Edit

pub mod nethost;
pub mod propagate;
pub mod reconcile;


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 propagate;
pub mod reconcile;

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

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

#[cfg(test)]
mod test_home;

pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};
pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
pub use adapter_update::{
    conduct_ripple, plan_adapter_update, AdapterRipple, AdapterUpdateOutcome, FilePullPayload,
    SkipReason,
};
pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::{DaemonConfig, RelayChoice};
pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{
    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
    update_to_json, DigestHub,
};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
pub use 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 lifecycle;
pub mod msg;
pub mod nethost;
+pub mod propagate;
pub mod reconcile;
pub mod relay;
pub mod relcache;


pub use relay::Relay;
pub use relcache::ReleaseCache;

pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};
pub use relay::Relay;
pub use relcache::ReleaseCache;


pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};
pub use relay::Relay;
pub use relcache::ReleaseCache;

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

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

#[cfg(test)]
mod test_home;

pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};
pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
pub use adapter_update::{
    conduct_ripple, plan_adapter_update, AdapterRipple, AdapterUpdateOutcome, FilePullPayload,
    SkipReason,
};
pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::{DaemonConfig, RelayChoice};
pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{
    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
    update_to_json, DigestHub,
};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
pub use 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 use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};


PowerShell

cargo clippy -p spt-daemon --lib -- -D warnings 2>&1 | Select-Object -Last 15

Clippy check propagate.rs


    Checking n0-future v0.3.2
    Checking backon v1.6.0
    Checking n0-watcher v0.6.1
    Checking iroh-dns v0.98.0
    Checking netwatch v0.16.0
    Checking iroh-relay v0.98.0
    Checking portmapper v0.16.0
    Checking iroh v0.98.2
    Checking spt-net v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
    Checking spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 9.20s


Clean. Now int tests — mirror `tests/sync.rs` harness.


Write

//! M4-D7-2 loopback propagation E2E (REQ-UPD-1, REQ-UPD-2,
//! REQ-HAZARD-UPDATE-ROLLBACK, REQ-HAZARD-WAN-ORIGIN-AUTH): a signed update
//! self-heals across broker-streamed pulls with the full M3c gate at every
//! receiving node, and every poison path (tampered relay, rollback offer,
//! untrusted origin) is rejected loud.
//!
//! Same harness shape as `tests/sync.rs`: two/three in-process brokers with
//! hermetic loopback net hosts; the responder snapshots its stream table
//! BEFORE the requester opens (the table holds locally-opened streams too —
//! grabbing your own stale row hangs the join forever).

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

use ed25519_dalek::{Signer, SigningKey};
use spt_daemon::nethost::{BindScope, LocalDiscovery, RelayPolicy};
use spt_daemon::propagate::{
    request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome,
};
use spt_daemon::relcache::ReleaseCache;
use spt_daemon::release::{RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy};
use spt_daemon::update::{BrokerAbi, UpdateClass, BROKER_RESOURCE_ABI};
use spt_daemon::{Brain, Broker, NetConfig, NetHost};
use spt_net::net::update::UpdRecord;
use spt_proto::identity::Identity;
use spt_store::trust::TrustStore;
use std::collections::{BTreeMap, BTreeSet};

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

/// One op-id namespace across every brain in the binary — journaled ops must
/// never collide.
static OP: AtomicU64 = AtomicU64::new(1);
fn op() -> u64 {
    OP.fetch_add(1, Ordering::Relaxed)
}

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

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

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

fn wait_for_stream_except(brain: &mut Brain, skip: &[u64]) -> (u64, String) {
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.iter().find(|s| !skip.contains(&s.stream_id)) {
            return (s.stream_id, s.remote_id_hex.clone());
        }
        thread::sleep(Duration::from_millis(5));
    }
    panic!("update stream never appeared at the responder's broker");
}

const KEY_ID: &str = "rel-2026";
const CHANNEL: &str = "stable";

/// The release signing key (deterministic — test-stable).
fn release_key() -> SigningKey {
    SigningKey::from_bytes(&[7u8; 32])
}

/// Build + sign release metadata for `artifact` at `version`.
fn signed_release(version: u64, artifact: &[u8]) -> SignedRelease {
    let meta = ReleaseMetadata {
        version,
        channel: CHANNEL.to_string(),
        expires_at_ms: 10_000,
        key_id: KEY_ID.to_string(),
        artifact_sha256: spt_daemon::sha256_hex(artifact),
        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
        broker_resource_abi: BROKER_RESOURCE_ABI,
    };
    let metadata_json = serde_json::to_string(&meta).unwrap();
    let sig = release_key().sign(metadata_json.as_bytes());
    let signature_hex: String = sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
    SignedRelease { metadata_json, signature_hex }
}

/// A node's verify policy: trusts the release key, pinned to stable, running
/// `current_version`, clock before expiry.
fn policy(current_version: u64) -> VerifyPolicy {
    let mut trusted = BTreeMap::new();
    trusted.insert(KEY_ID.to_string(), release_key().verifying_key());
    VerifyPolicy {
        trusted_keys: trusted,
        revoked_keys: BTreeSet::new(),
        pinned_channel: CHANNEL.to_string(),
        current_version,
        now_ms: 1_000,
    }
}

/// A trust store that pins `peers` (in subnet "home") — the serve gate's input.
fn trust_for(peers: &[&str]) -> TrustStore {
    let mut t = TrustStore::default();
    for p in peers {
        t.record("home", p, None, None);
    }
    t
}

/// One node of the propagation chain: a live broker + its release cache.
struct Node {
    name: String,
    cache: ReleaseCache,
}

impl Node {
    fn new(dir: &std::path::Path, tag: &str) -> Node {
        let name = unique_name();
        let _broker = net_broker(&name, &dir.join(tag));
        Node { name, cache: ReleaseCache::open(&dir.join(tag).join("releases")) }
    }

    fn node_id(&self) -> String {
        connect_retry(&self.name).net_status().unwrap().node_id_hex.unwrap()
    }
}

/// Run one pull: `requester` ← `responder`, with the responder serving under
/// `trust`. Returns `(pull outcome, serve outcome)`.
fn pull(
    requester: &Node,
    responder: &Node,
    trust: &TrustStore,
    pol: &VerifyPolicy,
    scratch: &std::path::Path,
) -> (std::io::Result<UpdatePullOutcome>, UpdateServeOutcome) {
    let mut serve_brain = connect_retry(&responder.name);
    let addr = serve_brain.net_status().expect("status").addr;
    // Snapshot BEFORE the requester opens — the table holds locally-opened
    // streams too; grabbing a stale row would hang the join forever.
    let skip: Vec<u64> = serve_brain
        .net_streams()
        .expect("stream snapshot")
        .streams
        .iter()
        .map(|s| s.stream_id)
        .collect();

    let mut req_brain = connect_retry(&requester.name);
    let conn = req_brain.net_dial(addr, Some(op())).expect("dial");
    let open_op = op();
    let running = BrokerAbi::current();
    let req_cache = requester.cache.clone();
    let req_pol = pol.clone();
    let req_scratch = scratch.join("req");
    let puller = thread::spawn(move || {
        request_update(
            &mut req_brain,
            conn.conn_id,
            open_op,
            &running,
            &req_pol,
            &req_cache,
            &req_scratch,
        )
    });

    let (stream, origin) = wait_for_stream_except(&mut serve_brain, &skip);
    let outcome = serve_update(&mut serve_brain, stream, &origin, 0, &responder.cache, trust)
        .expect("serve");
    (puller.join().expect("pull thread"), outcome)
}

// [unit->REQ-UPD-1] the self-heal chain: A holds the verified v6 release; B
// (running v5) pulls it through the gate and stages it; C then pulls FROM B —
// verified-pull ⇒ staged ⇒ servable, the gate running at every hop. Both
// pulls classify brain-only and land byte-identical artifacts.
#[test]
fn chain_self_heal_propagates_with_gate_at_every_hop() {
    let dir = tempfile::tempdir().expect("tempdir");
    let a = Node::new(dir.path(), "a");
    let b = Node::new(dir.path(), "b");
    let c = Node::new(dir.path(), "c");
    let scratch = dir.path().join("scratch");

    let artifact = vec![0xA5u8; 150 * 1024]; // >2 chunks
    a.cache.stage(&signed_release(6, &artifact), &artifact).unwrap();

    // B ← A.
    let trust_a = trust_for(&[&b.node_id()]);
    let (got, served) = pull(&b, &a, &trust_a, &policy(5), &scratch);
    match got.expect("pull b<-a") {
        UpdatePullOutcome::Updated(plan) => assert_eq!(plan.class, UpdateClass::BrainOnly),
        other => panic!("expected Updated at B, got {other:?}"),
    }
    assert_eq!(served, UpdateServeOutcome::Served { bytes: artifact.len() as u64 });
    assert_eq!(b.cache.load_artifact().unwrap(), artifact, "staged byte-identical at B");

    // C ← B (B never talked to A's cache — it serves what it verified).
    let trust_b = trust_for(&[&c.node_id()]);
    let (got, served) = pull(&c, &b, &trust_b, &policy(5), &scratch);
    match got.expect("pull c<-b") {
        UpdatePullOutcome::Updated(plan) => assert_eq!(plan.class, UpdateClass::BrainOnly),
        other => panic!("expected Updated at C, got {other:?}"),
    }
    assert_eq!(served, UpdateServeOutcome::Served { bytes: artifact.len() as u64 });
    assert_eq!(c.cache.load_artifact().unwrap(), artifact, "staged byte-identical at C");

    // An up-to-date node pulls nothing (idempotent re-query).
    let (got, served) = pull(&c, &b, &trust_b, &policy(6), &scratch);
    assert!(matches!(got.expect("re-pull"), UpdatePullOutcome::UpToDate));
    assert_eq!(served, UpdateServeOutcome::UpToDate);
}

// [unit->REQ-UPD-2] a compromised relay node cannot poison the chain: B's
// staged artifact is tampered at rest, so C's per-node gate ends the pull
// Rejected(ArtifactMismatch) and stages nothing — the gate is per-node, never
// inherited from the sender's claim to have verified.
#[test]
fn tampered_relay_artifact_is_rejected_and_never_staged() {
    let dir = tempfile::tempdir().expect("tempdir");
    let b = Node::new(dir.path(), "b");
    let c = Node::new(dir.path(), "c");
    let scratch = dir.path().join("scratch");

    let artifact = b"the genuine v6 binary".to_vec();
    b.cache.stage(&signed_release(6, &artifact), &artifact).unwrap();
    // Tamper the bytes at rest (post-verification compromise at B).
    std::fs::write(b.cache.artifact_path(), b"poisoned bytes").unwrap();

    let trust_b = trust_for(&[&c.node_id()]);
    let (got, served) = pull(&c, &b, &trust_b, &policy(5), &scratch);
    match got.expect("pull completes") {
        UpdatePullOutcome::Rejected(RejectReason::ArtifactMismatch { .. }) => {}
        other => panic!("expected ArtifactMismatch at C, got {other:?}"),
    }
    assert!(matches!(served, UpdateServeOutcome::Served { .. }));
    assert!(c.cache.staged().is_none(), "nothing staged at C — C never re-serves poison");
}

// [unit->REQ-HAZARD-UPDATE-ROLLBACK] a non-conforming peer offering a
// ROLLBACK (v4 to a v5 node) is rejected by the requester's pre-fetch gate
// with zero artifact bytes moved: the requester closes after the Offer, so
// the evil responder never even receives a Fetch.
#[test]
fn rollback_offer_is_rejected_before_any_fetch() {
    let dir = tempfile::tempdir().expect("tempdir");
    let evil = Node::new(dir.path(), "evil");
    let v = Node::new(dir.path(), "v");
    let scratch = dir.path().join("scratch");

    // The victim pulls; the "responder" is a hand-rolled non-conforming peer
    // (an honest serve_update never offers a rollback — the gate under test
    // is the REQUESTER's).
    let mut serve_brain = connect_retry(&evil.name);
    let addr = serve_brain.net_status().expect("status").addr;
    let skip: Vec<u64> = serve_brain
        .net_streams()
        .expect("snapshot")
        .streams
        .iter()
        .map(|s| s.stream_id)
        .collect();

    let mut req_brain = connect_retry(&v.name);
    let conn = req_brain.net_dial(addr, Some(op())).expect("dial");
    let open_op = op();
    let running = BrokerAbi::current();
    let pol = policy(5);
    let v_cache = v.cache.clone();
    let req_scratch = scratch.join("req");
    let puller = thread::spawn(move || {
        request_update(&mut req_brain, conn.conn_id, open_op, &running, &pol, &v_cache, &req_scratch)
    });

    let (stream, _origin) = wait_for_stream_except(&mut serve_brain, &skip);
    serve_brain.net_stream_subscribe(stream, 0).unwrap();
    // Push the rollback offer unsolicited (don't bother decoding the query).
    let rollback = signed_release(4, b"old binary");
    let offer = UpdRecord::Offer {
        upd_id: format!("upd-{open_op}"),
        metadata_json: rollback.metadata_json,
        signature_hex: rollback.signature_hex,
    }
    .encode_line();
    serve_brain.net_stream_send(stream, &offer, None, false).unwrap();

    match puller.join().expect("pull thread").expect("pull completes") {
        UpdatePullOutcome::Rejected(RejectReason::Rollback { current: 5, candidate: 4 }) => {}
        other => panic!("expected Rollback reject, got {other:?}"),
    }
    assert!(v.cache.staged().is_none(), "nothing staged from a rollback offer");

    // The victim closed without fetching: the evil peer sees EOF, no Fetch —
    // zero artifact bytes ever moved.
    let mut decoder = spt_net::net::update::UpdDecoder::new();
    loop {
        match serve_brain.read_event().expect("event") {
            spt_daemon::BrokerEvent::NetStreamData { stream_id: sid, bytes, .. }
                if sid == stream =>
            {
                for rec in decoder.push(&bytes) {
                    match rec {
                        UpdRecord::Query { .. } => {} // the victim's opening query
                        other => panic!("victim sent {other:?} after a rollback offer"),
                    }
                }
            }
            spt_daemon::BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream => {
                break; // closed without Fetch — the pre-fetch gate held
            }
            _ => {}
        }
    }
}

// [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] an origin absent from the responder's
// trust store gets the up-to-date shape (no offer, fail-closed) even though a
// newer release IS staged — and the gate's subject is the handshake-proven
// stream-table identity, which payload bytes cannot forge (the wire records
// carry no origin field at all).
#[test]
fn untrusted_origin_gets_no_offer() {
    let dir = tempfile::tempdir().expect("tempdir");
    let a = Node::new(dir.path(), "a");
    let b = Node::new(dir.path(), "b");
    let scratch = dir.path().join("scratch");

    let artifact = b"the v6 binary".to_vec();
    a.cache.stage(&signed_release(6, &artifact), &artifact).unwrap();

    // A trusts some other node — NOT b.
    let trust = trust_for(&["feedfacecafebeef"]);
    let (got, served) = pull(&b, &a, &trust, &policy(5), &scratch);
    assert!(
        matches!(got.expect("pull completes"), UpdatePullOutcome::UpToDate),
        "the refusal is indistinguishable from up-to-date"
    );
    assert_eq!(served, UpdateServeOutcome::Refused, "the serve gate observed the refusal");
    assert!(b.cache.staged().is_none(), "nothing landed");
}


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

//! M4-D7-2 loopback propagation E2E (REQ-UPD-1, REQ-UPD-2,
//! REQ-HAZARD-UPDATE-ROLLBACK, REQ-HAZARD-WAN-ORIGIN-AUTH): a signed update
//! self-heals across broker-streamed pulls with the full M3c gate at every
//! receiving node, and every poison path (tampered relay, rollback offer,
//! untrusted origin) is rejected loud.
//!
//! Same harness shape as `tests/sync.rs`: two/three in-process brokers with
//! hermetic loopback net hosts; the responder snapshots its stream table
//! BEFORE the requester opens (the table holds locally-opened streams too —
//! grabbing your own stale row hangs the join forever).

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

use ed25519_dalek::{Signer, SigningKey};
use spt_daemon::nethost::{BindScope, LocalDiscovery, RelayPolicy};
use spt_daemon::propagate::{
    request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome,
};
use spt_daemon::relcache::ReleaseCache;
use spt_daemon::release::{RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy};
use spt_daemon::update::{BrokerAbi, UpdateClass, BROKER_RESOURCE_ABI};
use spt_daemon::{Brain, Broker, NetConfig, NetHost};
use spt_net::net::update::UpdRecord;
use spt_proto::identity::Identity;
use spt_store::trust::TrustStore;
use std::collections::{BTreeMap, BTreeSet};

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

/// One op-id namespace across every brain in the binary — journaled ops must
/// never collide.
static OP: AtomicU64 = AtomicU64::new(1);
fn op() -> u64 {
    OP.fetch_add(1, Ordering::Relaxed)
}

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

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

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

fn wait_for_stream_except(brain: &mut Brain, skip: &[u64]) -> (u64, String) {
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.iter().find(|s| !skip.contains(&s.stream_id)) {
            return (s.stream_id, s.remote_id_hex.clone());
        }
        thread::sleep(Duration::from_millis(5));
    }
    panic!("update stream never appeared at the responder's broker");
}

const KEY_ID: &str = "rel-2026";
const CHANNEL: &str = "stable";

/// The release signing key (deterministic — test-stable).
fn release_key() -> SigningKey {
    SigningKey::from_bytes(&[7u8; 32])
}

/// Build + sign release metadata for `artifact` at `version`.
fn signed_release(version: u64, artifact: &[u8]) -> SignedRelease {
    let meta = ReleaseMetadata {
        version,
        channel: CHANNEL.to_string(),
        expires_at_ms: 10_000,
        key_id: KEY_ID.to_string(),
        artifact_sha256: spt_daemon::sha256_hex(artifact),
        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
        broker_resource_abi: BROKER_RESOURCE_ABI,
    };
    let metadata_json = serde_json::to_string(&meta).unwrap();
    let sig = release_key().sign(metadata_json.as_bytes());
    let signature_hex: String = sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
    SignedRelease { metadata_json, signature_hex }
}

/// A node's verify policy: trusts the release key, pinned to stable, running
/// `current_version`, clock before expiry.
fn policy(current_version: u64) -> VerifyPolicy {
    let mut trusted = BTreeMap::new();
    trusted.insert(KEY_ID.to_string(), release_key().verifying_key());
    VerifyPolicy {
        trusted_keys: trusted,
        revoked_keys: BTreeSet::new(),
        pinned_channel: CHANNEL.to_string(),
        current_version,
        now_ms: 1_000,
    }
}

/// A trust store that pins `peers` (in subnet "home") — the serve gate's input.
fn trust_for(peers: &[&str]) -> TrustStore {
    let mut t = TrustStore::default();
    for p in peers {
        t.record("home", p, None, None);
    }
    t
}

/// One node of the propagation chain: a live broker + its release cache.
struct Node {
    name: String,
    cache: ReleaseCache,
}

impl Node {
    fn new(dir: &std::path::Path, tag: &str) -> Node {
        let name = unique_name();
        let _broker = net_broker(&name, &dir.join(tag));
        Node { name, cache: ReleaseCache::open(&dir.join(tag).join("releases")) }
    }

    fn node_id(&self) -> String {
        connect_retry(&self.name).net_status().unwrap().node_id_hex.unwrap()
    }
}

/// Run one pull: `requester` ← `responder`, with the responder serving under
/// `trust`. Returns `(pull outcome, serve outcome)`.
fn pull(
    requester: &Node,
    responder: &Node,
    trust: &TrustStore,
    pol: &VerifyPolicy,
    scratch: &std::path::Path,
) -> (std::io::Result<UpdatePullOutcome>, UpdateServeOutcome) {
    let mut serve_brain = connect_retry(&responder.name);
    let addr = serve_brain.net_status().expect("status").addr;
    // Snapshot BEFORE the requester opens — the table holds locally-opened
    // streams too; grabbing a stale row would hang the join forever.
    let skip: Vec<u64> = serve_brain
        .net_streams()
        .expect("stream snapshot")
        .streams
        .iter()
        .map(|s| s.stream_id)
        .collect();

    let mut req_brain = connect_retry(&requester.name);
    let conn = req_brain.net_dial(addr, Some(op())).expect("dial");
    let open_op = op();
    let running = BrokerAbi::current();
    let req_cache = requester.cache.clone();
    let req_pol = pol.clone();
    let req_scratch = scratch.join("req");
    let puller = thread::spawn(move || {
        request_update(
            &mut req_brain,
            conn.conn_id,
            open_op,
            &running,
            &req_pol,
            &req_cache,
            &req_scratch,
        )
    });

    let (stream, origin) = wait_for_stream_except(&mut serve_brain, &skip);
    let outcome = serve_update(&mut serve_brain, stream, &origin, 0, &responder.cache, trust)
        .expect("serve");
    (puller.join().expect("pull thread"), outcome)
}

// [unit->REQ-UPD-1] the self-heal chain: A holds the verified v6 release; B
// (running v5) pulls it through the gate and stages it; C then pulls FROM B —
// verified-pull ⇒ staged ⇒ servable, the gate running at every hop. Both
// pulls classify brain-only and land byte-identical artifacts.
#[test]
fn chain_self_heal_propagates_with_gate_at_every_hop() {
    let dir = tempfile::tempdir().expect("tempdir");
    let a = Node::new(dir.path(), "a");
    let b = Node::new(dir.path(), "b");
    let c = Node::new(dir.path(), "c");
    let scratch = dir.path().join("scratch");

    let artifact = vec![0xA5u8; 150 * 1024]; // >2 chunks
    a.cache.stage(&signed_release(6, &artifact), &artifact).unwrap();

    // B ← A.
    let trust_a = trust_for(&[&b.node_id()]);
    let (got, served) = pull(&b, &a, &trust_a, &policy(5), &scratch);
    match got.expect("pull b<-a") {
        UpdatePullOutcome::Updated(plan) => assert_eq!(plan.class, UpdateClass::BrainOnly),
        other => panic!("expected Updated at B, got {other:?}"),
    }
    assert_eq!(served, UpdateServeOutcome::Served { bytes: artifact.len() as u64 });
    assert_eq!(b.cache.load_artifact().unwrap(), artifact, "staged byte-identical at B");

    // C ← B (B never talked to A's cache — it serves what it verified).
    let trust_b = trust_for(&[&c.node_id()]);
    let (got, served) = pull(&c, &b, &trust_b, &policy(5), &scratch);
    match got.expect("pull c<-b") {
        UpdatePullOutcome::Updated(plan) => assert_eq!(plan.class, UpdateClass::BrainOnly),
        other => panic!("expected Updated at C, got {other:?}"),
    }
    assert_eq!(served, UpdateServeOutcome::Served { bytes: artifact.len() as u64 });
    assert_eq!(c.cache.load_artifact().unwrap(), artifact, "staged byte-identical at C");

    // An up-to-date node pulls nothing (idempotent re-query).
    let (got, served) = pull(&c, &b, &trust_b, &policy(6), &scratch);
    assert!(matches!(got.expect("re-pull"), UpdatePullOutcome::UpToDate));
    assert_eq!(served, UpdateServeOutcome::UpToDate);
}

// [unit->REQ-UPD-2] a compromised relay node cannot poison the chain: B's
// staged artifact is tampered at rest, so C's per-node gate ends the pull
// Rejected(ArtifactMismatch) and stages nothing — the gate is per-node, never
// inherited from the sender's claim to have verified.
#[test]
fn tampered_relay_artifact_is_rejected_and_never_staged() {
    let dir = tempfile::tempdir().expect("tempdir");
    let b = Node::new(dir.path(), "b");
    let c = Node::new(dir.path(), "c");
    let scratch = dir.path().join("scratch");

    let artifact = b"the genuine v6 binary".to_vec();
    b.cache.stage(&signed_release(6, &artifact), &artifact).unwrap();
    // Tamper the bytes at rest (post-verification compromise at B).
    std::fs::write(b.cache.artifact_path(), b"poisoned bytes").unwrap();

    let trust_b = trust_for(&[&c.node_id()]);
    let (got, served) = pull(&c, &b, &trust_b, &policy(5), &scratch);
    match got.expect("pull completes") {
        UpdatePullOutcome::Rejected(RejectReason::ArtifactMismatch { .. }) => {}
        other => panic!("expected ArtifactMismatch at C, got {other:?}"),
    }
    assert!(matches!(served, UpdateServeOutcome::Served { .. }));
    assert!(c.cache.staged().is_none(), "nothing staged at C — C never re-serves poison");
}

// [unit->REQ-HAZARD-UPDATE-ROLLBACK] a non-conforming peer offering a
// ROLLBACK (v4 to a v5 node) is rejected by the requester's pre-fetch gate
// with zero artifact bytes moved: the requester closes after the Offer, so
// the evil responder never even receives a Fetch.
#[test]
fn rollback_offer_is_rejected_before_any_fetch() {
    let dir = tempfile::tempdir().expect("tempdir");
    let evil = Node::new(dir.path(), "evil");
    let v = Node::new(dir.path(), "v");
    let scratch = dir.path().join("scratch");

    // The victim pulls; the "responder" is a hand-rolled non-conforming peer
    // (an honest serve_update never offers a rollback — the gate under test
    // is the REQUESTER's).
    let mut serve_brain = connect_retry(&evil.name);
    let addr = serve_brain.net_status().expect("status").addr;
    let skip: Vec<u64> = serve_brain
        .net_streams()
        .expect("snapshot")
        .streams
        .iter()
        .map(|s| s.stream_id)
        .collect();

    let mut req_brain = connect_retry(&v.name);
    let conn = req_brain.net_dial(addr, Some(op())).expect("dial");
    let open_op = op();
    let running = BrokerAbi::current();
    let pol = policy(5);
    let v_cache = v.cache.clone();
    let req_scratch = scratch.join("req");
    let puller = thread::spawn(move || {
        request_update(&mut req_brain, conn.conn_id, open_op, &running, &pol, &v_cache, &req_scratch)
    });

    let (stream, _origin) = wait_for_stream_except(&mut serve_brain, &skip);
    serve_brain.net_stream_subscribe(stream, 0).unwrap();
    // Push the rollback offer unsolicited (don't bother decoding the query).
    let rollback = signed_release(4, b"old binary");
    let offer = UpdRecord::Offer {
        upd_id: format!("upd-{open_op}"),
        metadata_json: rollback.metadata_json,
        signature_hex: rollback.signature_hex,
    }
    .encode_line();
    serve_brain.net_stream_send(stream, &offer, None, false).unwrap();

    match puller.join().expect("pull thread").expect("pull completes") {
        UpdatePullOutcome::Rejected(RejectReason::Rollback { current: 5, candidate: 4 }) => {}
        other => panic!("expected Rollback reject, got {other:?}"),
    }
    assert!(v.cache.staged().is_none(), "nothing staged from a rollback offer");

    // The victim closed without fetching: the evil peer sees EOF, no Fetch —
    // zero artifact bytes ever moved.
    let mut decoder = spt_net::net::update::UpdDecoder::new();
    loop {
        match serve_brain.read_event().expect("event") {
            spt_daemon::BrokerEvent::NetStreamData { stream_id: sid, bytes, .. }
                if sid == stream =>
            {
                for rec in decoder.push(&bytes) {
                    match rec {
                        UpdRecord::Query { .. } => {} // the victim's opening query
                        other => panic!("victim sent {other:?} after a rollback offer"),
                    }
                }
            }
            spt_daemon::BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream => {
                break; // closed without Fetch — the pre-fetch gate held
            }
            _ => {}
        }
    }
}

// [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] an origin absent from the responder's
// trust store gets the up-to-date shape (no offer, fail-closed) even though a
// newer release IS staged — and the gate's subject is the handshake-proven
// stream-table identity, which payload bytes cannot forge (the wire records
// carry no origin field at all).
#[test]
fn untrusted_origin_gets_no_offer() {
    let dir = tempfile::tempdir().expect("tempdir");
    let a = Node::new(dir.path(), "a");
    let b = Node::new(dir.path(), "b");
    let scratch = dir.path().join("scratch");

    let artifact = b"the v6 binary".to_vec();
    a.cache.stage(&signed_release(6, &artifact), &artifact).unwrap();

    // A trusts some other node — NOT b.
    let trust = trust_for(&["feedfacecafebeef"]);
    let (got, served) = pull(&b, &a, &trust, &policy(5), &scratch);
    assert!(
        matches!(got.expect("pull completes"), UpdatePullOutcome::UpToDate),
        "the refusal is indistinguishable from up-to-date"
    );
    assert_eq!(served, UpdateServeOutcome::Refused, "the serve gate observed the refusal");
    assert!(b.cache.staged().is_none(), "nothing landed");
}


PowerShell

cargo clippy -p spt-daemon --test propagate -- -D warnings 2>&1 | Select-Object -Last 25

Clippy-check propagate int test


Error: Exit code 101
35 |     BindScope, Connection, EndpointAddr, LocalDiscovery, NetEndpoint, RecvStream, RelayPolicy,
   |                                          ^^^^^^^^^^^^^^
help: import `LocalDiscovery` directly
   |
18 | use spt_daemon::nethost::{BindScope, spt_net::net::endpoint::LocalDiscovery, RelayPolicy};
   |                                      ++++++++++++++++++++++++

error[E0603]: enum `RelayPolicy` is private
  --> crates\spt-daemon\tests\propagate.rs:18:54
   |
18 | use spt_daemon::nethost::{BindScope, LocalDiscovery, RelayPolicy};
   |                                                      ^^^^^^^^^^^ private enum
   |
note: the enum `RelayPolicy` is defined here
  --> C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\nethost.rs:35:83
   |
35 |     BindScope, Connection, EndpointAddr, LocalDiscovery, NetEndpoint, RecvStream, RelayPolicy,
   |                                                                                ^^^^^^^^^^^
help: import `RelayPolicy` directly
   |
18 | use spt_daemon::nethost::{BindScope, LocalDiscovery, spt_net::net::endpoint::RelayPolicy};
   |                                                      ++++++++++++++++++++++++

For more information about this error, try `rustc --explain E0603`.
error: could not compile `spt-daemon` (test "propagate") due to 3 previous errors


1	//! D6c regression — cross-node context bundle sync (REQ-NET-3, REQ-INST-5):
2	//! the mind follows the user across nodes over the broker-owned wire,
3	//! ref-scoped and server-gated, with concurrent writes surfaced and
4	//! Psyche-reconciled — never silently dropped.
5	//!
6	//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
7	//! the two daemons, each with its own `tracked/` context store. The invariants:
8	//!
9	//! - **two-tier scoping, gate server-side**: a pull lands `a-<id>` and
10	//!   `p-<project>` byte-identical at the requester, while an unsynced mind
11	//!   (no membership list — fail-closed) and a hidden one are refused **by the
12	//!   responder** even when requested by name, under the handshake-proven
13	//!   origin (never payload bytes);
14	//! - **concurrent writes converge through the elected reconciler**: both nodes
15	//!   surface the pair as durable artifacts, the non-elected node stands down,
16	//!   the elected node's bounded Psyche turn mints a dominating merge, and the
17	//!   merge propagates as a plain accept that clears artifacts everywhere
18	//!   (hazard 6.6: both versions outlive every intermediate state);
19	//! - **a torn pull recovers by re-pulling** (FAULT-MATRIX row): a stream lost
20	//!   mid-bundle errors the requester without touching its store; a fresh
21	//!   request converges, and a further re-pull is a no-op (apply is idempotent
22	//!   on ancestry).
23
24	use std::collections::BTreeMap;
25	use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
26	use std::sync::{Arc, OnceLock};
27	use std::thread;
28	use std::time::Duration;
29
30	use spt_daemon::brain::{Brain, BrokerEvent};
31	use spt_daemon::nethost::{NetConfig, NetHost};
32	use spt_daemon::sync::{
33	    reconcile_after_sync, request_sync, serve_sync, ReconcileWiring, SyncPolicy, SyncServeOutcome,
34	};
35	use spt_daemon::Broker;
36	use spt_live::reconcile::ReconcileOutcome;
37	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
38	use spt_net::net::registry::{Instance, Status};
39	use spt_net::net::sync::{SyncDecoder, SyncRecord};
40	use spt_proto::identity::Identity;

//! D6c regression — cross-node context bundle sync (REQ-NET-3, REQ-INST-5):
//! the mind follows the user across nodes over the broker-owned wire,
//! ref-scoped and server-gated, with concurrent writes surfaced and
//! Psyche-reconciled — never silently dropped.
//!
//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
//! the two daemons, each with its own `tracked/` context store. The invariants:
//!
//! - **two-tier scoping, gate server-side**: a pull lands `a-<id>` and
//!   `p-<project>` byte-identical at the requester, while an unsynced mind
//!   (no membership list — fail-closed) and a hidden one are refused **by the
//!   responder** even when requested by name, under the handshake-proven
//!   origin (never payload bytes);
//! - **concurrent writes converge through the elected reconciler**: both nodes
//!   surface the pair as durable artifacts, the non-elected node stands down,
//!   the elected node's bounded Psyche turn mints a dominating merge, and the
//!   merge propagates as a plain accept that clears artifacts everywhere
//!   (hazard 6.6: both versions outlive every intermediate state);
//! - **a torn pull recovers by re-pulling** (FAULT-MATRIX row): a stream lost
//!   mid-bundle errors the requester without touching its store; a fresh
//!   request converges, and a further re-pull is a no-op (apply is idempotent
//!   on ancestry).

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

use spt_daemon::brain::{Brain, BrokerEvent};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::sync::{
    reconcile_after_sync, request_sync, serve_sync, ReconcileWiring, SyncPolicy, SyncServeOutcome,
};
use spt_daemon::Broker;
use spt_live::reconcile::ReconcileOutcome;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::registry::{Instance, Status};
use spt_net::net::sync::{SyncDecoder, SyncRecord};
use spt_proto::identity::Identity;


Edit

use spt_daemon::nethost::{BindScope, LocalDiscovery, RelayPolicy};
use spt_daemon::propagate::{
    request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome,
};

use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::propagate::{
    request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome,
};


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

use spt_daemon::nethost::{BindScope, LocalDiscovery, RelayPolicy};
use spt_daemon::propagate::{
    request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome,
};

use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::propagate::{
    request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome,
};

-use spt_daemon::nethost::{BindScope, LocalDiscovery, RelayPolicy};


use spt_daemon::{Brain, Broker, NetConfig, NetHost};
use spt_net::net::update::UpdRecord;

use spt_daemon::{Brain, Broker};
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::update::UpdRecord;


use spt_daemon::{Brain, Broker, NetConfig, NetHost};
use spt_net::net::update::UpdRecord;

use spt_daemon::{Brain, Broker};
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::update::UpdRecord;

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

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

-use spt_daemon::{Brain, Broker, NetConfig, NetHost};

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


PowerShell

cargo clippy -p spt-daemon --test propagate -- -D warnings 2>&1 | Select-Object -Last 20

Re-check propagate test compile


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


cargo test -p spt-daemon --test propagate 2>&1 | Select-Object -Last 15

Run propagate int tests


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

running 4 tests
test untrusted_origin_gets_no_offer ... ok
test rollback_offer_is_rejected_before_any_fetch ... ok
test tampered_relay_artifact_is_rejected_and_never_staged ... ok
test chain_self_heal_propagates_with_gate_at_every_hop ... ok

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


All 4 int tests green first run. Add FAULT-MATRIX rows.


docs\FAULT-MATRIX.md