85	}
86
87	/// One authorized peer: its pinned node pubkey, the subnet it's trusted within, an
88	/// optional human label (the warn-on-change anchor), the cross-user seam, and a stamp.
89	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90	pub struct TrustedPeer {
91	    /// The peer's Ed25519 node pubkey, lowercase hex (the iroh NodeId). The identity.
92	    pub pubkey_hex: String,
93	    /// The subnet this peer paired into / is trusted within.
94	    pub subnet: String,
95	    /// Optional human node label. When present it is the stable handle warn-on-change
96	    /// keys against; absent (v1 default) means the pubkey is the sole identity.
97	    #[serde(skip_serializing_if = "Option::is_none")]
98	    pub label: Option<String>,
99	    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
100	    #[serde(skip_serializing_if = "Option::is_none")]
101	    pub user: Option<String>,
102	    /// First-trust stamp (epoch-seconds string; opaque).
103	    pub paired: String,
104	}
105
106	/// The whole `peers.json` document: every peer this node trusts, across all subnets.
107	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108	pub struct TrustStore {
109	    /// All trusted peers (multi-subnet — one node trusts peers across N subnets).
110	    #[serde(default)]
111	    pub peers: Vec<TrustedPeer>,
112	}
113
114	impl TrustStore {
115	    /// Load from the canonical [`trust_file`]. A missing file is the empty store (an
116	    /// unpaired node) — never an error.
117	    pub fn load() -> TrustStore {
118	        Self::load_from(&trust_file())
119	    }
120
121	    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable or
122	    /// corrupt file also degrades to empty rather than panicking — a damaged trust file
123	    /// must not brick the node (re-pair to rebuild it), the same stance as
124	    /// [`SubnetStore`](crate::subnet::SubnetStore).
125	    pub fn load_from(path: &Path) -> TrustStore {
126	        match std::fs::read_to_string(path) {
127	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
128	            Err(_) => TrustStore::default(),
129	        }
130	    }
131
132	    /// Persist to the canonical [`trust_file`], creating `identity/trust/` if needed.
133	    pub fn save(&self) -> Result<(), TrustError> {
134	        self.save_to(&trust_file())
135	    }
136
137	    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
138	    /// concurrent reader never sees a torn trust file (the [`atomic`](crate::atomic)
139	    /// invariant).
140	    pub fn save_to(&self, path: &Path) -> Result<(), TrustError> {
141	        if let Some(parent) = path.parent() {
142	            std::fs::create_dir_all(parent).map_err(|e| TrustError::Io(e.to_string()))?;
143	        }
144	        let json =
145	            serde_json::to_string_pretty(self).map_err(|e| TrustError::Io(e.to_string()))?;
146	        atomic_write_string(path, &json).map_err(|e| TrustError::Io(e.to_string()))
147	    }
148
149	    /// Whether `pubkey_hex` is a trusted peer in `subnet` — the connect-time gate (an
150	    /// inbound peer the runtime hasn't pinned is not one of the user's own nodes).
151	    // [impl->REQ-PAIR-2]
152	    pub fn is_trusted(&self, subnet: &str, pubkey_hex: &str) -> bool {
153	        self.peers
154	            .iter()
155	            .any(|p| p.subnet == subnet && p.pubkey_hex == pubkey_hex)
156	    }
157
158	    /// All peers trusted in `subnet`.
159	    pub fn peers_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a TrustedPeer> {
160	        self.peers.iter().filter(move |p| p.subnet == subnet)
161	    }
162
163	    /// Record (pin) a peer after a successful pairing ceremony — the TOFU write.
164	    ///
165	    /// Returns the [`TrustOutcome`]: [`Pinned`](TrustOutcome::Pinned) for a first use,
166	    /// [`AlreadyTrusted`](TrustOutcome::AlreadyTrusted) for an idempotent re-pair, or
167	    /// [`KeyChanged`](TrustOutcome::KeyChanged) if a peer under the same `(subnet,
168	    /// label)` already holds a different key — in which case **nothing is written** and
169	    /// the caller must warn + decide ([`repin`](Self::repin)). The `(subnet, user)`
170	    /// scope is honored (a label is only an anchor within its own user scope).
171	    // [impl->REQ-PAIR-2]
172	    pub fn record(
173	        &mut self,
174	        subnet: &str,
175	        pubkey_hex: &str,
176	        label: Option<&str>,
177	        user: Option<&str>,
178	    ) -> TrustOutcome {
179	        // Exact identity already trusted → idempotent (a key isn't "changed" against
180	        // itself, even when a label is present).
181	        if self.is_trusted(subnet, pubkey_hex) {
182	            return TrustOutcome::AlreadyTrusted;
183	        }
184	        // Warn-on-change: a label already bound to a DIFFERENT key in this scope.
185	        if let Some(lbl) = label {
186	            if let Some(existing) = self.peers.iter().find(|p| {
187	                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(lbl)
188	            }) {
189	                return TrustOutcome::KeyChanged {
190	                    existing_pubkey_hex: existing.pubkey_hex.clone(),
191	                };
192	            }
193	        }
194	        self.peers.push(TrustedPeer {
195	            pubkey_hex: pubkey_hex.to_string(),
196	            subnet: subnet.to_string(),
197	            label: label.map(str::to_string),
198	            user: user.map(str::to_string),
199	            paired: now_stamp(),
200	        });
201	        TrustOutcome::Pinned
202	    }
203
204	    /// Apply an **accepted** key change for a labelled peer: replace the pinned key for
205	    /// `(subnet, label)` with `new_pubkey_hex`. Call this only after a
206	    /// [`KeyChanged`](TrustOutcome::KeyChanged) outcome was surfaced and the operator
207	    /// confirmed the rotation (otherwise a MITM's key would be trusted). Errors if no
208	    /// peer holds that label in scope.
209	    // [impl->REQ-PAIR-2]

}

/// One authorized peer: its pinned node pubkey, the subnet it's trusted within, an
/// optional human label (the warn-on-change anchor), the cross-user seam, and a stamp.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustedPeer {
    /// The peer's Ed25519 node pubkey, lowercase hex (the iroh NodeId). The identity.
    pub pubkey_hex: String,
    /// The subnet this peer paired into / is trusted within.
    pub subnet: String,
    /// Optional human node label. When present it is the stable handle warn-on-change
    /// keys against; absent (v1 default) means the pubkey is the sole identity.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user: Option<String>,
    /// First-trust stamp (epoch-seconds string; opaque).
    pub paired: String,
}

/// The whole `peers.json` document: every peer this node trusts, across all subnets.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TrustStore {
    /// All trusted peers (multi-subnet — one node trusts peers across N subnets).
    #[serde(default)]
    pub peers: Vec<TrustedPeer>,
}

impl TrustStore {
    /// Load from the canonical [`trust_file`]. A missing file is the empty store (an
    /// unpaired node) — never an error.
    pub fn load() -> TrustStore {
        Self::load_from(&trust_file())
    }

    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable or
    /// corrupt file also degrades to empty rather than panicking — a damaged trust file
    /// must not brick the node (re-pair to rebuild it), the same stance as
    /// [`SubnetStore`](crate::subnet::SubnetStore).
    pub fn load_from(path: &Path) -> TrustStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => TrustStore::default(),
        }
    }

    /// Persist to the canonical [`trust_file`], creating `identity/trust/` if needed.
    pub fn save(&self) -> Result<(), TrustError> {
        self.save_to(&trust_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
    /// concurrent reader never sees a torn trust file (the [`atomic`](crate::atomic)
    /// invariant).
    pub fn save_to(&self, path: &Path) -> Result<(), TrustError> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| TrustError::Io(e.to_string()))?;
        }
        let json =
            serde_json::to_string_pretty(self).map_err(|e| TrustError::Io(e.to_string()))?;
        atomic_write_string(path, &json).map_err(|e| TrustError::Io(e.to_string()))
    }

    /// Whether `pubkey_hex` is a trusted peer in `subnet` — the connect-time gate (an
    /// inbound peer the runtime hasn't pinned is not one of the user's own nodes).
    // [impl->REQ-PAIR-2]
    pub fn is_trusted(&self, subnet: &str, pubkey_hex: &str) -> bool {
        self.peers
            .iter()
            .any(|p| p.subnet == subnet && p.pubkey_hex == pubkey_hex)
    }

    /// All peers trusted in `subnet`.
    pub fn peers_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a TrustedPeer> {
        self.peers.iter().filter(move |p| p.subnet == subnet)
    }

    /// Record (pin) a peer after a successful pairing ceremony — the TOFU write.
    ///
    /// Returns the [`TrustOutcome`]: [`Pinned`](TrustOutcome::Pinned) for a first use,
    /// [`AlreadyTrusted`](TrustOutcome::AlreadyTrusted) for an idempotent re-pair, or
    /// [`KeyChanged`](TrustOutcome::KeyChanged) if a peer under the same `(subnet,
    /// label)` already holds a different key — in which case **nothing is written** and
    /// the caller must warn + decide ([`repin`](Self::repin)). The `(subnet, user)`
    /// scope is honored (a label is only an anchor within its own user scope).
    // [impl->REQ-PAIR-2]
    pub fn record(
        &mut self,
        subnet: &str,
        pubkey_hex: &str,
        label: Option<&str>,
        user: Option<&str>,
    ) -> TrustOutcome {
        // Exact identity already trusted → idempotent (a key isn't "changed" against
        // itself, even when a label is present).
        if self.is_trusted(subnet, pubkey_hex) {
            return TrustOutcome::AlreadyTrusted;
        }
        // Warn-on-change: a label already bound to a DIFFERENT key in this scope.
        if let Some(lbl) = label {
            if let Some(existing) = self.peers.iter().find(|p| {
                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(lbl)
            }) {
                return TrustOutcome::KeyChanged {
                    existing_pubkey_hex: existing.pubkey_hex.clone(),
                };
            }
        }
        self.peers.push(TrustedPeer {
            pubkey_hex: pubkey_hex.to_string(),
            subnet: subnet.to_string(),
            label: label.map(str::to_string),
            user: user.map(str::to_string),
            paired: now_stamp(),
        });
        TrustOutcome::Pinned
    }

    /// Apply an **accepted** key change for a labelled peer: replace the pinned key for
    /// `(subnet, label)` with `new_pubkey_hex`. Call this only after a
    /// [`KeyChanged`](TrustOutcome::KeyChanged) outcome was surfaced and the operator
    /// confirmed the rotation (otherwise a MITM's key would be trusted). Errors if no
    /// peer holds that label in scope.
    // [impl->REQ-PAIR-2]


Now the daemon driver module:


Write

//! Notif feed drivers (M4-D8-2, ADR-0007) — emit + gated apply for the
//! [`NotifRecord`] replication protocol.
//!
//! The wire layer ([`spt_net::net::notif`]) owns the record + framing; the
//! store ([`spt_store::notif`]) owns the semilattice join. This module is the
//! daemon's seam between them: author a feed from the local store, and apply
//! an inbound feed **through the gate**:
//!
//! - the apply subject is the **handshake-proven** origin from the broker's
//!   stream table — never payload bytes (KNOWN-HAZARDS 7.5,
//!   REQ-HAZARD-WAN-ORIGIN-AUTH);
//! - a record for a subnet this node is **not a member of** is dropped
//!   fail-closed (never materialized — the `replicate.rs`/REQ-INST-13
//!   posture: unconfigured replicates nowhere);
//! - a record whose origin is **not trusted in that subnet's trust store** is
//!   dropped fail-closed (a paired-into-`home` peer cannot inject notifs
//!   into `work`).
//!
//! Admitted records go straight through [`NotifStore::merge_row`] — the join
//! is idempotent + commutative, so replay/loss/reorder need no protocol-level
//! recovery (the feed's FAULT-MATRIX rows reduce to "apply again"). **When
//! feeds pump** (the production trigger loop) rides D9 with the other loops;
//! these are the per-feed drivers it calls.
// [impl->REQ-NOTIF-1]

use spt_net::net::notif::NotifRecord;
use spt_store::notif::{MergeOutcome, NotifStore};
use spt_store::trust::TrustStore;

/// The notif apply gate's config: this node's subnet memberships + its pinned
/// peers. Loaded from the canonical stores in production (re-read per feed —
/// config edits take effect without a daemon restart, the access-gate
/// stance); tests construct it directly.
// [impl->REQ-NOTIF-1]
pub struct NotifPolicy {
    /// Names of every subnet this node is a member of.
    pub subnets: Vec<String>,
    pub trust: TrustStore,
}

impl NotifPolicy {
    /// Load from the node's canonical stores (`subnet.json`, `trust/peers.json`).
    pub fn load() -> NotifPolicy {
        let subnets = spt_store::subnet::SubnetStore::load()
            .subnets
            .iter()
            .map(|s| s.name.clone())
            .collect();
        NotifPolicy { subnets, trust: TrustStore::load() }
    }

    /// Whether a record in `subnet` from the handshake-proven `origin_node`
    /// is admitted: member subnet ∧ origin trusted **in that subnet** —
    /// fail-closed on both axes.
    // [impl->REQ-NOTIF-1]
    pub fn admits(&self, subnet: &str, origin_node: &str) -> bool {
        self.subnets.iter().any(|s| s == subnet) && self.trust.is_trusted(subnet, origin_node)
    }
}

/// What the apply did with one inbound record — loud + typed, never silent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NotifApplyVerdict {
    /// Admitted and joined (the store's outcome — `Unchanged` = replay no-op).
    Applied(MergeOutcome),
    /// Dropped: this node is not a member of the record's subnet.
    DroppedNonMember,
    /// Dropped: the origin is not trusted in the record's subnet.
    DroppedUntrusted,
}

/// Author one subnet's feed from the local store: every row, one
/// [`NotifRecord::Row`] NDJSON line each, ready for a broker stream send.
/// Full-row re-presentation is the protocol — the receiver's join makes
/// duplicates free, so the emitter needs no delta bookkeeping.
// [impl->REQ-NOTIF-1]
pub fn emit_notif_feed(store: &NotifStore, subnet: &str) -> rusqlite::Result<Vec<u8>> {
    let mut wire = Vec::new();
    for row in store.list(subnet)? {
        wire.extend_from_slice(&NotifRecord::Row { row }.encode_line());
    }
    Ok(wire)
}

/// Apply decoded inbound records under the gate (module docs). `origin_node`
/// MUST be the handshake-proven identity from the broker's stream table —
/// never anything read from the payload.
// [impl->REQ-NOTIF-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
pub fn apply_notif_feed(
    store: &NotifStore,
    origin_node: &str,
    records: &[NotifRecord],
    policy: &NotifPolicy,
) -> rusqlite::Result<Vec<NotifApplyVerdict>> {
    records
        .iter()
        .map(|record| {
            let NotifRecord::Row { row } = record;
            if !policy.subnets.iter().any(|s| s == &row.subnet) {
                return Ok(NotifApplyVerdict::DroppedNonMember);
            }
            if !policy.trust.is_trusted(&row.subnet, origin_node) {
                return Ok(NotifApplyVerdict::DroppedUntrusted);
            }
            Ok(NotifApplyVerdict::Applied(store.merge_row(row)?))
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_net::net::notif::NotifDecoder;
    use spt_store::epoch::EpochSource;
    use std::path::Path;

    fn store(dir: &Path, name: &str) -> NotifStore {
        NotifStore::open_at(&dir.join(name)).unwrap()
    }

    /// A policy that is a member of `subnets` and trusts `origin` in each.
    fn trusting(subnets: &[&str], origin: &str) -> NotifPolicy {
        let mut trust = TrustStore::default();
        for s in subnets {
            trust.record(s, origin, None, None);
        }
        NotifPolicy { subnets: subnets.iter().map(|s| s.to_string()).collect(), trust }
    }

    // [unit->REQ-NOTIF-1] the emit→decode→apply loop converges two stores:
    // B holds A's produced rows after one feed; a dismissed state fed back
    // latches at A (dismiss replicates); a replayed feed no-ops (Unchanged).
    #[test]
    fn feed_converges_two_stores_and_dismiss_replicates() {
        let dir = tempfile::tempdir().unwrap();
        let a = store(dir.path(), "a.db");
        let b = store(dir.path(), "b.db");
        let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
        a.produce("nodea", &mut epochs, "home", "agent", "doyle", "first").unwrap();
        a.produce("nodea", &mut epochs, "home", "consent", "spt", "second").unwrap();

        // A → B.
        let wire = emit_notif_feed(&a, "home").unwrap();
        let records = NotifDecoder::new().push(&wire);
        let policy_b = trusting(&["home"], "nodea-hex");
        let verdicts = apply_notif_feed(&b, "nodea-hex", &records, &policy_b).unwrap();
        assert_eq!(
            verdicts,
            vec![
                NotifApplyVerdict::Applied(MergeOutcome::Inserted),
                NotifApplyVerdict::Applied(MergeOutcome::Inserted)
            ]
        );
        assert_eq!(b.list("home").unwrap(), a.list("home").unwrap(), "converged");

        // Replay: the same feed again is wholly Unchanged.
        let replay = apply_notif_feed(&b, "nodea-hex", &records, &policy_b).unwrap();
        assert_eq!(
            replay,
            vec![
                NotifApplyVerdict::Applied(MergeOutcome::Unchanged),
                NotifApplyVerdict::Applied(MergeOutcome::Unchanged)
            ],
            "replayed feed no-ops"
        );

        // B dismisses one; the feed back latches A (dismiss replicates).
        b.dismiss("nodea:1").unwrap();
        let back = NotifDecoder::new().push(&emit_notif_feed(&b, "home").unwrap());
        let policy_a = trusting(&["home"], "nodeb-hex");
        apply_notif_feed(&a, "nodeb-hex", &back, &policy_a).unwrap();
        assert!(a.get("nodea:1").unwrap().unwrap().dismissed, "dismiss replicated to A");
        assert_eq!(a.undismissed("home").unwrap().len(), 1);
    }

    // [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] an untrusted origin's records are
    // dropped fail-closed with zero rows written — and trust is per-subnet:
    // a peer trusted in `home` cannot inject into `work` even when this node
    // is a member of both.
    #[test]
    fn untrusted_and_cross_subnet_origins_are_dropped() {
        let dir = tempfile::tempdir().unwrap();
        let a = store(dir.path(), "a.db");
        let b = store(dir.path(), "b.db");
        let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
        a.produce("nodea", &mut epochs, "home", "agent", "doyle", "x").unwrap();
        a.produce("nodea", &mut epochs, "work", "agent", "doyle", "y").unwrap();
        let home = NotifDecoder::new().push(&emit_notif_feed(&a, "home").unwrap());
        let work = NotifDecoder::new().push(&emit_notif_feed(&a, "work").unwrap());

        // Wholly untrusted origin: dropped, nothing written.
        let unpaired = NotifPolicy { subnets: vec!["home".into()], trust: TrustStore::default() };
        let verdicts = apply_notif_feed(&b, "evil-hex", &home, &unpaired).unwrap();
        assert_eq!(verdicts, vec![NotifApplyVerdict::DroppedUntrusted]);
        assert!(b.list("home").unwrap().is_empty(), "zero rows written");

        // Trusted in home only, member of both: the work record still drops.
        let mut policy = trusting(&["home"], "nodea-hex");
        policy.subnets.push("work".into());
        let verdicts = apply_notif_feed(&b, "nodea-hex", &work, &policy).unwrap();
        assert_eq!(verdicts, vec![NotifApplyVerdict::DroppedUntrusted], "per-subnet trust");
        assert!(b.list("work").unwrap().is_empty());
    }

    // [unit->REQ-NOTIF-1] a record for a subnet this node is no member of is
    // dropped fail-closed — never materialized, whatever the origin's trust.
    #[test]
    fn non_member_subnet_record_never_materializes() {
        let dir = tempfile::tempdir().unwrap();
        let a = store(dir.path(), "a.db");
        let b = store(dir.path(), "b.db");
        let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
        a.produce("nodea", &mut epochs, "elsewhere", "agent", "doyle", "x").unwrap();
        let records = NotifDecoder::new().push(&emit_notif_feed(&a, "elsewhere").unwrap());

        // Origin even trusted in `elsewhere` — membership still gates.
        let mut policy = trusting(&["home"], "nodea-hex");
        policy.trust.record("elsewhere", "nodea-hex", None, None);
        let verdicts = apply_notif_feed(&b, "nodea-hex", &records, &policy).unwrap();
        assert_eq!(verdicts, vec![NotifApplyVerdict::DroppedNonMember]);
        assert!(b.list("elsewhere").unwrap().is_empty(), "never materialized");
    }

    // [unit->REQ-NOTIF-1] concurrent state advances on two nodes commute
    // through the feed: a dismiss on one and a surface on the other both
    // survive the bidirectional exchange (the semilattice, end to end).
    #[test]
    fn concurrent_dismiss_and_surface_commute_across_the_feed() {
        let dir = tempfile::tempdir().unwrap();
        let a = store(dir.path(), "a.db");
        let b = store(dir.path(), "b.db");
        let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
        a.produce("nodea", &mut epochs, "home", "agent", "doyle", "x").unwrap();

        // Seed B with the row, then diverge: A dismisses, B surfaces.
        let policy_a = trusting(&["home"], "nodeb-hex");
        let policy_b = trusting(&["home"], "nodea-hex");
        let seed = NotifDecoder::new().push(&emit_notif_feed(&a, "home").unwrap());
        apply_notif_feed(&b, "nodea-hex", &seed, &policy_b).unwrap();
        a.dismiss("nodea:1").unwrap();
        b.mark_seen("nodea:1", "ling").unwrap();
        b.touch_surfaced("nodea:1", 9_000).unwrap();

        // Exchange both ways; both advances survive on both stores.
        let from_a = NotifDecoder::new().push(&emit_notif_feed(&a, "home").unwrap());
        let from_b = NotifDecoder::new().push(&emit_notif_feed(&b, "home").unwrap());
        apply_notif_feed(&b, "nodea-hex", &from_a, &policy_b).unwrap();
        apply_notif_feed(&a, "nodeb-hex", &from_b, &policy_a).unwrap();

        let ra = a.get("nodea:1").unwrap().unwrap();
        let rb = b.get("nodea:1").unwrap().unwrap();
        assert_eq!(ra, rb, "stores converged");
        assert!(ra.dismissed && ra.seen.contains("ling") && ra.last_surfaced_ms == 9_000);
    }
}


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

//! Notif feed drivers (M4-D8-2, ADR-0007) — emit + gated apply for the
//! [`NotifRecord`] replication protocol.
//!
//! The wire layer ([`spt_net::net::notif`]) owns the record + framing; the
//! store ([`spt_store::notif`]) owns the semilattice join. This module is the
//! daemon's seam between them: author a feed from the local store, and apply
//! an inbound feed **through the gate**:
//!
//! - the apply subject is the **handshake-proven** origin from the broker's
//!   stream table — never payload bytes (KNOWN-HAZARDS 7.5,
//!   REQ-HAZARD-WAN-ORIGIN-AUTH);
//! - a record for a subnet this node is **not a member of** is dropped
//!   fail-closed (never materialized — the `replicate.rs`/REQ-INST-13
//!   posture: unconfigured replicates nowhere);
//! - a record whose origin is **not trusted in that subnet's trust store** is
//!   dropped fail-closed (a paired-into-`home` peer cannot inject notifs
//!   into `work`).
//!
//! Admitted records go straight through [`NotifStore::merge_row`] — the join
//! is idempotent + commutative, so replay/loss/reorder need no protocol-level
//! recovery (the feed's FAULT-MATRIX rows reduce to "apply again"). **When
//! feeds pump** (the production trigger loop) rides D9 with the other loops;
//! these are the per-feed drivers it calls.
// [impl->REQ-NOTIF-1]

use spt_net::net::notif::NotifRecord;
use spt_store::notif::{MergeOutcome, NotifStore};
use spt_store::trust::TrustStore;

/// The notif apply gate's config: this node's subnet memberships + its pinned
/// peers. Loaded from the canonical stores in production (re-read per feed —
/// config edits take effect without a daemon restart, the access-gate
/// stance); tests construct it directly.
// [impl->REQ-NOTIF-1]
pub struct NotifPolicy {
    /// Names of every subnet this node is a member of.
    pub subnets: Vec<String>,
    pub trust: TrustStore,
}

impl NotifPolicy {
    /// Load from the node's canonical stores (`subnet.json`, `trust/peers.json`).
    pub fn load() -> NotifPolicy {
        let subnets = spt_store::subnet::SubnetStore::load()
            .subnets
            .iter()
            .map(|s| s.name.clone())
            .collect();
        NotifPolicy { subnets, trust: TrustStore::load() }
    }

    /// Whether a record in `subnet` from the handshake-proven `origin_node`
    /// is admitted: member subnet ∧ origin trusted **in that subnet** —
    /// fail-closed on both axes.
    // [impl->REQ-NOTIF-1]
    pub fn admits(&self, subnet: &str, origin_node: &str) -> bool {
        self.subnets.iter().any(|s| s == subnet) && self.trust.is_trusted(subnet, origin_node)
    }
}

/// What the apply did with one inbound record — loud + typed, never silent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NotifApplyVerdict {
    /// Admitted and joined (the store's outcome — `Unchanged` = replay no-op).
    Applied(MergeOutcome),
    /// Dropped: this node is not a member of the record's subnet.
    DroppedNonMember,
    /// Dropped: the origin is not trusted in the record's subnet.
    DroppedUntrusted,
}

/// Author one subnet's feed from the local store: every row, one
/// [`NotifRecord::Row`] NDJSON line each, ready for a broker stream send.
/// Full-row re-presentation is the protocol — the receiver's join makes
/// duplicates free, so the emitter needs no delta bookkeeping.
// [impl->REQ-NOTIF-1]
pub fn emit_notif_feed(store: &NotifStore, subnet: &str) -> rusqlite::Result<Vec<u8>> {
    let mut wire = Vec::new();
    for row in store.list(subnet)? {
        wire.extend_from_slice(&NotifRecord::Row { row }.encode_line());
    }
    Ok(wire)
}

/// Apply decoded inbound records under the gate (module docs). `origin_node`
/// MUST be the handshake-proven identity from the broker's stream table —
/// never anything read from the payload.
// [impl->REQ-NOTIF-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
pub fn apply_notif_feed(
    store: &NotifStore,
    origin_node: &str,
    records: &[NotifRecord],
    policy: &NotifPolicy,
) -> rusqlite::Result<Vec<NotifApplyVerdict>> {
    records
        .iter()
        .map(|record| {
            let NotifRecord::Row { row } = record;
            if !policy.subnets.iter().any(|s| s == &row.subnet) {
                return Ok(NotifApplyVerdict::DroppedNonMember);
            }
            if !policy.trust.is_trusted(&row.subnet, origin_node) {
                return Ok(NotifApplyVerdict::DroppedUntrusted);
            }
            Ok(NotifApplyVerdict::Applied(store.merge_row(row)?))
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_net::net::notif::NotifDecoder;
    use spt_store::epoch::EpochSource;
    use std::path::Path;

    fn store(dir: &Path, name: &str) -> NotifStore {
        NotifStore::open_at(&dir.join(name)).unwrap()
    }

    /// A policy that is a member of `subnets` and trusts `origin` in each.
    fn trusting(subnets: &[&str], origin: &str) -> NotifPolicy {
        let mut trust = TrustStore::default();
        for s in subnets {
            trust.record(s, origin, None, None);
        }
        NotifPolicy { subnets: subnets.iter().map(|s| s.to_string()).collect(), trust }
    }

    // [unit->REQ-NOTIF-1] the emit→decode→apply loop converges two stores:
    // B holds A's produced rows after one feed; a dismissed state fed back
    // latches at A (dismiss replicates); a replayed feed no-ops (Unchanged).
    #[test]
    fn feed_converges_two_stores_and_dismiss_replicates() {
        let dir = tempfile::tempdir().unwrap();
        let a = store(dir.path(), "a.db");
        let b = store(dir.path(), "b.db");
        let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
        a.produce("nodea", &mut epochs, "home", "agent", "doyle", "first").unwrap();
        a.produce("nodea", &mut epochs, "home", "consent", "spt", "second").unwrap();

        // A → B.
        let wire = emit_notif_feed(&a, "home").unwrap();
        let records = NotifDecoder::new().push(&wire);
        let policy_b = trusting(&["home"], "nodea-hex");
        let verdicts = apply_notif_feed(&b, "nodea-hex", &records, &policy_b).unwrap();
        assert_eq!(
            verdicts,
            vec![
                NotifApplyVerdict::Applied(MergeOutcome::Inserted),
                NotifApplyVerdict::Applied(MergeOutcome::Inserted)
            ]
        );
        assert_eq!(b.list("home").unwrap(), a.list("home").unwrap(), "converged");

        // Replay: the same feed again is wholly Unchanged.
        let replay = apply_notif_feed(&b, "nodea-hex", &records, &policy_b).unwrap();
        assert_eq!(
            replay,
            vec![
                NotifApplyVerdict::Applied(MergeOutcome::Unchanged),
                NotifApplyVerdict::Applied(MergeOutcome::Unchanged)
            ],
            "replayed feed no-ops"
        );

        // B dismisses one; the feed back latches A (dismiss replicates).
        b.dismiss("nodea:1").unwrap();
        let back = NotifDecoder::new().push(&emit_notif_feed(&b, "home").unwrap());
        let policy_a = trusting(&["home"], "nodeb-hex");
        apply_notif_feed(&a, "nodeb-hex", &back, &policy_a).unwrap();
        assert!(a.get("nodea:1").unwrap().unwrap().dismissed, "dismiss replicated to A");
        assert_eq!(a.undismissed("home").unwrap().len(), 1);
    }

    // [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] an untrusted origin's records are
    // dropped fail-closed with zero rows written — and trust is per-subnet:
    // a peer trusted in `home` cannot inject into `work` even when this node
    // is a member of both.
    #[test]
    fn untrusted_and_cross_subnet_origins_are_dropped() {
        let dir = tempfile::tempdir().unwrap();
        let a = store(dir.path(), "a.db");
        let b = store(dir.path(), "b.db");
        let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
        a.produce("nodea", &mut epochs, "home", "agent", "doyle", "x").unwrap();
        a.produce("nodea", &mut epochs, "work", "agent", "doyle", "y").unwrap();
        let home = NotifDecoder::new().push(&emit_notif_feed(&a, "home").unwrap());
        let work = NotifDecoder::new().push(&emit_notif_feed(&a, "work").unwrap());

        // Wholly untrusted origin: dropped, nothing written.
        let unpaired = NotifPolicy { subnets: vec!["home".into()], trust: TrustStore::default() };
        let verdicts = apply_notif_feed(&b, "evil-hex", &home, &unpaired).unwrap();
        assert_eq!(verdicts, vec![NotifApplyVerdict::DroppedUntrusted]);
        assert!(b.list("home").unwrap().is_empty(), "zero rows written");

        // Trusted in home only, member of both: the work record still drops.
        let mut policy = trusting(&["home"], "nodea-hex");
        policy.subnets.push("work".into());
        let verdicts = apply_notif_feed(&b, "nodea-hex", &work, &policy).unwrap();
        assert_eq!(verdicts, vec![NotifApplyVerdict::DroppedUntrusted], "per-subnet trust");
        assert!(b.list("work").unwrap().is_empty());
    }

    // [unit->REQ-NOTIF-1] a record for a subnet this node is no member of is
    // dropped fail-closed — never materialized, whatever the origin's trust.
    #[test]
    fn non_member_subnet_record_never_materializes() {
        let dir = tempfile::tempdir().unwrap();
        let a = store(dir.path(), "a.db");
        let b = store(dir.path(), "b.db");
        let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
        a.produce("nodea", &mut epochs, "elsewhere", "agent", "doyle", "x").unwrap();
        let records = NotifDecoder::new().push(&emit_notif_feed(&a, "elsewhere").unwrap());

        // Origin even trusted in `elsewhere` — membership still gates.
        let mut policy = trusting(&["home"], "nodea-hex");
        policy.trust.record("elsewhere", "nodea-hex", None, None);
        let verdicts = apply_notif_feed(&b, "nodea-hex", &records, &policy).unwrap();
        assert_eq!(verdicts, vec![NotifApplyVerdict::DroppedNonMember]);
        assert!(b.list("elsewhere").unwrap().is_empty(), "never materialized");
    }

    // [unit->REQ-NOTIF-1] concurrent state advances on two nodes commute
    // through the feed: a dismiss on one and a surface on the other both
    // survive the bidirectional exchange (the semilattice, end to end).
    #[test]
    fn concurrent_dismiss_and_surface_commute_across_the_feed() {
        let dir = tempfile::tempdir().unwrap();
        let a = store(dir.path(), "a.db");
        let b = store(dir.path(), "b.db");
        let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
        a.produce("nodea", &mut epochs, "home", "agent", "doyle", "x").unwrap();

        // Seed B with the row, then diverge: A dismisses, B surfaces.
        let policy_a = trusting(&["home"], "nodeb-hex");
        let policy_b = trusting(&["home"], "nodea-hex");
        let seed = NotifDecoder::new().push(&emit_notif_feed(&a, "home").unwrap());
        apply_notif_feed(&b, "nodea-hex", &seed, &policy_b).unwrap();
        a.dismiss("nodea:1").unwrap();
        b.mark_seen("nodea:1", "ling").unwrap();
        b.touch_surfaced("nodea:1", 9_000).unwrap();

        // Exchange both ways; both advances survive on both stores.
        let from_a = NotifDecoder::new().push(&emit_notif_feed(&a, "home").unwrap());
        let from_b = NotifDecoder::new().push(&emit_notif_feed(&b, "home").unwrap());
        apply_notif_feed(&b, "nodea-hex", &from_a, &policy_b).unwrap();
        apply_notif_feed(&a, "nodeb-hex", &from_b, &policy_a).unwrap();

        let ra = a.get("nodea:1").unwrap().unwrap();
        let rb = b.get("nodea:1").unwrap().unwrap();
        assert_eq!(ra, rb, "stores converged");
        assert!(ra.dismissed && ra.seen.contains("ling") && ra.last_surfaced_ms == 9_000);
    }
}


106:pub mod access;
107:pub mod adapter_update;
108:pub mod attach;
109:pub mod brain;
110:pub mod broker;
111:pub mod codec;
112:pub mod config;
113:pub mod consent;
114:pub mod daemon;
115:pub mod digest;
116:pub mod digesthub;
117:pub mod effect;
118:pub mod endpoint;
119:pub mod frame;
120:pub mod lifecycle;
121:pub mod msg;
122:pub mod nethost;
123:pub mod propagate;
124:pub mod psyrelay;
125:pub mod reconcile;
126:pub mod relay;
127:pub mod relcache;
128:pub mod release;
129:pub mod seedmap;
130:pub mod sync;
131:pub mod transport;
132:pub mod update;
133:pub mod wan;
134:pub mod xfer;
137:mod test_home;
139:pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};
140:pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
141:pub use adapter_update::{
145:pub use brain::{Brain, BrainState, BrokerEvent};
146:pub use broker::Broker;
147:pub use config::{DaemonConfig, RelayChoice};
148:pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
149:pub use daemon::{ensure_running, is_running, Daemon};
150:pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
151:pub use digesthub::{
155:pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
156:pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
157:pub use lifecycle::{BrainLifecycle, TickReport};
158:pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
159:pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};
160:pub use psyrelay::{
163:pub use relay::Relay;
164:pub use relcache::ReleaseCache;
165:pub use seedmap::{put_seed, take_seed, SeedRegistry};
166:pub use frame::{
170:pub use transport::{DaemonTransport, LocalSocketTransport};
171:pub use release::{
175:pub use update::{
179:pub use sync::{
183:pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
184:pub use wan::{receive_wan, WanOutcome};


120	pub mod lifecycle;
121	pub mod msg;
122	pub mod nethost;
123	pub mod propagate;
124	pub mod psyrelay;
125	pub mod reconcile;
126	pub mod relay;
127	pub mod relcache;

pub mod lifecycle;
pub mod msg;
pub mod nethost;
pub mod propagate;
pub mod psyrelay;
pub mod reconcile;
pub mod relay;
pub mod relcache;


Edit

pub mod msg;
pub mod nethost;
pub mod propagate;

pub mod msg;
pub mod nethost;
pub mod notifsync;
pub mod propagate;


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

pub mod msg;
pub mod nethost;
pub mod notifsync;
pub mod propagate;

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

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

#[cfg(test)]
mod test_home;

pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};
pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
pub use adapter_update::{
    conduct_ripple, plan_adapter_update, AdapterRipple, AdapterUpdateOutcome, FilePullPayload,
    SkipReason,
};
pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::{DaemonConfig, RelayChoice};
pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{
    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
    update_to_json, DigestHub,
};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};
pub use psyrelay::{
    psyche_turn_and_relay, relay_psyche_outbound, RelayOutcome, PSYCHE_BODY_CAP,
};
pub use relay::Relay;
pub use relcache::ReleaseCache;
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
pub use transport::{DaemonTransport, LocalSocketTransport};
pub use release::{
    parse_verifying_key, sha256_hex, verify_artifact, verify_metadata, verify_signature,
    RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy,
};
pub use update::{
    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
};
pub use sync::{
    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
    SyncPullReport, SyncServeOutcome,
};
pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
pub use wan::{receive_wan, WanOutcome};

pub mod lifecycle;
pub mod msg;
pub mod nethost;
+pub mod notifsync;
pub mod propagate;
pub mod psyrelay;
pub mod reconcile;


pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};

pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
pub use notifsync::{apply_notif_feed, emit_notif_feed, NotifApplyVerdict, NotifPolicy};


pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
pub use notifsync::{apply_notif_feed, emit_notif_feed, NotifApplyVerdict, NotifPolicy};

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

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

#[cfg(test)]
mod test_home;

pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};
pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
pub use adapter_update::{
    conduct_ripple, plan_adapter_update, AdapterRipple, AdapterUpdateOutcome, FilePullPayload,
    SkipReason,
};
pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::{DaemonConfig, RelayChoice};
pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{
    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
    update_to_json, DigestHub,
};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};
pub use psyrelay::{
    psyche_turn_and_relay, relay_psyche_outbound, RelayOutcome, PSYCHE_BODY_CAP,
};
pub use relay::Relay;
pub use relcache::ReleaseCache;
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
pub use transport::{DaemonTransport, LocalSocketTransport};
pub use release::{
    parse_verifying_key, sha256_hex, verify_artifact, verify_metadata, verify_signature,
    RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy,
};
pub use update::{
    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
};
pub use sync::{
    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
    SyncPullReport, SyncServeOutcome,
};
pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
pub use wan::{receive_wan, WanOutcome};

+pub use notifsync::{apply_notif_feed, emit_notif_feed, NotifApplyVerdict, NotifPolicy};


Research D8 prerequisites (M4-PLAN §D8, D3 distribution, ADR-0007, boundaries, update/consent producers)

Read M4-PLAN §D8, ADR-0007, D3/D4d replication machinery (replicate.rs, registry.rs, ndjson.rs), spool.rs, consent.rs most_recently_active, psyrelay.rs notify leg, visibility.rs, update-prompt producer (M3c), reported boundaries (api boundary/wake/new-session) — enough to draft M4-D8-PLAN.md.

Draft M4-D8-PLAN.md (slices D8a–D8d)

Author the D8 phase plan: notif first-class kind + per-subnet replicated spool (D8a, REQ-NOTIF-1), first-fire/resurface/suppression (D8b), refactor update+consent producers + swap D7.5 notify leg (D8c), spt notify CLI + notif_command manifest seam (D8d, REQ-NOTIF-2). Commit plan.

D8-1: notif store + semilattice merge (spt-store/src/notif.rs)

NotifRow + NotifStore over identity/notifs.db: produce (EpochSource-minted notif_id), list/undismissed/get, dismiss latch, mark_seen union, touch_surfaced max, merge_row semilattice join + MergeOutcome. Unit tests per M4-D8-PLAN slice 1. Tag [impl/unit->REQ-NOTIF-1]. Commit, push, watch CI FINAL.


TaskCreate

D8-2: notif replication record + drivers + E2E (spt-net/net/notif.rs, spt-daemon/notifsync.rs)

NotifRecord NDJSON + NotifDecoder; emit_notif_feed/apply_notif_feed under member+trust gate; loopback two-broker E2E (converge, dismiss replicates, replay no-ops, untrusted dropped); FAULT-MATRIX rows. Push after D8-1 green.


Task #4 created successfully: D8-2: notif replication record + drivers + E2E (spt-net/net/notif.rs, spt-daemon/notifsync.rs)


Bash

cargo test -p spt-daemon notifsync 2>&1 | tail -12

Run notifsync unit tests


error[E0433]: failed to resolve: use of unresolved module or unlinked crate `rusqlite`
  --> crates\spt-daemon\src\notifsync.rs:95:6
   |
95 | ) -> rusqlite::Result<Vec<NotifApplyVerdict>> {
   |      ^^^^^^^^ use of unresolved module or unlinked crate `rusqlite`
   |
   = help: if you wanted to use a crate named `rusqlite`, use `cargo add rusqlite` to add it to your `Cargo.toml`

For more information about this error, try `rustc --explain E0433`.
error: could not compile `spt-daemon` (lib) due to 2 previous errors
warning: build failed, waiting for other jobs to finish...
error: could not compile `spt-daemon` (lib test) due to 2 previous errors


spt-daemon lacks rusqlite dep. Map store errors to String (daemon convention):


Edit

/// Author one subnet's feed from the local store: every row, one
/// [`NotifRecord::Row`] NDJSON line each, ready for a broker stream send.
/// Full-row re-presentation is the protocol — the receiver's join makes
/// duplicates free, so the emitter needs no delta bookkeeping.
// [impl->REQ-NOTIF-1]
pub fn emit_notif_feed(store: &NotifStore, subnet: &str) -> rusqlite::Result<Vec<u8>> {
    let mut wire = Vec::new();
    for row in store.list(subnet)? {
        wire.extend_from_slice(&NotifRecord::Row { row }.encode_line());
    }
    Ok(wire)
}

/// Author one subnet's feed from the local store: every row, one
/// [`NotifRecord::Row`] NDJSON line each, ready for a broker stream send.
/// Full-row re-presentation is the protocol — the receiver's join makes
/// duplicates free, so the emitter needs no delta bookkeeping.
// [impl->REQ-NOTIF-1]
pub fn emit_notif_feed(store: &NotifStore, subnet: &str) -> Result<Vec<u8>, String> {
    let mut wire = Vec::new();
    for row in store.list(subnet).map_err(|e| format!("notif list: {e}"))? {
        wire.extend_from_slice(&NotifRecord::Row { row }.encode_line());
    }
    Ok(wire)
}


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

/// Author one subnet's feed from the local store: every row, one
/// [`NotifRecord::Row`] NDJSON line each, ready for a broker stream send.
/// Full-row re-presentation is the protocol — the receiver's join makes
/// duplicates free, so the emitter needs no delta bookkeeping.
// [impl->REQ-NOTIF-1]
pub fn emit_notif_feed(store: &NotifStore, subnet: &str) -> rusqlite::Result<Vec<u8>> {
    let mut wire = Vec::new();
    for row in store.list(subnet)? {
        wire.extend_from_slice(&NotifRecord::Row { row }.encode_line());
    }
    Ok(wire)
}

/// Author one subnet's feed from the local store: every row, one
/// [`NotifRecord::Row`] NDJSON line each, ready for a broker stream send.
/// Full-row re-presentation is the protocol — the receiver's join makes
/// duplicates free, so the emitter needs no delta bookkeeping.
// [impl->REQ-NOTIF-1]
pub fn emit_notif_feed(store: &NotifStore, subnet: &str) -> Result<Vec<u8>, String> {
    let mut wire = Vec::new();
    for row in store.list(subnet).map_err(|e| format!("notif list: {e}"))? {
        wire.extend_from_slice(&NotifRecord::Row { row }.encode_line());
    }
    Ok(wire)
}

-pub fn emit_notif_feed(store: &NotifStore, subnet: &str) -> rusqlite::Result<Vec<u8>> {

+pub fn emit_notif_feed(store: &NotifStore, subnet: &str) -> Result<Vec<u8>, String> {

+    for row in store.list(subnet).map_err(|e| format!("notif list: {e}"))? {


pub fn apply_notif_feed(
    store: &NotifStore,
    origin_node: &str,
    records: &[NotifRecord],
    policy: &NotifPolicy,
) -> rusqlite::Result<Vec<NotifApplyVerdict>> {
    records
        .iter()
        .map(|record| {
            let NotifRecord::Row { row } = record;
            if !policy.subnets.iter().any(|s| s == &row.subnet) {
                return Ok(NotifApplyVerdict::DroppedNonMember);
            }
            if !policy.trust.is_trusted(&row.subnet, origin_node) {
                return Ok(NotifApplyVerdict::DroppedUntrusted);
            }
            Ok(NotifApplyVerdict::Applied(store.merge_row(row)?))
        })
        .collect()
}

pub fn apply_notif_feed(
    store: &NotifStore,
    origin_node: &str,
    records: &[NotifRecord],
    policy: &NotifPolicy,
) -> Result<Vec<NotifApplyVerdict>, String> {
    records
        .iter()
        .map(|record| {
            let NotifRecord::Row { row } = record;
            if !policy.subnets.iter().any(|s| s == &row.subnet) {
                return Ok(NotifApplyVerdict::DroppedNonMember);
            }
            if !policy.trust.is_trusted(&row.subnet, origin_node) {
                return Ok(NotifApplyVerdict::DroppedUntrusted);
            }
            let outcome =
                store.merge_row(row).map_err(|e| format!("notif join {}: {e}", row.notif_id))?;
            Ok(NotifApplyVerdict::Applied(outcome))
        })
        .collect()
}


pub fn apply_notif_feed(
    store: &NotifStore,
    origin_node: &str,
    records: &[NotifRecord],
    policy: &NotifPolicy,
) -> rusqlite::Result<Vec<NotifApplyVerdict>> {
    records
        .iter()
        .map(|record| {
            let NotifRecord::Row { row } = record;
            if !policy.subnets.iter().any(|s| s == &row.subnet) {
                return Ok(NotifApplyVerdict::DroppedNonMember);
            }
            if !policy.trust.is_trusted(&row.subnet, origin_node) {
                return Ok(NotifApplyVerdict::DroppedUntrusted);
            }
            Ok(NotifApplyVerdict::Applied(store.merge_row(row)?))
        })
        .collect()
}

pub fn apply_notif_feed(
    store: &NotifStore,
    origin_node: &str,
    records: &[NotifRecord],
    policy: &NotifPolicy,
) -> Result<Vec<NotifApplyVerdict>, String> {
    records
        .iter()
        .map(|record| {
            let NotifRecord::Row { row } = record;
            if !policy.subnets.iter().any(|s| s == &row.subnet) {
                return Ok(NotifApplyVerdict::DroppedNonMember);
            }
            if !policy.trust.is_trusted(&row.subnet, origin_node) {
                return Ok(NotifApplyVerdict::DroppedUntrusted);
            }
            let outcome =
                store.merge_row(row).map_err(|e| format!("notif join {}: {e}", row.notif_id))?;
            Ok(NotifApplyVerdict::Applied(outcome))
        })
        .collect()
}

-) -> rusqlite::Result<Vec<NotifApplyVerdict>> {

+) -> Result<Vec<NotifApplyVerdict>, String> {

-            Ok(NotifApplyVerdict::Applied(store.merge_row(row)?))

+                store.merge_row(row).map_err(|e| format!("notif join {}: {e}", row.notif_id))?;

+            Ok(NotifApplyVerdict::Applied(outcome))


Bash

cargo test -p spt-daemon notifsync 2>&1 | tail -12

Run notifsync unit tests


     Running tests\wanmsg.rs (target\debug\deps\wanmsg-eb9f0f557c56ed92.exe)

running 0 tests

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

     Running tests\xfer.rs (target\debug\deps\xfer-7fba3ce660dea736.exe)

running 0 tests

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


cargo test -p spt-daemon notifsync 2>&1 | grep -E "running [1-9]|notifsync::|test result: ok\. [1-9]"

Show notifsync test results


running 4 tests
test notifsync::tests::untrusted_and_cross_subnet_origins_are_dropped ... ok
test notifsync::tests::non_member_subnet_record_never_materializes ... ok
test notifsync::tests::concurrent_dismiss_and_surface_commute_across_the_feed ... ok
test notifsync::tests::feed_converges_two_stores_and_dismiss_replicates ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 100 filtered out; finished in 0.11s


<task-notification>
<task-id>bb2tepmc0</task-id>
<tool-use-id>toolu_015qF48rcHhpHRGD8r26Woks</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bb2tepmc0.output</output-file>
<status>completed</status>
<summary>Background command "Poll CI until run for 9a6548f completes" completed (exit code 0)</summary>
</task-notification>


Write

//! D8-2 regression — notif replication over the wire: transport → join.
//!
//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
//! two daemons. Node A produces notifs in its store and feeds them to node B
//! over a broker-owned QUIC stream (D4a/b); B decodes the NDJSON feed and
//! applies each record through the gated [`apply_notif_feed`] seam. Then B
//! dismisses one and feeds back — the acceptance leg "dismiss replicates
//! subnet-wide". The invariants:
//!
//! - the stores **converge**: B holds A's rows verbatim; A holds B's dismiss;
//! - the **join holds over the wire**: a replayed feed is wholly `Unchanged`
//!   (idempotent — replay/loss/reorder all reduce to "apply again");
//! - the framing survives **real QUIC chunking**: a record split across two
//!   separate stream sends reassembles on B's decoder;
//! - the **gate is fail-closed under the handshake-proven origin**: an origin
//!   not trusted in the subnet writes zero rows, whatever the payload claims.

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

use spt_daemon::brain::{Brain, BrokerEvent};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::notifsync::{apply_notif_feed, emit_notif_feed, NotifApplyVerdict, NotifPolicy};
use spt_daemon::Broker;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::notif::{NotifDecoder, NotifRecord};
use spt_proto::identity::Identity;
use spt_store::epoch::EpochSource;
use spt_store::notif::{MergeOutcome, NotifStore};
use spt_store::trust::TrustStore;

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

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");
}

/// Poll the receiving brain's `net-streams` until a stream appears.
fn wait_for_stream(brain: &mut Brain) -> u64 {
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.first() {
            return s.stream_id;
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("stream never appeared at the receiver's broker");
}

/// Drain one stream to EOF at the receiver, decoding notif records.
fn read_feed(brain: &mut Brain, stream: u64) -> Vec<NotifRecord> {
    brain.net_stream_subscribe(stream, 0).expect("subscribe");
    let mut decoder = NotifDecoder::new();
    let mut records = Vec::new();
    loop {
        match brain.read_event().expect("read event") {
            BrokerEvent::NetStreamData { stream_id, bytes, .. } if stream_id == stream => {
                records.extend(decoder.push(&bytes));
            }
            BrokerEvent::NetStreamEof { stream_id, .. } if stream_id == stream => break,
            BrokerEvent::Error { message } => panic!("broker error: {message}"),
            _ => {}
        }
    }
    records
}

/// A policy member of `subnets` trusting `origin` in each.
fn trusting(subnets: &[&str], origin: &str) -> NotifPolicy {
    let mut trust = TrustStore::default();
    for s in subnets {
        trust.record(s, origin, None, None);
    }
    NotifPolicy { subnets: subnets.iter().map(|s| s.to_string()).collect(), trust }
}

// [unit->REQ-NOTIF-1] two daemons converge a notif spool over the broker-owned
// wire — produce at A → NDJSON feed over a QUIC stream (split mid-record
// across sends) → gated join at B; B's dismiss feeds back and latches at A
// (the acceptance leg: dismiss replicates subnet-wide); a replayed feed is
// wholly Unchanged.
// [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] and the gate's subject is the
// handshake-proven origin: records from an origin not trusted in the subnet
// write zero rows at the receiver.
#[test]
fn notif_spools_converge_over_the_wire_and_dismiss_replicates() {
    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"));

    // ── Node A: produce two notifs locally (EpochSource-minted ids). ────────
    let mut sender = connect_retry(&name_a);
    let a_node = sender.net_status().expect("a status").node_id_hex.expect("node id");
    let store_a = NotifStore::open_at(&dir.path().join("a-notifs.db")).expect("a store");
    let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
    let first = store_a.produce(&a_node, &mut epochs, "home", "agent", "doyle", "build done").unwrap();
    store_a.produce(&a_node, &mut epochs, "home", "consent", "spt", "update ready").unwrap();

    // ── The wire A→B: one feed, split MID-RECORD across two sends. ──────────
    let mut b_probe = connect_retry(&name_b);
    let b_node = b_probe.net_status().expect("b status").node_id_hex.expect("node id");
    let addr = b_probe.net_status().expect("b status").addr;
    let conn = sender.net_dial(addr, Some(1)).expect("dial");
    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");

    let wire = emit_notif_feed(&store_a, "home").expect("emit");
    let split = wire.len() / 2;
    sender.net_stream_send(opened.stream_id, &wire[..split], None, false).expect("first half");
    sender.net_stream_send(opened.stream_id, &wire[split..], None, true).expect("second half");

    // ── Node B: decode + gated join under the handshake-proven origin. ──────
    let store_b = NotifStore::open_at(&dir.path().join("b-notifs.db")).expect("b store");
    let stream = wait_for_stream(&mut b_probe);
    let records = read_feed(&mut b_probe, stream);
    assert_eq!(records.len(), 2, "both records reassembled across the split");

    // Gate negative FIRST: an origin not trusted in `home` writes nothing —
    // same records, wrong subject (the payload cannot vouch for itself).
    let unpaired = NotifPolicy { subnets: vec!["home".into()], trust: TrustStore::default() };
    let refused = apply_notif_feed(&store_b, &a_node, &records, &unpaired).expect("apply");
    assert_eq!(
        refused,
        vec![NotifApplyVerdict::DroppedUntrusted, NotifApplyVerdict::DroppedUntrusted]
    );
    assert!(store_b.list("home").unwrap().is_empty(), "zero rows past the closed gate");

    // Now with A trusted in home: converge.
    let policy_b = trusting(&["home"], &a_node);
    let verdicts = apply_notif_feed(&store_b, &a_node, &records, &policy_b).expect("apply");
    assert_eq!(
        verdicts,
        vec![
            NotifApplyVerdict::Applied(MergeOutcome::Inserted),
            NotifApplyVerdict::Applied(MergeOutcome::Inserted)
        ]
    );
    assert_eq!(store_b.list("home").unwrap(), store_a.list("home").unwrap(), "B holds A's rows");

    // Replay the same feed: wholly Unchanged (idempotent join over the wire).
    let replay = apply_notif_feed(&store_b, &a_node, &records, &policy_b).expect("replay");
    assert_eq!(
        replay,
        vec![
            NotifApplyVerdict::Applied(MergeOutcome::Unchanged),
            NotifApplyVerdict::Applied(MergeOutcome::Unchanged)
        ],
        "replayed feed no-ops"
    );

    // ── B dismisses; the feed back latches A (dismiss replicates). ──────────
    store_b.dismiss(&first.notif_id).unwrap();
    let back_wire = emit_notif_feed(&store_b, "home").expect("emit back");
    let conn_back = b_probe.net_dial(sender.net_status().expect("a").addr, Some(3)).expect("dial back");
    let opened_back = b_probe.net_open_stream(conn_back.conn_id, Some(4)).expect("open back");
    b_probe.net_stream_send(opened_back.stream_id, &back_wire, None, true).expect("send back");

    let back_stream = wait_for_stream(&mut sender);
    let back_records = read_feed(&mut sender, back_stream);
    let policy_a = trusting(&["home"], &b_node);
    apply_notif_feed(&store_a, &b_node, &back_records, &policy_a).expect("apply back");

    assert!(store_a.get(&first.notif_id).unwrap().unwrap().dismissed, "dismiss replicated to A");
    assert_eq!(store_a.undismissed("home").unwrap().len(), 1, "the other row stays live");
    assert_eq!(store_a.list("home").unwrap(), store_b.list("home").unwrap(), "converged again");
}


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

//! D8-2 regression — notif replication over the wire: transport → join.
//!
//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
//! two daemons. Node A produces notifs in its store and feeds them to node B
//! over a broker-owned QUIC stream (D4a/b); B decodes the NDJSON feed and
//! applies each record through the gated [`apply_notif_feed`] seam. Then B
//! dismisses one and feeds back — the acceptance leg "dismiss replicates
//! subnet-wide". The invariants:
//!
//! - the stores **converge**: B holds A's rows verbatim; A holds B's dismiss;
//! - the **join holds over the wire**: a replayed feed is wholly `Unchanged`
//!   (idempotent — replay/loss/reorder all reduce to "apply again");
//! - the framing survives **real QUIC chunking**: a record split across two
//!   separate stream sends reassembles on B's decoder;
//! - the **gate is fail-closed under the handshake-proven origin**: an origin
//!   not trusted in the subnet writes zero rows, whatever the payload claims.

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

use spt_daemon::brain::{Brain, BrokerEvent};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::notifsync::{apply_notif_feed, emit_notif_feed, NotifApplyVerdict, NotifPolicy};
use spt_daemon::Broker;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::notif::{NotifDecoder, NotifRecord};
use spt_proto::identity::Identity;
use spt_store::epoch::EpochSource;
use spt_store::notif::{MergeOutcome, NotifStore};
use spt_store::trust::TrustStore;

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

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");
}

/// Poll the receiving brain's `net-streams` until a stream appears.
fn wait_for_stream(brain: &mut Brain) -> u64 {
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.first() {
            return s.stream_id;
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("stream never appeared at the receiver's broker");
}

/// Drain one stream to EOF at the receiver, decoding notif records.
fn read_feed(brain: &mut Brain, stream: u64) -> Vec<NotifRecord> {
    brain.net_stream_subscribe(stream, 0).expect("subscribe");
    let mut decoder = NotifDecoder::new();
    let mut records = Vec::new();
    loop {
        match brain.read_event().expect("read event") {
            BrokerEvent::NetStreamData { stream_id, bytes, .. } if stream_id == stream => {
                records.extend(decoder.push(&bytes));
            }
            BrokerEvent::NetStreamEof { stream_id, .. } if stream_id == stream => break,
            BrokerEvent::Error { message } => panic!("broker error: {message}"),
            _ => {}
        }
    }
    records
}

/// A policy member of `subnets` trusting `origin` in each.
fn trusting(subnets: &[&str], origin: &str) -> NotifPolicy {
    let mut trust = TrustStore::default();
    for s in subnets {
        trust.record(s, origin, None, None);
    }
    NotifPolicy { subnets: subnets.iter().map(|s| s.to_string()).collect(), trust }
}

// [unit->REQ-NOTIF-1] two daemons converge a notif spool over the broker-owned
// wire — produce at A → NDJSON feed over a QUIC stream (split mid-record
// across sends) → gated join at B; B's dismiss feeds back and latches at A
// (the acceptance leg: dismiss replicates subnet-wide); a replayed feed is
// wholly Unchanged.
// [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] and the gate's subject is the
// handshake-proven origin: records from an origin not trusted in the subnet
// write zero rows at the receiver.
#[test]
fn notif_spools_converge_over_the_wire_and_dismiss_replicates() {
    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"));

    // ── Node A: produce two notifs locally (EpochSource-minted ids). ────────
    let mut sender = connect_retry(&name_a);
    let a_node = sender.net_status().expect("a status").node_id_hex.expect("node id");
    let store_a = NotifStore::open_at(&dir.path().join("a-notifs.db")).expect("a store");
    let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
    let first = store_a.produce(&a_node, &mut epochs, "home", "agent", "doyle", "build done").unwrap();
    store_a.produce(&a_node, &mut epochs, "home", "consent", "spt", "update ready").unwrap();

    // ── The wire A→B: one feed, split MID-RECORD across two sends. ──────────
    let mut b_probe = connect_retry(&name_b);
    let b_node = b_probe.net_status().expect("b status").node_id_hex.expect("node id");
    let addr = b_probe.net_status().expect("b status").addr;
    let conn = sender.net_dial(addr, Some(1)).expect("dial");
    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");

    let wire = emit_notif_feed(&store_a, "home").expect("emit");
    let split = wire.len() / 2;
    sender.net_stream_send(opened.stream_id, &wire[..split], None, false).expect("first half");
    sender.net_stream_send(opened.stream_id, &wire[split..], None, true).expect("second half");

    // ── Node B: decode + gated join under the handshake-proven origin. ──────
    let store_b = NotifStore::open_at(&dir.path().join("b-notifs.db")).expect("b store");
    let stream = wait_for_stream(&mut b_probe);
    let records = read_feed(&mut b_probe, stream);
    assert_eq!(records.len(), 2, "both records reassembled across the split");

    // Gate negative FIRST: an origin not trusted in `home` writes nothing —
    // same records, wrong subject (the payload cannot vouch for itself).
    let unpaired = NotifPolicy { subnets: vec!["home".into()], trust: TrustStore::default() };
    let refused = apply_notif_feed(&store_b, &a_node, &records, &unpaired).expect("apply");
    assert_eq!(
        refused,
        vec![NotifApplyVerdict::DroppedUntrusted, NotifApplyVerdict::DroppedUntrusted]
    );
    assert!(store_b.list("home").unwrap().is_empty(), "zero rows past the closed gate");

    // Now with A trusted in home: converge.
    let policy_b = trusting(&["home"], &a_node);
    let verdicts = apply_notif_feed(&store_b, &a_node, &records, &policy_b).expect("apply");
    assert_eq!(
        verdicts,
        vec![
            NotifApplyVerdict::Applied(MergeOutcome::Inserted),
            NotifApplyVerdict::Applied(MergeOutcome::Inserted)
        ]
    );
    assert_eq!(store_b.list("home").unwrap(), store_a.list("home").unwrap(), "B holds A's rows");

    // Replay the same feed: wholly Unchanged (idempotent join over the wire).
    let replay = apply_notif_feed(&store_b, &a_node, &records, &policy_b).expect("replay");
    assert_eq!(
        replay,
        vec![
            NotifApplyVerdict::Applied(MergeOutcome::Unchanged),
            NotifApplyVerdict::Applied(MergeOutcome::Unchanged)
        ],
        "replayed feed no-ops"
    );

    // ── B dismisses; the feed back latches A (dismiss replicates). ──────────
    store_b.dismiss(&first.notif_id).unwrap();
    let back_wire = emit_notif_feed(&store_b, "home").expect("emit back");
    let conn_back = b_probe.net_dial(sender.net_status().expect("a").addr, Some(3)).expect("dial back");
    let opened_back = b_probe.net_open_stream(conn_back.conn_id, Some(4)).expect("open back");
    b_probe.net_stream_send(opened_back.stream_id, &back_wire, None, true).expect("send back");

    let back_stream = wait_for_stream(&mut sender);
    let back_records = read_feed(&mut sender, back_stream);
    let policy_a = trusting(&["home"], &b_node);
    apply_notif_feed(&store_a, &b_node, &back_records, &policy_a).expect("apply back");

    assert!(store_a.get(&first.notif_id).unwrap().unwrap().dismissed, "dismiss replicated to A");
    assert_eq!(store_a.undismissed("home").unwrap().len(), 1, "the other row stays live");
    assert_eq!(store_a.list("home").unwrap(), store_b.list("home").unwrap(), "converged again");
}


Prompt:
<task-notification>
<task-id>bb2tepmc0</task-id>
<tool-use-id>toolu_015qF48rcHhpHRGD8r26Woks</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bb2tepmc0.output</output-file>
<status>completed</status>
<summary>Background command "Poll CI until run for 9a6548f completes" completed (exit code 0)</summary>
</task-notification>


Bash

tail -3 "C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bb2tepmc0.output"; gh run list --branch dev-freeform --limit 1 --json headSha,status,conclusion,name --jq '.[0]'

Check CI FINAL conclusion for 9a6548f


9a6548f queued
9a6548f queued
9a6548f completed success
{"conclusion":"success","headSha":"9a6548fbd0c85a20715632a62ff4c5c7c767d142","name":"ci","status":"completed"}


Updated task #3 status


Updated task #4 status


Edit

/// Poll the receiving brain's `net-streams` until a stream appears.
fn wait_for_stream(brain: &mut Brain) -> u64 {
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.first() {
            return s.stream_id;
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("stream never appeared at the receiver's broker");
}

/// Poll the receiving brain's `net-streams` until a stream NOT in `known`
/// appears. The broker's table holds locally-opened outbound streams too, so
/// a receiver that previously dialed out must exclude its own ids or it reads
/// a stale stream and hangs (the D5b/D6c lesson — wait_for_stream_except).
fn wait_for_stream_except(brain: &mut Brain, known: &[u64]) -> u64 {
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.iter().find(|s| !known.contains(&s.stream_id)) {
            return s.stream_id;
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("stream never appeared at the receiver's broker");
}


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

/// Poll the receiving brain's `net-streams` until a stream NOT in `known`
/// appears. The broker's table holds locally-opened outbound streams too, so
/// a receiver that previously dialed out must exclude its own ids or it reads
/// a stale stream and hangs (the D5b/D6c lesson — wait_for_stream_except).
fn wait_for_stream_except(brain: &mut Brain, known: &[u64]) -> u64 {
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.iter().find(|s| !known.contains(&s.stream_id)) {
            return s.stream_id;
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("stream never appeared at the receiver's broker");
}

//! D8-2 regression — notif replication over the wire: transport → join.
//!
//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
//! two daemons. Node A produces notifs in its store and feeds them to node B
//! over a broker-owned QUIC stream (D4a/b); B decodes the NDJSON feed and
//! applies each record through the gated [`apply_notif_feed`] seam. Then B
//! dismisses one and feeds back — the acceptance leg "dismiss replicates
//! subnet-wide". The invariants:
//!
//! - the stores **converge**: B holds A's rows verbatim; A holds B's dismiss;
//! - the **join holds over the wire**: a replayed feed is wholly `Unchanged`
//!   (idempotent — replay/loss/reorder all reduce to "apply again");
//! - the framing survives **real QUIC chunking**: a record split across two
//!   separate stream sends reassembles on B's decoder;
//! - the **gate is fail-closed under the handshake-proven origin**: an origin
//!   not trusted in the subnet writes zero rows, whatever the payload claims.

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

use spt_daemon::brain::{Brain, BrokerEvent};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::notifsync::{apply_notif_feed, emit_notif_feed, NotifApplyVerdict, NotifPolicy};
use spt_daemon::Broker;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::notif::{NotifDecoder, NotifRecord};
use spt_proto::identity::Identity;
use spt_store::epoch::EpochSource;
use spt_store::notif::{MergeOutcome, NotifStore};
use spt_store::trust::TrustStore;

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

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");
}

/// Poll the receiving brain's `net-streams` until a stream appears.
fn wait_for_stream(brain: &mut Brain) -> u64 {
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.first() {
            return s.stream_id;
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("stream never appeared at the receiver's broker");
}

/// Drain one stream to EOF at the receiver, decoding notif records.
fn read_feed(brain: &mut Brain, stream: u64) -> Vec<NotifRecord> {
    brain.net_stream_subscribe(stream, 0).expect("subscribe");
    let mut decoder = NotifDecoder::new();
    let mut records = Vec::new();
    loop {
        match brain.read_event().expect("read event") {
            BrokerEvent::NetStreamData { stream_id, bytes, .. } if stream_id == stream => {
                records.extend(decoder.push(&bytes));
            }
            BrokerEvent::NetStreamEof { stream_id, .. } if stream_id == stream => break,
            BrokerEvent::Error { message } => panic!("broker error: {message}"),
            _ => {}
        }
    }
    records
}

/// A policy member of `subnets` trusting `origin` in each.
fn trusting(subnets: &[&str], origin: &str) -> NotifPolicy {
    let mut trust = TrustStore::default();
    for s in subnets {
        trust.record(s, origin, None, None);
    }
    NotifPolicy { subnets: subnets.iter().map(|s| s.to_string()).collect(), trust }
}

// [unit->REQ-NOTIF-1] two daemons converge a notif spool over the broker-owned
// wire — produce at A → NDJSON feed over a QUIC stream (split mid-record
// across sends) → gated join at B; B's dismiss feeds back and latches at A
// (the acceptance leg: dismiss replicates subnet-wide); a replayed feed is
// wholly Unchanged.
// [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] and the gate's subject is the
// handshake-proven origin: records from an origin not trusted in the subnet
// write zero rows at the receiver.
#[test]
fn notif_spools_converge_over_the_wire_and_dismiss_replicates() {
    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"));

    // ── Node A: produce two notifs locally (EpochSource-minted ids). ────────
    let mut sender = connect_retry(&name_a);
    let a_node = sender.net_status().expect("a status").node_id_hex.expect("node id");
    let store_a = NotifStore::open_at(&dir.path().join("a-notifs.db")).expect("a store");
    let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
    let first = store_a.produce(&a_node, &mut epochs, "home", "agent", "doyle", "build done").unwrap();
    store_a.produce(&a_node, &mut epochs, "home", "consent", "spt", "update ready").unwrap();

    // ── The wire A→B: one feed, split MID-RECORD across two sends. ──────────
    let mut b_probe = connect_retry(&name_b);
    let b_node = b_probe.net_status().expect("b status").node_id_hex.expect("node id");
    let addr = b_probe.net_status().expect("b status").addr;
    let conn = sender.net_dial(addr, Some(1)).expect("dial");
    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");

    let wire = emit_notif_feed(&store_a, "home").expect("emit");
    let split = wire.len() / 2;
    sender.net_stream_send(opened.stream_id, &wire[..split], None, false).expect("first half");
    sender.net_stream_send(opened.stream_id, &wire[split..], None, true).expect("second half");

    // ── Node B: decode + gated join under the handshake-proven origin. ──────
    let store_b = NotifStore::open_at(&dir.path().join("b-notifs.db")).expect("b store");
    let stream = wait_for_stream(&mut b_probe);
    let records = read_feed(&mut b_probe, stream);
    assert_eq!(records.len(), 2, "both records reassembled across the split");

    // Gate negative FIRST: an origin not trusted in `home` writes nothing —
    // same records, wrong subject (the payload cannot vouch for itself).
    let unpaired = NotifPolicy { subnets: vec!["home".into()], trust: TrustStore::default() };
    let refused = apply_notif_feed(&store_b, &a_node, &records, &unpaired).expect("apply");
    assert_eq!(
        refused,
        vec![NotifApplyVerdict::DroppedUntrusted, NotifApplyVerdict::DroppedUntrusted]
    );
    assert!(store_b.list("home").unwrap().is_empty(), "zero rows past the closed gate");

    // Now with A trusted in home: converge.
    let policy_b = trusting(&["home"], &a_node);
    let verdicts = apply_notif_feed(&store_b, &a_node, &records, &policy_b).expect("apply");
    assert_eq!(
        verdicts,
        vec![
            NotifApplyVerdict::Applied(MergeOutcome::Inserted),
            NotifApplyVerdict::Applied(MergeOutcome::Inserted)
        ]
    );
    assert_eq!(store_b.list("home").unwrap(), store_a.list("home").unwrap(), "B holds A's rows");

    // Replay the same feed: wholly Unchanged (idempotent join over the wire).
    let replay = apply_notif_feed(&store_b, &a_node, &records, &policy_b).expect("replay");
    assert_eq!(
        replay,
        vec![
            NotifApplyVerdict::Applied(MergeOutcome::Unchanged),
            NotifApplyVerdict::Applied(MergeOutcome::Unchanged)
        ],
        "replayed feed no-ops"
    );

    // ── B dismisses; the feed back latches A (dismiss replicates). ──────────
    store_b.dismiss(&first.notif_id).unwrap();
    let back_wire = emit_notif_feed(&store_b, "home").expect("emit back");
    let conn_back = b_probe.net_dial(sender.net_status().expect("a").addr, Some(3)).expect("dial back");
    let opened_back = b_probe.net_open_stream(conn_back.conn_id, Some(4)).expect("open back");
    b_probe.net_stream_send(opened_back.stream_id, &back_wire, None, true).expect("send back");

    let back_stream = wait_for_stream(&mut sender);
    let back_records = read_feed(&mut sender, back_stream);
    let policy_a = trusting(&["home"], &b_node);
    apply_notif_feed(&store_a, &b_node, &back_records, &policy_a).expect("apply back");

    assert!(store_a.get(&first.notif_id).unwrap().unwrap().dismissed, "dismiss replicated to A");
    assert_eq!(store_a.undismissed("home").unwrap().len(), 1, "the other row stays live");
    assert_eq!(store_a.list("home").unwrap(), store_b.list("home").unwrap(), "converged again");
}

-/// Poll the receiving brain's `net-streams` until a stream appears.

-fn wait_for_stream(brain: &mut Brain) -> u64 {

+/// Poll the receiving brain's `net-streams` until a stream NOT in `known`

+/// appears. The broker's table holds locally-opened outbound streams too, so

+/// a receiver that previously dialed out must exclude its own ids or it reads

+/// a stale stream and hangs (the D5b/D6c lesson — wait_for_stream_except).

+fn wait_for_stream_except(brain: &mut Brain, known: &[u64]) -> u64 {

-        if let Some(s) = reply.streams.first() {

+        if let Some(s) = reply.streams.iter().find(|s| !known.contains(&s.stream_id)) {


    // ── Node B: decode + gated join under the handshake-proven origin. ──────
    let store_b = NotifStore::open_at(&dir.path().join("b-notifs.db")).expect("b store");
    let stream = wait_for_stream(&mut b_probe);
    let records = read_feed(&mut b_probe, stream);

    // ── Node B: decode + gated join under the handshake-proven origin. ──────
    let store_b = NotifStore::open_at(&dir.path().join("b-notifs.db")).expect("b store");
    let stream = wait_for_stream_except(&mut b_probe, &[]);
    let records = read_feed(&mut b_probe, stream);