Let me start implementing. **Layer 1 — stores.** First, add prev-seed retention to `SubnetRecord` and an adopt method.


Edit

    /// Monotonic seed generation (ADR-0005 #10). Bumped on every
    /// [`rotate_seed`](SubnetStore::rotate_seed); bound into the pairing transcript.
    pub epoch: u64,

    /// Monotonic seed generation (ADR-0005 #10). Bumped on every
    /// [`rotate_seed`](SubnetStore::rotate_seed); bound into the pairing transcript.
    pub epoch: u64,
    /// The **immediately-prior** generation's seed (hex), retained one-deep so a
    /// rotator can verify an `N-1` seed-proof from a benign offliner and re-seed
    /// it (the Mesh-D7 re-seed grace). `Some` only after at least one rotation;
    /// the prior epoch is implicitly [`epoch`](Self::epoch)` - 1`. Cleared past
    /// one generation (the grace is one-deep by construction — `≥2` stale must
    /// re-pair). Never synced; same secrecy posture as [`seed_hex`](Self::seed_hex).
    // [impl->REQ-MESH-4]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prev_seed_hex: Option<String>,


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

    /// Monotonic seed generation (ADR-0005 #10). Bumped on every
    /// [`rotate_seed`](SubnetStore::rotate_seed); bound into the pairing transcript.
    pub epoch: u64,
    /// The **immediately-prior** generation's seed (hex), retained one-deep so a
    /// rotator can verify an `N-1` seed-proof from a benign offliner and re-seed
    /// it (the Mesh-D7 re-seed grace). `Some` only after at least one rotation;
    /// the prior epoch is implicitly [`epoch`](Self::epoch)` - 1`. Cleared past
    /// one generation (the grace is one-deep by construction — `≥2` stale must
    /// re-pair). Never synced; same secrecy posture as [`seed_hex`](Self::seed_hex).
    // [impl->REQ-MESH-4]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prev_seed_hex: Option<String>,

+    /// The **immediately-prior** generation's seed (hex), retained one-deep so a

+    /// rotator can verify an `N-1` seed-proof from a benign offliner and re-seed

+    /// it (the Mesh-D7 re-seed grace). `Some` only after at least one rotation;

+    /// the prior epoch is implicitly [`epoch`](Self::epoch)` - 1`. Cleared past

+    /// one generation (the grace is one-deep by construction — `≥2` stale must

+    /// re-pair). Never synced; same secrecy posture as [`seed_hex`](Self::seed_hex).

+    #[serde(default, skip_serializing_if = "Option::is_none")]

/// Owning user — the cross-user seam. `None` = the single implicit v1 user.


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

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

    /// Decode the **prior**-generation seed to raw bytes (the one-deep re-seed
    /// grace material), paired with its epoch (`epoch - 1`). `None` when there is
    /// no prior generation (never rotated) or the hex is malformed.
    // [impl->REQ-MESH-4]
    pub fn prev_seed_bytes(&self) -> Option<([u8; TOTP_SEED_LEN], u64)> {
        let prev = self.prev_seed_hex.as_deref()?;
        let bytes = decode_hex(prev)?;
        let prior_epoch = self.epoch.checked_sub(1)?;
        Some((bytes, prior_epoch))
    }
}


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

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

    /// Decode the **prior**-generation seed to raw bytes (the one-deep re-seed
    /// grace material), paired with its epoch (`epoch - 1`). `None` when there is
    /// no prior generation (never rotated) or the hex is malformed.
    // [impl->REQ-MESH-4]
    pub fn prev_seed_bytes(&self) -> Option<([u8; TOTP_SEED_LEN], u64)> {
        let prev = self.prev_seed_hex.as_deref()?;
        let bytes = decode_hex(prev)?;
        let prior_epoch = self.epoch.checked_sub(1)?;
        Some((bytes, prior_epoch))
    }
}

+    /// Decode the **prior**-generation seed to raw bytes (the one-deep re-seed

+    /// grace material), paired with its epoch (`epoch - 1`). `None` when there is

+    /// no prior generation (never rotated) or the hex is malformed.

+    pub fn prev_seed_bytes(&self) -> Option<([u8; TOTP_SEED_LEN], u64)> {

+        let prev = self.prev_seed_hex.as_deref()?;

+        let prior_epoch = self.epoch.checked_sub(1)?;

/// The whole `subnet.json` document: every subnet the node is a member of.


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

        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,
            prev_seed_hex: None,
        });
        Ok(self.subnets.last().expect("just pushed"))
    }


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

        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,
            prev_seed_hex: None,
        });
        Ok(self.subnets.last().expect("just pushed"))
    }


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

        self.subnets.push(SubnetRecord {
            name: name.to_string(),
            seed_hex: encode_hex(&seed),
            epoch,
            user: None,
            created: now_stamp(),
            hide_new_endpoints: false,
            prev_seed_hex: None,
        });
        Ok(self.subnets.last().expect("just pushed"))
    }


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

        self.subnets.push(SubnetRecord {
            name: name.to_string(),
            seed_hex: encode_hex(&seed),
            epoch,
            user: None,
            created: now_stamp(),
            hide_new_endpoints: false,
            prev_seed_hex: None,
        });
        Ok(self.subnets.last().expect("just pushed"))
    }


Now `rotate_seed` retains the prior seed, plus a new `adopt_rotation` for the re-seed receiver:


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

    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()))?;
        // Retain the outgoing seed one-deep: it is the credential a benign
        // offliner still holds, and the rotator must be able to verify that
        // `N-1` proof to re-seed it across the rotation (Mesh-D7 grace).
        // [impl->REQ-MESH-4]
        rec.prev_seed_hex = Some(std::mem::replace(&mut rec.seed_hex, encode_hex(&random_seed())));
        rec.epoch += 1;
        Ok(rec)
    }

    /// Adopt a seed handed over by a rotator across the re-seed grace (Mesh-D7):
    /// a benign offliner that proved its `N-1` membership receives the current
    /// `{seed, epoch}` over the member control stream and adopts it here. The old
    /// seed is retained one-deep (so a *further* rotation it also missed would
    /// still find a usable prior generation) and the epoch jumps to `epoch`.
    /// A no-op (returns `false`) when the incoming epoch is not strictly newer —
    /// idempotent against a re-delivered push, and never a silent downgrade.
    // [impl->REQ-MESH-4]
    pub fn adopt_rotation(
        &mut self,
        name: &str,
        seed: [u8; TOTP_SEED_LEN],
        epoch: u64,
    ) -> Result<bool, SubnetError> {
        let rec = self
            .subnets
            .iter_mut()
            .find(|s| s.name == name)
            .ok_or_else(|| SubnetError::NotFound(name.to_string()))?;
        if epoch <= rec.epoch {
            return Ok(false); // not newer — ignore (idempotent, no downgrade)
        }
        rec.prev_seed_hex = Some(std::mem::replace(&mut rec.seed_hex, encode_hex(&seed)));
        rec.epoch = epoch;
        Ok(true)
    }


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

    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()))?;
        // Retain the outgoing seed one-deep: it is the credential a benign
        // offliner still holds, and the rotator must be able to verify that
        // `N-1` proof to re-seed it across the rotation (Mesh-D7 grace).
        // [impl->REQ-MESH-4]
        rec.prev_seed_hex = Some(std::mem::replace(&mut rec.seed_hex, encode_hex(&random_seed())));
        rec.epoch += 1;
        Ok(rec)
    }

    /// Adopt a seed handed over by a rotator across the re-seed grace (Mesh-D7):
    /// a benign offliner that proved its `N-1` membership receives the current
    /// `{seed, epoch}` over the member control stream and adopts it here. The old
    /// seed is retained one-deep (so a *further* rotation it also missed would
    /// still find a usable prior generation) and the epoch jumps to `epoch`.
    /// A no-op (returns `false`) when the incoming epoch is not strictly newer —
    /// idempotent against a re-delivered push, and never a silent downgrade.
    // [impl->REQ-MESH-4]
    pub fn adopt_rotation(
        &mut self,
        name: &str,
        seed: [u8; TOTP_SEED_LEN],
        epoch: u64,
    ) -> Result<bool, SubnetError> {
        let rec = self
            .subnets
            .iter_mut()
            .find(|s| s.name == name)
            .ok_or_else(|| SubnetError::NotFound(name.to_string()))?;
        if epoch <= rec.epoch {
            return Ok(false); // not newer — ignore (idempotent, no downgrade)
        }
        rec.prev_seed_hex = Some(std::mem::replace(&mut rec.seed_hex, encode_hex(&seed)));
        rec.epoch = epoch;
        Ok(true)
    }

-        rec.seed_hex = encode_hex(&random_seed());

+        // Retain the outgoing seed one-deep: it is the credential a benign

+        // offliner still holds, and the rotator must be able to verify that

+        // `N-1` proof to re-seed it across the rotation (Mesh-D7 grace).

+        rec.prev_seed_hex = Some(std::mem::replace(&mut rec.seed_hex, encode_hex(&random_seed())));

+    /// Adopt a seed handed over by a rotator across the re-seed grace (Mesh-D7):

+    /// a benign offliner that proved its `N-1` membership receives the current

+    /// `{seed, epoch}` over the member control stream and adopts it here. The old

+    /// seed is retained one-deep (so a *further* rotation it also missed would

+    /// still find a usable prior generation) and the epoch jumps to `epoch`.

+    /// A no-op (returns `false`) when the incoming epoch is not strictly newer —

+    /// idempotent against a re-delivered push, and never a silent downgrade.

+            .ok_or_else(|| SubnetError::NotFound(name.to_string()))?;

+            return Ok(false); // not newer — ignore (idempotent, no downgrade)

+        rec.prev_seed_hex = Some(std::mem::replace(&mut rec.seed_hex, encode_hex(&seed)));

/// 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


Now update/extend the unit tests for the new prev-seed behavior:


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

        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");
        // The outgoing seed is retained one-deep, paired with its prior epoch —
        // the re-seed-grace material a benign offliner is verified against.
        assert_eq!(
            after.prev_seed_hex.as_deref(),
            Some(before.seed_hex.as_str()),
            "prior seed retained one-deep"
        );
        assert_eq!(
            after.prev_seed_bytes().map(|(_, e)| e),
            Some(before.epoch),
            "prior epoch is epoch-1"
        );
        // 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-MESH-4] the re-seed receiver adopts a strictly-newer {seed,
    // epoch} handed over the grace, retaining its own outgoing seed one-deep; an
    // equal/older push is an idempotent no-op (never a silent downgrade); an
    // unknown subnet errors.
    #[test]
    fn adopt_rotation_takes_newer_seed_and_is_idempotent() {
        let mut store = SubnetStore::default();
        let before = store.create_subnet("home").expect("create").clone();
        let new_seed = [0x5au8; TOTP_SEED_LEN];

        // Stale node on epoch 1 adopts the rotator's epoch 2.
        assert!(store.adopt_rotation("home", new_seed, 2).expect("adopt"));
        let after = store.find("home").unwrap();
        assert_eq!(after.epoch, 2, "epoch jumps to the pushed generation");
        assert_eq!(after.seed_bytes(), Some(new_seed), "new seed adopted");
        assert_eq!(
            after.prev_seed_hex.as_deref(),
            Some(before.seed_hex.as_str()),
            "own outgoing seed retained one-deep"
        );

        // A re-delivered (equal) or older push changes nothing.
        assert!(!store.adopt_rotation("home", [0u8; TOTP_SEED_LEN], 2).expect("idem"));
        assert!(!store.adopt_rotation("home", [0u8; TOTP_SEED_LEN], 1).expect("older"));
        assert_eq!(store.find("home").unwrap().seed_bytes(), Some(new_seed));

        assert_eq!(
            store.adopt_rotation("ghost", new_seed, 9).unwrap_err(),
            SubnetError::NotFound("ghost".into())
        );
    }

    // [unit->REQ-MESH-4] a never-rotated record has no prior generation; the
    // prior-seed accessor reports None (the grace simply does not apply).
    #[test]
    fn no_prior_generation_before_rotation() {
        let mut store = SubnetStore::default();
        let rec = store.create_subnet("home").expect("create");
        assert!(rec.prev_seed_hex.is_none(), "fresh subnet has no prior seed");
        assert!(rec.prev_seed_bytes().is_none());
    }


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

        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");
        // The outgoing seed is retained one-deep, paired with its prior epoch —
        // the re-seed-grace material a benign offliner is verified against.
        assert_eq!(
            after.prev_seed_hex.as_deref(),
            Some(before.seed_hex.as_str()),
            "prior seed retained one-deep"
        );
        assert_eq!(
            after.prev_seed_bytes().map(|(_, e)| e),
            Some(before.epoch),
            "prior epoch is epoch-1"
        );
        // 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-MESH-4] the re-seed receiver adopts a strictly-newer {seed,
    // epoch} handed over the grace, retaining its own outgoing seed one-deep; an
    // equal/older push is an idempotent no-op (never a silent downgrade); an
    // unknown subnet errors.
    #[test]
    fn adopt_rotation_takes_newer_seed_and_is_idempotent() {
        let mut store = SubnetStore::default();
        let before = store.create_subnet("home").expect("create").clone();
        let new_seed = [0x5au8; TOTP_SEED_LEN];

        // Stale node on epoch 1 adopts the rotator's epoch 2.
        assert!(store.adopt_rotation("home", new_seed, 2).expect("adopt"));
        let after = store.find("home").unwrap();
        assert_eq!(after.epoch, 2, "epoch jumps to the pushed generation");
        assert_eq!(after.seed_bytes(), Some(new_seed), "new seed adopted");
        assert_eq!(
            after.prev_seed_hex.as_deref(),
            Some(before.seed_hex.as_str()),
            "own outgoing seed retained one-deep"
        );

        // A re-delivered (equal) or older push changes nothing.
        assert!(!store.adopt_rotation("home", [0u8; TOTP_SEED_LEN], 2).expect("idem"));
        assert!(!store.adopt_rotation("home", [0u8; TOTP_SEED_LEN], 1).expect("older"));
        assert_eq!(store.find("home").unwrap().seed_bytes(), Some(new_seed));

        assert_eq!(
            store.adopt_rotation("ghost", new_seed, 9).unwrap_err(),
            SubnetError::NotFound("ghost".into())
        );
    }

    // [unit->REQ-MESH-4] a never-rotated record has no prior generation; the
    // prior-seed accessor reports None (the grace simply does not apply).
    #[test]
    fn no_prior_generation_before_rotation() {
        let mut store = SubnetStore::default();
        let rec = store.create_subnet("home").expect("create");
        assert!(rec.prev_seed_hex.is_none(), "fresh subnet has no prior seed");
        assert!(rec.prev_seed_bytes().is_none());
    }

+        // The outgoing seed is retained one-deep, paired with its prior epoch —

+        // the re-seed-grace material a benign offliner is verified against.

+            after.prev_seed_hex.as_deref(),

+            Some(before.seed_hex.as_str()),

+            "prior seed retained one-deep"

+            after.prev_seed_bytes().map(|(_, e)| e),

+    // [unit->REQ-MESH-4] the re-seed receiver adopts a strictly-newer {seed,

+    // epoch} handed over the grace, retaining its own outgoing seed one-deep; an

+    // equal/older push is an idempotent no-op (never a silent downgrade); an

+    fn adopt_rotation_takes_newer_seed_and_is_idempotent() {

+        let mut store = SubnetStore::default();

+        let before = store.create_subnet("home").expect("create").clone();

+        let new_seed = [0x5au8; TOTP_SEED_LEN];

+        // Stale node on epoch 1 adopts the rotator's epoch 2.

+        assert!(store.adopt_rotation("home", new_seed, 2).expect("adopt"));

+        let after = store.find("home").unwrap();

+        assert_eq!(after.epoch, 2, "epoch jumps to the pushed generation");

+        assert_eq!(after.seed_bytes(), Some(new_seed), "new seed adopted");

+            after.prev_seed_hex.as_deref(),

+            Some(before.seed_hex.as_str()),

+            "own outgoing seed retained one-deep"

+        // A re-delivered (equal) or older push changes nothing.

+        assert!(!store.adopt_rotation("home", [0u8; TOTP_SEED_LEN], 2).expect("idem"));

+        assert!(!store.adopt_rotation("home", [0u8; TOTP_SEED_LEN], 1).expect("older"));

+        assert_eq!(store.find("home").unwrap().seed_bytes(), Some(new_seed));

+            store.adopt_rotation("ghost", new_seed, 9).unwrap_err(),

+            SubnetError::NotFound("ghost".into())

+    // [unit->REQ-MESH-4] a never-rotated record has no prior generation; the

+    // prior-seed accessor reports None (the grace simply does not apply).

+    fn no_prior_generation_before_rotation() {

+        let rec = store.create_subnet("home").expect("create");

+        assert!(rec.prev_seed_hex.is_none(), "fresh subnet has no prior seed");

+        assert!(rec.prev_seed_bytes().is_none());

// [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


Layer 1 stores done for subnet. Now create the rotation-pending store with pure `coalesce`/`due_rotations`. Let me check the perch path helpers first.


Grep

pub fn subnet_file|pub fn roster_file|pub fn identity_dir


84:pub fn identity_dir() -> PathBuf {
85-    spt_home().join("identity")
86-}
87-
88-\ The node's durable Ed25519 identity seed: `<spt_home>/identity/node.key`
--
102:pub fn subnet_file() -> PathBuf {
103-    identity_dir().join("subnet.json")
104-}
105-
106-\ The member-roster file: `<spt_home>/identity/roster.json` (REQ-MESH-2,
--
113:pub fn roster_file() -> PathBuf {
114-    identity_dir().join("roster.json")
115-}
116-
117-\ The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single


100	/// Replicated via pairing/P2P, **not** git.
101	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
102	pub fn subnet_file() -> PathBuf {
103	    identity_dir().join("subnet.json")
104	}
105
106	/// The member-roster file: `<spt_home>/identity/roster.json` (REQ-MESH-2,
107	/// ADR-0017). The subnet discovery directory — a node-level union-merge
108	/// grow-set of `(pubkey, label, machine_id, address, last-seen)` per member,
109	/// with per-pubkey tombstones. Node-local, replicated via the
110	/// pairing/seed-proof'd P2P channel, **never** git-tracked/synced (same class
111	/// as `subnet.json`/`peers.json`). NOT the seed.
112	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE] [impl->REQ-MESH-2]
113	pub fn roster_file() -> PathBuf {
114	    identity_dir().join("roster.json")
115	}
116
117	/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single
118	/// strictly-increasing `u64` serving both the registry lease (#8) and sync precedence
119	/// (#7) — never wall-clock. Node-local, never synced (each node owns its own counter).

/// Replicated via pairing/P2P, **not** git.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn subnet_file() -> PathBuf {
    identity_dir().join("subnet.json")
}

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

/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single
/// strictly-increasing `u64` serving both the registry lease (#8) and sync precedence
/// (#7) — never wall-clock. Node-local, never synced (each node owns its own counter).


Edit

/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single

/// The pending seed-rotation marker: `<spt_home>/identity/rotation-pending.json`
/// (Mesh-D7, REQ-MESH-4). The coalescing-window schedule a `subnet revoke`
/// writes and the daemon pump fires: per subnet, the rotation deadline and the
/// revokees that triggered it. Node-local, never synced (each node schedules its
/// own rotation; the resulting seed rides the member control stream, not gossip).
// [impl->REQ-MESH-4]
pub fn rotation_pending_file() -> PathBuf {
    identity_dir().join("rotation-pending.json")
}

/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single


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

/// The pending seed-rotation marker: `<spt_home>/identity/rotation-pending.json`
/// (Mesh-D7, REQ-MESH-4). The coalescing-window schedule a `subnet revoke`
/// writes and the daemon pump fires: per subnet, the rotation deadline and the
/// revokees that triggered it. Node-local, never synced (each node schedules its
/// own rotation; the resulting seed rides the member control stream, not gossip).
// [impl->REQ-MESH-4]
pub fn rotation_pending_file() -> PathBuf {
    identity_dir().join("rotation-pending.json")
}

/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single

+/// The pending seed-rotation marker: `<spt_home>/identity/rotation-pending.json`

+/// (Mesh-D7, REQ-MESH-4). The coalescing-window schedule a `subnet revoke`

+/// writes and the daemon pump fires: per subnet, the rotation deadline and the

+/// revokees that triggered it. Node-local, never synced (each node schedules its

+/// own rotation; the resulting seed rides the member control stream, not gossip).

+pub fn rotation_pending_file() -> PathBuf {

+    identity_dir().join("rotation-pending.json")

/// strictly-increasing `u64` serving both the registry lease (#8) and sync precedence

/// (#7) — never wall-clock. Node-local, never synced (each node owns its own counter).


Now create the rotation-pending store module:


Write

//! `rotation-pending.json` — the timeboxed seed-rotation schedule (Mesh-D7,
//! REQ-MESH-4).
//!
//! A `spt subnet revoke` does not rotate the seed immediately. It writes a
//! propagating roster tombstone now (so the revokee drops out of every member's
//! `is_member` gate within a roster round) and schedules **one** seed rotation at
//! the close of a coalescing window (default 1h). A second revoke inside the
//! window joins the *same* pending entry — union the revokees, keep the earliest
//! deadline — so a burst of revocations costs exactly **one** epoch bump. That
//! one-bump property is what keeps the re-seed grace one-deep: a benign offliner
//! never falls to `N-2` from a single revoke event.
//!
//! The daemon pump consumes this file each registry tick: any subnet whose
//! deadline has passed has its seed rotated ([`SubnetStore::rotate_seed`]) and
//! its pending entry cleared. `--force-rotate-seed` writes an entry with the
//! deadline set to *now* (the compromised-node path), so the next tick — or the
//! CLI inline — fires it immediately.
//!
//! The *when* (window logic) is pure and unit-tested here ([`coalesce`],
//! [`due_subnets`]); the *how* (the actual `rotate_seed` + clear) is the pump's,
//! kept out of the timing path so coalescing is a fact, not a flaky timer test.
//!
//! Node-local, never synced (each node schedules its own rotation). Same
//! `identity/` durability + atomic-write posture as [`SubnetStore`](crate::subnet).
//
// [impl->REQ-MESH-4]

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

use crate::atomic::atomic_write_string;
use crate::perch::rotation_pending_file;

/// One subnet's pending rotation: when it fires and which revokees triggered it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingRotation {
    /// The wall-clock deadline (epoch-ms) at/after which the pump fires the
    /// rotation. A second revoke in the window keeps the **earliest** deadline.
    pub deadline_ms: u64,
    /// The pubkeys (lowercase hex) whose revocation scheduled this rotation —
    /// unioned across every revoke that coalesced into this window. Informational
    /// (the tombstones are the authority); kept so the fired-rotation log can say
    /// *who* it was for.
    pub revokees: Vec<String>,
}

/// The whole `rotation-pending.json`: subnet name → its one pending rotation.
/// A `BTreeMap` so the on-disk form is deterministically ordered (stable diffs,
/// stable test assertions).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RotationPending {
    /// Pending rotations keyed by subnet name (one per subnet — the coalescing
    /// invariant).
    #[serde(default)]
    pub subnets: BTreeMap<String, PendingRotation>,
}

impl RotationPending {
    /// Load from the canonical [`rotation_pending_file`]. Absent ⇒ empty (nothing
    /// scheduled) — never an error.
    pub fn load() -> RotationPending {
        Self::load_from(&rotation_pending_file())
    }

    /// Load from an explicit path (the testable core). Absent/unreadable/corrupt
    /// ⇒ empty rather than a panic: a damaged schedule must not brick the pump
    /// (a missed rotation is re-scheduled by the next revoke, and the tombstone
    /// already locks the revokee out at the app gate).
    pub fn load_from(path: &Path) -> RotationPending {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => RotationPending::default(),
        }
    }

    /// Persist to the canonical [`rotation_pending_file`].
    pub fn save(&self) -> std::io::Result<()> {
        self.save_to(&rotation_pending_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let json = serde_json::to_string_pretty(self)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        atomic_write_string(path, &json).map_err(std::io::Error::other)
    }

    /// Schedule (or extend) a subnet's rotation — the **coalesce** rule. A first
    /// revoke inserts `{deadline, revokees}`; a later revoke inside the window
    /// unions the revokees into the existing entry and keeps the **earliest**
    /// deadline, so the burst still fires exactly one rotation (one epoch bump).
    /// Idempotent on a re-named revokee (the union dedups).
    // [impl->REQ-MESH-4]
    pub fn coalesce(&mut self, subnet: &str, revokees: &[String], deadline_ms: u64) {
        let entry = self
            .subnets
            .entry(subnet.to_string())
            .or_insert_with(|| PendingRotation {
                deadline_ms,
                revokees: Vec::new(),
            });
        // Earliest deadline wins (a force-rotate with deadline=now must not be
        // pushed back by a later windowed revoke).
        entry.deadline_ms = entry.deadline_ms.min(deadline_ms);
        for r in revokees {
            if !entry.revokees.contains(r) {
                entry.revokees.push(r.clone());
            }
        }
    }

    /// The subnets whose rotation is **due** at `now_ms` (deadline reached),
    /// sorted for determinism. The pump fires each, then [`clear`](Self::clear)s
    /// it. A pure read — firing is the caller's (the pump owns the seed mutation).
    // [impl->REQ-MESH-4]
    pub fn due_subnets(&self, now_ms: u64) -> Vec<String> {
        self.subnets
            .iter()
            .filter(|(_, p)| p.deadline_ms <= now_ms)
            .map(|(name, _)| name.clone())
            .collect()
    }

    /// Drop a subnet's pending entry after its rotation has fired. Returns whether
    /// one was present.
    pub fn clear(&mut self, subnet: &str) -> bool {
        self.subnets.remove(subnet).is_some()
    }

    /// Whether anything is scheduled (the pump skips the disk write when not).
    pub fn is_empty(&self) -> bool {
        self.subnets.is_empty()
    }
}

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

    fn hexes(v: &[&str]) -> Vec<String> {
        v.iter().map(|s| s.to_string()).collect()
    }

    // [unit->REQ-MESH-4] the coalescing invariant: N revokes in one window
    // produce exactly ONE pending entry — the revokees union, the EARLIEST
    // deadline is kept — so the burst fires one rotation (one epoch bump).
    #[test]
    fn coalesce_unions_revokees_and_keeps_earliest_deadline() {
        let mut p = RotationPending::default();
        p.coalesce("home", &hexes(&["aa"]), 1_000);
        // A second revoke later in the window: union the revokee, deadline must
        // NOT move later.
        p.coalesce("home", &hexes(&["bb"]), 5_000);
        // A third with an EARLIER deadline pulls the rotation in.
        p.coalesce("home", &hexes(&["cc"]), 800);
        // Re-naming an existing revokee dedups (idempotent).
        p.coalesce("home", &hexes(&["aa"]), 9_000);

        assert_eq!(p.subnets.len(), 1, "one pending entry for the burst");
        let entry = &p.subnets["home"];
        assert_eq!(entry.deadline_ms, 800, "earliest deadline kept");
        assert_eq!(entry.revokees, hexes(&["aa", "bb", "cc"]), "union, deduped");
    }

    // [unit->REQ-MESH-4] coalescing is per-subnet: revokes against different
    // subnets schedule independent rotations.
    #[test]
    fn coalesce_is_per_subnet() {
        let mut p = RotationPending::default();
        p.coalesce("home", &hexes(&["aa"]), 1_000);
        p.coalesce("work", &hexes(&["bb"]), 2_000);
        assert_eq!(p.subnets.len(), 2);
        assert_eq!(p.subnets["home"].deadline_ms, 1_000);
        assert_eq!(p.subnets["work"].deadline_ms, 2_000);
    }

    // [unit->REQ-MESH-4] due_subnets fires only at/after the deadline; a
    // force-rotate (deadline = now) is due immediately; clear removes it.
    #[test]
    fn due_subnets_respects_deadline_and_force_is_immediate() {
        let mut p = RotationPending::default();
        p.coalesce("home", &hexes(&["aa"]), 1_000); // windowed
        p.coalesce("now", &hexes(&["bb"]), 500); // force-rotate (deadline=now=500)

        assert_eq!(p.due_subnets(499), Vec::<String>::new(), "nothing due yet");
        assert_eq!(p.due_subnets(500), vec!["now".to_string()], "force fires at deadline");
        assert_eq!(
            p.due_subnets(1_000),
            vec!["home".to_string(), "now".to_string()],
            "both due once the window closes"
        );

        assert!(p.clear("now"));
        assert!(!p.clear("now"), "second clear is a no-op false");
        assert_eq!(p.due_subnets(1_000), vec!["home".to_string()], "only home remains");
    }

    // [unit->REQ-MESH-4] the schedule round-trips atomically, and an absent or
    // corrupt file degrades to the empty schedule (never a panic — a missed
    // rotation re-schedules; the tombstone already gates the revokee).
    #[test]
    fn persists_and_degrades_to_empty() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("rotation-pending.json");

        let mut p = RotationPending::default();
        p.coalesce("home", &hexes(&["aa", "bb"]), 1_700_000_000_000);
        p.save_to(&path).unwrap();

        let back = RotationPending::load_from(&path);
        assert_eq!(back.subnets["home"].revokees, hexes(&["aa", "bb"]));
        assert_eq!(back.subnets["home"].deadline_ms, 1_700_000_000_000);

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


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

//! `rotation-pending.json` — the timeboxed seed-rotation schedule (Mesh-D7,
//! REQ-MESH-4).
//!
//! A `spt subnet revoke` does not rotate the seed immediately. It writes a
//! propagating roster tombstone now (so the revokee drops out of every member's
//! `is_member` gate within a roster round) and schedules **one** seed rotation at
//! the close of a coalescing window (default 1h). A second revoke inside the
//! window joins the *same* pending entry — union the revokees, keep the earliest
//! deadline — so a burst of revocations costs exactly **one** epoch bump. That
//! one-bump property is what keeps the re-seed grace one-deep: a benign offliner
//! never falls to `N-2` from a single revoke event.
//!
//! The daemon pump consumes this file each registry tick: any subnet whose
//! deadline has passed has its seed rotated ([`SubnetStore::rotate_seed`]) and
//! its pending entry cleared. `--force-rotate-seed` writes an entry with the
//! deadline set to *now* (the compromised-node path), so the next tick — or the
//! CLI inline — fires it immediately.
//!
//! The *when* (window logic) is pure and unit-tested here ([`coalesce`],
//! [`due_subnets`]); the *how* (the actual `rotate_seed` + clear) is the pump's,
//! kept out of the timing path so coalescing is a fact, not a flaky timer test.
//!
//! Node-local, never synced (each node schedules its own rotation). Same
//! `identity/` durability + atomic-write posture as [`SubnetStore`](crate::subnet).
//
// [impl->REQ-MESH-4]

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

use crate::atomic::atomic_write_string;
use crate::perch::rotation_pending_file;

/// One subnet's pending rotation: when it fires and which revokees triggered it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingRotation {
    /// The wall-clock deadline (epoch-ms) at/after which the pump fires the
    /// rotation. A second revoke in the window keeps the **earliest** deadline.
    pub deadline_ms: u64,
    /// The pubkeys (lowercase hex) whose revocation scheduled this rotation —
    /// unioned across every revoke that coalesced into this window. Informational
    /// (the tombstones are the authority); kept so the fired-rotation log can say
    /// *who* it was for.
    pub revokees: Vec<String>,
}

/// The whole `rotation-pending.json`: subnet name → its one pending rotation.
/// A `BTreeMap` so the on-disk form is deterministically ordered (stable diffs,
/// stable test assertions).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RotationPending {
    /// Pending rotations keyed by subnet name (one per subnet — the coalescing
    /// invariant).
    #[serde(default)]
    pub subnets: BTreeMap<String, PendingRotation>,
}

impl RotationPending {
    /// Load from the canonical [`rotation_pending_file`]. Absent ⇒ empty (nothing
    /// scheduled) — never an error.
    pub fn load() -> RotationPending {
        Self::load_from(&rotation_pending_file())
    }

    /// Load from an explicit path (the testable core). Absent/unreadable/corrupt
    /// ⇒ empty rather than a panic: a damaged schedule must not brick the pump
    /// (a missed rotation is re-scheduled by the next revoke, and the tombstone
    /// already locks the revokee out at the app gate).
    pub fn load_from(path: &Path) -> RotationPending {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => RotationPending::default(),
        }
    }

    /// Persist to the canonical [`rotation_pending_file`].
    pub fn save(&self) -> std::io::Result<()> {
        self.save_to(&rotation_pending_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let json = serde_json::to_string_pretty(self)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        atomic_write_string(path, &json).map_err(std::io::Error::other)
    }

    /// Schedule (or extend) a subnet's rotation — the **coalesce** rule. A first
    /// revoke inserts `{deadline, revokees}`; a later revoke inside the window
    /// unions the revokees into the existing entry and keeps the **earliest**
    /// deadline, so the burst still fires exactly one rotation (one epoch bump).
    /// Idempotent on a re-named revokee (the union dedups).
    // [impl->REQ-MESH-4]
    pub fn coalesce(&mut self, subnet: &str, revokees: &[String], deadline_ms: u64) {
        let entry = self
            .subnets
            .entry(subnet.to_string())
            .or_insert_with(|| PendingRotation {
                deadline_ms,
                revokees: Vec::new(),
            });
        // Earliest deadline wins (a force-rotate with deadline=now must not be
        // pushed back by a later windowed revoke).
        entry.deadline_ms = entry.deadline_ms.min(deadline_ms);
        for r in revokees {
            if !entry.revokees.contains(r) {
                entry.revokees.push(r.clone());
            }
        }
    }

    /// The subnets whose rotation is **due** at `now_ms` (deadline reached),
    /// sorted for determinism. The pump fires each, then [`clear`](Self::clear)s
    /// it. A pure read — firing is the caller's (the pump owns the seed mutation).
    // [impl->REQ-MESH-4]
    pub fn due_subnets(&self, now_ms: u64) -> Vec<String> {
        self.subnets
            .iter()
            .filter(|(_, p)| p.deadline_ms <= now_ms)
            .map(|(name, _)| name.clone())
            .collect()
    }

    /// Drop a subnet's pending entry after its rotation has fired. Returns whether
    /// one was present.
    pub fn clear(&mut self, subnet: &str) -> bool {
        self.subnets.remove(subnet).is_some()
    }

    /// Whether anything is scheduled (the pump skips the disk write when not).
    pub fn is_empty(&self) -> bool {
        self.subnets.is_empty()
    }
}

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

    fn hexes(v: &[&str]) -> Vec<String> {
        v.iter().map(|s| s.to_string()).collect()
    }

    // [unit->REQ-MESH-4] the coalescing invariant: N revokes in one window
    // produce exactly ONE pending entry — the revokees union, the EARLIEST
    // deadline is kept — so the burst fires one rotation (one epoch bump).
    #[test]
    fn coalesce_unions_revokees_and_keeps_earliest_deadline() {
        let mut p = RotationPending::default();
        p.coalesce("home", &hexes(&["aa"]), 1_000);
        // A second revoke later in the window: union the revokee, deadline must
        // NOT move later.
        p.coalesce("home", &hexes(&["bb"]), 5_000);
        // A third with an EARLIER deadline pulls the rotation in.
        p.coalesce("home", &hexes(&["cc"]), 800);
        // Re-naming an existing revokee dedups (idempotent).
        p.coalesce("home", &hexes(&["aa"]), 9_000);

        assert_eq!(p.subnets.len(), 1, "one pending entry for the burst");
        let entry = &p.subnets["home"];
        assert_eq!(entry.deadline_ms, 800, "earliest deadline kept");
        assert_eq!(entry.revokees, hexes(&["aa", "bb", "cc"]), "union, deduped");
    }

    // [unit->REQ-MESH-4] coalescing is per-subnet: revokes against different
    // subnets schedule independent rotations.
    #[test]
    fn coalesce_is_per_subnet() {
        let mut p = RotationPending::default();
        p.coalesce("home", &hexes(&["aa"]), 1_000);
        p.coalesce("work", &hexes(&["bb"]), 2_000);
        assert_eq!(p.subnets.len(), 2);
        assert_eq!(p.subnets["home"].deadline_ms, 1_000);
        assert_eq!(p.subnets["work"].deadline_ms, 2_000);
    }

    // [unit->REQ-MESH-4] due_subnets fires only at/after the deadline; a
    // force-rotate (deadline = now) is due immediately; clear removes it.
    #[test]
    fn due_subnets_respects_deadline_and_force_is_immediate() {
        let mut p = RotationPending::default();
        p.coalesce("home", &hexes(&["aa"]), 1_000); // windowed
        p.coalesce("now", &hexes(&["bb"]), 500); // force-rotate (deadline=now=500)

        assert_eq!(p.due_subnets(499), Vec::<String>::new(), "nothing due yet");
        assert_eq!(p.due_subnets(500), vec!["now".to_string()], "force fires at deadline");
        assert_eq!(
            p.due_subnets(1_000),
            vec!["home".to_string(), "now".to_string()],
            "both due once the window closes"
        );

        assert!(p.clear("now"));
        assert!(!p.clear("now"), "second clear is a no-op false");
        assert_eq!(p.due_subnets(1_000), vec!["home".to_string()], "only home remains");
    }

    // [unit->REQ-MESH-4] the schedule round-trips atomically, and an absent or
    // corrupt file degrades to the empty schedule (never a panic — a missed
    // rotation re-schedules; the tombstone already gates the revokee).
    #[test]
    fn persists_and_degrades_to_empty() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("rotation-pending.json");

        let mut p = RotationPending::default();
        p.coalesce("home", &hexes(&["aa", "bb"]), 1_700_000_000_000);
        p.save_to(&path).unwrap();

        let back = RotationPending::load_from(&path);
        assert_eq!(back.subnets["home"].revokees, hexes(&["aa", "bb"]));
        assert_eq!(back.subnets["home"].deadline_ms, 1_700_000_000_000);

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


Register the module in `lib.rs`:


35:pub mod registry;
37:pub mod roster;
41:pub mod subnet;


34	pub mod project;
35	pub mod registry;
36	pub mod rename;
37	pub mod roster;
38	pub mod seed;
39	pub mod shellinfo;
40	pub mod spool;
41	pub mod subnet;
42	pub mod syncmerge;
43	pub mod visibility;

pub mod project;
pub mod registry;
pub mod rename;
pub mod roster;
pub mod seed;
pub mod shellinfo;
pub mod spool;
pub mod subnet;
pub mod syncmerge;
pub mod visibility;


Edit

pub mod roster;
pub mod rotation;
pub mod seed;


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

pub mod roster;
pub mod rotation;
pub mod seed;

//! `spt-store` — the persistence commodity layer for spt-core.
//!
//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
//! types to disk (spool, registry, info.json) through a single atomic-write
//! primitive. Stable on-disk formats are copied verbatim from the sister
//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
//!
// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
// depends only on spt-proto (one layer down), never the reverse — the
// dependency edge is one-way, so the workspace graph stays acyclic.
// [impl->REQ-ARCH-1]

pub mod access;
pub mod atomic;
pub mod attachment;
pub mod branchstore;
pub mod contextmark;
pub mod contextstore;
mod db;
pub mod epoch;
pub mod gitrun;
pub mod grants;
pub mod history;
pub mod home;
pub mod hostlabel;
pub mod info;
pub mod liveness;
pub mod nodeid;
pub mod notif;
pub mod peeraddrs;
pub mod perch;
pub mod proc;
pub mod project;
pub mod registry;
pub mod rename;
pub mod roster;
pub mod seed;
pub mod shellinfo;
pub mod spool;
pub mod subnet;
pub mod syncmerge;
pub mod visibility;
pub mod xfer;

pub mod registry;
pub mod rename;
pub mod roster;
+pub mod rotation;
pub mod seed;
pub mod shellinfo;
pub mod spool;


Now compile + test the store layer:


Bash

cargo test -p spt-store --lib subnet:: rotation:: 2>&1 | tail -30

Test the store layer (subnet + rotation)


error: unexpected argument 'rotation::' found

Usage: cargo.exe test [OPTIONS] [TESTNAME] [-- [ARGS]...]

For more information, try '--help'.


cargo test -p spt-store --lib 2>&1 | tail -30

Test the spt-store crate


test spool::tests::wan_mark_seen_claims_once_and_blocks_spool_path ... ok
test spool::tests::wan_spool_dedups_on_op_id_across_reopen ... ok
test spool::tests::event_part_body_persists_byte_exact ... ok
test branchstore::tests::multi_key_commit_is_one_commit ... ok
test branchstore::tests::commit_then_read_at_tip ... ok
test visibility::tests::hidden_gates_sync_even_when_listed ... ok
test visibility::tests::hidden_is_or_of_defaults ... ok
test visibility::tests::override_wins_both_directions ... ok
test syncmerge::tests::non_context_branch_refused ... ok
test visibility::tests::store_persists_and_degrades_safe ... ok
test visibility::tests::synced_requires_membership_listing ... ok
test xfer::tests::xfer_id_is_filename_safe_and_distinct ... ok
test contextstore::tests::project_branch_holds_many_agents ... ok
test xfer::tests::progress_round_trips_per_role_and_updates_mid_flight ... ok
test branchstore::tests::unchanged_write_is_no_commit ... ok
test contextstore::tests::two_tier_layout_and_commits ... ok
test contextstore::tests::conflict_artifacts_preserve_both_versions ... ok
test contextstore::tests::rename_ripples_both_tiers ... ok
test contextstore::tests::fork_copies_both_tiers_then_diverges ... ok
test syncmerge::tests::legacy_bottom_vectors_surface_not_overwrite ... ok
test contextstore::tests::remove_endpoint_removes_exactly_the_source ... ok
test syncmerge::tests::adopt_then_incremental_then_idempotent_repull ... ok
test syncmerge::tests::project_branch_unions_disjoint_agents ... ok
test syncmerge::tests::dominated_incoming_drops_but_joins_dag ... ok
test syncmerge::tests::concurrent_writes_surface_on_both_nodes ... ok
test syncmerge::tests::reconciled_write_propagates_and_clears_artifacts ... ok
test info::tests::concurrent_read_during_writes_never_torn ... ok

test result: ok. 151 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.37s


Store layer green (151 passed). **Layer 2 — the 3-valued proof grading** (the load-bearing crypto). First, add a multi-generation proof frame to the codec:


Edit

    /// A party's per-connection nonce together with the subnet names it offers.
    /// The dialer sends its full local list; the acceptor replies with only the
    /// intersection (minimizing name disclosure). The proven set is established
    /// by the per-subnet [`Proof`](SeedProofFrame::Proof)s that follow.
    Hello {
        /// This party's fresh per-connection nonce.
        nonce: [u8; NONCE_LEN],
        /// Offered subnet names (dialer: local set; acceptor: intersection).
        subnets: Vec<String>,
    },
}

const FRAME_CHALLENGE: u8 = 0;
const FRAME_PROOF: u8 = 1;
const FRAME_HELLO: u8 = 2;

    /// A party's per-connection nonce together with the subnet names it offers.
    /// The dialer sends its full local list; the acceptor replies with only the
    /// intersection (minimizing name disclosure). The proven set is established
    /// by the per-subnet [`Proof`](SeedProofFrame::Proof)s that follow.
    Hello {
        /// This party's fresh per-connection nonce.
        nonce: [u8; NONCE_LEN],
        /// Offered subnet names (dialer: local set; acceptor: intersection).
        subnets: Vec<String>,
    },
    /// A party's seed-proof tags for **one subnet**, one per generation it
    /// holds — the current epoch and (after a rotation) the immediately-prior
    /// one. Mesh-D7's re-seed grace: a stale peer (on `N-1`) and a fresh peer
    /// (on `N`, holding `N-1` as its prior) overlap on the `N-1` generation, so
    /// each side sends both tags and the verifier grades by the highest
    /// generation it can match. The epoch is carried so the verifier knows which
    /// of its generations a tag is for (the transcript binds it too — this is
    /// the routing hint, the tag is the authority). One `ProofSet` per shared
    /// subnet, in the agreed order, replacing D2's single [`Proof`].
    ProofSet {
        /// `(seed_epoch, tag)` for each held generation (1 or 2; current first).
        proofs: Vec<(u64, [u8; PROOF_TAG_LEN])>,
    },
}

const FRAME_CHALLENGE: u8 = 0;
const FRAME_PROOF: u8 = 1;
const FRAME_HELLO: u8 = 2;
const FRAME_PROOFSET: u8 = 3;

/// Cap on generations in one `ProofSet` — current + prior is two; a frame
/// claiming more is a malformed/hostile peer (decoded to `None`).
const MAX_PROOF_GENS: usize = 2;


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

    /// A party's per-connection nonce together with the subnet names it offers.
    /// The dialer sends its full local list; the acceptor replies with only the
    /// intersection (minimizing name disclosure). The proven set is established
    /// by the per-subnet [`Proof`](SeedProofFrame::Proof)s that follow.
    Hello {
        /// This party's fresh per-connection nonce.
        nonce: [u8; NONCE_LEN],
        /// Offered subnet names (dialer: local set; acceptor: intersection).
        subnets: Vec<String>,
    },
}

const FRAME_CHALLENGE: u8 = 0;
const FRAME_PROOF: u8 = 1;
const FRAME_HELLO: u8 = 2;

    /// A party's per-connection nonce together with the subnet names it offers.
    /// The dialer sends its full local list; the acceptor replies with only the
    /// intersection (minimizing name disclosure). The proven set is established
    /// by the per-subnet [`Proof`](SeedProofFrame::Proof)s that follow.
    Hello {
        /// This party's fresh per-connection nonce.
        nonce: [u8; NONCE_LEN],
        /// Offered subnet names (dialer: local set; acceptor: intersection).
        subnets: Vec<String>,
    },
    /// A party's seed-proof tags for **one subnet**, one per generation it
    /// holds — the current epoch and (after a rotation) the immediately-prior
    /// one. Mesh-D7's re-seed grace: a stale peer (on `N-1`) and a fresh peer
    /// (on `N`, holding `N-1` as its prior) overlap on the `N-1` generation, so
    /// each side sends both tags and the verifier grades by the highest
    /// generation it can match. The epoch is carried so the verifier knows which
    /// of its generations a tag is for (the transcript binds it too — this is
    /// the routing hint, the tag is the authority). One `ProofSet` per shared
    /// subnet, in the agreed order, replacing D2's single [`Proof`].
    ProofSet {
        /// `(seed_epoch, tag)` for each held generation (1 or 2; current first).
        proofs: Vec<(u64, [u8; PROOF_TAG_LEN])>,
    },
}

const FRAME_CHALLENGE: u8 = 0;
const FRAME_PROOF: u8 = 1;
const FRAME_HELLO: u8 = 2;
const FRAME_PROOFSET: u8 = 3;

/// Cap on generations in one `ProofSet` — current + prior is two; a frame
/// claiming more is a malformed/hostile peer (decoded to `None`).
const MAX_PROOF_GENS: usize = 2;

+    /// A party's seed-proof tags for **one subnet**, one per generation it

+    /// holds — the current epoch and (after a rotation) the immediately-prior

+    /// one. Mesh-D7's re-seed grace: a stale peer (on `N-1`) and a fresh peer

+    /// (on `N`, holding `N-1` as its prior) overlap on the `N-1` generation, so

+    /// each side sends both tags and the verifier grades by the highest

+    /// generation it can match. The epoch is carried so the verifier knows which

+    /// of its generations a tag is for (the transcript binds it too — this is

+    /// the routing hint, the tag is the authority). One `ProofSet` per shared

+    /// subnet, in the agreed order, replacing D2's single [`Proof`].

+        /// `(seed_epoch, tag)` for each held generation (1 or 2; current first).

+        proofs: Vec<(u64, [u8; PROOF_TAG_LEN])>,

+/// Cap on generations in one `ProofSet` — current + prior is two; a frame

+/// claiming more is a malformed/hostile peer (decoded to `None`).


            SeedProofFrame::Hello { nonce, subnets } => {
                let mut out = Vec::with_capacity(1 + NONCE_LEN + 4 + subnets.len() * 16);
                out.push(FRAME_HELLO);
                out.extend_from_slice(nonce);
                out.extend_from_slice(&(subnets.len() as u32).to_be_bytes());
                for name in subnets {
                    out.extend_from_slice(&(name.len() as u32).to_be_bytes());
                    out.extend_from_slice(name.as_bytes());
                }
                out
            }
        }
    }

            SeedProofFrame::Hello { nonce, subnets } => {
                let mut out = Vec::with_capacity(1 + NONCE_LEN + 4 + subnets.len() * 16);
                out.push(FRAME_HELLO);
                out.extend_from_slice(nonce);
                out.extend_from_slice(&(subnets.len() as u32).to_be_bytes());
                for name in subnets {
                    out.extend_from_slice(&(name.len() as u32).to_be_bytes());
                    out.extend_from_slice(name.as_bytes());
                }
                out
            }
            SeedProofFrame::ProofSet { proofs } => {
                let mut out = Vec::with_capacity(1 + 4 + proofs.len() * (8 + PROOF_TAG_LEN));
                out.push(FRAME_PROOFSET);
                out.extend_from_slice(&(proofs.len() as u32).to_be_bytes());
                for (epoch, tag) in proofs {
                    out.extend_from_slice(&epoch.to_be_bytes());
                    out.extend_from_slice(tag);
                }
                out
            }
        }
    }


            SeedProofFrame::Hello { nonce, subnets } => {
                let mut out = Vec::with_capacity(1 + NONCE_LEN + 4 + subnets.len() * 16);
                out.push(FRAME_HELLO);
                out.extend_from_slice(nonce);
                out.extend_from_slice(&(subnets.len() as u32).to_be_bytes());
                for name in subnets {
                    out.extend_from_slice(&(name.len() as u32).to_be_bytes());
                    out.extend_from_slice(name.as_bytes());
                }
                out
            }
        }
    }

            SeedProofFrame::Hello { nonce, subnets } => {
                let mut out = Vec::with_capacity(1 + NONCE_LEN + 4 + subnets.len() * 16);
                out.push(FRAME_HELLO);
                out.extend_from_slice(nonce);
                out.extend_from_slice(&(subnets.len() as u32).to_be_bytes());
                for name in subnets {
                    out.extend_from_slice(&(name.len() as u32).to_be_bytes());
                    out.extend_from_slice(name.as_bytes());
                }
                out
            }
            SeedProofFrame::ProofSet { proofs } => {
                let mut out = Vec::with_capacity(1 + 4 + proofs.len() * (8 + PROOF_TAG_LEN));
                out.push(FRAME_PROOFSET);
                out.extend_from_slice(&(proofs.len() as u32).to_be_bytes());
                for (epoch, tag) in proofs {
                    out.extend_from_slice(&epoch.to_be_bytes());
                    out.extend_from_slice(tag);
                }
                out
            }
        }
    }

+            SeedProofFrame::ProofSet { proofs } => {

+                let mut out = Vec::with_capacity(1 + 4 + proofs.len() * (8 + PROOF_TAG_LEN));

+                out.push(FRAME_PROOFSET);

+                out.extend_from_slice(&(proofs.len() as u32).to_be_bytes());

+                for (epoch, tag) in proofs {

+                    out.extend_from_slice(&epoch.to_be_bytes());

+                    out.extend_from_slice(tag);


            FRAME_HELLO => Self::decode_hello(rest),
            _ => None,
        }
    }

            FRAME_HELLO => Self::decode_hello(rest),
            FRAME_PROOFSET => Self::decode_proofset(rest),
            _ => None,
        }
    }

    /// Decode the `ProofSet` payload (everything after the tag byte). Total: a
    /// count over [`MAX_PROOF_GENS`], any truncation, or trailing bytes ⇒ `None`.
    fn decode_proofset(rest: &[u8]) -> Option<SeedProofFrame> {
        if rest.len() < 4 {
            return None;
        }
        let count = u32::from_be_bytes(rest[..4].try_into().ok()?) as usize;
        if count > MAX_PROOF_GENS {
            return None;
        }
        let mut pos = 4;
        let mut proofs = Vec::with_capacity(count);
        for _ in 0..count {
            if pos + 8 + PROOF_TAG_LEN > rest.len() {
                return None;
            }
            let epoch = u64::from_be_bytes(rest[pos..pos + 8].try_into().ok()?);
            pos += 8;
            let mut tag = [0u8; PROOF_TAG_LEN];
            tag.copy_from_slice(&rest[pos..pos + PROOF_TAG_LEN]);
            pos += PROOF_TAG_LEN;
            proofs.push((epoch, tag));
        }
        if pos != rest.len() {
            return None; // no trailing bytes
        }
        Some(SeedProofFrame::ProofSet { proofs })
    }


            FRAME_HELLO => Self::decode_hello(rest),
            _ => None,
        }
    }

            FRAME_HELLO => Self::decode_hello(rest),
            FRAME_PROOFSET => Self::decode_proofset(rest),
            _ => None,
        }
    }

    /// Decode the `ProofSet` payload (everything after the tag byte). Total: a
    /// count over [`MAX_PROOF_GENS`], any truncation, or trailing bytes ⇒ `None`.
    fn decode_proofset(rest: &[u8]) -> Option<SeedProofFrame> {
        if rest.len() < 4 {
            return None;
        }
        let count = u32::from_be_bytes(rest[..4].try_into().ok()?) as usize;
        if count > MAX_PROOF_GENS {
            return None;
        }
        let mut pos = 4;
        let mut proofs = Vec::with_capacity(count);
        for _ in 0..count {
            if pos + 8 + PROOF_TAG_LEN > rest.len() {
                return None;
            }
            let epoch = u64::from_be_bytes(rest[pos..pos + 8].try_into().ok()?);
            pos += 8;
            let mut tag = [0u8; PROOF_TAG_LEN];
            tag.copy_from_slice(&rest[pos..pos + PROOF_TAG_LEN]);
            pos += PROOF_TAG_LEN;
            proofs.push((epoch, tag));
        }
        if pos != rest.len() {
            return None; // no trailing bytes
        }
        Some(SeedProofFrame::ProofSet { proofs })
    }

+            FRAME_PROOFSET => Self::decode_proofset(rest),

+    /// Decode the `ProofSet` payload (everything after the tag byte). Total: a

+    /// count over [`MAX_PROOF_GENS`], any truncation, or trailing bytes ⇒ `None`.

+    fn decode_proofset(rest: &[u8]) -> Option<SeedProofFrame> {

+        let count = u32::from_be_bytes(rest[..4].try_into().ok()?) as usize;

+        let mut proofs = Vec::with_capacity(count);

+            if pos + 8 + PROOF_TAG_LEN > rest.len() {

+            let epoch = u64::from_be_bytes(rest[pos..pos + 8].try_into().ok()?);

+            let mut tag = [0u8; PROOF_TAG_LEN];

+            tag.copy_from_slice(&rest[pos..pos + PROOF_TAG_LEN]);

+            return None; // no trailing bytes

+        Some(SeedProofFrame::ProofSet { proofs })

/// Decode the `Hello` payload (everything after the tag byte). Total: any

/// truncation or non-UTF-8 name yields `None`.

fn decode_hello(rest: &[u8]) -> Option<SeedProofFrame> {


Add a codec round-trip test for `ProofSet`:


        // Trailing bytes past the declared (empty) name list.
        let mut trailing = vec![FRAME_HELLO];
        trailing.extend_from_slice(&nonce);
        trailing.extend_from_slice(&0u32.to_be_bytes());
        trailing.push(0x00);
        assert_eq!(SeedProofFrame::decode(&trailing), None, "trailing bytes");
    }
}

        // Trailing bytes past the declared (empty) name list.
        let mut trailing = vec![FRAME_HELLO];
        trailing.extend_from_slice(&nonce);
        trailing.extend_from_slice(&0u32.to_be_bytes());
        trailing.push(0x00);
        assert_eq!(SeedProofFrame::decode(&trailing), None, "trailing bytes");
    }

    // [unit->REQ-MESH-4] the Mesh-D7 ProofSet frame (per-subnet, one tag per
    // held generation) round-trips for one and two generations; and every
    // malformed shape (over-count, truncation, trailing bytes) decodes to None —
    // a hostile peer can't smuggle a partial/oversized proof set.
    #[test]
    fn proofset_frame_round_trips_and_rejects_malformed() {
        for proofs in [
            vec![(7u64, [1u8; PROOF_TAG_LEN])],
            vec![(7u64, [1u8; PROOF_TAG_LEN]), (6u64, [2u8; PROOF_TAG_LEN])],
            vec![],
        ] {
            let f = SeedProofFrame::ProofSet {
                proofs: proofs.clone(),
            };
            assert_eq!(SeedProofFrame::decode(&f.encode()), Some(f), "round-trip {proofs:?}");
        }

        // Over the generation cap.
        let mut over = vec![FRAME_PROOFSET];
        over.extend_from_slice(&3u32.to_be_bytes());
        assert_eq!(SeedProofFrame::decode(&over), None, "over MAX_PROOF_GENS");
        // Count claims 1 but no body follows.
        let mut short = vec![FRAME_PROOFSET];
        short.extend_from_slice(&1u32.to_be_bytes());
        assert_eq!(SeedProofFrame::decode(&short), None, "truncated proof body");
        // One full proof + one trailing byte.
        let mut trailing = SeedProofFrame::ProofSet {
            proofs: vec![(1u64, [9u8; PROOF_TAG_LEN])],
        }
        .encode();
        trailing.push(0xff);
        assert_eq!(SeedProofFrame::decode(&trailing), None, "trailing bytes");
    }
}


        // Trailing bytes past the declared (empty) name list.
        let mut trailing = vec![FRAME_HELLO];
        trailing.extend_from_slice(&nonce);
        trailing.extend_from_slice(&0u32.to_be_bytes());
        trailing.push(0x00);
        assert_eq!(SeedProofFrame::decode(&trailing), None, "trailing bytes");
    }
}

        // Trailing bytes past the declared (empty) name list.
        let mut trailing = vec![FRAME_HELLO];
        trailing.extend_from_slice(&nonce);
        trailing.extend_from_slice(&0u32.to_be_bytes());
        trailing.push(0x00);
        assert_eq!(SeedProofFrame::decode(&trailing), None, "trailing bytes");
    }

    // [unit->REQ-MESH-4] the Mesh-D7 ProofSet frame (per-subnet, one tag per
    // held generation) round-trips for one and two generations; and every
    // malformed shape (over-count, truncation, trailing bytes) decodes to None —
    // a hostile peer can't smuggle a partial/oversized proof set.
    #[test]
    fn proofset_frame_round_trips_and_rejects_malformed() {
        for proofs in [
            vec![(7u64, [1u8; PROOF_TAG_LEN])],
            vec![(7u64, [1u8; PROOF_TAG_LEN]), (6u64, [2u8; PROOF_TAG_LEN])],
            vec![],
        ] {
            let f = SeedProofFrame::ProofSet {
                proofs: proofs.clone(),
            };
            assert_eq!(SeedProofFrame::decode(&f.encode()), Some(f), "round-trip {proofs:?}");
        }

        // Over the generation cap.
        let mut over = vec![FRAME_PROOFSET];
        over.extend_from_slice(&3u32.to_be_bytes());
        assert_eq!(SeedProofFrame::decode(&over), None, "over MAX_PROOF_GENS");
        // Count claims 1 but no body follows.
        let mut short = vec![FRAME_PROOFSET];
        short.extend_from_slice(&1u32.to_be_bytes());
        assert_eq!(SeedProofFrame::decode(&short), None, "truncated proof body");
        // One full proof + one trailing byte.
        let mut trailing = SeedProofFrame::ProofSet {
            proofs: vec![(1u64, [9u8; PROOF_TAG_LEN])],
        }
        .encode();
        trailing.push(0xff);
        assert_eq!(SeedProofFrame::decode(&trailing), None, "trailing bytes");
    }
}

+    // [unit->REQ-MESH-4] the Mesh-D7 ProofSet frame (per-subnet, one tag per

+    // held generation) round-trips for one and two generations; and every

+    // malformed shape (over-count, truncation, trailing bytes) decodes to None —

+    // a hostile peer can't smuggle a partial/oversized proof set.

+    fn proofset_frame_round_trips_and_rejects_malformed() {

+            let f = SeedProofFrame::ProofSet {

+                proofs: proofs.clone(),

+            assert_eq!(SeedProofFrame::decode(&f.encode()), Some(f), "round-trip {proofs:?}");

+        let mut over = vec![FRAME_PROOFSET];

+        over.extend_from_slice(&3u32.to_be_bytes());

+        assert_eq!(SeedProofFrame::decode(&over), None, "over MAX_PROOF_GENS");

+        // Count claims 1 but no body follows.

+        let mut short = vec![FRAME_PROOFSET];

+        short.extend_from_slice(&1u32.to_be_bytes());

+        assert_eq!(SeedProofFrame::decode(&short), None, "truncated proof body");

+        // One full proof + one trailing byte.

+        let mut trailing = SeedProofFrame::ProofSet {

+            proofs: vec![(1u64, [9u8; PROOF_TAG_LEN])],

+        assert_eq!(SeedProofFrame::decode(&trailing), None, "trailing bytes");


Bash

cargo test -p spt-net --lib mesh::seedproof 2>&1 | tail -20

Test the seedproof codec changes


   Compiling tokio-rustls v0.26.4
   Compiling rustls-platform-verifier v0.7.0
   Compiling noq-proto v0.17.0
   Compiling hickory-net v0.26.0-beta.4
   Compiling hyper-rustls v0.27.9
   Compiling tokio-websockets v0.13.2
   Compiling reqwest v0.13.4
   Compiling hickory-resolver v0.26.0-beta.4
   Compiling noq v0.18.0
   Compiling iroh-relay v0.98.0
   Compiling iroh v0.98.2
   Compiling spt-net v0.2.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
error[E0063]: missing field `prev_seed_hex` in initializer of `SubnetRecord`
   --> crates\spt-net\src\net\pairing\wire.rs:920:27
    |
920 |             subnets: vec![SubnetRecord {
    |                           ^^^^^^^^^^^^ missing `prev_seed_hex`

For more information about this error, try `rustc --explain E0063`.
error: could not compile `spt-net` (lib test) due to 1 previous error


A test in wire.rs constructs `SubnetRecord`. Fix it:


915	    const NOW: u64 = 1_711_111_110;
916
917	    /// A responder-side subnet store holding one subnet with a known seed at `epoch`.
918	    fn subnet_store(name: &str, seed: [u8; 20], epoch: u64) -> SubnetStore {
919	        SubnetStore {
920	            subnets: vec![SubnetRecord {
921	                name: name.to_string(),
922	                seed_hex: hex(&seed),
923	                epoch,
924	                user: None,
925	                created: "0".into(),
926	                hide_new_endpoints: false,
927	            }],
928	        }
929	    }
930
931	    fn hex(bytes: &[u8]) -> String {
932	        bytes.iter().map(|b| format!("{b:02x}")).collect()
933	    }
934

    const NOW: u64 = 1_711_111_110;

    /// A responder-side subnet store holding one subnet with a known seed at `epoch`.
    fn subnet_store(name: &str, seed: [u8; 20], epoch: u64) -> SubnetStore {
        SubnetStore {
            subnets: vec![SubnetRecord {
                name: name.to_string(),
                seed_hex: hex(&seed),
                epoch,
                user: None,
                created: "0".into(),
                hide_new_endpoints: false,
            }],
        }
    }

    fn hex(bytes: &[u8]) -> String {
        bytes.iter().map(|b| format!("{b:02x}")).collect()
    }


Edit

                user: None,
                created: "0".into(),
                hide_new_endpoints: false,
            }],
        }
    }

                user: None,
                created: "0".into(),
                hide_new_endpoints: false,
                prev_seed_hex: None,
            }],
        }
    }


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

                user: None,
                created: "0".into(),
                hide_new_endpoints: false,
            }],
        }
    }

                user: None,
                created: "0".into(),
                hide_new_endpoints: false,
                prev_seed_hex: None,
            }],
        }
    }


Bash

cargo test -p spt-net --lib mesh::seedproof 2>&1 | tail -15

Re-test the seedproof codec


running 10 tests
test net::mesh::seedproof::tests::frame_codec_round_trips_and_rejects_garbage ... ok
test net::mesh::seedproof::tests::hello_frame_round_trips_and_rejects_malformed ... ok
test net::mesh::seedproof::tests::proofset_frame_round_trips_and_rejects_malformed ... ok
test net::mesh::seedproof::tests::mk_derivation_is_deterministic_and_input_sensitive ... ok
test net::mesh::seedproof::tests::wrong_epoch_fails ... ok
test net::mesh::seedproof::tests::valid_proof_verifies_and_roles_differ ... ok
test net::mesh::seedproof::tests::forged_proof_fails ... ok
test net::mesh::seedproof::tests::wrong_subnet_fails ... ok
test net::mesh::seedproof::tests::cross_connection_replay_fails ... ok
test net::mesh::seedproof::tests::mutual_requires_both_sides ... ok

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


Codec green. Now check who constructs `RosterExchange` before I extend it:


43-/// the proven subnets' slice; the sink merges the peer's slice in. Mirrors
44-/// `production_roster_exchange` minus the file/peeraddrs plumbing.
45:fn test_exchange(store: Arc<Mutex<RosterStore>>, self_hex: String) -> RosterExchange {
46-    let prov_store = Arc::clone(&store);
47:    RosterExchange {
48:        provider: Arc::new(move |proven: &HashSet<String>, self_addr: &serde_json::Value| {
49-            let mut s = prov_store.lock().unwrap();
50-            let addr = if self_addr.is_null() {
--
65-            (entries, tombs)
66-        }),
67:        sink: Arc::new(move |entries, tombs| {
68-            let mut s = store.lock().unwrap();
69-            for e in &entries {


Now the substantial rework of `seedproofx.rs`. The key insight for protocol robustness: **the roster + seed legs must run over the symmetric Hello-agreed shared set** (not the grade-filtered set), because tombstone-induced grading is asymmetric and would otherwise desync the stream. Let me rework it.

First, `SubnetCred` gains a prior generation, plus the grade/status types:


Edit

/// One subnet's membership credential as the proof needs it: the human name
/// (goes on the wire), the raw seed bytes, and the current epoch (both local,
/// never on the wire). Built from `spt_store::subnet::SubnetStore`.
#[derive(Clone)]
pub struct SubnetCred {
    /// Subnet name — the only field that crosses the wire (a label, not a secret).
    pub name: String,
    /// The subnet seed bytes (HKDF input material). Never transmitted.
    pub seed: Vec<u8>,
    /// The local current `seed_epoch` for this subnet. Never transmitted.
    pub epoch: u64,
}

/// One subnet's membership credential as the proof needs it: the human name
/// (goes on the wire), the raw seed bytes, and the current epoch (both local,
/// never on the wire). Built from `spt_store::subnet::SubnetStore`.
#[derive(Clone)]
pub struct SubnetCred {
    /// Subnet name — the only field that crosses the wire (a label, not a secret).
    pub name: String,
    /// The subnet seed bytes (HKDF input material). Never transmitted.
    pub seed: Vec<u8>,
    /// The local current `seed_epoch` for this subnet. Never transmitted.
    pub epoch: u64,
    /// The **immediately-prior** generation `(seed, epoch)`, retained one-deep
    /// (Mesh-D7). The fresh side keeps it so it can verify a benign offliner's
    /// `N-1` proof; the stale side keeps its own so a fresh peer's prior-epoch
    /// proof verifies. `None` until the subnet has rotated at least once. Never
    /// transmitted — the *new* seed crosses only the [`SeedTransfer`] grace leg.
    // [impl->REQ-MESH-4]
    pub prev: Option<(Vec<u8>, u64)>,
}

/// A peer's per-subnet roster status, as the re-seed grace needs it — supplied
/// by the [`RosterExchange::member_status`] seam (production: a `RosterStore`
/// lookup). The grace is gated on this: a tombstoned (revoked) peer is denied
/// outright; an absent (unknown) peer may still prove the *current* seed (a
/// just-paired member whose roster row has not merged yet) but is never *re*-
/// seeded; a present member gets the full one-deep grace.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemberStatus {
    /// A current, non-tombstoned roster member.
    Present,
    /// Carries a roster tombstone here — revoked. Denied even on an exact-epoch
    /// proof (locks the revokee out the instant the tombstone propagates, before
    /// the scheduled rotation even fires).
    Tombstoned,
    /// Not in this node's roster for the subnet (a roster gap). Admitted on an
    /// exact-epoch proof, never re-seeded.
    Absent,
}

/// The three-valued per-subnet seed-proof verdict (Mesh-D7 — generalizes D2's
/// exact-epoch pass/fail). `Full` joins the connection's proven set; `ReseedOnly`
/// triggers a confidential seed push to the behind peer (and is excluded from
/// the proven set — the conn re-forms full once the peer adopts); `Denied` is no
/// proof at this or the prior generation, or a revoked peer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Grade {
    /// Exact-epoch proof from a non-revoked peer — a full member connection.
    Full,
    /// Prior-epoch (`N-1`) proof from a present, non-revoked peer — push the seed.
    ReseedOnly,
    /// No usable proof (wrong seed, `≥2` stale), or a revoked peer.
    Denied,
}

/// Grade one subnet from the verify outcome + the peer's roster status. The pure
/// core of the re-seed grace, unit-tested as a truth table. `matched_current` =
/// the peer proved this node's CURRENT generation; `matched_prev` = it proved the
/// immediately-prior one. `status` is `None` when no roster seam is wired (the
/// D2 mechanics path — epoch-only, no revoke universe).
// [impl->REQ-MESH-4]
fn grade_subnet(matched_current: bool, matched_prev: bool, status: Option<MemberStatus>) -> Grade {
    match status {
        // No roster info (mechanics-only / D2 tests): epoch-only grading.
        None => {
            if matched_current {
                Grade::Full
            } else if matched_prev {
                Grade::ReseedOnly
            } else {
                Grade::Denied
            }
        }
        // Revoked: denied regardless of which generation it proved — the
        // tombstone locks the revokee out even before its seed is rotated away.
        Some(MemberStatus::Tombstoned) => Grade::Denied,
        // Unknown member: a current-epoch proof is admitted (roster merges
        // catch up on this very connection), but it is never RE-seeded.
        Some(MemberStatus::Absent) => {
            if matched_current {
                Grade::Full
            } else {
                Grade::Denied
            }
        }
        // Present member: the full one-deep grace.
        Some(MemberStatus::Present) => {
            if matched_current {
                Grade::Full
            } else if matched_prev {
                Grade::ReseedOnly
            } else {
                Grade::Denied
            }
        }
    }
}


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

/// One subnet's membership credential as the proof needs it: the human name
/// (goes on the wire), the raw seed bytes, and the current epoch (both local,
/// never on the wire). Built from `spt_store::subnet::SubnetStore`.
#[derive(Clone)]
pub struct SubnetCred {
    /// Subnet name — the only field that crosses the wire (a label, not a secret).
    pub name: String,
    /// The subnet seed bytes (HKDF input material). Never transmitted.
    pub seed: Vec<u8>,
    /// The local current `seed_epoch` for this subnet. Never transmitted.
    pub epoch: u64,
}

/// One subnet's membership credential as the proof needs it: the human name
/// (goes on the wire), the raw seed bytes, and the current epoch (both local,
/// never on the wire). Built from `spt_store::subnet::SubnetStore`.
#[derive(Clone)]
pub struct SubnetCred {
    /// Subnet name — the only field that crosses the wire (a label, not a secret).
    pub name: String,
    /// The subnet seed bytes (HKDF input material). Never transmitted.
    pub seed: Vec<u8>,
    /// The local current `seed_epoch` for this subnet. Never transmitted.
    pub epoch: u64,
    /// The **immediately-prior** generation `(seed, epoch)`, retained one-deep
    /// (Mesh-D7). The fresh side keeps it so it can verify a benign offliner's
    /// `N-1` proof; the stale side keeps its own so a fresh peer's prior-epoch
    /// proof verifies. `None` until the subnet has rotated at least once. Never
    /// transmitted — the *new* seed crosses only the [`SeedTransfer`] grace leg.
    // [impl->REQ-MESH-4]
    pub prev: Option<(Vec<u8>, u64)>,
}

/// A peer's per-subnet roster status, as the re-seed grace needs it — supplied
/// by the [`RosterExchange::member_status`] seam (production: a `RosterStore`
/// lookup). The grace is gated on this: a tombstoned (revoked) peer is denied
/// outright; an absent (unknown) peer may still prove the *current* seed (a
/// just-paired member whose roster row has not merged yet) but is never *re*-
/// seeded; a present member gets the full one-deep grace.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemberStatus {
    /// A current, non-tombstoned roster member.
    Present,
    /// Carries a roster tombstone here — revoked. Denied even on an exact-epoch
    /// proof (locks the revokee out the instant the tombstone propagates, before
    /// the scheduled rotation even fires).
    Tombstoned,
    /// Not in this node's roster for the subnet (a roster gap). Admitted on an
    /// exact-epoch proof, never re-seeded.
    Absent,
}

/// The three-valued per-subnet seed-proof verdict (Mesh-D7 — generalizes D2's
/// exact-epoch pass/fail). `Full` joins the connection's proven set; `ReseedOnly`
/// triggers a confidential seed push to the behind peer (and is excluded from
/// the proven set — the conn re-forms full once the peer adopts); `Denied` is no
/// proof at this or the prior generation, or a revoked peer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Grade {
    /// Exact-epoch proof from a non-revoked peer — a full member connection.
    Full,
    /// Prior-epoch (`N-1`) proof from a present, non-revoked peer — push the seed.
    ReseedOnly,
    /// No usable proof (wrong seed, `≥2` stale), or a revoked peer.
    Denied,
}

/// Grade one subnet from the verify outcome + the peer's roster status. The pure
/// core of the re-seed grace, unit-tested as a truth table. `matched_current` =
/// the peer proved this node's CURRENT generation; `matched_prev` = it proved the
/// immediately-prior one. `status` is `None` when no roster seam is wired (the
/// D2 mechanics path — epoch-only, no revoke universe).
// [impl->REQ-MESH-4]
fn grade_subnet(matched_current: bool, matched_prev: bool, status: Option<MemberStatus>) -> Grade {
    match status {
        // No roster info (mechanics-only / D2 tests): epoch-only grading.
        None => {
            if matched_current {
                Grade::Full
            } else if matched_prev {
                Grade::ReseedOnly
            } else {
                Grade::Denied
            }
        }
        // Revoked: denied regardless of which generation it proved — the
        // tombstone locks the revokee out even before its seed is rotated away.
        Some(MemberStatus::Tombstoned) => Grade::Denied,
        // Unknown member: a current-epoch proof is admitted (roster merges
        // catch up on this very connection), but it is never RE-seeded.
        Some(MemberStatus::Absent) => {
            if matched_current {
                Grade::Full
            } else {
                Grade::Denied
            }
        }
        // Present member: the full one-deep grace.
        Some(MemberStatus::Present) => {
            if matched_current {
                Grade::Full
            } else if matched_prev {
                Grade::ReseedOnly
            } else {
                Grade::Denied
            }
        }
    }
}

+    /// The **immediately-prior** generation `(seed, epoch)`, retained one-deep

+    /// (Mesh-D7). The fresh side keeps it so it can verify a benign offliner's

+    /// `N-1` proof; the stale side keeps its own so a fresh peer's prior-epoch

+    /// proof verifies. `None` until the subnet has rotated at least once. Never

+    /// transmitted — the *new* seed crosses only the [`SeedTransfer`] grace leg.

+/// A peer's per-subnet roster status, as the re-seed grace needs it — supplied

+/// by the [`RosterExchange::member_status`] seam (production: a `RosterStore`

+/// lookup). The grace is gated on this: a tombstoned (revoked) peer is denied

+/// outright; an absent (unknown) peer may still prove the *current* seed (a

+/// just-paired member whose roster row has not merged yet) but is never *re*-

+/// seeded; a present member gets the full one-deep grace.

+#[derive(Debug, Clone, Copy, PartialEq, Eq)]

+    /// A current, non-tombstoned roster member.

+    /// Carries a roster tombstone here — revoked. Denied even on an exact-epoch

+    /// proof (locks the revokee out the instant the tombstone propagates, before

+    /// the scheduled rotation even fires).

+    /// Not in this node's roster for the subnet (a roster gap). Admitted on an

+    /// exact-epoch proof, never re-seeded.

+/// The three-valued per-subnet seed-proof verdict (Mesh-D7 — generalizes D2's

+/// exact-epoch pass/fail). `Full` joins the connection's proven set; `ReseedOnly`

+/// triggers a confidential seed push to the behind peer (and is excluded from

+/// the proven set — the conn re-forms full once the peer adopts); `Denied` is no

+/// proof at this or the prior generation, or a revoked peer.

+    /// Exact-epoch proof from a non-revoked peer — a full member connection.

+    /// Prior-epoch (`N-1`) proof from a present, non-revoked peer — push the seed.

+    /// No usable proof (wrong seed, `≥2` stale), or a revoked peer.

+/// Grade one subnet from the verify outcome + the peer's roster status. The pure

+/// core of the re-seed grace, unit-tested as a truth table. `matched_current` =

+/// the peer proved this node's CURRENT generation; `matched_prev` = it proved the

+/// immediately-prior one. `status` is `None` when no roster seam is wired (the

+/// D2 mechanics path — epoch-only, no revoke universe).

+fn grade_subnet(matched_current: bool, matched_prev: bool, status: Option<MemberStatus>) -> Grade {

+        // No roster info (mechanics-only / D2 tests): epoch-only grading.

+        // Revoked: denied regardless of which generation it proved — the

+        // tombstone locks the revokee out even before its seed is rotated away.

+        Some(MemberStatus::Tombstoned) => Grade::Denied,

+        // Unknown member: a current-epoch proof is admitted (roster merges

+        // catch up on this very connection), but it is never RE-seeded.

+        Some(MemberStatus::Absent) => {

+        // Present member: the full one-deep grace.

+        Some(MemberStatus::Present) => {

/// A live source of this node's subnet credentials, re-read per connection so a

/// rotation (Mesh-D7) is picked up without a restart. Production wires this to

/// `SubnetStore::load()`; `None` on [`NetConfig`](crate::nethost::NetConfig)


Update `production_membership` to carry the prior generation:


        SubnetStore::load()
            .subnets
            .iter()
            .filter_map(|r| {
                r.seed_bytes().map(|seed| SubnetCred {
                    name: r.name.clone(),
                    seed: seed.to_vec(),
                    epoch: r.epoch,
                })
            })
            .collect()

        SubnetStore::load()
            .subnets
            .iter()
            .filter_map(|r| {
                r.seed_bytes().map(|seed| SubnetCred {
                    name: r.name.clone(),
                    seed: seed.to_vec(),
                    epoch: r.epoch,
                    // One-deep prior generation (Mesh-D7): present after a
                    // rotation, lets this node verify an N-1 grace proof.
                    prev: r.prev_seed_bytes().map(|(s, e)| (s.to_vec(), e)),
                })
            })
            .collect()


        SubnetStore::load()
            .subnets
            .iter()
            .filter_map(|r| {
                r.seed_bytes().map(|seed| SubnetCred {
                    name: r.name.clone(),
                    seed: seed.to_vec(),
                    epoch: r.epoch,
                })
            })
            .collect()

        SubnetStore::load()
            .subnets
            .iter()
            .filter_map(|r| {
                r.seed_bytes().map(|seed| SubnetCred {
                    name: r.name.clone(),
                    seed: seed.to_vec(),
                    epoch: r.epoch,
                    // One-deep prior generation (Mesh-D7): present after a
                    // rotation, lets this node verify an N-1 grace proof.
                    prev: r.prev_seed_bytes().map(|(s, e)| (s.to_vec(), e)),
                })
            })
            .collect()

+                    // One-deep prior generation (Mesh-D7): present after a

+                    // rotation, lets this node verify an N-1 grace proof.

+                    prev: r.prev_seed_bytes().map(|(s, e)| (s.to_vec(), e)),


Now rework `prove_membership` to thread the peer hex through, then rewrite the dialer/acceptor legs:


    let remote = PublicKey::from_bytes(conn.remote_id().as_bytes()).ok()?;
    // Channel binding: the transcript pins both handshake-proven pubkeys in a
    // role-fixed order (dialer first), so a tag is valid on exactly this conn.
    let (dialer_pub, acceptor_pub) = match role {
        ProofRole::Dialer => (local_id, remote),
        ProofRole::Acceptor => (remote, local_id),
    };
    let by_name: HashMap<&str, &SubnetCred> =
        creds.iter().map(|c| (c.name.as_str(), c)).collect();

    match role {
        ProofRole::Dialer => {
            let (mut send, mut recv) = conn.open_bi().await.ok()?;
            prove_as_dialer(
                &mut send,
                &mut recv,
                creds,
                &by_name,
                dialer_pub,
                acceptor_pub,
                roster,
                self_addr,
            )
            .await
        }
        ProofRole::Acceptor => {
            let (mut send, mut recv) = conn.accept_bi().await.ok()?;
            prove_as_acceptor(
                &mut send,
                &mut recv,
                &by_name,
                dialer_pub,
                acceptor_pub,
                roster,
                self_addr,
            )
            .await
        }
    }
}

    let remote = PublicKey::from_bytes(conn.remote_id().as_bytes()).ok()?;
    let remote_hex = remote.to_hex();
    // Channel binding: the transcript pins both handshake-proven pubkeys in a
    // role-fixed order (dialer first), so a tag is valid on exactly this conn.
    let (dialer_pub, acceptor_pub) = match role {
        ProofRole::Dialer => (local_id, remote),
        ProofRole::Acceptor => (remote, local_id),
    };
    let by_name: HashMap<&str, &SubnetCred> =
        creds.iter().map(|c| (c.name.as_str(), c)).collect();

    match role {
        ProofRole::Dialer => {
            let (mut send, mut recv) = conn.open_bi().await.ok()?;
            prove_as_dialer(
                &mut send,
                &mut recv,
                creds,
                &by_name,
                dialer_pub,
                acceptor_pub,
                &remote_hex,
                roster,
                self_addr,
            )
            .await
        }
        ProofRole::Acceptor => {
            let (mut send, mut recv) = conn.accept_bi().await.ok()?;
            prove_as_acceptor(
                &mut send,
                &mut recv,
                &by_name,
                dialer_pub,
                acceptor_pub,
                &remote_hex,
                roster,
                self_addr,
            )
            .await
        }
    }
}

/// One subnet's local generations, as the proof exchange needs them: the
/// channel-bound transcript + membership key for the current generation and
/// (one-deep) the prior one (Mesh-D7). The outbound proof set is one tag per
/// generation; the inbound is graded against both.
struct GenProof {
    epoch: u64,
    mk: MembershipKey,
    transcript: SeedProofTranscript,
}

struct LocalGens {
    name: String,
    current: GenProof,
    prev: Option<GenProof>,
}

impl LocalGens {
    /// The `(epoch, tag)` set this node sends for the subnet, proving each
    /// generation it holds in role `mine` (current first).
    fn outbound(&self, mine: ProofRole) -> Vec<(u64, [u8; 32])> {
        let mut out = vec![(self.current.epoch, self.current.transcript.tag(&self.current.mk, mine))];
        if let Some(p) = &self.prev {
            out.push((p.epoch, p.transcript.tag(&p.mk, mine)));
        }
        out
    }

    /// `(matched_current, matched_prev)` for the peer's offered proofs, verified
    /// in the peer's role `prover`. A tag is matched only at the generation whose
    /// epoch it carries — the transcript binds the epoch, so a tag for one
    /// generation can never satisfy another.
    fn matches(&self, peer_proofs: &[(u64, [u8; 32])], prover: ProofRole) -> (bool, bool) {
        let mc = peer_proofs.iter().any(|(e, t)| {
            *e == self.current.epoch && self.current.transcript.verify(&self.current.mk, prover, t)
        });
        let mp = self.prev.as_ref().is_some_and(|p| {
            peer_proofs
                .iter()
                .any(|(e, t)| *e == p.epoch && p.transcript.verify(&p.mk, prover, t))
        });
        (mc, mp)
    }
}

/// Build a [`GenProof`] for one generation (seed + epoch) under the connection's
/// channel binding.
fn gen_proof(
    seed: &[u8],
    name: &str,
    epoch: u64,
    dialer_pub: PublicKey,
    acceptor_pub: PublicKey,
    nonce_d: [u8; NONCE_LEN],
    nonce_a: [u8; NONCE_LEN],
) -> GenProof {
    GenProof {
        epoch,
        mk: MembershipKey::derive(seed, name, epoch),
        transcript: SeedProofTranscript {
            subnet_id: name.to_string(),
            seed_epoch: epoch,
            dialer_pub,
            acceptor_pub,
            dialer_nonce: nonce_d,
            acceptor_nonce: nonce_a,
        },
    }
}

/// Build the per-subnet generations for the shared set (current + one-deep
/// prior). `None` if a shared name has no local credential (cannot happen after
/// the membership filter, but kept total).
fn build_gens(
    shared: &[String],
    by_name: &HashMap<&str, &SubnetCred>,
    dialer_pub: PublicKey,
    acceptor_pub: PublicKey,
    nonce_d: [u8; NONCE_LEN],
    nonce_a: [u8; NONCE_LEN],
) -> Option<Vec<LocalGens>> {
    let mut out = Vec::with_capacity(shared.len());
    for name in shared {
        let cred = by_name.get(name.as_str())?;
        let current = gen_proof(
            &cred.seed, name, cred.epoch, dialer_pub, acceptor_pub, nonce_d, nonce_a,
        );
        let prev = cred.prev.as_ref().map(|(seed, epoch)| {
            gen_proof(seed, name, *epoch, dialer_pub, acceptor_pub, nonce_d, nonce_a)
        });
        out.push(LocalGens {
            name: name.clone(),
            current,
            prev,
        });
    }
    Some(out)
}

/// This peer's roster status for a subnet, via the (opt-in) roster seam. `None`
/// when no seam is wired — the mechanics-only path grades on epoch alone.
fn peer_status(roster: Option<&RosterExchange>, subnet: &str, peer_hex: &str) -> Option<MemberStatus> {
    roster.map(|rx| (rx.member_status)(subnet, peer_hex))
}