1	//! `subnet.json` — multi-subnet membership + TOTP seed material (ADR-0005/0006, D2c).
2	//!
3	//! A node belongs to *N* subnets (each cryptographically its own TOTP seed + trust
4	//! context — `CONTEXT.md` "subnet membership"), so this one durable file under
5	//! [`identity/`](crate::perch::identity_dir) holds a **collection** of
6	//! [`SubnetRecord`]s, not a single subnet. Node-local, durable, **never git-tracked
7	//! and never synced over the context repo** — it is subnet-joining secret material
8	//! (it replicates only via the pairing/P2P channel, `docs/STORAGE.md`).
9	//!
10	//! ## Seed epoch + rotation (KNOWN-HAZARDS / ADR-0005 #10)
11	//!
12	//! Each record carries a monotonic [`epoch`](SubnetRecord::epoch). Stage-A red-team
13	//! **#10**: *trust-store delete is not revocation* — the seed is replicated to every
14	//! trusted node, so a compromised node can re-pair as a "new" node after a mere
15	//! trust-store delete. The fix is [`rotate_seed`](SubnetStore::rotate_seed): removing
16	//! a node **rotates the subnet seed and bumps the epoch**, so the old seed computes
17	//! the wrong TOTP code and the [transcript](../../../spt_net/net/pairing/transcript)
18	//! (which binds the epoch) rejects an old-epoch rejoin. The epoch is the
19	//! responder-authoritative value the pairing ceremony binds.
20	//!
21	//! ## `(subnet, user)`-ready shape (cross-user seam)
22	//!
23	//! The optional [`user`](SubnetRecord::user) field is the forward seam for the
24	//! cross-user model (`CONTEXT.md` "Cross-user seam"): v1 has one implicit user
25	//! (`None`); when cross-user lands, the seed key generalizes to `(subnet, user)` with
26	//! no re-architecture — force-unlinking a whole user's node-group becomes dropping
27	//! that user's records.
28	//
29	// [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
30
31	use serde::{Deserialize, Serialize};
32	use spt_proto::identity::Identity;
33	use std::path::Path;
34
35	use crate::atomic::atomic_write_string;
36	use crate::perch::subnet_file;
37
38	/// TOTP seed length in bytes (160-bit, RFC 4226/6238). The crypto consumer
39	/// (`spt-net` `pairing::totp`) wraps these bytes; this layer stores them.
40	pub const TOTP_SEED_LEN: usize = 20;
41
42	/// Failures operating on the subnet store.
43	#[derive(Debug, PartialEq, Eq)]
44	pub enum SubnetError {
45	    /// `create_subnet` for a name (within the same user scope) that already exists.
46	    Exists(String),
47	    /// An operation named a subnet not in the store.
48	    NotFound(String),
49	    /// Reading/writing the backing file failed (message preserved).
50	    Io(String),
51	}
52
53	impl std::fmt::Display for SubnetError {
54	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55	        match self {
56	            SubnetError::Exists(n) => write!(f, "subnet already exists: {n}"),
57	            SubnetError::NotFound(n) => write!(f, "subnet not found: {n}"),
58	            SubnetError::Io(e) => write!(f, "subnet store io: {e}"),
59	        }
60	    }
61	}
62
63	impl std::error::Error for SubnetError {}
64
65	/// One subnet the node belongs to: its human name, the durable TOTP seed (hex), the
66	/// seed epoch, and (forward seam) the owning user.
67	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68	pub struct SubnetRecord {
69	    /// Human subnet label (R-PAIR-4) — what the user types when pairing.
70	    pub name: String,
71	    /// The 20-byte TOTP seed, lowercase hex. The subnet secret; never synced.
72	    pub seed_hex: String,
73	    /// Monotonic seed generation (ADR-0005 #10). Bumped on every
74	    /// [`rotate_seed`](SubnetStore::rotate_seed); bound into the pairing transcript.
75	    pub epoch: u64,
76	    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
77	    #[serde(skip_serializing_if = "Option::is_none")]
78	    pub user: Option<String>,
79	    /// Creation stamp (epoch-seconds string; opaque).
80	    pub created: String,
81	    /// The subnet's `hide_new_endpoints` policy (ADR-0006 §6), **captured at join
82	    /// time** into this node's record. When ON, endpoints default to hidden
83	    /// (= excluded: not advertised AND not routable) in this subnet unless a
84	    /// per-`(endpoint, subnet)` override forces them visible
85	    /// ([`VisibilityStore::hidden`](crate::visibility::VisibilityStore::hidden)).
86	    /// Ships OFF (visible). Serde-defaulted so pre-D3e `subnet.json` files load.
87	    // [impl->REQ-INST-12]
88	    #[serde(default)]
89	    pub hide_new_endpoints: bool,
90	}
91
92	impl SubnetRecord {
93	    /// Decode the seed to raw bytes for the crypto layer. `None` if the stored hex is
94	    /// malformed or the wrong length (defensive — the store always writes valid hex).
95	    pub fn seed_bytes(&self) -> Option<[u8; TOTP_SEED_LEN]> {
96	        decode_hex(&self.seed_hex)
97	    }
98	}
99
100	/// The whole `subnet.json` document: every subnet the node is a member of.
101	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102	pub struct SubnetStore {
103	    /// All subnets this node belongs to (multi-subnet — N seeds).
104	    #[serde(default)]
105	    pub subnets: Vec<SubnetRecord>,
106	}
107
108	impl SubnetStore {
109	    /// Load the node's subnet store from the canonical [`subnet_file`]. A missing file
110	    /// is the empty store (an unpaired node) — never an error.
111	    pub fn load() -> SubnetStore {
112	        Self::load_from(&subnet_file())
113	    }
114
115	    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable
116	    /// or corrupt file also degrades to empty rather than panicking (a corrupt secret
117	    /// file should not brick the node — re-provision via pairing).
118	    pub fn load_from(path: &Path) -> SubnetStore {
119	        match std::fs::read_to_string(path) {
120	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
121	            Err(_) => SubnetStore::default(),
122	        }
123	    }
124
125	    /// Persist to the canonical [`subnet_file`], creating `identity/` if needed.
126	    pub fn save(&self) -> Result<(), SubnetError> {
127	        self.save_to(&subnet_file())
128	    }
129
130	    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
131	    /// concurrent reader never sees a torn secret file (the [`atomic`](crate::atomic)
132	    /// invariant).
133	    pub fn save_to(&self, path: &Path) -> Result<(), SubnetError> {
134	        if let Some(parent) = path.parent() {
135	            std::fs::create_dir_all(parent).map_err(|e| SubnetError::Io(e.to_string()))?;
136	        }
137	        let json =
138	            serde_json::to_string_pretty(self).map_err(|e| SubnetError::Io(e.to_string()))?;
139	        atomic_write_string(path, &json).map_err(|e| SubnetError::Io(e.to_string()))
140	    }
141
142	    /// Find a subnet by name (within the implicit-user scope).
143	    pub fn find(&self, name: &str) -> Option<&SubnetRecord> {
144	        self.subnets.iter().find(|s| s.name == name)
145	    }
146
147	    /// Create a new subnet with a freshly minted seed at epoch 1 (the "create-new"
148	    /// pairing path — this node becomes the sole seed-holder). Errors if the name
149	    /// already exists. Returns the new record.
150	    ///
151	    /// This is **subnet naming on first pairing** (REQ-PAIR-4 / ADR-0006): the subnet is
152	    /// named at creation ("naming moves to link-start"); the name then rides every
153	    /// subsequent pairing ceremony (the joiner types it; the transcript binds it).
154	    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION] [impl->REQ-PAIR-4]
155	    pub fn create_subnet(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
156	        if self.find(name).is_some() {
157	            return Err(SubnetError::Exists(name.to_string()));
158	        }
159	        self.subnets.push(SubnetRecord {
160	            name: name.to_string(),
161	            seed_hex: encode_hex(&random_seed()),
162	            epoch: 1,
163	            user: None,
164	            created: now_stamp(),
165	            hide_new_endpoints: false,
166	        });
167	        Ok(self.subnets.last().expect("just pushed"))
168	    }
169
170	    /// Add a subnet whose seed was learned by **joining** an existing subnet (the seed
171	    /// and its current epoch arrive over the pairing channel from a seed-holder).
172	    /// Errors if the name already exists.
173	    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
174	    pub fn add_joined(
175	        &mut self,
176	        name: &str,
177	        seed: [u8; TOTP_SEED_LEN],
178	        epoch: u64,
179	    ) -> Result<&SubnetRecord, SubnetError> {
180	        if self.find(name).is_some() {
181	            return Err(SubnetError::Exists(name.to_string()));
182	        }
183	        self.subnets.push(SubnetRecord {
184	            name: name.to_string(),
185	            seed_hex: encode_hex(&seed),
186	            epoch,
187	            user: None,
188	            created: now_stamp(),
189	            hide_new_endpoints: false,
190	        });
191	        Ok(self.subnets.last().expect("just pushed"))
192	    }
193
194	    /// Rotate a subnet's seed and bump its epoch (ADR-0005 #10 — the node-removal
195	    /// response). The old seed can no longer compute a valid current code, and the
196	    /// epoch bump means even a captured old transcript won't bind. The new seed must
197	    /// then be redistributed to the *remaining* trusted nodes over the pairing
198	    /// channel; callers pair node-removal (trust-store delete) **with** this rotation,
199	    /// because a trust-delete alone is not revocation.
200	    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
201	    pub fn rotate_seed(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
202	        let rec = self
203	            .subnets
204	            .iter_mut()
205	            .find(|s| s.name == name)
206	            .ok_or_else(|| SubnetError::NotFound(name.to_string()))?;
207	        rec.seed_hex = encode_hex(&random_seed());
208	        rec.epoch += 1;
209	        Ok(rec)
210	    }
211
212	    /// Set a subnet's `hide_new_endpoints` policy (ADR-0006 §6). The flag is
213	    /// *captured at join time* in the sense that it lives on this node's join-created
214	    /// record; flipping it later only changes the default for endpoints without an
215	    /// explicit per-`(endpoint, subnet)` override.
216	    // [impl->REQ-INST-12]
217	    pub fn set_hide_new_endpoints(&mut self, name: &str, hide: bool) -> Result<(), SubnetError> {
218	        let rec = self
219	            .subnets
220	            .iter_mut()
221	            .find(|s| s.name == name)
222	            .ok_or_else(|| SubnetError::NotFound(name.to_string()))?;
223	        rec.hide_new_endpoints = hide;
224	        Ok(())
225	    }
226
227	    /// Remove a subnet entirely (the node leaves it). Returns whether a record was
228	    /// removed. (Distinct from [`rotate_seed`]: leaving a subnet drops our own
229	    /// membership; rotating evicts *another* node from a subnet we keep.)
230	    pub fn remove(&mut self, name: &str) -> bool {
231	        let before = self.subnets.len();
232	        self.subnets.retain(|s| s.name != name);
233	        self.subnets.len() != before
234	    }
235	}
236
237	/// Mint 20 cryptographically-random seed bytes. Reuses spt-proto's Ed25519 seed
238	/// generator (OsRng) as the CSPRNG — the same no-extra-`rand`-dependency trick used
239	/// elsewhere — taking the first [`TOTP_SEED_LEN`] of its 32 random bytes.
240	fn random_seed() -> [u8; TOTP_SEED_LEN] {
241	    let mut out = [0u8; TOTP_SEED_LEN];
242	    out.copy_from_slice(&Identity::generate().seed()[..TOTP_SEED_LEN]);
243	    out
244	}
245
246	/// Lowercase-hex encode bytes (dependency-free; small fixed inputs).
247	fn encode_hex(bytes: &[u8]) -> String {
248	    bytes.iter().map(|b| format!("{b:02x}")).collect()
249	}
250
251	/// Decode lowercase/uppercase hex into a fixed-length seed. `None` on bad length or
252	/// non-hex digits.
253	fn decode_hex(s: &str) -> Option<[u8; TOTP_SEED_LEN]> {
254	    if s.len() != TOTP_SEED_LEN * 2 {
255	        return None;
256	    }
257	    let mut out = [0u8; TOTP_SEED_LEN];
258	    for (i, slot) in out.iter_mut().enumerate() {
259	        *slot = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
260	    }
261	    Some(out)
262	}
263
264	/// Current unix-seconds as an opaque stamp string.
265	fn now_stamp() -> String {
266	    std::time::SystemTime::now()
267	        .duration_since(std::time::UNIX_EPOCH)
268	        .map(|d| d.as_secs().to_string())
269	        .unwrap_or_else(|_| "0".to_string())
270	}
271
272	#[cfg(test)]
273	mod tests {
274	    use super::*;
275
276	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] a created subnet starts at epoch 1 with a
277	    // 20-byte seed that decodes cleanly; a second create of the same name is rejected.
278	    #[test]
279	    fn create_subnet_starts_at_epoch_one_unique_name() {
280	        let mut store = SubnetStore::default();
281	        let rec = store.create_subnet("home").expect("create");
282	        assert_eq!(rec.epoch, 1);
283	        assert_eq!(rec.name, "home");
284	        assert!(rec.seed_bytes().is_some(), "seed hex decodes to 20 bytes");
285	        assert_eq!(
286	            store.create_subnet("home").unwrap_err(),
287	            SubnetError::Exists("home".into())
288	        );
289	    }
290
291	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] rotation is the node-removal response:
292	    // the epoch bumps and the seed CHANGES, so an old-seed/old-epoch node can no
293	    // longer compute a valid code (the transcript binds the epoch). Trust-delete
294	    // alone would leave the seed intact — this is what makes removal real revocation.
295	    #[test]
296	    fn rotate_bumps_epoch_and_changes_seed() {
297	        let mut store = SubnetStore::default();
298	        let before = store.create_subnet("home").expect("create").clone();
299	        let after = store.rotate_seed("home").expect("rotate").clone();
300	        assert_eq!(after.epoch, before.epoch + 1, "epoch bumps");
301	        assert_ne!(after.seed_hex, before.seed_hex, "seed material rotates");
302	        assert_eq!(after.name, before.name, "name preserved");
303	        // Rotating an unknown subnet is an error, not a silent no-op.
304	        assert_eq!(
305	            store.rotate_seed("ghost").unwrap_err(),
306	            SubnetError::NotFound("ghost".into())
307	        );
308	    }
309
310	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] a joined subnet stores the seed + epoch
311	    // learned from the seed-holder verbatim (so a node that joined post-rotation is
312	    // on the current epoch, not epoch 1).
313	    #[test]
314	    fn add_joined_preserves_seed_and_epoch() {
315	        let mut store = SubnetStore::default();
316	        let seed = [7u8; TOTP_SEED_LEN];
317	        let rec = store.add_joined("work", seed, 5).expect("join").clone();
318	        assert_eq!(rec.epoch, 5);
319	        assert_eq!(rec.seed_bytes(), Some(seed));
320	        assert_eq!(
321	            store.add_joined("work", seed, 6).unwrap_err(),
322	            SubnetError::Exists("work".into())
323	        );
324	    }
325
326	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] the store round-trips through the file
327	    // atomically (multi-subnet preserved), and a rotation persists across reload —
328	    // the epoch survives, so revocation is durable.
329	    #[test]
330	    fn store_persists_multi_subnet_and_rotation() {
331	        let dir = tempfile::tempdir().unwrap();
332	        let path = dir.path().join("identity").join("subnet.json");
333
334	        let mut store = SubnetStore::default();
335	        store.create_subnet("home").unwrap();
336	        store.create_subnet("work").unwrap();
337	        store.rotate_seed("home").unwrap();
338	        store.save_to(&path).unwrap();
339
340	        let reloaded = SubnetStore::load_from(&path);
341	        assert_eq!(reloaded.subnets.len(), 2, "multi-subnet preserved");
342	        assert_eq!(
343	            reloaded.find("home").unwrap().epoch,
344	            2,
345	            "rotation durable across reload"
346	        );
347	        assert_eq!(reloaded.find("work").unwrap().epoch, 1);
348	    }
349
350	    // [unit->REQ-INST-12] hide_new_endpoints ships OFF, a pre-D3e subnet.json (no
351	    // field) loads as OFF (serde default), and the setter flips + persists it.
352	    #[test]
353	    fn hide_new_endpoints_defaults_off_and_persists() {
354	        let mut store = SubnetStore::default();
355	        assert!(
356	            !store.create_subnet("home").unwrap().hide_new_endpoints,
357	            "ships OFF"
358	        );
359
360	        // Legacy record without the field parses as visible-by-default.
361	        let legacy = r#"{"subnets":[{"name":"old","seed_hex":"00","epoch":1,"created":"0"}]}"#;
362	        let parsed: SubnetStore = serde_json::from_str(legacy).unwrap();
363	        assert!(
364	            !parsed.find("old").unwrap().hide_new_endpoints,
365	            "legacy loads OFF"
366	        );
367
368	        let dir = tempfile::tempdir().unwrap();
369	        let path = dir.path().join("subnet.json");
370	        store.set_hide_new_endpoints("home", true).unwrap();
371	        assert_eq!(
372	            store.set_hide_new_endpoints("ghost", true).unwrap_err(),
373	            SubnetError::NotFound("ghost".into())
374	        );
375	        store.save_to(&path).unwrap();
376	        assert!(
377	            SubnetStore::load_from(&path)
378	                .find("home")
379	                .unwrap()
380	                .hide_new_endpoints,
381	            "flag durable across reload"
382	        );
383	    }
384
385	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] an absent or corrupt file degrades to an
386	    // empty store, never a panic — a damaged secret file must not brick the node
387	    // (re-provision via pairing).
388	    #[test]
389	    fn missing_or_corrupt_file_is_empty_store() {
390	        let dir = tempfile::tempdir().unwrap();
391	        let absent = dir.path().join("nope.json");
392	        assert!(SubnetStore::load_from(&absent).subnets.is_empty());
393
394	        let corrupt = dir.path().join("corrupt.json");
395	        std::fs::write(&corrupt, b"{ not json").unwrap();
396	        assert!(SubnetStore::load_from(&corrupt).subnets.is_empty());
397	    }
398
399	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] remove drops our membership and reports
400	    // whether anything was removed.
401	    #[test]
402	    fn remove_drops_membership() {
403	        let mut store = SubnetStore::default();
404	        store.create_subnet("home").unwrap();
405	        assert!(store.remove("home"));
406	        assert!(!store.remove("home"), "second remove is a no-op false");
407	        assert!(store.find("home").is_none());
408	    }
409
410	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] minted seeds are random (two creates
411	    // differ) — rotation actually changes key material, not a constant.
412	    #[test]
413	    fn minted_seeds_are_random() {
414	        let mut a = SubnetStore::default();
415	        let mut b = SubnetStore::default();
416	        let sa = a.create_subnet("x").unwrap().seed_hex.clone();
417	        let sb = b.create_subnet("x").unwrap().seed_hex.clone();
418	        assert_ne!(sa, sb);
419	    }
420
421	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] hex round-trips bytes exactly and rejects
422	    // bad length / non-hex.
423	    #[test]
424	    fn hex_round_trips_and_rejects_bad_input() {
425	        let bytes = [0xABu8; TOTP_SEED_LEN];
426	        assert_eq!(decode_hex(&encode_hex(&bytes)), Some(bytes));
427	        assert_eq!(decode_hex("abcd"), None, "wrong length");
428	        assert_eq!(
429	            decode_hex(&"zz".repeat(TOTP_SEED_LEN)),
430	            None,
431	            "non-hex digits"
432	        );
433	    }
434	}
435

//! `subnet.json` — multi-subnet membership + TOTP seed material (ADR-0005/0006, D2c).
//!
//! A node belongs to *N* subnets (each cryptographically its own TOTP seed + trust
//! context — `CONTEXT.md` "subnet membership"), so this one durable file under
//! [`identity/`](crate::perch::identity_dir) holds a **collection** of
//! [`SubnetRecord`]s, not a single subnet. Node-local, durable, **never git-tracked
//! and never synced over the context repo** — it is subnet-joining secret material
//! (it replicates only via the pairing/P2P channel, `docs/STORAGE.md`).
//!
//! ## Seed epoch + rotation (KNOWN-HAZARDS / ADR-0005 #10)
//!
//! Each record carries a monotonic [`epoch`](SubnetRecord::epoch). Stage-A red-team
//! **#10**: *trust-store delete is not revocation* — the seed is replicated to every
//! trusted node, so a compromised node can re-pair as a "new" node after a mere
//! trust-store delete. The fix is [`rotate_seed`](SubnetStore::rotate_seed): removing
//! a node **rotates the subnet seed and bumps the epoch**, so the old seed computes
//! the wrong TOTP code and the [transcript](../../../spt_net/net/pairing/transcript)
//! (which binds the epoch) rejects an old-epoch rejoin. The epoch is the
//! responder-authoritative value the pairing ceremony binds.
//!
//! ## `(subnet, user)`-ready shape (cross-user seam)
//!
//! The optional [`user`](SubnetRecord::user) field is the forward seam for the
//! cross-user model (`CONTEXT.md` "Cross-user seam"): v1 has one implicit user
//! (`None`); when cross-user lands, the seed key generalizes to `(subnet, user)` with
//! no re-architecture — force-unlinking a whole user's node-group becomes dropping
//! that user's records.
//
// [impl->REQ-HAZARD-PAIR-SEED-ROTATION]

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

use crate::atomic::atomic_write_string;
use crate::perch::subnet_file;

/// TOTP seed length in bytes (160-bit, RFC 4226/6238). The crypto consumer
/// (`spt-net` `pairing::totp`) wraps these bytes; this layer stores them.
pub const TOTP_SEED_LEN: usize = 20;

/// Failures operating on the subnet store.
#[derive(Debug, PartialEq, Eq)]
pub enum SubnetError {
    /// `create_subnet` for a name (within the same user scope) that already exists.
    Exists(String),
    /// An operation named a subnet not in the store.
    NotFound(String),
    /// Reading/writing the backing file failed (message preserved).
    Io(String),
}

impl std::fmt::Display for SubnetError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SubnetError::Exists(n) => write!(f, "subnet already exists: {n}"),
            SubnetError::NotFound(n) => write!(f, "subnet not found: {n}"),
            SubnetError::Io(e) => write!(f, "subnet store io: {e}"),
        }
    }
}

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

/// One subnet the node belongs to: its human name, the durable TOTP seed (hex), the
/// seed epoch, and (forward seam) the owning user.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubnetRecord {
    /// Human subnet label (R-PAIR-4) — what the user types when pairing.
    pub name: String,
    /// The 20-byte TOTP seed, lowercase hex. The subnet secret; never synced.
    pub seed_hex: String,
    /// Monotonic seed generation (ADR-0005 #10). Bumped on every
    /// [`rotate_seed`](SubnetStore::rotate_seed); bound into the pairing transcript.
    pub epoch: u64,
    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user: Option<String>,
    /// Creation stamp (epoch-seconds string; opaque).
    pub created: String,
    /// The subnet's `hide_new_endpoints` policy (ADR-0006 §6), **captured at join
    /// time** into this node's record. When ON, endpoints default to hidden
    /// (= excluded: not advertised AND not routable) in this subnet unless a
    /// per-`(endpoint, subnet)` override forces them visible
    /// ([`VisibilityStore::hidden`](crate::visibility::VisibilityStore::hidden)).
    /// Ships OFF (visible). Serde-defaulted so pre-D3e `subnet.json` files load.
    // [impl->REQ-INST-12]
    #[serde(default)]
    pub hide_new_endpoints: bool,
}

impl SubnetRecord {
    /// Decode the seed to raw bytes for the crypto layer. `None` if the stored hex is
    /// malformed or the wrong length (defensive — the store always writes valid hex).
    pub fn seed_bytes(&self) -> Option<[u8; TOTP_SEED_LEN]> {
        decode_hex(&self.seed_hex)
    }
}

/// The whole `subnet.json` document: every subnet the node is a member of.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SubnetStore {
    /// All subnets this node belongs to (multi-subnet — N seeds).
    #[serde(default)]
    pub subnets: Vec<SubnetRecord>,
}

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

    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable
    /// or corrupt file also degrades to empty rather than panicking (a corrupt secret
    /// file should not brick the node — re-provision via pairing).
    pub fn load_from(path: &Path) -> SubnetStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => SubnetStore::default(),
        }
    }

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

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

    /// Find a subnet by name (within the implicit-user scope).
    pub fn find(&self, name: &str) -> Option<&SubnetRecord> {
        self.subnets.iter().find(|s| s.name == name)
    }

    /// Create a new subnet with a freshly minted seed at epoch 1 (the "create-new"
    /// pairing path — this node becomes the sole seed-holder). Errors if the name
    /// already exists. Returns the new record.
    ///
    /// This is **subnet naming on first pairing** (REQ-PAIR-4 / ADR-0006): the subnet is
    /// named at creation ("naming moves to link-start"); the name then rides every
    /// subsequent pairing ceremony (the joiner types it; the transcript binds it).
    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION] [impl->REQ-PAIR-4]
    pub fn create_subnet(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
        if self.find(name).is_some() {
            return Err(SubnetError::Exists(name.to_string()));
        }
        self.subnets.push(SubnetRecord {
            name: name.to_string(),
            seed_hex: encode_hex(&random_seed()),
            epoch: 1,
            user: None,
            created: now_stamp(),
            hide_new_endpoints: false,
        });
        Ok(self.subnets.last().expect("just pushed"))
    }

    /// Add a subnet whose seed was learned by **joining** an existing subnet (the seed
    /// and its current epoch arrive over the pairing channel from a seed-holder).
    /// Errors if the name already exists.
    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
    pub fn add_joined(
        &mut self,
        name: &str,
        seed: [u8; TOTP_SEED_LEN],
        epoch: u64,
    ) -> Result<&SubnetRecord, SubnetError> {
        if self.find(name).is_some() {
            return Err(SubnetError::Exists(name.to_string()));
        }
        self.subnets.push(SubnetRecord {
            name: name.to_string(),
            seed_hex: encode_hex(&seed),
            epoch,
            user: None,
            created: now_stamp(),
            hide_new_endpoints: false,
        });
        Ok(self.subnets.last().expect("just pushed"))
    }

    /// Rotate a subnet's seed and bump its epoch (ADR-0005 #10 — the node-removal
    /// response). The old seed can no longer compute a valid current code, and the
    /// epoch bump means even a captured old transcript won't bind. The new seed must
    /// then be redistributed to the *remaining* trusted nodes over the pairing
    /// channel; callers pair node-removal (trust-store delete) **with** this rotation,
    /// because a trust-delete alone is not revocation.
    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
    pub fn rotate_seed(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
        let rec = self
            .subnets
            .iter_mut()
            .find(|s| s.name == name)
            .ok_or_else(|| SubnetError::NotFound(name.to_string()))?;
        rec.seed_hex = encode_hex(&random_seed());
        rec.epoch += 1;
        Ok(rec)
    }

    /// Set a subnet's `hide_new_endpoints` policy (ADR-0006 §6). The flag is
    /// *captured at join time* in the sense that it lives on this node's join-created
    /// record; flipping it later only changes the default for endpoints without an
    /// explicit per-`(endpoint, subnet)` override.
    // [impl->REQ-INST-12]
    pub fn set_hide_new_endpoints(&mut self, name: &str, hide: bool) -> Result<(), SubnetError> {
        let rec = self
            .subnets
            .iter_mut()
            .find(|s| s.name == name)
            .ok_or_else(|| SubnetError::NotFound(name.to_string()))?;
        rec.hide_new_endpoints = hide;
        Ok(())
    }

    /// Remove a subnet entirely (the node leaves it). Returns whether a record was
    /// removed. (Distinct from [`rotate_seed`]: leaving a subnet drops our own
    /// membership; rotating evicts *another* node from a subnet we keep.)
    pub fn remove(&mut self, name: &str) -> bool {
        let before = self.subnets.len();
        self.subnets.retain(|s| s.name != name);
        self.subnets.len() != before
    }
}

/// Mint 20 cryptographically-random seed bytes. Reuses spt-proto's Ed25519 seed
/// generator (OsRng) as the CSPRNG — the same no-extra-`rand`-dependency trick used
/// elsewhere — taking the first [`TOTP_SEED_LEN`] of its 32 random bytes.
fn random_seed() -> [u8; TOTP_SEED_LEN] {
    let mut out = [0u8; TOTP_SEED_LEN];
    out.copy_from_slice(&Identity::generate().seed()[..TOTP_SEED_LEN]);
    out
}

/// Lowercase-hex encode bytes (dependency-free; small fixed inputs).
fn encode_hex(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

/// Decode lowercase/uppercase hex into a fixed-length seed. `None` on bad length or
/// non-hex digits.
fn decode_hex(s: &str) -> Option<[u8; TOTP_SEED_LEN]> {
    if s.len() != TOTP_SEED_LEN * 2 {
        return None;
    }
    let mut out = [0u8; TOTP_SEED_LEN];
    for (i, slot) in out.iter_mut().enumerate() {
        *slot = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
    }
    Some(out)
}

/// Current unix-seconds as an opaque stamp string.
fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

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

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] a created subnet starts at epoch 1 with a
    // 20-byte seed that decodes cleanly; a second create of the same name is rejected.
    #[test]
    fn create_subnet_starts_at_epoch_one_unique_name() {
        let mut store = SubnetStore::default();
        let rec = store.create_subnet("home").expect("create");
        assert_eq!(rec.epoch, 1);
        assert_eq!(rec.name, "home");
        assert!(rec.seed_bytes().is_some(), "seed hex decodes to 20 bytes");
        assert_eq!(
            store.create_subnet("home").unwrap_err(),
            SubnetError::Exists("home".into())
        );
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] rotation is the node-removal response:
    // the epoch bumps and the seed CHANGES, so an old-seed/old-epoch node can no
    // longer compute a valid code (the transcript binds the epoch). Trust-delete
    // alone would leave the seed intact — this is what makes removal real revocation.
    #[test]
    fn rotate_bumps_epoch_and_changes_seed() {
        let mut store = SubnetStore::default();
        let before = store.create_subnet("home").expect("create").clone();
        let after = store.rotate_seed("home").expect("rotate").clone();
        assert_eq!(after.epoch, before.epoch + 1, "epoch bumps");
        assert_ne!(after.seed_hex, before.seed_hex, "seed material rotates");
        assert_eq!(after.name, before.name, "name preserved");
        // Rotating an unknown subnet is an error, not a silent no-op.
        assert_eq!(
            store.rotate_seed("ghost").unwrap_err(),
            SubnetError::NotFound("ghost".into())
        );
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] a joined subnet stores the seed + epoch
    // learned from the seed-holder verbatim (so a node that joined post-rotation is
    // on the current epoch, not epoch 1).
    #[test]
    fn add_joined_preserves_seed_and_epoch() {
        let mut store = SubnetStore::default();
        let seed = [7u8; TOTP_SEED_LEN];
        let rec = store.add_joined("work", seed, 5).expect("join").clone();
        assert_eq!(rec.epoch, 5);
        assert_eq!(rec.seed_bytes(), Some(seed));
        assert_eq!(
            store.add_joined("work", seed, 6).unwrap_err(),
            SubnetError::Exists("work".into())
        );
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] the store round-trips through the file
    // atomically (multi-subnet preserved), and a rotation persists across reload —
    // the epoch survives, so revocation is durable.
    #[test]
    fn store_persists_multi_subnet_and_rotation() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("subnet.json");

        let mut store = SubnetStore::default();
        store.create_subnet("home").unwrap();
        store.create_subnet("work").unwrap();
        store.rotate_seed("home").unwrap();
        store.save_to(&path).unwrap();

        let reloaded = SubnetStore::load_from(&path);
        assert_eq!(reloaded.subnets.len(), 2, "multi-subnet preserved");
        assert_eq!(
            reloaded.find("home").unwrap().epoch,
            2,
            "rotation durable across reload"
        );
        assert_eq!(reloaded.find("work").unwrap().epoch, 1);
    }

    // [unit->REQ-INST-12] hide_new_endpoints ships OFF, a pre-D3e subnet.json (no
    // field) loads as OFF (serde default), and the setter flips + persists it.
    #[test]
    fn hide_new_endpoints_defaults_off_and_persists() {
        let mut store = SubnetStore::default();
        assert!(
            !store.create_subnet("home").unwrap().hide_new_endpoints,
            "ships OFF"
        );

        // Legacy record without the field parses as visible-by-default.
        let legacy = r#"{"subnets":[{"name":"old","seed_hex":"00","epoch":1,"created":"0"}]}"#;
        let parsed: SubnetStore = serde_json::from_str(legacy).unwrap();
        assert!(
            !parsed.find("old").unwrap().hide_new_endpoints,
            "legacy loads OFF"
        );

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("subnet.json");
        store.set_hide_new_endpoints("home", true).unwrap();
        assert_eq!(
            store.set_hide_new_endpoints("ghost", true).unwrap_err(),
            SubnetError::NotFound("ghost".into())
        );
        store.save_to(&path).unwrap();
        assert!(
            SubnetStore::load_from(&path)
                .find("home")
                .unwrap()
                .hide_new_endpoints,
            "flag durable across reload"
        );
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] an absent or corrupt file degrades to an
    // empty store, never a panic — a damaged secret file must not brick the node
    // (re-provision via pairing).
    #[test]
    fn missing_or_corrupt_file_is_empty_store() {
        let dir = tempfile::tempdir().unwrap();
        let absent = dir.path().join("nope.json");
        assert!(SubnetStore::load_from(&absent).subnets.is_empty());

        let corrupt = dir.path().join("corrupt.json");
        std::fs::write(&corrupt, b"{ not json").unwrap();
        assert!(SubnetStore::load_from(&corrupt).subnets.is_empty());
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] remove drops our membership and reports
    // whether anything was removed.
    #[test]
    fn remove_drops_membership() {
        let mut store = SubnetStore::default();
        store.create_subnet("home").unwrap();
        assert!(store.remove("home"));
        assert!(!store.remove("home"), "second remove is a no-op false");
        assert!(store.find("home").is_none());
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] minted seeds are random (two creates
    // differ) — rotation actually changes key material, not a constant.
    #[test]
    fn minted_seeds_are_random() {
        let mut a = SubnetStore::default();
        let mut b = SubnetStore::default();
        let sa = a.create_subnet("x").unwrap().seed_hex.clone();
        let sb = b.create_subnet("x").unwrap().seed_hex.clone();
        assert_ne!(sa, sb);
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] hex round-trips bytes exactly and rejects
    // bad length / non-hex.
    #[test]
    fn hex_round_trips_and_rejects_bad_input() {
        let bytes = [0xABu8; TOTP_SEED_LEN];
        assert_eq!(decode_hex(&encode_hex(&bytes)), Some(bytes));
        assert_eq!(decode_hex("abcd"), None, "wrong length");
        assert_eq!(
            decode_hex(&"zz".repeat(TOTP_SEED_LEN)),
            None,
            "non-hex digits"
        );
    }
}


1	//! `roster.json` — the member roster: a subnet's discovery directory
2	//! (REQ-MESH-2, ADR-0017, Mesh-D3).
3	//!
4	//! A subnet is a **mesh, not a pairing graph** — every member must be able to
5	//! see and reach every other member, including ones it never directly paired
6	//! with. The roster is how: a **node-level union-merge grow-set** where each
7	//! node authors its own [`RosterEntry`] (its pubkey, label, machine_id,
8	//! last-known address, last-seen) stamped with its
9	//! [`lease_epoch`](RosterEntry::lease_epoch) — the same strictly-greater-wins
10	//! lease the registry runs for node labels (the `spt-net` registry's
11	//! `MergeOutcome`). It is **NOT** the seed (the seed never leaves a node except
12	//! over the pairing/seed-proof channel — ADR-0017); a roster entry is
13	//! **forgery-inert** because naming a pubkey you can't seed-proof gets you
14	//! nowhere at the membership gate (D2/D5).
15	//!
16	//! Seeded **in full at pairing** — the seed-holder hands the joiner its whole
17	//! current roster (incl. offline members), folding in the deferred pairing-time
18	//! hostname capture + post-join address seeding. Thereafter it merges over
19	//! seed-proof'd member connections (D4). Node-local, durable, replicated only
20	//! over the pairing/P2P channel, **never git-tracked or context-synced** —
21	//! alongside [`subnet.json`](crate::subnet) under
22	//! [`identity/`](crate::perch::identity_dir). (Mesh-D6 made this the sole
23	//! membership record — the former `peers.json` TOFU trust store is gone.)
24	//!
25	//! ## Removal needs a tombstone
26	//!
27	//! A grow-set cannot delete by omission: drop a member and the next merge
28	//! re-learns it. Removal is a [`Tombstone`] — a per-pubkey revoked marker that
29	//! **propagates** (it is itself a grow-set element), **dominates** the entry
30	//! ([`is_member`](RosterStore::is_member) returns false while it stands),
31	//! **suppresses reinsert** (a later entry-merge re-records the facts but the
32	//! member stays hidden), and is **cleared by a completed re-pair** of that
33	//! pubkey ([`clear_tombstone`](RosterStore::clear_tombstone)).
34	//!
35	//! ### Tombstone-convergence boundary (deliberate D3 scope)
36	//!
37	//! Cross-node convergence of an *un*-tombstone is **not** D3's concern.
38	//! [`clear_tombstone`] is a *local* elevated act (the re-pair). The compromised
39	//! path re-pairs under a **fresh, never-tombstoned key** (the milestone's
40	//! decision 10), so the old tombstone correctly stands forever; the benign
41	//! N-1 re-seed + timeboxed rotation propagation is **Mesh-D7**. D3 tests
42	//! clear-on-repair locally only.
43	//
44	// [impl->REQ-MESH-2]
45
46	use serde::{Deserialize, Serialize};
47	use std::path::Path;
48
49	use crate::atomic::atomic_write_string;
50	use crate::perch::roster_file;
51
52	/// Failures persisting the roster.
53	#[derive(Debug, PartialEq, Eq)]
54	pub enum RosterError {
55	    /// Reading/writing the backing file failed (message preserved).
56	    Io(String),
57	}
58
59	impl std::fmt::Display for RosterError {
60	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61	        match self {
62	            RosterError::Io(e) => write!(f, "roster store io: {e}"),
63	        }
64	    }
65	}
66
67	impl std::error::Error for RosterError {}
68
69	/// The lease decision when merging an entry (mirrors the `spt-net` registry's
70	/// `MergeOutcome` naming — strictly-greater wins).
71	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72	pub enum MergeOutcome {
73	    /// First entry for this `(subnet, pubkey)` — inserted.
74	    Inserted,
75	    /// A strictly-newer `lease_epoch` for an existing member — replaced.
76	    Updated,
77	    /// Equal/older `lease_epoch` (or a tombstoned-but-still-merged fact) — the
78	    /// incoming entry did not advance the lease; nothing changed.
79	    Stale,
80	}
81
82	/// One member of a subnet's roster. Authored by the member itself; merged into
83	/// every other member's roster strictly-greater-`lease_epoch`-wins.
84	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85	pub struct RosterEntry {
86	    /// The member's Ed25519 node pubkey, lowercase hex (the iroh NodeId). The
87	    /// merge key within a subnet, and what the mesh dials/seed-proofs.
88	    pub pubkey_hex: String,
89	    /// The subnet this entry belongs to (a node holds N subnets' rosters in one
90	    /// file, the multi-subnet shape `subnet.json` uses).
91	    pub subnet: String,
92	    /// The member's advertised node label (its OS hostname).
93	    pub label: String,
94	    /// The member's hashed stable machine id (REQ-SUBNET-7 anchor — the re-pair
95	    /// overwrite/tombstone-clear key; never the raw id).
96	    pub machine_id: String,
97	    /// Last-known dialable address — an opaque serde-serialized iroh
98	    /// `EndpointAddr` (this crate never grows an iroh edge; same posture as
99	    /// [`PeerAddrStore`](crate::peeraddrs)). `None` until one is observed.
100	    #[serde(default, skip_serializing_if = "Option::is_none")]
101	    pub address: Option<serde_json::Value>,
102	    /// Last-seen stamp (opaque epoch-seconds string).
103	    pub last_seen: String,
104	    /// The author's `node_label` lease — strictly-greater-wins. Equal/lower on
105	    /// merge ⇒ [`Stale`](MergeOutcome::Stale) (the registry lease, reused).
106	    pub lease_epoch: u64,
107	}
108
109	/// A per-pubkey revoked marker. Removal in a grow-set can only be additive —
110	/// the tombstone is the addition that means "gone."
111	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112	pub struct Tombstone {
113	    /// The revoked member's pubkey (lowercase hex).
114	    pub pubkey_hex: String,
115	    /// The subnet the revocation applies within.
116	    pub subnet: String,
117	    /// When it was written (opaque epoch-seconds string).
118	    pub stamp: String,
119	}
120
121	/// The whole `roster.json` document: every subnet's members + tombstones this
122	/// node knows. A union grow-set — merges are convergent, commutative, idempotent.
123	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
124	pub struct RosterStore {
125	    /// Known members across all subnets (keyed `(subnet, pubkey_hex)`).
126	    #[serde(default)]
127	    pub members: Vec<RosterEntry>,
128	    /// Revocation tombstones across all subnets (keyed `(subnet, pubkey_hex)`).
129	    #[serde(default)]
130	    pub tombstones: Vec<Tombstone>,
131	}
132
133	impl RosterStore {
134	    /// Load from the canonical [`roster_file`]. A missing file is the empty
135	    /// roster (an unpaired node) — never an error.
136	    pub fn load() -> RosterStore {
137	        Self::load_from(&roster_file())
138	    }
139
140	    /// Load from an explicit path (the testable core). Absent ⇒ empty; an
141	    /// unreadable or corrupt file also degrades to empty rather than panicking —
142	    /// a damaged roster must not brick the node (re-pair/merge rebuilds it), the
143	    /// same stance as [`SubnetStore`](crate::subnet).
144	    pub fn load_from(path: &Path) -> RosterStore {
145	        match std::fs::read_to_string(path) {
146	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
147	            Err(_) => RosterStore::default(),
148	        }
149	    }
150
151	    /// Persist to the canonical [`roster_file`], creating `identity/` if needed.
152	    pub fn save(&self) -> Result<(), RosterError> {
153	        self.save_to(&roster_file())
154	    }
155
156	    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write
157	    /// so a concurrent reader never sees a torn roster (the
158	    /// [`atomic`](crate::atomic) invariant).
159	    pub fn save_to(&self, path: &Path) -> Result<(), RosterError> {
160	        if let Some(parent) = path.parent() {
161	            std::fs::create_dir_all(parent).map_err(|e| RosterError::Io(e.to_string()))?;
162	        }
163	        let json = serde_json::to_string_pretty(self).map_err(|e| RosterError::Io(e.to_string()))?;
164	        atomic_write_string(path, &json).map_err(|e| RosterError::Io(e.to_string()))
165	    }
166
167	    /// Find a member entry by `(subnet, pubkey)`.
168	    pub fn find(&self, subnet: &str, pubkey_hex: &str) -> Option<&RosterEntry> {
169	        self.members
170	            .iter()
171	            .find(|m| m.subnet == subnet && m.pubkey_hex == pubkey_hex)
172	    }
173
174	    /// Whether `pubkey_hex` carries a tombstone in `subnet`.
175	    pub fn is_tombstoned(&self, subnet: &str, pubkey_hex: &str) -> bool {
176	        self.tombstones
177	            .iter()
178	            .any(|t| t.subnet == subnet && t.pubkey_hex == pubkey_hex)
179	    }
180
181	    /// The admission predicate **D5's gate consumes**: a present entry that is
182	    /// not tombstoned. Membership = roster-listed ∧ ¬revoked (the seed-proof ∧
183	    /// ¬tombstoned rule's roster half).
184	    // [impl->REQ-MESH-2]
185	    pub fn is_member(&self, subnet: &str, pubkey_hex: &str) -> bool {
186	        self.find(subnet, pubkey_hex).is_some() && !self.is_tombstoned(subnet, pubkey_hex)
187	    }
188
189	    /// Whether `pubkey_hex` is a (non-tombstoned) member of **any** subnet —
190	    /// the cross-subnet gate the serve paths use when the subnet is not in hand
191	    /// (the handshake proves a node id, not which subnet it speaks for). Mesh-D6
192	    /// replaced the former `trust.peers.iter().any(pubkey)` admission with this.
193	    // [impl->REQ-MESH-5]
194	    pub fn is_member_any(&self, pubkey_hex: &str) -> bool {
195	        self.members
196	            .iter()
197	            .any(|m| m.pubkey_hex == pubkey_hex && !self.is_tombstoned(&m.subnet, pubkey_hex))
198	    }
199
200	    /// The **visible** members of a subnet (entry present ∧ ¬tombstoned) — what
201	    /// the mesh dials by.
202	    pub fn members_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a RosterEntry> {
203	        self.members
204	            .iter()
205	            .filter(move |m| m.subnet == subnet && !self.is_tombstoned(subnet, &m.pubkey_hex))
206	    }
207
208	    /// Author/refresh **this node's own** entry. A node is authoritative for its
209	    /// own pubkey, so this overwrites unconditionally (the caller passes its
210	    /// current `lease_epoch`); lease ordering only governs how *others* merge it.
211	    // [impl->REQ-MESH-2]
212	    #[allow(clippy::too_many_arguments)]
213	    pub fn upsert_self(
214	        &mut self,
215	        subnet: &str,
216	        pubkey_hex: &str,
217	        label: &str,
218	        machine_id: &str,
219	        address: Option<serde_json::Value>,
220	        last_seen: &str,
221	        lease_epoch: u64,
222	    ) {
223	        let entry = RosterEntry {
224	            pubkey_hex: pubkey_hex.to_string(),
225	            subnet: subnet.to_string(),
226	            label: label.to_string(),
227	            machine_id: machine_id.to_string(),
228	            address,
229	            last_seen: last_seen.to_string(),
230	            lease_epoch,
231	        };
232	        match self
233	            .members
234	            .iter_mut()
235	            .find(|m| m.subnet == subnet && m.pubkey_hex == pubkey_hex)
236	        {
237	            Some(slot) => *slot = entry,
238	            None => self.members.push(entry),
239	        }
240	    }
241
242	    /// Merge one entry authored by **another** node: strictly-greater
243	    /// `lease_epoch` wins. A new `(subnet, pubkey)` is [`Inserted`]; a
244	    /// strictly-newer lease is [`Updated`]; equal/older is [`Stale`] (no
245	    /// clobber). A tombstone does **not** block recording the freshest facts
246	    /// (convergence) — it gates [`is_member`]/[`members_in`], not the merge.
247	    ///
248	    /// [`Inserted`]: MergeOutcome::Inserted
249	    /// [`Updated`]: MergeOutcome::Updated
250	    /// [`Stale`]: MergeOutcome::Stale
251	    // [impl->REQ-MESH-2]
252	    pub fn merge_entry(&mut self, incoming: RosterEntry) -> MergeOutcome {
253	        match self
254	            .members
255	            .iter_mut()
256	            .find(|m| m.subnet == incoming.subnet && m.pubkey_hex == incoming.pubkey_hex)
257	        {
258	            Some(existing) => {
259	                if incoming.lease_epoch > existing.lease_epoch {
260	                    *existing = incoming;
261	                    MergeOutcome::Updated
262	                } else {
263	                    MergeOutcome::Stale
264	                }
265	            }
266	            None => {
267	                self.members.push(incoming);
268	                MergeOutcome::Inserted
269	            }
270	        }
271	    }
272
273	    /// Record a tombstone (idempotent — re-tombstoning is a no-op). Dominates
274	    /// the member's entry and suppresses any later reinsert until cleared.
275	    // [impl->REQ-MESH-2]
276	    pub fn tombstone(&mut self, subnet: &str, pubkey_hex: &str, stamp: &str) {
277	        if !self.is_tombstoned(subnet, pubkey_hex) {
278	            self.tombstones.push(Tombstone {
279	                pubkey_hex: pubkey_hex.to_string(),
280	                subnet: subnet.to_string(),
281	                stamp: stamp.to_string(),
282	            });
283	        }
284	    }
285
286	    /// **Clear-on-repair**: drop the tombstone for `(subnet, pubkey)` — the
287	    /// deliberate elevated act a completed re-pair performs. Returns whether one
288	    /// was cleared. (A local op; see the module's convergence-boundary note.)
289	    // [impl->REQ-MESH-2]
290	    pub fn clear_tombstone(&mut self, subnet: &str, pubkey_hex: &str) -> bool {
291	        let before = self.tombstones.len();
292	        self.tombstones
293	            .retain(|t| !(t.subnet == subnet && t.pubkey_hex == pubkey_hex));
294	        self.tombstones.len() != before
295	    }
296
297	    /// Union-merge a whole other roster into this one: every entry through
298	    /// [`merge_entry`] (strictly-greater-wins), every tombstone unioned
299	    /// (dedup). The grow-set operation — commutative and idempotent.
300	    // [impl->REQ-MESH-2]
301	    pub fn merge(&mut self, other: &RosterStore) {
302	        for e in &other.members {
303	            self.merge_entry(e.clone());
304	        }
305	        for t in &other.tombstones {
306	            self.tombstone(&t.subnet, &t.pubkey_hex, &t.stamp);
307	        }
308	    }
309
310	    /// The slice handed to a joiner at pairing (or a peer on connect): every
311	    /// member entry **and** tombstone for one subnet (offline members included —
312	    /// persists-through-silence). The receiver merges it.
313	    // [impl->REQ-MESH-2]
314	    pub fn roster_for(&self, subnet: &str) -> (Vec<RosterEntry>, Vec<Tombstone>) {
315	        let members = self
316	            .members
317	            .iter()
318	            .filter(|m| m.subnet == subnet)
319	            .cloned()
320	            .collect();
321	        let tombstones = self
322	            .tombstones
323	            .iter()
324	            .filter(|t| t.subnet == subnet)
325	            .cloned()
326	            .collect();
327	        (members, tombstones)
328	    }
329	}
330
331	#[cfg(test)]
332	mod tests {
333	    use super::*;
334
335	    fn entry(subnet: &str, pk: &str, lease: u64) -> RosterEntry {
336	        RosterEntry {
337	            pubkey_hex: pk.to_string(),
338	            subnet: subnet.to_string(),
339	            label: format!("host-{pk}"),
340	            machine_id: format!("mid-{pk}"),
341	            address: None,
342	            last_seen: "100".to_string(),
343	            lease_epoch: lease,
344	        }
345	    }
346
347	    // [unit->REQ-MESH-2] the lease: a strictly-greater lease_epoch updates an
348	    // existing member; an equal or lower one is Stale and does NOT clobber the
349	    // newer facts; a never-seen pubkey is Inserted. The node_label lease, reused.
350	    #[test]
351	    fn merge_entry_is_strictly_greater_lease_wins() {
352	        let mut r = RosterStore::default();
353	        assert_eq!(r.merge_entry(entry("home", "aa", 5)), MergeOutcome::Inserted);
354
355	        // Lower lease loses (Stale) — the stored lease stays at 5.
356	        assert_eq!(r.merge_entry(entry("home", "aa", 3)), MergeOutcome::Stale);
357	        assert_eq!(r.find("home", "aa").unwrap().lease_epoch, 5);
358
359	        // Equal lease is Stale too (strictly-greater, not >=).
360	        assert_eq!(r.merge_entry(entry("home", "aa", 5)), MergeOutcome::Stale);
361
362	        // Strictly greater wins.
363	        let mut newer = entry("home", "aa", 6);
364	        newer.label = "renamed".into();
365	        assert_eq!(r.merge_entry(newer), MergeOutcome::Updated);
366	        assert_eq!(r.find("home", "aa").unwrap().lease_epoch, 6);
367	        assert_eq!(r.find("home", "aa").unwrap().label, "renamed");
368	    }
369
370	    // [unit->REQ-MESH-2] union convergence: merge is commutative (A∪B == B∪A on
371	    // the winning-lease facts) and idempotent (merging twice == once). The
372	    // grow-set CRDT property the mesh relies on.
373	    #[test]
374	    fn merge_is_commutative_and_idempotent() {
375	        let mut a = RosterStore::default();
376	        a.merge_entry(entry("home", "aa", 2));
377	        a.merge_entry(entry("home", "bb", 1));
378
379	        let mut b = RosterStore::default();
380	        b.merge_entry(entry("home", "bb", 9)); // bb newer on b
381	        b.merge_entry(entry("home", "cc", 1));
382
383	        let mut ab = a.clone();
384	        ab.merge(&b);
385	        let mut ba = b.clone();
386	        ba.merge(&a);
387
388	        // Same membership set and same winning leases regardless of order.
389	        for pk in ["aa", "bb", "cc"] {
390	            assert_eq!(
391	                ab.find("home", pk).map(|e| e.lease_epoch),
392	                ba.find("home", pk).map(|e| e.lease_epoch),
393	                "{pk} converges"
394	            );
395	        }
396	        assert_eq!(ab.find("home", "bb").unwrap().lease_epoch, 9, "newer bb wins");
397
398	        // Idempotent: merging b again changes nothing.
399	        let before = ab.members.len();
400	        let lease_sum: u64 = ab.members.iter().map(|e| e.lease_epoch).sum();
401	        ab.merge(&b);
402	        assert_eq!(ab.members.len(), before, "no rows added on re-merge");
403	        let after_sum: u64 = ab.members.iter().map(|e| e.lease_epoch).sum();
404	        assert_eq!(lease_sum, after_sum, "re-merge is a no-op");
405	    }
406
407	    // [unit->REQ-MESH-2] persists-through-silence: an offline member's entry
408	    // survives a merge that never mentions it, AND a save/load round-trip. A
409	    // silent member is not forgotten.
410	    #[test]
411	    fn offline_member_persists_through_silence_and_reload() {
412	        let dir = tempfile::tempdir().unwrap();
413	        let path = dir.path().join("identity").join("roster.json");
414
415	        let mut r = RosterStore::default();
416	        r.merge_entry(entry("home", "offline", 1));
417	        r.merge_entry(entry("home", "online", 1));
418
419	        // A merge from a peer that only knows `online` must NOT drop `offline`.
420	        let mut peer = RosterStore::default();
421	        peer.merge_entry(entry("home", "online", 2));
422	        r.merge(&peer);
423	        assert!(r.find("home", "offline").is_some(), "silent member kept");
424
425	        r.save_to(&path).unwrap();
426	        let reloaded = RosterStore::load_from(&path);
427	        assert!(reloaded.find("home", "offline").is_some(), "survives reload");
428	        assert_eq!(reloaded.find("home", "online").unwrap().lease_epoch, 2);
429	    }
430
431	    // [unit->REQ-MESH-2] the tombstone truth table: DOMINATES (a present entry
432	    // is no longer a member once tombstoned), SUPPRESSES REINSERT (a fresh-lease
433	    // entry-merge after the tombstone re-records facts but membership stays
434	    // false), and CLEARS-ON-REPAIR (clear_tombstone restores membership).
435	    #[test]
436	    fn tombstone_dominates_suppresses_reinsert_and_clears() {
437	        let mut r = RosterStore::default();
438	        r.merge_entry(entry("home", "aa", 1));
439	        assert!(r.is_member("home", "aa"), "listed member");
440
441	        // Dominates: tombstone hides it though the entry is still present.
442	        r.tombstone("home", "aa", "200");
443	        assert!(r.is_tombstoned("home", "aa"));
444	        assert!(!r.is_member("home", "aa"), "tombstone dominates the entry");
445	        assert!(r.find("home", "aa").is_some(), "entry still stored");
446	        assert_eq!(r.members_in("home").count(), 0, "hidden from the dial set");
447
448	        // Suppresses reinsert: a newer-lease merge updates facts but not membership.
449	        assert_eq!(r.merge_entry(entry("home", "aa", 9)), MergeOutcome::Updated);
450	        assert!(!r.is_member("home", "aa"), "reinsert stays suppressed");
451
452	        // Tombstone is idempotent (no duplicate marker).
453	        r.tombstone("home", "aa", "300");
454	        assert_eq!(r.tombstones.len(), 1);
455
456	        // Clears on re-pair: membership restored.
457	        assert!(r.clear_tombstone("home", "aa"));
458	        assert!(r.is_member("home", "aa"), "re-pair restores membership");
459	        assert!(!r.clear_tombstone("home", "aa"), "second clear is no-op false");
460	    }
461
462	    // [unit->REQ-MESH-5] is_member_any: a node present in ≥1 subnet passes the
463	    // cross-subnet serve gate; a tombstone in its only subnet drops it; a node
464	    // still listed in ANOTHER subnet stays admissible; an unknown key fails.
465	    #[test]
466	    fn is_member_any_spans_subnets_and_honors_tombstones() {
467	        let mut r = RosterStore::default();
468	        r.merge_entry(entry("home", "aa", 1));
469	        r.merge_entry(entry("work", "aa", 1));
470	        r.merge_entry(entry("home", "bb", 1));
471	        assert!(r.is_member_any("aa"));
472	        assert!(!r.is_member_any("zz"), "unknown key is no member anywhere");
473
474	        // Tombstone bb's only subnet → not a member anywhere.
475	        r.tombstone("home", "bb", "9");
476	        assert!(!r.is_member_any("bb"), "tombstoned in its only subnet");
477
478	        // Tombstone aa in home only → still a member via work.
479	        r.tombstone("home", "aa", "9");
480	        assert!(r.is_member_any("aa"), "still a member in another subnet");
481	    }
482
483	    // [unit->REQ-MESH-2] tombstones are subnet-scoped and propagate through a
484	    // merge (they are grow-set elements, not local-only).
485	    #[test]
486	    fn tombstones_are_scoped_and_merge() {
487	        let mut r = RosterStore::default();
488	        r.merge_entry(entry("home", "aa", 1));
489	        r.merge_entry(entry("work", "aa", 1));
490
491	        let mut peer = RosterStore::default();
492	        peer.tombstone("home", "aa", "200");
493	        r.merge(&peer);
494
495	        assert!(!r.is_member("home", "aa"), "tombstone propagated via merge");
496	        assert!(r.is_member("work", "aa"), "other subnet's aa untouched");
497	    }
498
499	    // [unit->REQ-MESH-2] upsert_self is authoritative for the node's own entry:
500	    // it overwrites unconditionally (a node always holds the latest truth about
501	    // itself), unlike a peer-authored merge which is lease-gated.
502	    #[test]
503	    fn upsert_self_overwrites_unconditionally() {
504	        let mut r = RosterStore::default();
505	        r.upsert_self("home", "me", "laptop", "mid", None, "100", 5);
506	        // Even a "lower" lease overwrites — self-authoring isn't lease-gated.
507	        r.upsert_self("home", "me", "laptop-renamed", "mid", None, "150", 3);
508	        let me = r.find("home", "me").unwrap();
509	        assert_eq!(me.label, "laptop-renamed");
510	        assert_eq!(me.lease_epoch, 3);
511	        assert_eq!(r.members.len(), 1, "no duplicate self row");
512	    }
513
514	    // [unit->REQ-MESH-2] roster_for projects one subnet's full slice (members +
515	    // tombstones, offline included) — the bundle handed to a joiner at pairing.
516	    #[test]
517	    fn roster_for_projects_one_subnets_slice() {
518	        let mut r = RosterStore::default();
519	        r.merge_entry(entry("home", "aa", 1));
520	        r.merge_entry(entry("home", "bb", 1));
521	        r.tombstone("home", "bb", "200");
522	        r.merge_entry(entry("work", "cc", 1));
523
524	        let (members, tombs) = r.roster_for("home");
525	        assert_eq!(members.len(), 2, "both home entries (incl. tombstoned bb)");
526	        assert_eq!(tombs.len(), 1);
527	        assert!(members.iter().all(|m| m.subnet == "home"));
528	        assert!(r.roster_for("solo").0.is_empty(), "unknown subnet ⇒ empty");
529	    }
530
531	    // [unit->REQ-MESH-2] serde is additive + the store degrades gracefully: a
532	    // legacy roster.json (no `tombstones`, an entry with no `address`) loads, an
533	    // address round-trips, and an absent/corrupt file is the empty store.
534	    #[test]
535	    fn serde_additive_and_degrades_to_empty() {
536	        // Legacy doc: members only, no tombstones array, no address field.
537	        let legacy = r#"{"members":[{"pubkey_hex":"aa","subnet":"home","label":"h","machine_id":"m","last_seen":"1","lease_epoch":1}]}"#;
538	        let parsed: RosterStore = serde_json::from_str(legacy).unwrap();
539	        assert!(parsed.is_member("home", "aa"));
540	        assert!(parsed.tombstones.is_empty());
541	        assert!(parsed.find("home", "aa").unwrap().address.is_none());
542
543	        // Address round-trips as opaque JSON.
544	        let dir = tempfile::tempdir().unwrap();
545	        let path = dir.path().join("roster.json");
546	        let mut r = RosterStore::default();
547	        r.upsert_self(
548	            "home",
549	            "me",
550	            "h",
551	            "m",
552	            Some(serde_json::json!({"addr": "1.2.3.4:5"})),
553	            "1",
554	            1,
555	        );
556	        r.save_to(&path).unwrap();
557	        let back = RosterStore::load_from(&path);
558	        assert_eq!(
559	            back.find("home", "me").unwrap().address,
560	            Some(serde_json::json!({"addr": "1.2.3.4:5"}))
561	        );
562
563	        // Absent + corrupt ⇒ empty, never a panic.
564	        assert!(RosterStore::load_from(&dir.path().join("nope.json"))
565	            .members
566	            .is_empty());
567	        let corrupt = dir.path().join("corrupt.json");
568	        std::fs::write(&corrupt, b"{ not json").unwrap();
569	        assert!(RosterStore::load_from(&corrupt).members.is_empty());
570	    }
571	}
572

//! `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 (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
//! 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) under
//! [`identity/`](crate::perch::identity_dir). (Mesh-D6 made this the sole
//! membership record — the former `peers.json` TOFU trust store is gone.)
//!
//! ## 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 `spt-net` registry's
/// `MergeOutcome` 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).
    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)
    }

    /// Whether `pubkey_hex` is a (non-tombstoned) member of **any** subnet —
    /// the cross-subnet gate the serve paths use when the subnet is not in hand
    /// (the handshake proves a node id, not which subnet it speaks for). Mesh-D6
    /// replaced the former `trust.peers.iter().any(pubkey)` admission with this.
    // [impl->REQ-MESH-5]
    pub fn is_member_any(&self, pubkey_hex: &str) -> bool {
        self.members
            .iter()
            .any(|m| m.pubkey_hex == pubkey_hex && !self.is_tombstoned(&m.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 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");
    }

    // [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-5] is_member_any: a node present in ≥1 subnet passes the
    // cross-subnet serve gate; a tombstone in its only subnet drops it; a node
    // still listed in ANOTHER subnet stays admissible; an unknown key fails.
    #[test]
    fn is_member_any_spans_subnets_and_honors_tombstones() {
        let mut r = RosterStore::default();
        r.merge_entry(entry("home", "aa", 1));
        r.merge_entry(entry("work", "aa", 1));
        r.merge_entry(entry("home", "bb", 1));
        assert!(r.is_member_any("aa"));
        assert!(!r.is_member_any("zz"), "unknown key is no member anywhere");

        // Tombstone bb's only subnet → not a member anywhere.
        r.tombstone("home", "bb", "9");
        assert!(!r.is_member_any("bb"), "tombstoned in its only subnet");

        // Tombstone aa in home only → still a member via work.
        r.tombstone("home", "aa", "9");
        assert!(r.is_member_any("aa"), "still a member in another subnet");
    }

    // [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());
    }
}


Stores understood. Now read consumption: nethost dispatcher + how `proven_subnets` gates serving, plus pump and CLI prune.