Write

//! Off-node file transfer (M4-D5c, REQ-REACH-1) — the target-side serve pump
//! and the operator-side fetch/push drivers for the [`XferRecord`] protocol.
//!
//! One transfer rides one bidi QUIC stream: the operator's daemon opens the
//! stream (D4) and sends [`XferRecord::Fetch`] (pull a file off the target)
//! or [`XferRecord::Push`] (put a file onto it); the target's
//! [`serve_xfer`] answers. Compute + files otherwise stay on the target
//! (CONTEXT §Remote-control vs local operation) — this channel is the
//! deliberate, gated exception, and the v1 surface is exactly the
//! driving-node reach-back case the Shell text+file channel later inherits.
//!
//! ## Exactly-once: positional chunks + atomic commit, never journaled sends
//!
//! Every wire send here is fire-and-forget (`op_id: None`) — a journaled
//! `net_stream_send` blocks for its ack and DISCARDS interleaved events,
//! starving a full-duplex pump (the D5b lesson, see `attach.rs` module docs).
//! Idempotency lives at the edges instead:
//!
//! - chunks carry their absolute byte `offset` — a replayed feed (a target
//!   brain restart re-serving the stream from seq 0 re-presents *everything*)
//!   rewrites the same bytes at the same positions;
//! - the receiving side writes into a temp file and commits with an atomic
//!   rename at [`XferRecord::Done`]; a replayed `Done` for an
//!   already-committed transfer dedups against the final file (present, full
//!   length) instead of failing on the missing temp.
//!
//! ## Progress is durable and queryable mid-flight (CONTEXT §file-transfer)
//!
//! Both ends rewrite their `{ bytes_done, bytes_total }` record
//! ([`spt_store::xfer`]) on every chunk, so the agent in the driven session
//! and the `spt` binary (GUI) can poll position from *outside* the busy pump
//! at any moment — and a brain crash mid-transfer leaves the last observed
//! position on disk.
//!
//! ## Gate + paths
//!
//! The first record on the stream is *unsolicited wire inbound*:
//! [`access_check`] runs under the handshake-proven origin from the broker's
//! stream table — never payload bytes (KNOWN-HAZARDS 7.5). File paths in
//! records are **root-relative**: resolved under the target's transfer root,
//! refusing absolute paths and `..` traversal — the wire never names
//! arbitrary filesystem locations.

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

use spt_net::net::xfer::{XferDecoder, XferRecord};
use spt_store::xfer::{write_progress_at, XferProgress, XferRole};

use crate::access::{access_check, AccessDecision, InboundClass};
use crate::brain::{Brain, BrokerEvent};
use crate::msg::{decode_bytes, encode_bytes};

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

/// Why a target-side serve loop returned.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum XferServeOutcome {
    /// The access gate refused the requesting origin — nothing was served.
    Refused,
    /// A `Fetch` was served to completion (`bytes` put on the wire).
    Sent { bytes: u64 },
    /// A `Push` was committed (temp → final rename durable; `bytes` written).
    Received { bytes: u64 },
    /// The transfer failed; an `Err` record was sent to the peer.
    Failed(String),
    /// The operator closed its send side before any transfer completed.
    Detached,
}

/// Resolve a wire-supplied `path` strictly under `root`: plain relative
/// components only — absolute paths, drive/UNC prefixes, and `..` are refused
/// (the wire never names arbitrary filesystem locations).
// [impl->REQ-REACH-1]
fn resolve_under(root: &Path, path: &str) -> Result<PathBuf, String> {
    let rel = Path::new(path);
    let mut out = root.to_path_buf();
    let mut any = false;
    for c in rel.components() {
        match c {
            Component::Normal(seg) => {
                out.push(seg);
                any = true;
            }
            Component::CurDir => {}
            _ => return Err(format!("refused path {path:?}: must be root-relative")),
        }
    }
    if !any {
        return Err("refused empty path".to_string());
    }
    Ok(out)
}

/// The receiving side's temp file for one transfer — sibling of the final
/// path, discriminated by xfer_id so concurrent transfers never collide.
fn temp_path(final_path: &Path, xfer_id: &str) -> PathBuf {
    let safe: String = xfer_id
        .chars()
        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
        .collect();
    let mut name = final_path.file_name().unwrap_or_default().to_os_string();
    name.push(format!(".{safe}.part"));
    final_path.with_file_name(name)
}

fn progress(xfer_id: &str, path: &str, done: u64, total: u64, state: &str) -> XferProgress {
    XferProgress {
        xfer_id: xfer_id.to_string(),
        path: path.to_string(),
        bytes_done: done,
        bytes_total: total,
        state: state.to_string(),
    }
}

/// The in-progress receive state for one `Push` (or operator-side fetch).
struct RecvState {
    xfer_id: String,
    wire_path: String,
    final_path: PathBuf,
    temp: PathBuf,
    file: Option<std::fs::File>,
    total: u64,
    done: u64,
}

impl RecvState {
    fn open(
        xfer_id: &str,
        wire_path: &str,
        final_path: PathBuf,
        total: u64,
    ) -> io::Result<RecvState> {
        if let Some(parent) = final_path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let temp = temp_path(&final_path, xfer_id);
        // No truncate: a re-served (replayed) push rewrites the same bytes at
        // the same offsets over whatever the dead life already wrote.
        let file = std::fs::OpenOptions::new().create(true).write(true).open(&temp)?;
        Ok(RecvState {
            xfer_id: xfer_id.to_string(),
            wire_path: wire_path.to_string(),
            final_path,
            temp,
            file: Some(file),
            total,
            done: 0,
        })
    }

    /// Write one chunk at its absolute offset — positionally idempotent.
    fn write_chunk(&mut self, offset: u64, bytes: &[u8]) -> io::Result<()> {
        let f = self.file.as_mut().ok_or_else(|| io::Error::other("chunk after commit"))?;
        f.seek(SeekFrom::Start(offset))?;
        f.write_all(bytes)?;
        self.done = self.done.max(offset + bytes.len() as u64);
        Ok(())
    }

    /// Commit: fsync + atomic rename temp → final. A replayed commit whose
    /// temp is already gone dedups against the final file (present, full
    /// length) — exactly-once at the boundary that matters.
    fn commit(&mut self, total: u64) -> io::Result<()> {
        if let Some(f) = self.file.take() {
            f.sync_all()?;
        }
        match std::fs::rename(&self.temp, &self.final_path) {
            Ok(()) => {}
            Err(e) => {
                let committed = std::fs::metadata(&self.final_path)
                    .map(|m| m.len() == total)
                    .unwrap_or(false);
                if !committed {
                    return Err(e);
                }
                // Replay of an already-committed transfer: dedup, drop the
                // (possibly re-created) temp.
                let _ = std::fs::remove_file(&self.temp);
            }
        }
        self.done = total;
        Ok(())
    }
}

/// Serve one transfer stream against this brain's broker, blocking until the
/// transfer completes, the operator detaches, or the gate refuses.
///
/// `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). `endpoint` names the endpoint being reached,
/// for the gate. `root` is the transfer root all wire paths resolve under;
/// `progress_dir` is where this end's progress records land
/// ([`spt_store::xfer::transfers_dir`] in production; tests inject).
/// `net_from_seq` is this brain's resume cursor on the stream (0 cold — also
/// the safe post-restart choice: the replayed feed re-gates, chunks rewrite
/// in place, and the commit dedups).
// [impl->REQ-REACH-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
pub fn serve_xfer(
    brain: &mut Brain,
    stream_id: u64,
    endpoint: &str,
    origin_node: &str,
    net_from_seq: u64,
    root: &Path,
    progress_dir: &Path,
) -> io::Result<XferServeOutcome> {
    brain.net_stream_subscribe(stream_id, net_from_seq)?;
    let mut decoder = XferDecoder::new();
    let mut recv: Option<RecvState> = None;

    // Refuse the transfer with an `Err` record + stream finish.
    let refuse = |brain: &mut Brain, xfer_id: &str, message: String| -> io::Result<XferServeOutcome> {
        let line = XferRecord::Err { xfer_id: xfer_id.to_string(), message: message.clone() }
            .encode_line();
        brain.net_stream_send(stream_id, &line, None, true)?;
        Ok(XferServeOutcome::Failed(message))
    };

    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        // ── Pull: read the file, pump it onto the wire. ────
                        XferRecord::Fetch { path, xfer_id } => {
                            // Unsolicited wire inbound — the ADR-0009 gate,
                            // under the transport-proven origin.
                            if access_check(endpoint, origin_node, InboundClass::Unsolicited)
                                == AccessDecision::Deny
                            {
                                brain.net_stream_send(stream_id, &[], None, true)?;
                                return Ok(XferServeOutcome::Refused);
                            }
                            let src = match resolve_under(root, &path) {
                                Ok(p) => p,
                                Err(m) => return refuse(brain, &xfer_id, m),
                            };
                            let mut file = match std::fs::File::open(&src) {
                                Ok(f) => f,
                                Err(e) => {
                                    return refuse(brain, &xfer_id, format!("open {path:?}: {e}"))
                                }
                            };
                            let total = file.metadata()?.len();
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Send,
                                &progress(&xfer_id, &path, 0, total, "active"),
                            );
                            let mut offset = 0u64;
                            let mut buf = vec![0u8; CHUNK_BYTES];
                            loop {
                                let n = file.read(&mut buf)?;
                                if n == 0 {
                                    break;
                                }
                                let line = XferRecord::Chunk {
                                    xfer_id: xfer_id.clone(),
                                    offset,
                                    data_b64: encode_bytes(&buf[..n]),
                                }
                                .encode_line();
                                // Fire-and-forget by design (module docs):
                                // the offset is the dedup, not a send journal.
                                brain.net_stream_send(stream_id, &line, None, false)?;
                                offset += n as u64;
                                let _ = write_progress_at(
                                    progress_dir,
                                    XferRole::Send,
                                    &progress(&xfer_id, &path, offset, total, "active"),
                                );
                            }
                            let done =
                                XferRecord::Done { xfer_id: xfer_id.clone(), total }.encode_line();
                            brain.net_stream_send(stream_id, &done, None, true)?;
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Send,
                                &progress(&xfer_id, &path, total, total, "done"),
                            );
                            return Ok(XferServeOutcome::Sent { bytes: total });
                        }

                        // ── Push: receive chunks, commit at Done. ──────────
                        XferRecord::Push { path, total, xfer_id } => {
                            if access_check(endpoint, origin_node, InboundClass::Unsolicited)
                                == AccessDecision::Deny
                            {
                                brain.net_stream_send(stream_id, &[], None, true)?;
                                return Ok(XferServeOutcome::Refused);
                            }
                            let dest = match resolve_under(root, &path) {
                                Ok(p) => p,
                                Err(m) => return refuse(brain, &xfer_id, m),
                            };
                            let state = match RecvState::open(&xfer_id, &path, dest, total) {
                                Ok(s) => s,
                                Err(e) => {
                                    return refuse(brain, &xfer_id, format!("open {path:?}: {e}"))
                                }
                            };
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Recv,
                                &progress(&xfer_id, &path, 0, total, "active"),
                            );
                            recv = Some(state);
                        }
                        XferRecord::Chunk { xfer_id, offset, data_b64 } => {
                            let Some(state) = recv.as_mut() else { continue };
                            if state.xfer_id != xfer_id {
                                continue; // not this transfer's chunk — protocol noise
                            }
                            let bytes = decode_bytes(&data_b64)
                                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                            state.write_chunk(offset, &bytes)?;
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Recv,
                                &progress(
                                    &state.xfer_id,
                                    &state.wire_path,
                                    state.done,
                                    state.total,
                                    "active",
                                ),
                            );
                        }
                        XferRecord::Done { xfer_id, total } => {
                            let Some(state) = recv.as_mut() else { continue };
                            if state.xfer_id != xfer_id {
                                continue;
                            }
                            state.commit(total)?;
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Recv,
                                &progress(&state.xfer_id, &state.wire_path, total, total, "done"),
                            );
                            // Echo the commit so the operator observes durability.
                            let echo =
                                XferRecord::Done { xfer_id, total }.encode_line();
                            brain.net_stream_send(stream_id, &echo, None, true)?;
                            return Ok(XferServeOutcome::Received { bytes: total });
                        }
                        XferRecord::Err { message, .. } => {
                            return Ok(XferServeOutcome::Failed(message));
                        }
                    }
                }
            }
            // The operator closed its send side before completing a transfer.
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                brain.net_stream_send(stream_id, &[], None, true)?;
                return Ok(XferServeOutcome::Detached);
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            // Acks and unrelated events: not ours to handle.
            _ => {}
        }
    }
}

/// Operator-side: pull `remote_path` (root-relative on the target) off the
/// target into `dest`, blocking until the file is committed locally. Returns
/// the byte count. `open_op` is the durable id for the exactly-once stream
/// open (safe to block for — no transfer traffic exists before the stream
/// does); everything after rides fire-and-forget + positional dedup.
/// Progress for this (receiving) end lands in `progress_dir` per chunk.
// [impl->REQ-REACH-1]
pub fn fetch_file(
    brain: &mut Brain,
    conn_id: u64,
    remote_path: &str,
    dest: &Path,
    xfer_id: &str,
    open_op: u64,
    progress_dir: &Path,
) -> io::Result<u64> {
    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 line = XferRecord::Fetch { path: remote_path.to_string(), xfer_id: xfer_id.to_string() }
        .encode_line();
    brain.net_stream_send(stream_id, &line, None, false)?;

    if let Some(parent) = dest.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let mut state = RecvState {
        xfer_id: xfer_id.to_string(),
        wire_path: remote_path.to_string(),
        final_path: dest.to_path_buf(),
        temp: temp_path(dest, xfer_id),
        file: Some(
            std::fs::OpenOptions::new().create(true).write(true).open(temp_path(dest, xfer_id))?,
        ),
        total: 0, // unknown until Done (chunks carry offsets, not totals)
        done: 0,
    };

    let mut decoder = XferDecoder::new();
    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        XferRecord::Chunk { xfer_id: xid, offset, data_b64 }
                            if xid == state.xfer_id =>
                        {
                            let bytes = decode_bytes(&data_b64)
                                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                            state.write_chunk(offset, &bytes)?;
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Recv,
                                &progress(xfer_id, remote_path, state.done, 0, "active"),
                            );
                        }
                        XferRecord::Done { xfer_id: xid, total } if xid == state.xfer_id => {
                            state.commit(total)?;
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Recv,
                                &progress(xfer_id, remote_path, total, total, "done"),
                            );
                            return Ok(total);
                        }
                        XferRecord::Err { message, .. } => {
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Recv,
                                &progress(xfer_id, remote_path, state.done, 0, "error"),
                            );
                            return Err(io::Error::other(format!("fetch refused: {message}")));
                        }
                        _ => {}
                    }
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                return Err(io::Error::new(
                    io::ErrorKind::UnexpectedEof,
                    "stream ended before Done",
                ));
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}

/// Operator-side: push the local file `src` onto the target at `remote_path`
/// (root-relative there), blocking until the target echoes the durable commit.
/// Returns the byte count. Same journaling posture as [`fetch_file`]: the
/// stream open is the only journaled op; chunks dedup by offset on the far
/// side. Progress for this (sending) end lands in `progress_dir` per chunk.
// [impl->REQ-REACH-1]
pub fn push_file(
    brain: &mut Brain,
    conn_id: u64,
    src: &Path,
    remote_path: &str,
    xfer_id: &str,
    open_op: u64,
    progress_dir: &Path,
) -> io::Result<u64> {
    let mut file = std::fs::File::open(src)?;
    let total = file.metadata()?.len();

    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 push = XferRecord::Push {
        path: remote_path.to_string(),
        total,
        xfer_id: xfer_id.to_string(),
    }
    .encode_line();
    brain.net_stream_send(stream_id, &push, None, false)?;
    let _ = write_progress_at(
        progress_dir,
        XferRole::Send,
        &progress(xfer_id, remote_path, 0, total, "active"),
    );

    let mut offset = 0u64;
    let mut buf = vec![0u8; CHUNK_BYTES];
    loop {
        let n = file.read(&mut buf)?;
        if n == 0 {
            break;
        }
        let line = XferRecord::Chunk {
            xfer_id: xfer_id.to_string(),
            offset,
            data_b64: encode_bytes(&buf[..n]),
        }
        .encode_line();
        brain.net_stream_send(stream_id, &line, None, false)?;
        offset += n as u64;
        let _ = write_progress_at(
            progress_dir,
            XferRole::Send,
            &progress(xfer_id, remote_path, offset, total, "active"),
        );
    }
    let done = XferRecord::Done { xfer_id: xfer_id.to_string(), total }.encode_line();
    brain.net_stream_send(stream_id, &done, None, false)?;

    // Wait for the target's echoed Done — the durable-commit observation.
    let mut decoder = XferDecoder::new();
    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        XferRecord::Done { xfer_id: xid, total: committed }
                            if xid == xfer_id && committed == total =>
                        {
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Send,
                                &progress(xfer_id, remote_path, total, total, "done"),
                            );
                            return Ok(total);
                        }
                        XferRecord::Err { message, .. } => {
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Send,
                                &progress(xfer_id, remote_path, offset, total, "error"),
                            );
                            return Err(io::Error::other(format!("push refused: {message}")));
                        }
                        _ => {}
                    }
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                return Err(io::Error::new(
                    io::ErrorKind::UnexpectedEof,
                    "stream ended before the commit echo",
                ));
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}

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

    // [unit->REQ-REACH-1] wire paths resolve strictly under the transfer root:
    // plain relative paths land inside; absolute paths and `..` traversal are
    // refused (the wire never names arbitrary filesystem locations).
    #[test]
    fn resolve_under_confines_to_root() {
        let root = Path::new("root");
        assert_eq!(resolve_under(root, "a.txt").unwrap(), root.join("a.txt"));
        assert_eq!(
            resolve_under(root, "sub/dir/b.bin").unwrap(),
            root.join("sub").join("dir").join("b.bin")
        );
        assert_eq!(resolve_under(root, "./c.txt").unwrap(), root.join("c.txt"));

        assert!(resolve_under(root, "../escape.txt").is_err());
        assert!(resolve_under(root, "sub/../../escape.txt").is_err());
        assert!(resolve_under(root, "/etc/passwd").is_err());
        #[cfg(windows)]
        assert!(resolve_under(root, "C:\\Windows\\system32").is_err());
        assert!(resolve_under(root, "").is_err());
    }

    // [unit->REQ-REACH-1] the receive state is positionally idempotent and the
    // commit is replay-safe: rewriting chunks at the same offsets changes
    // nothing, and a second commit after the rename dedups against the final
    // file instead of failing on the missing temp.
    #[test]
    fn recv_state_chunks_idempotent_and_commit_replays_safely() {
        let dir = tempfile::tempdir().unwrap();
        let dest = dir.path().join("got.bin");
        let mut s = RecvState::open("n:1", "got.bin", dest.clone(), 8).unwrap();

        s.write_chunk(0, b"abcd").unwrap();
        s.write_chunk(4, b"efgh").unwrap();
        // Replayed chunk (same offset, same bytes): no growth, no corruption.
        s.write_chunk(0, b"abcd").unwrap();
        assert_eq!(s.done, 8);

        s.commit(8).unwrap();
        assert_eq!(std::fs::read(&dest).unwrap(), b"abcdefgh");

        // A replayed Done after the rename: temp is gone, final is full-length
        // → dedup, not an error.
        let mut replay = RecvState::open("n:1", "got.bin", dest.clone(), 8).unwrap();
        replay.commit(8).unwrap();
        assert_eq!(std::fs::read(&dest).unwrap(), b"abcdefgh");
    }
}


NOTE FROM THE USER: proceed with next steps.


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

//! Off-node file transfer (M4-D5c, REQ-REACH-1) — the target-side serve pump
//! and the operator-side fetch/push drivers for the [`XferRecord`] protocol.
//!
//! One transfer rides one bidi QUIC stream: the operator's daemon opens the
//! stream (D4) and sends [`XferRecord::Fetch`] (pull a file off the target)
//! or [`XferRecord::Push`] (put a file onto it); the target's
//! [`serve_xfer`] answers. Compute + files otherwise stay on the target
//! (CONTEXT §Remote-control vs local operation) — this channel is the
//! deliberate, gated exception, and the v1 surface is exactly the
//! driving-node reach-back case the Shell text+file channel later inherits.
//!
//! ## Exactly-once: positional chunks + atomic commit, never journaled sends
//!
//! Every wire send here is fire-and-forget (`op_id: None`) — a journaled
//! `net_stream_send` blocks for its ack and DISCARDS interleaved events,
//! starving a full-duplex pump (the D5b lesson, see `attach.rs` module docs).
//! Idempotency lives at the edges instead:
//!
//! - chunks carry their absolute byte `offset` — a replayed feed (a target
//!   brain restart re-serving the stream from seq 0 re-presents *everything*)
//!   rewrites the same bytes at the same positions;
//! - the receiving side writes into a temp file and commits with an atomic
//!   rename at [`XferRecord::Done`]; a replayed `Done` for an
//!   already-committed transfer dedups against the final file (present, full
//!   length) instead of failing on the missing temp.
//!
//! ## Progress is durable and queryable mid-flight (CONTEXT §file-transfer)
//!
//! Both ends rewrite their `{ bytes_done, bytes_total }` record
//! ([`spt_store::xfer`]) on every chunk, so the agent in the driven session
//! and the `spt` binary (GUI) can poll position from *outside* the busy pump
//! at any moment — and a brain crash mid-transfer leaves the last observed
//! position on disk.
//!
//! ## Gate + paths
//!
//! The first record on the stream is *unsolicited wire inbound*:
//! [`access_check`] runs under the handshake-proven origin from the broker's
//! stream table — never payload bytes (KNOWN-HAZARDS 7.5). File paths in
//! records are **root-relative**: resolved under the target's transfer root,
//! refusing absolute paths and `..` traversal — the wire never names
//! arbitrary filesystem locations.

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

use spt_net::net::xfer::{XferDecoder, XferRecord};
use spt_store::xfer::{write_progress_at, XferProgress, XferRole};

use crate::access::{access_check, AccessDecision, InboundClass};
use crate::brain::{Brain, BrokerEvent};
use crate::msg::{decode_bytes, encode_bytes};

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

/// Why a target-side serve loop returned.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum XferServeOutcome {
    /// The access gate refused the requesting origin — nothing was served.
    Refused,
    /// A `Fetch` was served to completion (`bytes` put on the wire).
    Sent { bytes: u64 },
    /// A `Push` was committed (temp → final rename durable; `bytes` written).
    Received { bytes: u64 },
    /// The transfer failed; an `Err` record was sent to the peer.
    Failed(String),
    /// The operator closed its send side before any transfer completed.
    Detached,
}

/// Resolve a wire-supplied `path` strictly under `root`: plain relative
/// components only — absolute paths, drive/UNC prefixes, and `..` are refused
/// (the wire never names arbitrary filesystem locations).
// [impl->REQ-REACH-1]
fn resolve_under(root: &Path, path: &str) -> Result<PathBuf, String> {
    let rel = Path::new(path);
    let mut out = root.to_path_buf();
    let mut any = false;
    for c in rel.components() {
        match c {
            Component::Normal(seg) => {
                out.push(seg);
                any = true;
            }
            Component::CurDir => {}
            _ => return Err(format!("refused path {path:?}: must be root-relative")),
        }
    }
    if !any {
        return Err("refused empty path".to_string());
    }
    Ok(out)
}

/// The receiving side's temp file for one transfer — sibling of the final
/// path, discriminated by xfer_id so concurrent transfers never collide.
fn temp_path(final_path: &Path, xfer_id: &str) -> PathBuf {
    let safe: String = xfer_id
        .chars()
        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
        .collect();
    let mut name = final_path.file_name().unwrap_or_default().to_os_string();
    name.push(format!(".{safe}.part"));
    final_path.with_file_name(name)
}

fn progress(xfer_id: &str, path: &str, done: u64, total: u64, state: &str) -> XferProgress {
    XferProgress {
        xfer_id: xfer_id.to_string(),
        path: path.to_string(),
        bytes_done: done,
        bytes_total: total,
        state: state.to_string(),
    }
}

/// The in-progress receive state for one `Push` (or operator-side fetch).
struct RecvState {
    xfer_id: String,
    wire_path: String,
    final_path: PathBuf,
    temp: PathBuf,
    file: Option<std::fs::File>,
    total: u64,
    done: u64,
}

impl RecvState {
    fn open(
        xfer_id: &str,
        wire_path: &str,
        final_path: PathBuf,
        total: u64,
    ) -> io::Result<RecvState> {
        if let Some(parent) = final_path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let temp = temp_path(&final_path, xfer_id);
        // No truncate: a re-served (replayed) push rewrites the same bytes at
        // the same offsets over whatever the dead life already wrote.
        let file = std::fs::OpenOptions::new().create(true).write(true).open(&temp)?;
        Ok(RecvState {
            xfer_id: xfer_id.to_string(),
            wire_path: wire_path.to_string(),
            final_path,
            temp,
            file: Some(file),
            total,
            done: 0,
        })
    }

    /// Write one chunk at its absolute offset — positionally idempotent.
    fn write_chunk(&mut self, offset: u64, bytes: &[u8]) -> io::Result<()> {
        let f = self.file.as_mut().ok_or_else(|| io::Error::other("chunk after commit"))?;
        f.seek(SeekFrom::Start(offset))?;
        f.write_all(bytes)?;
        self.done = self.done.max(offset + bytes.len() as u64);
        Ok(())
    }

    /// Commit: fsync + atomic rename temp → final. A replayed commit whose
    /// temp is already gone dedups against the final file (present, full
    /// length) — exactly-once at the boundary that matters.
    fn commit(&mut self, total: u64) -> io::Result<()> {
        if let Some(f) = self.file.take() {
            f.sync_all()?;
        }
        match std::fs::rename(&self.temp, &self.final_path) {
            Ok(()) => {}
            Err(e) => {
                let committed = std::fs::metadata(&self.final_path)
                    .map(|m| m.len() == total)
                    .unwrap_or(false);
                if !committed {
                    return Err(e);
                }
                // Replay of an already-committed transfer: dedup, drop the
                // (possibly re-created) temp.
                let _ = std::fs::remove_file(&self.temp);
            }
        }
        self.done = total;
        Ok(())
    }
}

/// Serve one transfer stream against this brain's broker, blocking until the
/// transfer completes, the operator detaches, or the gate refuses.
///
/// `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). `endpoint` names the endpoint being reached,
/// for the gate. `root` is the transfer root all wire paths resolve under;
/// `progress_dir` is where this end's progress records land
/// ([`spt_store::xfer::transfers_dir`] in production; tests inject).
/// `net_from_seq` is this brain's resume cursor on the stream (0 cold — also
/// the safe post-restart choice: the replayed feed re-gates, chunks rewrite
/// in place, and the commit dedups).
// [impl->REQ-REACH-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
pub fn serve_xfer(
    brain: &mut Brain,
    stream_id: u64,
    endpoint: &str,
    origin_node: &str,
    net_from_seq: u64,
    root: &Path,
    progress_dir: &Path,
) -> io::Result<XferServeOutcome> {
    brain.net_stream_subscribe(stream_id, net_from_seq)?;
    let mut decoder = XferDecoder::new();
    let mut recv: Option<RecvState> = None;

    // Refuse the transfer with an `Err` record + stream finish.
    let refuse = |brain: &mut Brain, xfer_id: &str, message: String| -> io::Result<XferServeOutcome> {
        let line = XferRecord::Err { xfer_id: xfer_id.to_string(), message: message.clone() }
            .encode_line();
        brain.net_stream_send(stream_id, &line, None, true)?;
        Ok(XferServeOutcome::Failed(message))
    };

    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        // ── Pull: read the file, pump it onto the wire. ────
                        XferRecord::Fetch { path, xfer_id } => {
                            // Unsolicited wire inbound — the ADR-0009 gate,
                            // under the transport-proven origin.
                            if access_check(endpoint, origin_node, InboundClass::Unsolicited)
                                == AccessDecision::Deny
                            {
                                brain.net_stream_send(stream_id, &[], None, true)?;
                                return Ok(XferServeOutcome::Refused);
                            }
                            let src = match resolve_under(root, &path) {
                                Ok(p) => p,
                                Err(m) => return refuse(brain, &xfer_id, m),
                            };
                            let mut file = match std::fs::File::open(&src) {
                                Ok(f) => f,
                                Err(e) => {
                                    return refuse(brain, &xfer_id, format!("open {path:?}: {e}"))
                                }
                            };
                            let total = file.metadata()?.len();
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Send,
                                &progress(&xfer_id, &path, 0, total, "active"),
                            );
                            let mut offset = 0u64;
                            let mut buf = vec![0u8; CHUNK_BYTES];
                            loop {
                                let n = file.read(&mut buf)?;
                                if n == 0 {
                                    break;
                                }
                                let line = XferRecord::Chunk {
                                    xfer_id: xfer_id.clone(),
                                    offset,
                                    data_b64: encode_bytes(&buf[..n]),
                                }
                                .encode_line();
                                // Fire-and-forget by design (module docs):
                                // the offset is the dedup, not a send journal.
                                brain.net_stream_send(stream_id, &line, None, false)?;
                                offset += n as u64;
                                let _ = write_progress_at(
                                    progress_dir,
                                    XferRole::Send,
                                    &progress(&xfer_id, &path, offset, total, "active"),
                                );
                            }
                            let done =
                                XferRecord::Done { xfer_id: xfer_id.clone(), total }.encode_line();
                            brain.net_stream_send(stream_id, &done, None, true)?;
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Send,
                                &progress(&xfer_id, &path, total, total, "done"),
                            );
                            return Ok(XferServeOutcome::Sent { bytes: total });
                        }

                        // ── Push: receive chunks, commit at Done. ──────────
                        XferRecord::Push { path, total, xfer_id } => {
                            if access_check(endpoint, origin_node, InboundClass::Unsolicited)
                                == AccessDecision::Deny
                            {
                                brain.net_stream_send(stream_id, &[], None, true)?;
                                return Ok(XferServeOutcome::Refused);
                            }
                            let dest = match resolve_under(root, &path) {
                                Ok(p) => p,
                                Err(m) => return refuse(brain, &xfer_id, m),
                            };
                            let state = match RecvState::open(&xfer_id, &path, dest, total) {
                                Ok(s) => s,
                                Err(e) => {
                                    return refuse(brain, &xfer_id, format!("open {path:?}: {e}"))
                                }
                            };
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Recv,
                                &progress(&xfer_id, &path, 0, total, "active"),
                            );
                            recv = Some(state);
                        }
                        XferRecord::Chunk { xfer_id, offset, data_b64 } => {
                            let Some(state) = recv.as_mut() else { continue };
                            if state.xfer_id != xfer_id {
                                continue; // not this transfer's chunk — protocol noise
                            }
                            let bytes = decode_bytes(&data_b64)
                                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                            state.write_chunk(offset, &bytes)?;
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Recv,
                                &progress(
                                    &state.xfer_id,
                                    &state.wire_path,
                                    state.done,
                                    state.total,
                                    "active",
                                ),
                            );
                        }
                        XferRecord::Done { xfer_id, total } => {
                            let Some(state) = recv.as_mut() else { continue };
                            if state.xfer_id != xfer_id {
                                continue;
                            }
                            state.commit(total)?;
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Recv,
                                &progress(&state.xfer_id, &state.wire_path, total, total, "done"),
                            );
                            // Echo the commit so the operator observes durability.
                            let echo =
                                XferRecord::Done { xfer_id, total }.encode_line();
                            brain.net_stream_send(stream_id, &echo, None, true)?;
                            return Ok(XferServeOutcome::Received { bytes: total });
                        }
                        XferRecord::Err { message, .. } => {
                            return Ok(XferServeOutcome::Failed(message));
                        }
                    }
                }
            }
            // The operator closed its send side before completing a transfer.
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                brain.net_stream_send(stream_id, &[], None, true)?;
                return Ok(XferServeOutcome::Detached);
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            // Acks and unrelated events: not ours to handle.
            _ => {}
        }
    }
}

/// Operator-side: pull `remote_path` (root-relative on the target) off the
/// target into `dest`, blocking until the file is committed locally. Returns
/// the byte count. `open_op` is the durable id for the exactly-once stream
/// open (safe to block for — no transfer traffic exists before the stream
/// does); everything after rides fire-and-forget + positional dedup.
/// Progress for this (receiving) end lands in `progress_dir` per chunk.
// [impl->REQ-REACH-1]
pub fn fetch_file(
    brain: &mut Brain,
    conn_id: u64,
    remote_path: &str,
    dest: &Path,
    xfer_id: &str,
    open_op: u64,
    progress_dir: &Path,
) -> io::Result<u64> {
    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 line = XferRecord::Fetch { path: remote_path.to_string(), xfer_id: xfer_id.to_string() }
        .encode_line();
    brain.net_stream_send(stream_id, &line, None, false)?;

    if let Some(parent) = dest.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let mut state = RecvState {
        xfer_id: xfer_id.to_string(),
        wire_path: remote_path.to_string(),
        final_path: dest.to_path_buf(),
        temp: temp_path(dest, xfer_id),
        file: Some(
            std::fs::OpenOptions::new().create(true).write(true).open(temp_path(dest, xfer_id))?,
        ),
        total: 0, // unknown until Done (chunks carry offsets, not totals)
        done: 0,
    };

    let mut decoder = XferDecoder::new();
    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        XferRecord::Chunk { xfer_id: xid, offset, data_b64 }
                            if xid == state.xfer_id =>
                        {
                            let bytes = decode_bytes(&data_b64)
                                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                            state.write_chunk(offset, &bytes)?;
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Recv,
                                &progress(xfer_id, remote_path, state.done, 0, "active"),
                            );
                        }
                        XferRecord::Done { xfer_id: xid, total } if xid == state.xfer_id => {
                            state.commit(total)?;
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Recv,
                                &progress(xfer_id, remote_path, total, total, "done"),
                            );
                            return Ok(total);
                        }
                        XferRecord::Err { message, .. } => {
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Recv,
                                &progress(xfer_id, remote_path, state.done, 0, "error"),
                            );
                            return Err(io::Error::other(format!("fetch refused: {message}")));
                        }
                        _ => {}
                    }
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                return Err(io::Error::new(
                    io::ErrorKind::UnexpectedEof,
                    "stream ended before Done",
                ));
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}

/// Operator-side: push the local file `src` onto the target at `remote_path`
/// (root-relative there), blocking until the target echoes the durable commit.
/// Returns the byte count. Same journaling posture as [`fetch_file`]: the
/// stream open is the only journaled op; chunks dedup by offset on the far
/// side. Progress for this (sending) end lands in `progress_dir` per chunk.
// [impl->REQ-REACH-1]
pub fn push_file(
    brain: &mut Brain,
    conn_id: u64,
    src: &Path,
    remote_path: &str,
    xfer_id: &str,
    open_op: u64,
    progress_dir: &Path,
) -> io::Result<u64> {
    let mut file = std::fs::File::open(src)?;
    let total = file.metadata()?.len();

    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 push = XferRecord::Push {
        path: remote_path.to_string(),
        total,
        xfer_id: xfer_id.to_string(),
    }
    .encode_line();
    brain.net_stream_send(stream_id, &push, None, false)?;
    let _ = write_progress_at(
        progress_dir,
        XferRole::Send,
        &progress(xfer_id, remote_path, 0, total, "active"),
    );

    let mut offset = 0u64;
    let mut buf = vec![0u8; CHUNK_BYTES];
    loop {
        let n = file.read(&mut buf)?;
        if n == 0 {
            break;
        }
        let line = XferRecord::Chunk {
            xfer_id: xfer_id.to_string(),
            offset,
            data_b64: encode_bytes(&buf[..n]),
        }
        .encode_line();
        brain.net_stream_send(stream_id, &line, None, false)?;
        offset += n as u64;
        let _ = write_progress_at(
            progress_dir,
            XferRole::Send,
            &progress(xfer_id, remote_path, offset, total, "active"),
        );
    }
    let done = XferRecord::Done { xfer_id: xfer_id.to_string(), total }.encode_line();
    brain.net_stream_send(stream_id, &done, None, false)?;

    // Wait for the target's echoed Done — the durable-commit observation.
    let mut decoder = XferDecoder::new();
    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        XferRecord::Done { xfer_id: xid, total: committed }
                            if xid == xfer_id && committed == total =>
                        {
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Send,
                                &progress(xfer_id, remote_path, total, total, "done"),
                            );
                            return Ok(total);
                        }
                        XferRecord::Err { message, .. } => {
                            let _ = write_progress_at(
                                progress_dir,
                                XferRole::Send,
                                &progress(xfer_id, remote_path, offset, total, "error"),
                            );
                            return Err(io::Error::other(format!("push refused: {message}")));
                        }
                        _ => {}
                    }
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                return Err(io::Error::new(
                    io::ErrorKind::UnexpectedEof,
                    "stream ended before the commit echo",
                ));
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}

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

    // [unit->REQ-REACH-1] wire paths resolve strictly under the transfer root:
    // plain relative paths land inside; absolute paths and `..` traversal are
    // refused (the wire never names arbitrary filesystem locations).
    #[test]
    fn resolve_under_confines_to_root() {
        let root = Path::new("root");
        assert_eq!(resolve_under(root, "a.txt").unwrap(), root.join("a.txt"));
        assert_eq!(
            resolve_under(root, "sub/dir/b.bin").unwrap(),
            root.join("sub").join("dir").join("b.bin")
        );
        assert_eq!(resolve_under(root, "./c.txt").unwrap(), root.join("c.txt"));

        assert!(resolve_under(root, "../escape.txt").is_err());
        assert!(resolve_under(root, "sub/../../escape.txt").is_err());
        assert!(resolve_under(root, "/etc/passwd").is_err());
        #[cfg(windows)]
        assert!(resolve_under(root, "C:\\Windows\\system32").is_err());
        assert!(resolve_under(root, "").is_err());
    }

    // [unit->REQ-REACH-1] the receive state is positionally idempotent and the
    // commit is replay-safe: rewriting chunks at the same offsets changes
    // nothing, and a second commit after the rename dedups against the final
    // file instead of failing on the missing temp.
    #[test]
    fn recv_state_chunks_idempotent_and_commit_replays_safely() {
        let dir = tempfile::tempdir().unwrap();
        let dest = dir.path().join("got.bin");
        let mut s = RecvState::open("n:1", "got.bin", dest.clone(), 8).unwrap();

        s.write_chunk(0, b"abcd").unwrap();
        s.write_chunk(4, b"efgh").unwrap();
        // Replayed chunk (same offset, same bytes): no growth, no corruption.
        s.write_chunk(0, b"abcd").unwrap();
        assert_eq!(s.done, 8);

        s.commit(8).unwrap();
        assert_eq!(std::fs::read(&dest).unwrap(), b"abcdefgh");

        // A replayed Done after the rename: temp is gone, final is full-length
        // → dedup, not an error.
        let mut replay = RecvState::open("n:1", "got.bin", dest.clone(), 8).unwrap();
        replay.commit(8).unwrap();
        assert_eq!(std::fs::read(&dest).unwrap(), b"abcdefgh");
    }
}


Bug in `commit`: a replayed `Done` with an empty re-created temp would rename it over the good final. Guard on temp completeness:


Edit

    /// Commit: fsync + atomic rename temp → final. A replayed commit whose
    /// temp is already gone dedups against the final file (present, full
    /// length) — exactly-once at the boundary that matters.
    fn commit(&mut self, total: u64) -> io::Result<()> {
        if let Some(f) = self.file.take() {
            f.sync_all()?;
        }
        match std::fs::rename(&self.temp, &self.final_path) {
            Ok(()) => {}
            Err(e) => {
                let committed = std::fs::metadata(&self.final_path)
                    .map(|m| m.len() == total)
                    .unwrap_or(false);
                if !committed {
                    return Err(e);
                }
                // Replay of an already-committed transfer: dedup, drop the
                // (possibly re-created) temp.
                let _ = std::fs::remove_file(&self.temp);
            }
        }
        self.done = total;
        Ok(())
    }

    /// Commit: fsync + atomic rename temp → final, gated on the temp actually
    /// holding all `total` bytes. A replayed commit whose temp is incomplete
    /// (a re-served push whose chunks haven't all replayed, or a temp
    /// re-created empty after the rename) dedups against an already-committed
    /// final file instead of renaming garbage over it — exactly-once at the
    /// boundary that matters.
    fn commit(&mut self, total: u64) -> io::Result<()> {
        if let Some(f) = self.file.take() {
            f.sync_all()?;
        }
        let temp_complete =
            std::fs::metadata(&self.temp).map(|m| m.len() == total).unwrap_or(false);
        if temp_complete {
            std::fs::rename(&self.temp, &self.final_path)?;
        } else {
            let committed = std::fs::metadata(&self.final_path)
                .map(|m| m.len() == total)
                .unwrap_or(false);
            if !committed {
                return Err(io::Error::other(format!(
                    "incomplete transfer {}: temp holds neither {} bytes nor a committed final",
                    self.xfer_id, total
                )));
            }
            // Replay of an already-committed transfer: dedup, drop the temp.
            let _ = std::fs::remove_file(&self.temp);
        }
        self.done = total;
        Ok(())
    }


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

    /// Commit: fsync + atomic rename temp → final. A replayed commit whose
    /// temp is already gone dedups against the final file (present, full
    /// length) — exactly-once at the boundary that matters.
    fn commit(&mut self, total: u64) -> io::Result<()> {
        if let Some(f) = self.file.take() {
            f.sync_all()?;
        }
        match std::fs::rename(&self.temp, &self.final_path) {
            Ok(()) => {}
            Err(e) => {
                let committed = std::fs::metadata(&self.final_path)
                    .map(|m| m.len() == total)
                    .unwrap_or(false);
                if !committed {
                    return Err(e);
                }
                // Replay of an already-committed transfer: dedup, drop the
                // (possibly re-created) temp.
                let _ = std::fs::remove_file(&self.temp);
            }
        }
        self.done = total;
        Ok(())
    }

    /// Commit: fsync + atomic rename temp → final, gated on the temp actually
    /// holding all `total` bytes. A replayed commit whose temp is incomplete
    /// (a re-served push whose chunks haven't all replayed, or a temp
    /// re-created empty after the rename) dedups against an already-committed
    /// final file instead of renaming garbage over it — exactly-once at the
    /// boundary that matters.
    fn commit(&mut self, total: u64) -> io::Result<()> {
        if let Some(f) = self.file.take() {
            f.sync_all()?;
        }
        let temp_complete =
            std::fs::metadata(&self.temp).map(|m| m.len() == total).unwrap_or(false);
        if temp_complete {
            std::fs::rename(&self.temp, &self.final_path)?;
        } else {
            let committed = std::fs::metadata(&self.final_path)
                .map(|m| m.len() == total)
                .unwrap_or(false);
            if !committed {
                return Err(io::Error::other(format!(
                    "incomplete transfer {}: temp holds neither {} bytes nor a committed final",
                    self.xfer_id, total
                )));
            }
            // Replay of an already-committed transfer: dedup, drop the temp.
            let _ = std::fs::remove_file(&self.temp);
        }
        self.done = total;
        Ok(())
    }

-    /// Commit: fsync + atomic rename temp → final. A replayed commit whose

-    /// temp is already gone dedups against the final file (present, full

-    /// length) — exactly-once at the boundary that matters.

+    /// Commit: fsync + atomic rename temp → final, gated on the temp actually

+    /// holding all `total` bytes. A replayed commit whose temp is incomplete

+    /// (a re-served push whose chunks haven't all replayed, or a temp

+    /// re-created empty after the rename) dedups against an already-committed

+    /// final file instead of renaming garbage over it — exactly-once at the

-        match std::fs::rename(&self.temp, &self.final_path) {

-                let committed = std::fs::metadata(&self.final_path)

-                    .map(|m| m.len() == total)

-                // Replay of an already-committed transfer: dedup, drop the

-                // (possibly re-created) temp.

-                let _ = std::fs::remove_file(&self.temp);

+            std::fs::metadata(&self.temp).map(|m| m.len() == total).unwrap_or(false);

+            std::fs::rename(&self.temp, &self.final_path)?;

+            let committed = std::fs::metadata(&self.final_path)

+                .map(|m| m.len() == total)

+                return Err(io::Error::other(format!(

+                    "incomplete transfer {}: temp holds neither {} bytes nor a committed final",

+                    self.xfer_id, total

+            // Replay of an already-committed transfer: dedup, drop the temp.

+            let _ = std::fs::remove_file(&self.temp);


Now register module + stamp driven_by in serve_attach:


100	//! (R-ARCH-2).
101	//!
102	//! [`Envelope`]: frame::Envelope
103	//! [`Hello`]: frame::Hello
104	//! [`DaemonTransport`]: transport::DaemonTransport
105
106	pub mod access;
107	pub mod adapter_update;
108	pub mod attach;
109	pub mod brain;
110	pub mod broker;
111	pub mod codec;
112	pub mod config;
113	pub mod consent;
114	pub mod daemon;
115	pub mod digest;
116	pub mod digesthub;
117	pub mod effect;
118	pub mod endpoint;
119	pub mod frame;
120	pub mod lifecycle;
121	pub mod msg;
122	pub mod nethost;
123	pub mod relay;
124	pub mod release;
125	pub mod seedmap;
126	pub mod transport;
127	pub mod update;
128	pub mod wan;
129
130	#[cfg(test)]
131	mod test_home;
132
133	pub use access::{access_check, AccessDecision, InboundClass};
134	pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
135	pub use adapter_update::{
136	    conduct_ripple, plan_adapter_update, AdapterRipple, AdapterUpdateOutcome, FilePullPayload,
137	    SkipReason,
138	};
139	pub use brain::{Brain, BrainState, BrokerEvent};
140	pub use broker::Broker;
141	pub use config::{DaemonConfig, RelayChoice};
142	pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
143	pub use daemon::{ensure_running, is_running, Daemon};
144	pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
145	pub use digesthub::{
146	    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
147	    update_to_json, DigestHub,
148	};
149	pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
150	pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
151	pub use lifecycle::{BrainLifecycle, TickReport};
152	pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
153	pub use relay::Relay;
154	pub use seedmap::{put_seed, take_seed, SeedRegistry};
155	pub use frame::{
156	    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
157	    MIN_COMPATIBLE_VERSION,
158	};
159	pub use transport::{DaemonTransport, LocalSocketTransport};
160	pub use release::{
161	    parse_verifying_key, sha256_hex, verify_artifact, verify_metadata, verify_signature,
162	    RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy,
163	};
164	pub use update::{
165	    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
166	    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
167	};

//! (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 relay;
pub mod release;
pub mod seedmap;
pub mod transport;
pub mod update;
pub mod wan;

#[cfg(test)]
mod test_home;

pub use access::{access_check, 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 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 mod update;
pub mod wan;
pub mod xfer;


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 update;
pub mod wan;
pub mod xfer;

//! `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 relay;
pub mod release;
pub mod seedmap;
pub mod transport;
pub mod update;
pub mod wan;

#[cfg(test)]
mod test_home;

pub use access::{access_check, 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 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 wan::{receive_wan, WanOutcome};

pub mod transport;
pub mod update;
pub mod wan;
+pub mod xfer;
#[cfg(test)]
mod test_home;


pub use update::{
    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
};

pub use update::{
    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
};
pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};


pub use update::{
    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
};
pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};

//! `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 relay;
pub mod release;
pub mod seedmap;
pub mod transport;
pub mod update;
pub mod wan;
pub mod xfer;

#[cfg(test)]
mod test_home;

pub use access::{access_check, 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 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 wan::{receive_wan, WanOutcome};

+pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};


Now driven_by stamping in `serve_attach`:


use std::io;

use spt_net::net::attach::{AttachDecoder, AttachRecord};
use spt_net::net::ndjson;

use crate::access::{access_check, AccessDecision, InboundClass};
use crate::brain::{Brain, BrokerEvent};
use crate::msg::{decode_bytes, encode_bytes};

use std::io;

use spt_net::net::attach::{AttachDecoder, AttachRecord};
use spt_net::net::ndjson;
use spt_store::perch::{resolve_perch_path, ParentHint};

use crate::access::{access_check, AccessDecision, InboundClass};
use crate::brain::{Brain, BrokerEvent};
use crate::msg::{decode_bytes, encode_bytes};

/// Stamp (or clear) the endpoint's `driven_by` remote-drive marker — the
/// daemon fact REQ-REACH-1 turns detection on: the agent (and the `spt`
/// binary) read it off the perch's `info.json` rather than guessing from
/// session heuristics. Best-effort: a session served without a perch (raw
/// broker tests) simply has nowhere to record it.
fn mark_driven_by(endpoint: &str, origin_node: Option<&str>) {
    let perch = resolve_perch_path(endpoint, ParentHint::Infer);
    let _ = spt_store::info::set_driven_by(&perch, origin_node);
}


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

use std::io;

use spt_net::net::attach::{AttachDecoder, AttachRecord};
use spt_net::net::ndjson;
use spt_store::perch::{resolve_perch_path, ParentHint};

use crate::access::{access_check, AccessDecision, InboundClass};
use crate::brain::{Brain, BrokerEvent};
use crate::msg::{decode_bytes, encode_bytes};

/// Stamp (or clear) the endpoint's `driven_by` remote-drive marker — the
/// daemon fact REQ-REACH-1 turns detection on: the agent (and the `spt`
/// binary) read it off the perch's `info.json` rather than guessing from
/// session heuristics. Best-effort: a session served without a perch (raw
/// broker tests) simply has nowhere to record it.
fn mark_driven_by(endpoint: &str, origin_node: Option<&str>) {
    let perch = resolve_perch_path(endpoint, ParentHint::Infer);
    let _ = spt_store::info::set_driven_by(&perch, origin_node);
}

//! Remote terminal attach (M4-D5b, REQ-INST-8) — serve a byte-stream viewport
//! onto a local session for an operator on another node.
//!
//! The operator's daemon opened a bidi QUIC stream to this node (D4) and sent
//! [`AttachRecord::Request`]; this module is the target-side pump between that
//! stream and the local broker-hosted session:
//!
//! - session `Output` chunks → [`AttachRecord::Output`] lines onto the wire
//!   (fire-and-forget sends; `Output.seq` carries the dedup authority);
//! - inbound [`AttachRecord::Input`] → [`Brain::send_effect`] with the
//!   operator-minted `op_id` — exactly-once at the PTY write through the B5
//!   journal (a replayed input record cannot double-type);
//! - session `Exit` → [`AttachRecord::Exit`] + stream finish.
//!
//! ## Exactly-once lives at the EDGES, not the wire sends
//!
//! Stream sends here are deliberately **unjournaled** (`op_id: None`): a
//! journaled [`Brain::net_stream_send`] blocks reading events until its ack
//! and *discards* everything interleaved — inside a full-duplex pump loop that
//! eats the operator's `Input` records (both sides starve; found the hard way
//! in `tests/attach.rs`). Idempotency holds without it because each direction
//! dedups at its **effect**: the PTY write is journaled by the operator-minted
//! `Input.op_id`, and the operator's viewport dedups `Output` records by their
//! session-log `seq` (the render cursor). A post-restart re-serve may
//! re-transmit output records on the wire — duplicates are dropped at the
//! render cursor, the same discipline the local `OutputLog` replay uses
//! (REQ-HAZARD-RESTART-IDEMPOTENT at the boundary that matters).
//!
//! **Compute + files stay here**; the operator is a viewport (CONTEXT
//! §Remote-control vs local operation). The access gate runs before anything
//! is served: the [`AttachRecord::Request`] is *unsolicited wire inbound*, so
//! [`access_check`] sees the **handshake-proven** origin from the broker's
//! stream table — never payload bytes (KNOWN-HAZARDS 7.5). Remote-drive of
//! your own running instance is ungated by default (ADR-0009 default-open);
//! the D5d whitelist drops in at this same call.
//!
//! Restart survival composes from D4/B5 with **no new machinery**: the broker
//! owns the QUIC stream, the session, its `OutputLog`, and the effect journal.
//! A restarted target brain re-attaches the session from its output cursor,
//! resubscribes the stream from its net cursor, and re-drives journaled sends;
//! a restarted operator brain resubscribes and dedups rendered chunks by
//! `Output.seq`.

use std::io;

use spt_net::net::attach::{AttachDecoder, AttachRecord};
use spt_net::net::ndjson;

use crate::access::{access_check, AccessDecision, InboundClass};
use crate::brain::{Brain, BrokerEvent};
use crate::msg::{decode_bytes, encode_bytes};

/// Why a serve loop returned.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttachServeOutcome {
    /// The access gate refused the requesting origin — nothing was served.
    Refused,
    /// The session's child exited; the `Exit` record was sent and the stream
    /// finished.
    Exited(Option<i32>),
    /// The operator closed its send side (detached); the viewport ended with
    /// the session still running.
    Detached,
}

/// Serve one attach stream against this brain's broker, blocking until the
/// session exits, the operator detaches, or the gate refuses.
///
/// `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). `endpoint` names the endpoint whose session
/// is being attached, for the gate. `net_from_seq` is this brain's resume
/// cursor on the attach stream (0 cold — also the safe post-restart choice:
/// the replayed `Request`/`Input` records re-gate and dedup at the PTY
/// journal, and re-pumped output dedups at the operator's render cursor).
// [impl->REQ-INST-8]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
pub fn serve_attach(
    brain: &mut Brain,
    stream_id: u64,
    endpoint: &str,
    origin_node: &str,
    net_from_seq: u64,
) -> io::Result<AttachServeOutcome> {
    brain.net_stream_subscribe(stream_id, net_from_seq)?;
    let mut decoder = AttachDecoder::new();
    let mut attached = false;

    loop {
        match brain.read_event()? {
            // ── Wire → session: requests and input. ────────────────────────
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        AttachRecord::Request { session_id, from_seq } => {
                            // Unsolicited wire inbound — the ADR-0009 gate,
                            // under the transport-proven origin.
                            if access_check(endpoint, origin_node, InboundClass::Unsolicited)
                                == AccessDecision::Deny
                            {
                                brain.net_stream_send(stream_id, &[], None, true)?;
                                return Ok(AttachServeOutcome::Refused);
                            }
                            brain.attach(session_id, from_seq)?;
                            attached = true;
                        }
                        AttachRecord::Input { data_b64, op_id } if attached => {
                            let bytes = decode_bytes(&data_b64)
                                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                            // Exactly-once at the PTY: the operator-minted id
                            // rides the B5 journal.
                            brain.send_effect(op_id, &bytes)?;
                        }
                        // Input before Request, or target-direction records
                        // echoed back: protocol noise — ignore (degrade).
                        _ => {}
                    }
                }
            }
            // The operator closed its send side: the viewport is over.
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                brain.net_stream_send(stream_id, &[], None, true)?;
                return Ok(AttachServeOutcome::Detached);
            }

            // ── Session → wire: output and exit. ────────────────────────────
            BrokerEvent::Output { seq, bytes, .. } if attached => {
                let line = ndjson::encode_line(&AttachRecord::Output {
                    seq,
                    data_b64: encode_bytes(&bytes),
                });
                // Unjournaled by design (see module docs): `seq` rides inside
                // the record, and the operator's render cursor is the dedup.
                brain.net_stream_send(stream_id, &line, None, false)?;
            }
            BrokerEvent::Exit { code, .. } if attached => {
                let line = ndjson::encode_line(&AttachRecord::Exit { code });
                brain.net_stream_send(stream_id, &line, None, true)?;
                return Ok(AttachServeOutcome::Exited(code));
            }

            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            // Applied/NetSent acks and unrelated events: not ours to handle.
            _ => {}
        }
    }
}

/// Operator-side: open an attach stream on a dialed connection and send the
/// [`AttachRecord::Request`]. Returns the stream id the caller then consumes
/// ([`AttachRecord::Output`] frames, deduped by `seq`) and writes
/// ([`AttachRecord::Input`] with operator-minted op ids). `open_op` is the
/// durable id for the exactly-once stream open (safe to block for its ack —
/// no attach traffic can be in flight before the stream exists); the Request
/// send itself is unjournaled (see module docs — a replayed Request re-gates
/// and re-attaches idempotently).
// [impl->REQ-INST-8]
pub fn request_attach(
    brain: &mut Brain,
    conn_id: u64,
    session_id: u64,
    from_seq: u64,
    open_op: u64,
) -> io::Result<u64> {
    let opened = brain.net_open_stream(conn_id, Some(open_op))?;
    let line = ndjson::encode_line(&AttachRecord::Request { session_id, from_seq });
    brain.net_stream_send(opened.stream_id, &line, None, false)?;
    Ok(opened.stream_id)
}

/// Operator-side: send one input record (the keystrokes) onto an attach
/// stream. `wire_op` is the operator-minted durable id the TARGET journals the
/// PTY write under — exactly-once at the keys, however often the record
/// crosses the wire. The send itself is unjournaled (see module docs).
// [impl->REQ-INST-8]
pub fn send_attach_input(
    brain: &mut Brain,
    stream_id: u64,
    bytes: &[u8],
    wire_op: u64,
) -> io::Result<()> {
    let line = ndjson::encode_line(&AttachRecord::Input {
        data_b64: encode_bytes(bytes),
        op_id: wire_op,
    });
    brain.net_stream_send(stream_id, &line, None, false)?;
    Ok(())
}

+/// Stamp (or clear) the endpoint's `driven_by` remote-drive marker — the

+/// daemon fact REQ-REACH-1 turns detection on: the agent (and the `spt`

+/// binary) read it off the perch's `info.json` rather than guessing from

+/// session heuristics. Best-effort: a session served without a perch (raw

+/// broker tests) simply has nowhere to record it.

+fn mark_driven_by(endpoint: &str, origin_node: Option<&str>) {

+    let perch = resolve_perch_path(endpoint, ParentHint::Infer);

+    let _ = spt_store::info::set_driven_by(&perch, origin_node);


                            brain.attach(session_id, from_seq)?;
                            attached = true;

                            brain.attach(session_id, from_seq)?;
                            attached = true;
                            // Detection is a daemon fact (REQ-REACH-1): the
                            // served endpoint is now driven from origin_node.
                            // Re-stamped by a successor's re-serve after a
                            // brain crash (the same replayed Request that
                            // re-attaches); cleared when the viewport ends.
                            // [impl->REQ-REACH-1]
                            mark_driven_by(endpoint, Some(origin_node));