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	}
82
83	impl SubnetRecord {
84	    /// Decode the seed to raw bytes for the crypto layer. `None` if the stored hex is
85	    /// malformed or the wrong length (defensive — the store always writes valid hex).
86	    pub fn seed_bytes(&self) -> Option<[u8; TOTP_SEED_LEN]> {
87	        decode_hex(&self.seed_hex)
88	    }
89	}
90
91	/// The whole `subnet.json` document: every subnet the node is a member of.
92	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93	pub struct SubnetStore {
94	    /// All subnets this node belongs to (multi-subnet — N seeds).
95	    #[serde(default)]
96	    pub subnets: Vec<SubnetRecord>,
97	}
98
99	impl SubnetStore {
100	    /// Load the node's subnet store from the canonical [`subnet_file`]. A missing file
101	    /// is the empty store (an unpaired node) — never an error.
102	    pub fn load() -> SubnetStore {
103	        Self::load_from(&subnet_file())
104	    }
105
106	    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable
107	    /// or corrupt file also degrades to empty rather than panicking (a corrupt secret
108	    /// file should not brick the node — re-provision via pairing).
109	    pub fn load_from(path: &Path) -> SubnetStore {
110	        match std::fs::read_to_string(path) {
111	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
112	            Err(_) => SubnetStore::default(),
113	        }
114	    }
115
116	    /// Persist to the canonical [`subnet_file`], creating `identity/` if needed.
117	    pub fn save(&self) -> Result<(), SubnetError> {
118	        self.save_to(&subnet_file())
119	    }
120
121	    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
122	    /// concurrent reader never sees a torn secret file (the [`atomic`](crate::atomic)
123	    /// invariant).
124	    pub fn save_to(&self, path: &Path) -> Result<(), SubnetError> {
125	        if let Some(parent) = path.parent() {
126	            std::fs::create_dir_all(parent).map_err(|e| SubnetError::Io(e.to_string()))?;
127	        }
128	        let json =
129	            serde_json::to_string_pretty(self).map_err(|e| SubnetError::Io(e.to_string()))?;
130	        atomic_write_string(path, &json).map_err(|e| SubnetError::Io(e.to_string()))
131	    }
132
133	    /// Find a subnet by name (within the implicit-user scope).
134	    pub fn find(&self, name: &str) -> Option<&SubnetRecord> {
135	        self.subnets.iter().find(|s| s.name == name)
136	    }
137
138	    /// Create a new subnet with a freshly minted seed at epoch 1 (the "create-new"
139	    /// pairing path — this node becomes the sole seed-holder). Errors if the name
140	    /// already exists. Returns the new record.
141	    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
142	    pub fn create_subnet(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
143	        if self.find(name).is_some() {
144	            return Err(SubnetError::Exists(name.to_string()));
145	        }
146	        self.subnets.push(SubnetRecord {
147	            name: name.to_string(),
148	            seed_hex: encode_hex(&random_seed()),
149	            epoch: 1,
150	            user: None,
151	            created: now_stamp(),
152	        });
153	        Ok(self.subnets.last().expect("just pushed"))
154	    }
155
156	    /// Add a subnet whose seed was learned by **joining** an existing subnet (the seed
157	    /// and its current epoch arrive over the pairing channel from a seed-holder).
158	    /// Errors if the name already exists.
159	    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
160	    pub fn add_joined(
161	        &mut self,
162	        name: &str,
163	        seed: [u8; TOTP_SEED_LEN],
164	        epoch: u64,
165	    ) -> Result<&SubnetRecord, SubnetError> {
166	        if self.find(name).is_some() {
167	            return Err(SubnetError::Exists(name.to_string()));
168	        }
169	        self.subnets.push(SubnetRecord {
170	            name: name.to_string(),
171	            seed_hex: encode_hex(&seed),
172	            epoch,
173	            user: None,
174	            created: now_stamp(),
175	        });
176	        Ok(self.subnets.last().expect("just pushed"))
177	    }
178
179	    /// Rotate a subnet's seed and bump its epoch (ADR-0005 #10 — the node-removal
180	    /// response). The old seed can no longer compute a valid current code, and the
181	    /// epoch bump means even a captured old transcript won't bind. The new seed must
182	    /// then be redistributed to the *remaining* trusted nodes over the pairing
183	    /// channel; callers pair node-removal (trust-store delete) **with** this rotation,
184	    /// because a trust-delete alone is not revocation.
185	    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
186	    pub fn rotate_seed(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
187	        let rec = self
188	            .subnets
189	            .iter_mut()
190	            .find(|s| s.name == name)
191	            .ok_or_else(|| SubnetError::NotFound(name.to_string()))?;
192	        rec.seed_hex = encode_hex(&random_seed());
193	        rec.epoch += 1;
194	        Ok(rec)
195	    }
196
197	    /// Remove a subnet entirely (the node leaves it). Returns whether a record was
198	    /// removed. (Distinct from [`rotate_seed`]: leaving a subnet drops our own
199	    /// membership; rotating evicts *another* node from a subnet we keep.)
200	    pub fn remove(&mut self, name: &str) -> bool {
201	        let before = self.subnets.len();
202	        self.subnets.retain(|s| s.name != name);
203	        self.subnets.len() != before
204	    }
205	}
206
207	/// Mint 20 cryptographically-random seed bytes. Reuses spt-proto's Ed25519 seed
208	/// generator (OsRng) as the CSPRNG — the same no-extra-`rand`-dependency trick used
209	/// elsewhere — taking the first [`TOTP_SEED_LEN`] of its 32 random bytes.
210	fn random_seed() -> [u8; TOTP_SEED_LEN] {
211	    let mut out = [0u8; TOTP_SEED_LEN];
212	    out.copy_from_slice(&Identity::generate().seed()[..TOTP_SEED_LEN]);
213	    out
214	}
215
216	/// Lowercase-hex encode bytes (dependency-free; small fixed inputs).
217	fn encode_hex(bytes: &[u8]) -> String {
218	    bytes.iter().map(|b| format!("{b:02x}")).collect()
219	}
220
221	/// Decode lowercase/uppercase hex into a fixed-length seed. `None` on bad length or
222	/// non-hex digits.
223	fn decode_hex(s: &str) -> Option<[u8; TOTP_SEED_LEN]> {
224	    if s.len() != TOTP_SEED_LEN * 2 {
225	        return None;
226	    }
227	    let mut out = [0u8; TOTP_SEED_LEN];
228	    for (i, slot) in out.iter_mut().enumerate() {
229	        *slot = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
230	    }
231	    Some(out)
232	}
233
234	/// Current unix-seconds as an opaque stamp string.
235	fn now_stamp() -> String {
236	    std::time::SystemTime::now()
237	        .duration_since(std::time::UNIX_EPOCH)
238	        .map(|d| d.as_secs().to_string())
239	        .unwrap_or_else(|_| "0".to_string())
240	}
241
242	#[cfg(test)]
243	mod tests {
244	    use super::*;
245
246	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] a created subnet starts at epoch 1 with a
247	    // 20-byte seed that decodes cleanly; a second create of the same name is rejected.
248	    #[test]
249	    fn create_subnet_starts_at_epoch_one_unique_name() {
250	        let mut store = SubnetStore::default();
251	        let rec = store.create_subnet("home").expect("create");
252	        assert_eq!(rec.epoch, 1);
253	        assert_eq!(rec.name, "home");
254	        assert!(rec.seed_bytes().is_some(), "seed hex decodes to 20 bytes");
255	        assert_eq!(
256	            store.create_subnet("home").unwrap_err(),
257	            SubnetError::Exists("home".into())
258	        );
259	    }
260
261	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] rotation is the node-removal response:
262	    // the epoch bumps and the seed CHANGES, so an old-seed/old-epoch node can no
263	    // longer compute a valid code (the transcript binds the epoch). Trust-delete
264	    // alone would leave the seed intact — this is what makes removal real revocation.
265	    #[test]
266	    fn rotate_bumps_epoch_and_changes_seed() {
267	        let mut store = SubnetStore::default();
268	        let before = store.create_subnet("home").expect("create").clone();
269	        let after = store.rotate_seed("home").expect("rotate").clone();
270	        assert_eq!(after.epoch, before.epoch + 1, "epoch bumps");
271	        assert_ne!(after.seed_hex, before.seed_hex, "seed material rotates");
272	        assert_eq!(after.name, before.name, "name preserved");
273	        // Rotating an unknown subnet is an error, not a silent no-op.
274	        assert_eq!(
275	            store.rotate_seed("ghost").unwrap_err(),
276	            SubnetError::NotFound("ghost".into())
277	        );
278	    }
279
280	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] a joined subnet stores the seed + epoch
281	    // learned from the seed-holder verbatim (so a node that joined post-rotation is
282	    // on the current epoch, not epoch 1).
283	    #[test]
284	    fn add_joined_preserves_seed_and_epoch() {
285	        let mut store = SubnetStore::default();
286	        let seed = [7u8; TOTP_SEED_LEN];
287	        let rec = store.add_joined("work", seed, 5).expect("join").clone();
288	        assert_eq!(rec.epoch, 5);
289	        assert_eq!(rec.seed_bytes(), Some(seed));
290	        assert_eq!(
291	            store.add_joined("work", seed, 6).unwrap_err(),
292	            SubnetError::Exists("work".into())
293	        );
294	    }
295
296	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] the store round-trips through the file
297	    // atomically (multi-subnet preserved), and a rotation persists across reload —
298	    // the epoch survives, so revocation is durable.
299	    #[test]
300	    fn store_persists_multi_subnet_and_rotation() {
301	        let dir = tempfile::tempdir().unwrap();
302	        let path = dir.path().join("identity").join("subnet.json");
303
304	        let mut store = SubnetStore::default();
305	        store.create_subnet("home").unwrap();
306	        store.create_subnet("work").unwrap();
307	        store.rotate_seed("home").unwrap();
308	        store.save_to(&path).unwrap();
309
310	        let reloaded = SubnetStore::load_from(&path);
311	        assert_eq!(reloaded.subnets.len(), 2, "multi-subnet preserved");
312	        assert_eq!(
313	            reloaded.find("home").unwrap().epoch,
314	            2,
315	            "rotation durable across reload"
316	        );
317	        assert_eq!(reloaded.find("work").unwrap().epoch, 1);
318	    }
319
320	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] an absent or corrupt file degrades to an
321	    // empty store, never a panic — a damaged secret file must not brick the node
322	    // (re-provision via pairing).
323	    #[test]
324	    fn missing_or_corrupt_file_is_empty_store() {
325	        let dir = tempfile::tempdir().unwrap();
326	        let absent = dir.path().join("nope.json");
327	        assert!(SubnetStore::load_from(&absent).subnets.is_empty());
328
329	        let corrupt = dir.path().join("corrupt.json");
330	        std::fs::write(&corrupt, b"{ not json").unwrap();
331	        assert!(SubnetStore::load_from(&corrupt).subnets.is_empty());
332	    }
333
334	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] remove drops our membership and reports
335	    // whether anything was removed.
336	    #[test]
337	    fn remove_drops_membership() {
338	        let mut store = SubnetStore::default();
339	        store.create_subnet("home").unwrap();
340	        assert!(store.remove("home"));
341	        assert!(!store.remove("home"), "second remove is a no-op false");
342	        assert!(store.find("home").is_none());
343	    }
344
345	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] minted seeds are random (two creates
346	    // differ) — rotation actually changes key material, not a constant.
347	    #[test]
348	    fn minted_seeds_are_random() {
349	        let mut a = SubnetStore::default();
350	        let mut b = SubnetStore::default();
351	        let sa = a.create_subnet("x").unwrap().seed_hex.clone();
352	        let sb = b.create_subnet("x").unwrap().seed_hex.clone();
353	        assert_ne!(sa, sb);
354	    }
355
356	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] hex round-trips bytes exactly and rejects
357	    // bad length / non-hex.
358	    #[test]
359	    fn hex_round_trips_and_rejects_bad_input() {
360	        let bytes = [0xABu8; TOTP_SEED_LEN];
361	        assert_eq!(decode_hex(&encode_hex(&bytes)), Some(bytes));
362	        assert_eq!(decode_hex("abcd"), None, "wrong length");
363	        assert_eq!(
364	            decode_hex(&"zz".repeat(TOTP_SEED_LEN)),
365	            None,
366	            "non-hex digits"
367	        );
368	    }
369	}
370

//! `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,
}

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.
    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
    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(),
        });
        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(),
        });
        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)
    }

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


NOTE FROM THE USER: proceed withnextsteps.


Understood the full design surface. D2d = `pairing::ratelimit` — in-memory, per-subnet, per-node gate. Threat: public relay + multiple seed-holders → distributed SPAKE2 guessing; ±1 window triples valid-code space. Mitigation: one in-flight ceremony/subnet + shared failure counter + exp backoff. Time injected (no wall clock), matching crate convention.

Write the module.


Write

//! Subnet-global pairing rate limit (ADR-0005 SERIOUS #11, D2d) — the gate that makes
//! SPAKE2's "one online guess per ceremony" bound actually hold, and the justification
//! for keeping the ±1 TOTP window.
//!
//! ## The threat (ADR-0005 #11)
//!
//! SPAKE2 leaks at most one password guess per *completed* ceremony — but only while
//! ceremonies cannot be run in parallel or in a tight loop. A public pre-trust relay
//! lets any peer reach a seed-holder, and the [±1 window](super::totp::accepts_step)
//! widens the valid-code set from 1 to 3 of `10^6` (~21 bits → ~19). With no gate an
//! attacker drives back-to-back ceremonies (and, across multiple seed-holders, in
//! parallel) to brute the rotating code inside its 30s life. The required mitigation:
//! each seed-holder admits at most **one in-flight ceremony per subnet**, keeps a
//! **shared failure counter**, and imposes **exponential backoff** between attempts.
//!
//! ## Why this justifies the ±1 window
//!
//! The window triples the per-guess success probability (to `3 / 10^6 ≈ 3e-6`).
//! Serialization plus exponential backoff caps the guess *rate*, so the expected
//! guesses-to-break stays astronomically beyond a 30s code's lifetime regardless of
//! the 3× factor — #11's "justify or drop the ±1 window" clause is met by
//! justification, not by dropping the skew tolerance.
//!
//! ## Scope: per-node, per-subnet (the residual)
//!
//! State is in-memory and node-local: it bounds one seed-holder's admitted rate. With
//! `N` seed-holders the aggregate is `N×` a single node's bound — still a hard cap,
//! since every node independently serializes and backs off. A truly shared cross-node
//! counter needs the trusted P2P mesh (post-D3) and is deferred; this is the required
//! *local* gate each seed-holder runs. Backoff is ephemeral — a process restart resets
//! it — which is acceptable because restarting a seed-holder is not remotely
//! attacker-driven (unlike the relay-reachable ceremony surface itself).
//
// [impl->REQ-HAZARD-PAIR-RATE-LIMIT]

use std::collections::HashMap;

/// Backoff after the first failed/abandoned ceremony (seconds). Doubles per failure.
pub const BACKOFF_BASE_SECS: u64 = 1;

/// Backoff ceiling (seconds). One hour caps the exponential so a long attack settles
/// at a fixed (still punishing) retry interval instead of overflowing.
pub const BACKOFF_MAX_SECS: u64 = 3600;

/// A ceremony in flight longer than this is treated as abandoned and reclaimed (the
/// caller crashed, the peer vanished, or an attacker opened a ceremony and stalled to
/// wedge the subnet). The four-message ceremony is sub-second on a live link; 60s is
/// generous slack while still freeing a stuck slot.
pub const CEREMONY_TIMEOUT_SECS: u64 = 60;

/// Why a [`begin`](PairingRateLimiter::begin) was refused. Carries no secret material.
#[derive(Debug, PartialEq, Eq)]
pub enum RateLimited {
    /// Another ceremony for this subnet is already in flight (one-at-a-time): the
    /// serialization that denies parallel SPAKE2 guessing.
    Busy,
    /// Too many recent failures; the exponential-backoff window has not elapsed. Retry
    /// no sooner than `retry_after_secs` from the queried time.
    Backoff { retry_after_secs: u64 },
}

impl std::fmt::Display for RateLimited {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RateLimited::Busy => write!(f, "a pairing ceremony is already active for this subnet"),
            RateLimited::Backoff { retry_after_secs } => {
                write!(f, "pairing rate-limited — retry in {retry_after_secs}s")
            }
        }
    }
}

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

/// Per-subnet gate state: the one-at-a-time slot, the shared failure counter, and the
/// next time a ceremony may be admitted.
#[derive(Debug, Default, Clone)]
struct SubnetGate {
    /// `Some(start_unix)` while a ceremony holds the slot; `None` when free.
    active_since: Option<u64>,
    /// Consecutive failed/abandoned ceremonies — the exponential-backoff exponent.
    failures: u32,
    /// Earliest unix-second a new ceremony may be admitted (the backoff barrier).
    next_allowed: u64,
}

impl SubnetGate {
    /// Record a failed (or abandoned) ceremony: bump the counter and push the backoff
    /// barrier out from `now`.
    fn register_failure(&mut self, now: u64) {
        self.failures = self.failures.saturating_add(1);
        self.next_allowed = now.saturating_add(backoff_secs(self.failures));
    }
}

/// Subnet-keyed pairing admission control. The seed-holder (responder) side owns one
/// of these for the life of the process and consults it around every ceremony.
///
/// Time is passed in (`now`, unix seconds) rather than read from a clock, so backoff
/// is deterministically testable — the same dependency-injection the rest of
/// [`pairing`](super) uses for the TOTP step.
#[derive(Debug, Default)]
pub struct PairingRateLimiter {
    gates: HashMap<String, SubnetGate>,
}

impl PairingRateLimiter {
    /// A fresh limiter with no tracked subnets.
    pub fn new() -> PairingRateLimiter {
        PairingRateLimiter::default()
    }

    /// Try to admit a pairing ceremony for `subnet` at time `now` (unix seconds).
    ///
    /// On `Ok` the subnet's one-at-a-time slot is taken; the caller MUST later release
    /// it with exactly one of [`record_success`](Self::record_success) or
    /// [`record_failure`](Self::record_failure). A slot held past
    /// [`CEREMONY_TIMEOUT_SECS`] is reclaimed here and charged as a failure, so a
    /// crashed caller or a stalling attacker cannot wedge the subnet permanently.
    ///
    /// Refusals: [`Busy`](RateLimited::Busy) if a (non-stale) ceremony is active,
    /// [`Backoff`](RateLimited::Backoff) if the exponential-backoff window is open.
    pub fn begin(&mut self, subnet: &str, now: u64) -> Result<(), RateLimited> {
        let gate = self.gates.entry(subnet.to_string()).or_default();
        if let Some(start) = gate.active_since {
            if now.saturating_sub(start) < CEREMONY_TIMEOUT_SECS {
                return Err(RateLimited::Busy);
            }
            // Stale slot: the previous ceremony was abandoned. Reclaim it and charge a
            // failure so an attacker can't dodge backoff by simply never releasing.
            gate.active_since = None;
            gate.register_failure(now);
        }
        if now < gate.next_allowed {
            return Err(RateLimited::Backoff {
                retry_after_secs: gate.next_allowed - now,
            });
        }
        gate.active_since = Some(now);
        Ok(())
    }

    /// Release the slot after a *successful* ceremony: a confirmed pairing clears the
    /// failure counter and the backoff barrier (a legitimate join is proof the code
    /// holder is honest, not a guesser).
    pub fn record_success(&mut self, subnet: &str) {
        if let Some(gate) = self.gates.get_mut(subnet) {
            gate.active_since = None;
            gate.failures = 0;
            gate.next_allowed = 0;
        }
    }

    /// Release the slot after a *failed* ceremony (a confirmation-tag mismatch — i.e. a
    /// wrong code, the one signal SPAKE2 gives, see [`super::spake`]): increment the
    /// shared counter and extend the backoff window.
    pub fn record_failure(&mut self, subnet: &str, now: u64) {
        if let Some(gate) = self.gates.get_mut(subnet) {
            gate.active_since = None;
            gate.register_failure(now);
        }
    }
}

/// Exponential backoff for `failures` consecutive failed/abandoned ceremonies:
/// `BACKOFF_BASE_SECS * 2^(failures-1)`, saturating at [`BACKOFF_MAX_SECS`]. Zero
/// failures means no delay (the first attempt is always free).
pub fn backoff_secs(failures: u32) -> u64 {
    if failures == 0 {
        return 0;
    }
    let shift = failures - 1;
    // `1 << 63` is the largest non-overflowing shift; anything beyond is already far
    // past the cap. Guarding here keeps `checked_shl` from wrapping to a small value.
    if shift >= 63 {
        return BACKOFF_MAX_SECS;
    }
    BACKOFF_BASE_SECS
        .checked_shl(shift)
        .map(|v| v.min(BACKOFF_MAX_SECS))
        .unwrap_or(BACKOFF_MAX_SECS)
}

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

    // [unit->REQ-HAZARD-PAIR-RATE-LIMIT] the first ceremony is admitted with no delay
    // (failures=0), and a successful confirm releases the slot AND resets state — a
    // legitimate join must not leave the subnet rate-limited.
    #[test]
    fn first_ceremony_is_free_and_success_resets() {
        let mut rl = PairingRateLimiter::new();
        assert_eq!(rl.begin("home", 1000), Ok(()));
        rl.record_success("home");
        // Slot freed, counter clear: an immediate next ceremony is admitted.
        assert_eq!(rl.begin("home", 1000), Ok(()));
    }

    // [unit->REQ-HAZARD-PAIR-RATE-LIMIT] ONE ACTIVE CEREMONY PER SUBNET: a second
    // begin() while one is in flight is refused Busy — the serialization that denies
    // an attacker parallel SPAKE2 guesses against a single seed-holder.
    #[test]
    fn one_active_ceremony_per_subnet() {
        let mut rl = PairingRateLimiter::new();
        assert_eq!(rl.begin("home", 1000), Ok(()));
        assert_eq!(rl.begin("home", 1000), Err(RateLimited::Busy));
        assert_eq!(rl.begin("home", 1005), Err(RateLimited::Busy), "still held");
    }

    // [unit->REQ-HAZARD-PAIR-RATE-LIMIT] EXPONENTIAL BACKOFF: each failed ceremony
    // doubles the wait; a retry before the window elapses is refused with the exact
    // remaining time, and is admitted once the window passes.
    #[test]
    fn failures_impose_exponential_backoff() {
        let mut rl = PairingRateLimiter::new();
        // 1st failure → 1s backoff.
        rl.begin("home", 1000).unwrap();
        rl.record_failure("home", 1000);
        assert_eq!(
            rl.begin("home", 1000),
            Err(RateLimited::Backoff { retry_after_secs: 1 })
        );
        // After 1s the slot opens; that ceremony fails too → 2s backoff.
        rl.begin("home", 1001).unwrap();
        rl.record_failure("home", 1001);
        assert_eq!(
            rl.begin("home", 1001),
            Err(RateLimited::Backoff { retry_after_secs: 2 })
        );
        assert_eq!(
            rl.begin("home", 1002),
            Err(RateLimited::Backoff { retry_after_secs: 1 }),
            "remaining time counts down"
        );
        // 3rd failure → 4s. The doubling is what starves a sustained guesser.
        rl.begin("home", 1003).unwrap();
        rl.record_failure("home", 1003);
        assert_eq!(
            rl.begin("home", 1003),
            Err(RateLimited::Backoff { retry_after_secs: 4 })
        );
    }

    // [unit->REQ-HAZARD-PAIR-RATE-LIMIT] a success in the middle of a failure streak
    // clears the backoff — the counter tracks *consecutive* failed guesses, so an
    // honest pairing after a fat-fingered code isn't punished.
    #[test]
    fn success_clears_accumulated_backoff() {
        let mut rl = PairingRateLimiter::new();
        rl.begin("home", 1000).unwrap();
        rl.record_failure("home", 1000);
        rl.begin("home", 1001).unwrap();
        rl.record_failure("home", 1001); // failures=2, next_allowed=1003
        // Wait out the window, succeed, and the slate is wiped.
        rl.begin("home", 1003).unwrap();
        rl.record_success("home");
        rl.begin("home", 1003).unwrap();
        rl.record_failure("home", 1003);
        assert_eq!(
            rl.begin("home", 1003),
            Err(RateLimited::Backoff { retry_after_secs: 1 }),
            "back to a 1st-failure (1s) backoff, not 4s"
        );
    }

    // [unit->REQ-HAZARD-PAIR-RATE-LIMIT] an abandoned ceremony (begin without release)
    // is reclaimed after the timeout AND charged as a failure — a stalling attacker
    // cannot pin the slot forever, and gets backed off for trying.
    #[test]
    fn abandoned_ceremony_is_reclaimed_and_charged() {
        let mut rl = PairingRateLimiter::new();
        rl.begin("home", 1000).unwrap();
        // Still within the timeout: Busy, slot held.
        assert_eq!(
            rl.begin("home", 1000 + CEREMONY_TIMEOUT_SECS - 1),
            Err(RateLimited::Busy)
        );
        // Past the timeout: reclaimed, charged a failure → backoff (not a fresh admit).
        let now = 1000 + CEREMONY_TIMEOUT_SECS;
        assert_eq!(
            rl.begin("home", now),
            Err(RateLimited::Backoff { retry_after_secs: 1 }),
            "abandon counts as a failed attempt"
        );
    }

    // [unit->REQ-HAZARD-PAIR-RATE-LIMIT] PER-SUBNET isolation: backoff on one subnet
    // never gates a ceremony on another (each subnet's seed is independent, so its
    // guessing budget is too).
    #[test]
    fn backoff_is_per_subnet() {
        let mut rl = PairingRateLimiter::new();
        rl.begin("home", 1000).unwrap();
        rl.record_failure("home", 1000);
        assert!(matches!(
            rl.begin("home", 1000),
            Err(RateLimited::Backoff { .. })
        ));
        // A different subnet is unaffected.
        assert_eq!(rl.begin("work", 1000), Ok(()));
    }

    // [unit->REQ-HAZARD-PAIR-RATE-LIMIT] the backoff curve: 0 failures is free, then
    // 1,2,4,8,… doubling, saturating at the one-hour cap and never overflowing for
    // absurd failure counts. This is the rate ceiling that justifies the ±1 window —
    // 3 valid codes of 1e6 against a guess rate that decays exponentially stays far
    // out of reach within a 30s code's life.
    #[test]
    fn backoff_curve_doubles_then_caps() {
        assert_eq!(backoff_secs(0), 0, "first attempt free");
        assert_eq!(backoff_secs(1), 1);
        assert_eq!(backoff_secs(2), 2);
        assert_eq!(backoff_secs(3), 4);
        assert_eq!(backoff_secs(4), 8);
        // Strictly increasing until it pins to the cap.
        assert_eq!(backoff_secs(12), 2048);
        assert_eq!(backoff_secs(13), BACKOFF_MAX_SECS, "2^12=4096 clamped to 3600");
        assert_eq!(backoff_secs(60), BACKOFF_MAX_SECS, "stays capped");
        assert_eq!(backoff_secs(u32::MAX), BACKOFF_MAX_SECS, "no overflow panic");
    }
}


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

//! Subnet-global pairing rate limit (ADR-0005 SERIOUS #11, D2d) — the gate that makes
//! SPAKE2's "one online guess per ceremony" bound actually hold, and the justification
//! for keeping the ±1 TOTP window.
//!
//! ## The threat (ADR-0005 #11)
//!
//! SPAKE2 leaks at most one password guess per *completed* ceremony — but only while
//! ceremonies cannot be run in parallel or in a tight loop. A public pre-trust relay
//! lets any peer reach a seed-holder, and the [±1 window](super::totp::accepts_step)
//! widens the valid-code set from 1 to 3 of `10^6` (~21 bits → ~19). With no gate an
//! attacker drives back-to-back ceremonies (and, across multiple seed-holders, in
//! parallel) to brute the rotating code inside its 30s life. The required mitigation:
//! each seed-holder admits at most **one in-flight ceremony per subnet**, keeps a
//! **shared failure counter**, and imposes **exponential backoff** between attempts.
//!
//! ## Why this justifies the ±1 window
//!
//! The window triples the per-guess success probability (to `3 / 10^6 ≈ 3e-6`).
//! Serialization plus exponential backoff caps the guess *rate*, so the expected
//! guesses-to-break stays astronomically beyond a 30s code's lifetime regardless of
//! the 3× factor — #11's "justify or drop the ±1 window" clause is met by
//! justification, not by dropping the skew tolerance.
//!
//! ## Scope: per-node, per-subnet (the residual)
//!
//! State is in-memory and node-local: it bounds one seed-holder's admitted rate. With
//! `N` seed-holders the aggregate is `N×` a single node's bound — still a hard cap,
//! since every node independently serializes and backs off. A truly shared cross-node
//! counter needs the trusted P2P mesh (post-D3) and is deferred; this is the required
//! *local* gate each seed-holder runs. Backoff is ephemeral — a process restart resets
//! it — which is acceptable because restarting a seed-holder is not remotely
//! attacker-driven (unlike the relay-reachable ceremony surface itself).
//
// [impl->REQ-HAZARD-PAIR-RATE-LIMIT]

use std::collections::HashMap;

/// Backoff after the first failed/abandoned ceremony (seconds). Doubles per failure.
pub const BACKOFF_BASE_SECS: u64 = 1;

/// Backoff ceiling (seconds). One hour caps the exponential so a long attack settles
/// at a fixed (still punishing) retry interval instead of overflowing.
pub const BACKOFF_MAX_SECS: u64 = 3600;

/// A ceremony in flight longer than this is treated as abandoned and reclaimed (the
/// caller crashed, the peer vanished, or an attacker opened a ceremony and stalled to
/// wedge the subnet). The four-message ceremony is sub-second on a live link; 60s is
/// generous slack while still freeing a stuck slot.
pub const CEREMONY_TIMEOUT_SECS: u64 = 60;

/// Why a [`begin`](PairingRateLimiter::begin) was refused. Carries no secret material.
#[derive(Debug, PartialEq, Eq)]
pub enum RateLimited {
    /// Another ceremony for this subnet is already in flight (one-at-a-time): the
    /// serialization that denies parallel SPAKE2 guessing.
    Busy,
    /// Too many recent failures; the exponential-backoff window has not elapsed. Retry
    /// no sooner than `retry_after_secs` from the queried time.
    Backoff { retry_after_secs: u64 },
}

impl std::fmt::Display for RateLimited {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RateLimited::Busy => write!(f, "a pairing ceremony is already active for this subnet"),
            RateLimited::Backoff { retry_after_secs } => {
                write!(f, "pairing rate-limited — retry in {retry_after_secs}s")
            }
        }
    }
}

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

/// Per-subnet gate state: the one-at-a-time slot, the shared failure counter, and the
/// next time a ceremony may be admitted.
#[derive(Debug, Default, Clone)]
struct SubnetGate {
    /// `Some(start_unix)` while a ceremony holds the slot; `None` when free.
    active_since: Option<u64>,
    /// Consecutive failed/abandoned ceremonies — the exponential-backoff exponent.
    failures: u32,
    /// Earliest unix-second a new ceremony may be admitted (the backoff barrier).
    next_allowed: u64,
}

impl SubnetGate {
    /// Record a failed (or abandoned) ceremony: bump the counter and push the backoff
    /// barrier out from `now`.
    fn register_failure(&mut self, now: u64) {
        self.failures = self.failures.saturating_add(1);
        self.next_allowed = now.saturating_add(backoff_secs(self.failures));
    }
}

/// Subnet-keyed pairing admission control. The seed-holder (responder) side owns one
/// of these for the life of the process and consults it around every ceremony.
///
/// Time is passed in (`now`, unix seconds) rather than read from a clock, so backoff
/// is deterministically testable — the same dependency-injection the rest of
/// [`pairing`](super) uses for the TOTP step.
#[derive(Debug, Default)]
pub struct PairingRateLimiter {
    gates: HashMap<String, SubnetGate>,
}

impl PairingRateLimiter {
    /// A fresh limiter with no tracked subnets.
    pub fn new() -> PairingRateLimiter {
        PairingRateLimiter::default()
    }

    /// Try to admit a pairing ceremony for `subnet` at time `now` (unix seconds).
    ///
    /// On `Ok` the subnet's one-at-a-time slot is taken; the caller MUST later release
    /// it with exactly one of [`record_success`](Self::record_success) or
    /// [`record_failure`](Self::record_failure). A slot held past
    /// [`CEREMONY_TIMEOUT_SECS`] is reclaimed here and charged as a failure, so a
    /// crashed caller or a stalling attacker cannot wedge the subnet permanently.
    ///
    /// Refusals: [`Busy`](RateLimited::Busy) if a (non-stale) ceremony is active,
    /// [`Backoff`](RateLimited::Backoff) if the exponential-backoff window is open.
    pub fn begin(&mut self, subnet: &str, now: u64) -> Result<(), RateLimited> {
        let gate = self.gates.entry(subnet.to_string()).or_default();
        if let Some(start) = gate.active_since {
            if now.saturating_sub(start) < CEREMONY_TIMEOUT_SECS {
                return Err(RateLimited::Busy);
            }
            // Stale slot: the previous ceremony was abandoned. Reclaim it and charge a
            // failure so an attacker can't dodge backoff by simply never releasing.
            gate.active_since = None;
            gate.register_failure(now);
        }
        if now < gate.next_allowed {
            return Err(RateLimited::Backoff {
                retry_after_secs: gate.next_allowed - now,
            });
        }
        gate.active_since = Some(now);
        Ok(())
    }

    /// Release the slot after a *successful* ceremony: a confirmed pairing clears the
    /// failure counter and the backoff barrier (a legitimate join is proof the code
    /// holder is honest, not a guesser).
    pub fn record_success(&mut self, subnet: &str) {
        if let Some(gate) = self.gates.get_mut(subnet) {
            gate.active_since = None;
            gate.failures = 0;
            gate.next_allowed = 0;
        }
    }

    /// Release the slot after a *failed* ceremony (a confirmation-tag mismatch — i.e. a
    /// wrong code, the one signal SPAKE2 gives, see [`super::spake`]): increment the
    /// shared counter and extend the backoff window.
    pub fn record_failure(&mut self, subnet: &str, now: u64) {
        if let Some(gate) = self.gates.get_mut(subnet) {
            gate.active_since = None;
            gate.register_failure(now);
        }
    }
}

/// Exponential backoff for `failures` consecutive failed/abandoned ceremonies:
/// `BACKOFF_BASE_SECS * 2^(failures-1)`, saturating at [`BACKOFF_MAX_SECS`]. Zero
/// failures means no delay (the first attempt is always free).
pub fn backoff_secs(failures: u32) -> u64 {
    if failures == 0 {
        return 0;
    }
    let shift = failures - 1;
    // `1 << 63` is the largest non-overflowing shift; anything beyond is already far
    // past the cap. Guarding here keeps `checked_shl` from wrapping to a small value.
    if shift >= 63 {
        return BACKOFF_MAX_SECS;
    }
    BACKOFF_BASE_SECS
        .checked_shl(shift)
        .map(|v| v.min(BACKOFF_MAX_SECS))
        .unwrap_or(BACKOFF_MAX_SECS)
}

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

    // [unit->REQ-HAZARD-PAIR-RATE-LIMIT] the first ceremony is admitted with no delay
    // (failures=0), and a successful confirm releases the slot AND resets state — a
    // legitimate join must not leave the subnet rate-limited.
    #[test]
    fn first_ceremony_is_free_and_success_resets() {
        let mut rl = PairingRateLimiter::new();
        assert_eq!(rl.begin("home", 1000), Ok(()));
        rl.record_success("home");
        // Slot freed, counter clear: an immediate next ceremony is admitted.
        assert_eq!(rl.begin("home", 1000), Ok(()));
    }

    // [unit->REQ-HAZARD-PAIR-RATE-LIMIT] ONE ACTIVE CEREMONY PER SUBNET: a second
    // begin() while one is in flight is refused Busy — the serialization that denies
    // an attacker parallel SPAKE2 guesses against a single seed-holder.
    #[test]
    fn one_active_ceremony_per_subnet() {
        let mut rl = PairingRateLimiter::new();
        assert_eq!(rl.begin("home", 1000), Ok(()));
        assert_eq!(rl.begin("home", 1000), Err(RateLimited::Busy));
        assert_eq!(rl.begin("home", 1005), Err(RateLimited::Busy), "still held");
    }

    // [unit->REQ-HAZARD-PAIR-RATE-LIMIT] EXPONENTIAL BACKOFF: each failed ceremony
    // doubles the wait; a retry before the window elapses is refused with the exact
    // remaining time, and is admitted once the window passes.
    #[test]
    fn failures_impose_exponential_backoff() {
        let mut rl = PairingRateLimiter::new();
        // 1st failure → 1s backoff.
        rl.begin("home", 1000).unwrap();
        rl.record_failure("home", 1000);
        assert_eq!(
            rl.begin("home", 1000),
            Err(RateLimited::Backoff { retry_after_secs: 1 })
        );
        // After 1s the slot opens; that ceremony fails too → 2s backoff.
        rl.begin("home", 1001).unwrap();
        rl.record_failure("home", 1001);
        assert_eq!(
            rl.begin("home", 1001),
            Err(RateLimited::Backoff { retry_after_secs: 2 })
        );
        assert_eq!(
            rl.begin("home", 1002),
            Err(RateLimited::Backoff { retry_after_secs: 1 }),
            "remaining time counts down"
        );
        // 3rd failure → 4s. The doubling is what starves a sustained guesser.
        rl.begin("home", 1003).unwrap();
        rl.record_failure("home", 1003);
        assert_eq!(
            rl.begin("home", 1003),
            Err(RateLimited::Backoff { retry_after_secs: 4 })
        );
    }

    // [unit->REQ-HAZARD-PAIR-RATE-LIMIT] a success in the middle of a failure streak
    // clears the backoff — the counter tracks *consecutive* failed guesses, so an
    // honest pairing after a fat-fingered code isn't punished.
    #[test]
    fn success_clears_accumulated_backoff() {
        let mut rl = PairingRateLimiter::new();
        rl.begin("home", 1000).unwrap();
        rl.record_failure("home", 1000);
        rl.begin("home", 1001).unwrap();
        rl.record_failure("home", 1001); // failures=2, next_allowed=1003
        // Wait out the window, succeed, and the slate is wiped.
        rl.begin("home", 1003).unwrap();
        rl.record_success("home");
        rl.begin("home", 1003).unwrap();
        rl.record_failure("home", 1003);
        assert_eq!(
            rl.begin("home", 1003),
            Err(RateLimited::Backoff { retry_after_secs: 1 }),
            "back to a 1st-failure (1s) backoff, not 4s"
        );
    }

    // [unit->REQ-HAZARD-PAIR-RATE-LIMIT] an abandoned ceremony (begin without release)
    // is reclaimed after the timeout AND charged as a failure — a stalling attacker
    // cannot pin the slot forever, and gets backed off for trying.
    #[test]
    fn abandoned_ceremony_is_reclaimed_and_charged() {
        let mut rl = PairingRateLimiter::new();
        rl.begin("home", 1000).unwrap();
        // Still within the timeout: Busy, slot held.
        assert_eq!(
            rl.begin("home", 1000 + CEREMONY_TIMEOUT_SECS - 1),
            Err(RateLimited::Busy)
        );
        // Past the timeout: reclaimed, charged a failure → backoff (not a fresh admit).
        let now = 1000 + CEREMONY_TIMEOUT_SECS;
        assert_eq!(
            rl.begin("home", now),
            Err(RateLimited::Backoff { retry_after_secs: 1 }),
            "abandon counts as a failed attempt"
        );
    }

    // [unit->REQ-HAZARD-PAIR-RATE-LIMIT] PER-SUBNET isolation: backoff on one subnet
    // never gates a ceremony on another (each subnet's seed is independent, so its
    // guessing budget is too).
    #[test]
    fn backoff_is_per_subnet() {
        let mut rl = PairingRateLimiter::new();
        rl.begin("home", 1000).unwrap();
        rl.record_failure("home", 1000);
        assert!(matches!(
            rl.begin("home", 1000),
            Err(RateLimited::Backoff { .. })
        ));
        // A different subnet is unaffected.
        assert_eq!(rl.begin("work", 1000), Ok(()));
    }

    // [unit->REQ-HAZARD-PAIR-RATE-LIMIT] the backoff curve: 0 failures is free, then
    // 1,2,4,8,… doubling, saturating at the one-hour cap and never overflowing for
    // absurd failure counts. This is the rate ceiling that justifies the ±1 window —
    // 3 valid codes of 1e6 against a guess rate that decays exponentially stays far
    // out of reach within a 30s code's life.
    #[test]
    fn backoff_curve_doubles_then_caps() {
        assert_eq!(backoff_secs(0), 0, "first attempt free");
        assert_eq!(backoff_secs(1), 1);
        assert_eq!(backoff_secs(2), 2);
        assert_eq!(backoff_secs(3), 4);
        assert_eq!(backoff_secs(4), 8);
        // Strictly increasing until it pins to the cap.
        assert_eq!(backoff_secs(12), 2048);
        assert_eq!(backoff_secs(13), BACKOFF_MAX_SECS, "2^12=4096 clamped to 3600");
        assert_eq!(backoff_secs(60), BACKOFF_MAX_SECS, "stays capped");
        assert_eq!(backoff_secs(u32::MAX), BACKOFF_MAX_SECS, "no overflow panic");
    }
}


Wire module into pairing.rs + flip doc-tree line to active.


Edit

//!   (ratelimit)   subnet-global one-ceremony + shared counter + backoff (#11)       D2d

//!   [`ratelimit`]  subnet-global one-ceremony + shared counter + backoff (#11)       ← D2d


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


15	//!   (spake)       SPAKE2 handshake over the pairing ALPN                            D2a
16	//!   (transcript)  confirmation MAC binding roles/pubkeys/subnet/epoch/step (#12)    D2b
17	//!   (epoch)       seed epoch + rotation — trust-delete ≠ revocation (#10)           D2c
18	//!   (ratelimit)   subnet-global one-ceremony + shared counter + backoff (#11)       D2d
19	//!
20	//! ## Why a dedicated pre-trust ALPN
21	//!
22	//! Pairing happens *before* mutual trust exists, so it cannot ride the trusted
23	//! [`SPT_NET_ALPN`](super::endpoint::SPT_NET_ALPN). A separate pairing ALPN lets the
24	//! relay permit pre-trust contact (a seed-holder must be reachable by the untrusted
25	//! new node) without exposing the trusted message surface.
26
27	pub mod spake;
28	pub mod totp;
29	pub mod transcript;
30

//!   (spake)       SPAKE2 handshake over the pairing ALPN                            D2a
//!   (transcript)  confirmation MAC binding roles/pubkeys/subnet/epoch/step (#12)    D2b
//!   (epoch)       seed epoch + rotation — trust-delete ≠ revocation (#10)           D2c
//!   (ratelimit)   subnet-global one-ceremony + shared counter + backoff (#11)       D2d
//!
//! ## Why a dedicated pre-trust ALPN
//!
//! Pairing happens *before* mutual trust exists, so it cannot ride the trusted
//! [`SPT_NET_ALPN`](super::endpoint::SPT_NET_ALPN). A separate pairing ALPN lets the
//! relay permit pre-trust contact (a seed-holder must be reachable by the untrusted
//! new node) without exposing the trusted message surface.

pub mod spake;
pub mod totp;
pub mod transcript;


//!   (ratelimit)   subnet-global one-ceremony + shared counter + backoff (#11)       D2d

//!   [`ratelimit`]  subnet-global one-ceremony + shared counter + backoff (#11)      ← D2d


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

//! TOTP-seeded SPAKE2 node pairing (ADR-0005) — the first-contact ceremony that
//! authorizes a new node into a user's subnet with no account and no central
//! operator.
//!
//! The construction (ADR-0005, `CONTEXT.md` "Pairing & trust"): a durable **TOTP
//! seed** is the subnet secret, replicated to every already-trusted node and stored
//! in the user's authenticator app. To pair a new node the rotating 6-digit code is
//! used as the **password for a SPAKE2 (PAKE) handshake** — *not* a bearer token —
//! so the low-entropy code becomes MITM-resistant and an attacker gets one online
//! guess per attempt (no offline brute force).
//!
//! ## Module tree (M4-D2 sub-tasks)
//!
//!   [`totp`]      RFC 6238 seed + code + ±1 window + step counter + otpauth URI   ← D2a
//!   (spake)       SPAKE2 handshake over the pairing ALPN                            D2a
//!   (transcript)  confirmation MAC binding roles/pubkeys/subnet/epoch/step (#12)    D2b
//!   (epoch)       seed epoch + rotation — trust-delete ≠ revocation (#10)           D2c
//!   (ratelimit)   subnet-global one-ceremony + shared counter + backoff (#11)       D2d
//!
//! ## Why a dedicated pre-trust ALPN
//!
//! Pairing happens *before* mutual trust exists, so it cannot ride the trusted
//! [`SPT_NET_ALPN`](super::endpoint::SPT_NET_ALPN). A separate pairing ALPN lets the
//! relay permit pre-trust contact (a seed-holder must be reachable by the untrusted
//! new node) without exposing the trusted message surface.

pub mod spake;
pub mod totp;
pub mod transcript;

-//!   (ratelimit)   subnet-global one-ceremony + shared counter + backoff (#11)       D2d

+//!   [`ratelimit`]  subnet-global one-ceremony + shared counter + backoff (#11)      ← D2d


pub mod spake;
pub mod totp;
pub mod transcript;

pub mod ratelimit;
pub mod spake;
pub mod totp;
pub mod transcript;


pub mod ratelimit;
pub mod spake;
pub mod totp;
pub mod transcript;

//! TOTP-seeded SPAKE2 node pairing (ADR-0005) — the first-contact ceremony that
//! authorizes a new node into a user's subnet with no account and no central
//! operator.
//!
//! The construction (ADR-0005, `CONTEXT.md` "Pairing & trust"): a durable **TOTP
//! seed** is the subnet secret, replicated to every already-trusted node and stored
//! in the user's authenticator app. To pair a new node the rotating 6-digit code is
//! used as the **password for a SPAKE2 (PAKE) handshake** — *not* a bearer token —
//! so the low-entropy code becomes MITM-resistant and an attacker gets one online
//! guess per attempt (no offline brute force).
//!
//! ## Module tree (M4-D2 sub-tasks)
//!
//!   [`totp`]      RFC 6238 seed + code + ±1 window + step counter + otpauth URI   ← D2a
//!   (spake)       SPAKE2 handshake over the pairing ALPN                            D2a
//!   (transcript)  confirmation MAC binding roles/pubkeys/subnet/epoch/step (#12)    D2b
//!   (epoch)       seed epoch + rotation — trust-delete ≠ revocation (#10)           D2c
//!   [`ratelimit`]  subnet-global one-ceremony + shared counter + backoff (#11)      ← D2d
//!
//! ## Why a dedicated pre-trust ALPN
//!
//! Pairing happens *before* mutual trust exists, so it cannot ride the trusted
//! [`SPT_NET_ALPN`](super::endpoint::SPT_NET_ALPN). A separate pairing ALPN lets the
//! relay permit pre-trust contact (a seed-holder must be reachable by the untrusted
//! new node) without exposing the trusted message surface.

pub mod spake;
pub mod totp;
pub mod transcript;


Activate the req (rule 5: set real stages at the milestone delivering it).


562	[[requirements]]
563	id = "REQ-HAZARD-PAIR-RATE-LIMIT"
564	title = "Subnet-global pairing rate limit: one active ceremony per subnet, shared attempt counter, exponential backoff — a public pre-trust relay + multiple seed-holders otherwise enables distributed SPAKE2 guessing (and ±1 TOTP window triples the valid-password space) (ADR-0005 #11)"
565	required_stages = []  # rule 5: activate at M4-D2d when the shared counter + backoff + ±1-window justify-or-drop lands
566

[[requirements]]
id = "REQ-HAZARD-PAIR-RATE-LIMIT"
title = "Subnet-global pairing rate limit: one active ceremony per subnet, shared attempt counter, exponential backoff — a public pre-trust relay + multiple seed-holders otherwise enables distributed SPAKE2 guessing (and ±1 TOTP window triples the valid-password space) (ADR-0005 #11)"
required_stages = []  # rule 5: activate at M4-D2d when the shared counter + backoff + ±1-window justify-or-drop lands


required_stages = []  # rule 5: activate at M4-D2d when the shared counter + backoff + ±1-window justify-or-drop lands

required_stages = ["impl", "unit"]  # activated M4-D2d: spt-net::pairing::ratelimit PairingRateLimiter — one in-flight ceremony per subnet (Busy), shared consecutive-failure counter, exponential backoff (1s→cap 3600s, abandoned ceremonies reclaimed+charged), per-subnet isolated. The ±1 TOTP window is JUSTIFIED (not dropped): serialization + exp backoff caps the guess rate so 3/1e6 success-per-guess stays out of reach inside a 30s code. int = on-wire distributed-guessing rejection at D2-wire/D9.


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

title = "Subnet-global pairing rate limit: one active ceremony per subnet, shared attempt counter, exponential backoff — a public pre-trust relay + multiple seed-holders otherwise enables distributed SPAKE2 guessing (and ±1 TOTP window triples the valid-password space) (ADR-0005 #11)"

-required_stages = []  # rule 5: activate at M4-D2d when the shared counter + backoff + ±1-window justify-or-drop lands

+required_stages = ["impl", "unit"]  # activated M4-D2d: spt-net::pairing::ratelimit PairingRateLimiter — one in-flight ceremony per subnet (Busy), shared consecutive-failure counter, exponential backoff (1s→cap 3600s, abandoned ceremonies reclaimed+charged), per-subnet isolated. The ±1 TOTP window is JUSTIFIED (not dropped): serialization + exp backoff caps the guess rate so 3/1e6 success-per-guess stays out of reach inside a 30s code. int = on-wire distributed-guessing rejection at D2-wire/D9.


Build + test spt-net, then traceability check.


Bash

cargo test -p spt-net pairing::ratelimit 2>&1 | tail -30

Build + run ratelimit tests


   Compiling spt-net v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 4.44s
     Running unittests src\lib.rs (target\debug\deps\spt_net-b8fb0a38adb7f2c3.exe)

running 7 tests
test net::pairing::ratelimit::tests::backoff_curve_doubles_then_caps ... ok
test net::pairing::ratelimit::tests::one_active_ceremony_per_subnet ... ok
test net::pairing::ratelimit::tests::abandoned_ceremony_is_reclaimed_and_charged ... ok
test net::pairing::ratelimit::tests::first_ceremony_is_free_and_success_resets ... ok
test net::pairing::ratelimit::tests::failures_impose_exponential_backoff ... ok
test net::pairing::ratelimit::tests::backoff_is_per_subnet ... ok
test net::pairing::ratelimit::tests::success_clears_accumulated_backoff ... ok

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 25 filtered out; finished in 0.00s


7/7 green. Clippy on edited crate + traceability check.


cargo clippy -p spt-net --all-targets -- -D warnings 2>&1 | tail -15

Clippy deny-warnings on spt-net


    Checking futures-lite v2.6.1
    Checking spt-proto v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-proto)
    Checking backon v1.6.0
    Checking tempfile v3.27.0
    Checking spt-store v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-store)
    Checking n0-future v0.3.2
    Checking spt-msg v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-msg)
    Checking n0-watcher v0.6.1
    Checking iroh-dns v0.98.0
    Checking netwatch v0.16.0
    Checking iroh-relay v0.98.0
    Checking portmapper v0.16.0
    Checking iroh v0.98.2
    Checking spt-net v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.76s


Clippy clean. Traceability check.


Run traceability gate


       Harness-hosted startup: api seed then listen
  [OK] REQ-START-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       spt-hosted startup: spawn-session then api bind (no file)
  [OK] REQ-START-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Adapter-injected env aliases (SPT/OWL/LIVE)
  [OK] REQ-STORE-1  required: []  stages: -doc -impl -unit -int
       spt-store::BranchStore (git branch as versioned KV; commit=checkpoint/tip=resume, atomic multi-key, merge-native sync) is the substrate for coarse/durable/audited state (context, registry snapshot+distribution, daemon checkpoint); hot paths (B5 fsync journal) + indexed queries (SQLite spool) excluded (ADR-0011)
  [OK] REQ-TERM-1  required: [impl, unit]  stages: -doc +impl +unit -int
       Process-supervisor terminal wrapper hosting broker PTYs
  [OK] REQ-TERM-2  required: [impl, unit]  stages: -doc +impl +unit -int
       session-surface abstraction; send-keys + send-line injection
  [OK] REQ-TERM-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Byte-stream remote terminal streaming for v1
  [OK] REQ-TERM-4  required: [impl, unit, int]  stages: +doc +impl +unit +int
       Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log
  [OK] REQ-UPD-1  required: []  stages: -doc -impl -unit -int
       Peer-propagated update over P2P
  [OK] REQ-UPD-2  required: [impl, unit]  stages: -doc +impl +unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-core ripple-updates registered adapters


traceable-reqs check 2>&1 | grep -iE "RATE-LIMIT|FAIL|ERROR|exit|missing|invalid|✗|\[X\]" | head; echo "---EXIT---"; traceable-reqs check >/dev/null 2>&1; echo $?

Confirm rate-limit covered + exit code


       Ephemeral perch cleanup on every ring exit path (3.1)
  [OK] REQ-HAZARD-PAIR-RATE-LIMIT  required: [impl, unit]  stages: -doc +impl +unit -int
       Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)
       Stale registry entries degrade to fallback, never hard-fail (4.3)
       spt binary CLI surface: send/ring/ready/poll/list/stop/whoami, stable arg shapes + exit codes
---EXIT---
0


Covered (+impl +unit), exit 0. Commit. Check trailer format first.


git log -1 --format='%(trailers)' 597786e; echo "---"; git diff --stat

Show co-author trailer + diff stat


Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---
warning: in the working copy of 'crates/spt-net/src/net/pairing.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
 crates/spt-net/src/net/pairing.rs | 3 ++-
 traceable-reqs.toml               | 2 +-
 2 files changed, 3 insertions(+), 2 deletions(-)


git add crates/spt-net/src/net/pairing/ratelimit.rs crates/spt-net/src/net/pairing.rs traceable-reqs.toml && git status -sb

Stage edited files


warning: in the working copy of 'crates/spt-net/src/net/pairing.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/pairing/ratelimit.rs', LF will be replaced by CRLF the next time Git touches it
## dev-freeform...origin/dev-freeform
M  crates/spt-net/src/net/pairing.rs
A  crates/spt-net/src/net/pairing/ratelimit.rs
M  traceable-reqs.toml


git commit -q -F - <<'EOF'
feat(net): M4-D2d subnet-global pairing rate limit (#11)

pairing::ratelimit PairingRateLimiter — the gate that makes SPAKE2's
one-online-guess bound hold and justifies the ±1 TOTP window:

- one in-flight ceremony per subnet (Busy) — serializes guesses
- shared consecutive-failure counter, reset on a confirmed pairing
- exponential backoff 1s→cap 3600s, no overflow at u32::MAX failures
- abandoned ceremonies (begin without release) reclaimed after 60s and
  charged as a failure, so a stalling attacker can't wedge the slot
- per-subnet isolation; time injected (no wall clock) for deterministic tests

The ±1 window is JUSTIFIED, not dropped (ADR-0005 #11 clause): serialization
+ exp backoff caps the guess rate so 3/1e6 success-per-guess stays out of
reach inside a 30s code's life. State is in-memory + node-local; cross-node
shared counter deferred to the trusted P2P mesh (post-D3).

Activates REQ-HAZARD-PAIR-RATE-LIMIT [impl, unit].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git log --oneline -1

Commit D2d


b0bdaab feat(net): M4-D2d subnet-global pairing rate limit (#11)


Push to origin


To https://github.com/SaberMage/spt-core.git
   75f967c..b0bdaab  dev-freeform -> dev-freeform


D2d shipped + pushed (b0bdaab). Next per track: D2e trust store TOFU (REQ-PAIR-2) — psyche flagged "design carefully, re-read ADR-0009 + registry". Watch CI green while I read the design surface.


sleep 8; gh run list --limit 1 --json databaseId,status,headSha -q '.[0]'

Get latest CI run id


gh run watch 26909693469 --exit-status 2>&1 | grep -vE "Node.js 20|github.blog|FORCE_JAVASCRIPT|ACTIONS_ALLOW|windows-2025|deprecation" | tail -12

Watch CI on b0bdaab


Command running in background with ID: bs83skgdr. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bs83skgdr.output. You will be notified when it completes. To check interim output, use Read on that file path.


Read D2e design surface while CI runs: ADR-0009 + trust registry + REQ-PAIR-2.


find . -path ./target -prune -o -name "0009*.md" -print 2>/dev/null; echo "===TRUST FILES==="; find . -path ./target -prune -o -name "*.rs" -print 2>/dev/null | grep -iE "trust|registr" | head; echo "===REQ-PAIR-2==="; grep -n -A6 'id = "REQ-PAIR-2"' traceable-reqs.toml

Locate ADR-0009, trust/registry source, REQ-PAIR-2


./docs/adr/0009-endpoint-access-control-model.md
===TRUST FILES===
./crates/spt-store/src/registry.rs
===REQ-PAIR-2===
270:id = "REQ-PAIR-2"
271-title = "Local trust store with TOFU + warn-on-change"
272-required_stages = []
273-[[requirements]]
274-id = "REQ-PAIR-3"
275-title = "Fetch current pairing code from any paired node"
276-required_stages = []


1	# Endpoint access-control model (visibility / whitelist / grants)
2
3	## Status
4
5	accepted (2026-06-02)
6
7	## Context
8
9	Three access/discovery concepts accreted around the multi-subnet model and risked overlapping into a muddle: **endpoint visibility** (ADR-0006 — is an endpoint routable in a subnet at all?), a new **per-endpoint access whitelist** (who may *remotely reach* an endpoint), and the existing **consent grant store** (may *this agent* do *this high-risk thing*?). A new **resource advertisement** (free-text service blurb) also needed to know which access layer gates it. Folding the whitelist into the grant store was tempting but the two differ on two axes — subject granularity (origin **node/user** vs **agent**) and default polarity (**open** vs **deny**) — so merging would corrupt both. This ADR fixes the layering.
10
11	## Decision
12
13	Model access as **three orthogonal, nested gates**, evaluated in order:
14
15	1. **Subnet visibility** (ADR-0006, per-`(endpoint, subnet)`) — is the endpoint routable in this subnet at all? *Excluded* = invisible + unroutable.
16	2. **Endpoint access whitelist** (new, per-endpoint) — may *your node* (later: *your user*) remotely reach it?
17	3. **Capability grants** (existing consent store, `capability × agent × target-node`, default-deny) — may *this agent* perform *this high-risk capability* (remote-exec, instantiate-anywhere)?
18
19	**Resource advertisement** (the free-text discovery blurb / "subnet resource registry") is gated by gates 1–2: an endpoint a viewer can't reach never advertises what it can do.
20
21	### Access whitelist semantics
22
23	- **Origin-node gate.** It checks the **node the inbound interaction originates from** (where the operator is acting), **not** the sender endpoint's identity or home — *"the operator must be on a whitelisted machine; the sender endpoint's location is irrelevant."*
24	- **Stateful-firewall model**, not a blanket inbound block:
25	  - **Outbound** → any **visible** node (the whitelist never restricts who the endpoint talks *to*).
26	  - **Inbound reply** correlated to the endpoint's own prior outbound (`__REPLY_TO__`) → allowed from any visible node ("established/related").
27	  - **Unsolicited/direct inbound** (new control or message) → only from a **whitelisted** node ("new inbound").
28	- **Same-node operation is always allowed** (you're at the hardware; the home node never whitelists itself). The whitelist gates **remote (cross-node)** reach only.
29	- **Default empty = open** (any subnet-visible node — current behavior); setting a whitelist *restricts*.
30	- **Node-tier ships M4** (Ed25519 node pubkeys). **User-tier is deferred** to the per-(subnet, user) model (ADR-0006 cross-user seam); the schema reserves an inert `users` field until then.
31	- **Distinct from grants, same plumbing:** enforced at the target endpoint's node, synced as security material near the trust store (not the context repo), subnet-settable, revocable — but a **different table + polarity** (origin-node / default-open vs agent-subject / default-deny).
32
33	## Consequences
34
35	- New v1 requirements registered: `REQ-SEC-1` (access whitelist, M4 node-tier), `REQ-INST-14` (resource advertisement, M4), `REQ-PAIR-7` (subnet icon, M4 data / frontend render) — all `required_stages = []` until M4.
36	- The routing/messaging path (M4) gains a whitelist check on inbound: classify reply-vs-new (via `__REPLY_TO__` correlation) before applying the origin-node gate.
37	- The registry projection that backs resource advertisement must apply the visibility (and later whitelist) filter per viewer.
38	- **Forward (cross-user, ADR-0006 seam):** the whitelist's `users` tier and the "nodes belonging to whitelisted users" semantics activate with the per-(subnet, user) model.
39	- Builds on ADR-0006 (visibility, multi-subnet) and the existing consent grant store (CONTEXT §Consent & security gates).
40

# Endpoint access-control model (visibility / whitelist / grants)

## Status

accepted (2026-06-02)

## Context

Three access/discovery concepts accreted around the multi-subnet model and risked overlapping into a muddle: **endpoint visibility** (ADR-0006 — is an endpoint routable in a subnet at all?), a new **per-endpoint access whitelist** (who may *remotely reach* an endpoint), and the existing **consent grant store** (may *this agent* do *this high-risk thing*?). A new **resource advertisement** (free-text service blurb) also needed to know which access layer gates it. Folding the whitelist into the grant store was tempting but the two differ on two axes — subject granularity (origin **node/user** vs **agent**) and default polarity (**open** vs **deny**) — so merging would corrupt both. This ADR fixes the layering.

## Decision

Model access as **three orthogonal, nested gates**, evaluated in order:

1. **Subnet visibility** (ADR-0006, per-`(endpoint, subnet)`) — is the endpoint routable in this subnet at all? *Excluded* = invisible + unroutable.
2. **Endpoint access whitelist** (new, per-endpoint) — may *your node* (later: *your user*) remotely reach it?
3. **Capability grants** (existing consent store, `capability × agent × target-node`, default-deny) — may *this agent* perform *this high-risk capability* (remote-exec, instantiate-anywhere)?

**Resource advertisement** (the free-text discovery blurb / "subnet resource registry") is gated by gates 1–2: an endpoint a viewer can't reach never advertises what it can do.

### Access whitelist semantics

- **Origin-node gate.** It checks the **node the inbound interaction originates from** (where the operator is acting), **not** the sender endpoint's identity or home — *"the operator must be on a whitelisted machine; the sender endpoint's location is irrelevant."*
- **Stateful-firewall model**, not a blanket inbound block:
  - **Outbound** → any **visible** node (the whitelist never restricts who the endpoint talks *to*).
  - **Inbound reply** correlated to the endpoint's own prior outbound (`__REPLY_TO__`) → allowed from any visible node ("established/related").
  - **Unsolicited/direct inbound** (new control or message) → only from a **whitelisted** node ("new inbound").
- **Same-node operation is always allowed** (you're at the hardware; the home node never whitelists itself). The whitelist gates **remote (cross-node)** reach only.
- **Default empty = open** (any subnet-visible node — current behavior); setting a whitelist *restricts*.
- **Node-tier ships M4** (Ed25519 node pubkeys). **User-tier is deferred** to the per-(subnet, user) model (ADR-0006 cross-user seam); the schema reserves an inert `users` field until then.
- **Distinct from grants, same plumbing:** enforced at the target endpoint's node, synced as security material near the trust store (not the context repo), subnet-settable, revocable — but a **different table + polarity** (origin-node / default-open vs agent-subject / default-deny).

## Consequences

- New v1 requirements registered: `REQ-SEC-1` (access whitelist, M4 node-tier), `REQ-INST-14` (resource advertisement, M4), `REQ-PAIR-7` (subnet icon, M4 data / frontend render) — all `required_stages = []` until M4.
- The routing/messaging path (M4) gains a whitelist check on inbound: classify reply-vs-new (via `__REPLY_TO__` correlation) before applying the origin-node gate.
- The registry projection that backs resource advertisement must apply the visibility (and later whitelist) filter per viewer.
- **Forward (cross-user, ADR-0006 seam):** the whitelist's `users` tier and the "nodes belonging to whitelisted users" semantics activate with the per-(subnet, user) model.
- Builds on ADR-0006 (visibility, multi-subnet) and the existing consent grant store (CONTEXT §Consent & security gates).


grep -n -iE "tofu|trust|warn-on-change|REQ-PAIR-2" CONTEXT.md docs/STORAGE.md docs/KNOWN-HAZARDS.md 2>/dev/null | head -30; echo "===registry head==="; sed -n '1,60p' crates/spt-store/src/registry.rs

Grep trust/TOFU in docs + registry head