1	//! Perch layout — the **single source of truth** for where a perch lives on
2	//! disk. Every reader and writer of a perch path routes through
3	//! [`resolve_perch_path`]; nothing composes perch paths by hand. If the layout
4	//! ever changes, exactly this one module changes (KNOWN-HAZARDS 6.1 / 1.5).
5	//!
6	//! Adapted from sister `perch_path.rs` + `owlery.rs` path primitives
7	//! (ADR-0001), with two clean-room changes for the spt-core model:
8	//! - **Taxonomy:** the sister's Spine/Touch perch kinds are gone (CONTEXT
9	//!   endpoint types are ReadyAgent/LiveAgent/Psyche/Worker/SptNode). A top-level
10	//!   agent perch (ReadyAgent or a LiveAgent's Self) classifies as [`PerchKind::Self_`].
11	//! - **Purity:** path composition performs NO filesystem I/O — every function
12	//!   returns a `PathBuf`. Directory creation is the writer's job (e.g. the spool
13	//!   `create_dir_all`s on open).
14	//!
15	//! Layout invariant:
16	//! - `Self_`            → flat: `owlery/<id>/`
17	//! - `Psyche` / `Worker`→ nested under parent: `owlery/<parent>/nested/<child>/`
18	//!
19	//! The single `nested/` namespace holds both Psyche and Worker children — no
20	//! divergent sibling layouts (the hazard this module exists to prevent).
21
22	use std::path::{Path, PathBuf};
23
24	/// Root of all spt-core on-disk state. Honors `$SPT_HOME`, else the platform
25	/// default (`%LOCALAPPDATA%\spt-core` on Windows, `$HOME/.spt-core` on Unix).
26	/// **Pure** — reads env, composes a path, creates nothing.
27	///
28	/// The default lives under `spt-core/`, **deliberately distinct from the modern
29	/// spt runtime's `spt/` home**, so a dev build never collides with a running
30	/// spt-plugin during the parity build-out. Once spt-core + the spt-plugin reach
31	/// full parity with modern spt this may simply stay `spt-core/` for good; the
32	/// override (`$SPT_HOME`) is the relocation knob either way.
33	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
34	pub fn spt_home() -> PathBuf {
35	    if let Ok(over) = std::env::var("SPT_HOME") {
36	        if !over.is_empty() {
37	            return PathBuf::from(over);
38	        }
39	    }
40	    platform_default_spt_home()
41	}
42
43	#[cfg(windows)]
44	fn platform_default_spt_home() -> PathBuf {
45	    if let Ok(local) = std::env::var("LOCALAPPDATA") {
46	        if !local.is_empty() {
47	            return PathBuf::from(local).join("spt-core");
48	        }
49	    }
50	    let up =
51	        std::env::var("USERPROFILE").expect("LOCALAPPDATA or USERPROFILE must be set on Windows");
52	    PathBuf::from(up)
53	        .join("AppData")
54	        .join("Local")
55	        .join("spt-core")
56	}
57
58	#[cfg(unix)]
59	fn platform_default_spt_home() -> PathBuf {
60	    let home = std::env::var("HOME").expect("HOME must be set on Unix");
61	    PathBuf::from(home).join(".spt-core")
62	}
63
64	/// The owlery directory holding all perches: `<spt_home>/owlery/`.
65	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
66	pub fn owlery_dir() -> PathBuf {
67	    spt_home().join("owlery")
68	}
69
70	/// The synced-mind context tree: `<spt_home>/tracked/` (`docs/STORAGE.md`).
71	/// A git-backed two-tier store (`crate::contextstore`) — **durable, synced
72	/// across nodes** (the backup/migrate set), physically separate from the
73	/// never-synced `perches/` runtime. Pure path composition.
74	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
75	pub fn tracked_dir() -> PathBuf {
76	    spt_home().join("tracked")
77	}
78
79	/// Node-local **durable** identity/security material: `<spt_home>/identity/`
80	/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
81	/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
82	/// material (the node keypair + subnet TOTP seeds). Pure path composition.
83	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
84	pub fn identity_dir() -> PathBuf {
85	    spt_home().join("identity")
86	}
87
88	/// The node's durable Ed25519 identity seed: `<spt_home>/identity/node.key`
89	/// (`docs/STORAGE.md`). The node's stable name on the wire (the iroh EndpointId
90	/// *is* this keypair's public key, D1/D4) — per-node secret material, never
91	/// git-tracked, never synced.
92	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
93	pub fn node_key_file() -> PathBuf {
94	    identity_dir().join("node.key")
95	}
96
97	/// The subnet membership + seed file: `<spt_home>/identity/subnet.json`
98	/// (`docs/STORAGE.md`). One file holding *all* subnets the node belongs to
99	/// (multi-subnet — a node holds N seeds), in a `(subnet, user)`-ready shape.
100	/// Replicated via pairing/P2P, **not** git.
101	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
102	pub fn subnet_file() -> PathBuf {
103	    identity_dir().join("subnet.json")
104	}
105
106	/// The member-roster file: `<spt_home>/identity/roster.json` (REQ-MESH-2,
107	/// ADR-0017). The subnet discovery directory — a node-level union-merge
108	/// grow-set of `(pubkey, label, machine_id, address, last-seen)` per member,
109	/// with per-pubkey tombstones. Node-local, replicated via the
110	/// pairing/seed-proof'd P2P channel, **never** git-tracked/synced (same class
111	/// as `subnet.json`/`peers.json`). NOT the seed.
112	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE] [impl->REQ-MESH-2]
113	pub fn roster_file() -> PathBuf {
114	    identity_dir().join("roster.json")
115	}
116
117	/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single
118	/// strictly-increasing `u64` serving both the registry lease (#8) and sync precedence
119	/// (#7) — never wall-clock. Node-local, never synced (each node owns its own counter).
120	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
121	pub fn epoch_file() -> PathBuf {
122	    identity_dir().join("epoch")
123	}
124
125	/// The per-`(endpoint, subnet)` visibility + sync-membership config file:
126	/// `<spt_home>/identity/visibility.json` (ADR-0006 §5/§6, D3e). The owning node's
127	/// advertisement policy — node-local, never git-tracked/synced (other nodes see only
128	/// its *effect*: presence/absence in the subnet registry).
129	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
130	pub fn visibility_file() -> PathBuf {
131	    identity_dir().join("visibility.json")
132	}
133
134	/// The subnet notification spool: `<spt_home>/identity/notifs.db` (ADR-0007, D8).
135	/// Node-level (a notif targets the *user in a subnet*, not a perch), subnet-tagged
136	/// rows; the *content* replicates across each subnet's nodes via the P2P feed
137	/// (like `subnet.json`'s material — never git).
138	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
139	pub fn notifs_file() -> PathBuf {
140	    identity_dir().join("notifs.db")
141	}
142
143	/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
144	/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
145	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
146	pub fn trust_dir() -> PathBuf {
147	    identity_dir().join("trust")
148	}
149
150	/// The trusted-peers file: `<spt_home>/identity/trust/peers.json` — the TOFU store
151	/// of authorized peer node pubkeys (R-PAIR-2). Node-local, never git-tracked/synced.
152	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
153	pub fn trust_file() -> PathBuf {
154	    trust_dir().join("peers.json")
155	}
156
157	/// The endpoint access whitelist: `<spt_home>/identity/trust/access.json`
158	/// (ADR-0009, REQ-SEC-1). Security material next to the trust store —
159	/// node-local, never git-tracked/synced.
160	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
161	pub fn access_file() -> PathBuf {
162	    trust_dir().join("access.json")
163	}
164
165	/// The recent-outbound correlation log:
166	/// `<spt_home>/identity/trust/recent-outbound.json` — the stateful-firewall
167	/// reply-correlation state the access gate reads (REQ-SEC-1). Node-local,
168	/// never synced.
169	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
170	pub fn recent_outbound_file() -> PathBuf {
171	    trust_dir().join("recent-outbound.json")
172	}
173
174	/// The consent grant store: `<spt_home>/identity/trust/grants.json`
175	/// (CONTEXT §Consent & security gates, REQ-CONSENT-1). Security material next
176	/// to the trust store — node-local, never git-tracked/synced. Default-deny
177	/// (the access whitelist's opposite polarity).
178	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
179	pub fn grants_file() -> PathBuf {
180	    trust_dir().join("grants.json")
181	}
182
183	/// The registered-adapter set: `<spt_home>/adapters/` (`docs/STORAGE.md`,
184	/// REQ-INSTALL-4) — one subdir per adapter (`record.toml` + a manifest copy
185	/// or pointer). Node-local capability (drive/launch), not subnet
186	/// advertisement.
187	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
188	pub fn adapters_dir() -> PathBuf {
189	    spt_home().join("adapters")
190	}
191
192	/// The shells namespace under an owner's Self perch:
193	/// `owlery/<owner>/shells/` (`docs/STORAGE.md` §Shells in the layout, M5-D3a).
194	/// A third namespace beside flat-Self and `nested/` — shell instances are
195	/// **not** perch kinds: [`resolve_perch_path`] never resolves them, and
196	/// [`list_self_perch_ids`] never enumerates them (they sit *inside* an
197	/// owner's dir, invisible to the top-level scan by construction). Pure path
198	/// composition, no I/O.
199	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
200	// [impl->REQ-SHELL-1]
201	pub fn resolve_shells_dir_in(owlery: &Path, owner: &str) -> PathBuf {
202	    owlery.join(owner).join("shells")
203	}
204
205	/// [`resolve_shells_dir_in`] against the canonical [`owlery_dir`].
206	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
207	pub fn resolve_shells_dir(owner: &str) -> PathBuf {
208	    resolve_shells_dir_in(&owlery_dir(), owner)
209	}
210
211	/// **The** shell-instance perch resolver:
212	/// `owlery/<owner>/shells/<adapter>-<n>/`. Every reader/writer of a shell
213	/// perch routes through here — same single-source rule as agent perches
214	/// (KNOWN-HAZARDS 6.1).
215	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
216	// [impl->REQ-SHELL-1]
217	pub fn resolve_shell_perch_path_in(owlery: &Path, owner: &str, shell_id: &str) -> PathBuf {
218	    resolve_shells_dir_in(owlery, owner).join(shell_id)
219	}
220
221	/// [`resolve_shell_perch_path_in`] against the canonical [`owlery_dir`].
222	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
223	pub fn resolve_shell_perch_path(owner: &str, shell_id: &str) -> PathBuf {
224	    resolve_shell_perch_path_in(&owlery_dir(), owner, shell_id)
225	}
226
227	/// Classification of a perch id by its naming convention. Stable, pure, no I/O.
228	///
229	/// - `<x>-psyche` → [`PerchKind::Psyche`]
230	/// - `<x>-w{N}`   → [`PerchKind::Worker`] (`{N}` = one-or-more ASCII digits, `<x>` non-empty)
231	/// - anything else → [`PerchKind::Self_`]
232	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233	pub enum PerchKind {
234	    /// Top-level agent perch (a ReadyAgent, or a LiveAgent's Self).
235	    Self_,
236	    /// A LiveAgent's Psyche companion perch.
237	    Psyche,
238	    /// A subagent perch under a parent LiveAgent.
239	    Worker,
240	}
241
242	impl PerchKind {
243	    /// Classify an id by suffix. Worker is checked FIRST (rightmost `-w{digits}`)
244	    /// so `dunsen-psyche-w1` classifies as `Worker`.
245	    // [impl->REQ-HAZARD-WORKER-PATH]
246	    pub fn classify(id: &str) -> PerchKind {
247	        if is_worker_id(id) {
248	            return PerchKind::Worker;
249	        }
250	        if id.ends_with("-psyche") && id.len() > "-psyche".len() {
251	            return PerchKind::Psyche;
252	        }
253	        PerchKind::Self_
254	    }
255
256	    /// True for kinds nested under a parent (Psyche, Worker); false for Self_.
257	    pub fn nests_under_parent(self) -> bool {
258	        matches!(self, PerchKind::Psyche | PerchKind::Worker)
259	    }
260	}
261
262	/// Worker-id rule: rightmost `-w` whose suffix is one-or-more digits, with a
263	/// non-empty name before it.
264	fn is_worker_id(id: &str) -> bool {
265	    if let Some(pos) = id.rfind("-w") {
266	        if pos == 0 {
267	            return false;
268	        }
269	        let suffix = &id[pos + 2..];
270	        !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())
271	    } else {
272	        false
273	    }
274	}
275
276	/// How the resolver determines the parent id for nested kinds.
277	/// `Explicit` is preferred (caller knows the parent); `Infer` derives it by
278	/// stripping the kind suffix from the child id (legacy reader call sites).
279	#[derive(Debug, Clone)]
280	pub enum ParentHint<'a> {
281	    Explicit(&'a str),
282	    Infer,
283	}
284
285	/// **The** perch-directory resolver. Self_ → flat `owlery/<id>/`;
286	/// Psyche/Worker → `owlery/<parent>/nested/<child>/`. Panic-free: if `Infer`
287	/// cannot derive a parent (id shape doesn't match its kind — should not happen
288	/// since classify+infer agree), falls back to flat for safety.
289	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
290	// [impl->REQ-HAZARD-WORKER-PATH]
291	pub fn resolve_perch_path(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
292	    resolve_perch_path_in(&owlery_dir(), child_id, hint)
293	}
294
295	/// [`resolve_perch_path`] against an **explicit** owlery root rather than the
296	/// process-global [`owlery_dir`]. Same single-source layout logic — this is the
297	/// real resolver and [`resolve_perch_path`] is the canonical-root wrapper. Used
298	/// by callers that already hold an owlery root (e.g. the registry's stale-clean,
299	/// which resolves a row's perch to read its daemon-managed `status`) so they
300	/// resolve consistently with the owlery they operate on, not the global home.
301	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
302	// [impl->REQ-HAZARD-WORKER-PATH]
303	pub fn resolve_perch_path_in(owlery: &Path, child_id: &str, hint: ParentHint<'_>) -> PathBuf {
304	    let kind = PerchKind::classify(child_id);
305	    if !kind.nests_under_parent() {
306	        return owlery.join(child_id);
307	    }
308	    let parent = match hint {
309	        ParentHint::Explicit(p) => p.to_string(),
310	        ParentHint::Infer => match infer_parent(child_id, kind) {
311	            Some(p) => p,
312	            None => return owlery.join(child_id), // defensive flat fallback
313	        },
314	    };
315	    owlery.join(parent).join("nested").join(child_id)
316	}
317
318	/// Derive the parent id of a nested child by stripping its kind suffix.
319	fn infer_parent(id: &str, kind: PerchKind) -> Option<String> {
320	    match kind {
321	        PerchKind::Psyche => id.strip_suffix("-psyche").map(str::to_string),
322	        PerchKind::Worker => {
323	            let pos = id.rfind("-w")?;
324	            if pos == 0 {
325	                return None;
326	            }
327	            let suffix = &id[pos + 2..];
328	            if suffix.is_empty() || !suffix.bytes().all(|b| b.is_ascii_digit()) {
329	                return None;
330	            }
331	            Some(id[..pos].to_string())
332	        }
333	        PerchKind::Self_ => None,
334	    }
335	}
336
337	/// `<resolved_perch>/info.json`.
338	pub fn resolve_info_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
339	    info_file_at(&resolve_perch_path(child_id, hint))
340	}
341
342	/// `<resolved_perch>/spool.db` — the spool the path-aware spool surface opens.
343	pub fn resolve_spool_db(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
344	    resolve_perch_path(child_id, hint).join("spool.db")
345	}
346
347	/// `<resolved_perch>/ready`.
348	pub fn resolve_ready_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
349	    resolve_perch_path(child_id, hint).join("ready")
350	}
351
352	/// `<resolved_perch>/inbox`.
353	pub fn resolve_inbox_dir(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
354	    resolve_perch_path(child_id, hint).join("inbox")
355	}
356
357	/// The activity/idle sentinel filename within a perch dir (spt-core-owned; the
358	/// adapter never names it — MANIFEST §What is NOT in the manifest).
359	pub const IDLE_SENTINEL: &str = ".idle";
360	/// The echo-commune gate sentinel filename (`.more-done`-equiv; the idle
361	/// transition arms it). spt-core-owned.
362	pub const ECHO_GATE_SENTINEL: &str = ".more-done";
363
364	/// `<resolved_perch>/.idle` — the activity/idle sentinel.
365	pub fn resolve_idle_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
366	    resolve_perch_path(child_id, hint).join(IDLE_SENTINEL)
367	}
368
369	/// `<resolved_perch>/.more-done` — the echo-commune gate sentinel. The single
370	/// path source (6.1) shared by the `api` surface (which arms it) and the M2b
371	/// pulse (which reads + clears it).
372	pub fn resolve_echo_gate_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
373	    resolve_perch_path(child_id, hint).join(ECHO_GATE_SENTINEL)
374	}
375
376	/// `<perch_path>/info.json` from an already-resolved path.
377	pub fn info_file_at(perch_path: &Path) -> PathBuf {
378	    perch_path.join("info.json")
379	}
380
381	/// Enumerate the **top-level `Self_` perch ids** in `owlery` — every immediate
382	/// subdirectory that carries an `info.json` (a bound perch). Pure dir scan,
383	/// tolerant: a missing/unreadable owlery, or an entry without `info.json`, is
384	/// silently skipped (never panics, never hard-fails). Nested Psyche/Worker
385	/// perches live under `<self>/nested/` and are deliberately excluded — the
386	/// consent-target resolver wants the user's *agent* sessions, not their hosted
387	/// children. Order is unspecified (the caller ranks by recency).
388	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
389	pub fn list_self_perch_ids(owlery: &Path) -> Vec<String> {
390	    let Ok(entries) = std::fs::read_dir(owlery) else {
391	        return Vec::new();
392	    };
393	    let mut ids = Vec::new();
394	    for entry in entries.flatten() {
395	        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
396	            continue;
397	        }
398	        if !info_file_at(&entry.path()).is_file() {
399	            continue; // a dir without info.json is not a bound perch
400	        }
401	        if let Some(name) = entry.file_name().to_str() {
402	            ids.push(name.to_string());
403	        }
404	    }
405	    ids
406	}
407
408	#[cfg(test)]
409	mod tests {
410	    use super::*;
411	    use std::sync::Mutex;
412
413	    static ENV_LOCK: Mutex<()> = Mutex::new(());
414
415	    struct EnvGuard {
416	        _lock: std::sync::MutexGuard<'static, ()>,
417	        prev: Option<String>,
418	    }
419	    impl EnvGuard {
420	        fn set(tmp: &Path) -> Self {
421	            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
422	            let prev = std::env::var("SPT_HOME").ok();
423	            std::env::set_var("SPT_HOME", tmp);
424	            EnvGuard { _lock: lock, prev }
425	        }
426	    }
427	    impl Drop for EnvGuard {
428	        fn drop(&mut self) {
429	            match &self.prev {
430	                Some(v) => std::env::set_var("SPT_HOME", v),
431	                None => std::env::remove_var("SPT_HOME"),
432	            }
433	        }
434	    }
435
436	    // ---- classification ----
437
438	    // [unit->REQ-HAZARD-WORKER-PATH]
439	    #[test]
440	    fn classification_matrix() {
441	        assert_eq!(PerchKind::classify("dunsen"), PerchKind::Self_);
442	        assert_eq!(PerchKind::classify("dunsen-psyche"), PerchKind::Psyche);
443	        assert_eq!(PerchKind::classify("dunsen-w1"), PerchKind::Worker);
444	        assert_eq!(PerchKind::classify("dunsen-w42"), PerchKind::Worker);
445	        // Worker rule edges → Self_.
446	        assert_eq!(
447	            PerchKind::classify("-w1"),
448	            PerchKind::Self_,
449	            "no parent name"
450	        );
451	        assert_eq!(
452	            PerchKind::classify("dunsen-w"),
453	            PerchKind::Self_,
454	            "no digits"
455	        );
456	        assert_eq!(
457	            PerchKind::classify("dunsen-wa"),
458	            PerchKind::Self_,
459	            "non-digit"
460	        );
461	        // Rightmost -w wins.
462	        assert_eq!(PerchKind::classify("dunsen-psyche-w1"), PerchKind::Worker);
463	        // Dropped sister kinds are now plain Self_ (no Spine/Touch).
464	        assert_eq!(PerchKind::classify("dunsen-spine"), PerchKind::Self_);
465	        assert_eq!(PerchKind::classify("dunsen-touch"), PerchKind::Self_);
466	    }
467
468	    // ---- resolution: one resolver, no divergent layouts ----
469
470	    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] Self_ is flat.
471	    #[test]
472	    fn resolve_self_is_flat() {
473	        let tmp = tempfile::tempdir().unwrap();
474	        let _g = EnvGuard::set(tmp.path());
475	        let owlery = tmp.path().join("owlery");
476	        assert_eq!(
477	            resolve_perch_path("dunsen", ParentHint::Infer),
478	            owlery.join("dunsen")
479	        );
480	    }
481
482	    // [unit->REQ-HAZARD-WORKER-PATH] Psyche + Worker nest under the single
483	    // `nested/` namespace, via both Explicit and Infer.
484	    #[test]
485	    fn resolve_psyche_and_worker_nest_under_parent() {
486	        let tmp = tempfile::tempdir().unwrap();
487	        let _g = EnvGuard::set(tmp.path());
488	        let owlery = tmp.path().join("owlery");
489
490	        let psyche_nested = owlery.join("dunsen").join("nested").join("dunsen-psyche");
491	        assert_eq!(
492	            resolve_perch_path("dunsen-psyche", ParentHint::Infer),
493	            psyche_nested
494	        );
495	        assert_eq!(
496	            resolve_perch_path("dunsen-psyche", ParentHint::Explicit("dunsen")),
497	            psyche_nested
498	        );
499
500	        let worker_nested = owlery.join("dunsen").join("nested").join("dunsen-w42");
501	        assert_eq!(
502	            resolve_perch_path("dunsen-w42", ParentHint::Infer),
503	            worker_nested
504	        );
505	        assert_eq!(
506	            resolve_perch_path("dunsen-w42", ParentHint::Explicit("dunsen")),
507	            worker_nested
508	        );
509	    }
510
511	    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] Explicit parent is trusted
512	    // mechanically (overrides what Infer would derive).
513	    #[test]
514	    fn explicit_parent_wins_over_infer() {
515	        let tmp = tempfile::tempdir().unwrap();
516	        let _g = EnvGuard::set(tmp.path());
517	        let owlery = tmp.path().join("owlery");
518	        assert_eq!(
519	            resolve_perch_path("dunsen-psyche", ParentHint::Explicit("doyle")),
520	            owlery.join("doyle").join("nested").join("dunsen-psyche")
521	        );
522	    }
523
524	    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] child-file helpers all hang off the
525	    // one resolver — spool.db / info.json / ready / inbox share the perch root.
526	    #[test]
527	    fn child_files_route_through_one_resolver() {
528	        let tmp = tempfile::tempdir().unwrap();
529	        let _g = EnvGuard::set(tmp.path());
530	        let root = resolve_perch_path("dunsen-w7", ParentHint::Explicit("dunsen"));
531	        assert_eq!(
532	            resolve_spool_db("dunsen-w7", ParentHint::Explicit("dunsen")),
533	            root.join("spool.db")
534	        );
535	        assert_eq!(
536	            resolve_info_file("dunsen-w7", ParentHint::Explicit("dunsen")),
537	            root.join("info.json")
538	        );
539	        assert_eq!(
540	            resolve_ready_file("dunsen-w7", ParentHint::Explicit("dunsen")),
541	            root.join("ready")
542	        );
543	        assert_eq!(
544	            resolve_inbox_dir("dunsen-w7", ParentHint::Explicit("dunsen")),
545	            root.join("inbox")
546	        );
547	    }
548
549	    // [unit->REQ-UPD-4] list_self_perch_ids returns top-level bound perches only:
550	    // a dir with info.json counts; a dir without it, and nested children, do not;
551	    // a missing owlery yields empty (tolerant).
552	    #[test]
553	    fn list_self_perch_ids_enumerates_bound_top_level() {
554	        let tmp = tempfile::tempdir().unwrap();
555	        let owlery = tmp.path().join("owlery");
556
557	        // Missing owlery → empty, no panic.
558	        assert!(list_self_perch_ids(&owlery).is_empty());
559
560	        // Two bound Self perches.
561	        for id in ["doyle", "ling"] {
562	            let p = owlery.join(id);
563	            std::fs::create_dir_all(&p).unwrap();
564	            std::fs::write(p.join("info.json"), "{}").unwrap();
565	        }
566	        // A dir with no info.json (not a bound perch).
567	        std::fs::create_dir_all(owlery.join("not-a-perch")).unwrap();
568	        // A nested child under doyle — must NOT be enumerated at top level.
569	        let nested = owlery.join("doyle").join("nested").join("doyle-psyche");
570	        std::fs::create_dir_all(&nested).unwrap();
571	        std::fs::write(nested.join("info.json"), "{}").unwrap();
572
573	        let mut ids = list_self_perch_ids(&owlery);
574	        ids.sort();
575	        assert_eq!(ids, vec!["doyle".to_string(), "ling".to_string()]);
576	    }
577
578	    // [unit->REQ-SHELL-1] shell perches resolve under the owner's shells/
579	    // namespace — distinct from nested/, invisible to the top-level Self scan.
580	    #[test]
581	    fn shell_perches_resolve_under_owner_shells_namespace() {
582	        let tmp = tempfile::tempdir().unwrap();
583	        let _g = EnvGuard::set(tmp.path());
584	        let owlery = tmp.path().join("owlery");
585
586	        let shell = owlery.join("doyle").join("shells").join("GameRobot-0");
587	        assert_eq!(resolve_shell_perch_path("doyle", "GameRobot-0"), shell);
588	        assert_eq!(
589	            resolve_shell_perch_path_in(&owlery, "doyle", "GameRobot-0"),
590	            shell
591	        );
592	        assert_eq!(
593	            resolve_shells_dir("doyle"),
594	            owlery.join("doyle").join("shells")
595	        );
596	        // Not the nested/ namespace, and never what resolve_perch_path yields.
597	        assert_ne!(
598	            shell,
599	            resolve_perch_path("GameRobot-0", ParentHint::Explicit("doyle"))
600	        );
601
602	        // A bound owner with a bound shell: the top-level scan sees only the owner.
603	        std::fs::create_dir_all(owlery.join("doyle")).unwrap();
604	        std::fs::write(owlery.join("doyle").join("info.json"), "{}").unwrap();
605	        std::fs::create_dir_all(&shell).unwrap();
606	        std::fs::write(shell.join("info.json"), "{}").unwrap();
607	        assert_eq!(list_self_perch_ids(&owlery), vec!["doyle".to_string()]);
608	    }
609
610	    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] spt_home is pure: honors SPT_HOME,
611	    // creates nothing.
612	    #[test]
613	    fn spt_home_is_pure_and_honors_env() {
614	        let tmp = tempfile::tempdir().unwrap();
615	        let target = tmp.path().join("does-not-exist-yet");
616	        let _g = EnvGuard::set(&target);
617	        assert_eq!(spt_home(), target);
618	        assert!(!target.exists(), "spt_home must not create the dir");
619	    }
620	}
621

//! Perch layout — the **single source of truth** for where a perch lives on
//! disk. Every reader and writer of a perch path routes through
//! [`resolve_perch_path`]; nothing composes perch paths by hand. If the layout
//! ever changes, exactly this one module changes (KNOWN-HAZARDS 6.1 / 1.5).
//!
//! Adapted from sister `perch_path.rs` + `owlery.rs` path primitives
//! (ADR-0001), with two clean-room changes for the spt-core model:
//! - **Taxonomy:** the sister's Spine/Touch perch kinds are gone (CONTEXT
//!   endpoint types are ReadyAgent/LiveAgent/Psyche/Worker/SptNode). A top-level
//!   agent perch (ReadyAgent or a LiveAgent's Self) classifies as [`PerchKind::Self_`].
//! - **Purity:** path composition performs NO filesystem I/O — every function
//!   returns a `PathBuf`. Directory creation is the writer's job (e.g. the spool
//!   `create_dir_all`s on open).
//!
//! Layout invariant:
//! - `Self_`            → flat: `owlery/<id>/`
//! - `Psyche` / `Worker`→ nested under parent: `owlery/<parent>/nested/<child>/`
//!
//! The single `nested/` namespace holds both Psyche and Worker children — no
//! divergent sibling layouts (the hazard this module exists to prevent).

use std::path::{Path, PathBuf};

/// Root of all spt-core on-disk state. Honors `$SPT_HOME`, else the platform
/// default (`%LOCALAPPDATA%\spt-core` on Windows, `$HOME/.spt-core` on Unix).
/// **Pure** — reads env, composes a path, creates nothing.
///
/// The default lives under `spt-core/`, **deliberately distinct from the modern
/// spt runtime's `spt/` home**, so a dev build never collides with a running
/// spt-plugin during the parity build-out. Once spt-core + the spt-plugin reach
/// full parity with modern spt this may simply stay `spt-core/` for good; the
/// override (`$SPT_HOME`) is the relocation knob either way.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn spt_home() -> PathBuf {
    if let Ok(over) = std::env::var("SPT_HOME") {
        if !over.is_empty() {
            return PathBuf::from(over);
        }
    }
    platform_default_spt_home()
}

#[cfg(windows)]
fn platform_default_spt_home() -> PathBuf {
    if let Ok(local) = std::env::var("LOCALAPPDATA") {
        if !local.is_empty() {
            return PathBuf::from(local).join("spt-core");
        }
    }
    let up =
        std::env::var("USERPROFILE").expect("LOCALAPPDATA or USERPROFILE must be set on Windows");
    PathBuf::from(up)
        .join("AppData")
        .join("Local")
        .join("spt-core")
}

#[cfg(unix)]
fn platform_default_spt_home() -> PathBuf {
    let home = std::env::var("HOME").expect("HOME must be set on Unix");
    PathBuf::from(home).join(".spt-core")
}

/// The owlery directory holding all perches: `<spt_home>/owlery/`.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn owlery_dir() -> PathBuf {
    spt_home().join("owlery")
}

/// The synced-mind context tree: `<spt_home>/tracked/` (`docs/STORAGE.md`).
/// A git-backed two-tier store (`crate::contextstore`) — **durable, synced
/// across nodes** (the backup/migrate set), physically separate from the
/// never-synced `perches/` runtime. Pure path composition.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn tracked_dir() -> PathBuf {
    spt_home().join("tracked")
}

/// Node-local **durable** identity/security material: `<spt_home>/identity/`
/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
/// material (the node keypair + subnet TOTP seeds). Pure path composition.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn identity_dir() -> PathBuf {
    spt_home().join("identity")
}

/// The node's durable Ed25519 identity seed: `<spt_home>/identity/node.key`
/// (`docs/STORAGE.md`). The node's stable name on the wire (the iroh EndpointId
/// *is* this keypair's public key, D1/D4) — per-node secret material, never
/// git-tracked, never synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn node_key_file() -> PathBuf {
    identity_dir().join("node.key")
}

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

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

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

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

/// The subnet notification spool: `<spt_home>/identity/notifs.db` (ADR-0007, D8).
/// Node-level (a notif targets the *user in a subnet*, not a perch), subnet-tagged
/// rows; the *content* replicates across each subnet's nodes via the P2P feed
/// (like `subnet.json`'s material — never git).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn notifs_file() -> PathBuf {
    identity_dir().join("notifs.db")
}

/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_dir() -> PathBuf {
    identity_dir().join("trust")
}

/// The trusted-peers file: `<spt_home>/identity/trust/peers.json` — the TOFU store
/// of authorized peer node pubkeys (R-PAIR-2). Node-local, never git-tracked/synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_file() -> PathBuf {
    trust_dir().join("peers.json")
}

/// The endpoint access whitelist: `<spt_home>/identity/trust/access.json`
/// (ADR-0009, REQ-SEC-1). Security material next to the trust store —
/// node-local, never git-tracked/synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn access_file() -> PathBuf {
    trust_dir().join("access.json")
}

/// The recent-outbound correlation log:
/// `<spt_home>/identity/trust/recent-outbound.json` — the stateful-firewall
/// reply-correlation state the access gate reads (REQ-SEC-1). Node-local,
/// never synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn recent_outbound_file() -> PathBuf {
    trust_dir().join("recent-outbound.json")
}

/// The consent grant store: `<spt_home>/identity/trust/grants.json`
/// (CONTEXT §Consent & security gates, REQ-CONSENT-1). Security material next
/// to the trust store — node-local, never git-tracked/synced. Default-deny
/// (the access whitelist's opposite polarity).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn grants_file() -> PathBuf {
    trust_dir().join("grants.json")
}

/// The registered-adapter set: `<spt_home>/adapters/` (`docs/STORAGE.md`,
/// REQ-INSTALL-4) — one subdir per adapter (`record.toml` + a manifest copy
/// or pointer). Node-local capability (drive/launch), not subnet
/// advertisement.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn adapters_dir() -> PathBuf {
    spt_home().join("adapters")
}

/// The shells namespace under an owner's Self perch:
/// `owlery/<owner>/shells/` (`docs/STORAGE.md` §Shells in the layout, M5-D3a).
/// A third namespace beside flat-Self and `nested/` — shell instances are
/// **not** perch kinds: [`resolve_perch_path`] never resolves them, and
/// [`list_self_perch_ids`] never enumerates them (they sit *inside* an
/// owner's dir, invisible to the top-level scan by construction). Pure path
/// composition, no I/O.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
// [impl->REQ-SHELL-1]
pub fn resolve_shells_dir_in(owlery: &Path, owner: &str) -> PathBuf {
    owlery.join(owner).join("shells")
}

/// [`resolve_shells_dir_in`] against the canonical [`owlery_dir`].
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn resolve_shells_dir(owner: &str) -> PathBuf {
    resolve_shells_dir_in(&owlery_dir(), owner)
}

/// **The** shell-instance perch resolver:
/// `owlery/<owner>/shells/<adapter>-<n>/`. Every reader/writer of a shell
/// perch routes through here — same single-source rule as agent perches
/// (KNOWN-HAZARDS 6.1).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
// [impl->REQ-SHELL-1]
pub fn resolve_shell_perch_path_in(owlery: &Path, owner: &str, shell_id: &str) -> PathBuf {
    resolve_shells_dir_in(owlery, owner).join(shell_id)
}

/// [`resolve_shell_perch_path_in`] against the canonical [`owlery_dir`].
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn resolve_shell_perch_path(owner: &str, shell_id: &str) -> PathBuf {
    resolve_shell_perch_path_in(&owlery_dir(), owner, shell_id)
}

/// Classification of a perch id by its naming convention. Stable, pure, no I/O.
///
/// - `<x>-psyche` → [`PerchKind::Psyche`]
/// - `<x>-w{N}`   → [`PerchKind::Worker`] (`{N}` = one-or-more ASCII digits, `<x>` non-empty)
/// - anything else → [`PerchKind::Self_`]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PerchKind {
    /// Top-level agent perch (a ReadyAgent, or a LiveAgent's Self).
    Self_,
    /// A LiveAgent's Psyche companion perch.
    Psyche,
    /// A subagent perch under a parent LiveAgent.
    Worker,
}

impl PerchKind {
    /// Classify an id by suffix. Worker is checked FIRST (rightmost `-w{digits}`)
    /// so `dunsen-psyche-w1` classifies as `Worker`.
    // [impl->REQ-HAZARD-WORKER-PATH]
    pub fn classify(id: &str) -> PerchKind {
        if is_worker_id(id) {
            return PerchKind::Worker;
        }
        if id.ends_with("-psyche") && id.len() > "-psyche".len() {
            return PerchKind::Psyche;
        }
        PerchKind::Self_
    }

    /// True for kinds nested under a parent (Psyche, Worker); false for Self_.
    pub fn nests_under_parent(self) -> bool {
        matches!(self, PerchKind::Psyche | PerchKind::Worker)
    }
}

/// Worker-id rule: rightmost `-w` whose suffix is one-or-more digits, with a
/// non-empty name before it.
fn is_worker_id(id: &str) -> bool {
    if let Some(pos) = id.rfind("-w") {
        if pos == 0 {
            return false;
        }
        let suffix = &id[pos + 2..];
        !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())
    } else {
        false
    }
}

/// How the resolver determines the parent id for nested kinds.
/// `Explicit` is preferred (caller knows the parent); `Infer` derives it by
/// stripping the kind suffix from the child id (legacy reader call sites).
#[derive(Debug, Clone)]
pub enum ParentHint<'a> {
    Explicit(&'a str),
    Infer,
}

/// **The** perch-directory resolver. Self_ → flat `owlery/<id>/`;
/// Psyche/Worker → `owlery/<parent>/nested/<child>/`. Panic-free: if `Infer`
/// cannot derive a parent (id shape doesn't match its kind — should not happen
/// since classify+infer agree), falls back to flat for safety.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
// [impl->REQ-HAZARD-WORKER-PATH]
pub fn resolve_perch_path(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path_in(&owlery_dir(), child_id, hint)
}

/// [`resolve_perch_path`] against an **explicit** owlery root rather than the
/// process-global [`owlery_dir`]. Same single-source layout logic — this is the
/// real resolver and [`resolve_perch_path`] is the canonical-root wrapper. Used
/// by callers that already hold an owlery root (e.g. the registry's stale-clean,
/// which resolves a row's perch to read its daemon-managed `status`) so they
/// resolve consistently with the owlery they operate on, not the global home.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
// [impl->REQ-HAZARD-WORKER-PATH]
pub fn resolve_perch_path_in(owlery: &Path, child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    let kind = PerchKind::classify(child_id);
    if !kind.nests_under_parent() {
        return owlery.join(child_id);
    }
    let parent = match hint {
        ParentHint::Explicit(p) => p.to_string(),
        ParentHint::Infer => match infer_parent(child_id, kind) {
            Some(p) => p,
            None => return owlery.join(child_id), // defensive flat fallback
        },
    };
    owlery.join(parent).join("nested").join(child_id)
}

/// Derive the parent id of a nested child by stripping its kind suffix.
fn infer_parent(id: &str, kind: PerchKind) -> Option<String> {
    match kind {
        PerchKind::Psyche => id.strip_suffix("-psyche").map(str::to_string),
        PerchKind::Worker => {
            let pos = id.rfind("-w")?;
            if pos == 0 {
                return None;
            }
            let suffix = &id[pos + 2..];
            if suffix.is_empty() || !suffix.bytes().all(|b| b.is_ascii_digit()) {
                return None;
            }
            Some(id[..pos].to_string())
        }
        PerchKind::Self_ => None,
    }
}

/// `<resolved_perch>/info.json`.
pub fn resolve_info_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    info_file_at(&resolve_perch_path(child_id, hint))
}

/// `<resolved_perch>/spool.db` — the spool the path-aware spool surface opens.
pub fn resolve_spool_db(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join("spool.db")
}

/// `<resolved_perch>/ready`.
pub fn resolve_ready_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join("ready")
}

/// `<resolved_perch>/inbox`.
pub fn resolve_inbox_dir(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join("inbox")
}

/// The activity/idle sentinel filename within a perch dir (spt-core-owned; the
/// adapter never names it — MANIFEST §What is NOT in the manifest).
pub const IDLE_SENTINEL: &str = ".idle";
/// The echo-commune gate sentinel filename (`.more-done`-equiv; the idle
/// transition arms it). spt-core-owned.
pub const ECHO_GATE_SENTINEL: &str = ".more-done";

/// `<resolved_perch>/.idle` — the activity/idle sentinel.
pub fn resolve_idle_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join(IDLE_SENTINEL)
}

/// `<resolved_perch>/.more-done` — the echo-commune gate sentinel. The single
/// path source (6.1) shared by the `api` surface (which arms it) and the M2b
/// pulse (which reads + clears it).
pub fn resolve_echo_gate_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join(ECHO_GATE_SENTINEL)
}

/// `<perch_path>/info.json` from an already-resolved path.
pub fn info_file_at(perch_path: &Path) -> PathBuf {
    perch_path.join("info.json")
}

/// Enumerate the **top-level `Self_` perch ids** in `owlery` — every immediate
/// subdirectory that carries an `info.json` (a bound perch). Pure dir scan,
/// tolerant: a missing/unreadable owlery, or an entry without `info.json`, is
/// silently skipped (never panics, never hard-fails). Nested Psyche/Worker
/// perches live under `<self>/nested/` and are deliberately excluded — the
/// consent-target resolver wants the user's *agent* sessions, not their hosted
/// children. Order is unspecified (the caller ranks by recency).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn list_self_perch_ids(owlery: &Path) -> Vec<String> {
    let Ok(entries) = std::fs::read_dir(owlery) else {
        return Vec::new();
    };
    let mut ids = Vec::new();
    for entry in entries.flatten() {
        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
            continue;
        }
        if !info_file_at(&entry.path()).is_file() {
            continue; // a dir without info.json is not a bound perch
        }
        if let Some(name) = entry.file_name().to_str() {
            ids.push(name.to_string());
        }
    }
    ids
}

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

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    struct EnvGuard {
        _lock: std::sync::MutexGuard<'static, ()>,
        prev: Option<String>,
    }
    impl EnvGuard {
        fn set(tmp: &Path) -> Self {
            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
            let prev = std::env::var("SPT_HOME").ok();
            std::env::set_var("SPT_HOME", tmp);
            EnvGuard { _lock: lock, prev }
        }
    }
    impl Drop for EnvGuard {
        fn drop(&mut self) {
            match &self.prev {
                Some(v) => std::env::set_var("SPT_HOME", v),
                None => std::env::remove_var("SPT_HOME"),
            }
        }
    }

    // ---- classification ----

    // [unit->REQ-HAZARD-WORKER-PATH]
    #[test]
    fn classification_matrix() {
        assert_eq!(PerchKind::classify("dunsen"), PerchKind::Self_);
        assert_eq!(PerchKind::classify("dunsen-psyche"), PerchKind::Psyche);
        assert_eq!(PerchKind::classify("dunsen-w1"), PerchKind::Worker);
        assert_eq!(PerchKind::classify("dunsen-w42"), PerchKind::Worker);
        // Worker rule edges → Self_.
        assert_eq!(
            PerchKind::classify("-w1"),
            PerchKind::Self_,
            "no parent name"
        );
        assert_eq!(
            PerchKind::classify("dunsen-w"),
            PerchKind::Self_,
            "no digits"
        );
        assert_eq!(
            PerchKind::classify("dunsen-wa"),
            PerchKind::Self_,
            "non-digit"
        );
        // Rightmost -w wins.
        assert_eq!(PerchKind::classify("dunsen-psyche-w1"), PerchKind::Worker);
        // Dropped sister kinds are now plain Self_ (no Spine/Touch).
        assert_eq!(PerchKind::classify("dunsen-spine"), PerchKind::Self_);
        assert_eq!(PerchKind::classify("dunsen-touch"), PerchKind::Self_);
    }

    // ---- resolution: one resolver, no divergent layouts ----

    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] Self_ is flat.
    #[test]
    fn resolve_self_is_flat() {
        let tmp = tempfile::tempdir().unwrap();
        let _g = EnvGuard::set(tmp.path());
        let owlery = tmp.path().join("owlery");
        assert_eq!(
            resolve_perch_path("dunsen", ParentHint::Infer),
            owlery.join("dunsen")
        );
    }

    // [unit->REQ-HAZARD-WORKER-PATH] Psyche + Worker nest under the single
    // `nested/` namespace, via both Explicit and Infer.
    #[test]
    fn resolve_psyche_and_worker_nest_under_parent() {
        let tmp = tempfile::tempdir().unwrap();
        let _g = EnvGuard::set(tmp.path());
        let owlery = tmp.path().join("owlery");

        let psyche_nested = owlery.join("dunsen").join("nested").join("dunsen-psyche");
        assert_eq!(
            resolve_perch_path("dunsen-psyche", ParentHint::Infer),
            psyche_nested
        );
        assert_eq!(
            resolve_perch_path("dunsen-psyche", ParentHint::Explicit("dunsen")),
            psyche_nested
        );

        let worker_nested = owlery.join("dunsen").join("nested").join("dunsen-w42");
        assert_eq!(
            resolve_perch_path("dunsen-w42", ParentHint::Infer),
            worker_nested
        );
        assert_eq!(
            resolve_perch_path("dunsen-w42", ParentHint::Explicit("dunsen")),
            worker_nested
        );
    }

    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] Explicit parent is trusted
    // mechanically (overrides what Infer would derive).
    #[test]
    fn explicit_parent_wins_over_infer() {
        let tmp = tempfile::tempdir().unwrap();
        let _g = EnvGuard::set(tmp.path());
        let owlery = tmp.path().join("owlery");
        assert_eq!(
            resolve_perch_path("dunsen-psyche", ParentHint::Explicit("doyle")),
            owlery.join("doyle").join("nested").join("dunsen-psyche")
        );
    }

    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] child-file helpers all hang off the
    // one resolver — spool.db / info.json / ready / inbox share the perch root.
    #[test]
    fn child_files_route_through_one_resolver() {
        let tmp = tempfile::tempdir().unwrap();
        let _g = EnvGuard::set(tmp.path());
        let root = resolve_perch_path("dunsen-w7", ParentHint::Explicit("dunsen"));
        assert_eq!(
            resolve_spool_db("dunsen-w7", ParentHint::Explicit("dunsen")),
            root.join("spool.db")
        );
        assert_eq!(
            resolve_info_file("dunsen-w7", ParentHint::Explicit("dunsen")),
            root.join("info.json")
        );
        assert_eq!(
            resolve_ready_file("dunsen-w7", ParentHint::Explicit("dunsen")),
            root.join("ready")
        );
        assert_eq!(
            resolve_inbox_dir("dunsen-w7", ParentHint::Explicit("dunsen")),
            root.join("inbox")
        );
    }

    // [unit->REQ-UPD-4] list_self_perch_ids returns top-level bound perches only:
    // a dir with info.json counts; a dir without it, and nested children, do not;
    // a missing owlery yields empty (tolerant).
    #[test]
    fn list_self_perch_ids_enumerates_bound_top_level() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path().join("owlery");

        // Missing owlery → empty, no panic.
        assert!(list_self_perch_ids(&owlery).is_empty());

        // Two bound Self perches.
        for id in ["doyle", "ling"] {
            let p = owlery.join(id);
            std::fs::create_dir_all(&p).unwrap();
            std::fs::write(p.join("info.json"), "{}").unwrap();
        }
        // A dir with no info.json (not a bound perch).
        std::fs::create_dir_all(owlery.join("not-a-perch")).unwrap();
        // A nested child under doyle — must NOT be enumerated at top level.
        let nested = owlery.join("doyle").join("nested").join("doyle-psyche");
        std::fs::create_dir_all(&nested).unwrap();
        std::fs::write(nested.join("info.json"), "{}").unwrap();

        let mut ids = list_self_perch_ids(&owlery);
        ids.sort();
        assert_eq!(ids, vec!["doyle".to_string(), "ling".to_string()]);
    }

    // [unit->REQ-SHELL-1] shell perches resolve under the owner's shells/
    // namespace — distinct from nested/, invisible to the top-level Self scan.
    #[test]
    fn shell_perches_resolve_under_owner_shells_namespace() {
        let tmp = tempfile::tempdir().unwrap();
        let _g = EnvGuard::set(tmp.path());
        let owlery = tmp.path().join("owlery");

        let shell = owlery.join("doyle").join("shells").join("GameRobot-0");
        assert_eq!(resolve_shell_perch_path("doyle", "GameRobot-0"), shell);
        assert_eq!(
            resolve_shell_perch_path_in(&owlery, "doyle", "GameRobot-0"),
            shell
        );
        assert_eq!(
            resolve_shells_dir("doyle"),
            owlery.join("doyle").join("shells")
        );
        // Not the nested/ namespace, and never what resolve_perch_path yields.
        assert_ne!(
            shell,
            resolve_perch_path("GameRobot-0", ParentHint::Explicit("doyle"))
        );

        // A bound owner with a bound shell: the top-level scan sees only the owner.
        std::fs::create_dir_all(owlery.join("doyle")).unwrap();
        std::fs::write(owlery.join("doyle").join("info.json"), "{}").unwrap();
        std::fs::create_dir_all(&shell).unwrap();
        std::fs::write(shell.join("info.json"), "{}").unwrap();
        assert_eq!(list_self_perch_ids(&owlery), vec!["doyle".to_string()]);
    }

    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] spt_home is pure: honors SPT_HOME,
    // creates nothing.
    #[test]
    fn spt_home_is_pure_and_honors_env() {
        let tmp = tempfile::tempdir().unwrap();
        let target = tmp.path().join("does-not-exist-yet");
        let _g = EnvGuard::set(&target);
        assert_eq!(spt_home(), target);
        assert!(!target.exists(), "spt_home must not create the dir");
    }
}


Now the roster API + remaining call sites. Reading roster.rs, registryhost evict path, wire.rs sigs, dispatch caller, cli sections, lib.rs.


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


Roster API clear. Now the eviction path, wire sigs, dispatch caller, CLI sites, lib.rs. Pinpointing references first.