Write

//! Subnet notification spool (ADR-0007 / M4-D8a) — the notif as a first-class
//! kind, distinct from agent→agent messages: **user-directed, dismissable,
//! resurfacing**.
//!
//! One node-level SQLite database ([`notifs_file`](crate::perch::notifs_file)),
//! subnet-tagged rows. The replicated state is deliberately a
//! **join-semilattice** — every field is monotone, so concurrent writes on
//! different nodes commute and a replayed replication feed no-ops:
//!
//! - the row itself — insert idempotent on the unique [`NotifRow::notif_id`]
//!   (`<node-hex>:<epoch>`, stamped from the node's monotonic
//!   [`EpochSource`](crate::epoch::EpochSource) — the same per-node epoch
//!   source the D3b registry lease and D6b sync precedence ride);
//! - `dismissed` — a one-way `false→true` latch (merge = OR): a stale
//!   undismissed copy can never un-dismiss;
//! - `seen` — the set of endpoint ids the notif has surfaced at
//!   (merge = union): *seen* (surfaced at least once, per-endpoint) is
//!   distinct from *dismissed* (explicit ack) — surfacing ≠ dismissal;
//! - `last_surfaced_ms` — merge = max; powers the cross-endpoint resurface
//!   suppression window. Wall-clock here is a suppression heuristic only,
//!   never an ordering authority (a skewed clock can at worst over/under-
//!   suppress one resurface window — state is never lost to it).
//!
//! There is no version vector and no conflict surface: unlike D6b's divergent
//! text snapshots, nothing here needs causality — the join *is* the merge.
//! [`NotifStore::merge_row`] is the single apply point the D8 replication
//! feed drives (mirroring how the D4d registry feed drives `merge_instance`).
// [impl->REQ-NOTIF-1]

use std::collections::BTreeSet;
use std::path::Path;

use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};

use crate::epoch::EpochSource;

/// One notification — the full replicated row. Identity + content fields
/// (`notif_id` through `created_ms`) are immutable after produce (the same
/// `notif_id` always names the same content); the state fields (`dismissed`,
/// `seen`, `last_surfaced_ms`) are the monotone semilattice (module docs).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NotifRow {
    /// `<node-hex>:<epoch>` — unique subnet-wide, the idempotent insert key.
    pub notif_id: String,
    /// The subnet this notif belongs to (the replication + surfacing scope).
    pub subnet: String,
    /// Producer tag (`consent`, `update`, `psyche`, `agent`, …) — an open set
    /// (ADR-0007: one primitive, open producer set).
    pub kind: String,
    /// The issuer (ADR-0007 envelope `from`: endpoint id, with node + subnet
    /// qualification supplied by the producing daemon where it applies).
    pub from_id: String,
    /// The notif body (4.1-codec text; composed into a typed envelope at
    /// surfacing, not here).
    pub body: String,
    /// Produce-time wall-clock stamp (display/audit only).
    pub created_ms: u64,
    /// Explicit ack — the one-way latch. Replicates subnet-wide.
    pub dismissed: bool,
    /// Endpoint ids this notif has surfaced at (seen-per-endpoint).
    pub seen: BTreeSet<String>,
    /// Greatest surface time across all endpoints/nodes — the cross-endpoint
    /// suppression input. `0` = never surfaced.
    pub last_surfaced_ms: u64,
}

/// What [`NotifStore::merge_row`] did with one incoming row — the replication
/// feed's idempotency signal (an `Unchanged` replay writes nothing).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeOutcome {
    /// First sight of this `notif_id` — the row was inserted whole.
    Inserted,
    /// Known row; the join advanced at least one state field.
    Joined,
    /// Known row; the incoming state was wholly dominated — no write.
    Unchanged,
}

/// Handle over the notif database. Open per use (the spool.db posture —
/// short-lived connections, WAL + busy_timeout make concurrent openers safe).
pub struct NotifStore {
    conn: Connection,
}

impl NotifStore {
    /// Open (creating if needed) the canonical node-level store at
    /// [`notifs_file`](crate::perch::notifs_file).
    pub fn open() -> rusqlite::Result<NotifStore> {
        Self::open_at(&crate::perch::notifs_file())
    }

    /// Open at an explicit path (the testable core), creating the parent dir
    /// and schema if absent.
    pub fn open_at(path: &Path) -> rusqlite::Result<NotifStore> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)
                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
        }
        let conn = Connection::open(path)?;
        // [impl->REQ-HAZARD-REGISTRY-CONCURRENT] shared concurrency-safe pragmas.
        crate::db::tune_connection(&conn)?;
        conn.execute_batch(
            "CREATE TABLE IF NOT EXISTS notifs (
                notif_id TEXT PRIMARY KEY,
                subnet TEXT NOT NULL,
                kind TEXT NOT NULL,
                from_id TEXT NOT NULL,
                body TEXT NOT NULL,
                created_ms INTEGER NOT NULL,
                dismissed INTEGER NOT NULL DEFAULT 0,
                seen TEXT NOT NULL DEFAULT '[]',
                last_surfaced_ms INTEGER NOT NULL DEFAULT 0
            )",
        )?;
        Ok(NotifStore { conn })
    }

    /// Produce a new notif: mint `notif_id` from this node's monotonic
    /// [`EpochSource`] (`<node_hex>:<epoch>` — unique, replicable), insert it
    /// undismissed/unseen, and return the row (the caller hands it to
    /// first-fire and/or the replication feed). The epoch is consumed only on
    /// a successful insert path — a failed persist returns the error with the
    /// counter already durably advanced (a skipped epoch is harmless; a
    /// reissued one is not).
    // [impl->REQ-NOTIF-1]
    #[allow(clippy::too_many_arguments)]
    pub fn produce(
        &self,
        node_hex: &str,
        epochs: &mut EpochSource,
        subnet: &str,
        kind: &str,
        from_id: &str,
        body: &str,
    ) -> rusqlite::Result<NotifRow> {
        let epoch = epochs
            .next_epoch()
            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
        let row = NotifRow {
            notif_id: format!("{node_hex}:{epoch}"),
            subnet: subnet.to_string(),
            kind: kind.to_string(),
            from_id: from_id.to_string(),
            body: body.to_string(),
            created_ms: now_ms(),
            dismissed: false,
            seen: BTreeSet::new(),
            last_surfaced_ms: 0,
        };
        self.insert_row(&row)?;
        Ok(row)
    }

    /// Merge one incoming (replicated) row — the semilattice join, the single
    /// apply point for the D8 replication feed. Unknown id ⇒ insert whole;
    /// known id ⇒ join the state fields (`dismissed` OR, `seen` union,
    /// `last_surfaced_ms` max; identity/content fields keep the stored values
    /// — same id, same content by construction). Idempotent and commutative:
    /// a replayed or reordered feed converges to the same row.
    // [impl->REQ-NOTIF-1]
    pub fn merge_row(&self, incoming: &NotifRow) -> rusqlite::Result<MergeOutcome> {
        // BEGIN IMMEDIATE: the read-join-write must be one writer transaction
        // (two concurrent joins on one node must serialize, not lose updates).
        self.conn.execute_batch("BEGIN IMMEDIATE")?;
        let outcome = (|| -> rusqlite::Result<MergeOutcome> {
            let Some(current) = self.read_row(&incoming.notif_id)? else {
                self.insert_row(incoming)?;
                return Ok(MergeOutcome::Inserted);
            };
            let dismissed = current.dismissed || incoming.dismissed;
            let mut seen = current.seen.clone();
            seen.extend(incoming.seen.iter().cloned());
            let last_surfaced_ms = current.last_surfaced_ms.max(incoming.last_surfaced_ms);
            if dismissed == current.dismissed
                && seen == current.seen
                && last_surfaced_ms == current.last_surfaced_ms
            {
                return Ok(MergeOutcome::Unchanged);
            }
            self.conn.execute(
                "UPDATE notifs SET dismissed = ?2, seen = ?3, last_surfaced_ms = ?4
                 WHERE notif_id = ?1",
                params![incoming.notif_id, dismissed as i64, encode_seen(&seen), last_surfaced_ms as i64],
            )?;
            Ok(MergeOutcome::Joined)
        })();
        match &outcome {
            Ok(_) => self.conn.execute_batch("COMMIT")?,
            Err(_) => self.conn.execute_batch("ROLLBACK")?,
        }
        outcome
    }

    /// Dismiss (explicit ack) — the one-way latch. Returns whether the latch
    /// flipped (`false` = already dismissed or unknown id; never un-dismisses).
    // [impl->REQ-NOTIF-1]
    pub fn dismiss(&self, notif_id: &str) -> rusqlite::Result<bool> {
        let n = self.conn.execute(
            "UPDATE notifs SET dismissed = 1 WHERE notif_id = ?1 AND dismissed = 0",
            params![notif_id],
        )?;
        Ok(n > 0)
    }

    /// Record that the notif surfaced at `endpoint` (seen-per-endpoint, set
    /// union). Returns whether the set grew.
    // [impl->REQ-NOTIF-1]
    pub fn mark_seen(&self, notif_id: &str, endpoint: &str) -> rusqlite::Result<bool> {
        self.conn.execute_batch("BEGIN IMMEDIATE")?;
        let res = (|| -> rusqlite::Result<bool> {
            let Some(mut row) = self.read_row(notif_id)? else { return Ok(false) };
            if !row.seen.insert(endpoint.to_string()) {
                return Ok(false);
            }
            self.conn.execute(
                "UPDATE notifs SET seen = ?2 WHERE notif_id = ?1",
                params![notif_id, encode_seen(&row.seen)],
            )?;
            Ok(true)
        })();
        match &res {
            Ok(_) => self.conn.execute_batch("COMMIT")?,
            Err(_) => self.conn.execute_batch("ROLLBACK")?,
        }
        res
    }

    /// Advance `last_surfaced_ms` to `now_ms` if greater (max-merge — a lagging
    /// clock never rewinds the suppression window). Returns whether it moved.
    // [impl->REQ-NOTIF-1]
    pub fn touch_surfaced(&self, notif_id: &str, now_ms: u64) -> rusqlite::Result<bool> {
        let n = self.conn.execute(
            "UPDATE notifs SET last_surfaced_ms = ?2 WHERE notif_id = ?1 AND last_surfaced_ms < ?2",
            params![notif_id, now_ms as i64],
        )?;
        Ok(n > 0)
    }

    /// One row by id.
    pub fn get(&self, notif_id: &str) -> rusqlite::Result<Option<NotifRow>> {
        self.read_row(notif_id)
    }

    /// Every row in `subnet`, oldest produce first (created_ms, id tiebreak).
    pub fn list(&self, subnet: &str) -> rusqlite::Result<Vec<NotifRow>> {
        self.select_rows("subnet = ?1 ORDER BY created_ms ASC, notif_id ASC", params![subnet])
    }

    /// The undismissed rows in `subnet` — the resurface engine's working set.
    // [impl->REQ-NOTIF-1]
    pub fn undismissed(&self, subnet: &str) -> rusqlite::Result<Vec<NotifRow>> {
        self.select_rows(
            "subnet = ?1 AND dismissed = 0 ORDER BY created_ms ASC, notif_id ASC",
            params![subnet],
        )
    }

    fn insert_row(&self, row: &NotifRow) -> rusqlite::Result<()> {
        self.conn.execute(
            "INSERT INTO notifs (notif_id, subnet, kind, from_id, body, created_ms,
                                 dismissed, seen, last_surfaced_ms)
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
            params![
                row.notif_id,
                row.subnet,
                row.kind,
                row.from_id,
                row.body,
                row.created_ms as i64,
                row.dismissed as i64,
                encode_seen(&row.seen),
                row.last_surfaced_ms as i64,
            ],
        )?;
        Ok(())
    }

    fn read_row(&self, notif_id: &str) -> rusqlite::Result<Option<NotifRow>> {
        self.conn
            .query_row(
                &format!("SELECT {COLS} FROM notifs WHERE notif_id = ?1"),
                params![notif_id],
                decode_row,
            )
            .optional()
    }

    fn select_rows(
        &self,
        where_order: &str,
        p: impl rusqlite::Params,
    ) -> rusqlite::Result<Vec<NotifRow>> {
        let mut stmt =
            self.conn.prepare(&format!("SELECT {COLS} FROM notifs WHERE {where_order}"))?;
        let rows = stmt.query_map(p, decode_row)?.filter_map(|r| r.ok()).collect();
        Ok(rows)
    }
}

/// The selected column list shared by every reader (order matches [`decode_row`]).
const COLS: &str =
    "notif_id, subnet, kind, from_id, body, created_ms, dismissed, seen, last_surfaced_ms";

fn decode_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<NotifRow> {
    Ok(NotifRow {
        notif_id: r.get(0)?,
        subnet: r.get(1)?,
        kind: r.get(2)?,
        from_id: r.get(3)?,
        body: r.get(4)?,
        created_ms: r.get::<_, i64>(5)? as u64,
        dismissed: r.get::<_, i64>(6)? != 0,
        seen: decode_seen(&r.get::<_, String>(7)?),
        last_surfaced_ms: r.get::<_, i64>(8)? as u64,
    })
}

/// `seen` persists as a JSON array (BTreeSet ⇒ sorted, stable bytes).
fn encode_seen(seen: &BTreeSet<String>) -> String {
    serde_json::to_string(seen).expect("a string set always serializes")
}

/// Corrupt/legacy cells degrade to empty (damaged state must not brick the
/// store; the set re-grows from live surfacing + replication).
fn decode_seen(s: &str) -> BTreeSet<String> {
    serde_json::from_str(s).unwrap_or_default()
}

fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

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

    fn store(dir: &Path) -> NotifStore {
        NotifStore::open_at(&dir.join("notifs.db")).unwrap()
    }

    fn epochs(dir: &Path) -> EpochSource {
        EpochSource::load_from(&dir.join("epoch"))
    }

    /// A detached row for merge tests (as if replicated from another node).
    fn remote_row(id: &str, subnet: &str) -> NotifRow {
        NotifRow {
            notif_id: id.to_string(),
            subnet: subnet.to_string(),
            kind: "agent".into(),
            from_id: "ling".into(),
            body: "build done".into(),
            created_ms: 1_000,
            dismissed: false,
            seen: BTreeSet::new(),
            last_surfaced_ms: 0,
        }
    }

    // [unit->REQ-NOTIF-1] produce mints unique `<node-hex>:<epoch>` ids from
    // the monotonic source, inserts undismissed/unseen, and list returns rows
    // in produce order; dismiss latches and undismissed() drops the row.
    #[test]
    fn produce_list_dismiss_round_trip() {
        let dir = tempdir().unwrap();
        let s = store(dir.path());
        let mut e = epochs(dir.path());

        let a = s.produce("cafe", &mut e, "home", "agent", "doyle", "first").unwrap();
        let b = s.produce("cafe", &mut e, "home", "consent", "spt", "second").unwrap();
        assert_eq!(a.notif_id, "cafe:1");
        assert_eq!(b.notif_id, "cafe:2");
        assert!(!a.dismissed && a.seen.is_empty() && a.last_surfaced_ms == 0);

        let listed = s.list("home").unwrap();
        assert_eq!(
            listed.iter().map(|r| r.notif_id.as_str()).collect::<Vec<_>>(),
            vec!["cafe:1", "cafe:2"]
        );

        assert!(s.dismiss("cafe:1").unwrap(), "latch flips");
        assert!(!s.dismiss("cafe:1").unwrap(), "already latched");
        assert!(!s.dismiss("ghost:9").unwrap(), "unknown id is a no-op");
        let undis = s.undismissed("home").unwrap();
        assert_eq!(undis.len(), 1);
        assert_eq!(undis[0].notif_id, "cafe:2");
        // Dismissed row still listed (audit), just not in the working set.
        assert_eq!(s.list("home").unwrap().len(), 2);
    }

    // [unit->REQ-NOTIF-1] rows are subnet-scoped: list/undismissed never leak
    // across subnets.
    #[test]
    fn listing_is_subnet_scoped() {
        let dir = tempdir().unwrap();
        let s = store(dir.path());
        let mut e = epochs(dir.path());
        s.produce("cafe", &mut e, "home", "agent", "doyle", "x").unwrap();
        s.produce("cafe", &mut e, "work", "agent", "doyle", "y").unwrap();
        assert_eq!(s.list("home").unwrap().len(), 1);
        assert_eq!(s.undismissed("work").unwrap().len(), 1);
        assert!(s.list("elsewhere").unwrap().is_empty());
    }

    // [unit->REQ-NOTIF-1] merge is the semilattice join: idempotent (replay
    // ⇒ Unchanged, nothing written) and commutative (A∘B == B∘A — two stores
    // fed the same rows in opposite orders converge to identical state).
    #[test]
    fn merge_is_idempotent_and_commutative() {
        let dir = tempdir().unwrap();

        let mut a = remote_row("n1:5", "home");
        a.dismissed = true;
        let mut b = remote_row("n1:5", "home");
        b.seen.insert("doyle".into());
        b.last_surfaced_ms = 7_000;

        let s1 = NotifStore::open_at(&dir.path().join("s1.db")).unwrap();
        assert_eq!(s1.merge_row(&a).unwrap(), MergeOutcome::Inserted);
        assert_eq!(s1.merge_row(&b).unwrap(), MergeOutcome::Joined);
        assert_eq!(s1.merge_row(&a).unwrap(), MergeOutcome::Unchanged, "replay no-ops");
        assert_eq!(s1.merge_row(&b).unwrap(), MergeOutcome::Unchanged, "replay no-ops");

        let s2 = NotifStore::open_at(&dir.path().join("s2.db")).unwrap();
        assert_eq!(s2.merge_row(&b).unwrap(), MergeOutcome::Inserted);
        assert_eq!(s2.merge_row(&a).unwrap(), MergeOutcome::Joined);

        let r1 = s1.get("n1:5").unwrap().unwrap();
        let r2 = s2.get("n1:5").unwrap().unwrap();
        assert_eq!(r1, r2, "opposite apply orders converge");
        assert!(r1.dismissed, "OR survived");
        assert!(r1.seen.contains("doyle"), "union survived");
        assert_eq!(r1.last_surfaced_ms, 7_000, "max survived");
    }

    // [unit->REQ-NOTIF-1] the dismiss latch never un-dismisses: a stale
    // replicated copy with dismissed=false cannot clobber a local true (the
    // acceptance "dismiss replicates subnet-wide" depends on this hold).
    #[test]
    fn stale_undismissed_copy_cannot_undismiss() {
        let dir = tempdir().unwrap();
        let s = store(dir.path());

        let mut dismissed = remote_row("n1:1", "home");
        dismissed.dismissed = true;
        s.merge_row(&dismissed).unwrap();

        let stale = remote_row("n1:1", "home"); // dismissed=false, no state
        assert_eq!(s.merge_row(&stale).unwrap(), MergeOutcome::Unchanged);
        assert!(s.get("n1:1").unwrap().unwrap().dismissed, "latch held");
    }

    // [unit->REQ-NOTIF-1] seen unions and last_surfaced maxes through the
    // local mutators too — and touch_surfaced never rewinds (max-merge).
    #[test]
    fn seen_unions_and_surfaced_maxes() {
        let dir = tempdir().unwrap();
        let s = store(dir.path());
        s.merge_row(&remote_row("n1:1", "home")).unwrap();

        assert!(s.mark_seen("n1:1", "doyle").unwrap());
        assert!(!s.mark_seen("n1:1", "doyle").unwrap(), "already in the set");
        assert!(s.mark_seen("n1:1", "ling").unwrap());
        assert!(!s.mark_seen("ghost:9", "doyle").unwrap(), "unknown id no-ops");

        assert!(s.touch_surfaced("n1:1", 5_000).unwrap());
        assert!(!s.touch_surfaced("n1:1", 4_000).unwrap(), "never rewinds");
        assert!(s.touch_surfaced("n1:1", 6_000).unwrap());

        let row = s.get("n1:1").unwrap().unwrap();
        assert_eq!(
            row.seen.iter().map(String::as_str).collect::<Vec<_>>(),
            vec!["doyle", "ling"]
        );
        assert_eq!(row.last_surfaced_ms, 6_000);
    }

    // [unit->REQ-NOTIF-1] state is durable across reopen (a daemon restart
    // loses nothing), and a corrupt seen cell degrades to empty, not a panic.
    #[test]
    fn reopen_is_durable_and_corrupt_seen_degrades() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("notifs.db");
        {
            let s = NotifStore::open_at(&path).unwrap();
            let mut e = epochs(dir.path());
            s.produce("cafe", &mut e, "home", "agent", "doyle", "persists").unwrap();
            s.dismiss("cafe:1").unwrap();
            s.mark_seen("cafe:1", "doyle").unwrap();
        }
        let s = NotifStore::open_at(&path).unwrap();
        let row = s.get("cafe:1").unwrap().unwrap();
        assert!(row.dismissed && row.seen.contains("doyle"));

        s.conn
            .execute("UPDATE notifs SET seen = 'not json' WHERE notif_id = 'cafe:1'", [])
            .unwrap();
        let degraded = s.get("cafe:1").unwrap().unwrap();
        assert!(degraded.seen.is_empty(), "corrupt cell degrades, store survives");
    }

    // [unit->REQ-NOTIF-1] the row round-trips through serde (the D8-2 wire
    // record carries NotifRow whole) and an unknown future field decodes
    // (wire forward-compat, the replicate.rs posture).
    #[test]
    fn row_serde_round_trips_with_forward_compat() {
        let mut row = remote_row("n1:3", "home");
        row.seen.insert("doyle".into());
        let json = serde_json::to_string(&row).unwrap();
        let back: NotifRow = serde_json::from_str(&json).unwrap();
        assert_eq!(back, row);

        let wire = r#"{"notif_id":"n2:1","subnet":"home","kind":"agent","from_id":"ling",
            "body":"hi","created_ms":1,"dismissed":false,"seen":[],"last_surfaced_ms":0,
            "added_at_d9":true}"#;
        let future: NotifRow = serde_json::from_str(wire).unwrap();
        assert_eq!(future.notif_id, "n2:1");
    }
}


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

//! Subnet notification spool (ADR-0007 / M4-D8a) — the notif as a first-class
//! kind, distinct from agent→agent messages: **user-directed, dismissable,
//! resurfacing**.
//!
//! One node-level SQLite database ([`notifs_file`](crate::perch::notifs_file)),
//! subnet-tagged rows. The replicated state is deliberately a
//! **join-semilattice** — every field is monotone, so concurrent writes on
//! different nodes commute and a replayed replication feed no-ops:
//!
//! - the row itself — insert idempotent on the unique [`NotifRow::notif_id`]
//!   (`<node-hex>:<epoch>`, stamped from the node's monotonic
//!   [`EpochSource`](crate::epoch::EpochSource) — the same per-node epoch
//!   source the D3b registry lease and D6b sync precedence ride);
//! - `dismissed` — a one-way `false→true` latch (merge = OR): a stale
//!   undismissed copy can never un-dismiss;
//! - `seen` — the set of endpoint ids the notif has surfaced at
//!   (merge = union): *seen* (surfaced at least once, per-endpoint) is
//!   distinct from *dismissed* (explicit ack) — surfacing ≠ dismissal;
//! - `last_surfaced_ms` — merge = max; powers the cross-endpoint resurface
//!   suppression window. Wall-clock here is a suppression heuristic only,
//!   never an ordering authority (a skewed clock can at worst over/under-
//!   suppress one resurface window — state is never lost to it).
//!
//! There is no version vector and no conflict surface: unlike D6b's divergent
//! text snapshots, nothing here needs causality — the join *is* the merge.
//! [`NotifStore::merge_row`] is the single apply point the D8 replication
//! feed drives (mirroring how the D4d registry feed drives `merge_instance`).
// [impl->REQ-NOTIF-1]

use std::collections::BTreeSet;
use std::path::Path;

use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};

use crate::epoch::EpochSource;

/// One notification — the full replicated row. Identity + content fields
/// (`notif_id` through `created_ms`) are immutable after produce (the same
/// `notif_id` always names the same content); the state fields (`dismissed`,
/// `seen`, `last_surfaced_ms`) are the monotone semilattice (module docs).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NotifRow {
    /// `<node-hex>:<epoch>` — unique subnet-wide, the idempotent insert key.
    pub notif_id: String,
    /// The subnet this notif belongs to (the replication + surfacing scope).
    pub subnet: String,
    /// Producer tag (`consent`, `update`, `psyche`, `agent`, …) — an open set
    /// (ADR-0007: one primitive, open producer set).
    pub kind: String,
    /// The issuer (ADR-0007 envelope `from`: endpoint id, with node + subnet
    /// qualification supplied by the producing daemon where it applies).
    pub from_id: String,
    /// The notif body (4.1-codec text; composed into a typed envelope at
    /// surfacing, not here).
    pub body: String,
    /// Produce-time wall-clock stamp (display/audit only).
    pub created_ms: u64,
    /// Explicit ack — the one-way latch. Replicates subnet-wide.
    pub dismissed: bool,
    /// Endpoint ids this notif has surfaced at (seen-per-endpoint).
    pub seen: BTreeSet<String>,
    /// Greatest surface time across all endpoints/nodes — the cross-endpoint
    /// suppression input. `0` = never surfaced.
    pub last_surfaced_ms: u64,
}

/// What [`NotifStore::merge_row`] did with one incoming row — the replication
/// feed's idempotency signal (an `Unchanged` replay writes nothing).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeOutcome {
    /// First sight of this `notif_id` — the row was inserted whole.
    Inserted,
    /// Known row; the join advanced at least one state field.
    Joined,
    /// Known row; the incoming state was wholly dominated — no write.
    Unchanged,
}

/// Handle over the notif database. Open per use (the spool.db posture —
/// short-lived connections, WAL + busy_timeout make concurrent openers safe).
pub struct NotifStore {
    conn: Connection,
}

impl NotifStore {
    /// Open (creating if needed) the canonical node-level store at
    /// [`notifs_file`](crate::perch::notifs_file).
    pub fn open() -> rusqlite::Result<NotifStore> {
        Self::open_at(&crate::perch::notifs_file())
    }

    /// Open at an explicit path (the testable core), creating the parent dir
    /// and schema if absent.
    pub fn open_at(path: &Path) -> rusqlite::Result<NotifStore> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)
                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
        }
        let conn = Connection::open(path)?;
        // [impl->REQ-HAZARD-REGISTRY-CONCURRENT] shared concurrency-safe pragmas.
        crate::db::tune_connection(&conn)?;
        conn.execute_batch(
            "CREATE TABLE IF NOT EXISTS notifs (
                notif_id TEXT PRIMARY KEY,
                subnet TEXT NOT NULL,
                kind TEXT NOT NULL,
                from_id TEXT NOT NULL,
                body TEXT NOT NULL,
                created_ms INTEGER NOT NULL,
                dismissed INTEGER NOT NULL DEFAULT 0,
                seen TEXT NOT NULL DEFAULT '[]',
                last_surfaced_ms INTEGER NOT NULL DEFAULT 0
            )",
        )?;
        Ok(NotifStore { conn })
    }

    /// Produce a new notif: mint `notif_id` from this node's monotonic
    /// [`EpochSource`] (`<node_hex>:<epoch>` — unique, replicable), insert it
    /// undismissed/unseen, and return the row (the caller hands it to
    /// first-fire and/or the replication feed). The epoch is consumed only on
    /// a successful insert path — a failed persist returns the error with the
    /// counter already durably advanced (a skipped epoch is harmless; a
    /// reissued one is not).
    // [impl->REQ-NOTIF-1]
    #[allow(clippy::too_many_arguments)]
    pub fn produce(
        &self,
        node_hex: &str,
        epochs: &mut EpochSource,
        subnet: &str,
        kind: &str,
        from_id: &str,
        body: &str,
    ) -> rusqlite::Result<NotifRow> {
        let epoch = epochs
            .next_epoch()
            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
        let row = NotifRow {
            notif_id: format!("{node_hex}:{epoch}"),
            subnet: subnet.to_string(),
            kind: kind.to_string(),
            from_id: from_id.to_string(),
            body: body.to_string(),
            created_ms: now_ms(),
            dismissed: false,
            seen: BTreeSet::new(),
            last_surfaced_ms: 0,
        };
        self.insert_row(&row)?;
        Ok(row)
    }

    /// Merge one incoming (replicated) row — the semilattice join, the single
    /// apply point for the D8 replication feed. Unknown id ⇒ insert whole;
    /// known id ⇒ join the state fields (`dismissed` OR, `seen` union,
    /// `last_surfaced_ms` max; identity/content fields keep the stored values
    /// — same id, same content by construction). Idempotent and commutative:
    /// a replayed or reordered feed converges to the same row.
    // [impl->REQ-NOTIF-1]
    pub fn merge_row(&self, incoming: &NotifRow) -> rusqlite::Result<MergeOutcome> {
        // BEGIN IMMEDIATE: the read-join-write must be one writer transaction
        // (two concurrent joins on one node must serialize, not lose updates).
        self.conn.execute_batch("BEGIN IMMEDIATE")?;
        let outcome = (|| -> rusqlite::Result<MergeOutcome> {
            let Some(current) = self.read_row(&incoming.notif_id)? else {
                self.insert_row(incoming)?;
                return Ok(MergeOutcome::Inserted);
            };
            let dismissed = current.dismissed || incoming.dismissed;
            let mut seen = current.seen.clone();
            seen.extend(incoming.seen.iter().cloned());
            let last_surfaced_ms = current.last_surfaced_ms.max(incoming.last_surfaced_ms);
            if dismissed == current.dismissed
                && seen == current.seen
                && last_surfaced_ms == current.last_surfaced_ms
            {
                return Ok(MergeOutcome::Unchanged);
            }
            self.conn.execute(
                "UPDATE notifs SET dismissed = ?2, seen = ?3, last_surfaced_ms = ?4
                 WHERE notif_id = ?1",
                params![incoming.notif_id, dismissed as i64, encode_seen(&seen), last_surfaced_ms as i64],
            )?;
            Ok(MergeOutcome::Joined)
        })();
        match &outcome {
            Ok(_) => self.conn.execute_batch("COMMIT")?,
            Err(_) => self.conn.execute_batch("ROLLBACK")?,
        }
        outcome
    }

    /// Dismiss (explicit ack) — the one-way latch. Returns whether the latch
    /// flipped (`false` = already dismissed or unknown id; never un-dismisses).
    // [impl->REQ-NOTIF-1]
    pub fn dismiss(&self, notif_id: &str) -> rusqlite::Result<bool> {
        let n = self.conn.execute(
            "UPDATE notifs SET dismissed = 1 WHERE notif_id = ?1 AND dismissed = 0",
            params![notif_id],
        )?;
        Ok(n > 0)
    }

    /// Record that the notif surfaced at `endpoint` (seen-per-endpoint, set
    /// union). Returns whether the set grew.
    // [impl->REQ-NOTIF-1]
    pub fn mark_seen(&self, notif_id: &str, endpoint: &str) -> rusqlite::Result<bool> {
        self.conn.execute_batch("BEGIN IMMEDIATE")?;
        let res = (|| -> rusqlite::Result<bool> {
            let Some(mut row) = self.read_row(notif_id)? else { return Ok(false) };
            if !row.seen.insert(endpoint.to_string()) {
                return Ok(false);
            }
            self.conn.execute(
                "UPDATE notifs SET seen = ?2 WHERE notif_id = ?1",
                params![notif_id, encode_seen(&row.seen)],
            )?;
            Ok(true)
        })();
        match &res {
            Ok(_) => self.conn.execute_batch("COMMIT")?,
            Err(_) => self.conn.execute_batch("ROLLBACK")?,
        }
        res
    }

    /// Advance `last_surfaced_ms` to `now_ms` if greater (max-merge — a lagging
    /// clock never rewinds the suppression window). Returns whether it moved.
    // [impl->REQ-NOTIF-1]
    pub fn touch_surfaced(&self, notif_id: &str, now_ms: u64) -> rusqlite::Result<bool> {
        let n = self.conn.execute(
            "UPDATE notifs SET last_surfaced_ms = ?2 WHERE notif_id = ?1 AND last_surfaced_ms < ?2",
            params![notif_id, now_ms as i64],
        )?;
        Ok(n > 0)
    }

    /// One row by id.
    pub fn get(&self, notif_id: &str) -> rusqlite::Result<Option<NotifRow>> {
        self.read_row(notif_id)
    }

    /// Every row in `subnet`, oldest produce first (created_ms, id tiebreak).
    pub fn list(&self, subnet: &str) -> rusqlite::Result<Vec<NotifRow>> {
        self.select_rows("subnet = ?1 ORDER BY created_ms ASC, notif_id ASC", params![subnet])
    }

    /// The undismissed rows in `subnet` — the resurface engine's working set.
    // [impl->REQ-NOTIF-1]
    pub fn undismissed(&self, subnet: &str) -> rusqlite::Result<Vec<NotifRow>> {
        self.select_rows(
            "subnet = ?1 AND dismissed = 0 ORDER BY created_ms ASC, notif_id ASC",
            params![subnet],
        )
    }

    fn insert_row(&self, row: &NotifRow) -> rusqlite::Result<()> {
        self.conn.execute(
            "INSERT INTO notifs (notif_id, subnet, kind, from_id, body, created_ms,
                                 dismissed, seen, last_surfaced_ms)
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
            params![
                row.notif_id,
                row.subnet,
                row.kind,
                row.from_id,
                row.body,
                row.created_ms as i64,
                row.dismissed as i64,
                encode_seen(&row.seen),
                row.last_surfaced_ms as i64,
            ],
        )?;
        Ok(())
    }

    fn read_row(&self, notif_id: &str) -> rusqlite::Result<Option<NotifRow>> {
        self.conn
            .query_row(
                &format!("SELECT {COLS} FROM notifs WHERE notif_id = ?1"),
                params![notif_id],
                decode_row,
            )
            .optional()
    }

    fn select_rows(
        &self,
        where_order: &str,
        p: impl rusqlite::Params,
    ) -> rusqlite::Result<Vec<NotifRow>> {
        let mut stmt =
            self.conn.prepare(&format!("SELECT {COLS} FROM notifs WHERE {where_order}"))?;
        let rows = stmt.query_map(p, decode_row)?.filter_map(|r| r.ok()).collect();
        Ok(rows)
    }
}

/// The selected column list shared by every reader (order matches [`decode_row`]).
const COLS: &str =
    "notif_id, subnet, kind, from_id, body, created_ms, dismissed, seen, last_surfaced_ms";

fn decode_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<NotifRow> {
    Ok(NotifRow {
        notif_id: r.get(0)?,
        subnet: r.get(1)?,
        kind: r.get(2)?,
        from_id: r.get(3)?,
        body: r.get(4)?,
        created_ms: r.get::<_, i64>(5)? as u64,
        dismissed: r.get::<_, i64>(6)? != 0,
        seen: decode_seen(&r.get::<_, String>(7)?),
        last_surfaced_ms: r.get::<_, i64>(8)? as u64,
    })
}

/// `seen` persists as a JSON array (BTreeSet ⇒ sorted, stable bytes).
fn encode_seen(seen: &BTreeSet<String>) -> String {
    serde_json::to_string(seen).expect("a string set always serializes")
}

/// Corrupt/legacy cells degrade to empty (damaged state must not brick the
/// store; the set re-grows from live surfacing + replication).
fn decode_seen(s: &str) -> BTreeSet<String> {
    serde_json::from_str(s).unwrap_or_default()
}

fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

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

    fn store(dir: &Path) -> NotifStore {
        NotifStore::open_at(&dir.join("notifs.db")).unwrap()
    }

    fn epochs(dir: &Path) -> EpochSource {
        EpochSource::load_from(&dir.join("epoch"))
    }

    /// A detached row for merge tests (as if replicated from another node).
    fn remote_row(id: &str, subnet: &str) -> NotifRow {
        NotifRow {
            notif_id: id.to_string(),
            subnet: subnet.to_string(),
            kind: "agent".into(),
            from_id: "ling".into(),
            body: "build done".into(),
            created_ms: 1_000,
            dismissed: false,
            seen: BTreeSet::new(),
            last_surfaced_ms: 0,
        }
    }

    // [unit->REQ-NOTIF-1] produce mints unique `<node-hex>:<epoch>` ids from
    // the monotonic source, inserts undismissed/unseen, and list returns rows
    // in produce order; dismiss latches and undismissed() drops the row.
    #[test]
    fn produce_list_dismiss_round_trip() {
        let dir = tempdir().unwrap();
        let s = store(dir.path());
        let mut e = epochs(dir.path());

        let a = s.produce("cafe", &mut e, "home", "agent", "doyle", "first").unwrap();
        let b = s.produce("cafe", &mut e, "home", "consent", "spt", "second").unwrap();
        assert_eq!(a.notif_id, "cafe:1");
        assert_eq!(b.notif_id, "cafe:2");
        assert!(!a.dismissed && a.seen.is_empty() && a.last_surfaced_ms == 0);

        let listed = s.list("home").unwrap();
        assert_eq!(
            listed.iter().map(|r| r.notif_id.as_str()).collect::<Vec<_>>(),
            vec!["cafe:1", "cafe:2"]
        );

        assert!(s.dismiss("cafe:1").unwrap(), "latch flips");
        assert!(!s.dismiss("cafe:1").unwrap(), "already latched");
        assert!(!s.dismiss("ghost:9").unwrap(), "unknown id is a no-op");
        let undis = s.undismissed("home").unwrap();
        assert_eq!(undis.len(), 1);
        assert_eq!(undis[0].notif_id, "cafe:2");
        // Dismissed row still listed (audit), just not in the working set.
        assert_eq!(s.list("home").unwrap().len(), 2);
    }

    // [unit->REQ-NOTIF-1] rows are subnet-scoped: list/undismissed never leak
    // across subnets.
    #[test]
    fn listing_is_subnet_scoped() {
        let dir = tempdir().unwrap();
        let s = store(dir.path());
        let mut e = epochs(dir.path());
        s.produce("cafe", &mut e, "home", "agent", "doyle", "x").unwrap();
        s.produce("cafe", &mut e, "work", "agent", "doyle", "y").unwrap();
        assert_eq!(s.list("home").unwrap().len(), 1);
        assert_eq!(s.undismissed("work").unwrap().len(), 1);
        assert!(s.list("elsewhere").unwrap().is_empty());
    }

    // [unit->REQ-NOTIF-1] merge is the semilattice join: idempotent (replay
    // ⇒ Unchanged, nothing written) and commutative (A∘B == B∘A — two stores
    // fed the same rows in opposite orders converge to identical state).
    #[test]
    fn merge_is_idempotent_and_commutative() {
        let dir = tempdir().unwrap();

        let mut a = remote_row("n1:5", "home");
        a.dismissed = true;
        let mut b = remote_row("n1:5", "home");
        b.seen.insert("doyle".into());
        b.last_surfaced_ms = 7_000;

        let s1 = NotifStore::open_at(&dir.path().join("s1.db")).unwrap();
        assert_eq!(s1.merge_row(&a).unwrap(), MergeOutcome::Inserted);
        assert_eq!(s1.merge_row(&b).unwrap(), MergeOutcome::Joined);
        assert_eq!(s1.merge_row(&a).unwrap(), MergeOutcome::Unchanged, "replay no-ops");
        assert_eq!(s1.merge_row(&b).unwrap(), MergeOutcome::Unchanged, "replay no-ops");

        let s2 = NotifStore::open_at(&dir.path().join("s2.db")).unwrap();
        assert_eq!(s2.merge_row(&b).unwrap(), MergeOutcome::Inserted);
        assert_eq!(s2.merge_row(&a).unwrap(), MergeOutcome::Joined);

        let r1 = s1.get("n1:5").unwrap().unwrap();
        let r2 = s2.get("n1:5").unwrap().unwrap();
        assert_eq!(r1, r2, "opposite apply orders converge");
        assert!(r1.dismissed, "OR survived");
        assert!(r1.seen.contains("doyle"), "union survived");
        assert_eq!(r1.last_surfaced_ms, 7_000, "max survived");
    }

    // [unit->REQ-NOTIF-1] the dismiss latch never un-dismisses: a stale
    // replicated copy with dismissed=false cannot clobber a local true (the
    // acceptance "dismiss replicates subnet-wide" depends on this hold).
    #[test]
    fn stale_undismissed_copy_cannot_undismiss() {
        let dir = tempdir().unwrap();
        let s = store(dir.path());

        let mut dismissed = remote_row("n1:1", "home");
        dismissed.dismissed = true;
        s.merge_row(&dismissed).unwrap();

        let stale = remote_row("n1:1", "home"); // dismissed=false, no state
        assert_eq!(s.merge_row(&stale).unwrap(), MergeOutcome::Unchanged);
        assert!(s.get("n1:1").unwrap().unwrap().dismissed, "latch held");
    }

    // [unit->REQ-NOTIF-1] seen unions and last_surfaced maxes through the
    // local mutators too — and touch_surfaced never rewinds (max-merge).
    #[test]
    fn seen_unions_and_surfaced_maxes() {
        let dir = tempdir().unwrap();
        let s = store(dir.path());
        s.merge_row(&remote_row("n1:1", "home")).unwrap();

        assert!(s.mark_seen("n1:1", "doyle").unwrap());
        assert!(!s.mark_seen("n1:1", "doyle").unwrap(), "already in the set");
        assert!(s.mark_seen("n1:1", "ling").unwrap());
        assert!(!s.mark_seen("ghost:9", "doyle").unwrap(), "unknown id no-ops");

        assert!(s.touch_surfaced("n1:1", 5_000).unwrap());
        assert!(!s.touch_surfaced("n1:1", 4_000).unwrap(), "never rewinds");
        assert!(s.touch_surfaced("n1:1", 6_000).unwrap());

        let row = s.get("n1:1").unwrap().unwrap();
        assert_eq!(
            row.seen.iter().map(String::as_str).collect::<Vec<_>>(),
            vec!["doyle", "ling"]
        );
        assert_eq!(row.last_surfaced_ms, 6_000);
    }

    // [unit->REQ-NOTIF-1] state is durable across reopen (a daemon restart
    // loses nothing), and a corrupt seen cell degrades to empty, not a panic.
    #[test]
    fn reopen_is_durable_and_corrupt_seen_degrades() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("notifs.db");
        {
            let s = NotifStore::open_at(&path).unwrap();
            let mut e = epochs(dir.path());
            s.produce("cafe", &mut e, "home", "agent", "doyle", "persists").unwrap();
            s.dismiss("cafe:1").unwrap();
            s.mark_seen("cafe:1", "doyle").unwrap();
        }
        let s = NotifStore::open_at(&path).unwrap();
        let row = s.get("cafe:1").unwrap().unwrap();
        assert!(row.dismissed && row.seen.contains("doyle"));

        s.conn
            .execute("UPDATE notifs SET seen = 'not json' WHERE notif_id = 'cafe:1'", [])
            .unwrap();
        let degraded = s.get("cafe:1").unwrap().unwrap();
        assert!(degraded.seen.is_empty(), "corrupt cell degrades, store survives");
    }

    // [unit->REQ-NOTIF-1] the row round-trips through serde (the D8-2 wire
    // record carries NotifRow whole) and an unknown future field decodes
    // (wire forward-compat, the replicate.rs posture).
    #[test]
    fn row_serde_round_trips_with_forward_compat() {
        let mut row = remote_row("n1:3", "home");
        row.seen.insert("doyle".into());
        let json = serde_json::to_string(&row).unwrap();
        let back: NotifRow = serde_json::from_str(&json).unwrap();
        assert_eq!(back, row);

        let wire = r#"{"notif_id":"n2:1","subnet":"home","kind":"agent","from_id":"ling",
            "body":"hi","created_ms":1,"dismissed":false,"seen":[],"last_surfaced_ms":0,
            "added_at_d9":true}"#;
        let future: NotifRow = serde_json::from_str(wire).unwrap();
        assert_eq!(future.notif_id, "n2:1");
    }
}


Edit

pub mod nodeid;
pub mod notif;
pub mod perch;


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

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


1	//! `spt-store` — the persistence commodity layer for spt-core.
2	//!
3	//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
4	//! types to disk (spool, registry, info.json) through a single atomic-write
5	//! primitive. Stable on-disk formats are copied verbatim from the sister
6	//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
7	//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
8	//!
9	// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
10	// depends only on spt-proto (one layer down), never the reverse — the
11	// dependency edge is one-way, so the workspace graph stays acyclic.
12	// [impl->REQ-ARCH-1]
13
14	pub mod access;
15	pub mod atomic;
16	pub mod branchstore;
17	pub mod contextmark;
18	pub mod contextstore;
19	mod db;
20	pub mod epoch;
21	pub mod gitrun;
22	pub mod history;
23	pub mod info;
24	pub mod liveness;
25	pub mod nodeid;
26	pub mod perch;
27	pub mod proc;
28	pub mod project;
29	pub mod registry;
30	pub mod rename;
31	pub mod seed;
32	pub mod spool;
33	pub mod subnet;
34	pub mod syncmerge;
35	pub mod trust;
36	pub mod visibility;
37	pub mod xfer;
38

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

pub mod access;
pub mod atomic;
pub mod branchstore;
pub mod contextmark;
pub mod contextstore;
mod db;
pub mod epoch;
pub mod gitrun;
pub mod history;
pub mod info;
pub mod liveness;
pub mod nodeid;
pub mod perch;
pub mod proc;
pub mod project;
pub mod registry;
pub mod rename;
pub mod seed;
pub mod spool;
pub mod subnet;
pub mod syncmerge;
pub mod trust;
pub mod visibility;
pub mod xfer;


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.


pub mod nodeid;
pub mod notif;
pub mod perch;


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

pub mod nodeid;
pub mod notif;
pub mod perch;

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

pub mod access;
pub mod atomic;
pub mod branchstore;
pub mod contextmark;
pub mod contextstore;
mod db;
pub mod epoch;
pub mod gitrun;
pub mod history;
pub mod info;
pub mod liveness;
pub mod nodeid;
pub mod perch;
pub mod proc;
pub mod project;
pub mod registry;
pub mod rename;
pub mod seed;
pub mod spool;
pub mod subnet;
pub mod syncmerge;
pub mod trust;
pub mod visibility;
pub mod xfer;

pub mod info;
pub mod liveness;
pub mod nodeid;
+pub mod notif;
pub mod perch;
pub mod proc;
pub mod project;


Bash

cargo test -p spt-store notif 2>&1 | tail -25

Build + run notif store tests


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

running 7 tests
test notif::tests::row_serde_round_trips_with_forward_compat ... ok
test notif::tests::stale_undismissed_copy_cannot_undismiss ... ok
test notif::tests::listing_is_subnet_scoped ... ok
test notif::tests::seen_unions_and_surfaced_maxes ... ok
test notif::tests::produce_list_dismiss_round_trip ... ok
test notif::tests::reopen_is_durable_and_corrupt_seen_degrades ... ok
test notif::tests::merge_is_idempotent_and_commutative ... ok

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


cargo clippy -p spt-store --all-targets -- -D warnings 2>&1 | tail -5 && traceable-reqs check 2>&1 | tail -5

Clippy -D warnings + traceability check


    Checking spt-proto v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-proto)
    Checking spt-store v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-store)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.28s
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-core ripple-updates registered adapters


traceable-reqs check > $null 2>&1; echo "exit=$LASTEXITCODE"; cargo test -p spt-store 2>&1 | tail -3

Verify traceable-reqs exit code + full spt-store suite


/usr/bin/bash: line 4: $null: ambiguous redirect
exit=

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


traceable-reqs check >/dev/null 2>&1; echo "exit=$?"; cargo test -p spt-store 2>&1 | grep "test result"

traceable-reqs exit code + full spt-store suite results


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


Green across the board. Commit + push D8-1:


git add crates/spt-store/src/notif.rs crates/spt-store/src/lib.rs crates/spt-store/src/perch.rs && git commit -m "feat(store): M4-D8-1 notif store + semilattice merge (ADR-0007)

NotifRow + NotifStore over identity/notifs.db: produce mints
<node-hex>:<epoch> ids from the node's monotonic EpochSource; dismissed is
a one-way latch (OR), seen a per-endpoint set (union), last_surfaced_ms
max-merged — a join-semilattice, so merge_row (the replication feed's
single apply point) is idempotent + commutative with no version vector and
no conflict surface. Deliberately simpler than D6b: notif state is wholly
monotone. Unit: produce/list/dismiss round-trip, subnet scoping, join
convergence under opposite apply orders, latch never un-dismisses, reopen
durability, serde forward-compat for the D8-2 wire record.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>" && git push && git log --oneline -1

Commit and push D8-1


warning: in the working copy of 'crates/spt-store/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/notif.rs', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 9a6548f] feat(store): M4-D8-1 notif store + semilattice merge (ADR-0007)
 3 files changed, 534 insertions(+)
 create mode 100644 crates/spt-store/src/notif.rs
To https://github.com/SaberMage/spt-core.git
   e405b43..9a6548f  dev-freeform -> dev-freeform
9a6548f feat(store): M4-D8-1 notif store + semilattice merge (ADR-0007)


Pushed `9a6548f`. Watching CI (background — queue can sit minutes on self-hosted runners):


for i in $(seq 1 60); do out=$(gh run list --branch dev-freeform --limit 1 --json headSha,status,conclusion --jq '.[0] | "\(.headSha[0:7]) \(.status) \(.conclusion)"'); echo "$out"; case "$out" in 9a6548f\ completed*) break;; esac; sleep 30; done

Poll CI until run for 9a6548f completes


Command running in background with ID: bb2tepmc0. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bb2tepmc0.output. You will be notified when it completes. To check interim output, use Read on that file path.


While CI runs — prep D8-2: study the D4d replication E2E shape (the template for the notif feed E2E):


1	//! D4d regression — registry replication over the wire: transport → merge.
2	//!
3	//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
4	//! two daemons. Node A advertises a local endpoint (through the D3d/D3e
5	//! advertise gate, authored with its own [`EpochSource`] stamp) and replicates
6	//! the admitted row to node B over a broker-owned QUIC stream (D4a/b). B
7	//! decodes the NDJSON feed and applies each record straight through the D3
8	//! `merge_instance` seam. The invariants:
9	//!
10	//! - the registries **converge**: B holds A's row (node, status, epoch) verbatim;
11	//! - the **epoch lease holds over the wire**: a lagging update replayed after a
12	//!   newer one merges `Stale` and the stored row is untouched
13	//!   (REQ-HAZARD-REGISTRY-EPOCH-LEASE, now wire-fed);
14	//! - the framing survives **real QUIC chunking**: a record split across two
15	//!   separate stream sends reassembles on B's decoder.
16
17	use std::collections::BTreeMap;
18	use std::sync::atomic::{AtomicU32, Ordering};
19	use std::sync::Arc;
20	use std::thread;
21	use std::time::Duration;
22
23	use spt_daemon::brain::{Brain, BrokerEvent};
24	use spt_daemon::nethost::{NetConfig, NetHost};
25	use spt_daemon::Broker;
26	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
27	use spt_net::net::registry::{
28	    advertise_if_visible, Instance, MergeOutcome, Status, SubnetRegistry,
29	};
30	use spt_net::net::replicate::{apply_update, LineDecoder, RegistryUpdate};
31	use spt_proto::identity::Identity;
32	use spt_store::epoch::EpochSource;
33	use spt_store::visibility::VisibilityStore;
34
35	static SEQ: AtomicU32 = AtomicU32::new(0);
36	fn unique_name() -> String {
37	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
38	    format!("spt-daemon-d4d-{}-{}.sock", std::process::id(), n)
39	}
40
41	fn hermetic() -> NetConfig {
42	    NetConfig {
43	        identity: Identity::generate(),
44	        relay: RelayPolicy::Disabled,
45	        discovery: LocalDiscovery::Off,
46	        scope: BindScope::Loopback,
47	    }
48	}
49
50	fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
51	    let host = NetHost::start(hermetic()).expect("net host start");
52	    let broker =
53	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
54	    let serve = Arc::clone(&broker);
55	    thread::spawn(move || {
56	        let _ = serve.serve();
57	    });
58	    broker
59	}
60
61	fn connect_retry(name: &str) -> Brain {
62	    for _ in 0..300 {
63	        if let Ok(b) = Brain::cold_start(name, 1) {
64	            return b;
65	        }
66	        thread::sleep(Duration::from_millis(10));
67	    }
68	    panic!("brain could not connect");
69	}
70
71	/// Poll the receiving brain's `net-streams` until a stream appears.
72	fn wait_for_stream(brain: &mut Brain) -> u64 {
73	    for _ in 0..400 {
74	        let reply = brain.net_streams().expect("net-streams");
75	        if let Some(s) = reply.streams.first() {
76	            return s.stream_id;
77	        }
78	        thread::sleep(Duration::from_millis(5));
79	    }
80	    panic!("stream never appeared at the receiver's broker");
81	}
82
83	// [unit->REQ-INST-7] two daemons converge a registry row over the broker-owned
84	// wire: advertise gate → EpochSource-authored update → NDJSON over a QUIC
85	// stream → merge_instance at the receiver. A record split across two separate
86	// sends reassembles (QUIC chunking is not record framing).
87	// [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] and the lease holds over the wire:
88	// a lagging Active replayed after a newer Offline merges Stale — the stored
89	// row keeps the newer state (a stale Active can't clobber a newer Offline,
90	// now fed by the real transport).
91	#[test]
92	fn registries_converge_over_the_wire_and_the_lease_holds() {
93	    let dir = tempfile::tempdir().expect("tempdir");
94	    let (name_a, name_b) = (unique_name(), unique_name());
95	    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
96	    let _broker_b = net_broker(&name_b, &dir.path().join("b"));
97
98	    // ── Node A: admit the row locally, then author the wire updates. ────────
99	    let mut sender = connect_retry(&name_a);
100	    let a_node = sender.net_status().expect("a status").node_id_hex.expect("node id");
101	    let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
102	    let visibility = VisibilityStore::load_from(&dir.path().join("a-visibility.json"));
103
104	    let mut a_regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
105	    a_regs.insert("home".into(), SubnetRegistry::new());
106
107	    // The advertise gate runs LOCALLY, before anything is emitted: the wire
108	    // only ever carries already-admitted state (D3d collision + D3e visibility).
109	    let e1 = epochs.next_epoch().expect("epoch");
110	    let active = Instance { node: a_node.clone(), status: Status::Active, epoch: e1 };
111	    let admitted = advertise_if_visible(
112	        a_regs.get_mut("home").expect("member"),
113	        &visibility,
114	        "home",
115	        false,
116	        "doyle",
117	        active.clone(),
118	    )
119	    .expect("no collision")
120	    .expect("visible — advertised");
121	    assert_eq!(admitted, MergeOutcome::Inserted);
122
123	    let update_active = RegistryUpdate {
124	        subnet: "home".into(),
125	        endpoint_id: "doyle".into(),
126	        instance: active,
127	    };
128	    let e2 = epochs.next_epoch().expect("epoch");
129	    assert!(e2 > e1, "EpochSource is strictly monotonic");
130	    let update_offline = RegistryUpdate {
131	        subnet: "home".into(),
132	        endpoint_id: "doyle".into(),
133	        instance: Instance { node: a_node.clone(), status: Status::Offline, epoch: e2 },
134	    };
135
136	    // ── The wire: dial B, open a stream, send the feed (journaled ops). ─────
137	    let mut b_probe = connect_retry(&name_b);
138	    let addr = b_probe.net_status().expect("b status").addr;
139	    let conn = sender.net_dial(addr, Some(1)).expect("dial");
140	    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");
141
142	    // Send 1: the Active row, split MID-RECORD across two separate sends —
143	    // the receiver's decoder, not QUIC chunk shape, owns record framing.
144	    let line = update_active.encode_line();
145	    let split = line.len() / 2;
146	    sender
147	        .net_stream_send(opened.stream_id, &line[..split], Some(100), false)
148	        .expect("send first half");
149	    sender
150	        .net_stream_send(opened.stream_id, &line[split..], Some(101), false)
151	        .expect("send second half");
152	    // Send 2: the newer Offline. Send 3: the e1 Active REPLAYED (a lagging
153	    // duplicate arriving late) — the lease must drop it at B.
154	    sender
155	        .net_stream_send(opened.stream_id, &update_offline.encode_line(), Some(102), false)
156	        .expect("send offline");
157	    sender
158	        .net_stream_send(opened.stream_id, &update_active.encode_line(), Some(103), true)
159	        .expect("replay stale + finish");
160
161	    // ── Node B: decode the feed and apply through the merge seam. ───────────
162	    let mut b_regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
163	    b_regs.insert("home".into(), SubnetRegistry::new());
164
165	    let stream = wait_for_stream(&mut b_probe);
166	    b_probe.net_stream_subscribe(stream, 0).expect("subscribe");
167	    let mut decoder = LineDecoder::new();
168	    let mut outcomes: Vec<MergeOutcome> = Vec::new();
169	    loop {
170	        match b_probe.read_event().expect("read event") {
171	            BrokerEvent::NetStreamData { stream_id, bytes, .. } if stream_id == stream => {
172	                for upd in decoder.push(&bytes) {
173	                    outcomes.push(apply_update(&mut b_regs, upd).expect("member subnet"));
174	                }
175	            }
176	            BrokerEvent::NetStreamEof { stream_id, .. } if stream_id == stream => break,
177	            BrokerEvent::Error { message } => panic!("broker error: {message}"),
178	            _ => {}
179	        }
180	    }
181
182	    // Convergence + the lease, end to end: inserted, superseded, stale-dropped.
183	    assert_eq!(
184	        outcomes,
185	        vec![MergeOutcome::Inserted, MergeOutcome::Updated, MergeOutcome::Stale],
186	        "wire feed merged under the lease"
187	    );
188	    let row = &b_regs["home"].instances("doyle")[0];
189	    assert_eq!(row.node, a_node, "B holds A's row verbatim");
190	    assert_eq!(
191	        (row.status, row.epoch),
192	        (Status::Offline, e2),
193	        "the newer Offline survives the stale Active replay"
194	    );
195	    // And B's registry equals A's post-advertise state merged with the newer
196	    // update — the two nodes agree on the endpoint's instance.
197	    let a_row = &a_regs["home"].instances("doyle")[0];
198	    assert_eq!(a_row.node, row.node);
199	}
200

//! D4d regression — registry replication over the wire: transport → merge.
//!
//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
//! two daemons. Node A advertises a local endpoint (through the D3d/D3e
//! advertise gate, authored with its own [`EpochSource`] stamp) and replicates
//! the admitted row to node B over a broker-owned QUIC stream (D4a/b). B
//! decodes the NDJSON feed and applies each record straight through the D3
//! `merge_instance` seam. The invariants:
//!
//! - the registries **converge**: B holds A's row (node, status, epoch) verbatim;
//! - the **epoch lease holds over the wire**: a lagging update replayed after a
//!   newer one merges `Stale` and the stored row is untouched
//!   (REQ-HAZARD-REGISTRY-EPOCH-LEASE, now wire-fed);
//! - the framing survives **real QUIC chunking**: a record split across two
//!   separate stream sends reassembles on B's decoder.

use std::collections::BTreeMap;
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::Broker;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::registry::{
    advertise_if_visible, Instance, MergeOutcome, Status, SubnetRegistry,
};
use spt_net::net::replicate::{apply_update, LineDecoder, RegistryUpdate};
use spt_proto::identity::Identity;
use spt_store::epoch::EpochSource;
use spt_store::visibility::VisibilityStore;

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-d4d-{}-{}.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(5));
    }
    panic!("stream never appeared at the receiver's broker");
}

// [unit->REQ-INST-7] two daemons converge a registry row over the broker-owned
// wire: advertise gate → EpochSource-authored update → NDJSON over a QUIC
// stream → merge_instance at the receiver. A record split across two separate
// sends reassembles (QUIC chunking is not record framing).
// [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] and the lease holds over the wire:
// a lagging Active replayed after a newer Offline merges Stale — the stored
// row keeps the newer state (a stale Active can't clobber a newer Offline,
// now fed by the real transport).
#[test]
fn registries_converge_over_the_wire_and_the_lease_holds() {
    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: admit the row locally, then author the wire updates. ────────
    let mut sender = connect_retry(&name_a);
    let a_node = sender.net_status().expect("a status").node_id_hex.expect("node id");
    let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
    let visibility = VisibilityStore::load_from(&dir.path().join("a-visibility.json"));

    let mut a_regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
    a_regs.insert("home".into(), SubnetRegistry::new());

    // The advertise gate runs LOCALLY, before anything is emitted: the wire
    // only ever carries already-admitted state (D3d collision + D3e visibility).
    let e1 = epochs.next_epoch().expect("epoch");
    let active = Instance { node: a_node.clone(), status: Status::Active, epoch: e1 };
    let admitted = advertise_if_visible(
        a_regs.get_mut("home").expect("member"),
        &visibility,
        "home",
        false,
        "doyle",
        active.clone(),
    )
    .expect("no collision")
    .expect("visible — advertised");
    assert_eq!(admitted, MergeOutcome::Inserted);

    let update_active = RegistryUpdate {
        subnet: "home".into(),
        endpoint_id: "doyle".into(),
        instance: active,
    };
    let e2 = epochs.next_epoch().expect("epoch");
    assert!(e2 > e1, "EpochSource is strictly monotonic");
    let update_offline = RegistryUpdate {
        subnet: "home".into(),
        endpoint_id: "doyle".into(),
        instance: Instance { node: a_node.clone(), status: Status::Offline, epoch: e2 },
    };

    // ── The wire: dial B, open a stream, send the feed (journaled ops). ─────
    let mut b_probe = connect_retry(&name_b);
    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");

    // Send 1: the Active row, split MID-RECORD across two separate sends —
    // the receiver's decoder, not QUIC chunk shape, owns record framing.
    let line = update_active.encode_line();
    let split = line.len() / 2;
    sender
        .net_stream_send(opened.stream_id, &line[..split], Some(100), false)
        .expect("send first half");
    sender
        .net_stream_send(opened.stream_id, &line[split..], Some(101), false)
        .expect("send second half");
    // Send 2: the newer Offline. Send 3: the e1 Active REPLAYED (a lagging
    // duplicate arriving late) — the lease must drop it at B.
    sender
        .net_stream_send(opened.stream_id, &update_offline.encode_line(), Some(102), false)
        .expect("send offline");
    sender
        .net_stream_send(opened.stream_id, &update_active.encode_line(), Some(103), true)
        .expect("replay stale + finish");

    // ── Node B: decode the feed and apply through the merge seam. ───────────
    let mut b_regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
    b_regs.insert("home".into(), SubnetRegistry::new());

    let stream = wait_for_stream(&mut b_probe);
    b_probe.net_stream_subscribe(stream, 0).expect("subscribe");
    let mut decoder = LineDecoder::new();
    let mut outcomes: Vec<MergeOutcome> = Vec::new();
    loop {
        match b_probe.read_event().expect("read event") {
            BrokerEvent::NetStreamData { stream_id, bytes, .. } if stream_id == stream => {
                for upd in decoder.push(&bytes) {
                    outcomes.push(apply_update(&mut b_regs, upd).expect("member subnet"));
                }
            }
            BrokerEvent::NetStreamEof { stream_id, .. } if stream_id == stream => break,
            BrokerEvent::Error { message } => panic!("broker error: {message}"),
            _ => {}
        }
    }

    // Convergence + the lease, end to end: inserted, superseded, stale-dropped.
    assert_eq!(
        outcomes,
        vec![MergeOutcome::Inserted, MergeOutcome::Updated, MergeOutcome::Stale],
        "wire feed merged under the lease"
    );
    let row = &b_regs["home"].instances("doyle")[0];
    assert_eq!(row.node, a_node, "B holds A's row verbatim");
    assert_eq!(
        (row.status, row.epoch),
        (Status::Offline, e2),
        "the newer Offline survives the stale Active replay"
    );
    // And B's registry equals A's post-advertise state merged with the newer
    // update — the two nodes agree on the endpoint's instance.
    let a_row = &a_regs["home"].instances("doyle")[0];
    assert_eq!(a_row.node, row.node);
}


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


90:pub struct TrustedPeer {
108:pub struct TrustStore {
117:    pub fn load() -> TrustStore {
125:    pub fn load_from(path: &Path) -> TrustStore {
133:    pub fn save(&self) -> Result<(), TrustError> {
140:    pub fn save_to(&self, path: &Path) -> Result<(), TrustError> {
152:    pub fn is_trusted(&self, subnet: &str, pubkey_hex: &str) -> bool {
159:    pub fn peers_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a TrustedPeer> {
172:    pub fn record(
210:    pub fn repin(
233:    pub fn revoke(&mut self, subnet: &str, pubkey_hex: &str) -> bool {


1	9a6548f queued
2

9a6548f queued


CI queued. Prepping D8-2 in working tree (no push until D8-1 FINAL green):


grep -A8 dependencies crates/spt-net/Cargo.toml crates/spt-daemon/Cargo.toml | head -30

Check spt-net and spt-daemon deps


crates/spt-net/Cargo.toml:[dependencies]
crates/spt-net/Cargo.toml-spt-proto = { path = "../spt-proto" }
crates/spt-net/Cargo.toml-spt-store = { path = "../spt-store" }
crates/spt-net/Cargo.toml-spt-msg = { path = "../spt-msg" }
crates/spt-net/Cargo.toml-# Wire/registry/pairing-transcript types cross the network + the broker IPC, so they
crates/spt-net/Cargo.toml-# are serde-serializable.
crates/spt-net/Cargo.toml-serde = { version = "1", features = ["derive"] }
crates/spt-net/Cargo.toml-# The D4d replication wire format is NDJSON (net/replicate.rs); `net`-gated like the
crates/spt-net/Cargo.toml-# rest of the transport surface.
--
crates/spt-net/Cargo.toml:[dev-dependencies]
crates/spt-net/Cargo.toml-tempfile = "3"
crates/spt-net/Cargo.toml-# Registry/wire serde roundtrip tests (D3a) exercise the JSON form.
crates/spt-net/Cargo.toml-serde_json = "1"
--
crates/spt-daemon/Cargo.toml:[dependencies]
crates/spt-daemon/Cargo.toml-# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
crates/spt-daemon/Cargo.toml-# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
crates/spt-daemon/Cargo.toml-# crate's position in the workspace layering. Tasks B1–B8 consume them.
crates/spt-daemon/Cargo.toml-spt-proto = { path = "../spt-proto" }
crates/spt-daemon/Cargo.toml-spt-store = { path = "../spt-store" }
crates/spt-daemon/Cargo.toml-spt-msg = { path = "../spt-msg" }
crates/spt-daemon/Cargo.toml-spt-runtime = { path = "../spt-runtime" }
crates/spt-daemon/Cargo.toml-spt-term = { path = "../spt-term" }
--
crates/spt-daemon/Cargo.toml:[dev-dependencies]
crates/spt-daemon/Cargo.toml-tempfile = "3"
crates/spt-daemon/Cargo.toml-# Integration tests are separate crates and do not inherit the lib's deps, so the
crates/spt-daemon/Cargo.toml-# B8 digest int-test re-declares the two it touches directly: the manifest seam
crates/spt-daemon/Cargo.toml-# (PtyDigest) and the perch-path/history resolution it asserts persistence against.


CI still queued. Writing D8-2 wire record (spt-net):


1	//! NDJSON line framing shared by the wire feeds (registry replication M4-D4d,
2	//! WAN messages M4-D5a).
3	//!
4	//! One JSON object per `\n`-terminated line. QUIC stream reads do **not**
5	//! preserve write boundaries (the read pump pulls up-to-64KiB chunks — writes
6	//! coalesce and split arbitrarily), so records need framing independent of
7	//! chunk shape; a newline delimiter on a JSON document (whose interior newlines
8	//! are always escaped) is the simplest framing that needs no length
9	//! bookkeeping. Decoding tolerates unknown fields (a newer node may add fields
10	//! — same posture as the IPC catalog) and skips undecodable lines (degrade,
11	//! don't die — the 4.3 stale-clean philosophy: one corrupt record must not
12	//! wedge the feed).
13	//!
14	//! Extracted from `replicate.rs` (where D4d proved the framing for
15	//! [`RegistryUpdate`](crate::net::replicate::RegistryUpdate)) when D5a's
16	//! [`WanMessage`](crate::net::wanmsg::WanMessage) became the second record
17	//! type riding it. The decode rules are identical for every record type; per-
18	//! record semantics (epoch lease, op-id dedup) stay with the record's module.
19
20	use std::marker::PhantomData;
21
22	use serde::de::DeserializeOwned;
23	use serde::Serialize;
24
25	/// Encode one record as one NDJSON line (the JSON document + `\n`).
26	pub fn encode_line<T: Serialize>(record: &T) -> Vec<u8> {
27	    let mut line = serde_json::to_vec(record).expect("wire record serializes");
28	    line.push(b'\n');
29	    line
30	}
31
32	/// Reassembles NDJSON records from arbitrarily-chunked stream bytes: push each
33	/// received chunk, take the complete records. A partial trailing line stays
34	/// buffered until its `\n` arrives; an undecodable line is skipped (see module
35	/// docs). One decoder per stream — records never span streams.
36	#[derive(Debug)]
37	pub struct NdjsonDecoder<T> {
38	    buf: Vec<u8>,
39	    _records: PhantomData<fn() -> T>,
40	}
41
42	impl<T> Default for NdjsonDecoder<T> {
43	    fn default() -> NdjsonDecoder<T> {
44	        NdjsonDecoder { buf: Vec::new(), _records: PhantomData }
45	    }
46	}
47
48	impl<T: DeserializeOwned> NdjsonDecoder<T> {
49	    /// A fresh decoder (empty buffer).
50	    pub fn new() -> NdjsonDecoder<T> {
51	        NdjsonDecoder::default()
52	    }
53
54	    /// Push one received chunk and return every record completed by it, in wire
55	    /// order. Bytes after the last `\n` are retained for the next push.
56	    // [impl->REQ-INST-7]
57	    pub fn push(&mut self, chunk: &[u8]) -> Vec<T> {
58	        self.buf.extend_from_slice(chunk);
59	        let mut out = Vec::new();
60	        // Split off complete lines; keep the tail.
61	        while let Some(pos) = self.buf.iter().position(|b| *b == b'\n') {
62	            let rest = self.buf.split_off(pos + 1);
63	            let line = std::mem::replace(&mut self.buf, rest);
64	            if let Ok(rec) = serde_json::from_slice::<T>(&line[..pos]) {
65	                out.push(rec);
66	            }
67	            // else: skipped — a corrupt record must not wedge the feed.
68	        }
69	        out
70	    }
71	}
72

//! NDJSON line framing shared by the wire feeds (registry replication M4-D4d,
//! WAN messages M4-D5a).
//!
//! One JSON object per `\n`-terminated line. QUIC stream reads do **not**
//! preserve write boundaries (the read pump pulls up-to-64KiB chunks — writes
//! coalesce and split arbitrarily), so records need framing independent of
//! chunk shape; a newline delimiter on a JSON document (whose interior newlines
//! are always escaped) is the simplest framing that needs no length
//! bookkeeping. Decoding tolerates unknown fields (a newer node may add fields
//! — same posture as the IPC catalog) and skips undecodable lines (degrade,
//! don't die — the 4.3 stale-clean philosophy: one corrupt record must not
//! wedge the feed).
//!
//! Extracted from `replicate.rs` (where D4d proved the framing for
//! [`RegistryUpdate`](crate::net::replicate::RegistryUpdate)) when D5a's
//! [`WanMessage`](crate::net::wanmsg::WanMessage) became the second record
//! type riding it. The decode rules are identical for every record type; per-
//! record semantics (epoch lease, op-id dedup) stay with the record's module.

use std::marker::PhantomData;

use serde::de::DeserializeOwned;
use serde::Serialize;

/// Encode one record as one NDJSON line (the JSON document + `\n`).
pub fn encode_line<T: Serialize>(record: &T) -> Vec<u8> {
    let mut line = serde_json::to_vec(record).expect("wire record serializes");
    line.push(b'\n');
    line
}

/// Reassembles NDJSON records from arbitrarily-chunked stream bytes: push each
/// received chunk, take the complete records. A partial trailing line stays
/// buffered until its `\n` arrives; an undecodable line is skipped (see module
/// docs). One decoder per stream — records never span streams.
#[derive(Debug)]
pub struct NdjsonDecoder<T> {
    buf: Vec<u8>,
    _records: PhantomData<fn() -> T>,
}

impl<T> Default for NdjsonDecoder<T> {
    fn default() -> NdjsonDecoder<T> {
        NdjsonDecoder { buf: Vec::new(), _records: PhantomData }
    }
}

impl<T: DeserializeOwned> NdjsonDecoder<T> {
    /// A fresh decoder (empty buffer).
    pub fn new() -> NdjsonDecoder<T> {
        NdjsonDecoder::default()
    }

    /// Push one received chunk and return every record completed by it, in wire
    /// order. Bytes after the last `\n` are retained for the next push.
    // [impl->REQ-INST-7]
    pub fn push(&mut self, chunk: &[u8]) -> Vec<T> {
        self.buf.extend_from_slice(chunk);
        let mut out = Vec::new();
        // Split off complete lines; keep the tail.
        while let Some(pos) = self.buf.iter().position(|b| *b == b'\n') {
            let rest = self.buf.split_off(pos + 1);
            let line = std::mem::replace(&mut self.buf, rest);
            if let Ok(rec) = serde_json::from_slice::<T>(&line[..pos]) {
                out.push(rec);
            }
            // else: skipped — a corrupt record must not wedge the feed.
        }
        out
    }
}


Write

//! Notif replication over the wire (M4-D8-2, ADR-0007) — transport → join,
//! nothing more.
//!
//! D8-1 built the semilattice store ([`spt_store::notif::NotifStore`]) whose
//! [`merge_row`](spt_store::notif::NotifStore::merge_row) is idempotent and
//! commutative by construction. This module is the thin seam between that
//! join and the broker-owned QUIC streams (D4a/b): one full-row record on the
//! shared NDJSON framing ([`crate::net::ndjson`]). The **D4d push-feed
//! posture, not the D6c pull**: a feed re-presents whole rows; the receiver
//! joins them — replay, loss, and reorder are all safe because the join
//! converges regardless (no epoch lease needed here: every replicated field
//! is monotone, the store's module docs own that argument).
//!
//! Records carry **no origin field** (KNOWN-HAZARDS 7.5,
//! REQ-HAZARD-WAN-ORIGIN-AUTH — the 7.5 mapping names D8 notifs as a
//! wire-inbound consumer): the apply gate's subject is the handshake-proven
//! origin from the broker's stream table. The gate itself (origin trusted in
//! the row's subnet ∧ this node a member — fail-closed) lives with the daemon
//! apply driver, which owns the canonical stores.
// [impl->REQ-NOTIF-1]

use serde::{Deserialize, Serialize};
use spt_store::notif::NotifRow;

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

/// One notif replication record. Tagged (`kind`) so the feed can grow record
/// kinds (expiry tombstones, digests) without a wire break — an unknown
/// future kind fails the line's decode and is skipped (degrade, don't die).
// [impl->REQ-NOTIF-1]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NotifRecord {
    /// The full semilattice row, as currently held by the sender. The receiver
    /// joins it ([`NotifStore::merge_row`](spt_store::notif::NotifStore::merge_row));
    /// re-presenting an already-joined row is a no-op by construction.
    Row { row: NotifRow },
}

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

/// The notif feed's NDJSON reassembler — the shared
/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
/// [`NotifRecord`]s. One decoder per stream.
// [impl->REQ-NOTIF-1]
pub type NotifDecoder = NdjsonDecoder<NotifRecord>;

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

    fn row(id: &str) -> NotifRow {
        NotifRow {
            notif_id: id.to_string(),
            subnet: "home".into(),
            kind: "agent".into(),
            from_id: "ling".into(),
            body: "build done".into(),
            created_ms: 1_000,
            dismissed: true,
            seen: BTreeSet::from(["doyle".to_string()]),
            last_surfaced_ms: 5_000,
        }
    }

    // [unit->REQ-NOTIF-1] a record round-trips through the NDJSON line
    // encoding with the full semilattice state intact, and an unknown future
    // record kind is skipped without wedging the feed (wire forward-compat).
    #[test]
    fn record_round_trips_and_unknown_kind_is_skipped() {
        let r = NotifRecord::Row { row: row("n1:5") };
        let line = r.encode_line();
        assert_eq!(line.last(), Some(&b'\n'));

        let mut wire = line.clone();
        wire.extend_from_slice(b"{\"kind\":\"tombstone_added_at_d9\",\"notif_id\":\"n1:5\"}\n");
        wire.extend_from_slice(&NotifRecord::Row { row: row("n2:1") }.encode_line());

        let mut dec = NotifDecoder::new();
        let got = dec.push(&wire);
        assert_eq!(
            got,
            vec![NotifRecord::Row { row: row("n1:5") }, NotifRecord::Row { row: row("n2:1") }],
            "known kinds decode in wire order; the unknown kind is skipped"
        );
    }

    // [unit->REQ-NOTIF-1] a record split mid-bytes across pushes reassembles
    // (QUIC chunking is not record framing), and a partial tail emits nothing
    // until its newline arrives.
    #[test]
    fn record_split_across_chunks_reassembles() {
        let r = NotifRecord::Row { row: row("n1:7") };
        let line = r.encode_line();
        let mut dec = NotifDecoder::new();
        assert!(dec.push(&line[..line.len() / 2]).is_empty(), "no newline, no record");
        assert_eq!(dec.push(&line[line.len() / 2..]), vec![r]);
    }

    // [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] records carry no origin field: a
    // forged `origin_node`/`node` inside the JSON decodes as an ignored
    // unknown field — nothing in a decoded record can impersonate a node.
    // The apply gate's subject comes from the QUIC handshake (the broker's
    // stream table), asserted end-to-end in the daemon's notifsync tests.
    #[test]
    fn forged_origin_field_is_inert() {
        let r = NotifRecord::Row { row: row("n1:5") };
        let mut json = serde_json::to_value(&r).unwrap();
        json.as_object_mut().unwrap().insert("origin_node".into(), "evil".into());
        json.as_object_mut().unwrap().insert("node".into(), "evil".into());
        let decoded: NotifRecord = serde_json::from_value(json).unwrap();
        assert_eq!(decoded, r, "forged fields are inert unknowns");
    }
}


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

//! Notif replication over the wire (M4-D8-2, ADR-0007) — transport → join,
//! nothing more.
//!
//! D8-1 built the semilattice store ([`spt_store::notif::NotifStore`]) whose
//! [`merge_row`](spt_store::notif::NotifStore::merge_row) is idempotent and
//! commutative by construction. This module is the thin seam between that
//! join and the broker-owned QUIC streams (D4a/b): one full-row record on the
//! shared NDJSON framing ([`crate::net::ndjson`]). The **D4d push-feed
//! posture, not the D6c pull**: a feed re-presents whole rows; the receiver
//! joins them — replay, loss, and reorder are all safe because the join
//! converges regardless (no epoch lease needed here: every replicated field
//! is monotone, the store's module docs own that argument).
//!
//! Records carry **no origin field** (KNOWN-HAZARDS 7.5,
//! REQ-HAZARD-WAN-ORIGIN-AUTH — the 7.5 mapping names D8 notifs as a
//! wire-inbound consumer): the apply gate's subject is the handshake-proven
//! origin from the broker's stream table. The gate itself (origin trusted in
//! the row's subnet ∧ this node a member — fail-closed) lives with the daemon
//! apply driver, which owns the canonical stores.
// [impl->REQ-NOTIF-1]

use serde::{Deserialize, Serialize};
use spt_store::notif::NotifRow;

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

/// One notif replication record. Tagged (`kind`) so the feed can grow record
/// kinds (expiry tombstones, digests) without a wire break — an unknown
/// future kind fails the line's decode and is skipped (degrade, don't die).
// [impl->REQ-NOTIF-1]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NotifRecord {
    /// The full semilattice row, as currently held by the sender. The receiver
    /// joins it ([`NotifStore::merge_row`](spt_store::notif::NotifStore::merge_row));
    /// re-presenting an already-joined row is a no-op by construction.
    Row { row: NotifRow },
}

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

/// The notif feed's NDJSON reassembler — the shared
/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
/// [`NotifRecord`]s. One decoder per stream.
// [impl->REQ-NOTIF-1]
pub type NotifDecoder = NdjsonDecoder<NotifRecord>;

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

    fn row(id: &str) -> NotifRow {
        NotifRow {
            notif_id: id.to_string(),
            subnet: "home".into(),
            kind: "agent".into(),
            from_id: "ling".into(),
            body: "build done".into(),
            created_ms: 1_000,
            dismissed: true,
            seen: BTreeSet::from(["doyle".to_string()]),
            last_surfaced_ms: 5_000,
        }
    }

    // [unit->REQ-NOTIF-1] a record round-trips through the NDJSON line
    // encoding with the full semilattice state intact, and an unknown future
    // record kind is skipped without wedging the feed (wire forward-compat).
    #[test]
    fn record_round_trips_and_unknown_kind_is_skipped() {
        let r = NotifRecord::Row { row: row("n1:5") };
        let line = r.encode_line();
        assert_eq!(line.last(), Some(&b'\n'));

        let mut wire = line.clone();
        wire.extend_from_slice(b"{\"kind\":\"tombstone_added_at_d9\",\"notif_id\":\"n1:5\"}\n");
        wire.extend_from_slice(&NotifRecord::Row { row: row("n2:1") }.encode_line());

        let mut dec = NotifDecoder::new();
        let got = dec.push(&wire);
        assert_eq!(
            got,
            vec![NotifRecord::Row { row: row("n1:5") }, NotifRecord::Row { row: row("n2:1") }],
            "known kinds decode in wire order; the unknown kind is skipped"
        );
    }

    // [unit->REQ-NOTIF-1] a record split mid-bytes across pushes reassembles
    // (QUIC chunking is not record framing), and a partial tail emits nothing
    // until its newline arrives.
    #[test]
    fn record_split_across_chunks_reassembles() {
        let r = NotifRecord::Row { row: row("n1:7") };
        let line = r.encode_line();
        let mut dec = NotifDecoder::new();
        assert!(dec.push(&line[..line.len() / 2]).is_empty(), "no newline, no record");
        assert_eq!(dec.push(&line[line.len() / 2..]), vec![r]);
    }

    // [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] records carry no origin field: a
    // forged `origin_node`/`node` inside the JSON decodes as an ignored
    // unknown field — nothing in a decoded record can impersonate a node.
    // The apply gate's subject comes from the QUIC handshake (the broker's
    // stream table), asserted end-to-end in the daemon's notifsync tests.
    #[test]
    fn forged_origin_field_is_inert() {
        let r = NotifRecord::Row { row: row("n1:5") };
        let mut json = serde_json::to_value(&r).unwrap();
        json.as_object_mut().unwrap().insert("origin_node".into(), "evil".into());
        json.as_object_mut().unwrap().insert("node".into(), "evil".into());
        let decoded: NotifRecord = serde_json::from_value(json).unwrap();
        assert_eq!(decoded, r, "forged fields are inert unknowns");
    }
}


8:pub mod attach;
9:pub mod endpoint;
10:pub mod ndjson;
11:pub mod pairing;
12:pub mod registry;
13:pub mod replicate;
14:pub mod sync;
15:pub mod update;
16:pub mod wanmsg;
17:pub mod xfer;


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

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

pub mod attach;
pub mod endpoint;
pub mod ndjson;
pub mod pairing;
pub mod registry;
pub mod replicate;
pub mod sync;
pub mod update;
pub mod wanmsg;
pub mod xfer;


Edit

pub mod attach;
pub mod endpoint;
pub mod ndjson;
pub mod pairing;

pub mod attach;
pub mod endpoint;
pub mod ndjson;
pub mod notif;
pub mod pairing;


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

pub mod attach;
pub mod endpoint;
pub mod ndjson;
pub mod notif;
pub mod pairing;

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

pub mod attach;
pub mod endpoint;
pub mod ndjson;
pub mod pairing;
pub mod registry;
pub mod replicate;
pub mod sync;
pub mod update;
pub mod wanmsg;
pub mod xfer;

pub mod attach;
pub mod endpoint;
pub mod ndjson;
+pub mod notif;
pub mod pairing;
pub mod registry;
pub mod replicate;


Bash

cargo test -p spt-net notif 2>&1 | tail -10

Run spt-net notif record tests


    Finished `test` profile [unoptimized + debuginfo] target(s) in 11.57s
     Running unittests src\lib.rs (target\debug\deps\spt_net-1ddad5aaa031e7e0.exe)

running 3 tests
test net::notif::tests::forged_origin_field_is_inert ... ok
test net::notif::tests::record_split_across_chunks_reassembles ... ok
test net::notif::tests::record_round_trips_and_unknown_kind_is_skipped ... ok

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


Wire record green. Daemon drivers next — check subnet store surface:


68:pub struct SubnetRecord {
95:    pub fn seed_bytes(&self) -> Option<[u8; TOTP_SEED_LEN]> {
102:pub struct SubnetStore {
111:    pub fn load() -> SubnetStore {
118:    pub fn load_from(path: &Path) -> SubnetStore {
126:    pub fn save(&self) -> Result<(), SubnetError> {
133:    pub fn save_to(&self, path: &Path) -> Result<(), SubnetError> {
143:    pub fn find(&self, name: &str) -> Option<&SubnetRecord> {
155:    pub fn create_subnet(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
174:    pub fn add_joined(
201:    pub fn rotate_seed(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
217:    pub fn set_hide_new_endpoints(&mut self, name: &str, hide: bool) -> Result<(), SubnetError> {
230:    pub fn remove(&mut self, name: &str) -> bool {


76	impl SyncPolicy {
77	    /// Load from the node's canonical stores (`subnet.json`, `trust/peers.json`,
78	    /// `visibility.json`). Re-read per pull — config edits take effect on the
79	    /// next request without a daemon restart (the access-gate stance).
80	    pub fn load() -> SyncPolicy {
81	        let subnets = spt_store::subnet::SubnetStore::load()
82	            .subnets
83	            .iter()
84	            .map(|s| (s.name.clone(), s.hide_new_endpoints))
85	            .collect();
86	        SyncPolicy { subnets, trust: TrustStore::load(), vis: VisibilityStore::load() }
87	    }
88
89	    /// Whether `branch` may sync with `origin_node` (handshake-proven). The
90	    /// fail-closed decision table in the module docs. Used on **both** sides:
91	    /// the responder's serve gate, and the requester's ref selection (don't
92	    /// ask for what the peer shouldn't get — symmetric scoping).
93	    // [impl->REQ-INST-5]
94	    // [impl->REQ-INST-13]
95	    pub fn allows(&self, branch: &str, origin_node: &str) -> bool {
96	        if let Some(id) = branch.strip_prefix("a-") {
97	            self.subnets.iter().any(|(s, hide)| {
98	                self.trust.is_trusted(s, origin_node) && self.vis.synced(id, s, *hide)
99	            })
100	        } else if branch.strip_prefix("p-").is_some() {
101	            self.subnets.iter().any(|(s, _)| self.trust.is_trusted(s, origin_node))
102	        } else {
103	            false // machinery refs are never sync-served
104	        }
105	    }
106	}
107
108	/// Why a responder-side serve loop returned.
109	#[derive(Debug, Clone, PartialEq, Eq)]
110	pub enum SyncServeOutcome {
111	    /// A bundle was served (`bytes` on the wire, carrying `refs`); `denied`
112	    /// lists requested refs the gate refused.
113	    Sent { bytes: u64, refs: Vec<String>, denied: Vec<String> },
114	    /// Every allowed ref was already up to date — nothing sent.
115	    UpToDate { denied: Vec<String> },
116	    /// The serve failed; an `Err` record was sent to the peer.
117	    Failed(String),
118	    /// The requester closed its send side before a request completed.
119	    Detached,
120	}
121
122	/// What one pull applied, branch by branch.
123	#[derive(Debug, Default)]
124	pub struct SyncPullReport {
125	    /// Per-branch apply verdicts (accept/drop/conflict per file).
126	    pub applied: Vec<ApplyReport>,
127	    /// Refs the responder's gate refused (fail-closed on its side).
128	    pub denied: Vec<String>,
129	}
130
131	impl SyncPullReport {
132	    /// Every `(branch, file)` surfaced as a concurrent conflict by this pull —
133	    /// the reconcile wiring's input.
134	    pub fn conflicts(&self) -> Vec<(String, String)> {
135	        self.applied

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

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

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

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

impl SyncPullReport {
    /// Every `(branch, file)` surfaced as a concurrent conflict by this pull —
    /// the reconcile wiring's input.
    pub fn conflicts(&self) -> Vec<(String, String)> {
        self.applied