752	[[requirements]]
753	id = "REQ-MESH-1"
754	title = "Membership proof (seed-proof): symmetric current-epoch seed-knowledge replaces is_trusted at EVERY inbound gate (registry apply, WAN receive, sync, notif, connection accept). MK = HKDF(seed, domain ‖ subnet_id ‖ seed_epoch); mutual channel-bound challenge-response at connect (transcript binds both handshake-proven node pubkeys, both nonces, subnet_id, seed_epoch, role); verified once per connection, cached on the broker ConnEntry, kept warm via QUIC keep-alive so re-proof is restart/partition/rotation-only. Exact-epoch match (re-seed is the sole N-1 exception). SECURITY INVARIANTS: channel-bound (no cross-connection replay), mutual, accepts a member it never paired (the mesh property)."
755	required_stages = []  # activate at mesh execution. impl = MK derivation + the connect-time mutual seed-proof handshake + ConnEntry membership flag + gate swap (is_trusted→is_member) at all five inbound sites. unit = MK derivation; valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay proof; mutual-fail drops; gate admits-unpaired-member / rejects-non-member.
756	[[requirements]]

[[requirements]]
id = "REQ-MESH-1"
title = "Membership proof (seed-proof): symmetric current-epoch seed-knowledge replaces is_trusted at EVERY inbound gate (registry apply, WAN receive, sync, notif, connection accept). MK = HKDF(seed, domain ‖ subnet_id ‖ seed_epoch); mutual channel-bound challenge-response at connect (transcript binds both handshake-proven node pubkeys, both nonces, subnet_id, seed_epoch, role); verified once per connection, cached on the broker ConnEntry, kept warm via QUIC keep-alive so re-proof is restart/partition/rotation-only. Exact-epoch match (re-seed is the sole N-1 exception). SECURITY INVARIANTS: channel-bound (no cross-connection replay), mutual, accepts a member it never paired (the mesh property)."
required_stages = []  # activate at mesh execution. impl = MK derivation + the connect-time mutual seed-proof handshake + ConnEntry membership flag + gate swap (is_trusted→is_member) at all five inbound sites. unit = MK derivation; valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay proof; mutual-fail drops; gate admits-unpaired-member / rejects-non-member.
[[requirements]]


Edit

required_stages = []  # activate at mesh execution. impl = MK derivation + the connect-time mutual seed-proof handshake + ConnEntry membership flag + gate swap (is_trusted→is_member) at all five inbound sites. unit = MK derivation; valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay proof; mutual-fail drops; gate admits-unpaired-member / rejects-non-member.

required_stages = ["impl", "unit"]  # ACTIVATED Mesh-D1 (codec slice: MK derivation + mutual channel-bound seed-proof transcript/verify + frame codec). D2 enriches impl (connect-time handshake + ConnEntry flag), D5 enriches impl (gate swap is_trusted→is_member at the five inbound sites). unit = MK derivation; valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay/mutual-fail; D5 adds gate admits-unpaired-member / rejects-non-member.


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 = "Membership proof (seed-proof): symmetric current-epoch seed-knowledge replaces is_trusted at EVERY inbound gate (registry apply, WAN receive, sync, notif, connection accept). MK = HKDF(seed, domain ‖ subnet_id ‖ seed_epoch); mutual channel-bound challenge-response at connect (transcript binds both handshake-proven node pubkeys, both nonces, subnet_id, seed_epoch, role); verified once per connection, cached on the broker ConnEntry, kept warm via QUIC keep-alive so re-proof is restart/partition/rotation-only. Exact-epoch match (re-seed is the sole N-1 exception). SECURITY INVARIANTS: channel-bound (no cross-connection replay), mutual, accepts a member it never paired (the mesh property)."

-required_stages = []  # activate at mesh execution. impl = MK derivation + the connect-time mutual seed-proof handshake + ConnEntry membership flag + gate swap (is_trusted→is_member) at all five inbound sites. unit = MK derivation; valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay proof; mutual-fail drops; gate admits-unpaired-member / rejects-non-member.

+required_stages = ["impl", "unit"]  # ACTIVATED Mesh-D1 (codec slice: MK derivation + mutual channel-bound seed-proof transcript/verify + frame codec). D2 enriches impl (connect-time handshake + ConnEntry flag), D5 enriches impl (gate swap is_trusted→is_member at the five inbound sites). unit = MK derivation; valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay/mutual-fail; D5 adds gate admits-unpaired-member / rejects-non-member.

title = "Member roster: node-level union-merge grow-set (per member: pubkey, label, machine_id, last-known address, last-seen — NOT the seed), the discovery directory the mesh dials by. Seeded IN FULL at pairing (seed-holder hands joiner the whole current roster, incl. offline members — folds in deferred pairing-time hostname capture + post-join address seeding); each node authors its own entry stamped with its lease_epoch, merged strictly-greater-wins (the node_label lease); exchanged only over seed-proof'd member connections; forgery-inert (a fake entry names a pubkey that still can't seed-proof). Removal needs a TOMBSTONE — a per-pubkey revoked marker that propagates, dominates the entry, gates admission (seed-proof ∧ ¬tombstoned), and prevents reinsert; cleared by a completed re-pair of that pubkey. Persists through silence (offline member keeps its entry)."


Now the Cargo.toml dep + net.rs module decl:


net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]

net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:hkdf", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]


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


40	# SPAKE2 PAKE (D2a/b). 0.4 resolves curve25519-dalek 4.x (>=4.1.3, RUSTSEC-2024-0344
41	# fixed) — the SAME major ed25519-dalek v2 already pulls, so no second curve lib. The
42	# 0.5-pre line bumps to curve25519-dalek 5-pre and is deliberately avoided. The crate
43	# binds identities+messages into the derived key but does NO key confirmation, so D2b
44	# layers an explicit HMAC-SHA-256 confirmation MAC (the transcript binding, #12).
45	spake2 = { version = "0.4", optional = true }
46	sha2 = { version = "0.10", optional = true }
47	# Constant-time tag comparison for the confirmation MAC (no timing oracle on verify).
48	subtle = { version = "2", optional = true }
49	# Wipe the PAKE shared secret on drop (already in-tree via dalek; direct dep makes the
50	# secret-hygiene intent explicit). Pairing key material must not linger in freed memory.
51	zeroize = { version = "1", optional = true }

# SPAKE2 PAKE (D2a/b). 0.4 resolves curve25519-dalek 4.x (>=4.1.3, RUSTSEC-2024-0344
# fixed) — the SAME major ed25519-dalek v2 already pulls, so no second curve lib. The
# 0.5-pre line bumps to curve25519-dalek 5-pre and is deliberately avoided. The crate
# binds identities+messages into the derived key but does NO key confirmation, so D2b
# layers an explicit HMAC-SHA-256 confirmation MAC (the transcript binding, #12).
spake2 = { version = "0.4", optional = true }
sha2 = { version = "0.10", optional = true }
# Constant-time tag comparison for the confirmation MAC (no timing oracle on verify).
subtle = { version = "2", optional = true }
# Wipe the PAKE shared secret on drop (already in-tree via dalek; direct dep makes the
# secret-hygiene intent explicit). Pairing key material must not linger in freed memory.
zeroize = { version = "1", optional = true }


spake2 = { version = "0.4", optional = true }
sha2 = { version = "0.10", optional = true }

spake2 = { version = "0.4", optional = true }
sha2 = { version = "0.10", optional = true }
# HKDF-SHA-256 for the subnet membership key MK (Mesh-D1, REQ-MESH-1): a per-
# (subnet, seed_epoch) secret every member derives from the shared seed and no
# outsider can. Pairs with sha2 0.10 (same RustCrypto generation as hmac).
hkdf = { version = "0.12", optional = true }


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

spake2 = { version = "0.4", optional = true }
sha2 = { version = "0.10", optional = true }
# HKDF-SHA-256 for the subnet membership key MK (Mesh-D1, REQ-MESH-1): a per-
# (subnet, seed_epoch) secret every member derives from the shared seed and no
# outsider can. Pairs with sha2 0.10 (same RustCrypto generation as hmac).
hkdf = { version = "0.12", optional = true }

[package]
name = "spt-net"
version.workspace = true
edition = "2021"
publish = false
description = "spt-core P2P networking (M4): the WAN/LAN transport every cross-machine capability rides — an Iroh endpoint (WAN, n0-relay default + self-host knob), mDNS LAN discovery, TOTP-seeded SPAKE2 pairing (ADR-0005), the eventually-consistent per-subnet registry (ADR-0006), cross-node Psyche sync, and the update peer-propagation transport into the M3c engine seam. The remaining sibling above spt-msg (`spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime}`). Internal broker machinery, NOT public SDK (R-ARCH-2). Whole transport gated behind the default-on `net` feature (ADR-0002) so minimal library consumers can build it out."

[features]
# ADR-0002: WAN transport stack is default-on in the reference binary, optional for
# embedded library consumers. The `net` feature gates the whole iroh/tokio surface
# so a `--no-default-features` lib build stays transport-free.
default = ["net"]
net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
# Wire/registry/pairing-transcript types cross the network + the broker IPC, so they
# are serde-serializable.
serde = { version = "1", features = ["derive"] }
# The D4d replication wire format is NDJSON (net/replicate.rs); `net`-gated like the
# rest of the transport surface.
serde_json = { version = "1", optional = true }
# Iroh is pinned EXACT: the API churns every release (Spike #2 note) and 0.98.2 is the
# version smoke-validated incl. native cross-NAT hole-punch. `address-lookup-mdns`
# (D1c) adds the LAN discovery service. The whole dep is `net`-gated/optional.
iroh = { version = "=0.98.2", optional = true, features = ["address-lookup-mdns"] }
# spt-net is the first async crate in the workspace (the rest is thread-based, sync);
# iroh requires a tokio runtime. The daemon (D4) will host the runtime that owns the
# broker endpoint. Pulled in only with `net`.
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "macros", "time", "sync"] }
# Pairing crypto (ADR-0005 / D2), all `net`-gated/optional. RFC 6238 TOTP is rolled on
# RustCrypto HMAC+SHA-1 (D2a) rather than a TOTP crate, so the raw time-step counter
# stays first-class for transcript binding (#12) and no URL/QR/image deps are pulled.
# `data-encoding` is the dependency-free base32 for the otpauth `secret`.
hmac = { version = "0.12", optional = true }
sha1 = { version = "0.10", optional = true }
data-encoding = { version = "2", optional = true }
# SPAKE2 PAKE (D2a/b). 0.4 resolves curve25519-dalek 4.x (>=4.1.3, RUSTSEC-2024-0344
# fixed) — the SAME major ed25519-dalek v2 already pulls, so no second curve lib. The
# 0.5-pre line bumps to curve25519-dalek 5-pre and is deliberately avoided. The crate
# binds identities+messages into the derived key but does NO key confirmation, so D2b
# layers an explicit HMAC-SHA-256 confirmation MAC (the transcript binding, #12).
spake2 = { version = "0.4", optional = true }
sha2 = { version = "0.10", optional = true }
# Constant-time tag comparison for the confirmation MAC (no timing oracle on verify).
subtle = { version = "2", optional = true }
# Wipe the PAKE shared secret on drop (already in-tree via dalek; direct dep makes the
# secret-hygiene intent explicit). Pairing key material must not linger in freed memory.
zeroize = { version = "1", optional = true }

[dev-dependencies]
tempfile = "3"
# Registry/wire serde roundtrip tests (D3a) exercise the JSON form.
serde_json = "1"

+# HKDF-SHA-256 for the subnet membership key MK (Mesh-D1, REQ-MESH-1): a per-

+# (subnet, seed_epoch) secret every member derives from the shared seed and no

+# outsider can. Pairs with sha2 0.10 (same RustCrypto generation as hmac).

+hkdf = { version = "0.12", optional = true }


12	default = ["net"]
13	net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]

default = ["net"]
net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]


net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]

net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:hkdf", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]


[package]
name = "spt-net"
version.workspace = true
edition = "2021"
publish = false
description = "spt-core P2P networking (M4): the WAN/LAN transport every cross-machine capability rides — an Iroh endpoint (WAN, n0-relay default + self-host knob), mDNS LAN discovery, TOTP-seeded SPAKE2 pairing (ADR-0005), the eventually-consistent per-subnet registry (ADR-0006), cross-node Psyche sync, and the update peer-propagation transport into the M3c engine seam. The remaining sibling above spt-msg (`spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime}`). Internal broker machinery, NOT public SDK (R-ARCH-2). Whole transport gated behind the default-on `net` feature (ADR-0002) so minimal library consumers can build it out."

[features]
# ADR-0002: WAN transport stack is default-on in the reference binary, optional for
# embedded library consumers. The `net` feature gates the whole iroh/tokio surface
# so a `--no-default-features` lib build stays transport-free.
default = ["net"]
net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
# Wire/registry/pairing-transcript types cross the network + the broker IPC, so they
# are serde-serializable.
serde = { version = "1", features = ["derive"] }
# The D4d replication wire format is NDJSON (net/replicate.rs); `net`-gated like the
# rest of the transport surface.
serde_json = { version = "1", optional = true }
# Iroh is pinned EXACT: the API churns every release (Spike #2 note) and 0.98.2 is the
# version smoke-validated incl. native cross-NAT hole-punch. `address-lookup-mdns`
# (D1c) adds the LAN discovery service. The whole dep is `net`-gated/optional.
iroh = { version = "=0.98.2", optional = true, features = ["address-lookup-mdns"] }
# spt-net is the first async crate in the workspace (the rest is thread-based, sync);
# iroh requires a tokio runtime. The daemon (D4) will host the runtime that owns the
# broker endpoint. Pulled in only with `net`.
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "macros", "time", "sync"] }
# Pairing crypto (ADR-0005 / D2), all `net`-gated/optional. RFC 6238 TOTP is rolled on
# RustCrypto HMAC+SHA-1 (D2a) rather than a TOTP crate, so the raw time-step counter
# stays first-class for transcript binding (#12) and no URL/QR/image deps are pulled.
# `data-encoding` is the dependency-free base32 for the otpauth `secret`.
hmac = { version = "0.12", optional = true }
sha1 = { version = "0.10", optional = true }
data-encoding = { version = "2", optional = true }
# SPAKE2 PAKE (D2a/b). 0.4 resolves curve25519-dalek 4.x (>=4.1.3, RUSTSEC-2024-0344
# fixed) — the SAME major ed25519-dalek v2 already pulls, so no second curve lib. The
# 0.5-pre line bumps to curve25519-dalek 5-pre and is deliberately avoided. The crate
# binds identities+messages into the derived key but does NO key confirmation, so D2b
# layers an explicit HMAC-SHA-256 confirmation MAC (the transcript binding, #12).
spake2 = { version = "0.4", optional = true }
sha2 = { version = "0.10", optional = true }
# HKDF-SHA-256 for the subnet membership key MK (Mesh-D1, REQ-MESH-1): a per-
# (subnet, seed_epoch) secret every member derives from the shared seed and no
# outsider can. Pairs with sha2 0.10 (same RustCrypto generation as hmac).
hkdf = { version = "0.12", optional = true }
# Constant-time tag comparison for the confirmation MAC (no timing oracle on verify).
subtle = { version = "2", optional = true }
# Wipe the PAKE shared secret on drop (already in-tree via dalek; direct dep makes the
# secret-hygiene intent explicit). Pairing key material must not linger in freed memory.
zeroize = { version = "1", optional = true }

[dev-dependencies]
tempfile = "3"
# Registry/wire serde roundtrip tests (D3a) exercise the JSON form.
serde_json = "1"

-net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]

+net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:hkdf", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]


8	pub mod attach;
9	pub mod endpoint;
10	pub mod ndjson;
11	pub mod notif;
12	pub mod pairing;
13	pub mod registry;

pub mod attach;
pub mod endpoint;
pub mod ndjson;
pub mod notif;
pub mod pairing;
pub mod registry;


pub mod endpoint;
pub mod mesh;
pub mod ndjson;


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

pub mod endpoint;
pub mod mesh;
pub mod ndjson;

//! The `net`-gated transport tree.
//!
//! Populated from D1 onward (see `M4-PLAN.md` / `lib.rs`): the Iroh endpoint binds
//! here first, then pairing, the per-subnet registry, cross-node sync, and the
//! update-propagation transport. A `--no-default-features` lib build excludes this
//! whole module (and the iroh/tokio footprint with it).

pub mod attach;
pub mod endpoint;
pub mod ndjson;
pub mod notif;
pub mod pairing;
pub mod registry;
pub mod replicate;
pub mod rest;
pub mod serveprobe;
pub mod shelllink;
pub mod sync;
pub mod update;
pub mod wanmsg;
pub mod xfer;

pub mod attach;
pub mod endpoint;
+pub mod mesh;
pub mod ndjson;
pub mod notif;
pub mod pairing;


Now the module. Writing `net/mesh.rs` + `net/mesh/seedproof.rs`.


Write

//! Subnet mesh membership primitives (ADR-0017, `SUBNET-MESH-PLAN.md`).
//!
//! A subnet member is a node that can prove *current-epoch seed knowledge* on a
//! connection — symmetric, channel-bound, and crucially **without either side
//! having pre-pinned the other** (the mesh property that the pairwise
//! `is_trusted` gate lacks). [`seedproof`] is the crypto that proof rests on:
//! the membership key `MK = HKDF(seed, …)` and the mutual challenge-response
//! transcript. Pure primitives here (Mesh-D1) — the connect-time handshake that
//! drives them onto a live connection is Mesh-D2; the gate swap that consults
//! the result is Mesh-D5.

pub mod seedproof;


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

//! Subnet mesh membership primitives (ADR-0017, `SUBNET-MESH-PLAN.md`).
//!
//! A subnet member is a node that can prove *current-epoch seed knowledge* on a
//! connection — symmetric, channel-bound, and crucially **without either side
//! having pre-pinned the other** (the mesh property that the pairwise
//! `is_trusted` gate lacks). [`seedproof`] is the crypto that proof rests on:
//! the membership key `MK = HKDF(seed, …)` and the mutual challenge-response
//! transcript. Pure primitives here (Mesh-D1) — the connect-time handshake that
//! drives them onto a live connection is Mesh-D2; the gate swap that consults
//! the result is Mesh-D5.

pub mod seedproof;


//! Membership key + mutual channel-bound seed-proof (Mesh-D1, REQ-MESH-1).
//!
//! Authorization in the subnet mesh is a **live per-connection proof of
//! current-epoch seed knowledge**, not a stored pairwise pin. The two primitives:
//!
//! 1. [`MembershipKey`] — `MK = HKDF-SHA256(ikm = seed, info = domain ‖
//!    subnet_id ‖ seed_epoch)`. The shared per-(subnet, epoch) secret every
//!    member can derive and no outsider can. Exact-epoch by construction; the
//!    one-deep N-1 re-seed grace is Mesh-D7's concern, not this codec's.
//! 2. [`SeedProofTranscript`] — each side computes an HMAC-SHA-256 tag keyed by
//!    `MK` over a transcript binding **both handshake-proven node pubkeys, both
//!    nonces, subnet_id, seed_epoch, and role**, and constant-time-verifies the
//!    peer's tag. The two pubkeys are the channel binding (a tag is worthless on
//!    any other connection); the nonces give per-connection freshness; the role
//!    byte stops one side's tag being replayed as the other's; *mutual* means
//!    both sides must prove.
//!
//! This is the deliberate sibling of [`crate::net::pairing::transcript`] — same
//! HMAC-SHA-256 + length-prefixed-field + `subtle::ct_eq` shape, a different
//! domain and a different key source (the derived `MK`, not a PAKE secret).
//! Clean-room the structure; do not share code (the two bindings must be free to
//! diverge).
//
// [impl->REQ-MESH-1]

use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use spt_proto::identity::{PublicKey, PUBLIC_KEY_LEN};
use subtle::ConstantTimeEq;
use zeroize::Zeroize;

type HmacSha256 = Hmac<Sha256>;

/// Domain separation for the HKDF `info` that derives `MK` — versioned so a
/// future derivation change is a clean break, never a silent cross-version key
/// collision.
const MK_DOMAIN: &[u8] = b"spt-core/mesh/mk/v1";

/// Domain separation for the seed-proof confirmation MAC — distinct from
/// [`MK_DOMAIN`] (a tag and a key derivation must never share a transcript) and
/// from the pairing confirm domain.
const PROOF_DOMAIN: &[u8] = b"spt-core/mesh/seedproof/v1";

/// Membership-key length (HKDF-SHA-256 output).
pub const MK_LEN: usize = 32;

/// Seed-proof tag length (HMAC-SHA-256 output).
pub const PROOF_TAG_LEN: usize = 32;

/// Per-connection challenge nonce length.
pub const NONCE_LEN: usize = 32;

/// The subnet membership key — the shared secret for one `(subnet_id,
/// seed_epoch)` generation. Wiped on drop: a leaked `MK` is a leaked
/// subnet-wide credential, so it must not linger in freed memory.
pub struct MembershipKey([u8; MK_LEN]);

impl MembershipKey {
    /// Derive `MK = HKDF-SHA-256(ikm = seed, info = domain ‖ ⟨subnet_id⟩ ‖
    /// seed_epoch_be)`. `salt = None`: the seed is already high-entropy keying
    /// material and all separation rides the domain-tagged `info`. The
    /// length-prefixed `subnet_id` makes `("a", epoch 1)` and `("a1", epoch 0)`
    /// derive distinct keys (no field-boundary ambiguity).
    pub fn derive(seed: &[u8], subnet_id: &str, seed_epoch: u64) -> MembershipKey {
        let mut info = Vec::with_capacity(MK_DOMAIN.len() + 4 + subnet_id.len() + 8);
        info.extend_from_slice(MK_DOMAIN);
        info.extend_from_slice(&(subnet_id.len() as u32).to_be_bytes());
        info.extend_from_slice(subnet_id.as_bytes());
        info.extend_from_slice(&seed_epoch.to_be_bytes());

        let hk = Hkdf::<Sha256>::new(None, seed);
        let mut okm = [0u8; MK_LEN];
        hk.expand(&info, &mut okm)
            .expect("MK_LEN is a valid HKDF-SHA-256 output length");
        MembershipKey(okm)
    }

    /// The raw key bytes — for the HMAC key only. Not `Clone`/`Debug` on the
    /// outer type: keep the surface that can copy this secret minimal.
    fn key_bytes(&self) -> &[u8; MK_LEN] {
        &self.0
    }
}

impl Drop for MembershipKey {
    fn drop(&mut self) {
        self.0.zeroize();
    }
}

/// Which side of the connection a party plays. The **dialer** opened the
/// connection; the **acceptor** received it. Bound into the tag so the dialer's
/// proof can never be replayed as the acceptor's (reflection defense) — exactly
/// the role pinning [`crate::net::pairing::transcript::Role`] uses.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProofRole {
    /// The node that opened the connection.
    Dialer,
    /// The node that accepted the connection.
    Acceptor,
}

impl ProofRole {
    fn label(self) -> &'static [u8] {
        match self {
            ProofRole::Dialer => b"dialer",
            ProofRole::Acceptor => b"acceptor",
        }
    }
}

/// The context bound by the seed-proof MAC. Both sides MUST build an identical
/// transcript — same field values, same pubkey/nonce order (dialer first) — or
/// the tags diverge, and that divergence *is* the rejection. The pubkeys are the
/// connection's two handshake-proven identities (the channel binding); a tag is
/// therefore valid on exactly one connection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SeedProofTranscript {
    /// The subnet whose membership is being proven (binds the proof to one
    /// subnet — a member of X cannot prove into Y).
    pub subnet_id: String,
    /// The seed generation the proof is for. Exact-match: a tag for epoch N does
    /// not verify against epoch N±1 (the N-1 re-seed grace is a separate
    /// admission path at D7, not a relaxation of this codec).
    pub seed_epoch: u64,
    /// The dialer's handshake-proven Ed25519 public key (channel binding).
    pub dialer_pub: PublicKey,
    /// The acceptor's handshake-proven Ed25519 public key (channel binding).
    pub acceptor_pub: PublicKey,
    /// The dialer's per-connection challenge nonce (freshness).
    pub dialer_nonce: [u8; NONCE_LEN],
    /// The acceptor's per-connection challenge nonce (freshness).
    pub acceptor_nonce: [u8; NONCE_LEN],
}

impl SeedProofTranscript {
    /// The tag a party of role `prover` sends, keyed by `mk`, over the canonical
    /// transcript. Fields are bound in a fixed order regardless of prover, so
    /// both sides hash the same transcript and only the role byte differs
    /// between the two tags.
    // [impl->REQ-MESH-1]
    pub fn tag(&self, mk: &MembershipKey, prover: ProofRole) -> [u8; PROOF_TAG_LEN] {
        let mut mac = HmacSha256::new_from_slice(mk.key_bytes())
            .expect("HMAC accepts any key length");
        mac.update(PROOF_DOMAIN);
        mac.update(prover.label());
        // subnet_id is variable-length → length-prefix it. The rest are
        // fixed-width (epoch 8, pubkeys 32, nonces 32) and unambiguous raw.
        mac.update(&(self.subnet_id.len() as u32).to_be_bytes());
        mac.update(self.subnet_id.as_bytes());
        mac.update(&self.seed_epoch.to_be_bytes());
        mac.update(&self.dialer_pub.to_bytes());
        mac.update(&self.acceptor_pub.to_bytes());
        mac.update(&self.dialer_nonce);
        mac.update(&self.acceptor_nonce);
        mac.finalize().into_bytes().into()
    }

    /// Constant-time verify a peer's `claimed` tag for role `prover`. `true` only
    /// on an exact match — no early-exit timing oracle (`subtle::ct_eq`).
    // [impl->REQ-MESH-1]
    pub fn verify(&self, mk: &MembershipKey, prover: ProofRole, claimed: &[u8]) -> bool {
        let want = self.tag(mk, prover);
        want.ct_eq(claimed).into()
    }
}

/// The two wire frames of the mutual exchange. Each side opens with its
/// [`Challenge`](SeedProofFrame::Challenge) (its nonce), then — once both nonces
/// and the two handshake pubkeys fix the transcript — sends its
/// [`Proof`](SeedProofFrame::Proof). Defined here (Mesh-D1); *driven* onto a
/// connection at Mesh-D2.
///
/// Encoding: a 1-byte tag (`0` = Challenge, `1` = Proof) followed by the
/// fixed-width payload. Decoding is total and rejects any malformed buffer with
/// `None` (additive/robust, like the pairing wire frames).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SeedProofFrame {
    /// A party's per-connection nonce, sent first.
    Challenge { nonce: [u8; NONCE_LEN] },
    /// A party's seed-proof tag over the agreed transcript.
    Proof { tag: [u8; PROOF_TAG_LEN] },
}

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

impl SeedProofFrame {
    /// Serialize to the wire form.
    pub fn encode(&self) -> Vec<u8> {
        match self {
            SeedProofFrame::Challenge { nonce } => {
                let mut out = Vec::with_capacity(1 + NONCE_LEN);
                out.push(FRAME_CHALLENGE);
                out.extend_from_slice(nonce);
                out
            }
            SeedProofFrame::Proof { tag } => {
                let mut out = Vec::with_capacity(1 + PROOF_TAG_LEN);
                out.push(FRAME_PROOF);
                out.extend_from_slice(tag);
                out
            }
        }
    }

    /// Parse from the wire form. `None` on an unknown tag byte or a
    /// wrong-length payload — never panics, never partial-reads.
    pub fn decode(bytes: &[u8]) -> Option<SeedProofFrame> {
        let (&tag, rest) = bytes.split_first()?;
        match tag {
            FRAME_CHALLENGE if rest.len() == NONCE_LEN => {
                let mut nonce = [0u8; NONCE_LEN];
                nonce.copy_from_slice(rest);
                Some(SeedProofFrame::Challenge { nonce })
            }
            FRAME_PROOF if rest.len() == PROOF_TAG_LEN => {
                let mut t = [0u8; PROOF_TAG_LEN];
                t.copy_from_slice(rest);
                Some(SeedProofFrame::Proof { tag: t })
            }
            _ => None,
        }
    }
}

// A pubkey-length cross-check so a future PublicKey size change trips the test
// suite rather than silently reshaping the transcript.
const _: () = assert!(PUBLIC_KEY_LEN == 32);

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

    const SEED: &[u8] = b"a-32-byte-ish-subnet-seed-value!";

    fn pubs() -> (PublicKey, PublicKey) {
        (
            Identity::from_seed(&[1u8; 32]).public_key(),
            Identity::from_seed(&[2u8; 32]).public_key(),
        )
    }

    fn transcript() -> SeedProofTranscript {
        let (d, a) = pubs();
        SeedProofTranscript {
            subnet_id: "home".into(),
            seed_epoch: 1,
            dialer_pub: d,
            acceptor_pub: a,
            dialer_nonce: [7u8; NONCE_LEN],
            acceptor_nonce: [9u8; NONCE_LEN],
        }
    }

    // [unit->REQ-MESH-1] MK is deterministic for equal inputs and diverges for
    // a different seed, subnet, OR epoch — and the length-prefix closes the
    // ("a",1) vs ("a1",0) field-boundary collision.
    #[test]
    fn mk_derivation_is_deterministic_and_input_sensitive() {
        let base = MembershipKey::derive(SEED, "home", 1);
        assert_eq!(
            base.0,
            MembershipKey::derive(SEED, "home", 1).0,
            "deterministic"
        );
        assert_ne!(
            base.0,
            MembershipKey::derive(b"other-seed-other-seed-other-seed", "home", 1).0,
            "seed-sensitive"
        );
        assert_ne!(
            base.0,
            MembershipKey::derive(SEED, "work", 1).0,
            "subnet-sensitive"
        );
        assert_ne!(
            base.0,
            MembershipKey::derive(SEED, "home", 2).0,
            "epoch-sensitive"
        );
        // Field-boundary: ("a", epoch 1) must not equal ("a1", epoch 0)…0x01.
        // (to_be_bytes of 1 ends in 0x01; without the length prefix the two
        // info strings would be identical bytewise.)
        assert_ne!(
            MembershipKey::derive(SEED, "a", 1).0,
            MembershipKey::derive(SEED, "a1", 0).0,
            "length-prefix prevents subnet/epoch boundary collision"
        );
    }

    // [unit->REQ-MESH-1] a matching MK + transcript verifies, and the two role
    // tags differ so the dialer's proof can't be replayed as the acceptor's.
    #[test]
    fn valid_proof_verifies_and_roles_differ() {
        let mk = MembershipKey::derive(SEED, "home", 1);
        let t = transcript();
        let d = t.tag(&mk, ProofRole::Dialer);
        let a = t.tag(&mk, ProofRole::Acceptor);
        assert_ne!(d, a, "role byte distinguishes the two tags");
        assert!(t.verify(&mk, ProofRole::Dialer, &d));
        assert!(t.verify(&mk, ProofRole::Acceptor, &a));
        // No reflection: an acceptor tag must not verify as a dialer tag.
        assert!(!t.verify(&mk, ProofRole::Dialer, &a));
    }

    // [unit->REQ-MESH-1] a forged proof — a non-member without the seed, modeled
    // as a wrong MK — fails verification.
    #[test]
    fn forged_proof_fails() {
        let real = MembershipKey::derive(SEED, "home", 1);
        let t = transcript();
        let tag = t.tag(&real, ProofRole::Dialer);
        let outsider = MembershipKey::derive(b"the-outsider-has-the-wrong-seed!", "home", 1);
        assert!(!t.verify(&outsider, ProofRole::Dialer, &tag));
    }

    // [unit->REQ-MESH-1] wrong-subnet: a tag made under subnet "home"'s MK does
    // not verify under subnet "work"'s MK (same seed, different MK).
    #[test]
    fn wrong_subnet_fails() {
        let home = MembershipKey::derive(SEED, "home", 1);
        let work = MembershipKey::derive(SEED, "work", 1);
        let t = transcript();
        let tag = t.tag(&home, ProofRole::Dialer);
        assert!(!t.verify(&work, ProofRole::Dialer, &tag));
    }

    // [unit->REQ-MESH-1] wrong-epoch: exact-epoch match — a tag for epoch 1 does
    // not verify under epoch 2's MK (the N-1 grace is D7, not this codec).
    #[test]
    fn wrong_epoch_fails() {
        let e1 = MembershipKey::derive(SEED, "home", 1);
        let e2 = MembershipKey::derive(SEED, "home", 2);
        let t = transcript();
        let tag = t.tag(&e1, ProofRole::Dialer);
        assert!(!t.verify(&e2, ProofRole::Dialer, &tag));
    }

    // [unit->REQ-MESH-1] the channel-binding center: a tag valid on connection A
    // (its pubkeys + nonces) fails on connection B that differs in ANY of the
    // four channel fields — no cross-connection replay.
    #[test]
    fn cross_connection_replay_fails() {
        let mk = MembershipKey::derive(SEED, "home", 1);
        let base = transcript();
        let tag = base.tag(&mk, ProofRole::Dialer);

        let other_pub = Identity::from_seed(&[42u8; 32]).public_key();
        let variants = [
            SeedProofTranscript {
                dialer_pub: other_pub,
                ..base.clone()
            },
            SeedProofTranscript {
                acceptor_pub: other_pub,
                ..base.clone()
            },
            SeedProofTranscript {
                dialer_nonce: [0xABu8; NONCE_LEN],
                ..base.clone()
            },
            SeedProofTranscript {
                acceptor_nonce: [0xCDu8; NONCE_LEN],
                ..base.clone()
            },
        ];
        for (i, v) in variants.iter().enumerate() {
            assert!(
                !v.verify(&mk, ProofRole::Dialer, &tag),
                "channel field {i} must bind the tag"
            );
        }
    }

    // [unit->REQ-MESH-1] mutual: the pair is admitted only when BOTH sides
    // verify; if either side proves under the wrong MK, the mutual check fails.
    #[test]
    fn mutual_requires_both_sides() {
        let t = transcript();
        let good = MembershipKey::derive(SEED, "home", 1);
        let bad = MembershipKey::derive(b"impostor-impostor-impostor-impo!", "home", 1);

        // Each side sends its own-role tag; the peer verifies it.
        let dialer_tag = t.tag(&good, ProofRole::Dialer);
        let acceptor_tag_good = t.tag(&good, ProofRole::Acceptor);
        let acceptor_tag_bad = t.tag(&bad, ProofRole::Acceptor);

        let mutual = |dt: &[u8], at: &[u8]| {
            t.verify(&good, ProofRole::Dialer, dt) && t.verify(&good, ProofRole::Acceptor, at)
        };
        assert!(mutual(&dialer_tag, &acceptor_tag_good), "both prove → admit");
        assert!(
            !mutual(&dialer_tag, &acceptor_tag_bad),
            "one impostor → reject"
        );
    }

    // [unit->REQ-MESH-1] frame codec round-trips both variants, and a malformed
    // buffer (unknown tag / wrong length / empty) decodes to None.
    #[test]
    fn frame_codec_round_trips_and_rejects_garbage() {
        let ch = SeedProofFrame::Challenge {
            nonce: [3u8; NONCE_LEN],
        };
        let pf = SeedProofFrame::Proof {
            tag: [5u8; PROOF_TAG_LEN],
        };
        assert_eq!(SeedProofFrame::decode(&ch.encode()), Some(ch));
        assert_eq!(SeedProofFrame::decode(&pf.encode()), Some(pf));

        assert_eq!(SeedProofFrame::decode(&[]), None, "empty");
        assert_eq!(SeedProofFrame::decode(&[9u8; 33]), None, "unknown tag");
        assert_eq!(
            SeedProofFrame::decode(&[FRAME_CHALLENGE, 1, 2, 3]),
            None,
            "short challenge"
        );
        assert_eq!(
            SeedProofFrame::decode(&[FRAME_PROOF; 8]),
            None,
            "short proof"
        );
    }
}


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

//! Membership key + mutual channel-bound seed-proof (Mesh-D1, REQ-MESH-1).
//!
//! Authorization in the subnet mesh is a **live per-connection proof of
//! current-epoch seed knowledge**, not a stored pairwise pin. The two primitives:
//!
//! 1. [`MembershipKey`] — `MK = HKDF-SHA256(ikm = seed, info = domain ‖
//!    subnet_id ‖ seed_epoch)`. The shared per-(subnet, epoch) secret every
//!    member can derive and no outsider can. Exact-epoch by construction; the
//!    one-deep N-1 re-seed grace is Mesh-D7's concern, not this codec's.
//! 2. [`SeedProofTranscript`] — each side computes an HMAC-SHA-256 tag keyed by
//!    `MK` over a transcript binding **both handshake-proven node pubkeys, both
//!    nonces, subnet_id, seed_epoch, and role**, and constant-time-verifies the
//!    peer's tag. The two pubkeys are the channel binding (a tag is worthless on
//!    any other connection); the nonces give per-connection freshness; the role
//!    byte stops one side's tag being replayed as the other's; *mutual* means
//!    both sides must prove.
//!
//! This is the deliberate sibling of [`crate::net::pairing::transcript`] — same
//! HMAC-SHA-256 + length-prefixed-field + `subtle::ct_eq` shape, a different
//! domain and a different key source (the derived `MK`, not a PAKE secret).
//! Clean-room the structure; do not share code (the two bindings must be free to
//! diverge).
//
// [impl->REQ-MESH-1]

use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use spt_proto::identity::{PublicKey, PUBLIC_KEY_LEN};
use subtle::ConstantTimeEq;
use zeroize::Zeroize;

type HmacSha256 = Hmac<Sha256>;

/// Domain separation for the HKDF `info` that derives `MK` — versioned so a
/// future derivation change is a clean break, never a silent cross-version key
/// collision.
const MK_DOMAIN: &[u8] = b"spt-core/mesh/mk/v1";

/// Domain separation for the seed-proof confirmation MAC — distinct from
/// [`MK_DOMAIN`] (a tag and a key derivation must never share a transcript) and
/// from the pairing confirm domain.
const PROOF_DOMAIN: &[u8] = b"spt-core/mesh/seedproof/v1";

/// Membership-key length (HKDF-SHA-256 output).
pub const MK_LEN: usize = 32;

/// Seed-proof tag length (HMAC-SHA-256 output).
pub const PROOF_TAG_LEN: usize = 32;

/// Per-connection challenge nonce length.
pub const NONCE_LEN: usize = 32;

/// The subnet membership key — the shared secret for one `(subnet_id,
/// seed_epoch)` generation. Wiped on drop: a leaked `MK` is a leaked
/// subnet-wide credential, so it must not linger in freed memory.
pub struct MembershipKey([u8; MK_LEN]);

impl MembershipKey {
    /// Derive `MK = HKDF-SHA-256(ikm = seed, info = domain ‖ ⟨subnet_id⟩ ‖
    /// seed_epoch_be)`. `salt = None`: the seed is already high-entropy keying
    /// material and all separation rides the domain-tagged `info`. The
    /// length-prefixed `subnet_id` makes `("a", epoch 1)` and `("a1", epoch 0)`
    /// derive distinct keys (no field-boundary ambiguity).
    pub fn derive(seed: &[u8], subnet_id: &str, seed_epoch: u64) -> MembershipKey {
        let mut info = Vec::with_capacity(MK_DOMAIN.len() + 4 + subnet_id.len() + 8);
        info.extend_from_slice(MK_DOMAIN);
        info.extend_from_slice(&(subnet_id.len() as u32).to_be_bytes());
        info.extend_from_slice(subnet_id.as_bytes());
        info.extend_from_slice(&seed_epoch.to_be_bytes());

        let hk = Hkdf::<Sha256>::new(None, seed);
        let mut okm = [0u8; MK_LEN];
        hk.expand(&info, &mut okm)
            .expect("MK_LEN is a valid HKDF-SHA-256 output length");
        MembershipKey(okm)
    }

    /// The raw key bytes — for the HMAC key only. Not `Clone`/`Debug` on the
    /// outer type: keep the surface that can copy this secret minimal.
    fn key_bytes(&self) -> &[u8; MK_LEN] {
        &self.0
    }
}

impl Drop for MembershipKey {
    fn drop(&mut self) {
        self.0.zeroize();
    }
}

/// Which side of the connection a party plays. The **dialer** opened the
/// connection; the **acceptor** received it. Bound into the tag so the dialer's
/// proof can never be replayed as the acceptor's (reflection defense) — exactly
/// the role pinning [`crate::net::pairing::transcript::Role`] uses.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProofRole {
    /// The node that opened the connection.
    Dialer,
    /// The node that accepted the connection.
    Acceptor,
}

impl ProofRole {
    fn label(self) -> &'static [u8] {
        match self {
            ProofRole::Dialer => b"dialer",
            ProofRole::Acceptor => b"acceptor",
        }
    }
}

/// The context bound by the seed-proof MAC. Both sides MUST build an identical
/// transcript — same field values, same pubkey/nonce order (dialer first) — or
/// the tags diverge, and that divergence *is* the rejection. The pubkeys are the
/// connection's two handshake-proven identities (the channel binding); a tag is
/// therefore valid on exactly one connection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SeedProofTranscript {
    /// The subnet whose membership is being proven (binds the proof to one
    /// subnet — a member of X cannot prove into Y).
    pub subnet_id: String,
    /// The seed generation the proof is for. Exact-match: a tag for epoch N does
    /// not verify against epoch N±1 (the N-1 re-seed grace is a separate
    /// admission path at D7, not a relaxation of this codec).
    pub seed_epoch: u64,
    /// The dialer's handshake-proven Ed25519 public key (channel binding).
    pub dialer_pub: PublicKey,
    /// The acceptor's handshake-proven Ed25519 public key (channel binding).
    pub acceptor_pub: PublicKey,
    /// The dialer's per-connection challenge nonce (freshness).
    pub dialer_nonce: [u8; NONCE_LEN],
    /// The acceptor's per-connection challenge nonce (freshness).
    pub acceptor_nonce: [u8; NONCE_LEN],
}

impl SeedProofTranscript {
    /// The tag a party of role `prover` sends, keyed by `mk`, over the canonical
    /// transcript. Fields are bound in a fixed order regardless of prover, so
    /// both sides hash the same transcript and only the role byte differs
    /// between the two tags.
    // [impl->REQ-MESH-1]
    pub fn tag(&self, mk: &MembershipKey, prover: ProofRole) -> [u8; PROOF_TAG_LEN] {
        let mut mac = HmacSha256::new_from_slice(mk.key_bytes())
            .expect("HMAC accepts any key length");
        mac.update(PROOF_DOMAIN);
        mac.update(prover.label());
        // subnet_id is variable-length → length-prefix it. The rest are
        // fixed-width (epoch 8, pubkeys 32, nonces 32) and unambiguous raw.
        mac.update(&(self.subnet_id.len() as u32).to_be_bytes());
        mac.update(self.subnet_id.as_bytes());
        mac.update(&self.seed_epoch.to_be_bytes());
        mac.update(&self.dialer_pub.to_bytes());
        mac.update(&self.acceptor_pub.to_bytes());
        mac.update(&self.dialer_nonce);
        mac.update(&self.acceptor_nonce);
        mac.finalize().into_bytes().into()
    }

    /// Constant-time verify a peer's `claimed` tag for role `prover`. `true` only
    /// on an exact match — no early-exit timing oracle (`subtle::ct_eq`).
    // [impl->REQ-MESH-1]
    pub fn verify(&self, mk: &MembershipKey, prover: ProofRole, claimed: &[u8]) -> bool {
        let want = self.tag(mk, prover);
        want.ct_eq(claimed).into()
    }
}

/// The two wire frames of the mutual exchange. Each side opens with its
/// [`Challenge`](SeedProofFrame::Challenge) (its nonce), then — once both nonces
/// and the two handshake pubkeys fix the transcript — sends its
/// [`Proof`](SeedProofFrame::Proof). Defined here (Mesh-D1); *driven* onto a
/// connection at Mesh-D2.
///
/// Encoding: a 1-byte tag (`0` = Challenge, `1` = Proof) followed by the
/// fixed-width payload. Decoding is total and rejects any malformed buffer with
/// `None` (additive/robust, like the pairing wire frames).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SeedProofFrame {
    /// A party's per-connection nonce, sent first.
    Challenge { nonce: [u8; NONCE_LEN] },
    /// A party's seed-proof tag over the agreed transcript.
    Proof { tag: [u8; PROOF_TAG_LEN] },
}

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

impl SeedProofFrame {
    /// Serialize to the wire form.
    pub fn encode(&self) -> Vec<u8> {
        match self {
            SeedProofFrame::Challenge { nonce } => {
                let mut out = Vec::with_capacity(1 + NONCE_LEN);
                out.push(FRAME_CHALLENGE);
                out.extend_from_slice(nonce);
                out
            }
            SeedProofFrame::Proof { tag } => {
                let mut out = Vec::with_capacity(1 + PROOF_TAG_LEN);
                out.push(FRAME_PROOF);
                out.extend_from_slice(tag);
                out
            }
        }
    }

    /// Parse from the wire form. `None` on an unknown tag byte or a
    /// wrong-length payload — never panics, never partial-reads.
    pub fn decode(bytes: &[u8]) -> Option<SeedProofFrame> {
        let (&tag, rest) = bytes.split_first()?;
        match tag {
            FRAME_CHALLENGE if rest.len() == NONCE_LEN => {
                let mut nonce = [0u8; NONCE_LEN];
                nonce.copy_from_slice(rest);
                Some(SeedProofFrame::Challenge { nonce })
            }
            FRAME_PROOF if rest.len() == PROOF_TAG_LEN => {
                let mut t = [0u8; PROOF_TAG_LEN];
                t.copy_from_slice(rest);
                Some(SeedProofFrame::Proof { tag: t })
            }
            _ => None,
        }
    }
}

// A pubkey-length cross-check so a future PublicKey size change trips the test
// suite rather than silently reshaping the transcript.
const _: () = assert!(PUBLIC_KEY_LEN == 32);

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

    const SEED: &[u8] = b"a-32-byte-ish-subnet-seed-value!";

    fn pubs() -> (PublicKey, PublicKey) {
        (
            Identity::from_seed(&[1u8; 32]).public_key(),
            Identity::from_seed(&[2u8; 32]).public_key(),
        )
    }

    fn transcript() -> SeedProofTranscript {
        let (d, a) = pubs();
        SeedProofTranscript {
            subnet_id: "home".into(),
            seed_epoch: 1,
            dialer_pub: d,
            acceptor_pub: a,
            dialer_nonce: [7u8; NONCE_LEN],
            acceptor_nonce: [9u8; NONCE_LEN],
        }
    }

    // [unit->REQ-MESH-1] MK is deterministic for equal inputs and diverges for
    // a different seed, subnet, OR epoch — and the length-prefix closes the
    // ("a",1) vs ("a1",0) field-boundary collision.
    #[test]
    fn mk_derivation_is_deterministic_and_input_sensitive() {
        let base = MembershipKey::derive(SEED, "home", 1);
        assert_eq!(
            base.0,
            MembershipKey::derive(SEED, "home", 1).0,
            "deterministic"
        );
        assert_ne!(
            base.0,
            MembershipKey::derive(b"other-seed-other-seed-other-seed", "home", 1).0,
            "seed-sensitive"
        );
        assert_ne!(
            base.0,
            MembershipKey::derive(SEED, "work", 1).0,
            "subnet-sensitive"
        );
        assert_ne!(
            base.0,
            MembershipKey::derive(SEED, "home", 2).0,
            "epoch-sensitive"
        );
        // Field-boundary: ("a", epoch 1) must not equal ("a1", epoch 0)…0x01.
        // (to_be_bytes of 1 ends in 0x01; without the length prefix the two
        // info strings would be identical bytewise.)
        assert_ne!(
            MembershipKey::derive(SEED, "a", 1).0,
            MembershipKey::derive(SEED, "a1", 0).0,
            "length-prefix prevents subnet/epoch boundary collision"
        );
    }

    // [unit->REQ-MESH-1] a matching MK + transcript verifies, and the two role
    // tags differ so the dialer's proof can't be replayed as the acceptor's.
    #[test]
    fn valid_proof_verifies_and_roles_differ() {
        let mk = MembershipKey::derive(SEED, "home", 1);
        let t = transcript();
        let d = t.tag(&mk, ProofRole::Dialer);
        let a = t.tag(&mk, ProofRole::Acceptor);
        assert_ne!(d, a, "role byte distinguishes the two tags");
        assert!(t.verify(&mk, ProofRole::Dialer, &d));
        assert!(t.verify(&mk, ProofRole::Acceptor, &a));
        // No reflection: an acceptor tag must not verify as a dialer tag.
        assert!(!t.verify(&mk, ProofRole::Dialer, &a));
    }

    // [unit->REQ-MESH-1] a forged proof — a non-member without the seed, modeled
    // as a wrong MK — fails verification.
    #[test]
    fn forged_proof_fails() {
        let real = MembershipKey::derive(SEED, "home", 1);
        let t = transcript();
        let tag = t.tag(&real, ProofRole::Dialer);
        let outsider = MembershipKey::derive(b"the-outsider-has-the-wrong-seed!", "home", 1);
        assert!(!t.verify(&outsider, ProofRole::Dialer, &tag));
    }

    // [unit->REQ-MESH-1] wrong-subnet: a tag made under subnet "home"'s MK does
    // not verify under subnet "work"'s MK (same seed, different MK).
    #[test]
    fn wrong_subnet_fails() {
        let home = MembershipKey::derive(SEED, "home", 1);
        let work = MembershipKey::derive(SEED, "work", 1);
        let t = transcript();
        let tag = t.tag(&home, ProofRole::Dialer);
        assert!(!t.verify(&work, ProofRole::Dialer, &tag));
    }

    // [unit->REQ-MESH-1] wrong-epoch: exact-epoch match — a tag for epoch 1 does
    // not verify under epoch 2's MK (the N-1 grace is D7, not this codec).
    #[test]
    fn wrong_epoch_fails() {
        let e1 = MembershipKey::derive(SEED, "home", 1);
        let e2 = MembershipKey::derive(SEED, "home", 2);
        let t = transcript();
        let tag = t.tag(&e1, ProofRole::Dialer);
        assert!(!t.verify(&e2, ProofRole::Dialer, &tag));
    }

    // [unit->REQ-MESH-1] the channel-binding center: a tag valid on connection A
    // (its pubkeys + nonces) fails on connection B that differs in ANY of the
    // four channel fields — no cross-connection replay.
    #[test]
    fn cross_connection_replay_fails() {
        let mk = MembershipKey::derive(SEED, "home", 1);
        let base = transcript();
        let tag = base.tag(&mk, ProofRole::Dialer);

        let other_pub = Identity::from_seed(&[42u8; 32]).public_key();
        let variants = [
            SeedProofTranscript {
                dialer_pub: other_pub,
                ..base.clone()
            },
            SeedProofTranscript {
                acceptor_pub: other_pub,
                ..base.clone()
            },
            SeedProofTranscript {
                dialer_nonce: [0xABu8; NONCE_LEN],
                ..base.clone()
            },
            SeedProofTranscript {
                acceptor_nonce: [0xCDu8; NONCE_LEN],
                ..base.clone()
            },
        ];
        for (i, v) in variants.iter().enumerate() {
            assert!(
                !v.verify(&mk, ProofRole::Dialer, &tag),
                "channel field {i} must bind the tag"
            );
        }
    }

    // [unit->REQ-MESH-1] mutual: the pair is admitted only when BOTH sides
    // verify; if either side proves under the wrong MK, the mutual check fails.
    #[test]
    fn mutual_requires_both_sides() {
        let t = transcript();
        let good = MembershipKey::derive(SEED, "home", 1);
        let bad = MembershipKey::derive(b"impostor-impostor-impostor-impo!", "home", 1);

        // Each side sends its own-role tag; the peer verifies it.
        let dialer_tag = t.tag(&good, ProofRole::Dialer);
        let acceptor_tag_good = t.tag(&good, ProofRole::Acceptor);
        let acceptor_tag_bad = t.tag(&bad, ProofRole::Acceptor);

        let mutual = |dt: &[u8], at: &[u8]| {
            t.verify(&good, ProofRole::Dialer, dt) && t.verify(&good, ProofRole::Acceptor, at)
        };
        assert!(mutual(&dialer_tag, &acceptor_tag_good), "both prove → admit");
        assert!(
            !mutual(&dialer_tag, &acceptor_tag_bad),
            "one impostor → reject"
        );
    }

    // [unit->REQ-MESH-1] frame codec round-trips both variants, and a malformed
    // buffer (unknown tag / wrong length / empty) decodes to None.
    #[test]
    fn frame_codec_round_trips_and_rejects_garbage() {
        let ch = SeedProofFrame::Challenge {
            nonce: [3u8; NONCE_LEN],
        };
        let pf = SeedProofFrame::Proof {
            tag: [5u8; PROOF_TAG_LEN],
        };
        assert_eq!(SeedProofFrame::decode(&ch.encode()), Some(ch));
        assert_eq!(SeedProofFrame::decode(&pf.encode()), Some(pf));

        assert_eq!(SeedProofFrame::decode(&[]), None, "empty");
        assert_eq!(SeedProofFrame::decode(&[9u8; 33]), None, "unknown tag");
        assert_eq!(
            SeedProofFrame::decode(&[FRAME_CHALLENGE, 1, 2, 3]),
            None,
            "short challenge"
        );
        assert_eq!(
            SeedProofFrame::decode(&[FRAME_PROOF; 8]),
            None,
            "short proof"
        );
    }
}


Build + test the new module.


Bash

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

Test seedproof module


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

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

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


All 8 green. Now the purity gates: `--no-default-features` build (module is `net`-gated) + full crate build + traceability.


cd /c/Users/decid/Documents/projects/spt-core
echo "=== build --no-default-features (net-gated leak check) ==="; cargo build -p spt-net --no-default-features 2>&1 | tail -5
echo "=== full crate build ==="; cargo build -p spt-net 2>&1 | tail -3
echo "=== clippy on the module ==="; cargo clippy -p spt-net 2>&1 | grep -iE "mesh|seedproof|warning:|error" | head -15

Purity + lint gates


=== build --no-default-features (net-gated leak check) ===
   Compiling spt-proto v0.2.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-proto)
   Compiling spt-store v0.2.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-store)
   Compiling spt-msg v0.2.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-msg)
   Compiling spt-net v0.2.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.04s
=== full crate build ===
   Compiling iroh v0.98.2
   Compiling spt-net v0.2.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 17.31s
=== clippy on the module ===


Purity + clippy clean. Now traceability — REQ-MESH-1 active, codec evidence tagged.


traceable-reqs check 2>&1 | grep -iE "REQ-MESH-1|FAIL|error|missing" | head; echo "rc=${PIPESTATUS[0]}"; echo "=== full exit ==="; traceable-reqs check >/dev/null 2>&1; echo "exit=$?"

Traceability check for REQ-MESH-1


       Peer address seeding, both cold starts: durable peer-addrs.json (identity dir) maps peer pubkey → last-known dialable address; the pump's resolver consults it FIRST with id-only discovery fallback on miss or dial failure (a stale addr never strands a peer); written by the pairing ceremony (both sides, from the live connection) and by the pump on successful connect; post-join first sync and post-restart resync converge in seconds, not ~1 min (M8 decisions 14, 20)
       A surfaced concurrent context pair is durably preserved (both versions, tracked artifacts) until a strictly dominating write clears it; no reconcile failure path discards an unmerged version (6.6, ADR-0013)
       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)
  [OK] REQ-MESH-1  required: [impl, unit]  stages: -doc +impl +unit -int
       NTP TOTP offset: the pairing ceremony queries NTP at ceremony time (both sides) and applies the derived offset to the TOTP calculation in-process only; system-clock fallback when NTP is unreachable (offline LAN pairing unaffected — NTP failure never blocks a pairing that succeeds today); never sets the OS clock; no background sync loop (M8 decision 18; field trigger: enlyzeam clock >1 min off exceeds the ±1 window)
rc=0
=== full exit ===
exit=0


REQ-MESH-1 satisfied (`+impl +unit`), check exit 0. Committing D1.


cd /c/Users/decid/Documents/projects/spt-core
git add crates/spt-net/src/net/mesh.rs crates/spt-net/src/net/mesh/seedproof.rs crates/spt-net/src/net.rs crates/spt-net/Cargo.toml Cargo.lock traceable-reqs.toml MESH-D1-PLAN.md
git status --short

Stage D1 changes


warning: in the working copy of 'Cargo.lock', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'MESH-D1-PLAN.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/mesh.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/mesh/seedproof.rs', LF will be replaced by CRLF the next time Git touches it
M  Cargo.lock
A  MESH-D1-PLAN.md
M  crates/spt-net/Cargo.toml
M  crates/spt-net/src/net.rs
A  crates/spt-net/src/net/mesh.rs
A  crates/spt-net/src/net/mesh/seedproof.rs
M  traceable-reqs.toml


git commit -F - <<'EOF'
feat(mesh): membership key + mutual seed-proof codec (REQ-MESH-1) — Mesh-D1

The crypto a subnet member proves itself with — pure primitive, no
wiring (the connect-time handshake is D2, the gate swap D5).

- MembershipKey: MK = HKDF-SHA-256(seed, domain ‖ ⟨subnet_id⟩ ‖
  seed_epoch). The per-(subnet, epoch) secret every member derives and
  no outsider can; zeroized on drop.
- SeedProofTranscript: an HMAC-SHA-256 tag keyed by MK over both
  handshake-proven node pubkeys + both nonces + subnet_id + seed_epoch +
  role; constant-time verify. The two pubkeys are the channel binding
  (a tag is valid on exactly one connection); the role byte kills
  reflection; mutual means both sides must prove.
- SeedProofFrame: the Challenge/Proof wire codec (total decode, garbage
  → None). Defined here; driven onto a connection at D2.

Deliberate sibling of pairing::transcript (same HMAC + length-prefix +
ct_eq shape, different domain + key source), clean-roomed not shared so
the two bindings can diverge. New net/mesh/ module, net-gated.

8 units: MK derivation (deterministic + seed/subnet/epoch-sensitive +
boundary-collision-free); valid/forged/wrong-subnet/wrong-epoch;
cross-connection-replay (the channel-binding center); mutual-requires-
both; frame round-trip + garbage-rejects. Activates REQ-MESH-1
(impl, unit); D2/D5 enrich impl under the same id.

Promotes hkdf 0.12 to a direct spt-net dep (was transitive).

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

Commit D1


[dev-freeform fdbfbfd] feat(mesh): membership key + mutual seed-proof codec (REQ-MESH-1) — Mesh-D1
 7 files changed, 541 insertions(+), 2 deletions(-)
 create mode 100644 MESH-D1-PLAN.md
 create mode 100644 crates/spt-net/src/net/mesh.rs
 create mode 100644 crates/spt-net/src/net/mesh/seedproof.rs


**Mesh-D1 — landed (`fdbfbfd`).**

| gate | result |
|---|---|
| `cargo test mesh::seedproof` | 8/8 green |
| `--no-default-features` build | clean (module `net`-gated, no leak) |
| clippy | no findings |
| traceable-reqs | REQ-MESH-1 `+impl +unit`, exit 0 |

Shipped: `MembershipKey` (HKDF-SHA256, zeroized), `SeedProofTranscript` (HMAC keyed by MK, binds both QUIC pubkeys + nonces + role, ct_eq verify), `SeedProofFrame` codec. Channel-binding test covers all 4 cross-connection fields. `hkdf` promoted to direct dep.

**Next = Mesh-D2** — seed-proof at connect + `ConnEntry` flag. Wiring phase: drive the mutual handshake on every dial+accept in `nethost` *before* the conn is usable, cache the membership result on `ConnEntry`, fail→drop. Old `is_trusted` gates still run in parallel (belt+suspenders, no regression — swap is D5). Folds in **open-item #1**: verify/enable QUIC `keep_alive_interval` < `max_idle_timeout` in `endpoint.rs` so conns stay multi-week (re-proof = restart/partition/rotation-only).

D2 touches live connection code — fresh focus warranted. Proceed into it, or hold?


<command-message>spt:commune</command-message>
<command-name>/spt:commune</command-name>
<command-args>with immediate next steps and broad summary of the project's status + end goal. we'll resume in the next session</command-args>


Base directory for this skill: C:\Users\decid\.ccs\instances\bigscreen\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: with immediate next steps and broad summary of the project's status + end goal. we'll resume in the next session


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


Session-end commune. Download current Psyche context, diff, write the delta.


$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="fdbfbfdd4099ca5f4b7f5a3eb4580c46bffbb323" head_subject="feat(mesh): membership key + mutual seed-proof codec (REQ-MESH-1) — Mesh…" commits_since="0" commits_unpulled="0"/>
**Mesh-D1 DONE — landed (`fdbfbfd`).**

Membership key + seed-proof codec complete. `MembershipKey` (HKDF-SHA256, zeroized), `SeedProofTranscript` (HMAC keyed by MK, binds both QUIC pubkeys + nonces + role, ct_eq verify), `SeedProofFrame` codec. All 8 unit tests green. Build purity clean (`--no-default-features` module `net`-gated, no leak), clippy silent. Traceability: REQ-MESH-1 `+impl +unit`, exit 0. `hkdf` promoted to direct dep (0.12.4).

**Next = Mesh-D2** — seed-proof *in-band* at dial/accept. Wiring: mutual handshake on every conn before usable, cache membership result on `ConnEntry` flag, fail→drop. Old `is_trusted` gates stay (belt+suspenders, no regression until D5 swap). Includes open-item #1: verify QUIC `keep_alive_interval` < `max_idle_timeout` in `endpoint.rs` (multi-week conn tenure, re-proof = restart/partition only). Fresh connection-code focus warranted before D2.<project-context-resolved name="spt-core"/>
**Mesh-D1 landed (`fdbfbfd`).** REQ-MESH-1 activated (`+impl +unit`). Design seams remain as D0 — pairwise push at `peerloop.rs:356`, permissive gate at `registryhost.rs:228/291`, trust split isolates red to push-target seam. D2 wiring phase next (conn-level handshake before usable); D3/D4 roster-seeding refinement, D5 gate swap, D6 ignore-lift all remain per MESH-D0-PLAN.md.


/spt:commune with immediate next steps and broad summary of the project's status + end goal. we'll resume in the next session


Psyche already current through D1-done + D2-next (a background echo captured it). Delta = session-boundary + the broad status/end-goal anchor the user asked be communed for resume.


Write

<live-context>
**Session paused here — resume next session.** Two phases landed clean this session (Mesh-D0 red harness `2c46a83`, Mesh-D1 seed-proof codec `fdbfbfd`); D1 mechanics already in my context, no need to re-absorb. My read on the resume: D2 is the first phase that touches *live connection code* (`nethost` dial/accept), so it wants fresh focus, not a tail-end push. The codec from D1 is the thing D2 drives in-band — `SeedProofFrame::{Challenge,Proof}` over the conn, mutual verify before the conn is usable, result cached on `ConnEntry`. The crypto is done and tested; D2 is integration + the keep-alive verification, not new primitives.
</live-context>
<project-context>
**Resume pointer — start Mesh-D2 next session.** JIT-write `MESH-D2-PLAN.md` first (project convention). D2 = seed-proof in-band at every dial+accept in `nethost`, *before* the connection is usable; cache the membership verdict on `ConnEntry`; fail→drop. Old `is_trusted` gates stay running in parallel (belt+suspenders — no regression; the swap is D5). Fold in **open-item #1**: confirm/enable QUIC `keep_alive_interval` < `max_idle_timeout` in `endpoint.rs` (conns must stay multi-week so re-proof is restart/partition/rotation-only). int = a member conn establishes only when both sides prove; a non-prover is dropped. Enriches REQ-MESH-1 `impl` (already active).

**Mesh milestone arc (where D2 sits):** D0 ✅ red repro (flips green at D6) · D1 ✅ codec · **D2 ← next** seed-proof at connect · D3 roster store + tombstones + pairing seed-transfer · D4 roster propagation on-connect · D5 gate swap (`is_trusted`→`is_member`) + hard cutover (delete `peers.json`) · **D6 fan-out widen → MESH ON, D0 harness flips green** · D7 revoke/rotate/re-seed grace · D8 status-probe concurrency. Every pre-D6 phase is a no-reach-regression; D6 is the single switch. Carry-forward open items: #2 (REQ-SUBNET-5 probe loop concurrent? — D8) and #3 (self-update path node-local, not riding member conns → cutover won't deadlock — pre-D5).

**Broad status + end goal (user asked to record):** spt-core = harness-independent core for the agent ecosystem — messaging, live-agent lifecycle, terminal hosting, P2P networking, runtime-manifest harness contract; clean-room rebuild of `claude_skill_owl`. M0–M8 shipped; **v0.2.0 live on the reachable fleet**. Now in the post-M8 **subnet full-mesh** milestone (this work) — fixes "the subnet was the pairing graph, not a mesh." **End goal = v1 with the rebuilt `spt` plugin (Claude Code, separate downstream repo) working E2E on top = the acceptance proof.** Also queued on disk, untouched: `DAEMON-LIFECYCLE-PLAN.md` (service-aware daemon start/stop). `dev-freeform` carries unpushed commits (the mesh set + earlier offline-name fix + daemon-start alias) — user controls the push.
</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>
**Session paused here — resume next session.** Two phases landed clean this session (Mesh-D0 red harness `2c46a83`, Mesh-D1 seed-proof codec `fdbfbfd`); D1 mechanics already in my context, no need to re-absorb. My read on the resume: D2 is the first phase that touches *live connection code* (`nethost` dial/accept), so it wants fresh focus, not a tail-end push. The codec from D1 is the thing D2 drives in-band — `SeedProofFrame::{Challenge,Proof}` over the conn, mutual verify before the conn is usable, result cached on `ConnEntry`. The crypto is done and tested; D2 is integration + the keep-alive verification, not new primitives.
</live-context>
<project-context>
**Resume pointer — start Mesh-D2 next session.** JIT-write `MESH-D2-PLAN.md` first (project convention). D2 = seed-proof in-band at every dial+accept in `nethost`, *before* the connection is usable; cache the membership verdict on `ConnEntry`; fail→drop. Old `is_trusted` gates stay running in parallel (belt+suspenders — no regression; the swap is D5). Fold in **open-item #1**: confirm/enable QUIC `keep_alive_interval` < `max_idle_timeout` in `endpoint.rs` (conns must stay multi-week so re-proof is restart/partition/rotation-only). int = a member conn establishes only when both sides prove; a non-prover is dropped. Enriches REQ-MESH-1 `impl` (already active).

**Mesh milestone arc (where D2 sits):** D0 ✅ red repro (flips green at D6) · D1 ✅ codec · **D2 ← next** seed-proof at connect · D3 roster store + tombstones + pairing seed-transfer · D4 roster propagation on-connect · D5 gate swap (`is_trusted`→`is_member`) + hard cutover (delete `peers.json`) · **D6 fan-out widen → MESH ON, D0 harness flips green** · D7 revoke/rotate/re-seed grace · D8 status-probe concurrency. Every pre-D6 phase is a no-reach-regression; D6 is the single switch. Carry-forward open items: #2 (REQ-SUBNET-5 probe loop concurrent? — D8) and #3 (self-update path node-local, not riding member conns → cutover won't deadlock — pre-D5).

**Broad status + end goal (user asked to record):** spt-core = harness-independent core for the agent ecosystem — messaging, live-agent lifecycle, terminal hosting, P2P networking, runtime-manifest harness contract; clean-room rebuild of `claude_skill_owl`. M0–M8 shipped; **v0.2.0 live on the reachable fleet**. Now in the post-M8 **subnet full-mesh** milestone (this work) — fixes "the subnet was the pairing graph, not a mesh." **End goal = v1 with the rebuilt `spt` plugin (Claude Code, separate downstream repo) working E2E on top = the acceptance proof.** Also queued on disk, untouched: `DAEMON-LIFECYCLE-PLAN.md` (service-aware daemon start/stop). `dev-freeform` carries unpushed commits (the mesh set + earlier offline-name fix + daemon-start alias) — user controls the push.
</project-context>


Communed. Self listener ingests on next poll, then deletes the file.

**Session summary — 2 phases landed, clean stops.**

| phase | commit | state |
|---|---|---|
| Mesh-D0 | `2c46a83` | red staggered+star repro harness (flips green at D6) |
| Mesh-D1 | `fdbfbfd` | membership key + mutual seed-proof codec, 8/8 units, REQ-MESH-1 `+impl +unit` |

**Resume next session at Mesh-D2** — seed-proof in-band at dial/accept, cache on `ConnEntry`, fail→drop; old `is_trusted` stays parallel (swap is D5). Fold in QUIC keep-alive verify (`endpoint.rs`). JIT-write `MESH-D2-PLAN.md` first.

Branch `dev-freeform` carries the unpushed mesh commits — push is yours to make. Owl perch healthy. Paused.