1	//! `trust/peers.json` — the local trust store of authorized peer node pubkeys
2	//! (ADR-0005, R-PAIR-2, D2e).
3	//!
4	//! After a [pairing ceremony](../../../spt_net/net/pairing) confirms a peer's Ed25519
5	//! node pubkey, that pubkey is **pinned** here as trusted *within the subnet it paired
6	//! into*. This is the **TOFU** (trust-on-first-use) record the runtime consults to
7	//! decide whether an inbound peer is one of the user's own already-paired nodes
8	//! (`CONTEXT.md` "trust store"). Node-local, durable, **never git-tracked and never
9	//! synced over the context repo** — it is subnet security material, alongside
10	//! [`subnet.json`](crate::subnet) and `node.key` under [`identity/`](crate::perch::identity_dir).
11	//!
12	//! ## TOFU + warn-on-change (R-PAIR-2)
13	//!
14	//! - **Accept on first use.** The first ceremony with a given `(subnet, pubkey)` pins
15	//!   it ([`TrustOutcome::Pinned`]); there is no CA — the ceremony itself is the trust
16	//!   anchor. A re-pair with the *same* key is idempotent ([`AlreadyTrusted`]).
17	//! - **Warn on key change.** If a peer is pinned under an optional human **label** and
18	//!   a later record for that `(subnet, label)` carries a *different* pubkey, [`record`]
19	//!   returns [`KeyChanged`] and does **not** silently overwrite — the caller surfaces
20	//!   the warning (a legitimate key rotation vs a MITM look the same at this layer) and,
21	//!   only if accepted, applies it with [`repin`].
22	//!
23	//!   The change anchor is the label: an iroh NodeId *is* the Ed25519 pubkey, so a
24	//!   different key is cryptographically a different node, not a "changed" one. Until
25	//!   labels are assigned (deferred to the GUI/D9 pairing UX), warn-on-change is dormant
26	//!   — every distinct key pins as its own peer — which is the honest v1 behavior. The
27	//!   mechanism is implemented and tested now so the capability lands with the schema.
28	//!
29	//! ## Revocation
30	//!
31	//! Removing a paired node is a trust-store delete ([`revoke`]) — TOTP gates *joining*,
32	//! not continued membership (`CONTEXT.md`, ADR-0005). De-trusting a node should be
33	//! paired with a [seed rotation](crate::subnet::SubnetStore::rotate_seed) (#10), since
34	//! a trust-delete alone leaves the still-replicated seed able to re-pair.
35	//!
36	//! ## `(subnet, user)`-ready shape
37	//!
38	//! The optional [`user`](TrustedPeer::user) field mirrors [`SubnetRecord`](crate::subnet::SubnetRecord):
39	//! the cross-user seam (`CONTEXT.md`). v1 has one implicit user (`None`); the trust key
40	//! generalizes to `(subnet, user, pubkey)` with no re-architecture.
41	//
42	// [impl->REQ-PAIR-2]
43
44	use serde::{Deserialize, Serialize};
45	use std::path::Path;
46
47	use crate::atomic::atomic_write_string;
48	use crate::perch::trust_file;
49
50	/// Failures operating on the trust store.
51	#[derive(Debug, PartialEq, Eq)]
52	pub enum TrustError {
53	    /// A [`repin`](TrustStore::repin) named a `(subnet, label)` with no existing peer.
54	    NotFound(String),
55	    /// Reading/writing the backing file failed (message preserved).
56	    Io(String),
57	}
58
59	impl std::fmt::Display for TrustError {
60	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61	        match self {
62	            TrustError::NotFound(h) => write!(f, "no trusted peer for: {h}"),
63	            TrustError::Io(e) => write!(f, "trust store io: {e}"),
64	        }
65	    }
66	}
67
68	impl std::error::Error for TrustError {}
69
70	/// The result of recording (pinning) a peer — the TOFU decision the caller acts on.
71	#[derive(Debug, PartialEq, Eq)]
72	pub enum TrustOutcome {
73	    /// First use of this `(subnet, pubkey)`: pinned now. The normal post-ceremony path.
74	    Pinned,
75	    /// This exact `(subnet, pubkey)` was already trusted — an idempotent re-pair.
76	    AlreadyTrusted,
77	    /// A peer under this `(subnet, label)` is already pinned with a **different** key.
78	    /// NOT applied: the caller must warn the user and, only if the change is accepted,
79	    /// call [`repin`](TrustStore::repin). The danger case is a MITM presenting a fresh
80	    /// key under a known node's label.
81	    KeyChanged {
82	        /// The hex pubkey currently pinned for that label (the one being challenged).
83	        existing_pubkey_hex: String,
84	    },
85	}
86
87	/// One authorized peer: its pinned node pubkey, the subnet it's trusted within, an
88	/// optional human label (the warn-on-change anchor), the cross-user seam, and a stamp.
89	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90	pub struct TrustedPeer {
91	    /// The peer's Ed25519 node pubkey, lowercase hex (the iroh NodeId). The identity.
92	    pub pubkey_hex: String,
93	    /// The subnet this peer paired into / is trusted within.
94	    pub subnet: String,
95	    /// Optional human node label. When present it is the stable handle warn-on-change
96	    /// keys against; absent (v1 default) means the pubkey is the sole identity.
97	    #[serde(skip_serializing_if = "Option::is_none")]
98	    pub label: Option<String>,
99	    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
100	    #[serde(skip_serializing_if = "Option::is_none")]
101	    pub user: Option<String>,
102	    /// First-trust stamp (epoch-seconds string; opaque).
103	    pub paired: String,
104	}
105
106	/// The whole `peers.json` document: every peer this node trusts, across all subnets.
107	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108	pub struct TrustStore {
109	    /// All trusted peers (multi-subnet — one node trusts peers across N subnets).
110	    #[serde(default)]
111	    pub peers: Vec<TrustedPeer>,
112	}
113
114	impl TrustStore {
115	    /// Load from the canonical [`trust_file`]. A missing file is the empty store (an
116	    /// unpaired node) — never an error.
117	    pub fn load() -> TrustStore {
118	        Self::load_from(&trust_file())
119	    }
120
121	    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable or
122	    /// corrupt file also degrades to empty rather than panicking — a damaged trust file
123	    /// must not brick the node (re-pair to rebuild it), the same stance as
124	    /// [`SubnetStore`](crate::subnet::SubnetStore).
125	    pub fn load_from(path: &Path) -> TrustStore {
126	        match std::fs::read_to_string(path) {
127	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
128	            Err(_) => TrustStore::default(),
129	        }
130	    }
131
132	    /// Persist to the canonical [`trust_file`], creating `identity/trust/` if needed.
133	    pub fn save(&self) -> Result<(), TrustError> {
134	        self.save_to(&trust_file())
135	    }
136
137	    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
138	    /// concurrent reader never sees a torn trust file (the [`atomic`](crate::atomic)
139	    /// invariant).
140	    pub fn save_to(&self, path: &Path) -> Result<(), TrustError> {
141	        if let Some(parent) = path.parent() {
142	            std::fs::create_dir_all(parent).map_err(|e| TrustError::Io(e.to_string()))?;
143	        }
144	        let json = serde_json::to_string_pretty(self).map_err(|e| TrustError::Io(e.to_string()))?;
145	        atomic_write_string(path, &json).map_err(|e| TrustError::Io(e.to_string()))
146	    }
147
148	    /// Whether `pubkey_hex` is a trusted peer in `subnet` — the connect-time gate (an
149	    /// inbound peer the runtime hasn't pinned is not one of the user's own nodes).
150	    // [impl->REQ-PAIR-2]
151	    pub fn is_trusted(&self, subnet: &str, pubkey_hex: &str) -> bool {
152	        self.peers
153	            .iter()
154	            .any(|p| p.subnet == subnet && p.pubkey_hex == pubkey_hex)
155	    }
156
157	    /// All peers trusted in `subnet`.
158	    pub fn peers_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a TrustedPeer> {
159	        self.peers.iter().filter(move |p| p.subnet == subnet)
160	    }
161
162	    /// Record (pin) a peer after a successful pairing ceremony — the TOFU write.
163	    ///
164	    /// Returns the [`TrustOutcome`]: [`Pinned`](TrustOutcome::Pinned) for a first use,
165	    /// [`AlreadyTrusted`](TrustOutcome::AlreadyTrusted) for an idempotent re-pair, or
166	    /// [`KeyChanged`](TrustOutcome::KeyChanged) if a peer under the same `(subnet,
167	    /// label)` already holds a different key — in which case **nothing is written** and
168	    /// the caller must warn + decide ([`repin`](Self::repin)). The `(subnet, user)`
169	    /// scope is honored (a label is only an anchor within its own user scope).
170	    // [impl->REQ-PAIR-2]
171	    pub fn record(
172	        &mut self,
173	        subnet: &str,
174	        pubkey_hex: &str,
175	        label: Option<&str>,
176	        user: Option<&str>,
177	    ) -> TrustOutcome {
178	        // Exact identity already trusted → idempotent (a key isn't "changed" against
179	        // itself, even when a label is present).
180	        if self.is_trusted(subnet, pubkey_hex) {
181	            return TrustOutcome::AlreadyTrusted;
182	        }
183	        // Warn-on-change: a label already bound to a DIFFERENT key in this scope.
184	        if let Some(lbl) = label {
185	            if let Some(existing) = self.peers.iter().find(|p| {
186	                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(lbl)
187	            }) {
188	                return TrustOutcome::KeyChanged {
189	                    existing_pubkey_hex: existing.pubkey_hex.clone(),
190	                };
191	            }
192	        }
193	        self.peers.push(TrustedPeer {
194	            pubkey_hex: pubkey_hex.to_string(),
195	            subnet: subnet.to_string(),
196	            label: label.map(str::to_string),
197	            user: user.map(str::to_string),
198	            paired: now_stamp(),
199	        });
200	        TrustOutcome::Pinned
201	    }
202
203	    /// Apply an **accepted** key change for a labelled peer: replace the pinned key for
204	    /// `(subnet, label)` with `new_pubkey_hex`. Call this only after a
205	    /// [`KeyChanged`](TrustOutcome::KeyChanged) outcome was surfaced and the operator
206	    /// confirmed the rotation (otherwise a MITM's key would be trusted). Errors if no
207	    /// peer holds that label in scope.
208	    // [impl->REQ-PAIR-2]
209	    pub fn repin(
210	        &mut self,
211	        subnet: &str,
212	        label: &str,
213	        new_pubkey_hex: &str,
214	        user: Option<&str>,
215	    ) -> Result<(), TrustError> {
216	        let peer = self
217	            .peers
218	            .iter_mut()
219	            .find(|p| {
220	                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(label)
221	            })
222	            .ok_or_else(|| TrustError::NotFound(format!("{subnet}/{label}")))?;
223	        peer.pubkey_hex = new_pubkey_hex.to_string();
224	        peer.paired = now_stamp();
225	        Ok(())
226	    }
227
228	    /// Revoke a trusted peer (the node-removal trust-delete). Returns whether a peer was
229	    /// removed. Pair this with a [seed rotation](crate::subnet::SubnetStore::rotate_seed)
230	    /// — a trust-delete alone is not revocation while the seed is still replicated (#10).
231	    // [impl->REQ-PAIR-2]
232	    pub fn revoke(&mut self, subnet: &str, pubkey_hex: &str) -> bool {
233	        let before = self.peers.len();
234	        self.peers
235	            .retain(|p| !(p.subnet == subnet && p.pubkey_hex == pubkey_hex));
236	        self.peers.len() != before
237	    }
238	}
239
240	/// Current unix-seconds as an opaque stamp string (matches [`SubnetStore`](crate::subnet)).
241	fn now_stamp() -> String {
242	    std::time::SystemTime::now()
243	        .duration_since(std::time::UNIX_EPOCH)
244	        .map(|d| d.as_secs().to_string())
245	        .unwrap_or_else(|_| "0".to_string())
246	}
247
248	#[cfg(test)]
249	mod tests {
250	    use super::*;
251
252	    // [unit->REQ-PAIR-2] TOFU first-use: a fresh peer pins (Pinned) and is then
253	    // trusted; re-recording the SAME key is idempotent (AlreadyTrusted), not a
254	    // duplicate — the post-ceremony accept path.
255	    #[test]
256	    fn first_use_pins_then_is_trusted_idempotently() {
257	        let mut store = TrustStore::default();
258	        assert_eq!(
259	            store.record("home", "aa11", None, None),
260	            TrustOutcome::Pinned
261	        );
262	        assert!(store.is_trusted("home", "aa11"));
263	        assert_eq!(
264	            store.record("home", "aa11", None, None),
265	            TrustOutcome::AlreadyTrusted,
266	            "re-pair with same key is idempotent"
267	        );
268	        assert_eq!(store.peers.len(), 1, "no duplicate row");
269	    }
270
271	    // [unit->REQ-PAIR-2] trust is per-subnet: the same node pubkey trusted in `home`
272	    // is NOT trusted in `work` until separately paired there (each subnet its own trust
273	    // context). An unknown key is simply untrusted.
274	    #[test]
275	    fn trust_is_scoped_per_subnet() {
276	        let mut store = TrustStore::default();
277	        store.record("home", "aa11", None, None);
278	        assert!(store.is_trusted("home", "aa11"));
279	        assert!(
280	            !store.is_trusted("work", "aa11"),
281	            "not trusted in other subnet"
282	        );
283	        assert!(!store.is_trusted("home", "bb22"), "unknown key untrusted");
284	    }
285
286	    // [unit->REQ-PAIR-2] WARN-ON-CHANGE: a labelled peer that later presents a
287	    // DIFFERENT key returns KeyChanged carrying the existing key, and does NOT mutate
288	    // the store — the caller must decide. The MITM-vs-rotation guard.
289	    #[test]
290	    fn labelled_key_change_warns_without_mutating() {
291	        let mut store = TrustStore::default();
292	        store.record("home", "aa11", Some("laptop"), None);
293	        let outcome = store.record("home", "bb22", Some("laptop"), None);
294	        assert_eq!(
295	            outcome,
296	            TrustOutcome::KeyChanged {
297	                existing_pubkey_hex: "aa11".into()
298	            }
299	        );
300	        assert!(store.is_trusted("home", "aa11"), "old key untouched");
301	        assert!(
302	            !store.is_trusted("home", "bb22"),
303	            "new key NOT auto-trusted"
304	        );
305	        assert_eq!(store.peers.len(), 1, "no row added on a warned change");
306	    }
307
308	    // [unit->REQ-PAIR-2] an ACCEPTED key change is applied by repin: the label now maps
309	    // to the new key and the old one is no longer trusted. Repinning an unknown label is
310	    // an error, not a silent insert.
311	    #[test]
312	    fn repin_applies_accepted_key_change() {
313	        let mut store = TrustStore::default();
314	        store.record("home", "aa11", Some("laptop"), None);
315	        store.repin("home", "laptop", "bb22", None).expect("repin");
316	        assert!(store.is_trusted("home", "bb22"), "new key trusted");
317	        assert!(!store.is_trusted("home", "aa11"), "old key replaced");
318	        assert_eq!(
319	            store.repin("home", "ghost", "cc33", None).unwrap_err(),
320	            TrustError::NotFound("home/ghost".into())
321	        );
322	    }
323
324	    // [unit->REQ-PAIR-2] without a label there is no change anchor (v1 default): a
325	    // second distinct key pins as its own peer rather than warning. Documented honest
326	    // behavior — warn-on-change activates once labels exist.
327	    #[test]
328	    fn unlabelled_distinct_keys_each_pin() {
329	        let mut store = TrustStore::default();
330	        assert_eq!(
331	            store.record("home", "aa11", None, None),
332	            TrustOutcome::Pinned
333	        );
334	        assert_eq!(
335	            store.record("home", "bb22", None, None),
336	            TrustOutcome::Pinned
337	        );
338	        assert_eq!(store.peers.len(), 2);
339	        assert!(store.is_trusted("home", "aa11") && store.is_trusted("home", "bb22"));
340	    }
341
342	    // [unit->REQ-PAIR-2] revocation is a trust-delete: a revoked peer is no longer
343	    // trusted; a second revoke is a no-op false. (Pairs with seed rotation for real
344	    // revocation, #10.)
345	    #[test]
346	    fn revoke_removes_trust() {
347	        let mut store = TrustStore::default();
348	        store.record("home", "aa11", None, None);
349	        assert!(store.revoke("home", "aa11"));
350	        assert!(!store.is_trusted("home", "aa11"));
351	        assert!(
352	            !store.revoke("home", "aa11"),
353	            "second revoke is no-op false"
354	        );
355	    }
356
357	    // [unit->REQ-PAIR-2] the same key can label-collide across DIFFERENT subnets without
358	    // a false warn — the change anchor is scoped to (subnet, user, label).
359	    #[test]
360	    fn label_anchor_is_subnet_scoped() {
361	        let mut store = TrustStore::default();
362	        store.record("home", "aa11", Some("laptop"), None);
363	        // Same label, different subnet, different key → a clean first pin, not a warn.
364	        assert_eq!(
365	            store.record("work", "bb22", Some("laptop"), None),
366	            TrustOutcome::Pinned
367	        );
368	    }
369
370	    // [unit->REQ-PAIR-2] peers_in projects a subnet's trusted set across a multi-subnet
371	    // store.
372	    #[test]
373	    fn peers_in_filters_by_subnet() {
374	        let mut store = TrustStore::default();
375	        store.record("home", "aa11", None, None);
376	        store.record("home", "bb22", None, None);
377	        store.record("work", "cc33", None, None);
378	        let home: Vec<_> = store.peers_in("home").map(|p| &p.pubkey_hex).collect();
379	        assert_eq!(home.len(), 2);
380	        assert!(home.contains(&&"aa11".to_string()));
381	        assert_eq!(store.peers_in("work").count(), 1);
382	    }
383
384	    // [unit->REQ-PAIR-2] the store round-trips through the file atomically (multi-subnet
385	    // + label + a repin all preserved), and an absent/corrupt file degrades to empty —
386	    // a damaged trust file must not brick the node.
387	    #[test]
388	    fn store_persists_and_degrades_gracefully() {
389	        let dir = tempfile::tempdir().unwrap();
390	        let path = dir.path().join("identity").join("trust").join("peers.json");
391
392	        let mut store = TrustStore::default();
393	        store.record("home", "aa11", Some("laptop"), None);
394	        store.record("work", "cc33", None, None);
395	        store.repin("home", "laptop", "dd44", None).unwrap();
396	        store.save_to(&path).unwrap();
397
398	        let reloaded = TrustStore::load_from(&path);
399	        assert_eq!(reloaded.peers.len(), 2, "multi-subnet preserved");
400	        assert!(reloaded.is_trusted("home", "dd44"), "repin durable");
401	        assert!(reloaded.is_trusted("work", "cc33"));
402
403	        // Absent + corrupt → empty, never a panic.
404	        assert!(TrustStore::load_from(&dir.path().join("nope.json"))
405	            .peers
406	            .is_empty());
407	        let corrupt = dir.path().join("corrupt.json");
408	        std::fs::write(&corrupt, b"{ not json").unwrap();
409	        assert!(TrustStore::load_from(&corrupt).peers.is_empty());
410	    }
411	}
412

//! `trust/peers.json` — the local trust store of authorized peer node pubkeys
//! (ADR-0005, R-PAIR-2, D2e).
//!
//! After a [pairing ceremony](../../../spt_net/net/pairing) confirms a peer's Ed25519
//! node pubkey, that pubkey is **pinned** here as trusted *within the subnet it paired
//! into*. This is the **TOFU** (trust-on-first-use) record the runtime consults to
//! decide whether an inbound peer is one of the user's own already-paired nodes
//! (`CONTEXT.md` "trust store"). Node-local, durable, **never git-tracked and never
//! synced over the context repo** — it is subnet security material, alongside
//! [`subnet.json`](crate::subnet) and `node.key` under [`identity/`](crate::perch::identity_dir).
//!
//! ## TOFU + warn-on-change (R-PAIR-2)
//!
//! - **Accept on first use.** The first ceremony with a given `(subnet, pubkey)` pins
//!   it ([`TrustOutcome::Pinned`]); there is no CA — the ceremony itself is the trust
//!   anchor. A re-pair with the *same* key is idempotent ([`AlreadyTrusted`]).
//! - **Warn on key change.** If a peer is pinned under an optional human **label** and
//!   a later record for that `(subnet, label)` carries a *different* pubkey, [`record`]
//!   returns [`KeyChanged`] and does **not** silently overwrite — the caller surfaces
//!   the warning (a legitimate key rotation vs a MITM look the same at this layer) and,
//!   only if accepted, applies it with [`repin`].
//!
//!   The change anchor is the label: an iroh NodeId *is* the Ed25519 pubkey, so a
//!   different key is cryptographically a different node, not a "changed" one. Until
//!   labels are assigned (deferred to the GUI/D9 pairing UX), warn-on-change is dormant
//!   — every distinct key pins as its own peer — which is the honest v1 behavior. The
//!   mechanism is implemented and tested now so the capability lands with the schema.
//!
//! ## Revocation
//!
//! Removing a paired node is a trust-store delete ([`revoke`]) — TOTP gates *joining*,
//! not continued membership (`CONTEXT.md`, ADR-0005). De-trusting a node should be
//! paired with a [seed rotation](crate::subnet::SubnetStore::rotate_seed) (#10), since
//! a trust-delete alone leaves the still-replicated seed able to re-pair.
//!
//! ## `(subnet, user)`-ready shape
//!
//! The optional [`user`](TrustedPeer::user) field mirrors [`SubnetRecord`](crate::subnet::SubnetRecord):
//! the cross-user seam (`CONTEXT.md`). v1 has one implicit user (`None`); the trust key
//! generalizes to `(subnet, user, pubkey)` with no re-architecture.
//
// [impl->REQ-PAIR-2]

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

use crate::atomic::atomic_write_string;
use crate::perch::trust_file;

/// Failures operating on the trust store.
#[derive(Debug, PartialEq, Eq)]
pub enum TrustError {
    /// A [`repin`](TrustStore::repin) named a `(subnet, label)` with no existing peer.
    NotFound(String),
    /// Reading/writing the backing file failed (message preserved).
    Io(String),
}

impl std::fmt::Display for TrustError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            TrustError::NotFound(h) => write!(f, "no trusted peer for: {h}"),
            TrustError::Io(e) => write!(f, "trust store io: {e}"),
        }
    }
}

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

/// The result of recording (pinning) a peer — the TOFU decision the caller acts on.
#[derive(Debug, PartialEq, Eq)]
pub enum TrustOutcome {
    /// First use of this `(subnet, pubkey)`: pinned now. The normal post-ceremony path.
    Pinned,
    /// This exact `(subnet, pubkey)` was already trusted — an idempotent re-pair.
    AlreadyTrusted,
    /// A peer under this `(subnet, label)` is already pinned with a **different** key.
    /// NOT applied: the caller must warn the user and, only if the change is accepted,
    /// call [`repin`](TrustStore::repin). The danger case is a MITM presenting a fresh
    /// key under a known node's label.
    KeyChanged {
        /// The hex pubkey currently pinned for that label (the one being challenged).
        existing_pubkey_hex: String,
    },
}

/// One authorized peer: its pinned node pubkey, the subnet it's trusted within, an
/// optional human label (the warn-on-change anchor), the cross-user seam, and a stamp.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustedPeer {
    /// The peer's Ed25519 node pubkey, lowercase hex (the iroh NodeId). The identity.
    pub pubkey_hex: String,
    /// The subnet this peer paired into / is trusted within.
    pub subnet: String,
    /// Optional human node label. When present it is the stable handle warn-on-change
    /// keys against; absent (v1 default) means the pubkey is the sole identity.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user: Option<String>,
    /// First-trust stamp (epoch-seconds string; opaque).
    pub paired: String,
}

/// The whole `peers.json` document: every peer this node trusts, across all subnets.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TrustStore {
    /// All trusted peers (multi-subnet — one node trusts peers across N subnets).
    #[serde(default)]
    pub peers: Vec<TrustedPeer>,
}

impl TrustStore {
    /// Load from the canonical [`trust_file`]. A missing file is the empty store (an
    /// unpaired node) — never an error.
    pub fn load() -> TrustStore {
        Self::load_from(&trust_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 trust file
    /// must not brick the node (re-pair to rebuild it), the same stance as
    /// [`SubnetStore`](crate::subnet::SubnetStore).
    pub fn load_from(path: &Path) -> TrustStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => TrustStore::default(),
        }
    }

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

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

    /// Whether `pubkey_hex` is a trusted peer in `subnet` — the connect-time gate (an
    /// inbound peer the runtime hasn't pinned is not one of the user's own nodes).
    // [impl->REQ-PAIR-2]
    pub fn is_trusted(&self, subnet: &str, pubkey_hex: &str) -> bool {
        self.peers
            .iter()
            .any(|p| p.subnet == subnet && p.pubkey_hex == pubkey_hex)
    }

    /// All peers trusted in `subnet`.
    pub fn peers_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a TrustedPeer> {
        self.peers.iter().filter(move |p| p.subnet == subnet)
    }

    /// Record (pin) a peer after a successful pairing ceremony — the TOFU write.
    ///
    /// Returns the [`TrustOutcome`]: [`Pinned`](TrustOutcome::Pinned) for a first use,
    /// [`AlreadyTrusted`](TrustOutcome::AlreadyTrusted) for an idempotent re-pair, or
    /// [`KeyChanged`](TrustOutcome::KeyChanged) if a peer under the same `(subnet,
    /// label)` already holds a different key — in which case **nothing is written** and
    /// the caller must warn + decide ([`repin`](Self::repin)). The `(subnet, user)`
    /// scope is honored (a label is only an anchor within its own user scope).
    // [impl->REQ-PAIR-2]
    pub fn record(
        &mut self,
        subnet: &str,
        pubkey_hex: &str,
        label: Option<&str>,
        user: Option<&str>,
    ) -> TrustOutcome {
        // Exact identity already trusted → idempotent (a key isn't "changed" against
        // itself, even when a label is present).
        if self.is_trusted(subnet, pubkey_hex) {
            return TrustOutcome::AlreadyTrusted;
        }
        // Warn-on-change: a label already bound to a DIFFERENT key in this scope.
        if let Some(lbl) = label {
            if let Some(existing) = self.peers.iter().find(|p| {
                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(lbl)
            }) {
                return TrustOutcome::KeyChanged {
                    existing_pubkey_hex: existing.pubkey_hex.clone(),
                };
            }
        }
        self.peers.push(TrustedPeer {
            pubkey_hex: pubkey_hex.to_string(),
            subnet: subnet.to_string(),
            label: label.map(str::to_string),
            user: user.map(str::to_string),
            paired: now_stamp(),
        });
        TrustOutcome::Pinned
    }

    /// Apply an **accepted** key change for a labelled peer: replace the pinned key for
    /// `(subnet, label)` with `new_pubkey_hex`. Call this only after a
    /// [`KeyChanged`](TrustOutcome::KeyChanged) outcome was surfaced and the operator
    /// confirmed the rotation (otherwise a MITM's key would be trusted). Errors if no
    /// peer holds that label in scope.
    // [impl->REQ-PAIR-2]
    pub fn repin(
        &mut self,
        subnet: &str,
        label: &str,
        new_pubkey_hex: &str,
        user: Option<&str>,
    ) -> Result<(), TrustError> {
        let peer = self
            .peers
            .iter_mut()
            .find(|p| {
                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(label)
            })
            .ok_or_else(|| TrustError::NotFound(format!("{subnet}/{label}")))?;
        peer.pubkey_hex = new_pubkey_hex.to_string();
        peer.paired = now_stamp();
        Ok(())
    }

    /// Revoke a trusted peer (the node-removal trust-delete). Returns whether a peer was
    /// removed. Pair this with a [seed rotation](crate::subnet::SubnetStore::rotate_seed)
    /// — a trust-delete alone is not revocation while the seed is still replicated (#10).
    // [impl->REQ-PAIR-2]
    pub fn revoke(&mut self, subnet: &str, pubkey_hex: &str) -> bool {
        let before = self.peers.len();
        self.peers
            .retain(|p| !(p.subnet == subnet && p.pubkey_hex == pubkey_hex));
        self.peers.len() != before
    }
}

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

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

    // [unit->REQ-PAIR-2] TOFU first-use: a fresh peer pins (Pinned) and is then
    // trusted; re-recording the SAME key is idempotent (AlreadyTrusted), not a
    // duplicate — the post-ceremony accept path.
    #[test]
    fn first_use_pins_then_is_trusted_idempotently() {
        let mut store = TrustStore::default();
        assert_eq!(
            store.record("home", "aa11", None, None),
            TrustOutcome::Pinned
        );
        assert!(store.is_trusted("home", "aa11"));
        assert_eq!(
            store.record("home", "aa11", None, None),
            TrustOutcome::AlreadyTrusted,
            "re-pair with same key is idempotent"
        );
        assert_eq!(store.peers.len(), 1, "no duplicate row");
    }

    // [unit->REQ-PAIR-2] trust is per-subnet: the same node pubkey trusted in `home`
    // is NOT trusted in `work` until separately paired there (each subnet its own trust
    // context). An unknown key is simply untrusted.
    #[test]
    fn trust_is_scoped_per_subnet() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", None, None);
        assert!(store.is_trusted("home", "aa11"));
        assert!(
            !store.is_trusted("work", "aa11"),
            "not trusted in other subnet"
        );
        assert!(!store.is_trusted("home", "bb22"), "unknown key untrusted");
    }

    // [unit->REQ-PAIR-2] WARN-ON-CHANGE: a labelled peer that later presents a
    // DIFFERENT key returns KeyChanged carrying the existing key, and does NOT mutate
    // the store — the caller must decide. The MITM-vs-rotation guard.
    #[test]
    fn labelled_key_change_warns_without_mutating() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", Some("laptop"), None);
        let outcome = store.record("home", "bb22", Some("laptop"), None);
        assert_eq!(
            outcome,
            TrustOutcome::KeyChanged {
                existing_pubkey_hex: "aa11".into()
            }
        );
        assert!(store.is_trusted("home", "aa11"), "old key untouched");
        assert!(
            !store.is_trusted("home", "bb22"),
            "new key NOT auto-trusted"
        );
        assert_eq!(store.peers.len(), 1, "no row added on a warned change");
    }

    // [unit->REQ-PAIR-2] an ACCEPTED key change is applied by repin: the label now maps
    // to the new key and the old one is no longer trusted. Repinning an unknown label is
    // an error, not a silent insert.
    #[test]
    fn repin_applies_accepted_key_change() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", Some("laptop"), None);
        store.repin("home", "laptop", "bb22", None).expect("repin");
        assert!(store.is_trusted("home", "bb22"), "new key trusted");
        assert!(!store.is_trusted("home", "aa11"), "old key replaced");
        assert_eq!(
            store.repin("home", "ghost", "cc33", None).unwrap_err(),
            TrustError::NotFound("home/ghost".into())
        );
    }

    // [unit->REQ-PAIR-2] without a label there is no change anchor (v1 default): a
    // second distinct key pins as its own peer rather than warning. Documented honest
    // behavior — warn-on-change activates once labels exist.
    #[test]
    fn unlabelled_distinct_keys_each_pin() {
        let mut store = TrustStore::default();
        assert_eq!(
            store.record("home", "aa11", None, None),
            TrustOutcome::Pinned
        );
        assert_eq!(
            store.record("home", "bb22", None, None),
            TrustOutcome::Pinned
        );
        assert_eq!(store.peers.len(), 2);
        assert!(store.is_trusted("home", "aa11") && store.is_trusted("home", "bb22"));
    }

    // [unit->REQ-PAIR-2] revocation is a trust-delete: a revoked peer is no longer
    // trusted; a second revoke is a no-op false. (Pairs with seed rotation for real
    // revocation, #10.)
    #[test]
    fn revoke_removes_trust() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", None, None);
        assert!(store.revoke("home", "aa11"));
        assert!(!store.is_trusted("home", "aa11"));
        assert!(
            !store.revoke("home", "aa11"),
            "second revoke is no-op false"
        );
    }

    // [unit->REQ-PAIR-2] the same key can label-collide across DIFFERENT subnets without
    // a false warn — the change anchor is scoped to (subnet, user, label).
    #[test]
    fn label_anchor_is_subnet_scoped() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", Some("laptop"), None);
        // Same label, different subnet, different key → a clean first pin, not a warn.
        assert_eq!(
            store.record("work", "bb22", Some("laptop"), None),
            TrustOutcome::Pinned
        );
    }

    // [unit->REQ-PAIR-2] peers_in projects a subnet's trusted set across a multi-subnet
    // store.
    #[test]
    fn peers_in_filters_by_subnet() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", None, None);
        store.record("home", "bb22", None, None);
        store.record("work", "cc33", None, None);
        let home: Vec<_> = store.peers_in("home").map(|p| &p.pubkey_hex).collect();
        assert_eq!(home.len(), 2);
        assert!(home.contains(&&"aa11".to_string()));
        assert_eq!(store.peers_in("work").count(), 1);
    }

    // [unit->REQ-PAIR-2] the store round-trips through the file atomically (multi-subnet
    // + label + a repin all preserved), and an absent/corrupt file degrades to empty —
    // a damaged trust file must not brick the node.
    #[test]
    fn store_persists_and_degrades_gracefully() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("trust").join("peers.json");

        let mut store = TrustStore::default();
        store.record("home", "aa11", Some("laptop"), None);
        store.record("work", "cc33", None, None);
        store.repin("home", "laptop", "dd44", None).unwrap();
        store.save_to(&path).unwrap();

        let reloaded = TrustStore::load_from(&path);
        assert_eq!(reloaded.peers.len(), 2, "multi-subnet preserved");
        assert!(reloaded.is_trusted("home", "dd44"), "repin durable");
        assert!(reloaded.is_trusted("work", "cc33"));

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


Now the subnet store — where seed + epoch live, and how `is_trusted` is wired into nethost's accept path (currently nethost doesn't call is_trusted at all — pairing ALPN vs trusted ALPN is the boundary). Let me check subnet store + how the daemon plumbs subnet/seed to NetHost.


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Full data model clear. Key open design question for D2: **node is multi-subnet, each subnet its own seed/epoch — at connect, which subnet does the proof bind?** The transcript binds one `subnet_id`. Need ADR-0017's answer + how `dial` is called (does the caller know the subnet?).


1	# Subnet full-mesh via symmetric seed-proof membership + roster-only relay
2
3	## Status
4
5	accepted (2026-06-08) — amends ADR-0005 (pairing/trust) and ADR-0006 (multi-subnet membership); **preserves** KNOWN-HAZARDS 4.10 and 7.5 (does **not** supersede them — see Consequences). Design ratified in the 2026-06-08 grill-with-docs session (`SUBNET-MESH-PLAN.md` was the opening sketch; this ADR is the decided architecture).
6
7	## Context
8
9	A subnet was, until now, the **pairing graph, not a mesh**: a node saw only the peers it had *directly* paired with. Repro (user, 2026-06-08): A creates subnet → B joins A → A offline → C joins B → B offline → A online → **A and C can never see or reach each other**. Two v1-by-design gaps caused it:
10
11	1. **Authorization was pairwise TOFU pinning** (`spt-store::trust`): the inbound gate is `is_trusted(subnet, origin_node)` — A pinned B, C pinned B, A never pinned C, so A rejects C's origin.
12	2. **Gossip was own-rows-only with no roster and no relay** (`registryhost::advertise_local`, `peerloop`): a node pushed only its own registry rows to its directly-paired peers, and the pairing seed transfer (`pairing::wire::Frame::Seed`) carried only `{seed, epoch}` — no member list. So A never even *learned* C exists.
13
14	The plan's first instinct was **per-node-signed transitive row relay** — relay third-party registry rows, signed by their authors. That works but **breaks KNOWN-HAZARDS 4.10's safety assumption** ("v1 has no transitive gossip, so any future update for a node comes from that node itself, alive — no lagging third-party replay to mis-order against") and KH 7.5's per-author honesty, forcing a re-derivation of the eviction + epoch lease against lagging relayed rows. That re-derivation is subtle and was the plan's stated "subtle core."
15
16	The grill found a design that **dissolves that subtlety instead of solving it**, anchored on one fact: under ADR-0005 **every member node already holds the subnet seed**.
17
18	## Decision
19
20	**A subnet member is any node that can prove knowledge of the subnet's current-epoch seed.** Authorization moves from a stored pairwise pin to a **live, per-connection symmetric membership proof ("seed-proof")**; visibility is restored by relaying only a **member roster** (a discovery directory), never registry rows. Concretely:
21
22	1. **Membership proof (seed-proof) replaces `is_trusted` at every inbound gate** (registry apply, WAN message receive, sync, notif, connection accept). Derive a per-(subnet, seed-epoch) membership key `MK = HKDF(seed, domain ‖ subnet_id ‖ seed_epoch)`; at each QUIC connection a **mutual challenge–response** exchanges fresh nonces and `MAC(MK, transcript)` where the transcript binds **both handshake-proven node pubkeys, both nonces, subnet_id, seed_epoch, and role**. Channel-binding (both pubkeys in the MAC'd transcript) is load-bearing: a member cannot MITM-relay another member's proof onto a different connection. Verified **once per connection**, cached on the broker `ConnEntry`; connections are kept warm (QUIC keep-alive) so re-proof is rare (restart/partition/rotation only) and never a per-message tax. The node's per-node Ed25519 identity is unchanged (the QUIC handshake still proves it — KH 7.5). **Exact-epoch match** required (the re-seed path below is the sole N-1 exception).
23
24	2. **Member roster — relayed; registry rows — not.** The roster is a node-level union-merge grow-set (per member: pubkey, label, machine_id, last-known address, last-seen — **not** the seed), **seeded in full at pairing** (the seed-holder hands the joiner the whole current roster, so a fresh node knows even offline members) and merged on every member connection (each node authors its own entry, ordered by the per-node monotonic **lease epoch**, strictly-greater wins). Registry rows stay **own-authored**; the only change to gossip is that the push target widens from *directly-paired peers* to **all roster members** — a wider *direct* fan-out, never a third-party relay. So every row and message still arrives from its author over a handshake.
25
26	3. **Removal needs a tombstone.** A grow-set can't delete by omission (an un-tombstoned revokee re-inserts itself on its next seed-proof'd connect). `spt subnet revoke <node>...` writes a per-pubkey **tombstone** that propagates over member connections, dominates the roster entry, and augments the gate to **seed-proof ∧ ¬tombstoned**. A completed re-pair ceremony for a tombstoned pubkey clears its tombstone.
27
28	4. **Revocation = seed rotation, timeboxed.** The tombstone schedules **one** seed rotation (re-mint seed, bump `seed_epoch`, push the new seed **confidentially over member-authenticated TLS connections** — never in roster/registry gossip) at the close of a **coalescing window (default 1 h)**; further revokes in the window join the same rotation → one epoch bump however many nodes. `--force-rotate-seed` rotates immediately (compromised-node path). Elevation-gated, consistent with ADR-0006 membership mutations.
29
30	5. **Re-seed grace (auto-heal).** A benign member offline during a rotation returns on `seed_epoch − 1` and would fail exact-epoch proof. A node proving the **immediately-prior** epoch **and still on the roster** is granted a **re-seed-only** restricted connection that hands it the new seed and nothing else. The revoked node is off-roster → denied (not a revocation hole); a node ≥2 rotations stale gets no grace → re-pair. Batch/timeboxed revoke keeps multi-removal to one epoch bump so benign offliners stay inside this one-deep window.
31
32	6. **Warn-on-change demoted, hard cutover.** Pairwise authorization is retired; `peers.json` is deleted with **no migration** (the fleet is an expendable test ground until spt-core stabilizes — user decision 2026-06-08; the fleet re-pairs fresh under the new model). Warn-on-change survives only as an **awareness notice** (not a gate) anchored on **machine_id** (stable across reinstall; hostnames collide): "machine M, last seen as K1, now presents K2." Same event drives the REQ-SUBNET-7 re-pair overwrite.
33
34	Rejected alternative — **per-node-signed transitive row relay** (the plan's opening sketch): preserves per-author honesty but requires relaying third-party rows, which re-opens the KH 4.10 eviction lease against lagging replays and adds per-row signature machinery. It buys insider-honesty (one compromised member can't forge *another's* rows) that the v1 same-user threat model does not require — every member is the one user's own node, and a compromised member already holds the seed (full compromise, rotation-mitigated). Roster-only relay + direct row fetch achieves the same mesh visibility while **preserving** both hazards verbatim.
35
36	## Consequences
37
38	- **KNOWN-HAZARDS 4.10 and 7.5 are preserved, not superseded.** No registry row is ever relayed, so 7.5 ("origin is the handshake-proven node, never payload") holds verbatim and 4.10's eviction lease is untouched ("any future update for a node comes from that node itself, alive"). 4.10's wording is clarified: "no transitive **row** gossip" — the roster relays, rows do not. This is the central payoff of choosing roster-only relay over signed row relay.
39	- **Topology is O(N²) direct connections** (every member meshes every member). Trivial at same-user personal-fleet scale; iroh relay handles NAT. Only bites at large N, which same-user subnets are not.
40	- **A compromised member can impersonate membership and inject roster entries.** Both are inert beyond the existing model: it already holds the seed (ADR-0005 treats this as full subnet compromise, mitigated by rotation), and a forged roster entry names a pubkey that still cannot seed-proof. Seed-proof adds **no** new compromise surface.
41	- **Revocation requires the user to re-pair benign members stranded across multiple separate revoke events** (≥2 rotations stale, outside the one-deep re-seed grace). Batch/timeboxed revoke makes the common "remove several at once" case a single epoch bump.
42	- **New security invariants to test:** seed-proof must be channel-bound (no cross-connection replay) and mutual; the re-seed grace must be roster-gated (no revoked re-admit); the seed must never enter roster/registry gossip. Captured under `REQ-MESH-*`.
43	- **Amends** ADR-0005 (the trust model becomes membership-proof, not pairwise pin; warn-on-change demoted) and ADR-0006 (the cross-user seam now layers a roster-authority chain *on top of* seed-proof — symmetric membership generalizes to per-(subnet,user) membership later without reworking the data path).
44	- **Folds in** the deferred "pairing-time hostname capture" and "post-join address seeding" (DEFERRED.md) — both are roster fields delivered at the ceremony.
45	- **CONTEXT.md** updated inline (subnet member / membership proof, member roster, seed rotation / revoke, re-seed, warn-on-change reframe; trust store marked retired).
46

# Subnet full-mesh via symmetric seed-proof membership + roster-only relay

## Status

accepted (2026-06-08) — amends ADR-0005 (pairing/trust) and ADR-0006 (multi-subnet membership); **preserves** KNOWN-HAZARDS 4.10 and 7.5 (does **not** supersede them — see Consequences). Design ratified in the 2026-06-08 grill-with-docs session (`SUBNET-MESH-PLAN.md` was the opening sketch; this ADR is the decided architecture).

## Context

A subnet was, until now, the **pairing graph, not a mesh**: a node saw only the peers it had *directly* paired with. Repro (user, 2026-06-08): A creates subnet → B joins A → A offline → C joins B → B offline → A online → **A and C can never see or reach each other**. Two v1-by-design gaps caused it:

1. **Authorization was pairwise TOFU pinning** (`spt-store::trust`): the inbound gate is `is_trusted(subnet, origin_node)` — A pinned B, C pinned B, A never pinned C, so A rejects C's origin.
2. **Gossip was own-rows-only with no roster and no relay** (`registryhost::advertise_local`, `peerloop`): a node pushed only its own registry rows to its directly-paired peers, and the pairing seed transfer (`pairing::wire::Frame::Seed`) carried only `{seed, epoch}` — no member list. So A never even *learned* C exists.

The plan's first instinct was **per-node-signed transitive row relay** — relay third-party registry rows, signed by their authors. That works but **breaks KNOWN-HAZARDS 4.10's safety assumption** ("v1 has no transitive gossip, so any future update for a node comes from that node itself, alive — no lagging third-party replay to mis-order against") and KH 7.5's per-author honesty, forcing a re-derivation of the eviction + epoch lease against lagging relayed rows. That re-derivation is subtle and was the plan's stated "subtle core."

The grill found a design that **dissolves that subtlety instead of solving it**, anchored on one fact: under ADR-0005 **every member node already holds the subnet seed**.

## Decision

**A subnet member is any node that can prove knowledge of the subnet's current-epoch seed.** Authorization moves from a stored pairwise pin to a **live, per-connection symmetric membership proof ("seed-proof")**; visibility is restored by relaying only a **member roster** (a discovery directory), never registry rows. Concretely:

1. **Membership proof (seed-proof) replaces `is_trusted` at every inbound gate** (registry apply, WAN message receive, sync, notif, connection accept). Derive a per-(subnet, seed-epoch) membership key `MK = HKDF(seed, domain ‖ subnet_id ‖ seed_epoch)`; at each QUIC connection a **mutual challenge–response** exchanges fresh nonces and `MAC(MK, transcript)` where the transcript binds **both handshake-proven node pubkeys, both nonces, subnet_id, seed_epoch, and role**. Channel-binding (both pubkeys in the MAC'd transcript) is load-bearing: a member cannot MITM-relay another member's proof onto a different connection. Verified **once per connection**, cached on the broker `ConnEntry`; connections are kept warm (QUIC keep-alive) so re-proof is rare (restart/partition/rotation only) and never a per-message tax. The node's per-node Ed25519 identity is unchanged (the QUIC handshake still proves it — KH 7.5). **Exact-epoch match** required (the re-seed path below is the sole N-1 exception).

2. **Member roster — relayed; registry rows — not.** The roster is a node-level union-merge grow-set (per member: pubkey, label, machine_id, last-known address, last-seen — **not** the seed), **seeded in full at pairing** (the seed-holder hands the joiner the whole current roster, so a fresh node knows even offline members) and merged on every member connection (each node authors its own entry, ordered by the per-node monotonic **lease epoch**, strictly-greater wins). Registry rows stay **own-authored**; the only change to gossip is that the push target widens from *directly-paired peers* to **all roster members** — a wider *direct* fan-out, never a third-party relay. So every row and message still arrives from its author over a handshake.

3. **Removal needs a tombstone.** A grow-set can't delete by omission (an un-tombstoned revokee re-inserts itself on its next seed-proof'd connect). `spt subnet revoke <node>...` writes a per-pubkey **tombstone** that propagates over member connections, dominates the roster entry, and augments the gate to **seed-proof ∧ ¬tombstoned**. A completed re-pair ceremony for a tombstoned pubkey clears its tombstone.

4. **Revocation = seed rotation, timeboxed.** The tombstone schedules **one** seed rotation (re-mint seed, bump `seed_epoch`, push the new seed **confidentially over member-authenticated TLS connections** — never in roster/registry gossip) at the close of a **coalescing window (default 1 h)**; further revokes in the window join the same rotation → one epoch bump however many nodes. `--force-rotate-seed` rotates immediately (compromised-node path). Elevation-gated, consistent with ADR-0006 membership mutations.

5. **Re-seed grace (auto-heal).** A benign member offline during a rotation returns on `seed_epoch − 1` and would fail exact-epoch proof. A node proving the **immediately-prior** epoch **and still on the roster** is granted a **re-seed-only** restricted connection that hands it the new seed and nothing else. The revoked node is off-roster → denied (not a revocation hole); a node ≥2 rotations stale gets no grace → re-pair. Batch/timeboxed revoke keeps multi-removal to one epoch bump so benign offliners stay inside this one-deep window.

6. **Warn-on-change demoted, hard cutover.** Pairwise authorization is retired; `peers.json` is deleted with **no migration** (the fleet is an expendable test ground until spt-core stabilizes — user decision 2026-06-08; the fleet re-pairs fresh under the new model). Warn-on-change survives only as an **awareness notice** (not a gate) anchored on **machine_id** (stable across reinstall; hostnames collide): "machine M, last seen as K1, now presents K2." Same event drives the REQ-SUBNET-7 re-pair overwrite.

Rejected alternative — **per-node-signed transitive row relay** (the plan's opening sketch): preserves per-author honesty but requires relaying third-party rows, which re-opens the KH 4.10 eviction lease against lagging replays and adds per-row signature machinery. It buys insider-honesty (one compromised member can't forge *another's* rows) that the v1 same-user threat model does not require — every member is the one user's own node, and a compromised member already holds the seed (full compromise, rotation-mitigated). Roster-only relay + direct row fetch achieves the same mesh visibility while **preserving** both hazards verbatim.

## Consequences

- **KNOWN-HAZARDS 4.10 and 7.5 are preserved, not superseded.** No registry row is ever relayed, so 7.5 ("origin is the handshake-proven node, never payload") holds verbatim and 4.10's eviction lease is untouched ("any future update for a node comes from that node itself, alive"). 4.10's wording is clarified: "no transitive **row** gossip" — the roster relays, rows do not. This is the central payoff of choosing roster-only relay over signed row relay.
- **Topology is O(N²) direct connections** (every member meshes every member). Trivial at same-user personal-fleet scale; iroh relay handles NAT. Only bites at large N, which same-user subnets are not.
- **A compromised member can impersonate membership and inject roster entries.** Both are inert beyond the existing model: it already holds the seed (ADR-0005 treats this as full subnet compromise, mitigated by rotation), and a forged roster entry names a pubkey that still cannot seed-proof. Seed-proof adds **no** new compromise surface.
- **Revocation requires the user to re-pair benign members stranded across multiple separate revoke events** (≥2 rotations stale, outside the one-deep re-seed grace). Batch/timeboxed revoke makes the common "remove several at once" case a single epoch bump.
- **New security invariants to test:** seed-proof must be channel-bound (no cross-connection replay) and mutual; the re-seed grace must be roster-gated (no revoked re-admit); the seed must never enter roster/registry gossip. Captured under `REQ-MESH-*`.
- **Amends** ADR-0005 (the trust model becomes membership-proof, not pairwise pin; warn-on-change demoted) and ADR-0006 (the cross-user seam now layers a roster-authority chain *on top of* seed-proof — symmetric membership generalizes to per-(subnet,user) membership later without reworking the data path).
- **Folds in** the deferred "pairing-time hostname capture" and "post-join address seeding" (DEFERRED.md) — both are roster fields delivered at the ceremony.
- **CONTEXT.md** updated inline (subnet member / membership proof, member roster, seed rotation / revoke, re-seed, warn-on-change reframe; trust store marked retired).


ADR confirms the transcript binds `subnet_id` but doesn't resolve **which** subnet a connection proves when nodes are multi-subnet — the D1 `Challenge` frame carries only a nonce, no subnet_id. That's the real D2 design gap. Let me see how `is_trusted` is consumed at the gates (what D5 swaps) and whether dial sites know the subnet.