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

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