1	//! spt-core P2P networking — the cross-machine transport (M4).
2	//!
3	//! `spt-net` is the remaining sibling in the acyclic stack
4	//! (`spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime}`): the
5	//! WAN/LAN transport every cross-machine capability rides. It owns the Iroh
6	//! endpoint (WAN, with the n0-relay default + a self-host knob — ADR-0002), mDNS
7	//! LAN discovery, TOTP-seeded SPAKE2 pairing (ADR-0005), the eventually-consistent
8	//! per-subnet registry (ADR-0006), the subnet notification spool (ADR-0007),
9	//! cross-node Psyche sync (ADR-0003 + KNOWN-HAZARDS 6.5), and the update
10	//! peer-propagation transport plugged into the M3c update-engine seam (ADR-0004 §D).
11	//!
12	//! ## Why baked into core (ADR-0002)
13	//!
14	//! The sister project put networking in a separate `spt-node` daemon to decouple it
15	//! from a Claude-Code-tied binary. spt-core is already harness-independent, so that
16	//! reason is gone and WAN messaging becomes a **first-class, zero-config v1
17	//! capability**. The whole stack sits behind the default-on [`net`](#features)
18	//! feature: on in the reference `spt` binary, optional for embedded library
19	//! consumers, so a `--no-default-features` lib build stays Iroh-free.
20	//!
21	//! ## Not public SDK
22	//!
23	//! `spt-net` is internal broker machinery — the M3b/M4 `spt-daemon` programs against
24	//! it; external harness authors do not (R-ARCH-2 keeps the public surface at
25	//! `spt-proto`/`spt-store`/`spt-msg`/`spt-runtime`). The broker (not this crate)
26	//! *owns* the live Iroh endpoint + QUIC streams so they survive a brain restart
27	//! (ADR-0004 §B); `spt-net` provides the mechanism, the daemon owns the process.
28	//!
29	//! ## Build-out (see `M4-PLAN.md` task table)
30	//!
31	//!   D0  scaffold + `net` feature flag                         ← this commit
32	//!   D1  Iroh endpoint + n0 relay/self-host + mDNS discovery    (REQ-NET-1, REQ-NET-2)
33	//!   D2  TOTP-SPAKE2 pairing + transcript/seed-epoch/rate-limit (REQ-PAIR-1..6)
34	//!   D3  per-subnet registry + epoch-lease resolution           (REQ-INST-7,9..13)
35	//!   D4  broker-owned QUIC endpoint (ADR-0004 §B)               (REQ-EP-4 seam)
36	//!   D5  cross-node message + remote-drive + endpoint ACL       (REQ-INST-8, REQ-REACH-1, REQ-SEC-1)
37	//!   D6  cross-node Psyche sync (P2P), node-id precedence       (REQ-NET-3, REQ-INST-2/5)
38	//!   D7  update peer-propagation into the M3c seam              (REQ-UPD-1)
39	//!   D8  subnet notification primitive + `spt notify`           (REQ-NOTIF-1, REQ-NOTIF-2)
40	//
41	// The transport itself is clean-room (the sister had no P2P); only stable
42	// wire/registry *formats* that survive are copy-verbatim per ADR-0001. Reuses the
43	// Spike #2/#3 Iroh learnings (iroh 0.98 API; the loopback QUIC-survival shape).
44
45	// ── net-gated transport tree (empty at D0; populated D1+) ────────────────────────
46	#[cfg(feature = "net")]
47	pub mod net;
48

//! spt-core P2P networking — the cross-machine transport (M4).
//!
//! `spt-net` is the remaining sibling in the acyclic stack
//! (`spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime}`): the
//! WAN/LAN transport every cross-machine capability rides. It owns the Iroh
//! endpoint (WAN, with the n0-relay default + a self-host knob — ADR-0002), mDNS
//! LAN discovery, TOTP-seeded SPAKE2 pairing (ADR-0005), the eventually-consistent
//! per-subnet registry (ADR-0006), the subnet notification spool (ADR-0007),
//! cross-node Psyche sync (ADR-0003 + KNOWN-HAZARDS 6.5), and the update
//! peer-propagation transport plugged into the M3c update-engine seam (ADR-0004 §D).
//!
//! ## Why baked into core (ADR-0002)
//!
//! The sister project put networking in a separate `spt-node` daemon to decouple it
//! from a Claude-Code-tied binary. spt-core is already harness-independent, so that
//! reason is gone and WAN messaging becomes a **first-class, zero-config v1
//! capability**. The whole stack sits behind the default-on [`net`](#features)
//! feature: on in the reference `spt` binary, optional for embedded library
//! consumers, so a `--no-default-features` lib build stays Iroh-free.
//!
//! ## Not public SDK
//!
//! `spt-net` is internal broker machinery — the M3b/M4 `spt-daemon` programs against
//! it; external harness authors do not (R-ARCH-2 keeps the public surface at
//! `spt-proto`/`spt-store`/`spt-msg`/`spt-runtime`). The broker (not this crate)
//! *owns* the live Iroh endpoint + QUIC streams so they survive a brain restart
//! (ADR-0004 §B); `spt-net` provides the mechanism, the daemon owns the process.
//!
//! ## Build-out (see `M4-PLAN.md` task table)
//!
//!   D0  scaffold + `net` feature flag                         ← this commit
//!   D1  Iroh endpoint + n0 relay/self-host + mDNS discovery    (REQ-NET-1, REQ-NET-2)
//!   D2  TOTP-SPAKE2 pairing + transcript/seed-epoch/rate-limit (REQ-PAIR-1..6)
//!   D3  per-subnet registry + epoch-lease resolution           (REQ-INST-7,9..13)
//!   D4  broker-owned QUIC endpoint (ADR-0004 §B)               (REQ-EP-4 seam)
//!   D5  cross-node message + remote-drive + endpoint ACL       (REQ-INST-8, REQ-REACH-1, REQ-SEC-1)
//!   D6  cross-node Psyche sync (P2P), node-id precedence       (REQ-NET-3, REQ-INST-2/5)
//!   D7  update peer-propagation into the M3c seam              (REQ-UPD-1)
//!   D8  subnet notification primitive + `spt notify`           (REQ-NOTIF-1, REQ-NOTIF-2)
//
// The transport itself is clean-room (the sister had no P2P); only stable
// wire/registry *formats* that survive are copy-verbatim per ADR-0001. Reuses the
// Spike #2/#3 Iroh learnings (iroh 0.98 API; the loopback QUIC-survival shape).

// ── net-gated transport tree (empty at D0; populated D1+) ────────────────────────
#[cfg(feature = "net")]
pub mod net;


Now examine D6c transport pattern (D7 rides same broker-stream machinery).


No matches found


crates\spt-net\src\lib.rs
crates\spt-net\src\net\pairing\spake.rs
crates\spt-net\src\net\pairing\totp.rs
crates\spt-net\src\net\pairing\transcript.rs
crates\spt-net\src\net\pairing\ratelimit.rs
crates\spt-net\src\net\pairing\rendezvous.rs
crates\spt-net\src\net\pairing.rs
crates\spt-net\src\net\registry.rs
crates\spt-net\src\net\pairing\wire.rs
crates\spt-net\src\net\endpoint.rs
crates\spt-net\src\net\ndjson.rs
crates\spt-net\src\net\replicate.rs
crates\spt-net\src\net\wanmsg.rs
crates\spt-net\src\net\attach.rs
crates\spt-net\src\net\xfer.rs
crates\spt-net\src\net\sync.rs
crates\spt-net\src\net.rs


Found 8 files
crates\spt-daemon\tests\sync.rs
crates\spt-daemon\src\sync.rs
crates\spt-store\src\branchstore.rs
crates\spt-net\src\net\sync.rs
crates\spt-store\src\syncmerge.rs
crates\spt-store\src\contextstore.rs
crates\spt-net\src\net\pairing\wire.rs
crates\spt-daemon\src\update.rs


1	//! Context-sync wire records (M4-D6c, REQ-NET-3) — pull-based, ref-scoped
2	//! git-bundle exchange over a broker-owned QUIC stream (ADR-0013).
3	//!
4	//! One pull rides one bidi stream: the requester opens it (D4) and sends
5	//! [`SyncRecord::Request`] naming exactly the context branches it wants
6	//! (`a-<id>` / `p-<project>` — two-tier scoping falls out of the pull model)
7	//! plus its current tips, so the responder can build an incremental bundle
8	//! (`^have` exclusions). The responder re-checks every requested ref against
9	//! its own `synced(E,S)` + visibility gates **server-side, fail-closed**
10	//! (REQ-INST-5/REQ-INST-13: a peer must not be able to pull a hidden or
11	//! unsynced mind) — refused refs come back in [`SyncRecord::Deny`], allowed
12	//! ones stream as positional [`SyncRecord::Chunk`]s of the bundle file and
13	//! settle with [`SyncRecord::Done`].
14	//!
15	//! ## Same wire discipline as the D5c transfer channel
16	//!
17	//! Chunks carry their absolute byte `offset` (a replayed feed rewrites the
18	//! same bytes in place — positionally idempotent), sends are fire-and-forget
19	//! (a journaled `net_stream_send` blocks for its ack and DISCARDS interleaved
20	//! events — the D5b pump lesson), and records carry **no origin field**: the
21	//! gate's subject is the QUIC handshake identity from the broker's stream
22	//! table, never payload bytes (KNOWN-HAZARDS 7.5, REQ-HAZARD-WAN-ORIGIN-AUTH).
23	//! A torn pull needs no resume protocol at all — the bundle apply is
24	//! idempotent (ancestry short-circuits a re-pull), so the recovery is simply
25	//! "request again".
26
27	use std::collections::BTreeMap;
28
29	use serde::{Deserialize, Serialize};
30
31	use crate::net::ndjson::{self, NdjsonDecoder};
32
33	/// One sync-protocol record. Tagged (`kind`) so both directions share one
34	/// stream decoder; an unknown future kind fails the line's decode and is
35	/// skipped (degrade, don't die).
36	// [impl->REQ-NET-3]
37	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
38	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39	#[serde(tag = "kind", rename_all = "snake_case")]
40	pub enum SyncRecord {
41	    /// Requester → responder, first record on the stream: send me a bundle
42	    /// for `refs` (context branch names). `have_tips` maps each ref the
43	    /// requester already holds to its local tip, for `^have` exclusion.
44	    /// `sync_id` is the requester-minted id naming this pull end-to-end.
45	    Request {
46	        sync_id: String,
47	        refs: Vec<String>,
48	        have_tips: BTreeMap<String, String>,
49	    },
50	    /// Responder → requester: these requested refs were refused by the
51	    /// server-side gate (unsynced / hidden / not a context ref) — fail-closed;
52	    /// the rest of the pull proceeds without them.
53	    Deny {
54	        sync_id: String,
55	        refs: Vec<String>,
56	        message: String,
57	    },
58	    /// One chunk of bundle bytes at absolute byte `offset` — positionally
59	    /// idempotent, same as the D5c transfer channel.
60	    Chunk {
61	        sync_id: String,
62	        offset: u64,
63	        data_b64: String,
64	    },
65	    /// The bundle is complete: `total` bytes carrying `refs`. `total == 0`
66	    /// (and `refs` empty) means every allowed ref was already up to date —
67	    /// nothing was sent and there is no bundle to apply.
68	    Done {
69	        sync_id: String,
70	        total: u64,
71	        refs: Vec<String>,
72	    },
73	    /// The pull failed on the other side; nothing should be applied.
74	    Err { sync_id: String, message: String },
75	}
76
77	impl SyncRecord {
78	    /// Encode as one NDJSON line (the JSON document + `\n`).
79	    pub fn encode_line(&self) -> Vec<u8> {
80	        ndjson::encode_line(self)
81	    }
82	}
83
84	/// The sync stream's NDJSON reassembler — the shared
85	/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
86	/// [`SyncRecord`]s. One decoder per stream side.
87	pub type SyncDecoder = NdjsonDecoder<SyncRecord>;
88
89	#[cfg(test)]
90	mod tests {
91	    use super::*;
92
93	    // [unit->REQ-NET-3] every record kind round-trips through the NDJSON line
94	    // encoding, and the decoder skips an unknown future kind without wedging
95	    // the stream (forward compat: degrade, don't die).
96	    #[test]
97	    fn records_round_trip_and_unknown_kind_is_skipped() {
98	        let records = vec![
99	            SyncRecord::Request {
100	                sync_id: "n:1".into(),
101	                refs: vec!["a-doyle".into(), "p-spt-core".into()],
102	                have_tips: BTreeMap::from([("a-doyle".into(), "abc123".into())]),
103	            },
104	            SyncRecord::Deny {
105	                sync_id: "n:1".into(),
106	                refs: vec!["a-ghost".into()],
107	                message: "not synced".into(),
108	            },
109	            SyncRecord::Chunk { sync_id: "n:1".into(), offset: 4096, data_b64: "aGk=".into() },
110	            SyncRecord::Done { sync_id: "n:1".into(), total: 9, refs: vec!["a-doyle".into()] },
111	            SyncRecord::Err { sync_id: "n:1".into(), message: "bundle failed".into() },
112	        ];
113	        let mut wire = Vec::new();
114	        for r in &records {
115	            wire.extend_from_slice(&r.encode_line());
116	        }
117	        let mut dec = SyncDecoder::new();
118	        assert_eq!(dec.push(&wire), records, "all kinds round-trip in order");
119
120	        let mut with_junk = records[0].encode_line();
121	        with_junk.extend_from_slice(b"{\"kind\":\"added_at_d9\",\"x\":1}\n");
122	        with_junk.extend_from_slice(&records[3].encode_line());
123	        let mut dec = SyncDecoder::new();
124	        assert_eq!(
125	            dec.push(&with_junk),
126	            vec![records[0].clone(), records[3].clone()],
127	            "unknown kind skipped, feed not wedged"
128	        );
129	    }
130
131	    // [unit->REQ-NET-3] a record split mid-bytes across pushes reassembles
132	    // (QUIC chunking is not record framing).
133	    #[test]
134	    fn record_split_across_chunks_reassembles() {
135	        let r = SyncRecord::Chunk { sync_id: "n:7".into(), offset: 0, data_b64: "c3BsaXQ=".into() };
136	        let line = r.encode_line();
137	        let mut dec = SyncDecoder::new();
138	        assert!(dec.push(&line[..line.len() / 2]).is_empty());
139	        assert_eq!(dec.push(&line[line.len() / 2..]), vec![r]);
140	    }
141
142	    // [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] sync records carry no origin field: a
143	    // forged `origin_node` (or `node`) inside the JSON decodes as an ignored
144	    // unknown field — nothing in the decoded record can impersonate a node.
145	    // The gate's subject comes from the QUIC handshake (the broker's stream
146	    // table), asserted end-to-end in the daemon's sync int tests.
147	    #[test]
148	    fn forged_origin_field_is_inert() {
149	        let wire = br#"{"kind":"request","sync_id":"n:1","refs":["a-doyle"],"have_tips":{},"origin_node":"evil","node":"evil"}"#;
150	        let decoded: SyncRecord = serde_json::from_slice(wire).unwrap();

//! Context-sync wire records (M4-D6c, REQ-NET-3) — pull-based, ref-scoped
//! git-bundle exchange over a broker-owned QUIC stream (ADR-0013).
//!
//! One pull rides one bidi stream: the requester opens it (D4) and sends
//! [`SyncRecord::Request`] naming exactly the context branches it wants
//! (`a-<id>` / `p-<project>` — two-tier scoping falls out of the pull model)
//! plus its current tips, so the responder can build an incremental bundle
//! (`^have` exclusions). The responder re-checks every requested ref against
//! its own `synced(E,S)` + visibility gates **server-side, fail-closed**
//! (REQ-INST-5/REQ-INST-13: a peer must not be able to pull a hidden or
//! unsynced mind) — refused refs come back in [`SyncRecord::Deny`], allowed
//! ones stream as positional [`SyncRecord::Chunk`]s of the bundle file and
//! settle with [`SyncRecord::Done`].
//!
//! ## Same wire discipline as the D5c transfer channel
//!
//! Chunks carry their absolute byte `offset` (a replayed feed rewrites the
//! same bytes in place — positionally idempotent), sends are fire-and-forget
//! (a journaled `net_stream_send` blocks for its ack and DISCARDS interleaved
//! events — the D5b pump lesson), and records carry **no origin field**: the
//! gate's subject is the QUIC handshake identity from the broker's stream
//! table, never payload bytes (KNOWN-HAZARDS 7.5, REQ-HAZARD-WAN-ORIGIN-AUTH).
//! A torn pull needs no resume protocol at all — the bundle apply is
//! idempotent (ancestry short-circuits a re-pull), so the recovery is simply
//! "request again".

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

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

/// One sync-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).
// [impl->REQ-NET-3]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SyncRecord {
    /// Requester → responder, first record on the stream: send me a bundle
    /// for `refs` (context branch names). `have_tips` maps each ref the
    /// requester already holds to its local tip, for `^have` exclusion.
    /// `sync_id` is the requester-minted id naming this pull end-to-end.
    Request {
        sync_id: String,
        refs: Vec<String>,
        have_tips: BTreeMap<String, String>,
    },
    /// Responder → requester: these requested refs were refused by the
    /// server-side gate (unsynced / hidden / not a context ref) — fail-closed;
    /// the rest of the pull proceeds without them.
    Deny {
        sync_id: String,
        refs: Vec<String>,
        message: String,
    },
    /// One chunk of bundle bytes at absolute byte `offset` — positionally
    /// idempotent, same as the D5c transfer channel.
    Chunk {
        sync_id: String,
        offset: u64,
        data_b64: String,
    },
    /// The bundle is complete: `total` bytes carrying `refs`. `total == 0`
    /// (and `refs` empty) means every allowed ref was already up to date —
    /// nothing was sent and there is no bundle to apply.
    Done {
        sync_id: String,
        total: u64,
        refs: Vec<String>,
    },
    /// The pull failed on the other side; nothing should be applied.
    Err { sync_id: String, message: String },
}

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

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

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

    // [unit->REQ-NET-3] 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![
            SyncRecord::Request {
                sync_id: "n:1".into(),
                refs: vec!["a-doyle".into(), "p-spt-core".into()],
                have_tips: BTreeMap::from([("a-doyle".into(), "abc123".into())]),
            },
            SyncRecord::Deny {
                sync_id: "n:1".into(),
                refs: vec!["a-ghost".into()],
                message: "not synced".into(),
            },
            SyncRecord::Chunk { sync_id: "n:1".into(), offset: 4096, data_b64: "aGk=".into() },
            SyncRecord::Done { sync_id: "n:1".into(), total: 9, refs: vec!["a-doyle".into()] },
            SyncRecord::Err { sync_id: "n:1".into(), message: "bundle failed".into() },
        ];
        let mut wire = Vec::new();
        for r in &records {
            wire.extend_from_slice(&r.encode_line());
        }
        let mut dec = SyncDecoder::new();
        assert_eq!(dec.push(&wire), records, "all kinds round-trip in order");

        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 = SyncDecoder::new();
        assert_eq!(
            dec.push(&with_junk),
            vec![records[0].clone(), records[3].clone()],
            "unknown kind skipped, feed not wedged"
        );
    }

    // [unit->REQ-NET-3] a record split mid-bytes across pushes reassembles
    // (QUIC chunking is not record framing).
    #[test]
    fn record_split_across_chunks_reassembles() {
        let r = SyncRecord::Chunk { sync_id: "n:7".into(), offset: 0, data_b64: "c3BsaXQ=".into() };
        let line = r.encode_line();
        let mut dec = SyncDecoder::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] sync 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 sync int tests.
    #[test]
    fn forged_origin_field_is_inert() {
        let wire = br#"{"kind":"request","sync_id":"n:1","refs":["a-doyle"],"have_tips":{},"origin_node":"evil","node":"evil"}"#;
        let decoded: SyncRecord = serde_json::from_slice(wire).unwrap();


1	//! Context bundle sync over broker streams (M4-D6c, ADR-0013, REQ-NET-3) —
2	//! the serve/request drivers for the [`SyncRecord`] protocol, the server-side
3	//! ref gate, and the post-pull reconcile wiring.
4	//!
5	//! One pull rides one bidi QUIC stream: the requester's daemon opens it and
6	//! sends [`SyncRecord::Request`] naming the context branches it wants plus its
7	//! current tips; the responder's [`serve_sync`] gates every ref, builds an
8	//! incremental bundle ([`BranchStore::create_bundle`]), and streams it back as
9	//! positional chunks. The requester fetches the bundle into quarantine and
10	//! rules per file with the vector driver ([`spt_store::syncmerge`]); surfaced
11	//! conflicts feed the Psyche reconcile when this node is the elected
12	//! reconciler ([`reconcile_after_sync`]).
13	//!
14	//! ## The server-side gate (REQ-INST-5, REQ-INST-13 — fail-closed)
15	//!
16	//! The requester names refs, but naming is not entitlement: [`SyncPolicy`]
17	//! re-checks every requested ref on the responder under the
18	//! **handshake-proven** origin node (never payload bytes, KNOWN-HAZARDS 7.5):
19	//!
20	//! - `a-<id>` is served only when some subnet S has the origin **pinned in the
21	//!   trust store** AND `synced(id, S)` holds (membership list + visibility —
22	//!   the D3e gate, hidden ⟹ not synced). A hidden or unsynced mind is refused
23	//!   even when asked for by name.
24	//! - `p-<project>` is served only to an origin trusted in at least one subnet,
25	//!   and only for a project this node actually hosts (the branch exists).
26	//!   *Honest residual:* the p- tier is branch-granular — a hidden endpoint's
27	//!   per-project slice inside a hosted project's branch rides that branch;
28	//!   per-file p- filtering is post-v1 (CONTEXT-MEMORY §Deferred).
29	//! - Anything else (machinery refs, `refs/spt-sync/…`) is never served.
30	//!
31	//! ## Wire discipline + recovery
32	//!
33	//! Same posture as the D5c transfer channel: fire-and-forget sends (`op_id:
34	//! None` — a journaled send starves a pump), chunks dedup by absolute offset.
35	//! There is deliberately **no resume protocol**: a torn pull (stream lost
36	//! mid-bundle) is recovered by simply requesting again — the apply is
37	//! idempotent (an already-joined tip short-circuits on ancestry), the
38	//! FAULT-MATRIX "stream loss mid-bundle" row.
39	// [impl->REQ-NET-3]
40	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
41
42	use std::collections::BTreeMap;
43	use std::io::{self, Seek, SeekFrom, Write};
44	use std::path::{Path, PathBuf};
45	use std::time::Duration;
46
47	use spt_live::reconcile::{reconcile_file, ReconcileOutcome, ReconcileRequest};
48	use spt_net::net::registry::Instance;
49	use spt_net::net::sync::{SyncDecoder, SyncRecord};
50	use spt_runtime::AgentRuntime;
51	use spt_store::contextstore::ContextStore;
52	use spt_store::syncmerge::{apply_fetched, worktree_for_branch, ApplyReport};
53	use spt_store::trust::TrustStore;
54	use spt_store::visibility::VisibilityStore;
55
56	use crate::brain::{Brain, BrokerEvent};
57	use crate::msg::{decode_bytes, encode_bytes};
58	use crate::reconcile::is_reconciler;
59
60	/// Raw bytes per [`SyncRecord::Chunk`] (base64 inside the JSON line — the
61	/// shared NDJSON posture, no binary fork of the codec).
62	const CHUNK_BYTES: usize = 64 * 1024;
63
64	/// The responder's per-ref sync gate: the node's subnet membership (name +
65	/// `hide_new_endpoints` flag), its pinned peers, and its visibility/sync
66	/// config. Loaded from the canonical stores in production; tests construct it
67	/// directly.
68	// [impl->REQ-INST-5]
69	pub struct SyncPolicy {
70	    /// `(subnet name, hide_new_endpoints)` for every subnet this node is in.
71	    pub subnets: Vec<(String, bool)>,
72	    pub trust: TrustStore,
73	    pub vis: VisibilityStore,
74	}
75
76	impl SyncPolicy {
77	    /// Load from the node's canonical stores (`subnet.json`, `trust/peers.json`,
78	    /// `visibility.json`). Re-read per pull — config edits take effect on the
79	    /// next request without a daemon restart (the access-gate stance).
80	    pub fn load() -> SyncPolicy {
81	        let subnets = spt_store::subnet::SubnetStore::load()
82	            .subnets
83	            .iter()
84	            .map(|s| (s.name.clone(), s.hide_new_endpoints))
85	            .collect();
86	        SyncPolicy { subnets, trust: TrustStore::load(), vis: VisibilityStore::load() }
87	    }
88
89	    /// Whether `branch` may sync with `origin_node` (handshake-proven). The
90	    /// fail-closed decision table in the module docs. Used on **both** sides:
91	    /// the responder's serve gate, and the requester's ref selection (don't
92	    /// ask for what the peer shouldn't get — symmetric scoping).
93	    // [impl->REQ-INST-5]
94	    // [impl->REQ-INST-13]
95	    pub fn allows(&self, branch: &str, origin_node: &str) -> bool {
96	        if let Some(id) = branch.strip_prefix("a-") {
97	            self.subnets.iter().any(|(s, hide)| {
98	                self.trust.is_trusted(s, origin_node) && self.vis.synced(id, s, *hide)
99	            })
100	        } else if branch.strip_prefix("p-").is_some() {
101	            self.subnets.iter().any(|(s, _)| self.trust.is_trusted(s, origin_node))
102	        } else {
103	            false // machinery refs are never sync-served
104	        }
105	    }
106	}
107
108	/// Why a responder-side serve loop returned.
109	#[derive(Debug, Clone, PartialEq, Eq)]
110	pub enum SyncServeOutcome {
111	    /// A bundle was served (`bytes` on the wire, carrying `refs`); `denied`
112	    /// lists requested refs the gate refused.
113	    Sent { bytes: u64, refs: Vec<String>, denied: Vec<String> },
114	    /// Every allowed ref was already up to date — nothing sent.
115	    UpToDate { denied: Vec<String> },
116	    /// The serve failed; an `Err` record was sent to the peer.
117	    Failed(String),
118	    /// The requester closed its send side before a request completed.
119	    Detached,
120	}

//! Context bundle sync over broker streams (M4-D6c, ADR-0013, REQ-NET-3) —
//! the serve/request drivers for the [`SyncRecord`] protocol, the server-side
//! ref gate, and the post-pull reconcile wiring.
//!
//! One pull rides one bidi QUIC stream: the requester's daemon opens it and
//! sends [`SyncRecord::Request`] naming the context branches it wants plus its
//! current tips; the responder's [`serve_sync`] gates every ref, builds an
//! incremental bundle ([`BranchStore::create_bundle`]), and streams it back as
//! positional chunks. The requester fetches the bundle into quarantine and
//! rules per file with the vector driver ([`spt_store::syncmerge`]); surfaced
//! conflicts feed the Psyche reconcile when this node is the elected
//! reconciler ([`reconcile_after_sync`]).
//!
//! ## The server-side gate (REQ-INST-5, REQ-INST-13 — fail-closed)
//!
//! The requester names refs, but naming is not entitlement: [`SyncPolicy`]
//! re-checks every requested ref on the responder under the
//! **handshake-proven** origin node (never payload bytes, KNOWN-HAZARDS 7.5):
//!
//! - `a-<id>` is served only when some subnet S has the origin **pinned in the
//!   trust store** AND `synced(id, S)` holds (membership list + visibility —
//!   the D3e gate, hidden ⟹ not synced). A hidden or unsynced mind is refused
//!   even when asked for by name.
//! - `p-<project>` is served only to an origin trusted in at least one subnet,
//!   and only for a project this node actually hosts (the branch exists).
//!   *Honest residual:* the p- tier is branch-granular — a hidden endpoint's
//!   per-project slice inside a hosted project's branch rides that branch;
//!   per-file p- filtering is post-v1 (CONTEXT-MEMORY §Deferred).
//! - Anything else (machinery refs, `refs/spt-sync/…`) is never served.
//!
//! ## Wire discipline + recovery
//!
//! Same posture as the D5c transfer channel: fire-and-forget sends (`op_id:
//! None` — a journaled send starves a pump), chunks dedup by absolute offset.
//! There is deliberately **no resume protocol**: a torn pull (stream lost
//! mid-bundle) is recovered by simply requesting again — the apply is
//! idempotent (an already-joined tip short-circuits on ancestry), the
//! FAULT-MATRIX "stream loss mid-bundle" row.
// [impl->REQ-NET-3]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]

use std::collections::BTreeMap;
use std::io::{self, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;

use spt_live::reconcile::{reconcile_file, ReconcileOutcome, ReconcileRequest};
use spt_net::net::registry::Instance;
use spt_net::net::sync::{SyncDecoder, SyncRecord};
use spt_runtime::AgentRuntime;
use spt_store::contextstore::ContextStore;
use spt_store::syncmerge::{apply_fetched, worktree_for_branch, ApplyReport};
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;

use crate::brain::{Brain, BrokerEvent};
use crate::msg::{decode_bytes, encode_bytes};
use crate::reconcile::is_reconciler;

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

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

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

    /// Whether `branch` may sync with `origin_node` (handshake-proven). The
    /// fail-closed decision table in the module docs. Used on **both** sides:
    /// the responder's serve gate, and the requester's ref selection (don't
    /// ask for what the peer shouldn't get — symmetric scoping).
    // [impl->REQ-INST-5]
    // [impl->REQ-INST-13]
    pub fn allows(&self, branch: &str, origin_node: &str) -> bool {
        if let Some(id) = branch.strip_prefix("a-") {
            self.subnets.iter().any(|(s, hide)| {
                self.trust.is_trusted(s, origin_node) && self.vis.synced(id, s, *hide)
            })
        } else if branch.strip_prefix("p-").is_some() {
            self.subnets.iter().any(|(s, _)| self.trust.is_trusted(s, origin_node))
        } else {
            false // machinery refs are never sync-served
        }
    }
}

/// Why a responder-side serve loop returned.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SyncServeOutcome {
    /// A bundle was served (`bytes` on the wire, carrying `refs`); `denied`
    /// lists requested refs the gate refused.
    Sent { bytes: u64, refs: Vec<String>, denied: Vec<String> },
    /// Every allowed ref was already up to date — nothing sent.
    UpToDate { denied: Vec<String> },
    /// The serve failed; an `Err` record was sent to the peer.
    Failed(String),
    /// The requester closed its send side before a request completed.
    Detached,
}


1	//! Off-node file-transfer records (M4-D5c, REQ-REACH-1) — chunked,
2	//! progress-queryable transfer between an instance's node and the node
3	//! driving it.
4	//!
5	//! Remote-control keeps compute + files on the target node (CONTEXT
6	//! §Remote-control vs local operation); the file channel is the deliberate,
7	//! addressable exception: the driving node may pull a file off the target
8	//! ([`XferRecord::Fetch`]) or push one onto it ([`XferRecord::Push`]). One
9	//! transfer rides one bidi QUIC stream (D4), framed as the shared NDJSON
10	//! ([`crate::net::ndjson`]) with chunk bytes base64 inside the JSON — the
11	//! same posture as the attach viewport, no binary fork of the codec.
12	//!
13	//! ## Idempotency is positional, not journaled (KNOWN-HAZARDS gotcha)
14	//!
15	//! Wire sends in a transfer pump are fire-and-forget — a journaled
16	//! `net_stream_send` blocks for its ack and discards interleaved events,
17	//! which starves a full-duplex pump (the D5b lesson). Exactly-once lives at
18	//! the edges instead: every [`XferRecord::Chunk`] carries its absolute byte
19	//! `offset`, so a replayed feed (a target-brain restart re-serving the stream
20	//! from seq 0) rewrites the same bytes at the same positions — idempotent by
21	//! construction — and [`XferRecord::Done`] commits via atomic
22	//! temp-file rename, where a replayed commit of an already-renamed transfer
23	//! dedups against the final file. `xfer_id` is the sender-minted durable id
24	//! that names the transfer end-to-end: the progress query key on both ends
25	//! (CONTEXT §file-transfer progress) and the temp-file discriminator.
26	//!
27	//! Like [`WanMessage`](crate::net::wanmsg::WanMessage), transfer records
28	//! carry **no origin field** — the access gate's subject is the QUIC
29	//! handshake identity from the broker's stream table (KNOWN-HAZARDS 7.5,
30	//! REQ-HAZARD-WAN-ORIGIN-AUTH).
31
32	use serde::{Deserialize, Serialize};
33
34	use crate::net::ndjson::{self, NdjsonDecoder};
35
36	/// One transfer-protocol record. Tagged (`kind`) so both directions share one
37	/// stream decoder; an unknown future kind fails the line's decode and is
38	/// skipped (degrade, don't die — same posture as every wire feed).
39	// [impl->REQ-REACH-1]
40	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
41	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42	#[serde(tag = "kind", rename_all = "snake_case")]
43	pub enum XferRecord {
44	    /// Operator → target, first record on a pull stream: send me the file at
45	    /// `path` (resolved under the target's transfer root — never absolute,
46	    /// never traversing). `xfer_id` is the operator-minted durable transfer id.
47	    Fetch { path: String, xfer_id: String },
48	    /// Operator → target, first record on a push stream: an incoming file of
49	    /// `total` bytes for `path` (same root-relative rules as `Fetch`).
50	    Push { path: String, total: u64, xfer_id: String },
51	    /// One chunk of file bytes at absolute byte `offset` — positionally
52	    /// idempotent: a replayed chunk rewrites the same bytes in place.
53	    Chunk { xfer_id: String, offset: u64, data_b64: String },
54	    /// The sending side wrote every byte (`total` of them): commit the file
55	    /// (atomic temp→final rename on the receiving side). The target echoes
56	    /// `Done` back on a push so the operator observes the durable commit.
57	    Done { xfer_id: String, total: u64 },
58	    /// The transfer failed on the other side; nothing was committed.
59	    Err { xfer_id: String, message: String },
60	}
61
62	impl XferRecord {
63	    /// Encode as one NDJSON line (the JSON document + `\n`).
64	    pub fn encode_line(&self) -> Vec<u8> {
65	        ndjson::encode_line(self)
66	    }
67	}
68
69	/// The transfer stream's NDJSON reassembler — the shared
70	/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
71	/// [`XferRecord`]s. One decoder per stream side.
72	pub type XferDecoder = NdjsonDecoder<XferRecord>;
73
74	#[cfg(test)]
75	mod tests {
76	    use super::*;
77
78	    // [unit->REQ-REACH-1] every record kind round-trips through the NDJSON
79	    // line encoding, and the decoder skips an unknown future kind without
80	    // wedging the stream (forward compat: degrade, don't die).

//! 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).


crates\spt-daemon\src\lib.rs:172:    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
crates\spt-daemon\src\lib.rs:175:pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
crates\spt-daemon\src\msg.rs:504:            "stream_kind_added_at_d4b": "uni"
crates\spt-daemon\src\nethost.rs:36:    SendStream, SPT_NET_ALPN,
crates\spt-daemon\src\nethost.rs:382:    /// accept loop (every inbound `SPT_NET_ALPN` connection joins the table).
crates\spt-daemon\src\nethost.rs:395:                vec![SPT_NET_ALPN.to_vec()],
crates\spt-daemon\src\nethost.rs:447:    /// Dial a peer over `SPT_NET_ALPN`, registering the connection in the table.
crates\spt-daemon\src\sync.rs:7://! current tips; the responder's [`serve_sync`] gates every ref, builds an
crates\spt-daemon\src\sync.rs:162:pub fn serve_sync(
crates\spt-daemon\src\xfer.rs:7://! [`serve_xfer`] answers. Compute + files otherwise stay on the target
crates\spt-daemon\src\xfer.rs:37://! The first record on the stream is *unsolicited wire inbound*:
crates\spt-daemon\src\xfer.rs:211:pub fn serve_xfer(


120	}
121
122	/// What one pull applied, branch by branch.
123	#[derive(Debug, Default)]
124	pub struct SyncPullReport {
125	    /// Per-branch apply verdicts (accept/drop/conflict per file).
126	    pub applied: Vec<ApplyReport>,
127	    /// Refs the responder's gate refused (fail-closed on its side).
128	    pub denied: Vec<String>,
129	}
130
131	impl SyncPullReport {
132	    /// Every `(branch, file)` surfaced as a concurrent conflict by this pull —
133	    /// the reconcile wiring's input.
134	    pub fn conflicts(&self) -> Vec<(String, String)> {
135	        self.applied
136	            .iter()
137	            .flat_map(|r| r.conflicted.iter().map(move |f| (r.branch.clone(), f.clone())))
138	            .collect()
139	    }
140	}
141
142	/// Sanitize a wire-supplied sync id into a filesystem-safe scratch-file stem.
143	fn safe_stem(sync_id: &str) -> String {
144	    sync_id
145	        .chars()
146	        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
147	        .collect()
148	}
149
150	/// Serve one sync pull against this node's context store, blocking until the
151	/// pull completes, the requester detaches, or the request fails.
152	///
153	/// `origin_node` MUST be the handshake-proven remote id from the broker's
154	/// stream table (`NetStreamInfo::remote_id_hex`) — never payload bytes
155	/// (REQ-HAZARD-WAN-ORIGIN-AUTH). Every requested ref is re-checked against
156	/// `policy` **here**, fail-closed (REQ-INST-5: a peer must not be able to pull
157	/// a hidden/unsynced mind by naming it). `scratch_dir` holds the transient
158	/// bundle file.
159	// [impl->REQ-NET-3]
160	// [impl->REQ-INST-5]
161	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
162	pub fn serve_sync(
163	    brain: &mut Brain,
164	    stream_id: u64,
165	    origin_node: &str,
166	    net_from_seq: u64,
167	    cs: &ContextStore,
168	    policy: &SyncPolicy,
169	    scratch_dir: &Path,
170	) -> io::Result<SyncServeOutcome> {
171	    brain.net_stream_subscribe(stream_id, net_from_seq)?;
172	    let mut decoder = SyncDecoder::new();
173
174	    loop {
175	        match brain.read_event()? {
176	            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
177	                let Some(rec) = decoder.push(&bytes).into_iter().next() else { continue };
178	                let SyncRecord::Request { sync_id, refs, have_tips } = rec else {
179	                    continue; // not a request — protocol noise, keep waiting
180	                };
181
182	                // The gate: partition requested refs under the proven origin.
183	                let (allowed, denied): (Vec<String>, Vec<String>) =
184	                    refs.into_iter().partition(|r| policy.allows(r, origin_node));
185	                if !denied.is_empty() {
186	                    let line = SyncRecord::Deny {
187	                        sync_id: sync_id.clone(),
188	                        refs: denied.clone(),
189	                        message: "refused: not synced/visible to this origin".to_string(),
190	                    }
191	                    .encode_line();
192	                    brain.net_stream_send(stream_id, &line, None, false)?;
193	                }
194
195	                // Incremental bundle for the allowed refs (absent branches and
196	                // up-to-date tips are skipped inside create_bundle).
197	                let wants: Vec<(String, Option<String>)> = allowed
198	                    .iter()
199	                    .map(|r| (r.clone(), have_tips.get(r).cloned()))
200	                    .collect();
201	                std::fs::create_dir_all(scratch_dir)?;
202	                let bundle = scratch_dir.join(format!("serve-{}.bundle", safe_stem(&sync_id)));
203	                let included = match cs.branch_store().create_bundle(&bundle, &wants) {
204	                    Ok(i) => i,
205	                    Err(e) => {
206	                        let line = SyncRecord::Err {
207	                            sync_id: sync_id.clone(),
208	                            message: format!("bundle failed: {e}"),
209	                        }
210	                        .encode_line();
211	                        brain.net_stream_send(stream_id, &line, None, true)?;
212	                        return Ok(SyncServeOutcome::Failed(e.to_string()));
213	                    }
214	                };
215	                if included.is_empty() {
216	                    let line = SyncRecord::Done { sync_id, total: 0, refs: Vec::new() }
217	                        .encode_line();
218	                    brain.net_stream_send(stream_id, &line, None, true)?;
219	                    return Ok(SyncServeOutcome::UpToDate { denied });
220	                }
221
222	                // Pump the bundle file as positional chunks (fire-and-forget;
223	                // the offset is the dedup, not a send journal — D5b lesson).
224	                let bytes_total = {
225	                    let mut file = std::fs::File::open(&bundle)?;
226	                    let total = file.metadata()?.len();
227	                    let mut offset = 0u64;
228	                    let mut buf = vec![0u8; CHUNK_BYTES];
229	                    loop {
230	                        use std::io::Read;
231	                        let n = file.read(&mut buf)?;
232	                        if n == 0 {
233	                            break;
234	                        }
235	                        let line = SyncRecord::Chunk {
236	                            sync_id: sync_id.clone(),
237	                            offset,
238	                            data_b64: encode_bytes(&buf[..n]),
239	                        }
240	                        .encode_line();
241	                        brain.net_stream_send(stream_id, &line, None, false)?;
242	                        offset += n as u64;
243	                    }
244	                    total
245	                };
246	                let _ = std::fs::remove_file(&bundle);
247	                let done = SyncRecord::Done {
248	                    sync_id,
249	                    total: bytes_total,
250	                    refs: included.clone(),
251	                }
252	                .encode_line();
253	                brain.net_stream_send(stream_id, &done, None, true)?;
254	                return Ok(SyncServeOutcome::Sent { bytes: bytes_total, refs: included, denied });
255	            }
256	            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
257	                brain.net_stream_send(stream_id, &[], None, true)?;
258	                return Ok(SyncServeOutcome::Detached);
259	            }
260	            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
261	            _ => {}
262	        }
263	    }
264	}
265
266	/// The refs this node should request from `peer_node`: every local `a-<id>`
267	/// branch the policy allows toward that peer (symmetric scoping — don't ask
268	/// for a mind the peer's own gate would refuse to receive elsewhere) plus
269	/// every hosted `p-<project>` branch. The pull model's requester half:
270	/// two-tier scoping with no new registry fields (ADR-0013).
271	// [impl->REQ-INST-5]
272	pub fn select_refs(
273	    policy: &SyncPolicy,
274	    cs: &ContextStore,
275	    peer_node: &str,
276	) -> io::Result<Vec<String>> {
277	    Ok(cs
278	        .branch_store()
279	        .branches()?
280	        .into_iter()
281	        .filter(|b| policy.allows(b, peer_node))
282	        .collect())
283	}
284
285	/// Requester side: pull `refs` from the peer over `conn_id`, apply each
286	/// fetched tip with the vector driver, and report per-branch verdicts.
287	/// `open_op` is the durable id for the exactly-once stream open; everything
288	/// after rides fire-and-forget. A torn pull errors — recovery is a fresh
289	/// `request_sync` (apply is idempotent; FAULT-MATRIX stream-loss row).
290	// [impl->REQ-NET-3]
291	pub fn request_sync(
292	    brain: &mut Brain,
293	    conn_id: u64,
294	    refs: &[String],
295	    open_op: u64,
296	    cs: &ContextStore,
297	    scratch_dir: &Path,
298	) -> io::Result<SyncPullReport> {
299	    let opened = brain.net_open_stream(conn_id, Some(open_op))?;
300	    let stream_id = opened.stream_id;
301	    brain.net_stream_subscribe(stream_id, 0)?;
302
303	    let sync_id = format!("pull-{open_op}");
304	    let mut have_tips = BTreeMap::new();
305	    for r in refs {
306	        if let Some(tip) = cs.branch_store().tip(r)? {
307	            have_tips.insert(r.clone(), tip);
308	        }
309	    }
310	    let line = SyncRecord::Request {
311	        sync_id: sync_id.clone(),
312	        refs: refs.to_vec(),
313	        have_tips,
314	    }
315	    .encode_line();
316	    brain.net_stream_send(stream_id, &line, None, true)?;
317
318	    std::fs::create_dir_all(scratch_dir)?;
319	    let bundle: PathBuf = scratch_dir.join(format!("recv-{}.bundle", safe_stem(&sync_id)));
320	    let mut file = Some(
321	        std::fs::OpenOptions::new().create(true).truncate(true).write(true).open(&bundle)?,
322	    );
323	    let mut report = SyncPullReport::default();
324	    let mut decoder = SyncDecoder::new();
325
326	    loop {
327	        match brain.read_event()? {
328	            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
329	                for rec in decoder.push(&bytes) {
330	                    match rec {
331	                        SyncRecord::Deny { sync_id: xid, refs, .. } if xid == sync_id => {
332	                            report.denied.extend(refs);
333	                        }
334	                        SyncRecord::Chunk { sync_id: xid, offset, data_b64 }
335	                            if xid == sync_id =>
336	                        {
337	                            let chunk = decode_bytes(&data_b64)
338	                                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
339	                            let f = file

}

/// What one pull applied, branch by branch.
#[derive(Debug, Default)]
pub struct SyncPullReport {
    /// Per-branch apply verdicts (accept/drop/conflict per file).
    pub applied: Vec<ApplyReport>,
    /// Refs the responder's gate refused (fail-closed on its side).
    pub denied: Vec<String>,
}

impl SyncPullReport {
    /// Every `(branch, file)` surfaced as a concurrent conflict by this pull —
    /// the reconcile wiring's input.
    pub fn conflicts(&self) -> Vec<(String, String)> {
        self.applied
            .iter()
            .flat_map(|r| r.conflicted.iter().map(move |f| (r.branch.clone(), f.clone())))
            .collect()
    }
}

/// Sanitize a wire-supplied sync id into a filesystem-safe scratch-file stem.
fn safe_stem(sync_id: &str) -> String {
    sync_id
        .chars()
        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
        .collect()
}

/// Serve one sync pull against this node's context store, blocking until the
/// pull completes, the requester detaches, or the request fails.
///
/// `origin_node` MUST be the handshake-proven remote id from the broker's
/// stream table (`NetStreamInfo::remote_id_hex`) — never payload bytes
/// (REQ-HAZARD-WAN-ORIGIN-AUTH). Every requested ref is re-checked against
/// `policy` **here**, fail-closed (REQ-INST-5: a peer must not be able to pull
/// a hidden/unsynced mind by naming it). `scratch_dir` holds the transient
/// bundle file.
// [impl->REQ-NET-3]
// [impl->REQ-INST-5]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
pub fn serve_sync(
    brain: &mut Brain,
    stream_id: u64,
    origin_node: &str,
    net_from_seq: u64,
    cs: &ContextStore,
    policy: &SyncPolicy,
    scratch_dir: &Path,
) -> io::Result<SyncServeOutcome> {
    brain.net_stream_subscribe(stream_id, net_from_seq)?;
    let mut decoder = SyncDecoder::new();

    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                let Some(rec) = decoder.push(&bytes).into_iter().next() else { continue };
                let SyncRecord::Request { sync_id, refs, have_tips } = rec else {
                    continue; // not a request — protocol noise, keep waiting
                };

                // The gate: partition requested refs under the proven origin.
                let (allowed, denied): (Vec<String>, Vec<String>) =
                    refs.into_iter().partition(|r| policy.allows(r, origin_node));
                if !denied.is_empty() {
                    let line = SyncRecord::Deny {
                        sync_id: sync_id.clone(),
                        refs: denied.clone(),
                        message: "refused: not synced/visible to this origin".to_string(),
                    }
                    .encode_line();
                    brain.net_stream_send(stream_id, &line, None, false)?;
                }

                // Incremental bundle for the allowed refs (absent branches and
                // up-to-date tips are skipped inside create_bundle).
                let wants: Vec<(String, Option<String>)> = allowed
                    .iter()
                    .map(|r| (r.clone(), have_tips.get(r).cloned()))
                    .collect();
                std::fs::create_dir_all(scratch_dir)?;
                let bundle = scratch_dir.join(format!("serve-{}.bundle", safe_stem(&sync_id)));
                let included = match cs.branch_store().create_bundle(&bundle, &wants) {
                    Ok(i) => i,
                    Err(e) => {
                        let line = SyncRecord::Err {
                            sync_id: sync_id.clone(),
                            message: format!("bundle failed: {e}"),
                        }
                        .encode_line();
                        brain.net_stream_send(stream_id, &line, None, true)?;
                        return Ok(SyncServeOutcome::Failed(e.to_string()));
                    }
                };
                if included.is_empty() {
                    let line = SyncRecord::Done { sync_id, total: 0, refs: Vec::new() }
                        .encode_line();
                    brain.net_stream_send(stream_id, &line, None, true)?;
                    return Ok(SyncServeOutcome::UpToDate { denied });
                }

                // Pump the bundle file as positional chunks (fire-and-forget;
                // the offset is the dedup, not a send journal — D5b lesson).
                let bytes_total = {
                    let mut file = std::fs::File::open(&bundle)?;
                    let total = file.metadata()?.len();
                    let mut offset = 0u64;
                    let mut buf = vec![0u8; CHUNK_BYTES];
                    loop {
                        use std::io::Read;
                        let n = file.read(&mut buf)?;
                        if n == 0 {
                            break;
                        }
                        let line = SyncRecord::Chunk {
                            sync_id: sync_id.clone(),
                            offset,
                            data_b64: encode_bytes(&buf[..n]),
                        }
                        .encode_line();
                        brain.net_stream_send(stream_id, &line, None, false)?;
                        offset += n as u64;
                    }
                    total
                };
                let _ = std::fs::remove_file(&bundle);
                let done = SyncRecord::Done {
                    sync_id,
                    total: bytes_total,
                    refs: included.clone(),
                }
                .encode_line();
                brain.net_stream_send(stream_id, &done, None, true)?;
                return Ok(SyncServeOutcome::Sent { bytes: bytes_total, refs: included, denied });
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                brain.net_stream_send(stream_id, &[], None, true)?;
                return Ok(SyncServeOutcome::Detached);
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}

/// The refs this node should request from `peer_node`: every local `a-<id>`
/// branch the policy allows toward that peer (symmetric scoping — don't ask
/// for a mind the peer's own gate would refuse to receive elsewhere) plus
/// every hosted `p-<project>` branch. The pull model's requester half:
/// two-tier scoping with no new registry fields (ADR-0013).
// [impl->REQ-INST-5]
pub fn select_refs(
    policy: &SyncPolicy,
    cs: &ContextStore,
    peer_node: &str,
) -> io::Result<Vec<String>> {
    Ok(cs
        .branch_store()
        .branches()?
        .into_iter()
        .filter(|b| policy.allows(b, peer_node))
        .collect())
}

/// Requester side: pull `refs` from the peer over `conn_id`, apply each
/// fetched tip with the vector driver, and report per-branch verdicts.
/// `open_op` is the durable id for the exactly-once stream open; everything
/// after rides fire-and-forget. A torn pull errors — recovery is a fresh
/// `request_sync` (apply is idempotent; FAULT-MATRIX stream-loss row).
// [impl->REQ-NET-3]
pub fn request_sync(
    brain: &mut Brain,
    conn_id: u64,
    refs: &[String],
    open_op: u64,
    cs: &ContextStore,
    scratch_dir: &Path,
) -> io::Result<SyncPullReport> {
    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 sync_id = format!("pull-{open_op}");
    let mut have_tips = BTreeMap::new();
    for r in refs {
        if let Some(tip) = cs.branch_store().tip(r)? {
            have_tips.insert(r.clone(), tip);
        }
    }
    let line = SyncRecord::Request {
        sync_id: sync_id.clone(),
        refs: refs.to_vec(),
        have_tips,
    }
    .encode_line();
    brain.net_stream_send(stream_id, &line, None, true)?;

    std::fs::create_dir_all(scratch_dir)?;
    let bundle: PathBuf = scratch_dir.join(format!("recv-{}.bundle", safe_stem(&sync_id)));
    let mut file = Some(
        std::fs::OpenOptions::new().create(true).truncate(true).write(true).open(&bundle)?,
    );
    let mut report = SyncPullReport::default();
    let mut decoder = SyncDecoder::new();

    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        SyncRecord::Deny { sync_id: xid, refs, .. } if xid == sync_id => {
                            report.denied.extend(refs);
                        }
                        SyncRecord::Chunk { sync_id: xid, offset, data_b64 }
                            if xid == sync_id =>
                        {
                            let chunk = decode_bytes(&data_b64)
                                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                            let f = file


crates\spt-daemon\tests\xfer.rs:26:use spt_daemon::xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
crates\spt-daemon\tests\xfer.rs:139:        serve_xfer(&mut target, stream_a, "ling", &origin, 0, &root, &progress_a).expect("serve");
crates\spt-daemon\tests\xfer.rs:168:        serve_xfer(&mut target, stream2, "ling", &origin2, 0, &root, &progress_a).expect("serve2");
crates\spt-daemon\tests\xfer.rs:256:    let outcome = serve_xfer(&mut life2, stream_a, "ling", &origin, 0, &root, &progress_a)
crates\spt-daemon\src\lib.rs:172:    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
crates\spt-daemon\src\lib.rs:175:pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
crates\spt-daemon\tests\sync.rs:33:    reconcile_after_sync, request_sync, serve_sync, ReconcileWiring, SyncPolicy, SyncServeOutcome,
crates\spt-daemon\tests\sync.rs:176:            request_sync(&mut req_brain, conn.conn_id, &refs_owned, open_op, &cs, &scratch_req)
crates\spt-daemon\tests\sync.rs:182:        let outcome = serve_sync(
crates\spt-daemon\tests\sync.rs:432:        request_sync(
crates\spt-daemon\src\sync.rs:7://! current tips; the responder's [`serve_sync`] gates every ref, builds an
crates\spt-daemon\src\sync.rs:162:pub fn serve_sync(
crates\spt-daemon\src\sync.rs:289:/// `request_sync` (apply is idempotent; FAULT-MATRIX stream-loss row).
crates\spt-daemon\src\sync.rs:291:pub fn request_sync(
crates\spt-daemon\src\xfer.rs:7://! [`serve_xfer`] answers. Compute + files otherwise stay on the target
crates\spt-daemon\src\xfer.rs:211:pub fn serve_xfer(


Pattern clear. Check D6 plan format + config seams before drafting D7 plan.


1	# M4-D6 — cross-node Psyche sync (P2P), retire gh-repo interim (JIT plan)
2
3	**Status:** D6a COMPLETE (D6a-1 `8b42452`, D6a-2 `1ef9802` — CI-green); D6b COMPLETE
4	(D6b-1 `7e9c2b5` CI-green: marker v2 + vectors + decide_merge + conflict artifacts +
5	hazard 6.6; D6b-2 `50620e4`: bounded turn driver (D7.5a) + reconcile_file +
6	is_reconciler election). D6c COMPLETE (D6c-1 `f08aedc`: BranchStore bundle plumbing
7	+ `syncmerge::apply_fetched` per-file vector driver, REQ-NET-3 activated; D6c-2:
8	SyncRecord wire + SyncPolicy server gate + serve_sync/request_sync/select_refs +
9	reconcile_after_sync election wiring + loopback two-daemon E2E + FAULT-MATRIX rows
10	14–17, REQ-INST-5 activated). **Production trigger loops** (context-write notify →
11	peers pull; dormant→active freshness pull; periodic anti-entropy) are thin calls
12	over `select_refs` + `request_sync` once the daemon lifecycle holds peer conns —
13	wired at D9 with the two-host topology (the same deferral as the D5 serve loops'
14	daemon dispatch). Remaining D6 exit: D9 two-host int legs; gh-repo interim sync
15	retires after the D9 proof.
16	Drafted 2026-06-03 at D6 start; all design forks resolved with user → **ADR-0013**;
17	decisions mirrored into M4-PLAN §D6b/§D7.5a, CONTEXT.md, STORAGE.md, KNOWN-HAZARDS
18	6.5/6.6.
19
20	## Goal
21
22	The mind follows the user across machines: an endpoint's **live context** replicates to
23	all its instances, **project context** only to same-project instances (loopback
24	two-daemon in CI; two-host at D9). A concurrent cross-node write is **surfaced and
25	Psyche-reconciled, never silently dropped**. Reqs: REQ-NET-3, REQ-INST-2, REQ-INST-5,
26	REQ-STORE-1, REQ-HAZARD-DIRECT-WRITE-PRECEDENCE (distributed generalization).
27
28	## Decisions (locked — ADR-0013, user-confirmed 2026-06-03)
29
30	- **Merge order = version vectors, wall-clock never orders.** Marker grows
31	  `spt:node=<node_id> spt:vector=<node:epoch,…>`; entries from each node's monotonic
32	  `EpochSource` (`spt-store/src/epoch.rs` — the same counter behind the D3b lease, the
33	  planned unification). Dominate→accept, dominated→drop, concurrent→surface. A
34	  missing/legacy vector = ⊥ (dominated by any vector; two ⊥s fall back to the local
35	  6.5 window rule).
36	- **Concurrent → Psyche auto-reconcile, single reconciler.** Both versions persist as
37	  **tracked conflict artifacts** (deterministic content-hash names — idempotent,
38	  replicate like any context file; local working file untouched). The **active
39	  instance's node** runs one bounded, stdin-fed, **stdout-captured** Psyche turn over
40	  both versions; fallback reconciler = lowest node id among holders. Merged write =
41	  `join(vA,vB)` + reconciler bump — dominates both parents, clears artifacts
42	  subnet-wide. Turn bounded (5.3) + output validated (non-empty, marker-parseable);
43	  any failure leaves artifacts for retry at next sync/activation + fires the event
44	  seam (D8 notif producer later). Reconciled output is `source=llm` — the 6.5
45	  direct-write window still applies.
46	- **D7.5a pulled forward into D6b.** The bounded stdout-captured Psyche turn driver
47	  (NOT the `Stdio::null()` interim, 7.3) is built here; D7.5 keeps b/c (taxonomy +
48	  relay/sanitize). Driver code tagged `[impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]` as
49	  evidence, but that req **activates at D7.5** (full invariant needs the relay).
50	- **Transport = git-native bundles over broker QUIC streams; the DAG is shared.**
51	  Pull-based, **ref-scoped**: a node requests `a-<id>` for endpoints with
52	  `synced(E,S)` (the D3e gate, finally consumed) and `p-<project>` only for projects
53	  it hosts — two-tier scoping falls out of the pull model, no new registry fields.
54	  Responder builds an incremental `git bundle` (`^have` exclusions); requester fetches
55	  from it + merges with the vector driver; first contact merges unrelated seeds once
56	  (`--allow-unrelated-histories`). Divergent per-node histories rejected.
57	- **BranchStore v1 = git-CLI-backed** (system `git`; the `git pull` handoff seam
58	  already assumes it) behind a trait so gitoxide is a later drop-in. Commit-per-write
59	  latency measured in D6a tests (the ADR-0011 spike, empirically).
60	- **Hub mode (gh opt-in) stays a deferred seam** — D6 is P2P only. Interim
61	  gh-repo-sync skill retires after the D9 two-host proof.
62
63	## Pieces (build order)
64
65	1. **D6a — context store realization** (REQ-STORE-1, REQ-INST-2).
66	   `spt-store::branchstore`: ensure-repo/ensure-branch, **commit = checkpoint, tip =
67	   resume**, linked worktree views with relative paths (`worktree.useRelativePaths`,
68	   Git ≥ 2.48; else `git worktree repair` fallback — STORAGE §Worktree portability).
69	   `tracked/` layout + path fns in `perch.rs` (`agents/<id>/live-context.md` on
70	   `a-<id>`; `projects/<pid>/<id>/project-context.md` on `p-<pid>` —
71	   REQ-HAZARD-SINGLE-PATH-SOURCE tags). `project_id` derivation (git remote URL →
72	   folder-name fallback, STORAGE §project_id). Ingest migration: commune drops'
73	   `<live-context>`/`<project-context>` slices route into the tier files
74	   (precedence-guarded writes, `ingest.rs` folds there instead of flat `context.md`);
75	   resume/preload reads follow. `spt rename` context-branch arm (the REQ-INST-11
76	   seam noted at D3f: `a-<old>` → `a-<new>`).
77	2. **D6b — distributed precedence + Psyche reconcile** (extends
78	   REQ-HAZARD-DIRECT-WRITE-PRECEDENCE; new hazard req below).
79	   `VersionVector` type + dominance (pure, hermetic); marker v2 parse/render
80	   (back-compat: v1 markers = ⊥ vector); per-file vector merge fn; conflict artifact
81	   write/clear; reconciler election (registry Active row → that node; none → lowest
82	   node id); **the bounded stdout-captured Psyche turn driver** (per-turn `--resume`,
83	   timeout 5.3, captured stdout = the merged file candidate); reconcile flow + output
84	   validation + retry-on-failure; event seam record. Negative tests: turn timeout,
85	   garbage output, harness absent — artifacts persist every time.
86	3. **D6c — bundle sync over the wire + gates** (REQ-NET-3, REQ-INST-5; consumes
87	   REQ-INST-13). NDJSON control records (`SyncRequest { refs, have_tips }` /
88	   bundle-chunk records riding the D5c xfer chunking) over broker streams; responder
89	   ref-scope enforcement (**`synced(E,S)` + visibility gate checked server-side too**
90	   — a peer must not be able to pull a hidden/unsynced mind, fail-closed); requester
91	   fetch + driver merge + two-parent merge commit; sync triggers: context-write
92	   notify → peers pull, **activation freshness feed** (dormant→active pulls first —
93	   the cross-instance context-freshness auto-feed + the D5c handoff seam's
94	   fresh-with-preload "mind" leg), periodic anti-entropy backstop. Loopback
95	   two-daemon tests: two-tier scoping (live everywhere, project same-project only),
96	   unsynced/hidden refused server-side, concurrent-write conflict → artifacts on both
97	   nodes → reconcile on active node → merged write converges everywhere.
98	4. **FAULT-MATRIX rows** (same change as the piece that lands them): stream loss
99	   mid-bundle (re-pull idempotent), partition concurrent writes (conflict path),
100	   reconcile turn failure (artifacts persist), registry ambiguity during election
101	   (re-conflict of merged outputs detected, not lost).
102
103	## Requirement activation (rule 5)
104
105	- **REQ-STORE-1, REQ-INST-2** → `[impl, unit]` at D6a.
106	- **New (rule 3): `REQ-HAZARD-CONFLICT-BOTH-PRESERVED`** — a surfaced concurrent pair
107	  is durably preserved (both versions) until a dominating write clears it; no
108	  reconcile path may discard an unmerged version. Register in `traceable-reqs.toml`
109	  at D6b start; `[impl, unit]` at D6b.
110	- **REQ-HAZARD-DIRECT-WRITE-PRECEDENCE** — stays `[impl, unit]`; D6b adds the
111	  multi-writer/node-id evidence.
112	- **REQ-NET-3, REQ-INST-5** → `[impl, unit]` at D6c. `int` = D9 two-host E2E.
113	- **REQ-HAZARD-PSYCHE-OUTBOUND-PROXY** — evidence tagged at D6b, **activates at D7.5**.
114
115	## NOT in D6
116
117	- Hub mode (gh opt-in) — deferred seam; interim skill retires at D9.
118	- D7.5b/c (event taxonomy + daemon relay/sanitize), D8 notif (D6 emits event seam only).
119	- memformat tier-tagged topics / block-level OCC (CONTEXT-MEMORY §Deferred) — the
120	  two-tier *files* land here; the schema evolution is post-v1.
121	- Two-host integration — D9 (loopback shapes only here).
122
123	## Conventions (carried)
124
125	- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence.
126	  `traceable-reqs check` from repo root before done. Linux CI clippy `-D warnings` is
127	  the real gate. Push each slice → watch green before next (verify FINAL via
128	  `gh run list --json conclusion`).
129	  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
130

# M4-D6 — cross-node Psyche sync (P2P), retire gh-repo interim (JIT plan)

**Status:** D6a COMPLETE (D6a-1 `8b42452`, D6a-2 `1ef9802` — CI-green); D6b COMPLETE
(D6b-1 `7e9c2b5` CI-green: marker v2 + vectors + decide_merge + conflict artifacts +
hazard 6.6; D6b-2 `50620e4`: bounded turn driver (D7.5a) + reconcile_file +
is_reconciler election). D6c COMPLETE (D6c-1 `f08aedc`: BranchStore bundle plumbing
+ `syncmerge::apply_fetched` per-file vector driver, REQ-NET-3 activated; D6c-2:
SyncRecord wire + SyncPolicy server gate + serve_sync/request_sync/select_refs +
reconcile_after_sync election wiring + loopback two-daemon E2E + FAULT-MATRIX rows
14–17, REQ-INST-5 activated). **Production trigger loops** (context-write notify →
peers pull; dormant→active freshness pull; periodic anti-entropy) are thin calls
over `select_refs` + `request_sync` once the daemon lifecycle holds peer conns —
wired at D9 with the two-host topology (the same deferral as the D5 serve loops'
daemon dispatch). Remaining D6 exit: D9 two-host int legs; gh-repo interim sync
retires after the D9 proof.
Drafted 2026-06-03 at D6 start; all design forks resolved with user → **ADR-0013**;
decisions mirrored into M4-PLAN §D6b/§D7.5a, CONTEXT.md, STORAGE.md, KNOWN-HAZARDS
6.5/6.6.

## Goal

The mind follows the user across machines: an endpoint's **live context** replicates to
all its instances, **project context** only to same-project instances (loopback
two-daemon in CI; two-host at D9). A concurrent cross-node write is **surfaced and
Psyche-reconciled, never silently dropped**. Reqs: REQ-NET-3, REQ-INST-2, REQ-INST-5,
REQ-STORE-1, REQ-HAZARD-DIRECT-WRITE-PRECEDENCE (distributed generalization).

## Decisions (locked — ADR-0013, user-confirmed 2026-06-03)

- **Merge order = version vectors, wall-clock never orders.** Marker grows
  `spt:node=<node_id> spt:vector=<node:epoch,…>`; entries from each node's monotonic
  `EpochSource` (`spt-store/src/epoch.rs` — the same counter behind the D3b lease, the
  planned unification). Dominate→accept, dominated→drop, concurrent→surface. A
  missing/legacy vector = ⊥ (dominated by any vector; two ⊥s fall back to the local
  6.5 window rule).
- **Concurrent → Psyche auto-reconcile, single reconciler.** Both versions persist as
  **tracked conflict artifacts** (deterministic content-hash names — idempotent,
  replicate like any context file; local working file untouched). The **active
  instance's node** runs one bounded, stdin-fed, **stdout-captured** Psyche turn over
  both versions; fallback reconciler = lowest node id among holders. Merged write =
  `join(vA,vB)` + reconciler bump — dominates both parents, clears artifacts
  subnet-wide. Turn bounded (5.3) + output validated (non-empty, marker-parseable);
  any failure leaves artifacts for retry at next sync/activation + fires the event
  seam (D8 notif producer later). Reconciled output is `source=llm` — the 6.5
  direct-write window still applies.
- **D7.5a pulled forward into D6b.** The bounded stdout-captured Psyche turn driver
  (NOT the `Stdio::null()` interim, 7.3) is built here; D7.5 keeps b/c (taxonomy +
  relay/sanitize). Driver code tagged `[impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]` as
  evidence, but that req **activates at D7.5** (full invariant needs the relay).
- **Transport = git-native bundles over broker QUIC streams; the DAG is shared.**
  Pull-based, **ref-scoped**: a node requests `a-<id>` for endpoints with
  `synced(E,S)` (the D3e gate, finally consumed) and `p-<project>` only for projects
  it hosts — two-tier scoping falls out of the pull model, no new registry fields.
  Responder builds an incremental `git bundle` (`^have` exclusions); requester fetches
  from it + merges with the vector driver; first contact merges unrelated seeds once
  (`--allow-unrelated-histories`). Divergent per-node histories rejected.
- **BranchStore v1 = git-CLI-backed** (system `git`; the `git pull` handoff seam
  already assumes it) behind a trait so gitoxide is a later drop-in. Commit-per-write
  latency measured in D6a tests (the ADR-0011 spike, empirically).
- **Hub mode (gh opt-in) stays a deferred seam** — D6 is P2P only. Interim
  gh-repo-sync skill retires after the D9 two-host proof.

## Pieces (build order)

1. **D6a — context store realization** (REQ-STORE-1, REQ-INST-2).
   `spt-store::branchstore`: ensure-repo/ensure-branch, **commit = checkpoint, tip =
   resume**, linked worktree views with relative paths (`worktree.useRelativePaths`,
   Git ≥ 2.48; else `git worktree repair` fallback — STORAGE §Worktree portability).
   `tracked/` layout + path fns in `perch.rs` (`agents/<id>/live-context.md` on
   `a-<id>`; `projects/<pid>/<id>/project-context.md` on `p-<pid>` —
   REQ-HAZARD-SINGLE-PATH-SOURCE tags). `project_id` derivation (git remote URL →
   folder-name fallback, STORAGE §project_id). Ingest migration: commune drops'
   `<live-context>`/`<project-context>` slices route into the tier files
   (precedence-guarded writes, `ingest.rs` folds there instead of flat `context.md`);
   resume/preload reads follow. `spt rename` context-branch arm (the REQ-INST-11
   seam noted at D3f: `a-<old>` → `a-<new>`).
2. **D6b — distributed precedence + Psyche reconcile** (extends
   REQ-HAZARD-DIRECT-WRITE-PRECEDENCE; new hazard req below).
   `VersionVector` type + dominance (pure, hermetic); marker v2 parse/render
   (back-compat: v1 markers = ⊥ vector); per-file vector merge fn; conflict artifact
   write/clear; reconciler election (registry Active row → that node; none → lowest
   node id); **the bounded stdout-captured Psyche turn driver** (per-turn `--resume`,
   timeout 5.3, captured stdout = the merged file candidate); reconcile flow + output
   validation + retry-on-failure; event seam record. Negative tests: turn timeout,
   garbage output, harness absent — artifacts persist every time.
3. **D6c — bundle sync over the wire + gates** (REQ-NET-3, REQ-INST-5; consumes
   REQ-INST-13). NDJSON control records (`SyncRequest { refs, have_tips }` /
   bundle-chunk records riding the D5c xfer chunking) over broker streams; responder
   ref-scope enforcement (**`synced(E,S)` + visibility gate checked server-side too**
   — a peer must not be able to pull a hidden/unsynced mind, fail-closed); requester
   fetch + driver merge + two-parent merge commit; sync triggers: context-write
   notify → peers pull, **activation freshness feed** (dormant→active pulls first —
   the cross-instance context-freshness auto-feed + the D5c handoff seam's
   fresh-with-preload "mind" leg), periodic anti-entropy backstop. Loopback
   two-daemon tests: two-tier scoping (live everywhere, project same-project only),
   unsynced/hidden refused server-side, concurrent-write conflict → artifacts on both
   nodes → reconcile on active node → merged write converges everywhere.
4. **FAULT-MATRIX rows** (same change as the piece that lands them): stream loss
   mid-bundle (re-pull idempotent), partition concurrent writes (conflict path),
   reconcile turn failure (artifacts persist), registry ambiguity during election
   (re-conflict of merged outputs detected, not lost).

## Requirement activation (rule 5)

- **REQ-STORE-1, REQ-INST-2** → `[impl, unit]` at D6a.
- **New (rule 3): `REQ-HAZARD-CONFLICT-BOTH-PRESERVED`** — a surfaced concurrent pair
  is durably preserved (both versions) until a dominating write clears it; no
  reconcile path may discard an unmerged version. Register in `traceable-reqs.toml`
  at D6b start; `[impl, unit]` at D6b.
- **REQ-HAZARD-DIRECT-WRITE-PRECEDENCE** — stays `[impl, unit]`; D6b adds the
  multi-writer/node-id evidence.
- **REQ-NET-3, REQ-INST-5** → `[impl, unit]` at D6c. `int` = D9 two-host E2E.
- **REQ-HAZARD-PSYCHE-OUTBOUND-PROXY** — evidence tagged at D6b, **activates at D7.5**.

## NOT in D6

- Hub mode (gh opt-in) — deferred seam; interim skill retires at D9.
- D7.5b/c (event taxonomy + daemon relay/sanitize), D8 notif (D6 emits event seam only).
- memformat tier-tagged topics / block-level OCC (CONTEXT-MEMORY §Deferred) — the
  two-tier *files* land here; the schema evolution is post-v1.
- Two-host integration — D9 (loopback shapes only here).

## Conventions (carried)

- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence.
  `traceable-reqs check` from repo root before done. Linux CI clippy `-D warnings` is
  the real gate. Push each slice → watch green before next (verify FINAL via
  `gh run list --json conclusion`).
  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.


crates\spt-daemon\src\lib.rs:165:    RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy,


103:        let reply = brain.net_streams().expect("net-streams");
149:    /// The broker's stream table holds BOTH inbound and locally-opened
161:            .net_streams()


40	use spt_proto::identity::Identity;
41	use spt_runtime::{Manifest, ManifestRuntime};
42	use spt_store::contextstore::{ContextStore, LIVE_CONTEXT_FILE};
43	use spt_store::trust::TrustStore;
44	use spt_store::visibility::VisibilityStore;
45	use tempfile::TempDir;
46
47	/// Hold a temp home for the whole test binary (the reconcile turn's merged
48	/// write stamps the node identity under SPT_HOME).
49	static HOME: OnceLock<TempDir> = OnceLock::new();
50	fn init_home() {
51	    HOME.get_or_init(|| {
52	        let dir = TempDir::new().expect("temp dir");
53	        std::env::set_var("SPT_HOME", dir.path());
54	        dir
55	    });
56	}
57
58	static SEQ: AtomicU32 = AtomicU32::new(0);
59	fn unique_name() -> String {
60	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
61	    format!("spt-daemon-d6c-{}-{}.sock", std::process::id(), n)
62	}
63
64	/// One op-id namespace across every brain in the binary — journaled ops must
65	/// never collide.
66	static OP: AtomicU64 = AtomicU64::new(1);
67	fn op() -> u64 {
68	    OP.fetch_add(1, Ordering::Relaxed)
69	}
70
71	fn hermetic() -> NetConfig {
72	    NetConfig {
73	        identity: Identity::generate(),
74	        relay: RelayPolicy::Disabled,
75	        discovery: LocalDiscovery::Off,
76	        scope: BindScope::Loopback,
77	    }
78	}
79
80	fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
81	    let host = NetHost::start(hermetic()).expect("net host start");
82	    let broker =
83	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
84	    let serve = Arc::clone(&broker);
85	    thread::spawn(move || {
86	        let _ = serve.serve();
87	    });
88	    broker
89	}
90
91	fn connect_retry(name: &str) -> Brain {
92	    for _ in 0..300 {
93	        if let Ok(b) = Brain::cold_start(name, 1) {
94	            return b;
95	        }
96	        thread::sleep(Duration::from_millis(10));
97	    }
98	    panic!("brain could not connect");
99	}
100
101	fn wait_for_stream_except(brain: &mut Brain, skip: &[u64]) -> (u64, String) {
102	    for _ in 0..400 {
103	        let reply = brain.net_streams().expect("net-streams");
104	        if let Some(s) = reply.streams.iter().find(|s| !skip.contains(&s.stream_id)) {
105	            return (s.stream_id, s.remote_id_hex.clone());
106	        }
107	        thread::sleep(Duration::from_millis(5));
108	    }
109	    panic!("sync stream never appeared at the responder's broker");
110	}
111
112	/// A marker-stamped context body (vectors hand-crafted per node so the two
113	/// in-process "nodes" can fork causally — the live write path stamps these in
114	/// production).
115	fn stamped(vector: &str, body: &str) -> String {
116	    format!("<!-- spt:source=llm spt:routed_at_ms=1000 spt:node=t spt:vector={vector} -->\n{body}")
117	}
118
119	fn write_live(cs: &ContextStore, id: &str, content: &str) {
120	    let p = cs.live_context_path(id).unwrap();
121	    std::fs::write(&p, content).unwrap();
122	    cs.commit_live(id, "write").unwrap();
123	}
124
125	/// A policy that trusts `peer` in subnet `home` and syncs `synced_ids` there.
126	fn policy_for(peer: &str, synced_ids: &[&str]) -> SyncPolicy {
127	    let mut trust = TrustStore::default();
128	    trust.record("home", peer, None, None);
129	    let mut vis = VisibilityStore::default();
130	    for id in synced_ids {
131	        vis.set_sync_subnets(id, vec!["home".to_string()]);
132	    }
133	    SyncPolicy { subnets: vec![("home".to_string(), false)], trust, vis }
134	}
135
136	/// One pull direction between two daemons: requester ← responder.
137	struct PullLink {
138	    requester: String,
139	    requester_root: std::path::PathBuf,
140	    responder: String,
141	    responder_root: std::path::PathBuf,
142	    scratch: std::path::PathBuf,
143	}
144
145	impl PullLink {
146	    /// Run one pull: requester thread + responder serve. Returns
147	    /// `(pull report, serve outcome)`.
148	    ///
149	    /// The broker's stream table holds BOTH inbound and locally-opened
150	    /// streams, so the responder must not grab one of its own (or an earlier
151	    /// pull's) rows: snapshot the table before the requester opens its
152	    /// stream, then wait for the genuinely new one.
153	    fn pull(
154	        &mut self,
155	        refs: &[&str],
156	        policy: &SyncPolicy,
157	    ) -> (spt_daemon::sync::SyncPullReport, SyncServeOutcome) {
158	        let mut serve_brain = connect_retry(&self.responder);
159	        let addr = serve_brain.net_status().expect("status").addr;
160	        let skip: Vec<u64> = serve_brain
161	            .net_streams()
162	            .expect("stream snapshot")
163	            .streams
164	            .iter()
165	            .map(|s| s.stream_id)
166	            .collect();
167
168	        let mut req_brain = connect_retry(&self.requester);
169	        let conn = req_brain.net_dial(addr, Some(op())).expect("dial");
170	        let refs_owned: Vec<String> = refs.iter().map(|r| r.to_string()).collect();
171	        let req_root = self.requester_root.clone();
172	        let scratch_req = self.scratch.join("req");
173	        let open_op = op();
174	        let puller = thread::spawn(move || {
175	            let cs = ContextStore::open_or_init_in(&req_root).unwrap();
176	            request_sync(&mut req_brain, conn.conn_id, &refs_owned, open_op, &cs, &scratch_req)
177	                .expect("pull")
178	        });
179
180	        let (stream, origin) = wait_for_stream_except(&mut serve_brain, &skip);
181	        let cs = ContextStore::open_or_init_in(&self.responder_root).unwrap();
182	        let outcome = serve_sync(
183	            &mut serve_brain,
184	            stream,
185	            &origin,
186	            0,
187	            &cs,
188	            policy,
189	            &self.scratch.join("serve"),
190	        )
191	        .expect("serve");
192	        (puller.join().expect("pull thread"), outcome)
193	    }
194	}
195
196	// [unit->REQ-INST-5] two-tier sync over the wire: a-<id> + p-<project> land
197	// byte-identical at the requester, while an unsynced mind (fail-closed: no
198	// membership list) and a hidden one are refused SERVER-SIDE even when named in
199	// the request — under the handshake-proven origin, never payload bytes.
200	// [unit->REQ-NET-3]
201	// [unit->REQ-HAZARD-WAN-ORIGIN-AUTH]
202	#[test]
203	fn two_tier_sync_lands_and_gate_refuses_server_side() {
204	    init_home();
205	    let dir = tempfile::tempdir().expect("tempdir");
206	    let (name_a, name_b) = (unique_name(), unique_name());
207	    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
208	    let _broker_b = net_broker(&name_b, &dir.path().join("b"));
209	    let (root_a, root_b) = (dir.path().join("tracked-a"), dir.path().join("tracked-b"));
210	    let scratch = dir.path().join("scratch");
211	    let mut b_from_a = PullLink {
212	        requester: name_b.clone(),
213	        requester_root: root_b.clone(),
214	        responder: name_a.clone(),
215	        responder_root: root_a.clone(),
216	        scratch: scratch.clone(),
217	    };
218
219	    // Node A hosts: doyle (synced), ghost (no membership list), casper
220	    // (listed but hidden), and the spt-core project.
221	    let a = ContextStore::open_or_init_in(&root_a).unwrap();
222	    write_live(&a, "doyle", &stamped("aa:1", "doyle mind"));
223	    write_live(&a, "ghost", &stamped("aa:1", "ghost mind"));
224	    write_live(&a, "casper", &stamped("aa:1", "casper mind"));
225	    let proj = a.project_context_path("spt-core", "doyle").unwrap();
226	    std::fs::write(&proj, stamped("aa:1", "doyle project mind")).unwrap();
227	    a.commit_project("spt-core", "proj").unwrap().unwrap();
228
229	    // A's policy: B's node is a trusted peer; doyle + casper are listed, but
230	    // casper is hidden in home (hidden ⟹ not synced, ADR-0006 §6).
231	    let b_node = connect_retry(&name_b).net_status().unwrap().node_id_hex.unwrap();
232	    let mut policy = policy_for(&b_node, &["doyle", "casper"]);
233	    policy.vis.set_override("casper", "home", Some(true));
234
235	    // B asks for everything by name — the gate, not the request, decides.
236	    let (report, outcome) =
237	        b_from_a.pull(&["a-doyle", "p-spt-core", "a-ghost", "a-casper"], &policy);
238	    match &outcome {
239	        SyncServeOutcome::Sent { refs, denied, .. } => {
240	            assert_eq!(refs, &["a-doyle".to_string(), "p-spt-core".to_string()]);
241	            assert!(denied.contains(&"a-ghost".to_string()), "unsynced refused: {denied:?}");
242	            assert!(denied.contains(&"a-casper".to_string()), "hidden refused: {denied:?}");
243	        }
244	        other => panic!("expected Sent, got {other:?}"),
245	    }
246	    assert_eq!(report.denied.len(), 2, "requester observes the refusal: {report:?}");
247	    assert!(report.applied.iter().all(|r| r.adopted), "first contact adopts");
248
249	    // Byte-identical across the wire; the refused minds never landed.
250	    assert_eq!(
251	        std::fs::read_to_string(root_b.join("agents/doyle/live-context.md")).unwrap(),
252	        stamped("aa:1", "doyle mind")
253	    );
254	    assert_eq!(
255	        std::fs::read_to_string(root_b.join("projects/spt-core/doyle/project-context.md"))
256	            .unwrap(),
257	        stamped("aa:1", "doyle project mind")
258	    );
259	    assert!(!root_b.join("agents/ghost").exists(), "unsynced mind never landed");

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

/// Hold a temp home for the whole test binary (the reconcile turn's merged
/// write stamps the node identity under SPT_HOME).
static HOME: OnceLock<TempDir> = OnceLock::new();
fn init_home() {
    HOME.get_or_init(|| {
        let dir = TempDir::new().expect("temp dir");
        std::env::set_var("SPT_HOME", dir.path());
        dir
    });
}

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

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

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

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

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

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

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

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

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

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

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

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

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

// [unit->REQ-INST-5] two-tier sync over the wire: a-<id> + p-<project> land
// byte-identical at the requester, while an unsynced mind (fail-closed: no
// membership list) and a hidden one are refused SERVER-SIDE even when named in
// the request — under the handshake-proven origin, never payload bytes.
// [unit->REQ-NET-3]
// [unit->REQ-HAZARD-WAN-ORIGIN-AUTH]
#[test]
fn two_tier_sync_lands_and_gate_refuses_server_side() {
    init_home();
    let dir = tempfile::tempdir().expect("tempdir");
    let (name_a, name_b) = (unique_name(), unique_name());
    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
    let _broker_b = net_broker(&name_b, &dir.path().join("b"));
    let (root_a, root_b) = (dir.path().join("tracked-a"), dir.path().join("tracked-b"));
    let scratch = dir.path().join("scratch");
    let mut b_from_a = PullLink {
        requester: name_b.clone(),
        requester_root: root_b.clone(),
        responder: name_a.clone(),
        responder_root: root_a.clone(),
        scratch: scratch.clone(),
    };

    // Node A hosts: doyle (synced), ghost (no membership list), casper
    // (listed but hidden), and the spt-core project.
    let a = ContextStore::open_or_init_in(&root_a).unwrap();
    write_live(&a, "doyle", &stamped("aa:1", "doyle mind"));
    write_live(&a, "ghost", &stamped("aa:1", "ghost mind"));
    write_live(&a, "casper", &stamped("aa:1", "casper mind"));
    let proj = a.project_context_path("spt-core", "doyle").unwrap();
    std::fs::write(&proj, stamped("aa:1", "doyle project mind")).unwrap();
    a.commit_project("spt-core", "proj").unwrap().unwrap();

    // A's policy: B's node is a trusted peer; doyle + casper are listed, but
    // casper is hidden in home (hidden ⟹ not synced, ADR-0006 §6).
    let b_node = connect_retry(&name_b).net_status().unwrap().node_id_hex.unwrap();
    let mut policy = policy_for(&b_node, &["doyle", "casper"]);
    policy.vis.set_override("casper", "home", Some(true));

    // B asks for everything by name — the gate, not the request, decides.
    let (report, outcome) =
        b_from_a.pull(&["a-doyle", "p-spt-core", "a-ghost", "a-casper"], &policy);
    match &outcome {
        SyncServeOutcome::Sent { refs, denied, .. } => {
            assert_eq!(refs, &["a-doyle".to_string(), "p-spt-core".to_string()]);
            assert!(denied.contains(&"a-ghost".to_string()), "unsynced refused: {denied:?}");
            assert!(denied.contains(&"a-casper".to_string()), "hidden refused: {denied:?}");
        }
        other => panic!("expected Sent, got {other:?}"),
    }
    assert_eq!(report.denied.len(), 2, "requester observes the refusal: {report:?}");
    assert!(report.applied.iter().all(|r| r.adopted), "first contact adopts");

    // Byte-identical across the wire; the refused minds never landed.
    assert_eq!(
        std::fs::read_to_string(root_b.join("agents/doyle/live-context.md")).unwrap(),
        stamped("aa:1", "doyle mind")
    );
    assert_eq!(
        std::fs::read_to_string(root_b.join("projects/spt-core/doyle/project-context.md"))
            .unwrap(),
        stamped("aa:1", "doyle project mind")
    );
    assert!(!root_b.join("agents/ghost").exists(), "unsynced mind never landed");