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

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

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

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

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

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

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

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

/// One subnet the node belongs to: its human name, the durable TOTP seed (hex), the
/// seed epoch, and (forward seam) the owning user.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubnetRecord {
    /// Human subnet label (R-PAIR-4) — what the user types when pairing.
    pub name: String,
    /// The 20-byte TOTP seed, lowercase hex. The subnet secret; never synced.
    pub seed_hex: String,
    /// Monotonic seed generation (ADR-0005 #10). Bumped on every
    /// [`rotate_seed`](SubnetStore::rotate_seed); bound into the pairing transcript.
    pub epoch: u64,
    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user: Option<String>,
    /// Creation stamp (epoch-seconds string; opaque).
    pub created: String,
}

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

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

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

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

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

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

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

    /// Create a new subnet with a freshly minted seed at epoch 1 (the "create-new"
    /// pairing path — this node becomes the sole seed-holder). Errors if the name
    /// already exists. Returns the new record.
    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
    pub fn create_subnet(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
        if self.find(name).is_some() {
            return Err(SubnetError::Exists(name.to_string()));
        }
        self.subnets.push(SubnetRecord {
            name: name.to_string(),
            seed_hex: encode_hex(&random_seed()),
            epoch: 1,
            user: None,
            created: now_stamp(),
        });
        Ok(self.subnets.last().expect("just pushed"))
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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


1	//! `spt-store` — the persistence commodity layer for spt-core.
2	//!
3	//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
4	//! types to disk (spool, registry, info.json) through a single atomic-write
5	//! primitive. Stable on-disk formats are copied verbatim from the sister
6	//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
7	//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
8	//!
9	// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
10	// depends only on spt-proto (one layer down), never the reverse — the
11	// dependency edge is one-way, so the workspace graph stays acyclic.
12	// [impl->REQ-ARCH-1]
13
14	pub mod atomic;
15	mod db;
16	pub mod history;
17	pub mod info;
18	pub mod liveness;
19	pub mod perch;
20	pub mod proc;
21	pub mod registry;
22	pub mod seed;
23	pub mod spool;
24

//! `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 atomic;
mod db;
pub mod history;
pub mod info;
pub mod liveness;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod seed;
pub mod spool;


Edit

pub mod seed;
pub mod spool;
pub mod subnet;


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 seed;
pub mod spool;
pub mod subnet;

//! `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 atomic;
mod db;
pub mod history;
pub mod info;
pub mod liveness;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod seed;
pub mod spool;

pub mod registry;
pub mod seed;
pub mod spool;
+pub mod subnet;


Bash

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

Test subnet store


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

running 8 tests
test subnet::tests::add_joined_preserves_seed_and_epoch ... ok
test subnet::tests::hex_round_trips_and_rejects_bad_input ... ok
test subnet::tests::create_subnet_starts_at_epoch_one_unique_name ... ok
test subnet::tests::remove_drops_membership ... ok
test subnet::tests::minted_seeds_are_random ... ok
test subnet::tests::rotate_bumps_epoch_and_changes_seed ... ok
test subnet::tests::missing_or_corrupt_file_is_empty_store ... ok
test subnet::tests::store_persists_multi_subnet_and_rotation ... ok

test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 44 filtered out; finished in 0.01s


All 8 pass. Bridge to spt-net is just `TotpSeed::from_bytes(record.seed_bytes()?)` — both sides already exist, no new spt-net code. Clippy + fmt, then activate REQ-HAZARD-PAIR-SEED-ROTATION.


cargo clippy -p spt-store --all-targets 2>&1 | grep -iE "warning|error" | head; echo "clippy-done"; cargo fmt -p spt-store 2>&1; cargo fmt -p spt-store -- --check 2>&1 | tail -2; echo "fmt-clean"

Clippy + fmt spt-store


warning: doc list item without indentation
warning: `spt-store` (lib) generated 1 warning
warning: `spt-store` (lib test) generated 1 warning (1 duplicate)
clippy-done
fmt-clean
[This command modified 4 files you've previously read: crates\spt-store\src\info.rs, crates\spt-store\src\subnet.rs, crates\spt-store\src\perch.rs, crates\spt-store\src\atomic.rs. Call Read before editing.]


1	//! Perch layout — the **single source of truth** for where a perch lives on
2	//! disk. Every reader and writer of a perch path routes through
3	//! [`resolve_perch_path`]; nothing composes perch paths by hand. If the layout
4	//! ever changes, exactly this one module changes (KNOWN-HAZARDS 6.1 / 1.5).
5	//!
6	//! Adapted from sister `perch_path.rs` + `owlery.rs` path primitives
7	//! (ADR-0001), with two clean-room changes for the spt-core model:
8	//! - **Taxonomy:** the sister's Spine/Touch perch kinds are gone (CONTEXT
9	//!   endpoint types are ReadyAgent/LiveAgent/Psyche/Worker/SptNode). A top-level
10	//!   agent perch (ReadyAgent or a LiveAgent's Self) classifies as [`PerchKind::Self_`].
11	//! - **Purity:** path composition performs NO filesystem I/O — every function
12	//!   returns a `PathBuf`. Directory creation is the writer's job (e.g. the spool
13	//!   `create_dir_all`s on open).
14	//!
15	//! Layout invariant:
16	//! - `Self_`            → flat: `owlery/<id>/`
17	//! - `Psyche` / `Worker`→ nested under parent: `owlery/<parent>/nested/<child>/`
18	//!
19	//! The single `nested/` namespace holds both Psyche and Worker children — no
20	//! divergent sibling layouts (the hazard this module exists to prevent).
21
22	use std::path::{Path, PathBuf};
23
24	/// Root of all spt-core on-disk state. Honors `$SPT_HOME`, else the platform
25	/// default (`%LOCALAPPDATA%\spt-core` on Windows, `$HOME/.spt-core` on Unix).
26	/// **Pure** — reads env, composes a path, creates nothing.
27	///
28	/// The default lives under `spt-core/`, **deliberately distinct from the modern
29	/// spt runtime's `spt/` home**, so a dev build never collides with a running
30	/// spt-plugin during the parity build-out. Once spt-core + the spt-plugin reach
31	/// full parity with modern spt this may simply stay `spt-core/` for good; the
32	/// override (`$SPT_HOME`) is the relocation knob either way.
33	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
34	pub fn spt_home() -> PathBuf {
35	    if let Ok(over) = std::env::var("SPT_HOME") {
36	        if !over.is_empty() {
37	            return PathBuf::from(over);
38	        }
39	    }
40	    platform_default_spt_home()
41	}
42
43	#[cfg(windows)]
44	fn platform_default_spt_home() -> PathBuf {
45	    if let Ok(local) = std::env::var("LOCALAPPDATA") {
46	        if !local.is_empty() {
47	            return PathBuf::from(local).join("spt-core");
48	        }
49	    }
50	    let up =
51	        std::env::var("USERPROFILE").expect("LOCALAPPDATA or USERPROFILE must be set on Windows");
52	    PathBuf::from(up)
53	        .join("AppData")
54	        .join("Local")
55	        .join("spt-core")
56	}
57
58	#[cfg(unix)]
59	fn platform_default_spt_home() -> PathBuf {
60	    let home = std::env::var("HOME").expect("HOME must be set on Unix");
61	    PathBuf::from(home).join(".spt-core")
62	}
63
64	/// The owlery directory holding all perches: `<spt_home>/owlery/`.
65	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
66	pub fn owlery_dir() -> PathBuf {
67	    spt_home().join("owlery")
68	}
69
70	/// Node-local **durable** identity/security material: `<spt_home>/identity/`
71	/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
72	/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
73	/// material (the node keypair + subnet TOTP seeds). Pure path composition.
74	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
75	pub fn identity_dir() -> PathBuf {
76	    spt_home().join("identity")
77	}
78
79	/// The subnet membership + seed file: `<spt_home>/identity/subnet.json`
80	/// (`docs/STORAGE.md`). One file holding *all* subnets the node belongs to
81	/// (multi-subnet — a node holds N seeds), in a `(subnet, user)`-ready shape.
82	/// Replicated via pairing/P2P, **not** git.
83	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
84	pub fn subnet_file() -> PathBuf {
85	    identity_dir().join("subnet.json")
86	}
87
88	/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
89	/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
90	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
91	pub fn trust_dir() -> PathBuf {
92	    identity_dir().join("trust")
93	}
94
95	/// Classification of a perch id by its naming convention. Stable, pure, no I/O.
96	///
97	/// - `<x>-psyche` → [`PerchKind::Psyche`]
98	/// - `<x>-w{N}`   → [`PerchKind::Worker`] (`{N}` = one-or-more ASCII digits, `<x>` non-empty)
99	/// - anything else → [`PerchKind::Self_`]
100	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101	pub enum PerchKind {
102	    /// Top-level agent perch (a ReadyAgent, or a LiveAgent's Self).
103	    Self_,
104	    /// A LiveAgent's Psyche companion perch.
105	    Psyche,
106	    /// A subagent perch under a parent LiveAgent.
107	    Worker,
108	}
109
110	impl PerchKind {
111	    /// Classify an id by suffix. Worker is checked FIRST (rightmost `-w{digits}`)
112	    /// so `dunsen-psyche-w1` classifies as `Worker`.
113	    // [impl->REQ-HAZARD-WORKER-PATH]
114	    pub fn classify(id: &str) -> PerchKind {
115	        if is_worker_id(id) {
116	            return PerchKind::Worker;
117	        }
118	        if id.ends_with("-psyche") && id.len() > "-psyche".len() {
119	            return PerchKind::Psyche;
120	        }
121	        PerchKind::Self_
122	    }
123
124	    /// True for kinds nested under a parent (Psyche, Worker); false for Self_.
125	    pub fn nests_under_parent(self) -> bool {
126	        matches!(self, PerchKind::Psyche | PerchKind::Worker)
127	    }
128	}
129
130	/// Worker-id rule: rightmost `-w` whose suffix is one-or-more digits, with a
131	/// non-empty name before it.
132	fn is_worker_id(id: &str) -> bool {
133	    if let Some(pos) = id.rfind("-w") {
134	        if pos == 0 {
135	            return false;
136	        }
137	        let suffix = &id[pos + 2..];
138	        !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())
139	    } else {
140	        false
141	    }
142	}
143
144	/// How the resolver determines the parent id for nested kinds.
145	/// `Explicit` is preferred (caller knows the parent); `Infer` derives it by
146	/// stripping the kind suffix from the child id (legacy reader call sites).
147	#[derive(Debug, Clone)]
148	pub enum ParentHint<'a> {
149	    Explicit(&'a str),
150	    Infer,
151	}
152
153	/// **The** perch-directory resolver. Self_ → flat `owlery/<id>/`;
154	/// Psyche/Worker → `owlery/<parent>/nested/<child>/`. Panic-free: if `Infer`
155	/// cannot derive a parent (id shape doesn't match its kind — should not happen
156	/// since classify+infer agree), falls back to flat for safety.
157	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
158	// [impl->REQ-HAZARD-WORKER-PATH]
159	pub fn resolve_perch_path(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
160	    resolve_perch_path_in(&owlery_dir(), child_id, hint)
161	}
162
163	/// [`resolve_perch_path`] against an **explicit** owlery root rather than the
164	/// process-global [`owlery_dir`]. Same single-source layout logic — this is the
165	/// real resolver and [`resolve_perch_path`] is the canonical-root wrapper. Used
166	/// by callers that already hold an owlery root (e.g. the registry's stale-clean,
167	/// which resolves a row's perch to read its daemon-managed `status`) so they
168	/// resolve consistently with the owlery they operate on, not the global home.
169	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
170	// [impl->REQ-HAZARD-WORKER-PATH]
171	pub fn resolve_perch_path_in(owlery: &Path, child_id: &str, hint: ParentHint<'_>) -> PathBuf {
172	    let kind = PerchKind::classify(child_id);
173	    if !kind.nests_under_parent() {
174	        return owlery.join(child_id);
175	    }
176	    let parent = match hint {
177	        ParentHint::Explicit(p) => p.to_string(),
178	        ParentHint::Infer => match infer_parent(child_id, kind) {
179	            Some(p) => p,
180	            None => return owlery.join(child_id), // defensive flat fallback
181	        },
182	    };
183	    owlery.join(parent).join("nested").join(child_id)
184	}
185
186	/// Derive the parent id of a nested child by stripping its kind suffix.
187	fn infer_parent(id: &str, kind: PerchKind) -> Option<String> {

... [269 lines truncated] ...


1	//! `subnet.json` — multi-subnet membership + TOTP seed material (ADR-0005/0006, D2c).
2	//!
3	//! A node belongs to *N* subnets (each cryptographically its own TOTP seed + trust
4	//! context — `CONTEXT.md` "subnet membership"), so this one durable file under
5	//! [`identity/`](crate::perch::identity_dir) holds a **collection** of
6	//! [`SubnetRecord`]s, not a single subnet. Node-local, durable, **never git-tracked
7	//! and never synced over the context repo** — it is subnet-joining secret material
8	//! (it replicates only via the pairing/P2P channel, `docs/STORAGE.md`).
9	//!
10	//! ## Seed epoch + rotation (KNOWN-HAZARDS / ADR-0005 #10)
11	//!
12	//! Each record carries a monotonic [`epoch`](SubnetRecord::epoch). Stage-A red-team
13	//! **#10**: *trust-store delete is not revocation* — the seed is replicated to every
14	//! trusted node, so a compromised node can re-pair as a "new" node after a mere
15	//! trust-store delete. The fix is [`rotate_seed`](SubnetStore::rotate_seed): removing
16	//! a node **rotates the subnet seed and bumps the epoch**, so the old seed computes
17	//! the wrong TOTP code and the [transcript](../../../spt_net/net/pairing/transcript)
18	//! (which binds the epoch) rejects an old-epoch rejoin. The epoch is the
19	//! responder-authoritative value the pairing ceremony binds.
20	//!
21	//! ## `(subnet, user)`-ready shape (cross-user seam)
22	//!
23	//! The optional [`user`](SubnetRecord::user) field is the forward seam for the
24	//! cross-user model (`CONTEXT.md` "Cross-user seam"): v1 has one implicit user
25	//! (`None`); when cross-user lands, the seed key generalizes to `(subnet, user)` with
26	//! no re-architecture — force-unlinking a whole user's node-group becomes dropping
27	//! that user's records.
28	//
29	// [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
30
31	use serde::{Deserialize, Serialize};
32	use spt_proto::identity::Identity;
33	use std::path::Path;
34
35	use crate::atomic::atomic_write_string;
36	use crate::perch::subnet_file;
37
38	/// TOTP seed length in bytes (160-bit, RFC 4226/6238). The crypto consumer
39	/// (`spt-net` `pairing::totp`) wraps these bytes; this layer stores them.
40	pub const TOTP_SEED_LEN: usize = 20;
41
42	/// Failures operating on the subnet store.
43	#[derive(Debug, PartialEq, Eq)]
44	pub enum SubnetError {
45	    /// `create_subnet` for a name (within the same user scope) that already exists.
46	    Exists(String),
47	    /// An operation named a subnet not in the store.
48	    NotFound(String),
49	    /// Reading/writing the backing file failed (message preserved).
50	    Io(String),
51	}
52
53	impl std::fmt::Display for SubnetError {
54	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55	        match self {
56	            SubnetError::Exists(n) => write!(f, "subnet already exists: {n}"),
57	            SubnetError::NotFound(n) => write!(f, "subnet not found: {n}"),
58	            SubnetError::Io(e) => write!(f, "subnet store io: {e}"),
59	        }
60	    }
61	}
62
63	impl std::error::Error for SubnetError {}
64
65	/// One subnet the node belongs to: its human name, the durable TOTP seed (hex), the
66	/// seed epoch, and (forward seam) the owning user.
67	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68	pub struct SubnetRecord {
69	    /// Human subnet label (R-PAIR-4) — what the user types when pairing.
70	    pub name: String,
71	    /// The 20-byte TOTP seed, lowercase hex. The subnet secret; never synced.
72	    pub seed_hex: String,
73	    /// Monotonic seed generation (ADR-0005 #10). Bumped on every
74	    /// [`rotate_seed`](SubnetStore::rotate_seed); bound into the pairing transcript.
75	    pub epoch: u64,
76	    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
77	    #[serde(skip_serializing_if = "Option::is_none")]
78	    pub user: Option<String>,
79	    /// Creation stamp (epoch-seconds string; opaque).
80	    pub created: String,
81	}
82
83	impl SubnetRecord {
84	    /// Decode the seed to raw bytes for the crypto layer. `None` if the stored hex is
85	    /// malformed or the wrong length (defensive — the store always writes valid hex).
86	    pub fn seed_bytes(&self) -> Option<[u8; TOTP_SEED_LEN]> {
87	        decode_hex(&self.seed_hex)
88	    }
89	}
90
91	/// The whole `subnet.json` document: every subnet the node is a member of.
92	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93	pub struct SubnetStore {
94	    /// All subnets this node belongs to (multi-subnet — N seeds).
95	    #[serde(default)]
96	    pub subnets: Vec<SubnetRecord>,
97	}
98
99	impl SubnetStore {
100	    /// Load the node's subnet store from the canonical [`subnet_file`]. A missing file
101	    /// is the empty store (an unpaired node) — never an error.
102	    pub fn load() -> SubnetStore {
103	        Self::load_from(&subnet_file())
104	    }
105
106	    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable
107	    /// or corrupt file also degrades to empty rather than panicking (a corrupt secret
108	    /// file should not brick the node — re-provision via pairing).
109	    pub fn load_from(path: &Path) -> SubnetStore {
110	        match std::fs::read_to_string(path) {
111	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
112	            Err(_) => SubnetStore::default(),
113	        }
114	    }
115
116	    /// Persist to the canonical [`subnet_file`], creating `identity/` if needed.
117	    pub fn save(&self) -> Result<(), SubnetError> {
118	        self.save_to(&subnet_file())
119	    }
120
121	    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
122	    /// concurrent reader never sees a torn secret file (the [`atomic`](crate::atomic)
123	    /// invariant).
124	    pub fn save_to(&self, path: &Path) -> Result<(), SubnetError> {
125	        if let Some(parent) = path.parent() {
126	            std::fs::create_dir_all(parent).map_err(|e| SubnetError::Io(e.to_string()))?;
127	        }
128	        let json =
129	            serde_json::to_string_pretty(self).map_err(|e| SubnetError::Io(e.to_string()))?;
130	        atomic_write_string(path, &json).map_err(|e| SubnetError::Io(e.to_string()))
131	    }
132
133	    /// Find a subnet by name (within the implicit-user scope).
134	    pub fn find(&self, name: &str) -> Option<&SubnetRecord> {
135	        self.subnets.iter().find(|s| s.name == name)
136	    }
137
138	    /// Create a new subnet with a freshly minted seed at epoch 1 (the "create-new"
139	    /// pairing path — this node becomes the sole seed-holder). Errors if the name
140	    /// already exists. Returns the new record.
141	    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
142	    pub fn create_subnet(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
143	        if self.find(name).is_some() {
144	            return Err(SubnetError::Exists(name.to_string()));
145	        }
146	        self.subnets.push(SubnetRecord {
147	            name: name.to_string(),
148	            seed_hex: encode_hex(&random_seed()),
149	            epoch: 1,
150	            user: None,
151	            created: now_stamp(),
152	        });
153	        Ok(self.subnets.last().expect("just pushed"))
154	    }
155
156	    /// Add a subnet whose seed was learned by **joining** an existing subnet (the seed
157	    /// + its current epoch arrive over the pairing channel from a seed-holder). Errors
158	    /// if the name already exists.
159	    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
160	    pub fn add_joined(
161	        &mut self,
162	        name: &str,
163	        seed: [u8; TOTP_SEED_LEN],
164	        epoch: u64,
165	    ) -> Result<&SubnetRecord, SubnetError> {
166	        if self.find(name).is_some() {
167	            return Err(SubnetError::Exists(name.to_string()));
168	        }
169	        self.subnets.push(SubnetRecord {
170	            name: name.to_string(),
171	            seed_hex: encode_hex(&seed),
172	            epoch,
173	            user: None,
174	            created: now_stamp(),
175	        });
176	        Ok(self.subnets.last().expect("just pushed"))
177	    }
178

... [191 lines truncated] ...


NOTE FROM THE USER:proceed with next steps.


clippy doc-lint warning + fmt may have touched unrelated files. Check what actually changed.


git status -s; echo "===diff stat==="; git diff --stat; echo "===clippy warning loc==="; cargo clippy -p spt-store 2>&1 | grep -B1 -A4 "doc list item" | head -15

Check fmt scope + clippy warning location


 M crates/spt-store/src/atomic.rs
 M crates/spt-store/src/info.rs
 M crates/spt-store/src/lib.rs
 M crates/spt-store/src/liveness.rs
 M crates/spt-store/src/perch.rs
 M crates/spt-store/src/proc.rs
 M crates/spt-store/src/registry.rs
 M crates/spt-store/src/spool.rs
?? crates/spt-store/src/subnet.rs
===diff stat===
warning: in the working copy of 'crates/spt-store/src/atomic.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/info.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/liveness.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/perch.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/proc.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/registry.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/spool.rs', LF will be replaced by CRLF the next time Git touches it
 crates/spt-store/src/atomic.rs   |  5 ++-
 crates/spt-store/src/info.rs     | 53 +++++++++++++++++-----
 crates/spt-store/src/lib.rs      |  1 +
 crates/spt-store/src/liveness.rs | 78 +++++++++++++++++++++++++-------
 crates/spt-store/src/perch.rs    | 96 ++++++++++++++++++++++++++++++++++------
 crates/spt-store/src/proc.rs     | 11 ++++-
 crates/spt-store/src/registry.rs | 72 ++++++++++++++++++++++++------
 crates/spt-store/src/spool.rs    | 89 ++++++++++++++++++++++++++++---------
 8 files changed, 328 insertions(+), 77 deletions(-)
===clippy warning loc===
    Checking spt-store v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-store)
warning: doc list item without indentation
   --> crates\spt-store\src\subnet.rs:158:9
    |
158 |     /// if the name already exists.
    |         ^


D2a: TOTP module (seed gen, code, ±1 window, otpauth URI)

spt-net pairing::totp — random 20-byte seed (reuse Identity::generate RNG), RFC 6238 code at step T, adjacent ±1 steps, expose raw time-step counter (needed for transcript bind), otpauth:// URI + QR. Test against RFC 6238 vectors. REQ-PAIR-1 (impl).

D2a/b: SPAKE2 handshake + pairing-transcript binding

SPAKE2 PAKE with current TOTP code as password over dedicated pre-trust pairing ALPN. Transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, confirmation MACs (REQ-HAZARD-PAIR-TRANSCRIPT-BIND). Negative tests: MITM, replay, reflection, wrong-subnet, stale time-step. Blocked on SPAKE2 crate decision (research running). REQ-PAIR-1, activate REQ-HAZARD-PAIR-TRANSCRIPT-BIND.

D2c: Seed epoch + rotation

Subnet seed carries an epoch; removing a node bumps epoch so old node/seed can't rejoin. Transcript binds epoch. Trust-store delete alone is NOT revocation. REQ-HAZARD-PAIR-SEED-ROTATION. Test: old-epoch rejoin rejected.


`cargo fmt -p spt-store` reformatted the **entire crate** — 6 files I never touched have pre-existing fmt drift. That must not pollute D2c. Revert unrelated files; check whether CI even enforces fmt.


grep -rniE "fmt|rustfmt" .github/workflows/*.yml 2>/dev/null | head; echo "---revert unrelated---"; git checkout -- crates/spt-store/src/atomic.rs crates/spt-store/src/info.rs crates/spt-store/src/liveness.rs crates/spt-store/src/proc.rs crates/spt-store/src/registry.rs crates/spt-store/src/spool.rs crates/spt-store/src/perch.rs; git status -s

Check CI fmt + revert unrelated fmt churn incl perch


---revert unrelated---
 M crates/spt-store/src/lib.rs
?? crates/spt-store/src/subnet.rs
[This command modified 3 files you've previously read: crates\spt-store\src\atomic.rs, crates\spt-store\src\info.rs, crates\spt-store\src\perch.rs. Call Read before editing.]


No fmt gate in CI — drift tolerated, so I won't reformat. Reverted perch.rs too (lost my resolvers); re-apply them cleanly without cargo fmt.


Edit

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

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

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

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

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


<tool_use_error>File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.</tool_use_error>

Error: File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.


60	/// The owlery directory holding all perches: `<spt_home>/owlery/`.
61	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
62	pub fn owlery_dir() -> PathBuf {
63	    spt_home().join("owlery")
64	}
65
66	/// Classification of a perch id by its naming convention. Stable, pure, no I/O.
67	///

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

/// Classification of a perch id by its naming convention. Stable, pure, no I/O.
///


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

/// Classification of a perch id by its naming convention. Stable, pure, no I/O.

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

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

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

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

/// Classification of a perch id by its naming convention. Stable, pure, no I/O.


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

/// Classification of a perch id by its naming convention. Stable, pure, no I/O.

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

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

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

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

/// Classification of a perch id by its naming convention. Stable, pure, no I/O.

+/// Node-local **durable** identity/security material: `<spt_home>/identity/`

+/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and

+/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret

+/// material (the node keypair + subnet TOTP seeds). Pure path composition.

+/// The subnet membership + seed file: `<spt_home>/identity/subnet.json`

+/// (`docs/STORAGE.md`). One file holding *all* subnets the node belongs to

+/// (multi-subnet — a node holds N seeds), in a `(subnet, user)`-ready shape.

+/// Replicated via pairing/P2P, **not** git.

+/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer

+/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.

/// - `<x>-psyche` → [`PerchKind::Psyche`]


Fix the clippy doc-lint: the `add_joined` doc has a line starting `+ its current epoch` — markdown reads `+` as a bullet. Reword.


    /// Add a subnet whose seed was learned by **joining** an existing subnet (the seed
    /// + its current epoch arrive over the pairing channel from a seed-holder). Errors
    /// if the name already exists.

    /// Add a subnet whose seed was learned by **joining** an existing subnet (the seed
    /// and its current epoch arrive over the pairing channel from a seed-holder).
    /// Errors if the name already exists.


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)

    /// Add a subnet whose seed was learned by **joining** an existing subnet (the seed
    /// + its current epoch arrive over the pairing channel from a seed-holder). Errors
    /// if the name already exists.

    /// Add a subnet whose seed was learned by **joining** an existing subnet (the seed
    /// and its current epoch arrive over the pairing channel from a seed-holder).
    /// Errors if the name already exists.

-    /// + its current epoch arrive over the pairing channel from a seed-holder). Errors

+    /// and its current epoch arrive over the pairing channel from a seed-holder).

+    /// Errors if the name already exists.


Bash

cargo clippy -p spt-store --all-targets 2>&1 | grep -iE "warning|error" | head; echo "---test---"; cargo test -p spt-store --lib subnet 2>&1 | tail -3; echo "---diff scope---"; git status -s

Clippy + test + verify clean diff scope


---test---

test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 44 filtered out; finished in 0.01s

---diff scope---
 M crates/spt-store/src/lib.rs
 M crates/spt-store/src/perch.rs
?? crates/spt-store/src/subnet.rs


Clean: no clippy warnings, 8 pass, diff scoped to my 3 files. Activate REQ-HAZARD-PAIR-SEED-ROTATION.


Edit

title = "Removing a node rotates the subnet seed (epoch bump) so an old node/old seed cannot rejoin; trust-store delete alone is NOT revocation because the seed is replicated to every trusted node (ADR-0005 #10)"
required_stages = []  # rule 5: activate at M4-D2c when seed epoch + rotation lands; transcript binds the epoch (composes with TRANSCRIPT-BIND)

title = "Removing a node rotates the subnet seed (epoch bump) so an old node/old seed cannot rejoin; trust-store delete alone is NOT revocation because the seed is replicated to every trusted node (ADR-0005 #10)"
required_stages = ["impl", "unit"]  # activated M4-D2c: spt-store::subnet SubnetStore holds N per-subnet TOTP seeds + monotonic epoch; rotate_seed mints fresh seed material + bumps the epoch (the node-removal response), persisted atomically + durable across reload. The epoch is bound into the pairing transcript (REQ-HAZARD-PAIR-TRANSCRIPT-BIND), and the spake stale_epoch/wrong_code tests prove an old seed/epoch can't rejoin. int = on-wire evict-then-rejoin E2E at D9


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

title = "Removing a node rotates the subnet seed (epoch bump) so an old node/old seed cannot rejoin; trust-store delete alone is NOT revocation because the seed is replicated to every trusted node (ADR-0005 #10)"
required_stages = []  # rule 5: activate at M4-D2c when seed epoch + rotation lands; transcript binds the epoch (composes with TRANSCRIPT-BIND)

title = "Removing a node rotates the subnet seed (epoch bump) so an old node/old seed cannot rejoin; trust-store delete alone is NOT revocation because the seed is replicated to every trusted node (ADR-0005 #10)"
required_stages = ["impl", "unit"]  # activated M4-D2c: spt-store::subnet SubnetStore holds N per-subnet TOTP seeds + monotonic epoch; rotate_seed mints fresh seed material + bumps the epoch (the node-removal response), persisted atomically + durable across reload. The epoch is bound into the pairing transcript (REQ-HAZARD-PAIR-TRANSCRIPT-BIND), and the spake stale_epoch/wrong_code tests prove an old seed/epoch can't rejoin. int = on-wire evict-then-rejoin E2E at D9

-required_stages = []  # rule 5: activate at M4-D2c when seed epoch + rotation lands; transcript binds the epoch (composes with TRANSCRIPT-BIND)

+required_stages = ["impl", "unit"]  # activated M4-D2c: spt-store::subnet SubnetStore holds N per-subnet TOTP seeds + monotonic epoch; rotate_seed mints fresh seed material + bumps the epoch (the node-removal response), persisted atomically + durable across reload. The epoch is bound into the pairing transcript (REQ-HAZARD-PAIR-TRANSCRIPT-BIND), and the spake stale_epoch/wrong_code tests prove an old seed/epoch can't rejoin. int = on-wire evict-then-rejoin E2E at D9

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


D2c: Seed epoch + rotation


Bash

traceable-reqs check 2>&1 | grep -iE "FAIL|error|SEED-ROTATION" | head; echo "EXIT:${PIPESTATUS[0]}"; echo "---full workspace test (spt-store+spt-net)---"; cargo test -p spt-store -p spt-net --lib 2>&1 | grep -E "test result|error\[" | tail

Trace check + workspace tests


  [OK] REQ-HAZARD-PAIR-SEED-ROTATION  required: [impl, unit]  stages: -doc +impl +unit -int
       Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)
       Stale registry entries degrade to fallback, never hard-fail (4.3)
EXIT:0
---full workspace test (spt-store+spt-net)---
test result: ok. 24 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.12s
test result: ok. 52 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 12.10s


All green — SEED-ROTATION satisfied [impl,unit]. Commit D2c.


cd "C:\Users\decid\Documents\projects\spt-core" && git add -A && git commit -q -F - <<'EOF'
feat(store): M4-D2c subnet seed store + epoch rotation (#10)

The durable subnet secret material + the seed-rotation revocation primitive
(ADR-0005 #10). spt-store::subnet — `SubnetStore` over identity/subnet.json
holding N per-subnet `SubnetRecord`s (multi-subnet: a node holds N seeds), each
with the 20-byte TOTP seed (hex), a monotonic epoch, and a `(subnet, user)`-ready
optional user field (cross-user forward seam). Node-local, durable, never
git-tracked/synced — replicates only via the pairing channel (docs/STORAGE.md).

rotate_seed is the node-removal response that makes removal *real* revocation:
Stage-A red-team #10 — trust-store delete alone is NOT revocation because the
seed is replicated to every trusted node, so a compromised node can re-pair after
a mere delete. Rotation mints fresh seed material and bumps the epoch, so the old
seed computes the wrong code and the epoch-binding transcript (D2b) rejects an
old-epoch rejoin. Callers pair node-removal (trust delete) WITH rotation.

create_subnet (sole seed-holder, epoch 1) / add_joined (seed+epoch learned from a
seed-holder) / remove (leave a subnet). Seeds minted via spt-proto's Ed25519 RNG
(no extra rand dep). Atomic persistence; a corrupt/absent file degrades to the
empty store, never a panic (re-provision via pairing). New path resolvers
identity_dir/subnet_file/trust_dir in perch.rs (the single-path-source resolver,
6.1). spt-net wraps the stored bytes via the existing TotpSeed::from_bytes —
no cycle (spt-store stays below spt-net).

8 unit tests (epoch bump, seed change, multi-subnet persistence + durable
rotation, corrupt-file resilience, randomness). Activates
REQ-HAZARD-PAIR-SEED-ROTATION [impl,unit] (int = on-wire evict-then-rejoin at
D9). clippy clean; traceable-reqs check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git log -1 --format='%h %s'

Commit D2c


warning: in the working copy of 'crates/spt-store/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/subnet.rs', LF will be replaced by CRLF the next time Git touches it
75f967c feat(store): M4-D2c subnet seed store + epoch rotation (#10)


D2c committed (75f967c). 3 commits this session (D2a/D2b/D2c); 2 of 3 D2 hazard reqs activated. Push so CI validates the new crypto deps (spake2/curve25519-dalek) build on ubuntu+windows.


cd "C:\Users\decid\Documents\projects\spt-core" && git push 2>&1 | tail -5; echo "---"; git log --oneline origin/dev-freeform..HEAD 2>&1 | head

Push D2a-c


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


Pushed (bfacfb8..75f967c). CI now validating the new crypto deps cross-platform.

Three commits done this session. Context is climbing and the next unit (D2e trust store) needs careful TOFU/warn-on-change design — a clean checkpoint. Update tasks, then commune + self-clear per the CLAUDE.md protocol.


Updated task #3 status


TaskCreate

D2d: subnet-global pairing rate limit (#11)

REQ-HAZARD-PAIR-RATE-LIMIT. One active ceremony per subnet, shared attempt counter, exponential backoff. Justify-or-drop the ±1 TOTP window (it triples the valid-password space). Pure-logic state machine in spt-net pairing (or spt-store for the shared/persisted counter). Activate REQ-HAZARD-PAIR-RATE-LIMIT.


Task #4 created successfully: D2d: subnet-global pairing rate limit (#11)


D2e: local trust store + TOFU + warn-on-key-change

REQ-PAIR-2. spt-store::trust over identity/trust/ — authorized peer node pubkeys per subnet. TOFU: accept first post-pairing connect, warn on key change. Revocation = trust delete (paired with D2c rotate_seed). Needs careful warn-on-change semantics (pubkey==identity here) — re-read ADR-0009 + registry binding before designing. Activate REQ-PAIR-2.


Task #5 created successfully: D2e: local trust store + TOFU + warn-on-key-change


D2-wire: pairing ALPN connection driver (activates REQ-PAIR-1)

Dedicated pre-trust pairing ALPN on the iroh endpoint; drive Initiator/Responder state machines over a bidi QUIC stream (4-message ceremony). Rendezvous token H(subnet-name ‖ TOTP-epoch). Wire the ±1 step announce/accept. Loopback test = two endpoints pair via one code. Then activate REQ-PAIR-1 [impl,unit]; int=D9.


Task #6 created successfully: D2-wire: pairing ALPN connection driver (activates REQ-PAIR-1)


D2f/g: multi-subnet ceremony inputs + elevation-gated code fetch + CLI

REQ-PAIR-5/4/3/6. Discovery takes TOTP code + subnet-name; create-new names subnet up front. Rendezvous-token hashing. spt pair show-totp [--subnet|--create-new] elevation-gated (UAC/root or elevated agent; else authenticator app). QR optional (defer to D9). REQ-PAIR-7 icon = GUI-only, defer.


Task #7 created successfully: D2f/g: multi-subnet ceremony inputs + elevation-gated code fetch + CLI


Now commune the status + next steps before self-clearing.


Skill

M4-D2 pairing in progress. Session shipped 3 commits (pushed bfacfb8..75f967c, CI running on the new crypto deps):

D2a (56f2190) — pairing::totp: roll-own RFC 6238 TOTP (sha1+hmac+data-encoding), verified vs Appendix-B vectors; TotpSeed (CSPRNG via spt-proto RNG), base32, otpauth URI, raw time-step exposed for transcript bind, ±1 window. Also registered the 3 D2 hazard reqs up front (rule 3).
D2b (597786e) — pairing::transcript + pairing::spake: spake2 0.4 (curve25519-dalek 4.1.3, shares ed25519-dalek v2's, no new major) + OUR HMAC-SHA256 confirmation MAC (spake2 does NO key-confirm). Transcript binds domain/role/both-msgs/subnet-name/epoch/step/both-pubkeys, length-prefixed, ct_eq. KEY FACT: SPAKE2 finish() does NOT error on wrong password — keys just diverge — so the confirm MAC is the ONLY rejecter. All negative tests green: wrong-code, wrong-subnet, stale-step, stale-epoch, unknown-key-share, reflection(BadSide), tampered-tag. Activated REQ-HAZARD-PAIR-TRANSCRIPT-BIND [impl,unit].
D2c (75f967c) — spt-store::subnet: SubnetStore over identity/subnet.json, N per-subnet seeds (multi-subnet), monotonic epoch, (subnet,user)-ready. rotate_seed = node-removal revocation (#10): fresh seed + epoch bump; old seed/epoch can't rejoin (transcript binds epoch). Added perch::identity_dir/subnet_file/trust_dir resolvers. spt-net bridges via existing TotpSeed::from_bytes — no cycle. Activated REQ-HAZARD-PAIR-SEED-ROTATION [impl,unit].

KEY FACTS for resume: pairing crypto core (TOTP+SPAKE2+transcript) DONE + fully unit-tested; what remains is glue. Joiner holds only the code, NOT the seed → transcript binds subnet-NAME (both have it); seed-knowledge bound via PAKE password, seed-generation via epoch. spt-store owns seed-bytes lifecycle; spt-net wraps for crypto (layering: spt-store below spt-net). Project has NO rustfmt gate in CI — do NOT `cargo fmt -p <crate>` (it reformats the whole crate incl pre-existing drift, polluting the commit); only my edited files. CRLF warnings are benign.

NEXT (in order): D2d subnet-global rate-limit (#11, REQ-HAZARD-PAIR-RATE-LIMIT) → D2e trust store TOFU+warn-on-change (REQ-PAIR-2, needs careful design: pubkey==iroh identity, re-read ADR-0009+registry) → D2-wire pairing ALPN driver over iroh bidi stream (4-msg ceremony, rendezvous H(name‖epoch), activates REQ-PAIR-1) → D2f/g multi-subnet ceremony + elevation-gated `spt pair show-totp` CLI (REQ-PAIR-3/4/5/6). Then D3 registry. Tasks #4-7 track this. Confirm CI green on 75f967c first.


Launching skill: spt:commune


Base directory for this skill: C:\Users\decid\.claude\plugins\cache\cplugs\spt\1.11.25\skills\commune

# /spt:commune

All commands use `$LIVE` env var, auto-injected by the plugin's SessionStart hook. If commands fail with "command not found", restart the Claude Code session so the SessionStart hook re-runs.

> **Identity auto-detection:** Your identity is auto-detected from your session. Pass your ID explicitly only if auto-detection fails.

## Flow

1. **Download Psyche's *current* context** — it shifts during the session as background echo communes fire. Re-download right before composing; the gen-N snapshot you read at session start is already stale:
   ```bash
   $LIVE psyche-download <your-id>
   ```

   > **Output truncation.** If the Bash tool reports `Output too large (N KB)` and saves the full result to a `tool-results/<id>.txt` file, use the `Read` tool on that saved path to access the full content. Do NOT re-run `psyche-download` to grep/awk out sections — repeated invocations re-download the same context, waste tokens, and may duplicate `## Pending Commune` sections in `live_context.md`.

2. **Diff your knowledge against this fresh download** — only what's missing from *it* is the real delta (new work, decisions, intentions). Anything already absorbed is not. If the download includes a `<memformat>` section, use it as a guide for what topics to cover. A `## Pending Commune (uncommitted)` section means a previous commune is still queued — do NOT re-send it.

3. **Commune only the delta** -- use the **Write tool** to create
   `.claude/{your-id}-commune.md` with the two-slice body as its contents
   (see `## Two-slice body shape (Phase 25 D-10/D-11)` below for the required
   envelope wrapping). Do not use Bash/heredoc -- Write is the canonical path:
   no shell escaping, no `EOF` collision risk, and Write auto-creates the
   `.claude/` parent directory.

   Your Self listener detects the file on its next poll iteration, notifies
   your Psyche wrapper, and the wrapper ingests the content via the existing
   Psyche session. On success (subprocess exit code 0) the wrapper deletes the
   file. On error (token rate limit, etc) the file is retained and retried on
   the next consume cycle. If you write again before the wrapper consumes, the
   latest write wins (last-write-wins overwrite semantics).

If Psyche's context is already current, no commune is needed. Say so and move on.

Read `commune.md` (in this skill directory) for the full commune protocol -- when to send, what to include, and diligence triggers.

**Agent-type guard:** Only works on live agents. If target is not live, refuses with: "Communes require a live agent with Psyche."

## Two-slice body shape (Phase 25 D-10/D-11)

The body you write in `.claude/{your-id}-commune.md` MUST itself be wrapped in two-slice envelopes per Phase 25 D-10/D-11. The taxonomy — what belongs in `<live-context>` vs `<project-context>` — is defined canonically in `psyche.md` §`<output_envelope>` (lines 282–323). Read that section as the source of truth; this skill doc teaches only when and how to apply it.

**In-project detection rule (D-25.1-04).** Before composing your commune body, run `$LIVE psyche-download <your-id>` (Step 1 of `## Flow` above) and inspect the `<current ... />` tag at the top of the output. The `<current/>` tag carries a `project="..."` attribute whenever any payload is produced — its presence means you are inside a tracked project (inside a git repo, `project` is the repo name; outside a git repo per D-11, `project` is the cwd-basename fallback, which still counts as a project context for routing purposes). A `psyche-download` invocation that returns no payload at all (NO-CONTEXT exit) means no project resolved. So: `project="..."` populated on `<current/>` → emit BOTH envelopes (apply rule 1 or 2 below); no payload at all → emit ONLY `<live-context>` (rule 3). Secondary signal: the `<project-context-resolved name="..."/>` sentinel further down the output indicates the project file already has prior content — useful for spotting first-time-in-project pairings, but NOT the routing rule.

**Three rules (D-25.1-01..03):**

- **In-project, both slices populated** (marker present, you have project-specific content this cycle): emit `<live-context>...</live-context>` AND `<project-context>...</project-context>`, both with non-empty bodies.
- **In-project, no project-specific update this cycle** (marker present, but nothing project-bound to report): emit `<live-context>...</live-context>` AND `<project-context></project-context>` (empty body — the deliberate "in-project but quiet" signal per D-25.1-02). Distinguishable from "no project resolved".
- **Outside any tracked project** (marker absent): emit ONLY `<live-context>...</live-context>`. Do NOT emit an empty `<project-context>` envelope (missing tag means "no project resolved" per D-25.1-03).

**Nested envelopes.** The Phase 23 `<EVENT type="commune">` envelope (see `## Envelope shape (Phase 23 v1.8)` below) is composed by `$LIVE commune` automatically — you NEVER write `<EVENT>` tags. You write ONLY the inner two-slice body. The two envelope layers nest: outer EVENT wraps inner two-slice body.

(See `psyche.md` §`<output_envelope>` for the canonical taxonomy and per-slice content rules.)

## Envelope shape (Phase 23 v1.8)

As of v1.8 Phase 23, commune deliveries are wrapped in a typed EVENT envelope carrying a 5-field stamp (`machine`, `project`, `branch`, `head_sha`, `head_subject`); the prose `COMMUNE (timestamp): body` form is gone.

```xml
<EVENT type="commune" timestamp="2026-05-20T06:00:00-07:00" machine="hostname" project="claude_skill_owl" branch="main" head_sha="abc123..." head_subject="subject line capped at 72 chars">body</EVENT>
```

Outside a git repo (D-11), `branch`, `head_sha`, and `head_subject` are omitted entirely (not empty-string-set):

```xml
<EVENT type="commune" timestamp="2026-05-20T06:00:00-07:00" machine="hostname" project="cwd-basename">body</EVENT>
```

`$LIVE commune` composes this envelope automatically — Self does NOT manually construct EVENT XML. The `body` placeholder in the example above is the two-slice body (see `## Two-slice body shape (Phase 25 D-10/D-11)` above); the two envelope layers nest. The wrapper-side parser ignores unknown attrs, so the stamp fields are forward-compatible with older receivers. For drift detection on resume, see the `live` skill.

ARGUMENTS: M4-D2 pairing in progress. Session shipped 3 commits (pushed bfacfb8..75f967c, CI running on the new crypto deps):

D2a (56f2190) — pairing::totp: roll-own RFC 6238 TOTP (sha1+hmac+data-encoding), verified vs Appendix-B vectors; TotpSeed (CSPRNG via spt-proto RNG), base32, otpauth URI, raw time-step exposed for transcript bind, ±1 window. Also registered the 3 D2 hazard reqs up front (rule 3).
D2b (597786e) — pairing::transcript + pairing::spake: spake2 0.4 (curve25519-dalek 4.1.3, shares ed25519-dalek v2's, no new major) + OUR HMAC-SHA256 confirmation MAC (spake2 does NO key-confirm). Transcript binds domain/role/both-msgs/subnet-name/epoch/step/both-pubkeys, length-prefixed, ct_eq. KEY FACT: SPAKE2 finish() does NOT error on wrong password — keys just diverge — so the confirm MAC is the ONLY rejecter. All negative tests green: wrong-code, wrong-subnet, stale-step, stale-epoch, unknown-key-share, reflection(BadSide), tampered-tag. Activated REQ-HAZARD-PAIR-TRANSCRIPT-BIND [impl,unit].
D2c (75f967c) — spt-store::subnet: SubnetStore over identity/subnet.json, N per-subnet seeds (multi-subnet), monotonic epoch, (subnet,user)-ready. rotate_seed = node-removal revocation (#10): fresh seed + epoch bump; old seed/epoch can't rejoin (transcript binds epoch). Added perch::identity_dir/subnet_file/trust_dir resolvers. spt-net bridges via existing TotpSeed::from_bytes — no cycle. Activated REQ-HAZARD-PAIR-SEED-ROTATION [impl,unit].

KEY FACTS for resume: pairing crypto core (TOTP+SPAKE2+transcript) DONE + fully unit-tested; what remains is glue. Joiner holds only the code, NOT the seed → transcript binds subnet-NAME (both have it); seed-knowledge bound via PAKE password, seed-generation via epoch. spt-store owns seed-bytes lifecycle; spt-net wraps for crypto (layering: spt-store below spt-net). Project has NO rustfmt gate in CI — do NOT `cargo fmt -p <crate>` (it reformats the whole crate incl pre-existing drift, polluting the commit); only my edited files. CRLF warnings are benign.

NEXT (in order): D2d subnet-global rate-limit (#11, REQ-HAZARD-PAIR-RATE-LIMIT) → D2e trust store TOFU+warn-on-change (REQ-PAIR-2, needs careful design: pubkey==iroh identity, re-read ADR-0009+registry) → D2-wire pairing ALPN driver over iroh bidi stream (4-msg ceremony, rendezvous H(name‖epoch), activates REQ-PAIR-1) → D2f/g multi-subnet ceremony + elevation-gated `spt pair show-totp` CLI (REQ-PAIR-3/4/5/6). Then D3 registry. Tasks #4-7 track this. Confirm CI green on 75f967c first.


Bash

$LIVE psyche-download doyle 2>&1 | head -50

Download current Psyche context


<memformat>
<?xml version="1.0"?>
<memformat version="1">
<!-- Changelog: v1 - initial seed -->
<topics>
  <topic name="events">Notable or unusual events and requests</topic>
  <topic name="interactions">Agent interactions -- who, roles, contributions</topic>
  <topic name="reflections">Thoughts on recent work -- what went well, what was tricky</topic>
  <topic name="ideas">Ideas to raise with the user</topic>
  <topic name="improvements">Further improvement ideas for recent problem spaces</topic>
  <topic name="timeline">What happened when, timing, sequencing</topic>
  <topic name="blockers">Current blockers or concerns</topic>
  <topic name="focus">Current focus and immediate next steps</topic>
</topics>
</memformat>
</memformat>

<current machine="HFENDULEAM" project="spt-core" branch="dev-freeform" head_sha="75f967c960709596dc76ba852ac741cf767c3008" head_subject="feat(store): M4-D2c subnet seed store + epoch rotation (#10)" commits_since="0" commits_unpulled="0"/>
D1 done + docs pass pushed (bfacfb8, 7 commits). CI green ✓. D2a committed (56f2190).

TOTP vectors pass (otpauth `:` delimiter literal). D2a 8 tests pass, `--no-default-features` clean. Clippy clean, check exit 0. REQ-PAIR-1 evidence detected (+impl+unit) but inactive `[]` until SPAKE2 lands — "activated = delivered" stays honest.

D2b design lock: joiner has 6-digit code only (not seed); transcript "subnet ID" = subnet-name (both sides know it); seed-knowledge bound implicitly via PAKE password. D2b scope = pure SPAKE2+transcript crypto core + full negative tests; defer iroh pairing-ALPN streaming driver to later sub-task.

spake2 0.4.0 resolved (curve25519-dalek 4.1.3 shared + iroh's 5-pre). Next: grep real spake2 0.4 API from registry source before coding.

STILL: register 3 hazard reqs (REQ-HAZARD-PAIR-TRANSCRIPT-BIND #12, REQ-HAZARD-PAIR-SEED-ROTATION #10, REQ-HAZARD-PAIR-RATE-LIMIT #11) before D2b impl.<project-context-resolved name="spt-core"/>
**M4 (spt-net) — D2a done, D2b locked.**

D2a (TOTP baseline) committed. Tests: RFC 6238 vectors pass (8 total), clippy clean. Net-gated per design; REQ-PAIR-1 stays unenforced until SPAKE2 crate lands.

D2b design finalized: **joiner knows code (6-digit) + subnet-name only; seed stays bound implicitly via PAKE password**. Transcript struct uses subnet-name as ID, not seed. Scope: pure SPAKE2 password-authenticated key exchange + transcript binding crypto, full negative tests (bogus codes, wrong subnets, replays); defer the iroh ALPN-streaming ceremony driver to sub-task after D2b lands.

No new ADRs/hazards this cycle. Three hazard reqs still pending registration (rule 3 before D2b impl): REQ-HAZARD-PAIR-TRANSCRIPT-BIND, REQ-HAZARD-PAIR-SEED-ROTATION, REQ-HAZARD-PAIR-RATE-LIMIT.


Psyche context is at head 75f967c but its prose still reflects "D2a done, D2b locked, hazard reqs pending registration." Delta = D2b+D2c actually shipped, reqs registered (2 activated), new learnings, next steps. In-project → both envelopes.


Write

<live-context>
D2b + D2c SHIPPED (your context still says "D2b locked, hazard reqs pending registration" — both stale now). Pushed bfacfb8..75f967c; CI running on the new crypto deps (confirm green before D2d).

- 3 D2 hazard reqs REGISTERED (rule 3, in 56f2190); 2 ACTIVATED: REQ-HAZARD-PAIR-TRANSCRIPT-BIND + REQ-HAZARD-PAIR-SEED-ROTATION (both [impl,unit]). REQ-HAZARD-PAIR-RATE-LIMIT still [] (D2d). REQ-PAIR-1 still [] until the wire driver.
- D2b (597786e): pairing::transcript + pairing::spake. spake2 0.4 (curve25519-dalek 4.1.3 = shares ed25519-dalek v2's, NO new major). We add our own HMAC-SHA256 confirm MAC — spake2 does NO key-confirmation.
- D2c (75f967c): spt-store::subnet SubnetStore (N seeds, epoch, rotate_seed = #10 revocation). perch::identity_dir/subnet_file/trust_dir added.

LEARNINGS to retain:
- SPAKE2 finish() does NOT error on a wrong password — both sides just derive divergent keys. The confirmation MAC is the ONLY thing that rejects a bad pairing. All negative tests (wrong-code/subnet/step/epoch, unknown-key-share, reflection=BadSide, tampered-tag) green.
- Project has NO rustfmt gate in CI — do NOT `cargo fmt -p <crate>`, it reformats the whole crate incl pre-existing drift and pollutes the commit. Touch only files you edited. CRLF warnings benign.
- Layering bridge: spt-store owns seed-BYTES lifecycle; spt-net wraps via existing TotpSeed::from_bytes (spt-store stays below spt-net, no cycle).
</live-context>
<project-context>
**M4 (spt-net) D2 pairing — crypto core DONE, glue remains.**

Shipped this session (3 commits, pushed, CI pending on 75f967c):
- D2a 56f2190 — pairing::totp (RFC 6238 rolled on sha1+hmac+data-encoding, Appendix-B verified; raw time-step exposed for transcript bind) + registered 3 hazard reqs.
- D2b 597786e — pairing::transcript (HMAC-SHA256 confirm MAC binding domain/role/both-msgs/subnet-NAME/epoch/step/both-pubkeys, length-prefixed, ct_eq) + pairing::spake (Initiator/Responder over spake2 0.4). Activated REQ-HAZARD-PAIR-TRANSCRIPT-BIND.
- D2c 75f967c — spt-store::subnet SubnetStore (identity/subnet.json, multi-subnet, monotonic epoch, (subnet,user)-ready). rotate_seed bumps epoch + mints fresh seed = real revocation (#10). Activated REQ-HAZARD-PAIR-SEED-ROTATION.

Design facts that persist: joiner holds the 6-digit code + subnet-name only, NOT the seed; transcript binds subnet-NAME (both sides have it), seed-knowledge bound implicitly via PAKE password, seed-generation via epoch. Crypto core (TOTP+SPAKE2+transcript+seed store) is fully unit-tested; only transport glue + CLI remain.

NEXT (tasks #4-7 track): D2d subnet-global rate-limit (#11, REQ-HAZARD-PAIR-RATE-LIMIT — one ceremony/subnet, shared counter, exp backoff, justify-or-drop ±1 window) → D2e trust store TOFU+warn-on-change (REQ-PAIR-2; design carefully — pubkey==iroh identity; re-read ADR-0009 + registry) → D2-wire pairing ALPN driver over iroh bidi stream (4-msg ceremony, rendezvous H(name‖epoch), activates REQ-PAIR-1) → D2f/g multi-subnet ceremony inputs + elevation-gated `spt pair show-totp` CLI (REQ-PAIR-3/4/5/6; QR + icon deferred to D9/GUI). Then D3 subnet registry.
</project-context>


File created successfully at: C:\Users\decid\Documents\projects\spt-core\.claude\doyle-commune.md (file state is current in your context — no need to Read it back)

<live-context>
D2b + D2c SHIPPED (your context still says "D2b locked, hazard reqs pending registration" — both stale now). Pushed bfacfb8..75f967c; CI running on the new crypto deps (confirm green before D2d).

- 3 D2 hazard reqs REGISTERED (rule 3, in 56f2190); 2 ACTIVATED: REQ-HAZARD-PAIR-TRANSCRIPT-BIND + REQ-HAZARD-PAIR-SEED-ROTATION (both [impl,unit]). REQ-HAZARD-PAIR-RATE-LIMIT still [] (D2d). REQ-PAIR-1 still [] until the wire driver.
- D2b (597786e): pairing::transcript + pairing::spake. spake2 0.4 (curve25519-dalek 4.1.3 = shares ed25519-dalek v2's, NO new major). We add our own HMAC-SHA256 confirm MAC — spake2 does NO key-confirmation.
- D2c (75f967c): spt-store::subnet SubnetStore (N seeds, epoch, rotate_seed = #10 revocation). perch::identity_dir/subnet_file/trust_dir added.

LEARNINGS to retain:
- SPAKE2 finish() does NOT error on a wrong password — both sides just derive divergent keys. The confirmation MAC is the ONLY thing that rejects a bad pairing. All negative tests (wrong-code/subnet/step/epoch, unknown-key-share, reflection=BadSide, tampered-tag) green.
- Project has NO rustfmt gate in CI — do NOT `cargo fmt -p <crate>`, it reformats the whole crate incl pre-existing drift and pollutes the commit. Touch only files you edited. CRLF warnings benign.
- Layering bridge: spt-store owns seed-BYTES lifecycle; spt-net wraps via existing TotpSeed::from_bytes (spt-store stays below spt-net, no cycle).
</live-context>
<project-context>
**M4 (spt-net) D2 pairing — crypto core DONE, glue remains.**

Shipped this session (3 commits, pushed, CI pending on 75f967c):
- D2a 56f2190 — pairing::totp (RFC 6238 rolled on sha1+hmac+data-encoding, Appendix-B verified; raw time-step exposed for transcript bind) + registered 3 hazard reqs.
- D2b 597786e — pairing::transcript (HMAC-SHA256 confirm MAC binding domain/role/both-msgs/subnet-NAME/epoch/step/both-pubkeys, length-prefixed, ct_eq) + pairing::spake (Initiator/Responder over spake2 0.4). Activated REQ-HAZARD-PAIR-TRANSCRIPT-BIND.
- D2c 75f967c — spt-store::subnet SubnetStore (identity/subnet.json, multi-subnet, monotonic epoch, (subnet,user)-ready). rotate_seed bumps epoch + mints fresh seed = real revocation (#10). Activated REQ-HAZARD-PAIR-SEED-ROTATION.

Design facts that persist: joiner holds the 6-digit code + subnet-name only, NOT the seed; transcript binds subnet-NAME (both sides have it), seed-knowledge bound implicitly via PAKE password, seed-generation via epoch. Crypto core (TOTP+SPAKE2+transcript+seed store) is fully unit-tested; only transport glue + CLI remain.

NEXT (tasks #4-7 track): D2d subnet-global rate-limit (#11, REQ-HAZARD-PAIR-RATE-LIMIT — one ceremony/subnet, shared counter, exp backoff, justify-or-drop ±1 window) → D2e trust store TOFU+warn-on-change (REQ-PAIR-2; design carefully — pubkey==iroh identity; re-read ADR-0009 + registry) → D2-wire pairing ALPN driver over iroh bidi stream (4-msg ceremony, rendezvous H(name‖epoch), activates REQ-PAIR-1) → D2f/g multi-subnet ceremony inputs + elevation-gated `spt pair show-totp` CLI (REQ-PAIR-3/4/5/6; QR + icon deferred to D9/GUI). Then D3 subnet registry.
</project-context>


Commune queued for the Psyche. Now self-clear with a resume note (CI confirmation + D2d as the immediate next step).


Skill

Resume M4-D2 pairing. First: confirm CI green on 75f967c (`gh run list --limit 1`) — this session added new crypto deps (spake2 0.4 / curve25519-dalek 4.1.3 / sha1 / data-encoding / subtle / zeroize) so watch the ubuntu+windows build. Then execute task #4 (D2d subnet-global pairing rate-limit, REQ-HAZARD-PAIR-RATE-LIMIT): one active ceremony per subnet, shared attempt counter, exponential backoff, justify-or-drop the ±1 TOTP window. Tasks #4-7 track remaining D2 (D2d → D2e trust store → D2-wire pairing ALPN driver → D2f/g ceremony+CLI), then D3 registry. Crypto core (TOTP+SPAKE2+transcript+seed store) is DONE+tested across 3 pushed commits. CRITICAL gotchas: do NOT `cargo fmt -p <crate>` (no CI fmt gate; it reformats whole crate + pollutes commit — touch only edited files); SPAKE2 finish() never errors on wrong password (confirm MAC is the only rejecter); commit per sub-task with the project Co-Authored-By trailer; run `traceable-reqs check` before declaring done; activate a req's stages only in the commit that delivers its evidence (rule 5). Read M4-PLAN.md §D2 + the just-committed pairing modules (crates/spt-net/src/net/pairing/, crates/spt-store/src/subnet.rs) on resume.


Launching skill: self-clear


Base directory for this skill: C:\Users\decid\.claude\skills\self-clear

# /self-clear

Wipes THIS agent's conversation (`/clear`), then ~10s later TYPES a self-note
as a fresh user prompt so the agent wakes on a clean context and proceeds.

## Run it

One script does everything. Just run:

```powershell
& "C:\Users\decid\.claude\skills\self-clear\scripts\self-clear.ps1"
```

Expect: `SELF-CLEAR armed: id=<agent_id> winpid=<n> hwnd=<n> — /clear queued; self-note typed in ~10s`

Then stop — end your turn promptly so the queued `/clear` fires.

## What the script does

1. `owl whoami` → resolves this session's `agent_id` (context-independent, survives the wipe). No perch → it aborts WITHOUT clearing and tells you to `/spt:ready` or `/spt:live` first.
2. Walks the parent chain (`pwsh → claude.exe → … → WindowsTerminal`) to the ancestor that owns the window, foregrounds it.
3. Types `/clear` + Enter — **queued**; only fires when this turn ends.
4. Spawns a **detached** waker: waits 10s (past turn-end + wipe), re-foregrounds the terminal via the arm-time `hwnd`/`winpid`, then **types** `NOTE FROM THE USER: proceed with next steps.` + Enter as a fresh user prompt.

The 10s delay + detachment are deliberate: the prompt must be typed **after**
`/clear` takes effect, so it lands on the clean context and starts a new turn.

Why typed, not `owl send`: after `/clear` the session sits idle awaiting user
input. A spooled owl message can't self-start a turn — it surfaces only during an
already-active turn (PreToolUse hook) or in a live Monitor stream (whose buffer
`/clear` wipes). So a self-addressed owl note never woke the agent. A typed prompt
deterministically starts a turn AND is the re-seed.

## Caveats

- Window focus brings the whole terminal forward; it can't target a specific tab. Clean when the agent's tab is active.
- The note is typed via SendKeys, so the terminal must stay the foreground-able window of `winpid`. Don't close/replace the terminal in the ~10s gap.
- SendKeys reserves `+ ^ % ~ ( ) { } [ ]`; the default note text uses none. If you customize the note, escape those chars.
- If your turn runs longer than ~10s after the script returns, the prompt could be typed before the wipe completes. Stop promptly after running.

ARGUMENTS: Resume M4-D2 pairing. First: confirm CI green on 75f967c (`gh run list --limit 1`) — this session added new crypto deps (spake2 0.4 / curve25519-dalek 4.1.3 / sha1 / data-encoding / subtle / zeroize) so watch the ubuntu+windows build. Then execute task #4 (D2d subnet-global pairing rate-limit, REQ-HAZARD-PAIR-RATE-LIMIT): one active ceremony per subnet, shared attempt counter, exponential backoff, justify-or-drop the ±1 TOTP window. Tasks #4-7 track remaining D2 (D2d → D2e trust store → D2-wire pairing ALPN driver → D2f/g ceremony+CLI), then D3 registry. Crypto core (TOTP+SPAKE2+transcript+seed store) is DONE+tested across 3 pushed commits. CRITICAL gotchas: do NOT `cargo fmt -p <crate>` (no CI fmt gate; it reformats whole crate + pollutes commit — touch only edited files); SPAKE2 finish() never errors on wrong password (confirm MAC is the only rejecter); commit per sub-task with the project Co-Authored-By trailer; run `traceable-reqs check` before declaring done; activate a req's stages only in the commit that delivers its evidence (rule 5). Read M4-PLAN.md §D2 + the just-committed pairing modules (crates/spt-net/src/net/pairing/, crates/spt-store/src/subnet.rs) on resume.


/clear


Running the self-clear script — resume context is durably captured in tasks #4-7, the queued Psyche commune, git history, and M4-PLAN.md §D2.


PowerShell

Run self-clear script


SELF-CLEAR armed: id=doyle winpid=29328 hwnd=133338 — /clear queued; self-note typed in ~10s


Self-clear armed. Ending turn now so the queued `/clear` fires on a clean context, then wakes to resume at D2d.

**Session summary** — M4-D2 pairing, 3 commits pushed (`bfacfb8..75f967c`):
- **D2a** `56f2190` — `pairing::totp`: RFC 6238 (verified vs Appendix-B), `TotpSeed`, otpauth URI, ±1 window, raw step exposed; registered 3 D2 hazard reqs.
- **D2b** `597786e` — `pairing::transcript` + `pairing::spake`: spake2 0.4 + our HMAC-SHA256 confirm MAC; all 7 negative tests green. Activated `REQ-HAZARD-PAIR-TRANSCRIPT-BIND`.
- **D2c** `75f967c` — `spt-store::subnet`: multi-subnet seed store + epoch `rotate_seed` (#10 revocation). Activated `REQ-HAZARD-PAIR-SEED-ROTATION`.

Crypto core done + unit-tested; glue (rate-limit, trust store, ALPN wire, CLI) tracked in tasks #4-7. Psyche communed. Resuming at D2d after CI-green confirmation.