Write

//! Off-node file-transfer records (M4-D5c, REQ-REACH-1) — chunked,
//! progress-queryable transfer between an instance's node and the node
//! driving it.
//!
//! Remote-control keeps compute + files on the target node (CONTEXT
//! §Remote-control vs local operation); the file channel is the deliberate,
//! addressable exception: the driving node may pull a file off the target
//! ([`XferRecord::Fetch`]) or push one onto it ([`XferRecord::Push`]). One
//! transfer rides one bidi QUIC stream (D4), framed as the shared NDJSON
//! ([`crate::net::ndjson`]) with chunk bytes base64 inside the JSON — the
//! same posture as the attach viewport, no binary fork of the codec.
//!
//! ## Idempotency is positional, not journaled (KNOWN-HAZARDS gotcha)
//!
//! Wire sends in a transfer pump are fire-and-forget — a journaled
//! `net_stream_send` blocks for its ack and discards interleaved events,
//! which starves a full-duplex pump (the D5b lesson). Exactly-once lives at
//! the edges instead: every [`XferRecord::Chunk`] carries its absolute byte
//! `offset`, so a replayed feed (a target-brain restart re-serving the stream
//! from seq 0) rewrites the same bytes at the same positions — idempotent by
//! construction — and [`XferRecord::Done`] commits via atomic
//! temp-file rename, where a replayed commit of an already-renamed transfer
//! dedups against the final file. `xfer_id` is the sender-minted durable id
//! that names the transfer end-to-end: the progress query key on both ends
//! (CONTEXT §file-transfer progress) and the temp-file discriminator.
//!
//! Like [`WanMessage`](crate::net::wanmsg::WanMessage), transfer records
//! carry **no origin field** — the access gate's subject is the QUIC
//! handshake identity from the broker's stream table (KNOWN-HAZARDS 7.5,
//! REQ-HAZARD-WAN-ORIGIN-AUTH).

use serde::{Deserialize, Serialize};

use crate::net::ndjson::{self, NdjsonDecoder};

/// One transfer-protocol record. Tagged (`kind`) so both directions share one
/// stream decoder; an unknown future kind fails the line's decode and is
/// skipped (degrade, don't die — same posture as every wire feed).
// [impl->REQ-REACH-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum XferRecord {
    /// Operator → target, first record on a pull stream: send me the file at
    /// `path` (resolved under the target's transfer root — never absolute,
    /// never traversing). `xfer_id` is the operator-minted durable transfer id.
    Fetch { path: String, xfer_id: String },
    /// Operator → target, first record on a push stream: an incoming file of
    /// `total` bytes for `path` (same root-relative rules as `Fetch`).
    Push { path: String, total: u64, xfer_id: String },
    /// One chunk of file bytes at absolute byte `offset` — positionally
    /// idempotent: a replayed chunk rewrites the same bytes in place.
    Chunk { xfer_id: String, offset: u64, data_b64: String },
    /// The sending side wrote every byte (`total` of them): commit the file
    /// (atomic temp→final rename on the receiving side). The target echoes
    /// `Done` back on a push so the operator observes the durable commit.
    Done { xfer_id: String, total: u64 },
    /// The transfer failed on the other side; nothing was committed.
    Err { xfer_id: String, message: String },
}

impl XferRecord {
    /// Encode as one NDJSON line (the JSON document + `\n`).
    pub fn encode_line(&self) -> Vec<u8> {
        ndjson::encode_line(self)
    }
}

/// The transfer stream's NDJSON reassembler — the shared
/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
/// [`XferRecord`]s. One decoder per stream side.
pub type XferDecoder = NdjsonDecoder<XferRecord>;

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

    // [unit->REQ-REACH-1] every record kind round-trips through the NDJSON
    // line encoding, and the decoder skips an unknown future kind without
    // wedging the stream (forward compat: degrade, don't die).
    #[test]
    fn records_round_trip_and_unknown_kind_is_skipped() {
        let records = vec![
            XferRecord::Fetch { path: "notes/plan.md".into(), xfer_id: "n:1".into() },
            XferRecord::Push { path: "in/drop.bin".into(), total: 9, xfer_id: "n:2".into() },
            XferRecord::Chunk { xfer_id: "n:2".into(), offset: 4096, data_b64: "aGk=".into() },
            XferRecord::Done { xfer_id: "n:2".into(), total: 9 },
            XferRecord::Err { xfer_id: "n:1".into(), message: "no such file".into() },
        ];
        let mut wire = Vec::new();
        for r in &records {
            wire.extend_from_slice(&r.encode_line());
        }
        let mut with_junk = records[0].encode_line();
        with_junk.extend_from_slice(b"{\"kind\":\"added_at_d9\",\"x\":1}\n");
        with_junk.extend_from_slice(&records[3].encode_line());

        let mut dec = XferDecoder::new();
        assert_eq!(dec.push(&wire), records, "all kinds round-trip in order");

        let mut dec = XferDecoder::new();
        assert_eq!(
            dec.push(&with_junk),
            vec![records[0].clone(), records[3].clone()],
            "unknown kind skipped, feed not wedged"
        );
    }

    // [unit->REQ-REACH-1] chunk-split safety rides the shared decoder: a
    // record split mid-bytes across pushes reassembles (QUIC chunking is not
    // record framing).
    #[test]
    fn record_split_across_chunks_reassembles() {
        let r = XferRecord::Chunk { xfer_id: "n:7".into(), offset: 0, data_b64: "c3BsaXQ=".into() };
        let line = r.encode_line();
        let mut dec = XferDecoder::new();
        assert!(dec.push(&line[..line.len() / 2]).is_empty());
        assert_eq!(dec.push(&line[line.len() / 2..]), vec![r]);
    }

    // [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] transfer records carry no origin
    // field: a forged `origin_node` (or `node`) inside the JSON decodes as an
    // ignored unknown field — nothing in the decoded record can impersonate a
    // node. The gate's subject comes from the QUIC handshake (the broker's
    // stream table), asserted end-to-end in the daemon's xfer int tests.
    #[test]
    fn forged_origin_field_is_inert() {
        let wire =
            br#"{"kind":"fetch","path":"a.txt","xfer_id":"n:1","origin_node":"evil","node":"evil"}"#;
        let decoded: XferRecord = serde_json::from_slice(wire).unwrap();
        // Every field the record actually has is accounted for — there is no
        // place for the forged origin to land.
        assert_eq!(decoded, XferRecord::Fetch { path: "a.txt".into(), xfer_id: "n:1".into() });
    }
}


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

//! Off-node file-transfer records (M4-D5c, REQ-REACH-1) — chunked,
//! progress-queryable transfer between an instance's node and the node
//! driving it.
//!
//! Remote-control keeps compute + files on the target node (CONTEXT
//! §Remote-control vs local operation); the file channel is the deliberate,
//! addressable exception: the driving node may pull a file off the target
//! ([`XferRecord::Fetch`]) or push one onto it ([`XferRecord::Push`]). One
//! transfer rides one bidi QUIC stream (D4), framed as the shared NDJSON
//! ([`crate::net::ndjson`]) with chunk bytes base64 inside the JSON — the
//! same posture as the attach viewport, no binary fork of the codec.
//!
//! ## Idempotency is positional, not journaled (KNOWN-HAZARDS gotcha)
//!
//! Wire sends in a transfer pump are fire-and-forget — a journaled
//! `net_stream_send` blocks for its ack and discards interleaved events,
//! which starves a full-duplex pump (the D5b lesson). Exactly-once lives at
//! the edges instead: every [`XferRecord::Chunk`] carries its absolute byte
//! `offset`, so a replayed feed (a target-brain restart re-serving the stream
//! from seq 0) rewrites the same bytes at the same positions — idempotent by
//! construction — and [`XferRecord::Done`] commits via atomic
//! temp-file rename, where a replayed commit of an already-renamed transfer
//! dedups against the final file. `xfer_id` is the sender-minted durable id
//! that names the transfer end-to-end: the progress query key on both ends
//! (CONTEXT §file-transfer progress) and the temp-file discriminator.
//!
//! Like [`WanMessage`](crate::net::wanmsg::WanMessage), transfer records
//! carry **no origin field** — the access gate's subject is the QUIC
//! handshake identity from the broker's stream table (KNOWN-HAZARDS 7.5,
//! REQ-HAZARD-WAN-ORIGIN-AUTH).

use serde::{Deserialize, Serialize};

use crate::net::ndjson::{self, NdjsonDecoder};

/// One transfer-protocol record. Tagged (`kind`) so both directions share one
/// stream decoder; an unknown future kind fails the line's decode and is
/// skipped (degrade, don't die — same posture as every wire feed).
// [impl->REQ-REACH-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum XferRecord {
    /// Operator → target, first record on a pull stream: send me the file at
    /// `path` (resolved under the target's transfer root — never absolute,
    /// never traversing). `xfer_id` is the operator-minted durable transfer id.
    Fetch { path: String, xfer_id: String },
    /// Operator → target, first record on a push stream: an incoming file of
    /// `total` bytes for `path` (same root-relative rules as `Fetch`).
    Push { path: String, total: u64, xfer_id: String },
    /// One chunk of file bytes at absolute byte `offset` — positionally
    /// idempotent: a replayed chunk rewrites the same bytes in place.
    Chunk { xfer_id: String, offset: u64, data_b64: String },
    /// The sending side wrote every byte (`total` of them): commit the file
    /// (atomic temp→final rename on the receiving side). The target echoes
    /// `Done` back on a push so the operator observes the durable commit.
    Done { xfer_id: String, total: u64 },
    /// The transfer failed on the other side; nothing was committed.
    Err { xfer_id: String, message: String },
}

impl XferRecord {
    /// Encode as one NDJSON line (the JSON document + `\n`).
    pub fn encode_line(&self) -> Vec<u8> {
        ndjson::encode_line(self)
    }
}

/// The transfer stream's NDJSON reassembler — the shared
/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
/// [`XferRecord`]s. One decoder per stream side.
pub type XferDecoder = NdjsonDecoder<XferRecord>;

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

    // [unit->REQ-REACH-1] every record kind round-trips through the NDJSON
    // line encoding, and the decoder skips an unknown future kind without
    // wedging the stream (forward compat: degrade, don't die).
    #[test]
    fn records_round_trip_and_unknown_kind_is_skipped() {
        let records = vec![
            XferRecord::Fetch { path: "notes/plan.md".into(), xfer_id: "n:1".into() },
            XferRecord::Push { path: "in/drop.bin".into(), total: 9, xfer_id: "n:2".into() },
            XferRecord::Chunk { xfer_id: "n:2".into(), offset: 4096, data_b64: "aGk=".into() },
            XferRecord::Done { xfer_id: "n:2".into(), total: 9 },
            XferRecord::Err { xfer_id: "n:1".into(), message: "no such file".into() },
        ];
        let mut wire = Vec::new();
        for r in &records {
            wire.extend_from_slice(&r.encode_line());
        }
        let mut with_junk = records[0].encode_line();
        with_junk.extend_from_slice(b"{\"kind\":\"added_at_d9\",\"x\":1}\n");
        with_junk.extend_from_slice(&records[3].encode_line());

        let mut dec = XferDecoder::new();
        assert_eq!(dec.push(&wire), records, "all kinds round-trip in order");

        let mut dec = XferDecoder::new();
        assert_eq!(
            dec.push(&with_junk),
            vec![records[0].clone(), records[3].clone()],
            "unknown kind skipped, feed not wedged"
        );
    }

    // [unit->REQ-REACH-1] chunk-split safety rides the shared decoder: a
    // record split mid-bytes across pushes reassembles (QUIC chunking is not
    // record framing).
    #[test]
    fn record_split_across_chunks_reassembles() {
        let r = XferRecord::Chunk { xfer_id: "n:7".into(), offset: 0, data_b64: "c3BsaXQ=".into() };
        let line = r.encode_line();
        let mut dec = XferDecoder::new();
        assert!(dec.push(&line[..line.len() / 2]).is_empty());
        assert_eq!(dec.push(&line[line.len() / 2..]), vec![r]);
    }

    // [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] transfer records carry no origin
    // field: a forged `origin_node` (or `node`) inside the JSON decodes as an
    // ignored unknown field — nothing in the decoded record can impersonate a
    // node. The gate's subject comes from the QUIC handshake (the broker's
    // stream table), asserted end-to-end in the daemon's xfer int tests.
    #[test]
    fn forged_origin_field_is_inert() {
        let wire =
            br#"{"kind":"fetch","path":"a.txt","xfer_id":"n:1","origin_node":"evil","node":"evil"}"#;
        let decoded: XferRecord = serde_json::from_slice(wire).unwrap();
        // Every field the record actually has is accounted for — there is no
        // place for the forged origin to land.
        assert_eq!(decoded, XferRecord::Fetch { path: "a.txt".into(), xfer_id: "n:1".into() });
    }
}


Bash

grep -n "pub mod\|mod " crates/spt-net/src/net.rs crates/spt-net/src/lib.rs 2>/dev/null | head

Find net module declarations


crates/spt-net/src/net.rs:8:pub mod attach;
crates/spt-net/src/net.rs:9:pub mod endpoint;
crates/spt-net/src/net.rs:10:pub mod ndjson;
crates/spt-net/src/net.rs:11:pub mod pairing;
crates/spt-net/src/net.rs:12:pub mod registry;
crates/spt-net/src/net.rs:13:pub mod replicate;
crates/spt-net/src/net.rs:14:pub mod wanmsg;
crates/spt-net/src/lib.rs:47:pub mod net;


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


1	//! The `net`-gated transport tree.
2	//!
3	//! Populated from D1 onward (see `M4-PLAN.md` / `lib.rs`): the Iroh endpoint binds
4	//! here first, then pairing, the per-subnet registry, cross-node sync, and the
5	//! update-propagation transport. A `--no-default-features` lib build excludes this
6	//! whole module (and the iroh/tokio footprint with it).
7
8	pub mod attach;
9	pub mod endpoint;
10	pub mod ndjson;
11	pub mod pairing;
12	pub mod registry;
13	pub mod replicate;
14	pub mod wanmsg;
15

//! The `net`-gated transport tree.
//!
//! Populated from D1 onward (see `M4-PLAN.md` / `lib.rs`): the Iroh endpoint binds
//! here first, then pairing, the per-subnet registry, cross-node sync, and the
//! update-propagation transport. A `--no-default-features` lib build excludes this
//! whole module (and the iroh/tokio footprint with it).

pub mod attach;
pub mod endpoint;
pub mod ndjson;
pub mod pairing;
pub mod registry;
pub mod replicate;
pub mod wanmsg;


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

//! The `net`-gated transport tree.
//!
//! Populated from D1 onward (see `M4-PLAN.md` / `lib.rs`): the Iroh endpoint binds
//! here first, then pairing, the per-subnet registry, cross-node sync, and the
//! update-propagation transport. A `--no-default-features` lib build excludes this
//! whole module (and the iroh/tokio footprint with it).

pub mod attach;
pub mod endpoint;
pub mod ndjson;
pub mod pairing;
pub mod registry;
pub mod replicate;
pub mod wanmsg;

pub mod registry;
pub mod replicate;
pub mod wanmsg;
+pub mod xfer;


cargo test -p spt-net xfer 2>&1 | tail -15

Run spt-net xfer unit tests


   Compiling spt-net v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 6.41s
     Running unittests src\lib.rs (target\debug\deps\spt_net-e23a63b12dbad81b.exe)

running 3 tests
test net::xfer::tests::forged_origin_field_is_inert ... ok
test net::xfer::tests::record_split_across_chunks_reassembles ... ok
test net::xfer::tests::records_round_trip_and_unknown_kind_is_skipped ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 76 filtered out; finished in 0.00s


NOTE FROM THE USER: proceed with next steps.


Task 1 done. Task 2: spt-store progress + driven_by.


Updated task #1 status


Updated task #2 status


1	//! `info.json` — the per-perch identity/status record at `<perch_path>/info.json`.
2	//!
3	//! Adapted from sister `types.rs` (`InfoJson` / `PidValue`) per ADR-0001:
4	//! - [`PidValue`] (untagged numeric-or-`"BUSY"`) is copied verbatim — the
5	//!   `"BUSY"` sentinel is load-bearing (a busy poll-listener writes it; numeric
6	//!   readers must treat it as "no usable pid").
7	//! - `state` is the spt-core [`EndpointType`](spt_proto::endpoint::EndpointType)
8	//!   tag string rather than the sister's `PerchState` enum (whose Spine/Touch
9	//!   kinds the spt-core taxonomy drops — see T4).
10	//!
11	//! ## Torn-read tolerance (KNOWN-HAZARDS 1.2, REQ-HAZARD-INFO-JSON-TORN-READ)
12	//! A reader may hit the file mid-write. Two layers guarantee it never observes a
13	//! truncated record:
14	//! 1. **Writers go through [`write_info`]** → [`crate::atomic::atomic_write_string`]
15	//!    (tmp + rename): a same-volume rename is atomic, so a concurrent reader
16	//!    sees the *complete* old file or the *complete* new file, never a torn one.
17	//! 2. **Readers tolerate failure**: [`read_info`] / [`read_pid`] parse via a
18	//!    bounded retry and return `None` on persistent failure — never panic.
19
20	use serde::{Deserialize, Serialize};
21	use std::path::Path;
22
23	use crate::atomic::atomic_write_string;
24
25	/// Pid field that is either a real numeric pid or the `"BUSY"` sentinel a
26	/// listener writes while it holds the perch. Untagged: serializes as a bare
27	/// integer or a bare string. Copied verbatim (sister `PidValue`).
28	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29	#[serde(untagged)]
30	pub enum PidValue {
31	    Numeric(u32),
32	    Busy(String),
33	}
34
35	/// On-disk shape of a perch's `info.json`. Field order is locked by declaration
36	/// (serde_json `preserve_order`). Optional fields are omitted when `None`.
37	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38	pub struct InfoJson {
39	    /// The perch / agent id.
40	    pub id: String,
41	    /// Creation timestamp (opaque string; producer's format).
42	    pub started: String,
43	    /// Owning process pid, or the `"BUSY"` sentinel.
44	    pub pid: PidValue,
45	    /// Harness session id bound to this perch.
46	    pub session_id: String,
47	    /// Endpoint type tag (`spt_proto::endpoint::EndpointType::as_tag`).
48	    pub state: String,
49	    #[serde(skip_serializing_if = "Option::is_none")]
50	    pub parent_pid: Option<u32>,
51	    /// Working directory the perch was created in (forward-slash normalized).
52	    #[serde(skip_serializing_if = "Option::is_none")]
53	    pub cwd: Option<String>,
54	    /// Daemon-managed liveness status (`online`/`offline`) for **daemon-hosted**
55	    /// perches (Psyche, spt-hosted Self). Present ⇒ the daemon owns this perch's
56	    /// liveness and it is read authoritatively, **never** `is_process_alive(pid)`
57	    /// (KNOWN-HAZARDS 2.5). Absent ⇒ an external/interim perch whose liveness is
58	    /// the per-pid probe. The single liveness resolver
59	    /// ([`crate::liveness::is_perch_alive`]) is the only consumer.
60	    #[serde(skip_serializing_if = "Option::is_none")]
61	    pub status: Option<String>,
62	    /// Last-active wall-clock stamp (epoch ms), refreshed by the daemon on this
63	    /// endpoint's activity (pulse tick / input / bind). The recency signal the
64	    /// consent gate resolves "most-recently-active live session" against
65	    /// (REQ-UPD-4) — a v1 precursor to PresenceChannel dispatch. Absent ⇒ never
66	    /// stamped (an interim perch, or one with no activity yet); a resolver treats
67	    /// that as least-recent.
68	    #[serde(skip_serializing_if = "Option::is_none")]
69	    pub last_active_ms: Option<u64>,
70	}
71
72	impl InfoJson {
73	    /// Construct a numeric-pid record.
74	    pub fn new(id: &str, started: &str, pid: u32, session_id: &str, state: &str) -> InfoJson {
75	        InfoJson {
76	            id: id.to_string(),
77	            started: started.to_string(),
78	            pid: PidValue::Numeric(pid),
79	            session_id: session_id.to_string(),
80	            state: state.to_string(),
81	            parent_pid: None,
82	            cwd: None,
83	            status: None,
84	            last_active_ms: None,
85	        }
86	    }
87	}
88
89	/// Set the daemon-managed `status` on an existing perch's `info.json` (the 2.5
90	/// daemon-authoritative liveness marker). Read-modify-write through the atomic
91	/// path, so a concurrent reader still sees a complete old-or-new record. Errors
92	/// if the record is absent/unreadable — the daemon only marks perches it bound.
93	// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
94	pub fn set_status(perch_path: &Path, status: &str) -> std::io::Result<()> {
95	    let mut rec = read_info(perch_path).ok_or_else(|| {
96	        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")
97	    })?;
98	    rec.status = Some(status.to_string());
99	    write_info(perch_path, &rec)
100	}
101
102	/// Stamp the `last_active_ms` recency marker on an existing perch's `info.json`
103	/// (REQ-UPD-4). Read-modify-write through the atomic path (a concurrent reader
104	/// still sees a complete old-or-new record). Errors if the record is
105	/// absent/unreadable — the daemon only stamps perches it hosts.
106	// [impl->REQ-UPD-4]
107	pub fn set_last_active(perch_path: &Path, now_ms: u64) -> std::io::Result<()> {
108	    let mut rec = read_info(perch_path).ok_or_else(|| {
109	        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")
110	    })?;
111	    rec.last_active_ms = Some(now_ms);
112	    write_info(perch_path, &rec)
113	}
114
115	/// Number of read attempts before giving up (torn-read retry).
116	const READ_ATTEMPTS: u32 = 5;
117
118	/// Atomically write `info` to `<perch_path>/info.json` (tmp + rename, EBUSY
119	/// retry via the atomic layer). The perch directory must already exist.
120	// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
121	pub fn write_info(perch_path: &Path, info: &InfoJson) -> std::io::Result<()> {
122	    let json = serde_json::to_string(info)
123	        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
124	    atomic_write_string(&perch_path.join("info.json"), &json)
125	}
126
127	/// Read and parse `<perch_path>/info.json`. Tolerant: a transient read/parse
128	/// failure (e.g. catching the file mid-replace on a filesystem where that is
129	/// observable) is retried up to [`READ_ATTEMPTS`] times; returns `None` on
130	/// persistent absence/corruption — never panics.
131	// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
132	pub fn read_info(perch_path: &Path) -> Option<InfoJson> {
133	    let path = perch_path.join("info.json");
134	    for _ in 0..READ_ATTEMPTS {
135	        if let Ok(content) = std::fs::read_to_string(&path) {
136	            if let Ok(info) = serde_json::from_str::<InfoJson>(&content) {
137	                return Some(info);
138	            }
139	        }
140	    }
141	    None
142	}
143
144	/// Read just the numeric pid, tolerantly. Routed through `serde_json::Value` so
145	/// a schema-minimal `{"pid": N}` (as wrapper-owned listeners write) parses
146	/// without the full `InfoJson` shape. Returns `None` for a missing file, the
147	/// `"BUSY"` sentinel, or any non-numeric pid. Copied semantics from sister
148	/// `get_pid_from_info`.
149	// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
150	pub fn read_pid(perch_path: &Path) -> Option<u32> {
151	    let path = perch_path.join("info.json");
152	    for _ in 0..READ_ATTEMPTS {
153	        if let Ok(content) = std::fs::read_to_string(&path) {
154	            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
155	                // `as_u64` is None for the "BUSY" string sentinel → None pid.
156	                return val.get("pid").and_then(|v| v.as_u64()).map(|n| n as u32);
157	            }
158	        }
159	    }
160	    None
161	}
162
163	#[cfg(test)]
164	mod tests {
165	    use super::*;
166	    use std::sync::atomic::{AtomicBool, Ordering};
167	    use std::sync::Arc;
168
169	    fn perch() -> tempfile::TempDir {
170	        tempfile::tempdir().unwrap()
171	    }
172
173	    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] write → read round-trip; optional
174	    // fields omitted when None.
175	    #[test]
176	    fn write_read_round_trip() {
177	        let d = perch();
178	        let mut info = InfoJson::new("doyle", "2026-05-31T00:00:00Z", 4242, "sid-1", "live_agent");
179	        info.cwd = Some("C:/repo/spt-core".to_string());
180	        write_info(d.path(), &info).unwrap();
181
182	        let got = read_info(d.path()).expect("reads back");
183	        assert_eq!(got, info);
184
185	        // parent_pid omitted (None) → absent from JSON.
186	        let raw = std::fs::read_to_string(d.path().join("info.json")).unwrap();
187	        assert!(!raw.contains("parent_pid"), "None field must be omitted: {raw}");
188	        assert!(raw.contains("\"state\":\"live_agent\""));
189	    }
190
191	    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] BUSY sentinel: full parse keeps it;
192	    // read_pid yields None (no usable pid).
193	    #[test]
194	    fn busy_sentinel_round_trips_and_read_pid_is_none() {
195	        let d = perch();
196	        let info = InfoJson {
197	            id: "doyle".to_string(),
198	            started: "t".to_string(),
199	            pid: PidValue::Busy("BUSY".to_string()),
200	            session_id: "sid".to_string(),
201	            state: "ready_agent".to_string(),
202	            parent_pid: None,
203	            cwd: None,
204	            status: None,
205	            last_active_ms: None,
206	        };
207	        write_info(d.path(), &info).unwrap();
208	        assert_eq!(read_info(d.path()).unwrap().pid, PidValue::Busy("BUSY".to_string()));
209	        assert_eq!(read_pid(d.path()), None, "BUSY must yield no numeric pid");
210	    }
211
212	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] set_status stamps the daemon
213	    // liveness marker onto an existing record; absent by default (interim
214	    // perches carry no status); set_status on an absent record errors.
215	    #[test]
216	    fn set_status_marks_existing_record() {
217	        let d = perch();
218	        write_info(d.path(), &InfoJson::new("doyle", "t", 4242, "sid", "live_agent")).unwrap();
219	        // Interim record: no status field serialized.
220	        let raw = std::fs::read_to_string(d.path().join("info.json")).unwrap();
221	        assert!(!raw.contains("status"), "interim perch carries no status: {raw}");
222
223	        set_status(d.path(), "online").unwrap();
224	        assert_eq!(read_info(d.path()).unwrap().status.as_deref(), Some("online"));
225	        // Re-mark offline.
226	        set_status(d.path(), "offline").unwrap();
227	        assert_eq!(read_info(d.path()).unwrap().status.as_deref(), Some("offline"));
228
229	        // No record to mark → error, never a panic / silent write.
230	        let empty = perch();
231	        assert!(set_status(empty.path(), "online").is_err());
232	    }
233
234	    // [unit->REQ-UPD-4] set_last_active stamps the recency marker on an existing
235	    // record (absent by default), and errors on a missing record.
236	    #[test]
237	    fn set_last_active_stamps_recency() {
238	        let d = perch();
239	        write_info(d.path(), &InfoJson::new("doyle", "t", 4242, "sid", "live_agent")).unwrap();
240	        assert_eq!(read_info(d.path()).unwrap().last_active_ms, None, "absent by default");
241
242	        set_last_active(d.path(), 1234).unwrap();
243	        assert_eq!(read_info(d.path()).unwrap().last_active_ms, Some(1234));
244	        // A later stamp overwrites.
245	        set_last_active(d.path(), 5678).unwrap();
246	        assert_eq!(read_info(d.path()).unwrap().last_active_ms, Some(5678));
247
248	        // No record → error, never a silent write.
249	        let empty = perch();
250	        assert!(set_last_active(empty.path(), 1).is_err());
251	    }
252
253	    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] read_pid tolerates a schema-minimal
254	    // {"pid": N} record (wrapper-owned listener shape).
255	    #[test]
256	    fn read_pid_tolerates_schema_minimal() {
257	        let d = perch();
258	        atomic_write_string(&d.path().join("info.json"), r#"{"pid": 1234}"#).unwrap();
259	        assert_eq!(read_pid(d.path()), Some(1234));
260	        // But the full InfoJson parse fails on the minimal shape → None, no panic.
261	        assert_eq!(read_info(d.path()), None);
262	    }
263
264	    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] corrupt / truncated content is
265	    // tolerated: None, never a panic.
266	    #[test]
267	    fn corrupt_content_returns_none() {
268	        let d = perch();
269	        std::fs::write(d.path().join("info.json"), "{ \"id\": \"doyle\", \"pi").unwrap();
270	        assert_eq!(read_info(d.path()), None);
271	        assert_eq!(read_pid(d.path()), None);
272	        // Missing file too.
273	        let empty = perch();
274	        assert_eq!(read_info(empty.path()), None);
275	    }
276
277	    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] under a concurrent writer
278	    // continuously rewriting info.json, every successful read is a COMPLETE,
279	    // valid record — never a torn parse. Atomic rename guarantees old-or-new.
280	    #[test]
281	    fn concurrent_read_during_writes_never_torn() {
282	        let d = perch();
283	        let path = Arc::new(d.path().to_path_buf());
284	        // Seed a valid file so early readers have something.
285	        write_info(&path, &InfoJson::new("doyle", "t", 1, "sid", "live_agent")).unwrap();
286
287	        let stop = Arc::new(AtomicBool::new(false));
288	        let writer_path = Arc::clone(&path);
289	        let writer_stop = Arc::clone(&stop);
290	        let writer = std::thread::spawn(move || {
291	            let mut n = 0u32;
292	            while !writer_stop.load(Ordering::Relaxed) {
293	                n = n.wrapping_add(1);
294	                // Vary length (long cwd) so a torn read would be detectable.
295	                let mut info = InfoJson::new("doyle", "t", n, "sid", "live_agent");
296	                info.cwd = Some("x".repeat((n % 500) as usize));
297	                let _ = write_info(&writer_path, &info);
298	            }
299	        });
300
301	        // Hammer reads; each must be None or a valid record with id == "doyle".
302	        let mut valid_reads = 0;
303	        for _ in 0..3000 {
304	            if let Some(info) = read_info(&path) {
305	                assert_eq!(info.id, "doyle", "a read must never be torn/garbage");
306	                valid_reads += 1;
307	            }
308	        }
309	        stop.store(true, Ordering::Relaxed);
310	        writer.join().unwrap();
311	        assert!(valid_reads > 0, "should have observed some complete reads");
312	    }
313	}
314

//! `info.json` — the per-perch identity/status record at `<perch_path>/info.json`.
//!
//! Adapted from sister `types.rs` (`InfoJson` / `PidValue`) per ADR-0001:
//! - [`PidValue`] (untagged numeric-or-`"BUSY"`) is copied verbatim — the
//!   `"BUSY"` sentinel is load-bearing (a busy poll-listener writes it; numeric
//!   readers must treat it as "no usable pid").
//! - `state` is the spt-core [`EndpointType`](spt_proto::endpoint::EndpointType)
//!   tag string rather than the sister's `PerchState` enum (whose Spine/Touch
//!   kinds the spt-core taxonomy drops — see T4).
//!
//! ## Torn-read tolerance (KNOWN-HAZARDS 1.2, REQ-HAZARD-INFO-JSON-TORN-READ)
//! A reader may hit the file mid-write. Two layers guarantee it never observes a
//! truncated record:
//! 1. **Writers go through [`write_info`]** → [`crate::atomic::atomic_write_string`]
//!    (tmp + rename): a same-volume rename is atomic, so a concurrent reader
//!    sees the *complete* old file or the *complete* new file, never a torn one.
//! 2. **Readers tolerate failure**: [`read_info`] / [`read_pid`] parse via a
//!    bounded retry and return `None` on persistent failure — never panic.

use serde::{Deserialize, Serialize};
use std::path::Path;

use crate::atomic::atomic_write_string;

/// Pid field that is either a real numeric pid or the `"BUSY"` sentinel a
/// listener writes while it holds the perch. Untagged: serializes as a bare
/// integer or a bare string. Copied verbatim (sister `PidValue`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PidValue {
    Numeric(u32),
    Busy(String),
}

/// On-disk shape of a perch's `info.json`. Field order is locked by declaration
/// (serde_json `preserve_order`). Optional fields are omitted when `None`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InfoJson {
    /// The perch / agent id.
    pub id: String,
    /// Creation timestamp (opaque string; producer's format).
    pub started: String,
    /// Owning process pid, or the `"BUSY"` sentinel.
    pub pid: PidValue,
    /// Harness session id bound to this perch.
    pub session_id: String,
    /// Endpoint type tag (`spt_proto::endpoint::EndpointType::as_tag`).
    pub state: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_pid: Option<u32>,
    /// Working directory the perch was created in (forward-slash normalized).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    /// Daemon-managed liveness status (`online`/`offline`) for **daemon-hosted**
    /// perches (Psyche, spt-hosted Self). Present ⇒ the daemon owns this perch's
    /// liveness and it is read authoritatively, **never** `is_process_alive(pid)`
    /// (KNOWN-HAZARDS 2.5). Absent ⇒ an external/interim perch whose liveness is
    /// the per-pid probe. The single liveness resolver
    /// ([`crate::liveness::is_perch_alive`]) is the only consumer.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
    /// Last-active wall-clock stamp (epoch ms), refreshed by the daemon on this
    /// endpoint's activity (pulse tick / input / bind). The recency signal the
    /// consent gate resolves "most-recently-active live session" against
    /// (REQ-UPD-4) — a v1 precursor to PresenceChannel dispatch. Absent ⇒ never
    /// stamped (an interim perch, or one with no activity yet); a resolver treats
    /// that as least-recent.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_active_ms: Option<u64>,
}

impl InfoJson {
    /// Construct a numeric-pid record.
    pub fn new(id: &str, started: &str, pid: u32, session_id: &str, state: &str) -> InfoJson {
        InfoJson {
            id: id.to_string(),
            started: started.to_string(),
            pid: PidValue::Numeric(pid),
            session_id: session_id.to_string(),
            state: state.to_string(),
            parent_pid: None,
            cwd: None,
            status: None,
            last_active_ms: None,
        }
    }
}

/// Set the daemon-managed `status` on an existing perch's `info.json` (the 2.5
/// daemon-authoritative liveness marker). Read-modify-write through the atomic
/// path, so a concurrent reader still sees a complete old-or-new record. Errors
/// if the record is absent/unreadable — the daemon only marks perches it bound.
// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
pub fn set_status(perch_path: &Path, status: &str) -> std::io::Result<()> {
    let mut rec = read_info(perch_path).ok_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")
    })?;
    rec.status = Some(status.to_string());
    write_info(perch_path, &rec)
}

/// Stamp the `last_active_ms` recency marker on an existing perch's `info.json`
/// (REQ-UPD-4). Read-modify-write through the atomic path (a concurrent reader
/// still sees a complete old-or-new record). Errors if the record is
/// absent/unreadable — the daemon only stamps perches it hosts.
// [impl->REQ-UPD-4]
pub fn set_last_active(perch_path: &Path, now_ms: u64) -> std::io::Result<()> {
    let mut rec = read_info(perch_path).ok_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")
    })?;
    rec.last_active_ms = Some(now_ms);
    write_info(perch_path, &rec)
}

/// Number of read attempts before giving up (torn-read retry).
const READ_ATTEMPTS: u32 = 5;

/// Atomically write `info` to `<perch_path>/info.json` (tmp + rename, EBUSY
/// retry via the atomic layer). The perch directory must already exist.
// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
pub fn write_info(perch_path: &Path, info: &InfoJson) -> std::io::Result<()> {
    let json = serde_json::to_string(info)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    atomic_write_string(&perch_path.join("info.json"), &json)
}

/// Read and parse `<perch_path>/info.json`. Tolerant: a transient read/parse
/// failure (e.g. catching the file mid-replace on a filesystem where that is
/// observable) is retried up to [`READ_ATTEMPTS`] times; returns `None` on
/// persistent absence/corruption — never panics.
// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
pub fn read_info(perch_path: &Path) -> Option<InfoJson> {
    let path = perch_path.join("info.json");
    for _ in 0..READ_ATTEMPTS {
        if let Ok(content) = std::fs::read_to_string(&path) {
            if let Ok(info) = serde_json::from_str::<InfoJson>(&content) {
                return Some(info);
            }
        }
    }
    None
}

/// Read just the numeric pid, tolerantly. Routed through `serde_json::Value` so
/// a schema-minimal `{"pid": N}` (as wrapper-owned listeners write) parses
/// without the full `InfoJson` shape. Returns `None` for a missing file, the
/// `"BUSY"` sentinel, or any non-numeric pid. Copied semantics from sister
/// `get_pid_from_info`.
// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
pub fn read_pid(perch_path: &Path) -> Option<u32> {
    let path = perch_path.join("info.json");
    for _ in 0..READ_ATTEMPTS {
        if let Ok(content) = std::fs::read_to_string(&path) {
            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
                // `as_u64` is None for the "BUSY" string sentinel → None pid.
                return val.get("pid").and_then(|v| v.as_u64()).map(|n| n as u32);
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicBool, Ordering};
    use std::sync::Arc;

    fn perch() -> tempfile::TempDir {
        tempfile::tempdir().unwrap()
    }

    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] write → read round-trip; optional
    // fields omitted when None.
    #[test]
    fn write_read_round_trip() {
        let d = perch();
        let mut info = InfoJson::new("doyle", "2026-05-31T00:00:00Z", 4242, "sid-1", "live_agent");
        info.cwd = Some("C:/repo/spt-core".to_string());
        write_info(d.path(), &info).unwrap();

        let got = read_info(d.path()).expect("reads back");
        assert_eq!(got, info);

        // parent_pid omitted (None) → absent from JSON.
        let raw = std::fs::read_to_string(d.path().join("info.json")).unwrap();
        assert!(!raw.contains("parent_pid"), "None field must be omitted: {raw}");
        assert!(raw.contains("\"state\":\"live_agent\""));
    }

    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] BUSY sentinel: full parse keeps it;
    // read_pid yields None (no usable pid).
    #[test]
    fn busy_sentinel_round_trips_and_read_pid_is_none() {
        let d = perch();
        let info = InfoJson {
            id: "doyle".to_string(),
            started: "t".to_string(),
            pid: PidValue::Busy("BUSY".to_string()),
            session_id: "sid".to_string(),
            state: "ready_agent".to_string(),
            parent_pid: None,
            cwd: None,
            status: None,
            last_active_ms: None,
        };
        write_info(d.path(), &info).unwrap();
        assert_eq!(read_info(d.path()).unwrap().pid, PidValue::Busy("BUSY".to_string()));
        assert_eq!(read_pid(d.path()), None, "BUSY must yield no numeric pid");
    }

    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] set_status stamps the daemon
    // liveness marker onto an existing record; absent by default (interim
    // perches carry no status); set_status on an absent record errors.
    #[test]
    fn set_status_marks_existing_record() {
        let d = perch();
        write_info(d.path(), &InfoJson::new("doyle", "t", 4242, "sid", "live_agent")).unwrap();
        // Interim record: no status field serialized.
        let raw = std::fs::read_to_string(d.path().join("info.json")).unwrap();
        assert!(!raw.contains("status"), "interim perch carries no status: {raw}");

        set_status(d.path(), "online").unwrap();
        assert_eq!(read_info(d.path()).unwrap().status.as_deref(), Some("online"));
        // Re-mark offline.
        set_status(d.path(), "offline").unwrap();
        assert_eq!(read_info(d.path()).unwrap().status.as_deref(), Some("offline"));

        // No record to mark → error, never a panic / silent write.
        let empty = perch();
        assert!(set_status(empty.path(), "online").is_err());
    }

    // [unit->REQ-UPD-4] set_last_active stamps the recency marker on an existing
    // record (absent by default), and errors on a missing record.
    #[test]
    fn set_last_active_stamps_recency() {
        let d = perch();
        write_info(d.path(), &InfoJson::new("doyle", "t", 4242, "sid", "live_agent")).unwrap();
        assert_eq!(read_info(d.path()).unwrap().last_active_ms, None, "absent by default");

        set_last_active(d.path(), 1234).unwrap();
        assert_eq!(read_info(d.path()).unwrap().last_active_ms, Some(1234));
        // A later stamp overwrites.
        set_last_active(d.path(), 5678).unwrap();
        assert_eq!(read_info(d.path()).unwrap().last_active_ms, Some(5678));

        // No record → error, never a silent write.
        let empty = perch();
        assert!(set_last_active(empty.path(), 1).is_err());
    }

    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] read_pid tolerates a schema-minimal
    // {"pid": N} record (wrapper-owned listener shape).
    #[test]
    fn read_pid_tolerates_schema_minimal() {
        let d = perch();
        atomic_write_string(&d.path().join("info.json"), r#"{"pid": 1234}"#).unwrap();
        assert_eq!(read_pid(d.path()), Some(1234));
        // But the full InfoJson parse fails on the minimal shape → None, no panic.
        assert_eq!(read_info(d.path()), None);
    }

    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] corrupt / truncated content is
    // tolerated: None, never a panic.
    #[test]
    fn corrupt_content_returns_none() {
        let d = perch();
        std::fs::write(d.path().join("info.json"), "{ \"id\": \"doyle\", \"pi").unwrap();
        assert_eq!(read_info(d.path()), None);
        assert_eq!(read_pid(d.path()), None);
        // Missing file too.
        let empty = perch();
        assert_eq!(read_info(empty.path()), None);
    }

    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] under a concurrent writer
    // continuously rewriting info.json, every successful read is a COMPLETE,
    // valid record — never a torn parse. Atomic rename guarantees old-or-new.
    #[test]
    fn concurrent_read_during_writes_never_torn() {
        let d = perch();
        let path = Arc::new(d.path().to_path_buf());
        // Seed a valid file so early readers have something.
        write_info(&path, &InfoJson::new("doyle", "t", 1, "sid", "live_agent")).unwrap();

        let stop = Arc::new(AtomicBool::new(false));
        let writer_path = Arc::clone(&path);
        let writer_stop = Arc::clone(&stop);
        let writer = std::thread::spawn(move || {
            let mut n = 0u32;
            while !writer_stop.load(Ordering::Relaxed) {
                n = n.wrapping_add(1);
                // Vary length (long cwd) so a torn read would be detectable.
                let mut info = InfoJson::new("doyle", "t", n, "sid", "live_agent");
                info.cwd = Some("x".repeat((n % 500) as usize));
                let _ = write_info(&writer_path, &info);
            }
        });

        // Hammer reads; each must be None or a valid record with id == "doyle".
        let mut valid_reads = 0;
        for _ in 0..3000 {
            if let Some(info) = read_info(&path) {
                assert_eq!(info.id, "doyle", "a read must never be torn/garbage");
                valid_reads += 1;
            }
        }
        stop.store(true, Ordering::Relaxed);
        writer.join().unwrap();
        assert!(valid_reads > 0, "should have observed some complete reads");
    }
}


Edit

    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_active_ms: Option<u64>,
}

    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_active_ms: Option<u64>,
    /// The node currently remote-driving this endpoint's session (hex node id),
    /// stamped by the daemon when it serves a net-attached surface and cleared
    /// when the viewport ends (REQ-REACH-1: detection is a daemon fact, not an
    /// agent guess). Absent ⇒ locally driven (or never net-attached). `default`
    /// so a pre-D5c record (no field) still parses.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub driven_by: Option<String>,
}


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

    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_active_ms: Option<u64>,
}

    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_active_ms: Option<u64>,
    /// The node currently remote-driving this endpoint's session (hex node id),
    /// stamped by the daemon when it serves a net-attached surface and cleared
    /// when the viewport ends (REQ-REACH-1: detection is a daemon fact, not an
    /// agent guess). Absent ⇒ locally driven (or never net-attached). `default`
    /// so a pre-D5c record (no field) still parses.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub driven_by: Option<String>,
}

+    /// The node currently remote-driving this endpoint's session (hex node id),

+    /// stamped by the daemon when it serves a net-attached surface and cleared

+    /// when the viewport ends (REQ-REACH-1: detection is a daemon fact, not an

+    /// agent guess). Absent ⇒ locally driven (or never net-attached). `default`

+    /// so a pre-D5c record (no field) still parses.

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


            status: None,
            last_active_ms: None,
        }
    }
}

            status: None,
            last_active_ms: None,
            driven_by: None,
        }
    }
}


            status: None,
            last_active_ms: None,
        }
    }
}

            status: None,
            last_active_ms: None,
            driven_by: None,
        }
    }
}

cwd: None,
status: None,
last_active_ms: None,
+            driven_by: None,
}
}
}


    rec.last_active_ms = Some(now_ms);
    write_info(perch_path, &rec)
}

    rec.last_active_ms = Some(now_ms);
    write_info(perch_path, &rec)
}

/// Stamp (or clear, with `None`) the `driven_by` remote-drive marker on an
/// existing perch's `info.json` (REQ-REACH-1): the daemon records which node's
/// operator is driving this endpoint's session while it serves a net-attached
/// surface. Read-modify-write through the atomic path (a concurrent reader
/// still sees a complete old-or-new record). Errors if the record is
/// absent/unreadable — the daemon only stamps perches that exist.
// [impl->REQ-REACH-1]
pub fn set_driven_by(perch_path: &Path, node: Option<&str>) -> std::io::Result<()> {
    let mut rec = read_info(perch_path).ok_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")
    })?;
    rec.driven_by = node.map(str::to_string);
    write_info(perch_path, &rec)
}


    rec.last_active_ms = Some(now_ms);
    write_info(perch_path, &rec)
}

/// Stamp (or clear, with `None`) the `driven_by` remote-drive marker on an
/// existing perch's `info.json` (REQ-REACH-1): the daemon records which node's
/// operator is driving this endpoint's session while it serves a net-attached
/// surface. Read-modify-write through the atomic path (a concurrent reader
/// still sees a complete old-or-new record). Errors if the record is
/// absent/unreadable — the daemon only stamps perches that exist.
// [impl->REQ-REACH-1]
pub fn set_driven_by(perch_path: &Path, node: Option<&str>) -> std::io::Result<()> {
    let mut rec = read_info(perch_path).ok_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")
    })?;
    rec.driven_by = node.map(str::to_string);
    write_info(perch_path, &rec)
}

+/// Stamp (or clear, with `None`) the `driven_by` remote-drive marker on an

+/// existing perch's `info.json` (REQ-REACH-1): the daemon records which node's

+/// operator is driving this endpoint's session while it serves a net-attached

+/// surface. Read-modify-write through the atomic path (a concurrent reader

+/// still sees a complete old-or-new record). Errors if the record is

+/// absent/unreadable — the daemon only stamps perches that exist.

+pub fn set_driven_by(perch_path: &Path, node: Option<&str>) -> std::io::Result<()> {

+    let mut rec = read_info(perch_path).ok_or_else(|| {

+        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")

+    rec.driven_by = node.map(str::to_string);

/// Number of read attempts before giving up (torn-read retry).


            parent_pid: None,
            cwd: None,
            status: None,
            last_active_ms: None,
        };
        write_info(d.path(), &info).unwrap();

            parent_pid: None,
            cwd: None,
            status: None,
            last_active_ms: None,
            driven_by: None,
        };
        write_info(d.path(), &info).unwrap();


            parent_pid: None,
            cwd: None,
            status: None,
            last_active_ms: None,
        };
        write_info(d.path(), &info).unwrap();

            parent_pid: None,
            cwd: None,
            status: None,
            last_active_ms: None,
            driven_by: None,
        };
        write_info(d.path(), &info).unwrap();


Add unit test for `set_driven_by` + pre-D5c record tolerance:


// [unit->REQ-HAZARD-INFO-JSON-TORN-READ] read_pid tolerates a schema-minimal

    // [unit->REQ-REACH-1] set_driven_by stamps the remote-drive marker onto an
    // existing record (absent by default = locally driven), clears it with
    // None, errors on a missing record — and a pre-D5c record (no field at
    // all) still parses with driven_by == None.
    #[test]
    fn set_driven_by_stamps_and_clears() {
        let d = perch();
        write_info(d.path(), &InfoJson::new("ling", "t", 4242, "sid", "live_agent")).unwrap();
        let raw = std::fs::read_to_string(d.path().join("info.json")).unwrap();
        assert!(!raw.contains("driven_by"), "locally driven perch carries no marker: {raw}");

        set_driven_by(d.path(), Some("cafe1234")).unwrap();
        assert_eq!(read_info(d.path()).unwrap().driven_by.as_deref(), Some("cafe1234"));

        set_driven_by(d.path(), None).unwrap();
        assert_eq!(read_info(d.path()).unwrap().driven_by, None, "viewport over → cleared");

        // No record → error, never a silent write.
        let empty = perch();
        assert!(set_driven_by(empty.path(), Some("cafe1234")).is_err());

        // Forward tolerance: a pre-D5c record without the field parses.
        let old = perch();
        atomic_write_string(
            &old.path().join("info.json"),
            r#"{"id":"ling","started":"t","pid":1,"session_id":"sid","state":"live_agent"}"#,
        )
        .unwrap();
        assert_eq!(read_info(old.path()).unwrap().driven_by, None);
    }

    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] read_pid tolerates a schema-minimal


    // [unit->REQ-REACH-1] set_driven_by stamps the remote-drive marker onto an
    // existing record (absent by default = locally driven), clears it with
    // None, errors on a missing record — and a pre-D5c record (no field at
    // all) still parses with driven_by == None.
    #[test]
    fn set_driven_by_stamps_and_clears() {
        let d = perch();
        write_info(d.path(), &InfoJson::new("ling", "t", 4242, "sid", "live_agent")).unwrap();
        let raw = std::fs::read_to_string(d.path().join("info.json")).unwrap();
        assert!(!raw.contains("driven_by"), "locally driven perch carries no marker: {raw}");

        set_driven_by(d.path(), Some("cafe1234")).unwrap();
        assert_eq!(read_info(d.path()).unwrap().driven_by.as_deref(), Some("cafe1234"));

        set_driven_by(d.path(), None).unwrap();
        assert_eq!(read_info(d.path()).unwrap().driven_by, None, "viewport over → cleared");

        // No record → error, never a silent write.
        let empty = perch();
        assert!(set_driven_by(empty.path(), Some("cafe1234")).is_err());

        // Forward tolerance: a pre-D5c record without the field parses.
        let old = perch();
        atomic_write_string(
            &old.path().join("info.json"),
            r#"{"id":"ling","started":"t","pid":1,"session_id":"sid","state":"live_agent"}"#,
        )
        .unwrap();
        assert_eq!(read_info(old.path()).unwrap().driven_by, None);
    }

    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] read_pid tolerates a schema-minimal

+    // [unit->REQ-REACH-1] set_driven_by stamps the remote-drive marker onto an

+    // existing record (absent by default = locally driven), clears it with

+    // None, errors on a missing record — and a pre-D5c record (no field at

+    // all) still parses with driven_by == None.

+    fn set_driven_by_stamps_and_clears() {

+        write_info(d.path(), &InfoJson::new("ling", "t", 4242, "sid", "live_agent")).unwrap();

+        let raw = std::fs::read_to_string(d.path().join("info.json")).unwrap();

+        assert!(!raw.contains("driven_by"), "locally driven perch carries no marker: {raw}");

+        set_driven_by(d.path(), Some("cafe1234")).unwrap();

+        assert_eq!(read_info(d.path()).unwrap().driven_by.as_deref(), Some("cafe1234"));

+        set_driven_by(d.path(), None).unwrap();

+        assert_eq!(read_info(d.path()).unwrap().driven_by, None, "viewport over → cleared");

+        // No record → error, never a silent write.

+        assert!(set_driven_by(empty.path(), Some("cafe1234")).is_err());

+        // Forward tolerance: a pre-D5c record without the field parses.

+            &old.path().join("info.json"),

+            r#"{"id":"ling","started":"t","pid":1,"session_id":"sid","state":"live_agent"}"#,

+        assert_eq!(read_info(old.path()).unwrap().driven_by, None);

// {"pid": N} record (wrapper-owned listener shape).


crates\spt-store\src\info.rs:91:            last_active_ms: None,
crates\spt-store\src\info.rs:228:            last_active_ms: None,


Now progress store. Check `spt_home` + atomic write signature:


Bash

grep -n "pub fn spt_home\|pub fn owlery_dir" crates/spt-store/src/perch.rs && grep -n "^pub mod\|^mod" crates/spt-store/src/lib.rs

Check store module list and home fn


34:pub fn spt_home() -> PathBuf {
62:pub fn owlery_dir() -> PathBuf {
14:pub mod atomic;
15:mod db;
16:pub mod epoch;
17:pub mod history;
18:pub mod info;
19:pub mod liveness;
20:pub mod nodeid;
21:pub mod perch;
22:pub mod proc;
23:pub mod registry;
24:pub mod rename;
25:pub mod seed;
26:pub mod spool;
27:pub mod subnet;
28:pub mod trust;
29:pub mod visibility;


D5c: wire records — spt-net xfer.rs (NDJSON, no origin field)

XferRecord {Fetch|Push|Chunk(offset)|Done|Err} + XferDecoder, unknown-kind skip, unit tests (round-trip, unknown-field tolerance, no-origin-field hazard test)

D5c: progress store + driven_by field — spt-store

xfer progress files {bytes_done,bytes_total,state} per (xfer_id, role) under spt_home/transfers, atomic writes, read helper; InfoJson.driven_by + set_driven_by; unit tests

D5c: daemon pumps — serve_xfer/fetch_file/push_file + driven_by stamping in serve_attach

spt-daemon/src/xfer.rs: target serve (gated, offset-idempotent writes, temp+rename, progress per chunk, fire-and-forget sends only); operator fetch/push; serve_attach stamps/clears driven_by on endpoint perch

D5c: api expose driven_by

Query surface so agent can detect "driven by node X" via spt api

D5c: int tests — fetch cross-daemon + push survives brain restart + detection

tests/xfer.rs (fetch w/ mid-flight progress; crash mid-push → re-serve seq 0 → byte-identical); attach.rs detection test (driven_by set on attach, cleared on detach)

D5c: docs + activation — handoff seam shape, FAULT-MATRIX row, REQ-REACH-1 [impl,unit]

Handoff seam (git pull + fresh-with-preload, remote dormant) doc'd as D6-consumed shape; FAULT-MATRIX row 13; traceable-reqs.toml activation; traceable-reqs check green; push + CI watch


Write

//! File-transfer progress records (M4-D5c, REQ-REACH-1) — the durable,
//! mid-flight-queryable `{ bytes_done, bytes_total }` both ends of an off-node
//! transfer keep (CONTEXT §file-transfer progress).
//!
//! Each transfer end (the byte **sender** and the byte **receiver** — every
//! transfer has exactly one of each, whichever node initiated) maintains one
//! small JSON file under `<spt_home>/transfers/`, rewritten atomically per
//! chunk by the pump that owns that end. Durable files — not an in-memory
//! table — because the queryers are *other processes*: the agent inside the
//! driven session and the `spt` binary (GUI) both read progress while the
//! daemon's pump is busy mid-transfer, and a brain restart mid-transfer must
//! not lose the last observed position. The same torn-read posture as
//! `info.json` (KNOWN-HAZARDS 1.2): atomic tmp+rename writes, tolerant reads.
//!
//! Keyed by `(xfer_id, role)`: the sender-minted durable `xfer_id` names the
//! transfer end-to-end across both nodes; the [`XferRole`] suffix keeps the
//! two ends' files distinct (load-bearing under a shared `SPT_HOME` — the
//! loopback two-daemon tests — and honest everywhere: each end knows only its
//! own position).

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

use serde::{Deserialize, Serialize};

use crate::atomic::atomic_write_string;
use crate::perch::spt_home;

/// Which end of the transfer this progress record describes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum XferRole {
    /// The end reading the file and putting chunks on the wire.
    Send,
    /// The end writing chunks to disk and committing the file.
    Recv,
}

impl XferRole {
    fn as_tag(self) -> &'static str {
        match self {
            XferRole::Send => "send",
            XferRole::Recv => "recv",
        }
    }
}

/// Where a transfer end stands. `state` transitions `active` → `done` |
/// `error`; `bytes_done` is monotonic while active (a replayed chunk at an
/// already-covered offset does not move it backwards).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct XferProgress {
    /// The sender-minted durable transfer id (names the transfer on both nodes).
    pub xfer_id: String,
    /// This end's file path (display metadata).
    pub path: String,
    /// Bytes this end has processed so far.
    pub bytes_done: u64,
    /// Total bytes the transfer carries.
    pub bytes_total: u64,
    /// `active` | `done` | `error`.
    pub state: String,
}

/// The transfers directory under this node's `spt_home`.
pub fn transfers_dir() -> PathBuf {
    spt_home().join("transfers")
}

fn progress_path(dir: &Path, xfer_id: &str, role: XferRole) -> PathBuf {
    // xfer_id is sender-minted ("<node-hex>:<epoch>" by convention): keep the
    // filename filesystem-safe regardless of what rides in it.
    let safe: String = xfer_id
        .chars()
        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
        .collect();
    dir.join(format!("{}.{}.json", safe, role.as_tag()))
}

/// Atomically (re)write one end's progress record into `dir` (use
/// [`transfers_dir`] for the production location; tests inject a temp dir).
/// Creates `dir` on first write.
// [impl->REQ-REACH-1]
pub fn write_progress_at(dir: &Path, role: XferRole, p: &XferProgress) -> std::io::Result<()> {
    std::fs::create_dir_all(dir)?;
    let json = serde_json::to_string(p)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    atomic_write_string(&progress_path(dir, &p.xfer_id, role), &json)
}

/// Read one end's progress record from `dir`. Tolerant: `None` for a missing
/// or torn file — never panics (same posture as `info::read_info`).
// [impl->REQ-REACH-1]
pub fn read_progress_at(dir: &Path, xfer_id: &str, role: XferRole) -> Option<XferProgress> {
    let content = std::fs::read_to_string(progress_path(dir, xfer_id, role)).ok()?;
    serde_json::from_str(&content).ok()
}

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

    fn p(done: u64, total: u64, state: &str) -> XferProgress {
        XferProgress {
            xfer_id: "cafe:42".into(),
            path: "notes/plan.md".into(),
            bytes_done: done,
            bytes_total: total,
            state: state.into(),
        }
    }

    // [unit->REQ-REACH-1] both ends' records live side by side under one home
    // (distinct role files), update mid-flight, and read back exactly — the
    // query surface the agent + GUI poll during a transfer.
    #[test]
    fn progress_round_trips_per_role_and_updates_mid_flight() {
        let dir = tempfile::tempdir().unwrap();

        // Mid-flight: sender ahead of receiver — two distinct records.
        write_progress_at(dir.path(), XferRole::Send, &p(4096, 10_000, "active")).unwrap();
        write_progress_at(dir.path(), XferRole::Recv, &p(2048, 10_000, "active")).unwrap();
        let s = read_progress_at(dir.path(), "cafe:42", XferRole::Send).expect("send");
        let r = read_progress_at(dir.path(), "cafe:42", XferRole::Recv).expect("recv");
        assert_eq!((s.bytes_done, s.bytes_total, s.state.as_str()), (4096, 10_000, "active"));
        assert_eq!((r.bytes_done, r.bytes_total, r.state.as_str()), (2048, 10_000, "active"));

        // Per-chunk rewrite moves the position; completion flips the state.
        write_progress_at(dir.path(), XferRole::Recv, &p(10_000, 10_000, "done")).unwrap();
        let r = read_progress_at(dir.path(), "cafe:42", XferRole::Recv).expect("recv");
        assert_eq!((r.bytes_done, r.state.as_str()), (10_000, "done"));

        // Unknown transfer / missing file → None, never a panic.
        assert_eq!(read_progress_at(dir.path(), "nope:1", XferRole::Send), None);
    }

    // [unit->REQ-REACH-1] the xfer_id rides into a filesystem-safe filename
    // (the ":" convention in sender-minted ids must not break Windows paths),
    // and distinct ids never collide.
    #[test]
    fn xfer_id_is_filename_safe_and_distinct()  {
        let dir = tempfile::tempdir().unwrap();
        let mut a = p(1, 2, "active");
        let mut b = p(1, 2, "active");
        a.xfer_id = "node:1".into();
        b.xfer_id = "node:2".into();
        write_progress_at(dir.path(), XferRole::Send, &a).unwrap();
        write_progress_at(dir.path(), XferRole::Send, &b).unwrap();
        assert_eq!(read_progress_at(dir.path(), "node:1", XferRole::Send), Some(a));
        assert_eq!(read_progress_at(dir.path(), "node:2", XferRole::Send), Some(b));
    }
}


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

//! File-transfer progress records (M4-D5c, REQ-REACH-1) — the durable,
//! mid-flight-queryable `{ bytes_done, bytes_total }` both ends of an off-node
//! transfer keep (CONTEXT §file-transfer progress).
//!
//! Each transfer end (the byte **sender** and the byte **receiver** — every
//! transfer has exactly one of each, whichever node initiated) maintains one
//! small JSON file under `<spt_home>/transfers/`, rewritten atomically per
//! chunk by the pump that owns that end. Durable files — not an in-memory
//! table — because the queryers are *other processes*: the agent inside the
//! driven session and the `spt` binary (GUI) both read progress while the
//! daemon's pump is busy mid-transfer, and a brain restart mid-transfer must
//! not lose the last observed position. The same torn-read posture as
//! `info.json` (KNOWN-HAZARDS 1.2): atomic tmp+rename writes, tolerant reads.
//!
//! Keyed by `(xfer_id, role)`: the sender-minted durable `xfer_id` names the
//! transfer end-to-end across both nodes; the [`XferRole`] suffix keeps the
//! two ends' files distinct (load-bearing under a shared `SPT_HOME` — the
//! loopback two-daemon tests — and honest everywhere: each end knows only its
//! own position).

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

use serde::{Deserialize, Serialize};

use crate::atomic::atomic_write_string;
use crate::perch::spt_home;

/// Which end of the transfer this progress record describes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum XferRole {
    /// The end reading the file and putting chunks on the wire.
    Send,
    /// The end writing chunks to disk and committing the file.
    Recv,
}

impl XferRole {
    fn as_tag(self) -> &'static str {
        match self {
            XferRole::Send => "send",
            XferRole::Recv => "recv",
        }
    }
}

/// Where a transfer end stands. `state` transitions `active` → `done` |
/// `error`; `bytes_done` is monotonic while active (a replayed chunk at an
/// already-covered offset does not move it backwards).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct XferProgress {
    /// The sender-minted durable transfer id (names the transfer on both nodes).
    pub xfer_id: String,
    /// This end's file path (display metadata).
    pub path: String,
    /// Bytes this end has processed so far.
    pub bytes_done: u64,
    /// Total bytes the transfer carries.
    pub bytes_total: u64,
    /// `active` | `done` | `error`.
    pub state: String,
}

/// The transfers directory under this node's `spt_home`.
pub fn transfers_dir() -> PathBuf {
    spt_home().join("transfers")
}

fn progress_path(dir: &Path, xfer_id: &str, role: XferRole) -> PathBuf {
    // xfer_id is sender-minted ("<node-hex>:<epoch>" by convention): keep the
    // filename filesystem-safe regardless of what rides in it.
    let safe: String = xfer_id
        .chars()
        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
        .collect();
    dir.join(format!("{}.{}.json", safe, role.as_tag()))
}

/// Atomically (re)write one end's progress record into `dir` (use
/// [`transfers_dir`] for the production location; tests inject a temp dir).
/// Creates `dir` on first write.
// [impl->REQ-REACH-1]
pub fn write_progress_at(dir: &Path, role: XferRole, p: &XferProgress) -> std::io::Result<()> {
    std::fs::create_dir_all(dir)?;
    let json = serde_json::to_string(p)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    atomic_write_string(&progress_path(dir, &p.xfer_id, role), &json)
}

/// Read one end's progress record from `dir`. Tolerant: `None` for a missing
/// or torn file — never panics (same posture as `info::read_info`).
// [impl->REQ-REACH-1]
pub fn read_progress_at(dir: &Path, xfer_id: &str, role: XferRole) -> Option<XferProgress> {
    let content = std::fs::read_to_string(progress_path(dir, xfer_id, role)).ok()?;
    serde_json::from_str(&content).ok()
}

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

    fn p(done: u64, total: u64, state: &str) -> XferProgress {
        XferProgress {
            xfer_id: "cafe:42".into(),
            path: "notes/plan.md".into(),
            bytes_done: done,
            bytes_total: total,
            state: state.into(),
        }
    }

    // [unit->REQ-REACH-1] both ends' records live side by side under one home
    // (distinct role files), update mid-flight, and read back exactly — the
    // query surface the agent + GUI poll during a transfer.
    #[test]
    fn progress_round_trips_per_role_and_updates_mid_flight() {
        let dir = tempfile::tempdir().unwrap();

        // Mid-flight: sender ahead of receiver — two distinct records.
        write_progress_at(dir.path(), XferRole::Send, &p(4096, 10_000, "active")).unwrap();
        write_progress_at(dir.path(), XferRole::Recv, &p(2048, 10_000, "active")).unwrap();
        let s = read_progress_at(dir.path(), "cafe:42", XferRole::Send).expect("send");
        let r = read_progress_at(dir.path(), "cafe:42", XferRole::Recv).expect("recv");
        assert_eq!((s.bytes_done, s.bytes_total, s.state.as_str()), (4096, 10_000, "active"));
        assert_eq!((r.bytes_done, r.bytes_total, r.state.as_str()), (2048, 10_000, "active"));

        // Per-chunk rewrite moves the position; completion flips the state.
        write_progress_at(dir.path(), XferRole::Recv, &p(10_000, 10_000, "done")).unwrap();
        let r = read_progress_at(dir.path(), "cafe:42", XferRole::Recv).expect("recv");
        assert_eq!((r.bytes_done, r.state.as_str()), (10_000, "done"));

        // Unknown transfer / missing file → None, never a panic.
        assert_eq!(read_progress_at(dir.path(), "nope:1", XferRole::Send), None);
    }

    // [unit->REQ-REACH-1] the xfer_id rides into a filesystem-safe filename
    // (the ":" convention in sender-minted ids must not break Windows paths),
    // and distinct ids never collide.
    #[test]
    fn xfer_id_is_filename_safe_and_distinct()  {
        let dir = tempfile::tempdir().unwrap();
        let mut a = p(1, 2, "active");
        let mut b = p(1, 2, "active");
        a.xfer_id = "node:1".into();
        b.xfer_id = "node:2".into();
        write_progress_at(dir.path(), XferRole::Send, &a).unwrap();
        write_progress_at(dir.path(), XferRole::Send, &b).unwrap();
        assert_eq!(read_progress_at(dir.path(), "node:1", XferRole::Send), Some(a));
        assert_eq!(read_progress_at(dir.path(), "node:2", XferRole::Send), Some(b));
    }
}


Edit

pub mod trust;
pub mod visibility;
pub mod xfer;


10	// depends only on spt-proto (one layer down), never the reverse — the
11	// dependency edge is one-way, so the workspace graph stays acyclic.
12	// [impl->REQ-ARCH-1]
13
14	pub mod atomic;
15	mod db;
16	pub mod epoch;
17	pub mod history;
18	pub mod info;
19	pub mod liveness;
20	pub mod nodeid;
21	pub mod perch;
22	pub mod proc;
23	pub mod registry;
24	pub mod rename;
25	pub mod seed;
26	pub mod spool;
27	pub mod subnet;
28	pub mod trust;
29	pub mod visibility;
30

// depends only on spt-proto (one layer down), never the reverse — the
// dependency edge is one-way, so the workspace graph stays acyclic.
// [impl->REQ-ARCH-1]

pub mod atomic;
mod db;
pub mod epoch;
pub mod history;
pub mod info;
pub mod liveness;
pub mod nodeid;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod rename;
pub mod seed;
pub mod spool;
pub mod subnet;
pub mod trust;
pub mod visibility;


pub mod trust;
pub mod visibility;
pub mod xfer;


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

pub mod trust;
pub mod visibility;
pub mod xfer;

//! `spt-store` — the persistence commodity layer for spt-core.
//!
//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
//! types to disk (spool, registry, info.json) through a single atomic-write
//! primitive. Stable on-disk formats are copied verbatim from the sister
//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
//!
// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
// depends only on spt-proto (one layer down), never the reverse — the
// dependency edge is one-way, so the workspace graph stays acyclic.
// [impl->REQ-ARCH-1]

pub mod atomic;
mod db;
pub mod epoch;
pub mod history;
pub mod info;
pub mod liveness;
pub mod nodeid;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod rename;
pub mod seed;
pub mod spool;
pub mod subnet;
pub mod trust;
pub mod visibility;

pub mod subnet;
pub mod trust;
pub mod visibility;
+pub mod xfer;


Bash

cargo test -p spt-store xfer info 2>&1 | tail -6; cargo test -p spt-store 2>&1 | tail -5

Run spt-store tests


error: unexpected argument 'info' found

Usage: cargo.exe test [OPTIONS] [TESTNAME] [-- [ARGS]...]

For more information, try '--help'.

running 0 tests

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


Run full spt-store test suite


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

   Doc-tests spt_store

running 0 tests

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


Task 2 done. Task 3: daemon pumps.


Updated task #2 status


Updated task #3 status