The file C:/Users/decid/Documents/projects/spt-core/traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

id = "REQ-MESH-2"
title = "Member roster: node-level union-merge grow-set (per member: pubkey, label, machine_id, last-known address, last-seen — NOT the seed), the discovery directory the mesh dials by. Seeded IN FULL at pairing (seed-holder hands joiner the whole current roster, incl. offline members — folds in deferred pairing-time hostname capture + post-join address seeding); each node authors its own entry stamped with its lease_epoch, merged strictly-greater-wins (the node_label lease); exchanged only over seed-proof'd member connections; forgery-inert (a fake entry names a pubkey that still can't seed-proof). Removal needs a TOMBSTONE — a per-pubkey revoked marker that propagates, dominates the entry, gates admission (seed-proof ∧ ¬tombstoned), and prevents reinsert; cleared by a completed re-pair of that pubkey. Persists through silence (offline member keeps its entry)."
required_stages = []  # activate at mesh execution. impl = roster store (union-merge + lease, atomic, corrupt-degrades-empty) + pairing-seed full-roster transfer (Frame::Seed gains the roster) + on-connect roster exchange/merge + tombstone propagation + admission suppression. unit = merge lease (strictly-greater self-entry) + union convergence + tombstone dominates/suppresses-reinsert/clears-on-repair + persist-through-silence + serde additive.

id = "REQ-MESH-2"
title = "Member roster: node-level union-merge grow-set (per member: pubkey, label, machine_id, last-known address, last-seen — NOT the seed), the discovery directory the mesh dials by. Seeded IN FULL at pairing (seed-holder hands joiner the whole current roster, incl. offline members — folds in deferred pairing-time hostname capture + post-join address seeding); each node authors its own entry stamped with its lease_epoch, merged strictly-greater-wins (the node_label lease); exchanged only over seed-proof'd member connections; forgery-inert (a fake entry names a pubkey that still can't seed-proof). Removal needs a TOMBSTONE — a per-pubkey revoked marker that propagates, dominates the entry, gates admission (seed-proof ∧ ¬tombstoned), and prevents reinsert; cleared by a completed re-pair of that pubkey. Persists through silence (offline member keeps its entry)."
required_stages = ["impl", "unit"]  # Mesh-D3 activates: impl = roster store (RosterStore union-merge + strictly-greater lease, tombstone dominate/suppress-reinsert/clear-on-repair, atomic, corrupt-degrades-empty) + pairing-seed full-roster transfer (Frame::Seed gains the roster, additive) + ceremony adopt-on-join wiring (pairhost both legs). unit = merge lease (strictly-greater self-entry) + union convergence (commutative/idempotent) + tombstone truth table + persist-through-silence + serde additive + extended-Seed codec round-trip + loopback ceremony roster transfer. D4 enriches impl (on-connect exchange/merge over member conns, peer-addrs reconcile); int lands at D4/D6.

-required_stages = []  # activate at mesh execution. impl = roster store (union-merge + lease, atomic, corrupt-degrades-empty) + pairing-seed full-roster transfer (Frame::Seed gains the roster) + on-connect roster exchange/merge + tombstone propagation + admission suppression. unit = merge lease (strictly-greater self-entry) + union convergence + tombstone dominates/suppresses-reinsert/clears-on-repair + persist-through-silence + serde additive.

+required_stages = ["impl", "unit"]  # Mesh-D3 activates: impl = roster store (RosterStore union-merge + strictly-greater lease, tombstone dominate/suppress-reinsert/clear-on-repair, atomic, corrupt-degrades-empty) + pairing-seed full-roster transfer (Frame::Seed gains the roster, additive) + ceremony adopt-on-join wiring (pairhost both legs). unit = merge lease (strictly-greater self-entry) + union convergence (commutative/idempotent) + tombstone truth table + persist-through-silence + serde additive + extended-Seed codec round-trip + loopback ceremony roster transfer. D4 enriches impl (on-connect exchange/merge over member conns, peer-addrs reconcile); int lands at D4/D6.

title = "Mesh row fan-out: registry rows stay OWN-AUTHORED; the only change is the push target widens from directly-paired peers to ALL roster members (a wider DIRECT fan-out, never a third-party relay). Every row/message still arrives from its author over a handshake → KNOWN-HAZARDS 7.5 (origin = handshake node) and 4.10 (eviction lease: any future update comes from that node itself, alive) PRESERVED VERBATIM. Closes the staggered A→B→C repro: C (roster-seeded with A at pairing) initiates to A, seed-proof admits C unpaired, A learns C, both push directly."


Now read the perch path helpers + atomic to mirror exactly, then write `roster.rs`.


96
97	/// The subnet membership + seed file: `<spt_home>/identity/subnet.json`
98	/// (`docs/STORAGE.md`). One file holding *all* subnets the node belongs to
99	/// (multi-subnet — a node holds N seeds), in a `(subnet, user)`-ready shape.
100	/// Replicated via pairing/P2P, **not** git.
101	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
102	pub fn subnet_file() -> PathBuf {
103	    identity_dir().join("subnet.json")
104	}
105
106	/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single
107	/// strictly-increasing `u64` serving both the registry lease (#8) and sync precedence
108	/// (#7) — never wall-clock. Node-local, never synced (each node owns its own counter).
109	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
110	pub fn epoch_file() -> PathBuf {
111	    identity_dir().join("epoch")
112	}
113
114	/// The per-`(endpoint, subnet)` visibility + sync-membership config file:
115	/// `<spt_home>/identity/visibility.json` (ADR-0006 §5/§6, D3e). The owning node's
116	/// advertisement policy — node-local, never git-tracked/synced (other nodes see only
117	/// its *effect*: presence/absence in the subnet registry).
118	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
119	pub fn visibility_file() -> PathBuf {
120	    identity_dir().join("visibility.json")
121	}
122
123	/// The subnet notification spool: `<spt_home>/identity/notifs.db` (ADR-0007, D8).
124	/// Node-level (a notif targets the *user in a subnet*, not a perch), subnet-tagged
125	/// rows; the *content* replicates across each subnet's nodes via the P2P feed

/// The subnet membership + seed file: `<spt_home>/identity/subnet.json`
/// (`docs/STORAGE.md`). One file holding *all* subnets the node belongs to
/// (multi-subnet — a node holds N seeds), in a `(subnet, user)`-ready shape.
/// Replicated via pairing/P2P, **not** git.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn subnet_file() -> PathBuf {
    identity_dir().join("subnet.json")
}

/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single
/// strictly-increasing `u64` serving both the registry lease (#8) and sync precedence
/// (#7) — never wall-clock. Node-local, never synced (each node owns its own counter).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn epoch_file() -> PathBuf {
    identity_dir().join("epoch")
}

/// The per-`(endpoint, subnet)` visibility + sync-membership config file:
/// `<spt_home>/identity/visibility.json` (ADR-0006 §5/§6, D3e). The owning node's
/// advertisement policy — node-local, never git-tracked/synced (other nodes see only
/// its *effect*: presence/absence in the subnet registry).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn visibility_file() -> PathBuf {
    identity_dir().join("visibility.json")
}

/// The subnet notification spool: `<spt_home>/identity/notifs.db` (ADR-0007, D8).
/// Node-level (a notif targets the *user in a subnet*, not a perch), subnet-tagged
/// rows; the *content* replicates across each subnet's nodes via the P2P feed


Add `roster_file()` path helper next to the others.


Edit

/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single

/// The member-roster file: `<spt_home>/identity/roster.json` (REQ-MESH-2,
/// ADR-0017). The subnet discovery directory — a node-level union-merge
/// grow-set of `(pubkey, label, machine_id, address, last-seen)` per member,
/// with per-pubkey tombstones. Node-local, replicated via the
/// pairing/seed-proof'd P2P channel, **never** git-tracked/synced (same class
/// as `subnet.json`/`peers.json`). NOT the seed.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE] [impl->REQ-MESH-2]
pub fn roster_file() -> PathBuf {
    identity_dir().join("roster.json")
}

/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single


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

/// The member-roster file: `<spt_home>/identity/roster.json` (REQ-MESH-2,
/// ADR-0017). The subnet discovery directory — a node-level union-merge
/// grow-set of `(pubkey, label, machine_id, address, last-seen)` per member,
/// with per-pubkey tombstones. Node-local, replicated via the
/// pairing/seed-proof'd P2P channel, **never** git-tracked/synced (same class
/// as `subnet.json`/`peers.json`). NOT the seed.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE] [impl->REQ-MESH-2]
pub fn roster_file() -> PathBuf {
    identity_dir().join("roster.json")
}

/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single

+/// The member-roster file: `<spt_home>/identity/roster.json` (REQ-MESH-2,

+/// ADR-0017). The subnet discovery directory — a node-level union-merge

+/// grow-set of `(pubkey, label, machine_id, address, last-seen)` per member,

+/// with per-pubkey tombstones. Node-local, replicated via the

+/// pairing/seed-proof'd P2P channel, **never** git-tracked/synced (same class

+/// as `subnet.json`/`peers.json`). NOT the seed.

/// strictly-increasing `u64` serving both the registry lease (#8) and sync precedence

/// (#7) — never wall-clock. Node-local, never synced (each node owns its own counter).


Now write the roster store. This is the bulk — pure CRDT + tombstones + full unit suite.


Write

//! `roster.json` — the member roster: a subnet's discovery directory
//! (REQ-MESH-2, ADR-0017, Mesh-D3).
//!
//! A subnet is a **mesh, not a pairing graph** — every member must be able to
//! see and reach every other member, including ones it never directly paired
//! with. The roster is how: a **node-level union-merge grow-set** where each
//! node authors its own [`RosterEntry`] (its pubkey, label, machine_id,
//! last-known address, last-seen) stamped with its
//! [`lease_epoch`](RosterEntry::lease_epoch) — the same strictly-greater-wins
//! lease the registry runs for node labels (`spt_net::net::registry`,
//! `MergeOutcome`). It is **NOT** the seed (the seed never leaves a node except
//! over the pairing/seed-proof channel — ADR-0017); a roster entry is
//! **forgery-inert** because naming a pubkey you can't seed-proof gets you
//! nowhere at the membership gate (D2/D5).
//!
//! Seeded **in full at pairing** — the seed-holder hands the joiner its whole
//! current roster (incl. offline members), folding in the deferred pairing-time
//! hostname capture + post-join address seeding. Thereafter it merges over
//! seed-proof'd member connections (D4). Node-local, durable, replicated only
//! over the pairing/P2P channel, **never git-tracked or context-synced** —
//! alongside [`subnet.json`](crate::subnet) and [`peers.json`](crate::trust)
//! under [`identity/`](crate::perch::identity_dir).
//!
//! ## Removal needs a tombstone
//!
//! A grow-set cannot delete by omission: drop a member and the next merge
//! re-learns it. Removal is a [`Tombstone`] — a per-pubkey revoked marker that
//! **propagates** (it is itself a grow-set element), **dominates** the entry
//! ([`is_member`](RosterStore::is_member) returns false while it stands),
//! **suppresses reinsert** (a later entry-merge re-records the facts but the
//! member stays hidden), and is **cleared by a completed re-pair** of that
//! pubkey ([`clear_tombstone`](RosterStore::clear_tombstone)).
//!
//! ### Tombstone-convergence boundary (deliberate D3 scope)
//!
//! Cross-node convergence of an *un*-tombstone is **not** D3's concern.
//! [`clear_tombstone`] is a *local* elevated act (the re-pair). The compromised
//! path re-pairs under a **fresh, never-tombstoned key** (the milestone's
//! decision 10), so the old tombstone correctly stands forever; the benign
//! N-1 re-seed + timeboxed rotation propagation is **Mesh-D7**. D3 tests
//! clear-on-repair locally only.
//
// [impl->REQ-MESH-2]

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

use crate::atomic::atomic_write_string;
use crate::perch::roster_file;

/// Failures persisting the roster.
#[derive(Debug, PartialEq, Eq)]
pub enum RosterError {
    /// Reading/writing the backing file failed (message preserved).
    Io(String),
}

impl std::fmt::Display for RosterError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RosterError::Io(e) => write!(f, "roster store io: {e}"),
        }
    }
}

impl std::error::Error for RosterError {}

/// The lease decision when merging an entry (mirrors the registry's
/// [`MergeOutcome`](spt_net::net::registry) naming — strictly-greater wins).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeOutcome {
    /// First entry for this `(subnet, pubkey)` — inserted.
    Inserted,
    /// A strictly-newer `lease_epoch` for an existing member — replaced.
    Updated,
    /// Equal/older `lease_epoch` (or a tombstoned-but-still-merged fact) — the
    /// incoming entry did not advance the lease; nothing changed.
    Stale,
}

/// One member of a subnet's roster. Authored by the member itself; merged into
/// every other member's roster strictly-greater-`lease_epoch`-wins.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RosterEntry {
    /// The member's Ed25519 node pubkey, lowercase hex (the iroh NodeId). The
    /// merge key within a subnet, and what the mesh dials/seed-proofs.
    pub pubkey_hex: String,
    /// The subnet this entry belongs to (a node holds N subnets' rosters in one
    /// file, the multi-subnet shape `subnet.json` uses).
    pub subnet: String,
    /// The member's advertised node label (its OS hostname).
    pub label: String,
    /// The member's hashed stable machine id (REQ-SUBNET-7 anchor — the re-pair
    /// overwrite/tombstone-clear key; never the raw id).
    pub machine_id: String,
    /// Last-known dialable address — an opaque serde-serialized iroh
    /// `EndpointAddr` (this crate never grows an iroh edge; same posture as
    /// [`PeerAddrStore`](crate::peeraddrs)). `None` until one is observed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub address: Option<serde_json::Value>,
    /// Last-seen stamp (opaque epoch-seconds string).
    pub last_seen: String,
    /// The author's `node_label` lease — strictly-greater-wins. Equal/lower on
    /// merge ⇒ [`Stale`](MergeOutcome::Stale) (the registry lease, reused).
    pub lease_epoch: u64,
}

/// A per-pubkey revoked marker. Removal in a grow-set can only be additive —
/// the tombstone is the addition that means "gone."
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tombstone {
    /// The revoked member's pubkey (lowercase hex).
    pub pubkey_hex: String,
    /// The subnet the revocation applies within.
    pub subnet: String,
    /// When it was written (opaque epoch-seconds string).
    pub stamp: String,
}

/// The whole `roster.json` document: every subnet's members + tombstones this
/// node knows. A union grow-set — merges are convergent, commutative, idempotent.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RosterStore {
    /// Known members across all subnets (keyed `(subnet, pubkey_hex)`).
    #[serde(default)]
    pub members: Vec<RosterEntry>,
    /// Revocation tombstones across all subnets (keyed `(subnet, pubkey_hex)`).
    #[serde(default)]
    pub tombstones: Vec<Tombstone>,
}

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

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

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

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

    /// Find a member entry by `(subnet, pubkey)`.
    pub fn find(&self, subnet: &str, pubkey_hex: &str) -> Option<&RosterEntry> {
        self.members
            .iter()
            .find(|m| m.subnet == subnet && m.pubkey_hex == pubkey_hex)
    }

    /// Whether `pubkey_hex` carries a tombstone in `subnet`.
    pub fn is_tombstoned(&self, subnet: &str, pubkey_hex: &str) -> bool {
        self.tombstones
            .iter()
            .any(|t| t.subnet == subnet && t.pubkey_hex == pubkey_hex)
    }

    /// The admission predicate **D5's gate consumes**: a present entry that is
    /// not tombstoned. Membership = roster-listed ∧ ¬revoked (the seed-proof ∧
    /// ¬tombstoned rule's roster half).
    // [impl->REQ-MESH-2]
    pub fn is_member(&self, subnet: &str, pubkey_hex: &str) -> bool {
        self.find(subnet, pubkey_hex).is_some() && !self.is_tombstoned(subnet, pubkey_hex)
    }

    /// The **visible** members of a subnet (entry present ∧ ¬tombstoned) — what
    /// the mesh dials by.
    pub fn members_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a RosterEntry> {
        self.members
            .iter()
            .filter(move |m| m.subnet == subnet && !self.is_tombstoned(subnet, &m.pubkey_hex))
    }

    /// Author/refresh **this node's own** entry. A node is authoritative for its
    /// own pubkey, so this overwrites unconditionally (the caller passes its
    /// current `lease_epoch`); lease ordering only governs how *others* merge it.
    // [impl->REQ-MESH-2]
    #[allow(clippy::too_many_arguments)]
    pub fn upsert_self(
        &mut self,
        subnet: &str,
        pubkey_hex: &str,
        label: &str,
        machine_id: &str,
        address: Option<serde_json::Value>,
        last_seen: &str,
        lease_epoch: u64,
    ) {
        let entry = RosterEntry {
            pubkey_hex: pubkey_hex.to_string(),
            subnet: subnet.to_string(),
            label: label.to_string(),
            machine_id: machine_id.to_string(),
            address,
            last_seen: last_seen.to_string(),
            lease_epoch,
        };
        match self
            .members
            .iter_mut()
            .find(|m| m.subnet == subnet && m.pubkey_hex == pubkey_hex)
        {
            Some(slot) => *slot = entry,
            None => self.members.push(entry),
        }
    }

    /// Merge one entry authored by **another** node: strictly-greater
    /// `lease_epoch` wins. A new `(subnet, pubkey)` is [`Inserted`]; a
    /// strictly-newer lease is [`Updated`]; equal/older is [`Stale`] (no
    /// clobber). A tombstone does **not** block recording the freshest facts
    /// (convergence) — it gates [`is_member`]/[`members_in`], not the merge.
    ///
    /// [`Inserted`]: MergeOutcome::Inserted
    /// [`Updated`]: MergeOutcome::Updated
    /// [`Stale`]: MergeOutcome::Stale
    // [impl->REQ-MESH-2]
    pub fn merge_entry(&mut self, incoming: RosterEntry) -> MergeOutcome {
        match self
            .members
            .iter_mut()
            .find(|m| m.subnet == incoming.subnet && m.pubkey_hex == incoming.pubkey_hex)
        {
            Some(existing) => {
                if incoming.lease_epoch > existing.lease_epoch {
                    *existing = incoming;
                    MergeOutcome::Updated
                } else {
                    MergeOutcome::Stale
                }
            }
            None => {
                self.members.push(incoming);
                MergeOutcome::Inserted
            }
        }
    }

    /// Record a tombstone (idempotent — re-tombstoning is a no-op). Dominates
    /// the member's entry and suppresses any later reinsert until cleared.
    // [impl->REQ-MESH-2]
    pub fn tombstone(&mut self, subnet: &str, pubkey_hex: &str, stamp: &str) {
        if !self.is_tombstoned(subnet, pubkey_hex) {
            self.tombstones.push(Tombstone {
                pubkey_hex: pubkey_hex.to_string(),
                subnet: subnet.to_string(),
                stamp: stamp.to_string(),
            });
        }
    }

    /// **Clear-on-repair**: drop the tombstone for `(subnet, pubkey)` — the
    /// deliberate elevated act a completed re-pair performs. Returns whether one
    /// was cleared. (A local op; see the module's convergence-boundary note.)
    // [impl->REQ-MESH-2]
    pub fn clear_tombstone(&mut self, subnet: &str, pubkey_hex: &str) -> bool {
        let before = self.tombstones.len();
        self.tombstones
            .retain(|t| !(t.subnet == subnet && t.pubkey_hex == pubkey_hex));
        self.tombstones.len() != before
    }

    /// Union-merge a whole other roster into this one: every entry through
    /// [`merge_entry`] (strictly-greater-wins), every tombstone unioned
    /// (dedup). The grow-set operation — commutative and idempotent.
    // [impl->REQ-MESH-2]
    pub fn merge(&mut self, other: &RosterStore) {
        for e in &other.members {
            self.merge_entry(e.clone());
        }
        for t in &other.tombstones {
            self.tombstone(&t.subnet, &t.pubkey_hex, &t.stamp);
        }
    }

    /// The slice handed to a joiner at pairing (or a peer on connect): every
    /// member entry **and** tombstone for one subnet (offline members included —
    /// persists-through-silence). The receiver merges it.
    // [impl->REQ-MESH-2]
    pub fn roster_for(&self, subnet: &str) -> (Vec<RosterEntry>, Vec<Tombstone>) {
        let members = self
            .members
            .iter()
            .filter(|m| m.subnet == subnet)
            .cloned()
            .collect();
        let tombstones = self
            .tombstones
            .iter()
            .filter(|t| t.subnet == subnet)
            .cloned()
            .collect();
        (members, tombstones)
    }
}

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

    fn entry(subnet: &str, pk: &str, lease: u64) -> RosterEntry {
        RosterEntry {
            pubkey_hex: pk.to_string(),
            subnet: subnet.to_string(),
            label: format!("host-{pk}"),
            machine_id: format!("mid-{pk}"),
            address: None,
            last_seen: "100".to_string(),
            lease_epoch: lease,
        }
    }

    // [unit->REQ-MESH-2] the lease: a strictly-greater lease_epoch updates an
    // existing member; an equal or lower one is Stale and does NOT clobber the
    // newer facts; a never-seen pubkey is Inserted. The node_label lease, reused.
    #[test]
    fn merge_entry_is_strictly_greater_lease_wins() {
        let mut r = RosterStore::default();
        assert_eq!(r.merge_entry(entry("home", "aa", 5)), MergeOutcome::Inserted);

        // Lower lease loses (Stale) — the stored lease stays at 5.
        assert_eq!(r.merge_entry(entry("home", "aa", 3)), MergeOutcome::Stale);
        assert_eq!(r.find("home", "aa").unwrap().lease_epoch, 5);

        // Equal lease is Stale too (strictly-greater, not >=).
        assert_eq!(r.merge_entry(entry("home", "aa", 5)), MergeOutcome::Stale);

        // Strictly greater wins.
        let mut newer = entry("home", "aa", 6);
        newer.label = "renamed".into();
        assert_eq!(r.merge_entry(newer), MergeOutcome::Updated);
        assert_eq!(r.find("home", "aa").unwrap().lease_epoch, 6);
        assert_eq!(r.find("home", "aa").unwrap().label, "renamed");
    }

    // [unit->REQ-MESH-2] union convergence: merge is commutative (A∪B == B∪A on
    // the winning-lease facts) and idempotent (merging twice == once). The
    // grow-set CRDT property the mesh relies on.
    #[test]
    fn merge_is_commutative_and_idempotent() {
        let mut a = RosterStore::default();
        a.merge_entry(entry("home", "aa", 2));
        a.merge_entry(entry("home", "bb", 1));

        let mut b = RosterStore::default();
        b.merge_entry(entry("home", "bb", 9)); // bb newer on b
        b.merge_entry(entry("home", "cc", 1));

        let mut ab = a.clone();
        ab.merge(&b);
        let mut ba = b.clone();
        ba.merge(&a);

        // Same membership set and same winning leases regardless of order.
        for pk in ["aa", "bb", "cc"] {
            assert_eq!(
                ab.find("home", pk).map(|e| e.lease_epoch),
                ba.find("home", pk).map(|e| e.lease_epoch),
                "{pk} converges"
            );
        }
        assert_eq!(ab.find("home", "bb").unwrap().lease_epoch, 9, "newer bb wins");

        // Idempotent: merging b again changes nothing.
        let snapshot: Vec<_> = ab.members.iter().map(|e| e.lease_epoch).sum::<u64>().into();
        ab.merge(&b);
        let after: Vec<_> = ab.members.iter().map(|e| e.lease_epoch).sum::<u64>().into();
        assert_eq!(snapshot, after, "re-merge is a no-op");
    }

    // [unit->REQ-MESH-2] persists-through-silence: an offline member's entry
    // survives a merge that never mentions it, AND a save/load round-trip. A
    // silent member is not forgotten.
    #[test]
    fn offline_member_persists_through_silence_and_reload() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("roster.json");

        let mut r = RosterStore::default();
        r.merge_entry(entry("home", "offline", 1));
        r.merge_entry(entry("home", "online", 1));

        // A merge from a peer that only knows `online` must NOT drop `offline`.
        let mut peer = RosterStore::default();
        peer.merge_entry(entry("home", "online", 2));
        r.merge(&peer);
        assert!(r.find("home", "offline").is_some(), "silent member kept");

        r.save_to(&path).unwrap();
        let reloaded = RosterStore::load_from(&path);
        assert!(reloaded.find("home", "offline").is_some(), "survives reload");
        assert_eq!(reloaded.find("home", "online").unwrap().lease_epoch, 2);
    }

    // [unit->REQ-MESH-2] the tombstone truth table: DOMINATES (a present entry
    // is no longer a member once tombstoned), SUPPRESSES REINSERT (a fresh-lease
    // entry-merge after the tombstone re-records facts but membership stays
    // false), and CLEARS-ON-REPAIR (clear_tombstone restores membership).
    #[test]
    fn tombstone_dominates_suppresses_reinsert_and_clears() {
        let mut r = RosterStore::default();
        r.merge_entry(entry("home", "aa", 1));
        assert!(r.is_member("home", "aa"), "listed member");

        // Dominates: tombstone hides it though the entry is still present.
        r.tombstone("home", "aa", "200");
        assert!(r.is_tombstoned("home", "aa"));
        assert!(!r.is_member("home", "aa"), "tombstone dominates the entry");
        assert!(r.find("home", "aa").is_some(), "entry still stored");
        assert_eq!(r.members_in("home").count(), 0, "hidden from the dial set");

        // Suppresses reinsert: a newer-lease merge updates facts but not membership.
        assert_eq!(r.merge_entry(entry("home", "aa", 9)), MergeOutcome::Updated);
        assert!(!r.is_member("home", "aa"), "reinsert stays suppressed");

        // Tombstone is idempotent (no duplicate marker).
        r.tombstone("home", "aa", "300");
        assert_eq!(r.tombstones.len(), 1);

        // Clears on re-pair: membership restored.
        assert!(r.clear_tombstone("home", "aa"));
        assert!(r.is_member("home", "aa"), "re-pair restores membership");
        assert!(!r.clear_tombstone("home", "aa"), "second clear is no-op false");
    }

    // [unit->REQ-MESH-2] tombstones are subnet-scoped and propagate through a
    // merge (they are grow-set elements, not local-only).
    #[test]
    fn tombstones_are_scoped_and_merge() {
        let mut r = RosterStore::default();
        r.merge_entry(entry("home", "aa", 1));
        r.merge_entry(entry("work", "aa", 1));

        let mut peer = RosterStore::default();
        peer.tombstone("home", "aa", "200");
        r.merge(&peer);

        assert!(!r.is_member("home", "aa"), "tombstone propagated via merge");
        assert!(r.is_member("work", "aa"), "other subnet's aa untouched");
    }

    // [unit->REQ-MESH-2] upsert_self is authoritative for the node's own entry:
    // it overwrites unconditionally (a node always holds the latest truth about
    // itself), unlike a peer-authored merge which is lease-gated.
    #[test]
    fn upsert_self_overwrites_unconditionally() {
        let mut r = RosterStore::default();
        r.upsert_self("home", "me", "laptop", "mid", None, "100", 5);
        // Even a "lower" lease overwrites — self-authoring isn't lease-gated.
        r.upsert_self("home", "me", "laptop-renamed", "mid", None, "150", 3);
        let me = r.find("home", "me").unwrap();
        assert_eq!(me.label, "laptop-renamed");
        assert_eq!(me.lease_epoch, 3);
        assert_eq!(r.members.len(), 1, "no duplicate self row");
    }

    // [unit->REQ-MESH-2] roster_for projects one subnet's full slice (members +
    // tombstones, offline included) — the bundle handed to a joiner at pairing.
    #[test]
    fn roster_for_projects_one_subnets_slice() {
        let mut r = RosterStore::default();
        r.merge_entry(entry("home", "aa", 1));
        r.merge_entry(entry("home", "bb", 1));
        r.tombstone("home", "bb", "200");
        r.merge_entry(entry("work", "cc", 1));

        let (members, tombs) = r.roster_for("home");
        assert_eq!(members.len(), 2, "both home entries (incl. tombstoned bb)");
        assert_eq!(tombs.len(), 1);
        assert!(members.iter().all(|m| m.subnet == "home"));
        assert!(r.roster_for("solo").0.is_empty(), "unknown subnet ⇒ empty");
    }

    // [unit->REQ-MESH-2] serde is additive + the store degrades gracefully: a
    // legacy roster.json (no `tombstones`, an entry with no `address`) loads, an
    // address round-trips, and an absent/corrupt file is the empty store.
    #[test]
    fn serde_additive_and_degrades_to_empty() {
        // Legacy doc: members only, no tombstones array, no address field.
        let legacy = r#"{"members":[{"pubkey_hex":"aa","subnet":"home","label":"h","machine_id":"m","last_seen":"1","lease_epoch":1}]}"#;
        let parsed: RosterStore = serde_json::from_str(legacy).unwrap();
        assert!(parsed.is_member("home", "aa"));
        assert!(parsed.tombstones.is_empty());
        assert!(parsed.find("home", "aa").unwrap().address.is_none());

        // Address round-trips as opaque JSON.
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("roster.json");
        let mut r = RosterStore::default();
        r.upsert_self(
            "home",
            "me",
            "h",
            "m",
            Some(serde_json::json!({"addr": "1.2.3.4:5"})),
            "1",
            1,
        );
        r.save_to(&path).unwrap();
        let back = RosterStore::load_from(&path);
        assert_eq!(
            back.find("home", "me").unwrap().address,
            Some(serde_json::json!({"addr": "1.2.3.4:5"}))
        );

        // Absent + corrupt ⇒ empty, never a panic.
        assert!(RosterStore::load_from(&dir.path().join("nope.json"))
            .members
            .is_empty());
        let corrupt = dir.path().join("corrupt.json");
        std::fs::write(&corrupt, b"{ not json").unwrap();
        assert!(RosterStore::load_from(&corrupt).members.is_empty());
    }
}


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

//! `roster.json` — the member roster: a subnet's discovery directory
//! (REQ-MESH-2, ADR-0017, Mesh-D3).
//!
//! A subnet is a **mesh, not a pairing graph** — every member must be able to
//! see and reach every other member, including ones it never directly paired
//! with. The roster is how: a **node-level union-merge grow-set** where each
//! node authors its own [`RosterEntry`] (its pubkey, label, machine_id,
//! last-known address, last-seen) stamped with its
//! [`lease_epoch`](RosterEntry::lease_epoch) — the same strictly-greater-wins
//! lease the registry runs for node labels (`spt_net::net::registry`,
//! `MergeOutcome`). It is **NOT** the seed (the seed never leaves a node except
//! over the pairing/seed-proof channel — ADR-0017); a roster entry is
//! **forgery-inert** because naming a pubkey you can't seed-proof gets you
//! nowhere at the membership gate (D2/D5).
//!
//! Seeded **in full at pairing** — the seed-holder hands the joiner its whole
//! current roster (incl. offline members), folding in the deferred pairing-time
//! hostname capture + post-join address seeding. Thereafter it merges over
//! seed-proof'd member connections (D4). Node-local, durable, replicated only
//! over the pairing/P2P channel, **never git-tracked or context-synced** —
//! alongside [`subnet.json`](crate::subnet) and [`peers.json`](crate::trust)
//! under [`identity/`](crate::perch::identity_dir).
//!
//! ## Removal needs a tombstone
//!
//! A grow-set cannot delete by omission: drop a member and the next merge
//! re-learns it. Removal is a [`Tombstone`] — a per-pubkey revoked marker that
//! **propagates** (it is itself a grow-set element), **dominates** the entry
//! ([`is_member`](RosterStore::is_member) returns false while it stands),
//! **suppresses reinsert** (a later entry-merge re-records the facts but the
//! member stays hidden), and is **cleared by a completed re-pair** of that
//! pubkey ([`clear_tombstone`](RosterStore::clear_tombstone)).
//!
//! ### Tombstone-convergence boundary (deliberate D3 scope)
//!
//! Cross-node convergence of an *un*-tombstone is **not** D3's concern.
//! [`clear_tombstone`] is a *local* elevated act (the re-pair). The compromised
//! path re-pairs under a **fresh, never-tombstoned key** (the milestone's
//! decision 10), so the old tombstone correctly stands forever; the benign
//! N-1 re-seed + timeboxed rotation propagation is **Mesh-D7**. D3 tests
//! clear-on-repair locally only.
//
// [impl->REQ-MESH-2]

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

use crate::atomic::atomic_write_string;
use crate::perch::roster_file;

/// Failures persisting the roster.
#[derive(Debug, PartialEq, Eq)]
pub enum RosterError {
    /// Reading/writing the backing file failed (message preserved).
    Io(String),
}

impl std::fmt::Display for RosterError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RosterError::Io(e) => write!(f, "roster store io: {e}"),
        }
    }
}

impl std::error::Error for RosterError {}

/// The lease decision when merging an entry (mirrors the registry's
/// [`MergeOutcome`](spt_net::net::registry) naming — strictly-greater wins).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeOutcome {
    /// First entry for this `(subnet, pubkey)` — inserted.
    Inserted,
    /// A strictly-newer `lease_epoch` for an existing member — replaced.
    Updated,
    /// Equal/older `lease_epoch` (or a tombstoned-but-still-merged fact) — the
    /// incoming entry did not advance the lease; nothing changed.
    Stale,
}

/// One member of a subnet's roster. Authored by the member itself; merged into
/// every other member's roster strictly-greater-`lease_epoch`-wins.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RosterEntry {
    /// The member's Ed25519 node pubkey, lowercase hex (the iroh NodeId). The
    /// merge key within a subnet, and what the mesh dials/seed-proofs.
    pub pubkey_hex: String,
    /// The subnet this entry belongs to (a node holds N subnets' rosters in one
    /// file, the multi-subnet shape `subnet.json` uses).
    pub subnet: String,
    /// The member's advertised node label (its OS hostname).
    pub label: String,
    /// The member's hashed stable machine id (REQ-SUBNET-7 anchor — the re-pair
    /// overwrite/tombstone-clear key; never the raw id).
    pub machine_id: String,
    /// Last-known dialable address — an opaque serde-serialized iroh
    /// `EndpointAddr` (this crate never grows an iroh edge; same posture as
    /// [`PeerAddrStore`](crate::peeraddrs)). `None` until one is observed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub address: Option<serde_json::Value>,
    /// Last-seen stamp (opaque epoch-seconds string).
    pub last_seen: String,
    /// The author's `node_label` lease — strictly-greater-wins. Equal/lower on
    /// merge ⇒ [`Stale`](MergeOutcome::Stale) (the registry lease, reused).
    pub lease_epoch: u64,
}

/// A per-pubkey revoked marker. Removal in a grow-set can only be additive —
/// the tombstone is the addition that means "gone."
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tombstone {
    /// The revoked member's pubkey (lowercase hex).
    pub pubkey_hex: String,
    /// The subnet the revocation applies within.
    pub subnet: String,
    /// When it was written (opaque epoch-seconds string).
    pub stamp: String,
}

/// The whole `roster.json` document: every subnet's members + tombstones this
/// node knows. A union grow-set — merges are convergent, commutative, idempotent.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RosterStore {
    /// Known members across all subnets (keyed `(subnet, pubkey_hex)`).
    #[serde(default)]
    pub members: Vec<RosterEntry>,
    /// Revocation tombstones across all subnets (keyed `(subnet, pubkey_hex)`).
    #[serde(default)]
    pub tombstones: Vec<Tombstone>,
}

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

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

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

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

    /// Find a member entry by `(subnet, pubkey)`.
    pub fn find(&self, subnet: &str, pubkey_hex: &str) -> Option<&RosterEntry> {
        self.members
            .iter()
            .find(|m| m.subnet == subnet && m.pubkey_hex == pubkey_hex)
    }

    /// Whether `pubkey_hex` carries a tombstone in `subnet`.
    pub fn is_tombstoned(&self, subnet: &str, pubkey_hex: &str) -> bool {
        self.tombstones
            .iter()
            .any(|t| t.subnet == subnet && t.pubkey_hex == pubkey_hex)
    }

    /// The admission predicate **D5's gate consumes**: a present entry that is
    /// not tombstoned. Membership = roster-listed ∧ ¬revoked (the seed-proof ∧
    /// ¬tombstoned rule's roster half).
    // [impl->REQ-MESH-2]
    pub fn is_member(&self, subnet: &str, pubkey_hex: &str) -> bool {
        self.find(subnet, pubkey_hex).is_some() && !self.is_tombstoned(subnet, pubkey_hex)
    }

    /// The **visible** members of a subnet (entry present ∧ ¬tombstoned) — what
    /// the mesh dials by.
    pub fn members_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a RosterEntry> {
        self.members
            .iter()
            .filter(move |m| m.subnet == subnet && !self.is_tombstoned(subnet, &m.pubkey_hex))
    }

    /// Author/refresh **this node's own** entry. A node is authoritative for its
    /// own pubkey, so this overwrites unconditionally (the caller passes its
    /// current `lease_epoch`); lease ordering only governs how *others* merge it.
    // [impl->REQ-MESH-2]
    #[allow(clippy::too_many_arguments)]
    pub fn upsert_self(
        &mut self,
        subnet: &str,
        pubkey_hex: &str,
        label: &str,
        machine_id: &str,
        address: Option<serde_json::Value>,
        last_seen: &str,
        lease_epoch: u64,
    ) {
        let entry = RosterEntry {
            pubkey_hex: pubkey_hex.to_string(),
            subnet: subnet.to_string(),
            label: label.to_string(),
            machine_id: machine_id.to_string(),
            address,
            last_seen: last_seen.to_string(),
            lease_epoch,
        };
        match self
            .members
            .iter_mut()
            .find(|m| m.subnet == subnet && m.pubkey_hex == pubkey_hex)
        {
            Some(slot) => *slot = entry,
            None => self.members.push(entry),
        }
    }

    /// Merge one entry authored by **another** node: strictly-greater
    /// `lease_epoch` wins. A new `(subnet, pubkey)` is [`Inserted`]; a
    /// strictly-newer lease is [`Updated`]; equal/older is [`Stale`] (no
    /// clobber). A tombstone does **not** block recording the freshest facts
    /// (convergence) — it gates [`is_member`]/[`members_in`], not the merge.
    ///
    /// [`Inserted`]: MergeOutcome::Inserted
    /// [`Updated`]: MergeOutcome::Updated
    /// [`Stale`]: MergeOutcome::Stale
    // [impl->REQ-MESH-2]
    pub fn merge_entry(&mut self, incoming: RosterEntry) -> MergeOutcome {
        match self
            .members
            .iter_mut()
            .find(|m| m.subnet == incoming.subnet && m.pubkey_hex == incoming.pubkey_hex)
        {
            Some(existing) => {
                if incoming.lease_epoch > existing.lease_epoch {
                    *existing = incoming;
                    MergeOutcome::Updated
                } else {
                    MergeOutcome::Stale
                }
            }
            None => {
                self.members.push(incoming);
                MergeOutcome::Inserted
            }
        }
    }

    /// Record a tombstone (idempotent — re-tombstoning is a no-op). Dominates
    /// the member's entry and suppresses any later reinsert until cleared.
    // [impl->REQ-MESH-2]
    pub fn tombstone(&mut self, subnet: &str, pubkey_hex: &str, stamp: &str) {
        if !self.is_tombstoned(subnet, pubkey_hex) {
            self.tombstones.push(Tombstone {
                pubkey_hex: pubkey_hex.to_string(),
                subnet: subnet.to_string(),
                stamp: stamp.to_string(),
            });
        }
    }

    /// **Clear-on-repair**: drop the tombstone for `(subnet, pubkey)` — the
    /// deliberate elevated act a completed re-pair performs. Returns whether one
    /// was cleared. (A local op; see the module's convergence-boundary note.)
    // [impl->REQ-MESH-2]
    pub fn clear_tombstone(&mut self, subnet: &str, pubkey_hex: &str) -> bool {
        let before = self.tombstones.len();
        self.tombstones
            .retain(|t| !(t.subnet == subnet && t.pubkey_hex == pubkey_hex));
        self.tombstones.len() != before
    }

    /// Union-merge a whole other roster into this one: every entry through
    /// [`merge_entry`] (strictly-greater-wins), every tombstone unioned
    /// (dedup). The grow-set operation — commutative and idempotent.
    // [impl->REQ-MESH-2]
    pub fn merge(&mut self, other: &RosterStore) {
        for e in &other.members {
            self.merge_entry(e.clone());
        }
        for t in &other.tombstones {
            self.tombstone(&t.subnet, &t.pubkey_hex, &t.stamp);
        }
    }

    /// The slice handed to a joiner at pairing (or a peer on connect): every
    /// member entry **and** tombstone for one subnet (offline members included —
    /// persists-through-silence). The receiver merges it.
    // [impl->REQ-MESH-2]
    pub fn roster_for(&self, subnet: &str) -> (Vec<RosterEntry>, Vec<Tombstone>) {
        let members = self
            .members
            .iter()
            .filter(|m| m.subnet == subnet)
            .cloned()
            .collect();
        let tombstones = self
            .tombstones
            .iter()
            .filter(|t| t.subnet == subnet)
            .cloned()
            .collect();
        (members, tombstones)
    }
}

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

    fn entry(subnet: &str, pk: &str, lease: u64) -> RosterEntry {
        RosterEntry {
            pubkey_hex: pk.to_string(),
            subnet: subnet.to_string(),
            label: format!("host-{pk}"),
            machine_id: format!("mid-{pk}"),
            address: None,
            last_seen: "100".to_string(),
            lease_epoch: lease,
        }
    }

    // [unit->REQ-MESH-2] the lease: a strictly-greater lease_epoch updates an
    // existing member; an equal or lower one is Stale and does NOT clobber the
    // newer facts; a never-seen pubkey is Inserted. The node_label lease, reused.
    #[test]
    fn merge_entry_is_strictly_greater_lease_wins() {
        let mut r = RosterStore::default();
        assert_eq!(r.merge_entry(entry("home", "aa", 5)), MergeOutcome::Inserted);

        // Lower lease loses (Stale) — the stored lease stays at 5.
        assert_eq!(r.merge_entry(entry("home", "aa", 3)), MergeOutcome::Stale);
        assert_eq!(r.find("home", "aa").unwrap().lease_epoch, 5);

        // Equal lease is Stale too (strictly-greater, not >=).
        assert_eq!(r.merge_entry(entry("home", "aa", 5)), MergeOutcome::Stale);

        // Strictly greater wins.
        let mut newer = entry("home", "aa", 6);
        newer.label = "renamed".into();
        assert_eq!(r.merge_entry(newer), MergeOutcome::Updated);
        assert_eq!(r.find("home", "aa").unwrap().lease_epoch, 6);
        assert_eq!(r.find("home", "aa").unwrap().label, "renamed");
    }

    // [unit->REQ-MESH-2] union convergence: merge is commutative (A∪B == B∪A on
    // the winning-lease facts) and idempotent (merging twice == once). The
    // grow-set CRDT property the mesh relies on.
    #[test]
    fn merge_is_commutative_and_idempotent() {
        let mut a = RosterStore::default();
        a.merge_entry(entry("home", "aa", 2));
        a.merge_entry(entry("home", "bb", 1));

        let mut b = RosterStore::default();
        b.merge_entry(entry("home", "bb", 9)); // bb newer on b
        b.merge_entry(entry("home", "cc", 1));

        let mut ab = a.clone();
        ab.merge(&b);
        let mut ba = b.clone();
        ba.merge(&a);

        // Same membership set and same winning leases regardless of order.
        for pk in ["aa", "bb", "cc"] {
            assert_eq!(
                ab.find("home", pk).map(|e| e.lease_epoch),
                ba.find("home", pk).map(|e| e.lease_epoch),
                "{pk} converges"
            );
        }
        assert_eq!(ab.find("home", "bb").unwrap().lease_epoch, 9, "newer bb wins");

        // Idempotent: merging b again changes nothing.
        let snapshot: Vec<_> = ab.members.iter().map(|e| e.lease_epoch).sum::<u64>().into();
        ab.merge(&b);
        let after: Vec<_> = ab.members.iter().map(|e| e.lease_epoch).sum::<u64>().into();
        assert_eq!(snapshot, after, "re-merge is a no-op");
    }

    // [unit->REQ-MESH-2] persists-through-silence: an offline member's entry
    // survives a merge that never mentions it, AND a save/load round-trip. A
    // silent member is not forgotten.
    #[test]
    fn offline_member_persists_through_silence_and_reload() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("roster.json");

        let mut r = RosterStore::default();
        r.merge_entry(entry("home", "offline", 1));
        r.merge_entry(entry("home", "online", 1));

        // A merge from a peer that only knows `online` must NOT drop `offline`.
        let mut peer = RosterStore::default();
        peer.merge_entry(entry("home", "online", 2));
        r.merge(&peer);
        assert!(r.find("home", "offline").is_some(), "silent member kept");

        r.save_to(&path).unwrap();
        let reloaded = RosterStore::load_from(&path);
        assert!(reloaded.find("home", "offline").is_some(), "survives reload");
        assert_eq!(reloaded.find("home", "online").unwrap().lease_epoch, 2);
    }

    // [unit->REQ-MESH-2] the tombstone truth table: DOMINATES (a present entry
    // is no longer a member once tombstoned), SUPPRESSES REINSERT (a fresh-lease
    // entry-merge after the tombstone re-records facts but membership stays
    // false), and CLEARS-ON-REPAIR (clear_tombstone restores membership).
    #[test]
    fn tombstone_dominates_suppresses_reinsert_and_clears() {
        let mut r = RosterStore::default();
        r.merge_entry(entry("home", "aa", 1));
        assert!(r.is_member("home", "aa"), "listed member");

        // Dominates: tombstone hides it though the entry is still present.
        r.tombstone("home", "aa", "200");
        assert!(r.is_tombstoned("home", "aa"));
        assert!(!r.is_member("home", "aa"), "tombstone dominates the entry");
        assert!(r.find("home", "aa").is_some(), "entry still stored");
        assert_eq!(r.members_in("home").count(), 0, "hidden from the dial set");

        // Suppresses reinsert: a newer-lease merge updates facts but not membership.
        assert_eq!(r.merge_entry(entry("home", "aa", 9)), MergeOutcome::Updated);
        assert!(!r.is_member("home", "aa"), "reinsert stays suppressed");

        // Tombstone is idempotent (no duplicate marker).
        r.tombstone("home", "aa", "300");
        assert_eq!(r.tombstones.len(), 1);

        // Clears on re-pair: membership restored.
        assert!(r.clear_tombstone("home", "aa"));
        assert!(r.is_member("home", "aa"), "re-pair restores membership");
        assert!(!r.clear_tombstone("home", "aa"), "second clear is no-op false");
    }

    // [unit->REQ-MESH-2] tombstones are subnet-scoped and propagate through a
    // merge (they are grow-set elements, not local-only).
    #[test]
    fn tombstones_are_scoped_and_merge() {
        let mut r = RosterStore::default();
        r.merge_entry(entry("home", "aa", 1));
        r.merge_entry(entry("work", "aa", 1));

        let mut peer = RosterStore::default();
        peer.tombstone("home", "aa", "200");
        r.merge(&peer);

        assert!(!r.is_member("home", "aa"), "tombstone propagated via merge");
        assert!(r.is_member("work", "aa"), "other subnet's aa untouched");
    }

    // [unit->REQ-MESH-2] upsert_self is authoritative for the node's own entry:
    // it overwrites unconditionally (a node always holds the latest truth about
    // itself), unlike a peer-authored merge which is lease-gated.
    #[test]
    fn upsert_self_overwrites_unconditionally() {
        let mut r = RosterStore::default();
        r.upsert_self("home", "me", "laptop", "mid", None, "100", 5);
        // Even a "lower" lease overwrites — self-authoring isn't lease-gated.
        r.upsert_self("home", "me", "laptop-renamed", "mid", None, "150", 3);
        let me = r.find("home", "me").unwrap();
        assert_eq!(me.label, "laptop-renamed");
        assert_eq!(me.lease_epoch, 3);
        assert_eq!(r.members.len(), 1, "no duplicate self row");
    }

    // [unit->REQ-MESH-2] roster_for projects one subnet's full slice (members +
    // tombstones, offline included) — the bundle handed to a joiner at pairing.
    #[test]
    fn roster_for_projects_one_subnets_slice() {
        let mut r = RosterStore::default();
        r.merge_entry(entry("home", "aa", 1));
        r.merge_entry(entry("home", "bb", 1));
        r.tombstone("home", "bb", "200");
        r.merge_entry(entry("work", "cc", 1));

        let (members, tombs) = r.roster_for("home");
        assert_eq!(members.len(), 2, "both home entries (incl. tombstoned bb)");
        assert_eq!(tombs.len(), 1);
        assert!(members.iter().all(|m| m.subnet == "home"));
        assert!(r.roster_for("solo").0.is_empty(), "unknown subnet ⇒ empty");
    }

    // [unit->REQ-MESH-2] serde is additive + the store degrades gracefully: a
    // legacy roster.json (no `tombstones`, an entry with no `address`) loads, an
    // address round-trips, and an absent/corrupt file is the empty store.
    #[test]
    fn serde_additive_and_degrades_to_empty() {
        // Legacy doc: members only, no tombstones array, no address field.
        let legacy = r#"{"members":[{"pubkey_hex":"aa","subnet":"home","label":"h","machine_id":"m","last_seen":"1","lease_epoch":1}]}"#;
        let parsed: RosterStore = serde_json::from_str(legacy).unwrap();
        assert!(parsed.is_member("home", "aa"));
        assert!(parsed.tombstones.is_empty());
        assert!(parsed.find("home", "aa").unwrap().address.is_none());

        // Address round-trips as opaque JSON.
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("roster.json");
        let mut r = RosterStore::default();
        r.upsert_self(
            "home",
            "me",
            "h",
            "m",
            Some(serde_json::json!({"addr": "1.2.3.4:5"})),
            "1",
            1,
        );
        r.save_to(&path).unwrap();
        let back = RosterStore::load_from(&path);
        assert_eq!(
            back.find("home", "me").unwrap().address,
            Some(serde_json::json!({"addr": "1.2.3.4:5"}))
        );

        // Absent + corrupt ⇒ empty, never a panic.
        assert!(RosterStore::load_from(&dir.path().join("nope.json"))
            .members
            .is_empty());
        let corrupt = dir.path().join("corrupt.json");
        std::fs::write(&corrupt, b"{ not json").unwrap();
        assert!(RosterStore::load_from(&corrupt).members.is_empty());
    }
}


Fix two things: cross-crate doc links (spt-store is below spt-net — those intra-doc links won't resolve, CI-gated) and a sloppy idempotent assertion.


Edit

//! lease the registry runs for node labels (`spt_net::net::registry`,
//! `MergeOutcome`). It is **NOT** the seed

//! lease the registry runs for node labels (the `spt-net` registry's
//! `MergeOutcome`). It is **NOT** the seed


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

//! lease the registry runs for node labels (`spt_net::net::registry`,
//! `MergeOutcome`). It is **NOT** the seed

//! lease the registry runs for node labels (the `spt-net` registry's
//! `MergeOutcome`). It is **NOT** the seed

//! node authors its own [`RosterEntry`] (its pubkey, label, machine_id,

//! last-known address, last-seen) stamped with its

//! [`lease_epoch`](RosterEntry::lease_epoch) — the same strictly-greater-wins

-//! lease the registry runs for node labels (`spt_net::net::registry`,

+//! lease the registry runs for node labels (the `spt-net` registry's

//! `MergeOutcome`). It is **NOT** the seed (the seed never leaves a node except

//! over the pairing/seed-proof channel — ADR-0017); a roster entry is

//! **forgery-inert** because naming a pubkey you can't seed-proof gets you


/// The lease decision when merging an entry (mirrors the registry's
/// [`MergeOutcome`](spt_net::net::registry) naming — strictly-greater wins).

/// The lease decision when merging an entry (mirrors the `spt-net` registry's
/// `MergeOutcome` naming — strictly-greater wins).


/// The lease decision when merging an entry (mirrors the registry's
/// [`MergeOutcome`](spt_net::net::registry) naming — strictly-greater wins).

/// The lease decision when merging an entry (mirrors the `spt-net` registry's
/// `MergeOutcome` naming — strictly-greater wins).

impl std::error::Error for RosterError {}

-/// The lease decision when merging an entry (mirrors the registry's

-/// [`MergeOutcome`](spt_net::net::registry) naming — strictly-greater wins).

+/// The lease decision when merging an entry (mirrors the `spt-net` registry's

+/// `MergeOutcome` naming — strictly-greater wins).

#[derive(Debug, Clone, Copy, PartialEq, Eq)]

/// First entry for this `(subnet, pubkey)` — inserted.


        // Idempotent: merging b again changes nothing.
        let snapshot: Vec<_> = ab.members.iter().map(|e| e.lease_epoch).sum::<u64>().into();
        ab.merge(&b);
        let after: Vec<_> = ab.members.iter().map(|e| e.lease_epoch).sum::<u64>().into();
        assert_eq!(snapshot, after, "re-merge is a no-op");

        // Idempotent: merging b again changes nothing.
        let before = ab.members.len();
        let lease_sum: u64 = ab.members.iter().map(|e| e.lease_epoch).sum();
        ab.merge(&b);
        assert_eq!(ab.members.len(), before, "no rows added on re-merge");
        let after_sum: u64 = ab.members.iter().map(|e| e.lease_epoch).sum();
        assert_eq!(lease_sum, after_sum, "re-merge is a no-op");


        // Idempotent: merging b again changes nothing.
        let snapshot: Vec<_> = ab.members.iter().map(|e| e.lease_epoch).sum::<u64>().into();
        ab.merge(&b);
        let after: Vec<_> = ab.members.iter().map(|e| e.lease_epoch).sum::<u64>().into();
        assert_eq!(snapshot, after, "re-merge is a no-op");

        // Idempotent: merging b again changes nothing.
        let before = ab.members.len();
        let lease_sum: u64 = ab.members.iter().map(|e| e.lease_epoch).sum();
        ab.merge(&b);
        assert_eq!(ab.members.len(), before, "no rows added on re-merge");
        let after_sum: u64 = ab.members.iter().map(|e| e.lease_epoch).sum();
        assert_eq!(lease_sum, after_sum, "re-merge is a no-op");

assert_eq!(ab.find("home", "bb").unwrap().lease_epoch, 9, "newer bb wins");

-        let snapshot: Vec<_> = ab.members.iter().map(|e| e.lease_epoch).sum::<u64>().into();

+        let lease_sum: u64 = ab.members.iter().map(|e| e.lease_epoch).sum();

-        let after: Vec<_> = ab.members.iter().map(|e| e.lease_epoch).sum::<u64>().into();

-        assert_eq!(snapshot, after, "re-merge is a no-op");

+        assert_eq!(ab.members.len(), before, "no rows added on re-merge");

+        let after_sum: u64 = ab.members.iter().map(|e| e.lease_epoch).sum();

+        assert_eq!(lease_sum, after_sum, "re-merge is a no-op");

// [unit->REQ-MESH-2] persists-through-silence: an offline member's entry


Register the module + build/test the store before wiring the ceremony.


pub mod registry;
pub mod rename;
pub mod roster;


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 registry;
pub mod rename;
pub mod roster;

//! `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 attachment;
pub mod branchstore;
pub mod contextmark;
pub mod contextstore;
mod db;
pub mod epoch;
pub mod gitrun;
pub mod grants;
pub mod history;
pub mod home;
pub mod hostlabel;
pub mod info;
pub mod liveness;
pub mod nodeid;
pub mod notif;
pub mod peeraddrs;
pub mod perch;
pub mod proc;
pub mod project;
pub mod registry;
pub mod rename;
pub mod seed;
pub mod shellinfo;
pub mod spool;
pub mod subnet;
pub mod syncmerge;
pub mod trust;
pub mod visibility;
pub mod xfer;

pub mod project;
pub mod registry;
pub mod rename;
+pub mod roster;
pub mod seed;
pub mod shellinfo;
pub mod spool;


Bash

cd C:/Users/decid/Documents/projects/spt-core && cargo test -p spt-store roster 2>&1 | tail -30

Build + test roster store


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

running 8 tests
test roster::tests::merge_entry_is_strictly_greater_lease_wins ... ok
test roster::tests::tombstone_dominates_suppresses_reinsert_and_clears ... ok
test roster::tests::merge_is_commutative_and_idempotent ... ok
test roster::tests::tombstones_are_scoped_and_merge ... ok
test roster::tests::upsert_self_overwrites_unconditionally ... ok
test roster::tests::roster_for_projects_one_subnets_slice ... ok
test roster::tests::offline_member_persists_through_silence_and_reload ... ok
test roster::tests::serde_additive_and_degrades_to_empty ... ok

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


Store green (8/8). Now Part B — extend `Frame::Seed`. Read wire.rs imports + Cursor accessors.


40	// [impl->REQ-PAIR-1]
41
42	use iroh::endpoint::{Connection, RecvStream, SendStream};
43	use spt_proto::identity::PublicKey;
44	use spt_store::trust::{TrustOutcome, TrustStore};
45
46	use spt_store::subnet::{SubnetError, SubnetStore, TOTP_SEED_LEN};
47
48	use super::ratelimit::{PairingRateLimiter, RateLimited};
49	use super::spake::{Initiator, PairError, Responder};
50	use super::totp::{accepts_step, time_step, TotpSeed, DIGITS};
51	use super::transcript::{PairingTranscript, TAG_LEN};
52
53	/// The pre-trust pairing ALPN. Distinct from the trusted
54	/// [`SPT_NET_ALPN`](super::super::endpoint::SPT_NET_ALPN) so the relay can permit
55	/// pre-trust contact (a seed-holder must be reachable by an untrusted new node) without
56	/// exposing the trusted message surface (ADR-0005; `pairing.rs`). Versioned for a clean
57	/// future wire break.
58	pub const SPT_PAIR_ALPN: &[u8] = b"spt-core/pair/0";
59
60	/// Hard cap on a single wire frame. The ceremony's largest field is a SPAKE2 message
61	/// (~tens of bytes) or a subnet name; 4 KiB is generous slack while bounding a hostile
62	/// length prefix so a pre-trust peer cannot force a huge allocation.
63	const MAX_FRAME: usize = 4096;
64
65	// Frame tag bytes.
66	const T_ABORT: u8 = 0;
67	const T_HELLO: u8 = 1;
68	const T_ANNOUNCE: u8 = 2;
69	const T_SPAKE1: u8 = 3;
70	const T_SPAKE2: u8 = 4;
71	const T_CONFIRM: u8 = 5;
72	const T_DONE: u8 = 6;
73	const T_SEED: u8 = 7;
74
75	// Abort reason bytes.
76	const A_BUSY: u8 = 1;
77	const A_BACKOFF: u8 = 2;
78	const A_BAD_STEP: u8 = 3;
79	const A_UNKNOWN_SUBNET: u8 = 4;
80	const A_BAD_MSG: u8 = 5;
81	const A_CONFIRM_FAIL: u8 = 6;
82
83	/// A failure of the pairing wire ceremony. Carries no secret material.
84	#[derive(Debug)]
85	pub enum PairWireError {
86	    /// Stream read/write/framing failure (peer vanished, malformed length, EOF).
87	    Io(String),
88	    /// A frame was the wrong type or malformed for the current ceremony step.
89	    Protocol(String),
90	    /// The responder refused to admit the ceremony (rate-limit): another is in flight
91	    /// ([`Busy`](RateLimited::Busy)) or the backoff window is open
92	    /// ([`Backoff`](RateLimited::Backoff)).
93	    Refused(RateLimited),
94	    /// The dialer's announced TOTP step was outside the responder's ±1 window.
95	    StepRejected,
96	    /// The responder holds no subnet by that name.
97	    UnknownSubnet(String),
98	    /// The SPAKE2 / key-confirmation step failed: wrong code, MITM, wrong subnet, stale
99	    /// step — the one signal a bad pairing gives ([`spake`](super::spake)).
100	    Pairing(PairError),
101	}
102
103	impl std::fmt::Display for PairWireError {
104	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105	        match self {
106	            PairWireError::Io(e) => write!(f, "pairing wire io: {e}"),
107	            PairWireError::Protocol(e) => write!(f, "pairing wire protocol error: {e}"),
108	            PairWireError::Refused(r) => write!(f, "pairing refused: {r}"),
109	            PairWireError::StepRejected => {
110	                write!(f, "pairing rejected: TOTP step outside the ±1 window")
111	            }
112	            PairWireError::UnknownSubnet(n) => write!(f, "pairing rejected: unknown subnet {n}"),
113	            PairWireError::Pairing(e) => write!(f, "{e}"),
114	        }
115	    }
116	}
117
118	impl std::error::Error for PairWireError {}
119
120	/// The joiner's self-introduction riding `Hello` (M8 decision 13,
121	/// REQ-SUBNET-7): its node label + **hashed** machine id — the re-pair
122	/// overwrite inputs. Claimed pre-authentication but CONSUMED only after the
123	/// ceremony completes: the SPAKE2 confirm proves the connection peer holds
124	/// the code, and Hello rode that same iroh-authenticated connection.
125	/// Optional + additive on the wire: an older joiner sends none, an older
126	/// responder ignores the trailing Hello bytes (the decoder reads
127	/// positionally and never rejects a longer frame).
128	// [impl->REQ-SUBNET-7]
129	#[derive(Debug, Clone, PartialEq, Eq)]

// [impl->REQ-PAIR-1]

use iroh::endpoint::{Connection, RecvStream, SendStream};
use spt_proto::identity::PublicKey;
use spt_store::trust::{TrustOutcome, TrustStore};

use spt_store::subnet::{SubnetError, SubnetStore, TOTP_SEED_LEN};

use super::ratelimit::{PairingRateLimiter, RateLimited};
use super::spake::{Initiator, PairError, Responder};
use super::totp::{accepts_step, time_step, TotpSeed, DIGITS};
use super::transcript::{PairingTranscript, TAG_LEN};

/// The pre-trust pairing ALPN. Distinct from the trusted
/// [`SPT_NET_ALPN`](super::super::endpoint::SPT_NET_ALPN) so the relay can permit
/// pre-trust contact (a seed-holder must be reachable by an untrusted new node) without
/// exposing the trusted message surface (ADR-0005; `pairing.rs`). Versioned for a clean
/// future wire break.
pub const SPT_PAIR_ALPN: &[u8] = b"spt-core/pair/0";

/// Hard cap on a single wire frame. The ceremony's largest field is a SPAKE2 message
/// (~tens of bytes) or a subnet name; 4 KiB is generous slack while bounding a hostile
/// length prefix so a pre-trust peer cannot force a huge allocation.
const MAX_FRAME: usize = 4096;

// Frame tag bytes.
const T_ABORT: u8 = 0;
const T_HELLO: u8 = 1;
const T_ANNOUNCE: u8 = 2;
const T_SPAKE1: u8 = 3;
const T_SPAKE2: u8 = 4;
const T_CONFIRM: u8 = 5;
const T_DONE: u8 = 6;
const T_SEED: u8 = 7;

// Abort reason bytes.
const A_BUSY: u8 = 1;
const A_BACKOFF: u8 = 2;
const A_BAD_STEP: u8 = 3;
const A_UNKNOWN_SUBNET: u8 = 4;
const A_BAD_MSG: u8 = 5;
const A_CONFIRM_FAIL: u8 = 6;

/// A failure of the pairing wire ceremony. Carries no secret material.
#[derive(Debug)]
pub enum PairWireError {
    /// Stream read/write/framing failure (peer vanished, malformed length, EOF).
    Io(String),
    /// A frame was the wrong type or malformed for the current ceremony step.
    Protocol(String),
    /// The responder refused to admit the ceremony (rate-limit): another is in flight
    /// ([`Busy`](RateLimited::Busy)) or the backoff window is open
    /// ([`Backoff`](RateLimited::Backoff)).
    Refused(RateLimited),
    /// The dialer's announced TOTP step was outside the responder's ±1 window.
    StepRejected,
    /// The responder holds no subnet by that name.
    UnknownSubnet(String),
    /// The SPAKE2 / key-confirmation step failed: wrong code, MITM, wrong subnet, stale
    /// step — the one signal a bad pairing gives ([`spake`](super::spake)).
    Pairing(PairError),
}

impl std::fmt::Display for PairWireError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PairWireError::Io(e) => write!(f, "pairing wire io: {e}"),
            PairWireError::Protocol(e) => write!(f, "pairing wire protocol error: {e}"),
            PairWireError::Refused(r) => write!(f, "pairing refused: {r}"),
            PairWireError::StepRejected => {
                write!(f, "pairing rejected: TOTP step outside the ±1 window")
            }
            PairWireError::UnknownSubnet(n) => write!(f, "pairing rejected: unknown subnet {n}"),
            PairWireError::Pairing(e) => write!(f, "{e}"),
        }
    }
}

impl std::error::Error for PairWireError {}

/// The joiner's self-introduction riding `Hello` (M8 decision 13,
/// REQ-SUBNET-7): its node label + **hashed** machine id — the re-pair
/// overwrite inputs. Claimed pre-authentication but CONSUMED only after the
/// ceremony completes: the SPAKE2 confirm proves the connection peer holds
/// the code, and Hello rode that same iroh-authenticated connection.
/// Optional + additive on the wire: an older joiner sends none, an older
/// responder ignores the trailing Hello bytes (the decoder reads
/// positionally and never rejects a longer frame).
// [impl->REQ-SUBNET-7]
#[derive(Debug, Clone, PartialEq, Eq)]


719	    fn take(&mut self, n: usize) -> Result<&'a [u8], PairWireError> {
720	        let end = self
721	            .pos
722	            .checked_add(n)
723	            .filter(|e| *e <= self.buf.len())
724	            .ok_or_else(|| PairWireError::Protocol("frame truncated".into()))?;
725	        let slice = &self.buf[self.pos..end];
726	        self.pos = end;
727	        Ok(slice)
728	    }
729
730	    fn u8(&mut self) -> Result<u8, PairWireError> {
731	        Ok(self.take(1)?[0])
732	    }
733
734	    fn u64(&mut self) -> Result<u64, PairWireError> {
735	        let b = self.take(8)?;
736	        Ok(u64::from_be_bytes(b.try_into().expect("took 8")))
737	    }
738
739	    /// A u32-BE length-prefixed field, bounded by [`MAX_FRAME`].
740	    fn field(&mut self) -> Result<&'a [u8], PairWireError> {
741	        let len = u32::from_be_bytes(self.take(4)?.try_into().expect("took 4")) as usize;
742	        if len > MAX_FRAME {
743	            return Err(PairWireError::Protocol("field length out of range".into()));
744	        }
745	        self.take(len)
746	    }
747
748	    fn tag(&mut self) -> Result<[u8; TAG_LEN], PairWireError> {
749	        Ok(self.take(TAG_LEN)?.try_into().expect("took TAG_LEN"))
750	    }
751
752	    /// Unread bytes left in the frame (the optional-trailing-fields probe).
753	    fn remaining(&self) -> usize {
754	        self.buf.len().saturating_sub(self.pos)
755	    }
756	}
757
758	#[cfg(test)]
759	mod tests {
760	    use super::*;
761	    use crate::net::endpoint::{BindScope, LocalDiscovery, NetEndpoint, RelayPolicy};
762	    use spt_proto::identity::Identity;
763	    use spt_store::subnet::{SubnetRecord, SubnetStore};
764	    use spt_store::trust::TrustStore;
765
766	    /// Reference time: a fixed unix second so both sides share a TOTP step deterministically.
767	    const NOW: u64 = 1_711_111_110;
768
769	    /// A responder-side subnet store holding one subnet with a known seed at `epoch`.
770	    fn subnet_store(name: &str, seed: [u8; 20], epoch: u64) -> SubnetStore {
771	        SubnetStore {
772	            subnets: vec![SubnetRecord {
773	                name: name.to_string(),
774	                seed_hex: hex(&seed),
775	                epoch,
776	                user: None,
777	                created: "0".into(),
778	                hide_new_endpoints: false,
779	            }],
780	        }
781	    }
782
783	    fn hex(bytes: &[u8]) -> String {
784	        bytes.iter().map(|b| format!("{b:02x}")).collect()
785	    }
786

    fn take(&mut self, n: usize) -> Result<&'a [u8], PairWireError> {
        let end = self
            .pos
            .checked_add(n)
            .filter(|e| *e <= self.buf.len())
            .ok_or_else(|| PairWireError::Protocol("frame truncated".into()))?;
        let slice = &self.buf[self.pos..end];
        self.pos = end;
        Ok(slice)
    }

    fn u8(&mut self) -> Result<u8, PairWireError> {
        Ok(self.take(1)?[0])
    }

    fn u64(&mut self) -> Result<u64, PairWireError> {
        let b = self.take(8)?;
        Ok(u64::from_be_bytes(b.try_into().expect("took 8")))
    }

    /// A u32-BE length-prefixed field, bounded by [`MAX_FRAME`].
    fn field(&mut self) -> Result<&'a [u8], PairWireError> {
        let len = u32::from_be_bytes(self.take(4)?.try_into().expect("took 4")) as usize;
        if len > MAX_FRAME {
            return Err(PairWireError::Protocol("field length out of range".into()));
        }
        self.take(len)
    }

    fn tag(&mut self) -> Result<[u8; TAG_LEN], PairWireError> {
        Ok(self.take(TAG_LEN)?.try_into().expect("took TAG_LEN"))
    }

    /// Unread bytes left in the frame (the optional-trailing-fields probe).
    fn remaining(&self) -> usize {
        self.buf.len().saturating_sub(self.pos)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::net::endpoint::{BindScope, LocalDiscovery, NetEndpoint, RelayPolicy};
    use spt_proto::identity::Identity;
    use spt_store::subnet::{SubnetRecord, SubnetStore};
    use spt_store::trust::TrustStore;

    /// Reference time: a fixed unix second so both sides share a TOTP step deterministically.
    const NOW: u64 = 1_711_111_110;

    /// A responder-side subnet store holding one subnet with a known seed at `epoch`.
    fn subnet_store(name: &str, seed: [u8; 20], epoch: u64) -> SubnetStore {
        SubnetStore {
            subnets: vec![SubnetRecord {
                name: name.to_string(),
                seed_hex: hex(&seed),
                epoch,
                user: None,
                created: "0".into(),
                hide_new_endpoints: false,
            }],
        }
    }

    fn hex(bytes: &[u8]) -> String {
        bytes.iter().map(|b| format!("{b:02x}")).collect()
    }


Read the existing Seed codec test (~1340) + check `Frame` derives.


1285	    }
1286
1287	    // [unit->REQ-SUBNET-7] the Hello intro is additive on the wire: absent
1288	    // decodes None (the pre-M8 joiner), present round-trips both fields,
1289	    // and a HALF-written intro is a protocol error — never a half-claim.
1290	    #[test]
1291	    fn hello_intro_is_additive_and_roundtrips() {
1292	        // Absent → None.
1293	        match decode_frame(&enc_hello("home", 7, None)).expect("decodes") {
1294	            Frame::Hello { intro, .. } => assert!(intro.is_none()),
1295	            _ => panic!("expected Hello"),
1296	        }
1297	        // Present → both fields intact.
1298	        let sent = NodeIntro {
1299	            label: "KITSUBITO".into(),
1300	            machine_id: "cd".repeat(32),
1301	        };
1302	        match decode_frame(&enc_hello("home", 7, Some(&sent))).expect("decodes") {
1303	            Frame::Hello {
1304	                subnet_name,
1305	                step,
1306	                intro,
1307	            } => {
1308	                assert_eq!(subnet_name, "home");
1309	                assert_eq!(step, 7);
1310	                assert_eq!(intro, Some(sent));
1311	            }
1312	            _ => panic!("expected Hello"),
1313	        }
1314	        // Half an intro (label field only) refuses, never half-claims.
1315	        let mut half = vec![T_HELLO];
1316	        push_field(&mut half, b"home");
1317	        half.extend_from_slice(&7u64.to_be_bytes());
1318	        push_field(&mut half, b"KITSUBITO");
1319	        assert!(matches!(
1320	            decode_frame(&half),
1321	            Err(PairWireError::Protocol(_))
1322	        ));
1323	    }
1324
1325	    // [unit->REQ-PAIR-1] the frame codec round-trips every variant and rejects a
1326	    // truncated body without panicking (the codec is pre-trust attacker-reachable).
1327	    #[test]
1328	    fn frame_codec_round_trips_and_rejects_garbage() {
1329	        let cases = [
1330	            enc_hello("home fleet", 57_037_037, None),
1331	            // The M8 intro-carrying Hello round-trips too.
1332	            enc_hello(
1333	                "home fleet",
1334	                57_037_037,
1335	                Some(&NodeIntro {
1336	                    label: "HFENDULEAM".into(),
1337	                    machine_id: "ab".repeat(32),
1338	                }),
1339	            ),
1340	            enc_announce(42),
1341	            enc_spake1(b"msg-a-bytes"),
1342	            enc_spake2(b"msg-b", &[7u8; TAG_LEN]),
1343	            enc_confirm(&[9u8; TAG_LEN]),
1344	            enc_done(true),
1345	            enc_done(false),
1346	            enc_seed(&[5u8; TOTP_SEED_LEN], 9),
1347	            enc_abort(A_BACKOFF, 17),
1348	        ];
1349	        for body in cases {
1350	            decode_frame(&body).expect("valid frame decodes");
1351	        }
1352	        // A Hello claiming an 8-byte step but missing it is truncated, not a panic.
1353	        let mut bad = vec![T_HELLO];
1354	        push_field(&mut bad, b"home");
1355	        bad.extend_from_slice(&[0u8; 3]); // only 3 of the 8 step bytes
1356	        assert!(matches!(
1357	            decode_frame(&bad),
1358	            Err(PairWireError::Protocol(_))
1359	        ));
1360	        // Unknown tag is rejected.
1361	        assert!(matches!(
1362	            decode_frame(&[0xFE]),
1363	            Err(PairWireError::Protocol(_))
1364	        ));
1365	    }
1366	}
1367

    }

    // [unit->REQ-SUBNET-7] the Hello intro is additive on the wire: absent
    // decodes None (the pre-M8 joiner), present round-trips both fields,
    // and a HALF-written intro is a protocol error — never a half-claim.
    #[test]
    fn hello_intro_is_additive_and_roundtrips() {
        // Absent → None.
        match decode_frame(&enc_hello("home", 7, None)).expect("decodes") {
            Frame::Hello { intro, .. } => assert!(intro.is_none()),
            _ => panic!("expected Hello"),
        }
        // Present → both fields intact.
        let sent = NodeIntro {
            label: "KITSUBITO".into(),
            machine_id: "cd".repeat(32),
        };
        match decode_frame(&enc_hello("home", 7, Some(&sent))).expect("decodes") {
            Frame::Hello {
                subnet_name,
                step,
                intro,
            } => {
                assert_eq!(subnet_name, "home");
                assert_eq!(step, 7);
                assert_eq!(intro, Some(sent));
            }
            _ => panic!("expected Hello"),
        }
        // Half an intro (label field only) refuses, never half-claims.
        let mut half = vec![T_HELLO];
        push_field(&mut half, b"home");
        half.extend_from_slice(&7u64.to_be_bytes());
        push_field(&mut half, b"KITSUBITO");
        assert!(matches!(
            decode_frame(&half),
            Err(PairWireError::Protocol(_))
        ));
    }

    // [unit->REQ-PAIR-1] the frame codec round-trips every variant and rejects a
    // truncated body without panicking (the codec is pre-trust attacker-reachable).
    #[test]
    fn frame_codec_round_trips_and_rejects_garbage() {
        let cases = [
            enc_hello("home fleet", 57_037_037, None),
            // The M8 intro-carrying Hello round-trips too.
            enc_hello(
                "home fleet",
                57_037_037,
                Some(&NodeIntro {
                    label: "HFENDULEAM".into(),
                    machine_id: "ab".repeat(32),
                }),
            ),
            enc_announce(42),
            enc_spake1(b"msg-a-bytes"),
            enc_spake2(b"msg-b", &[7u8; TAG_LEN]),
            enc_confirm(&[9u8; TAG_LEN]),
            enc_done(true),
            enc_done(false),
            enc_seed(&[5u8; TOTP_SEED_LEN], 9),
            enc_abort(A_BACKOFF, 17),
        ];
        for body in cases {
            decode_frame(&body).expect("valid frame decodes");
        }
        // A Hello claiming an 8-byte step but missing it is truncated, not a panic.
        let mut bad = vec![T_HELLO];
        push_field(&mut bad, b"home");
        bad.extend_from_slice(&[0u8; 3]); // only 3 of the 8 step bytes
        assert!(matches!(
            decode_frame(&bad),
            Err(PairWireError::Protocol(_))
        ));
        // Unknown tag is rejected.
        assert!(matches!(
            decode_frame(&[0xFE]),
            Err(PairWireError::Protocol(_))
        ));
    }
}


Now extend the wire codec. Imports + frame-size note first.


Edit

use spt_store::subnet::{SubnetError, SubnetStore, TOTP_SEED_LEN};

use spt_store::roster::{RosterEntry, RosterStore, Tombstone};
use spt_store::subnet::{SubnetError, SubnetStore, TOTP_SEED_LEN};


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

use spt_store::roster::{RosterEntry, RosterStore, Tombstone};
use spt_store::subnet::{SubnetError, SubnetStore, TOTP_SEED_LEN};

+use spt_store::roster::{RosterEntry, RosterStore, Tombstone};


/// Hard cap on a single wire frame. The ceremony's largest field is a SPAKE2 message
/// (~tens of bytes) or a subnet name; 4 KiB is generous slack while bounding a hostile
/// length prefix so a pre-trust peer cannot force a huge allocation.
const MAX_FRAME: usize = 4096;

/// Hard cap on a single wire frame. Pre-trust frames (Hello/Spake/…) are tens of bytes;
/// the largest payload is the **post-confirm `Seed` frame**, which now carries the
/// seed-holder's full member roster (REQ-MESH-2) — a sequence of small per-member fields.
/// 64 KiB bounds a hostile length prefix (one ceremony at a time, rate-limited, so this is
/// not a DoS lever) while leaving generous room for a real subnet's roster; each *field*
/// stays individually bounded by this same cap, and counts are never pre-allocated from the
/// wire (a huge count hits "frame truncated" immediately, not a huge allocation).
const MAX_FRAME: usize = 65536;


/// Hard cap on a single wire frame. The ceremony's largest field is a SPAKE2 message
/// (~tens of bytes) or a subnet name; 4 KiB is generous slack while bounding a hostile
/// length prefix so a pre-trust peer cannot force a huge allocation.
const MAX_FRAME: usize = 4096;

/// Hard cap on a single wire frame. Pre-trust frames (Hello/Spake/…) are tens of bytes;
/// the largest payload is the **post-confirm `Seed` frame**, which now carries the
/// seed-holder's full member roster (REQ-MESH-2) — a sequence of small per-member fields.
/// 64 KiB bounds a hostile length prefix (one ceremony at a time, rate-limited, so this is
/// not a DoS lever) while leaving generous room for a real subnet's roster; each *field*
/// stays individually bounded by this same cap, and counts are never pre-allocated from the
/// wire (a huge count hits "frame truncated" immediately, not a huge allocation).
const MAX_FRAME: usize = 65536;

pub const SPT_PAIR_ALPN: &[u8] = b"spt-core/pair/0";

-/// Hard cap on a single wire frame. The ceremony's largest field is a SPAKE2 message

-/// (~tens of bytes) or a subnet name; 4 KiB is generous slack while bounding a hostile

-/// length prefix so a pre-trust peer cannot force a huge allocation.

+/// Hard cap on a single wire frame. Pre-trust frames (Hello/Spake/…) are tens of bytes;

+/// the largest payload is the **post-confirm `Seed` frame**, which now carries the

+/// seed-holder's full member roster (REQ-MESH-2) — a sequence of small per-member fields.

+/// 64 KiB bounds a hostile length prefix (one ceremony at a time, rate-limited, so this is

+/// not a DoS lever) while leaving generous room for a real subnet's roster; each *field*

+/// stays individually bounded by this same cap, and counts are never pre-allocated from the

+/// wire (a huge count hits "frame truncated" immediately, not a huge allocation).


    Seed {
        seed: [u8; TOTP_SEED_LEN],
        epoch: u64,
    },
    Abort {

    Seed {
        seed: [u8; TOTP_SEED_LEN],
        epoch: u64,
        /// The seed-holder's full current roster for this subnet (REQ-MESH-2),
        /// trailing + additive — a legacy/short Seed body (seed+epoch only)
        /// decodes to an empty roster. Entries carry an **empty** `subnet`
        /// field on the wire (it is implicit = the ceremony's subnet); the
        /// joiner stamps the real name on adopt.
        roster: Vec<RosterEntry>,
        /// The revocation tombstones for this subnet (also trailing/additive).
        tombstones: Vec<Tombstone>,
    },
    Abort {


    Seed {
        seed: [u8; TOTP_SEED_LEN],
        epoch: u64,
    },
    Abort {

    Seed {
        seed: [u8; TOTP_SEED_LEN],
        epoch: u64,
        /// The seed-holder's full current roster for this subnet (REQ-MESH-2),
        /// trailing + additive — a legacy/short Seed body (seed+epoch only)
        /// decodes to an empty roster. Entries carry an **empty** `subnet`
        /// field on the wire (it is implicit = the ceremony's subnet); the
        /// joiner stamps the real name on adopt.
        roster: Vec<RosterEntry>,
        /// The revocation tombstones for this subnet (also trailing/additive).
        tombstones: Vec<Tombstone>,
    },
    Abort {

+        /// The seed-holder's full current roster for this subnet (REQ-MESH-2),

+        /// trailing + additive — a legacy/short Seed body (seed+epoch only)

+        /// decodes to an empty roster. Entries carry an **empty** `subnet`

+        /// field on the wire (it is implicit = the ceremony's subnet); the

+        /// joiner stamps the real name on adopt.

+        /// The revocation tombstones for this subnet (also trailing/additive).


Now the encoder: extend `enc_seed` + roster helpers.


fn enc_seed(seed: &[u8; TOTP_SEED_LEN], epoch: u64) -> Vec<u8> {
    let mut b = vec![T_SEED];
    b.extend_from_slice(seed);
    b.extend_from_slice(&epoch.to_be_bytes());
    b
}

fn enc_seed(
    seed: &[u8; TOTP_SEED_LEN],
    epoch: u64,
    roster: &[RosterEntry],
    tombstones: &[Tombstone],
) -> Vec<u8> {
    let mut b = vec![T_SEED];
    b.extend_from_slice(seed);
    b.extend_from_slice(&epoch.to_be_bytes());
    // Trailing + additive roster (REQ-MESH-2): a pre-mesh decoder stops after
    // epoch; a mesh decoder reads these counts. `subnet` is implicit (the
    // ceremony's) so it is NOT written — the joiner stamps it on adopt.
    b.extend_from_slice(&(roster.len() as u32).to_be_bytes());
    for m in roster {
        push_field(&mut b, m.pubkey_hex.as_bytes());
        push_field(&mut b, m.label.as_bytes());
        push_field(&mut b, m.machine_id.as_bytes());
        // Address: opaque JSON bytes, empty field = None.
        let addr_bytes = m
            .address
            .as_ref()
            .and_then(|v| serde_json::to_vec(v).ok())
            .unwrap_or_default();
        push_field(&mut b, &addr_bytes);
        push_field(&mut b, m.last_seen.as_bytes());
        b.extend_from_slice(&m.lease_epoch.to_be_bytes());
    }
    b.extend_from_slice(&(tombstones.len() as u32).to_be_bytes());
    for t in tombstones {
        push_field(&mut b, t.pubkey_hex.as_bytes());
        push_field(&mut b, t.stamp.as_bytes());
    }
    b
}


fn enc_seed(seed: &[u8; TOTP_SEED_LEN], epoch: u64) -> Vec<u8> {
    let mut b = vec![T_SEED];
    b.extend_from_slice(seed);
    b.extend_from_slice(&epoch.to_be_bytes());
    b
}

fn enc_seed(
    seed: &[u8; TOTP_SEED_LEN],
    epoch: u64,
    roster: &[RosterEntry],
    tombstones: &[Tombstone],
) -> Vec<u8> {
    let mut b = vec![T_SEED];
    b.extend_from_slice(seed);
    b.extend_from_slice(&epoch.to_be_bytes());
    // Trailing + additive roster (REQ-MESH-2): a pre-mesh decoder stops after
    // epoch; a mesh decoder reads these counts. `subnet` is implicit (the
    // ceremony's) so it is NOT written — the joiner stamps it on adopt.
    b.extend_from_slice(&(roster.len() as u32).to_be_bytes());
    for m in roster {
        push_field(&mut b, m.pubkey_hex.as_bytes());
        push_field(&mut b, m.label.as_bytes());
        push_field(&mut b, m.machine_id.as_bytes());
        // Address: opaque JSON bytes, empty field = None.
        let addr_bytes = m
            .address
            .as_ref()
            .and_then(|v| serde_json::to_vec(v).ok())
            .unwrap_or_default();
        push_field(&mut b, &addr_bytes);
        push_field(&mut b, m.last_seen.as_bytes());
        b.extend_from_slice(&m.lease_epoch.to_be_bytes());
    }
    b.extend_from_slice(&(tombstones.len() as u32).to_be_bytes());
    for t in tombstones {
        push_field(&mut b, t.pubkey_hex.as_bytes());
        push_field(&mut b, t.stamp.as_bytes());
    }
    b
}

-fn enc_seed(seed: &[u8; TOTP_SEED_LEN], epoch: u64) -> Vec<u8> {

+    // Trailing + additive roster (REQ-MESH-2): a pre-mesh decoder stops after

+    // epoch; a mesh decoder reads these counts. `subnet` is implicit (the

+    // ceremony's) so it is NOT written — the joiner stamps it on adopt.

+    b.extend_from_slice(&(roster.len() as u32).to_be_bytes());

+        push_field(&mut b, m.pubkey_hex.as_bytes());

+        push_field(&mut b, m.label.as_bytes());

+        push_field(&mut b, m.machine_id.as_bytes());

+        // Address: opaque JSON bytes, empty field = None.

+            .and_then(|v| serde_json::to_vec(v).ok())

+        push_field(&mut b, &addr_bytes);

+        push_field(&mut b, m.last_seen.as_bytes());

+        b.extend_from_slice(&m.lease_epoch.to_be_bytes());

+    b.extend_from_slice(&(tombstones.len() as u32).to_be_bytes());

+        push_field(&mut b, t.pubkey_hex.as_bytes());

+        push_field(&mut b, t.stamp.as_bytes());


Now the decoder for the trailing roster:


        T_SEED => {
            let seed: [u8; TOTP_SEED_LEN] = c
                .take(TOTP_SEED_LEN)?
                .try_into()
                .expect("took TOTP_SEED_LEN");
            let epoch = c.u64()?;
            Frame::Seed { seed, epoch }
        }

        T_SEED => {
            let seed: [u8; TOTP_SEED_LEN] = c
                .take(TOTP_SEED_LEN)?
                .try_into()
                .expect("took TOTP_SEED_LEN");
            let epoch = c.u64()?;
            // Trailing + additive roster (REQ-MESH-2): a legacy short Seed body
            // (seed+epoch only) leaves nothing here ⇒ empty roster.
            let (roster, tombstones) = if c.remaining() > 0 {
                dec_roster(&mut c)?
            } else {
                (Vec::new(), Vec::new())
            };
            Frame::Seed {
                seed,
                epoch,
                roster,
                tombstones,
            }
        }


        T_SEED => {
            let seed: [u8; TOTP_SEED_LEN] = c
                .take(TOTP_SEED_LEN)?
                .try_into()
                .expect("took TOTP_SEED_LEN");
            let epoch = c.u64()?;
            Frame::Seed { seed, epoch }
        }

        T_SEED => {
            let seed: [u8; TOTP_SEED_LEN] = c
                .take(TOTP_SEED_LEN)?
                .try_into()
                .expect("took TOTP_SEED_LEN");
            let epoch = c.u64()?;
            // Trailing + additive roster (REQ-MESH-2): a legacy short Seed body
            // (seed+epoch only) leaves nothing here ⇒ empty roster.
            let (roster, tombstones) = if c.remaining() > 0 {
                dec_roster(&mut c)?
            } else {
                (Vec::new(), Vec::new())
            };
            Frame::Seed {
                seed,
                epoch,
                roster,
                tombstones,
            }
        }

-            Frame::Seed { seed, epoch }

+            // Trailing + additive roster (REQ-MESH-2): a legacy short Seed body

+            // (seed+epoch only) leaves nothing here ⇒ empty roster.

+            let (roster, tombstones) = if c.remaining() > 0 {

+                (Vec::new(), Vec::new())


Add the `dec_roster` helper (near the encoder):


fn enc_abort(reason: u8, retry_after: u64) -> Vec<u8> {

/// Decode the trailing roster block of a `Seed` frame (REQ-MESH-2): a u32 member
/// count + entries, then a u32 tombstone count + entries. Entries carry an empty
/// `subnet` (implicit; the joiner stamps it). Counts are never pre-allocated — a
/// hostile count exhausts the buffer on the first short field and errors
/// "truncated", not a huge allocation. Every field is bounds-checked by [`Cursor`].
fn dec_roster(c: &mut Cursor) -> Result<(Vec<RosterEntry>, Vec<Tombstone>), PairWireError> {
    let member_count = u32::from_be_bytes(c.take(4)?.try_into().expect("took 4"));
    let mut roster = Vec::new();
    for _ in 0..member_count {
        let pubkey_hex = dec_utf8(c.field()?, "roster pubkey")?;
        let label = dec_utf8(c.field()?, "roster label")?;
        let machine_id = dec_utf8(c.field()?, "roster machine_id")?;
        let addr_bytes = c.field()?;
        let address = if addr_bytes.is_empty() {
            None
        } else {
            Some(
                serde_json::from_slice(addr_bytes)
                    .map_err(|_| PairWireError::Protocol("roster address not json".into()))?,
            )
        };
        let last_seen = dec_utf8(c.field()?, "roster last_seen")?;
        let lease_epoch = c.u64()?;
        roster.push(RosterEntry {
            pubkey_hex,
            subnet: String::new(), // implicit — stamped by the joiner on adopt
            label,
            machine_id,
            address,
            last_seen,
            lease_epoch,
        });
    }
    let tomb_count = u32::from_be_bytes(c.take(4)?.try_into().expect("took 4"));
    let mut tombstones = Vec::new();
    for _ in 0..tomb_count {
        let pubkey_hex = dec_utf8(c.field()?, "tombstone pubkey")?;
        let stamp = dec_utf8(c.field()?, "tombstone stamp")?;
        tombstones.push(Tombstone {
            pubkey_hex,
            subnet: String::new(),
            stamp,
        });
    }
    Ok((roster, tombstones))
}

/// Decode a length-prefixed field as UTF-8, mapping a bad encoding to a labelled
/// protocol error (never a panic — the input is attacker-reachable).
fn dec_utf8(bytes: &[u8], what: &str) -> Result<String, PairWireError> {
    String::from_utf8(bytes.to_vec())
        .map_err(|_| PairWireError::Protocol(format!("{what} not utf-8")))
}

fn enc_abort(reason: u8, retry_after: u64) -> Vec<u8> {