Edit

[features]
# ADR-0002: WAN transport stack is default-on in the reference binary, optional for
# embedded library consumers. D0 ships the flag + an empty gated module tree; D1
# wires iroh/mdns deps behind it.
default = ["net"]
net = []

[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 (lands with the D1+ types; declared now for the gated tree).
serde = { version = "1", features = ["derive"] }

[dev-dependencies]
tempfile = "3"

[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"]

[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"] }
# 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"] }

[dev-dependencies]
tempfile = "3"


make both calls using best judgment and proceed


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)

[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"]

[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"] }
# 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"] }

[dev-dependencies]
tempfile = "3"

[package]
name = "spt-net"
version = "0.0.0"
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. D0 ships the flag + an empty gated module tree; D1
# wires iroh/mdns deps behind it.
default = ["net"]
net = []

[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 (lands with the D1+ types; declared now for the gated tree).
serde = { version = "1", features = ["derive"] }

[dev-dependencies]
tempfile = "3"

-# embedded library consumers. D0 ships the flag + an empty gated module tree; D1

+# embedded library consumers. The `net` feature gates the whole iroh/tokio surface

+# so a `--no-default-features` lib build stays transport-free.

-# are serde-serializable (lands with the D1+ types; declared now for the gated tree).

+# 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"] }


Now the module: update net.rs to declare `endpoint`, write net/endpoint.rs.


Write

//! 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 endpoint;


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)

//! 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 endpoint;

-//! Empty at D0 — the scaffold proves the crate compiles, layers acyclically above

-//! `spt-msg`, and that the `net` feature gates the whole stack (a

-//! `--no-default-features` lib build excludes this module entirely). The Iroh

-//! endpoint, mDNS discovery, pairing, registry, sync, and update-propagation

-//! submodules land behind this gate from D1 onward (see `M4-PLAN.md` / `lib.rs`).

+//! 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).

//! The `net`-gated transport tree.
//!
//! Empty at D0 — the scaffold proves the crate compiles, layers acyclically above
//! `spt-msg`, and that the `net` feature gates the whole stack (a
//! `--no-default-features` lib build excludes this module entirely). The Iroh
//! endpoint, mDNS discovery, pairing, registry, sync, and update-propagation
//! submodules land behind this gate from D1 onward (see `M4-PLAN.md` / `lib.rs`).


net/endpoint.rs needs the self-host `RelayMap`-from-URL API for REQ-NET-2. Quick grep to build it right.


Bash

IROH="C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2"; echo "===RelayMap ctors==="; grep -rn "impl RelayMap\|pub fn from_url\|pub fn from_nodes\|pub fn from_iter\|pub fn empty\|pub fn from_url" "$IROH/src/"**/*.rs "$IROH/src/"*.rs 2>/dev/null | grep -i relaymap | head; grep -rn "pub fn from_url\|pub fn from_nodes\|pub fn empty\|pub fn insert" "$IROH/src/magicsock/relay_map"*.rs "$IROH/src/"*relay* 2>/dev/null | head; echo "===EndpointId as_bytes==="; BASE="C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-base-0.98.0"; grep -n "pub fn as_bytes\|pub type EndpointId\|pub fn public" "$BASE/src/key.rs" | head; echo "===iroh reexports (lib.rs)==="; grep -n "pub use\|pub mod" "$IROH/src/lib.rs" | grep -i "relay\|EndpointId\|SecretKey\|EndpointAddr\|RelayUrl\|RelayMap" | head

RelayMap ctor + EndpointId bytes + reexports


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


<task-notification>
<task-id>bqiax09gh</task-id>
<tool-use-id>toolu_01VvAAvHFBP86h47fxeDBiYk</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bqiax09gh.output</output-file>
<status>completed</status>
<summary>Background command "RelayMap ctor + EndpointId bytes + reexports" completed (exit code 0)</summary>
</task-notification>


1	===RelayMap ctors===
2	===EndpointId as_bytes===
3	70:pub type EndpointId = PublicKey;
4	111:    pub fn as_bytes(&self) -> &[u8; 32] {
5	299:    pub fn public(&self) -> PublicKey {
6	===iroh reexports (lib.rs)===
7	282:pub use endpoint::{Endpoint, RelayMode};
8	288:pub use iroh_relay::dns;
9	289:pub use iroh_relay::{RelayConfig, RelayMap, endpoint_info};
10

===RelayMap ctors===
===EndpointId as_bytes===
70:pub type EndpointId = PublicKey;
111:    pub fn as_bytes(&self) -> &[u8; 32] {
299:    pub fn public(&self) -> PublicKey {
===iroh reexports (lib.rs)===
282:pub use endpoint::{Endpoint, RelayMode};
288:pub use iroh_relay::dns;
289:pub use iroh_relay::{RelayConfig, RelayMap, endpoint_info};


Prompt:
<task-notification>
<task-id>bqiax09gh</task-id>
<tool-use-id>toolu_01VvAAvHFBP86h47fxeDBiYk</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bqiax09gh.output</output-file>
<status>completed</status>
<summary>Background command "RelayMap ctor + EndpointId bytes + reexports" completed (exit code 0)</summary>
</task-notification>


All APIs verified (`EndpointId::as_bytes`, `RelayMap`/`RelayMode` reexports). Naming my enum `RelayPolicy` to avoid clash with iroh's `RelayConfig`. Writing net/endpoint.rs.


Write

//! The Iroh endpoint — the node's WAN/LAN presence (D1).
//!
//! [`NetEndpoint`] wraps an `iroh::Endpoint` bound to the node's **own** Ed25519
//! identity (`spt-proto`'s [`Identity`]): the iroh `EndpointId` *is* the node's
//! public key (same 32-byte seed → same ed25519 keypair), so a node's network
//! address and its stable name (`CONTEXT.md` "SptNode") are one and the same — no
//! second identity to reconcile. Connections speak the [`SPT_NET_ALPN`] protocol.
//!
//! Relay policy ([`RelayPolicy`]) is the ADR-0002 v1 relay stance made concrete:
//! n0's public relays by default for zero-config onboarding, a self-host escape
//! hatch, and a disabled mode (LAN/air-gapped + hermetic tests). See
//! [`RELAY_DISCLOSURE`] for the plain-language disclosure that ships to users.
//!
//! API shape is pinned to iroh 0.98.2 (Spike #2 — the API churns every release).
//! This crate owns the *mechanism*; the broker (ADR-0004 §B, D4) owns the *process*
//! that holds a live endpoint across brain restarts — [`NetEndpoint::iroh`] is the
//! escape hatch the daemon uses to take ownership.

use iroh::endpoint::{presets, Connection, Incoming};
use iroh::{Endpoint, EndpointAddr, RelayMap, RelayMode, SecretKey};
use spt_proto::identity::{Identity, PublicKey};

/// The ALPN every spt-core node-to-node QUIC connection negotiates. Versioned so a
/// future wire break is a new protocol id, not a silent incompatibility.
pub const SPT_NET_ALPN: &[u8] = b"spt-core/net/0";

/// Plain-language disclosure of the soft runtime dependency on n0-operated relay
/// infrastructure (ADR-0002 v1 relay stance). Surfaced to the user by the CLI/setup
/// path; the dependency is operational + metadata-only (traffic is end-to-end
/// encrypted) and escapable via [`RelayPolicy::SelfHosted`].
// [impl->REQ-NET-2]
pub const RELAY_DISCLOSURE: &str = "\
By default spt connects through n0's free public relay servers to find your other \
machines and to assist NAT hole-punching. Relays only help peers locate and connect \
to each other — your traffic between machines is end-to-end encrypted and the relay \
cannot read it. You can run your own relay instead (self-host) or disable relays \
entirely for a LAN-only/air-gapped setup.";

/// Where this endpoint looks for relay assistance (ADR-0002). Maps onto iroh's
/// [`RelayMode`]; the default ([`RelayPolicy::N0Default`]) is the zero-config path.
// [impl->REQ-NET-2]
#[derive(Debug, Clone)]
pub enum RelayPolicy {
    /// n0's public production relays — the zero-config "just works" default.
    N0Default,
    /// Self-hosted relay(s) — the principled escape hatch from the n0 dependency.
    SelfHosted(RelayMap),
    /// No relays: direct/LAN connectivity only. Used by air-gapped LANs and the
    /// hermetic loopback tests (no network reached at all).
    Disabled,
}

/// Failures binding or dialing the endpoint. Wraps iroh's error chain as a message
/// so iroh's churning error types don't leak into this crate's public surface
/// (matches the workspace's hand-rolled, dependency-free error convention).
#[derive(Debug)]
pub enum NetError {
    /// The endpoint could not bind its UDP socket / initialise the transport.
    Bind(String),
    /// Dialing a peer failed (no path, ALPN mismatch, peer down, …).
    Connect(String),
}

impl std::fmt::Display for NetError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NetError::Bind(e) => write!(f, "endpoint bind failed: {e}"),
            NetError::Connect(e) => write!(f, "connect failed: {e}"),
        }
    }
}

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

/// A bound spt-core network endpoint: the node's reachable presence on the subnet.
pub struct NetEndpoint {
    inner: Endpoint,
}

impl NetEndpoint {
    /// Bind an endpoint to this node's identity under the given relay policy.
    ///
    /// The iroh secret key is derived from the identity's 32-byte ed25519 seed, so
    /// the resulting `EndpointId` equals the node's [`PublicKey`] — the address and
    /// the name are the same value.
    // [impl->REQ-NET-1]
    pub async fn bind(identity: &Identity, relay: RelayPolicy) -> Result<NetEndpoint, NetError> {
        // iroh and spt-proto both build the ed25519 keypair from the same 32-byte
        // seed, so this preserves the node's stable public-key name on the wire.
        let secret = SecretKey::from_bytes(&identity.seed());
        let builder = Endpoint::builder(presets::N0)
            .secret_key(secret)
            .alpns(vec![SPT_NET_ALPN.to_vec()]);
        // The N0 preset already wires the n0 relay default; only override it when the
        // policy asks for self-host or disabled.
        let builder = match relay {
            RelayPolicy::N0Default => builder,
            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
        };
        let inner = builder
            .bind()
            .await
            .map_err(|e| NetError::Bind(e.to_string()))?;
        Ok(NetEndpoint { inner })
    }

    /// This node's public key — its stable subnet name, derived from the live
    /// endpoint id. Always a valid ed25519 point (iroh guarantees it).
    // [impl->REQ-NET-1]
    pub fn node_id(&self) -> PublicKey {
        PublicKey::from_bytes(self.inner.id().as_bytes())
            .expect("an iroh EndpointId is always a valid ed25519 public key")
    }

    /// The node's stable name as lowercase hex (the `CONTEXT.md` SptNode string).
    pub fn node_id_hex(&self) -> String {
        self.node_id().to_hex()
    }

    /// This endpoint's dialable address (id + currently-known direct/relay paths).
    /// Serialise it (serde) to hand a peer a connect ticket.
    pub fn addr(&self) -> EndpointAddr {
        self.inner.addr()
    }

    /// Wait until the endpoint has registered with its relays — needed before the
    /// [`addr`](Self::addr) ticket is dialable from off-LAN. Not required for a
    /// direct/loopback dial where the peer already has a direct address.
    pub async fn online(&self) {
        self.inner.online().await;
    }

    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
    /// `None` once the endpoint is closed.
    pub async fn accept(&self) -> Option<Incoming> {
        self.inner.accept().await
    }

    /// Dial a peer by address over [`SPT_NET_ALPN`].
    // [impl->REQ-NET-1]
    pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {
        self.inner
            .connect(addr, SPT_NET_ALPN)
            .await
            .map_err(|e| NetError::Connect(e.to_string()))
    }

    /// Borrow the underlying iroh endpoint — the seam the D4 broker uses to own the
    /// endpoint + its live QUIC streams across brain restarts (ADR-0004 §B).
    pub fn iroh(&self) -> &Endpoint {
        &self.inner
    }
}

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

    // [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the
    // iroh EndpointId equals the node's public key (same seed → same keypair).
    #[tokio::test]
    async fn endpoint_id_binds_to_identity() {
        let id = Identity::generate();
        let ep = NetEndpoint::bind(&id, RelayPolicy::Disabled)
            .await
            .expect("bind");
        assert_eq!(ep.node_id(), id.public_key(), "endpoint id must equal node pubkey");
        assert_eq!(ep.node_id_hex(), id.public_key().to_hex());
    }

    // [int->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
    // QUIC with relays DISABLED — fully hermetic (no network), proving the ALPN +
    // connect/accept path. This is the local-discovery precursor; D1c adds mDNS so
    // the address ticket no longer has to be passed by hand.
    #[tokio::test]
    async fn two_endpoints_loopback_echo() {
        let server = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled)
            .await
            .expect("server bind");
        let server_addr = server.addr();

        let srv = tokio::spawn(async move {
            let incoming = server.accept().await.expect("incoming");
            let conn = incoming.accept().expect("accept").await.expect("connecting");
            let (mut send, mut recv) = conn.accept_bi().await.expect("accept_bi");
            let req = recv.read_to_end(1024).await.expect("read req");
            let mut echo = b"echo:".to_vec();
            echo.extend_from_slice(&req);
            send.write_all(&echo).await.expect("write echo");
            send.finish().expect("finish");
            conn.closed().await;
        });

        let client = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled)
            .await
            .expect("client bind");
        let conn = client.connect(server_addr).await.expect("connect");
        let (mut send, mut recv) = conn.open_bi().await.expect("open_bi");
        send.write_all(b"ping").await.expect("write ping");
        send.finish().expect("finish");
        let resp = recv.read_to_end(1024).await.expect("read resp");
        assert_eq!(resp, b"echo:ping", "bidi echo over loopback QUIC");
        conn.close(0u32.into(), b"done");

        srv.await.expect("server task joins");
    }
}


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

//! The Iroh endpoint — the node's WAN/LAN presence (D1).
//!
//! [`NetEndpoint`] wraps an `iroh::Endpoint` bound to the node's **own** Ed25519
//! identity (`spt-proto`'s [`Identity`]): the iroh `EndpointId` *is* the node's
//! public key (same 32-byte seed → same ed25519 keypair), so a node's network
//! address and its stable name (`CONTEXT.md` "SptNode") are one and the same — no
//! second identity to reconcile. Connections speak the [`SPT_NET_ALPN`] protocol.
//!
//! Relay policy ([`RelayPolicy`]) is the ADR-0002 v1 relay stance made concrete:
//! n0's public relays by default for zero-config onboarding, a self-host escape
//! hatch, and a disabled mode (LAN/air-gapped + hermetic tests). See
//! [`RELAY_DISCLOSURE`] for the plain-language disclosure that ships to users.
//!
//! API shape is pinned to iroh 0.98.2 (Spike #2 — the API churns every release).
//! This crate owns the *mechanism*; the broker (ADR-0004 §B, D4) owns the *process*
//! that holds a live endpoint across brain restarts — [`NetEndpoint::iroh`] is the
//! escape hatch the daemon uses to take ownership.

use iroh::endpoint::{presets, Connection, Incoming};
use iroh::{Endpoint, EndpointAddr, RelayMap, RelayMode, SecretKey};
use spt_proto::identity::{Identity, PublicKey};

/// The ALPN every spt-core node-to-node QUIC connection negotiates. Versioned so a
/// future wire break is a new protocol id, not a silent incompatibility.
pub const SPT_NET_ALPN: &[u8] = b"spt-core/net/0";

/// Plain-language disclosure of the soft runtime dependency on n0-operated relay
/// infrastructure (ADR-0002 v1 relay stance). Surfaced to the user by the CLI/setup
/// path; the dependency is operational + metadata-only (traffic is end-to-end
/// encrypted) and escapable via [`RelayPolicy::SelfHosted`].
// [impl->REQ-NET-2]
pub const RELAY_DISCLOSURE: &str = "\
By default spt connects through n0's free public relay servers to find your other \
machines and to assist NAT hole-punching. Relays only help peers locate and connect \
to each other — your traffic between machines is end-to-end encrypted and the relay \
cannot read it. You can run your own relay instead (self-host) or disable relays \
entirely for a LAN-only/air-gapped setup.";

/// Where this endpoint looks for relay assistance (ADR-0002). Maps onto iroh's
/// [`RelayMode`]; the default ([`RelayPolicy::N0Default`]) is the zero-config path.
// [impl->REQ-NET-2]
#[derive(Debug, Clone)]
pub enum RelayPolicy {
    /// n0's public production relays — the zero-config "just works" default.
    N0Default,
    /// Self-hosted relay(s) — the principled escape hatch from the n0 dependency.
    SelfHosted(RelayMap),
    /// No relays: direct/LAN connectivity only. Used by air-gapped LANs and the
    /// hermetic loopback tests (no network reached at all).
    Disabled,
}

/// Failures binding or dialing the endpoint. Wraps iroh's error chain as a message
/// so iroh's churning error types don't leak into this crate's public surface
/// (matches the workspace's hand-rolled, dependency-free error convention).
#[derive(Debug)]
pub enum NetError {
    /// The endpoint could not bind its UDP socket / initialise the transport.
    Bind(String),
    /// Dialing a peer failed (no path, ALPN mismatch, peer down, …).
    Connect(String),
}

impl std::fmt::Display for NetError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NetError::Bind(e) => write!(f, "endpoint bind failed: {e}"),
            NetError::Connect(e) => write!(f, "connect failed: {e}"),
        }
    }
}

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

/// A bound spt-core network endpoint: the node's reachable presence on the subnet.
pub struct NetEndpoint {
    inner: Endpoint,
}

impl NetEndpoint {
    /// Bind an endpoint to this node's identity under the given relay policy.
    ///
    /// The iroh secret key is derived from the identity's 32-byte ed25519 seed, so
    /// the resulting `EndpointId` equals the node's [`PublicKey`] — the address and
    /// the name are the same value.
    // [impl->REQ-NET-1]
    pub async fn bind(identity: &Identity, relay: RelayPolicy) -> Result<NetEndpoint, NetError> {
        // iroh and spt-proto both build the ed25519 keypair from the same 32-byte
        // seed, so this preserves the node's stable public-key name on the wire.
        let secret = SecretKey::from_bytes(&identity.seed());
        let builder = Endpoint::builder(presets::N0)
            .secret_key(secret)
            .alpns(vec![SPT_NET_ALPN.to_vec()]);
        // The N0 preset already wires the n0 relay default; only override it when the
        // policy asks for self-host or disabled.
        let builder = match relay {
            RelayPolicy::N0Default => builder,
            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
        };
        let inner = builder
            .bind()
            .await
            .map_err(|e| NetError::Bind(e.to_string()))?;
        Ok(NetEndpoint { inner })
    }

    /// This node's public key — its stable subnet name, derived from the live
    /// endpoint id. Always a valid ed25519 point (iroh guarantees it).
    // [impl->REQ-NET-1]
    pub fn node_id(&self) -> PublicKey {
        PublicKey::from_bytes(self.inner.id().as_bytes())
            .expect("an iroh EndpointId is always a valid ed25519 public key")
    }

    /// The node's stable name as lowercase hex (the `CONTEXT.md` SptNode string).
    pub fn node_id_hex(&self) -> String {
        self.node_id().to_hex()
    }

    /// This endpoint's dialable address (id + currently-known direct/relay paths).
    /// Serialise it (serde) to hand a peer a connect ticket.
    pub fn addr(&self) -> EndpointAddr {
        self.inner.addr()
    }

    /// Wait until the endpoint has registered with its relays — needed before the
    /// [`addr`](Self::addr) ticket is dialable from off-LAN. Not required for a
    /// direct/loopback dial where the peer already has a direct address.
    pub async fn online(&self) {
        self.inner.online().await;
    }

    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
    /// `None` once the endpoint is closed.
    pub async fn accept(&self) -> Option<Incoming> {
        self.inner.accept().await
    }

    /// Dial a peer by address over [`SPT_NET_ALPN`].
    // [impl->REQ-NET-1]
    pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {
        self.inner
            .connect(addr, SPT_NET_ALPN)
            .await
            .map_err(|e| NetError::Connect(e.to_string()))
    }

    /// Borrow the underlying iroh endpoint — the seam the D4 broker uses to own the
    /// endpoint + its live QUIC streams across brain restarts (ADR-0004 §B).
    pub fn iroh(&self) -> &Endpoint {
        &self.inner
    }
}

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

    // [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the
    // iroh EndpointId equals the node's public key (same seed → same keypair).
    #[tokio::test]
    async fn endpoint_id_binds_to_identity() {
        let id = Identity::generate();
        let ep = NetEndpoint::bind(&id, RelayPolicy::Disabled)
            .await
            .expect("bind");
        assert_eq!(ep.node_id(), id.public_key(), "endpoint id must equal node pubkey");
        assert_eq!(ep.node_id_hex(), id.public_key().to_hex());
    }

    // [int->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
    // QUIC with relays DISABLED — fully hermetic (no network), proving the ALPN +
    // connect/accept path. This is the local-discovery precursor; D1c adds mDNS so
    // the address ticket no longer has to be passed by hand.
    #[tokio::test]
    async fn two_endpoints_loopback_echo() {
        let server = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled)
            .await
            .expect("server bind");
        let server_addr = server.addr();

        let srv = tokio::spawn(async move {
            let incoming = server.accept().await.expect("incoming");
            let conn = incoming.accept().expect("accept").await.expect("connecting");
            let (mut send, mut recv) = conn.accept_bi().await.expect("accept_bi");
            let req = recv.read_to_end(1024).await.expect("read req");
            let mut echo = b"echo:".to_vec();
            echo.extend_from_slice(&req);
            send.write_all(&echo).await.expect("write echo");
            send.finish().expect("finish");
            conn.closed().await;
        });

        let client = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled)
            .await
            .expect("client bind");
        let conn = client.connect(server_addr).await.expect("connect");
        let (mut send, mut recv) = conn.open_bi().await.expect("open_bi");
        send.write_all(b"ping").await.expect("write ping");
        send.finish().expect("finish");
        let resp = recv.read_to_end(1024).await.expect("read resp");
        assert_eq!(resp, b"echo:ping", "bidi echo over loopback QUIC");
        conn.close(0u32.into(), b"done");

        srv.await.expect("server task joins");
    }
}


D1: Iroh endpoint + relay + mDNS discovery

Iroh endpoint bound to spt-proto Ed25519 identity (Spike #2 shape). n0 relay default + self-host knob + disclosure (REQ-NET-2). mDNS LAN discovery + connect on net ALPN. Acceptance: two local nodes discover + connect, E2E. Activates REQ-NET-1 impl/unit + REQ-NET-2.


Code written. Marking D1 in_progress, building (first iroh compile is heavy).


Updated task #3 status


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && cargo check -p spt-net 2>&1 | tail -40

cargo check spt-net with iroh (heavy first build)


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


While iroh compiles, recording Decision B (merge-model #7) in the plan — resolving the "decision pending" in D6b.


Edit

- **D6b** **Distributed precedence/freshness merge** — precedence marker carries source +
  timestamp + **node identity**; freshness = newest-and-newer-than-mine; the distributed
  generalization of 6.5 (red-team **#7**). **Merge-model decision pending** (monotonic
  per-node epochs / vector clocks / CRDT-ish / explicit conflict review) — decide at D6
  authoring with the user; two concurrent active writers = conflict, not stale-write.

- **D6b** **Distributed precedence/freshness merge** — precedence marker carries source +
  timestamp + **node identity**; freshness = newest-and-newer-than-mine; the distributed
  generalization of 6.5 (red-team **#7**). **Merge-model DECIDED (2026-06-03, best
  judgment):** per-node **monotonic epoch counters + a version vector** (one counter per
  node, persisted in branchstore). A write carries the author's vector; on receipt, if the
  incoming vector strictly dominates → accept; if dominated → drop (stale); if **concurrent**
  (neither dominates) → **surface as an explicit conflict**, never silently newest-wins.
  Rationale: text context snapshots written infrequently by ≤N nodes don't need CRDT
  machinery, but DO need correct causality + concurrent-write detection (#7's core); a
  version vector gives both cheaply and **unifies with D3b's epoch-lease (#8)** — one
  per-node monotonic epoch source serves registry leases *and* sync precedence. Wall-clock
  stays only as a human-facing tiebreaker hint inside a flagged conflict, never as the
  ordering authority. Confirm with user at D6 build start before locking the conflict-surface UX.


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

- **D6b** **Distributed precedence/freshness merge** — precedence marker carries source +
  timestamp + **node identity**; freshness = newest-and-newer-than-mine; the distributed
  generalization of 6.5 (red-team **#7**). **Merge-model decision pending** (monotonic
  per-node epochs / vector clocks / CRDT-ish / explicit conflict review) — decide at D6
  authoring with the user; two concurrent active writers = conflict, not stale-write.

- **D6b** **Distributed precedence/freshness merge** — precedence marker carries source +
  timestamp + **node identity**; freshness = newest-and-newer-than-mine; the distributed
  generalization of 6.5 (red-team **#7**). **Merge-model DECIDED (2026-06-03, best
  judgment):** per-node **monotonic epoch counters + a version vector** (one counter per
  node, persisted in branchstore). A write carries the author's vector; on receipt, if the
  incoming vector strictly dominates → accept; if dominated → drop (stale); if **concurrent**
  (neither dominates) → **surface as an explicit conflict**, never silently newest-wins.
  Rationale: text context snapshots written infrequently by ≤N nodes don't need CRDT
  machinery, but DO need correct causality + concurrent-write detection (#7's core); a
  version vector gives both cheaply and **unifies with D3b's epoch-lease (#8)** — one
  per-node monotonic epoch source serves registry leases *and* sync precedence. Wall-clock
  stays only as a human-facing tiebreaker hint inside a flagged conflict, never as the
  ordering authority. Confirm with user at D6 build start before locking the conflict-surface UX.

same-project instances. · **REQ-NET-3, REQ-INST-2, REQ-INST-5**

-  generalization of 6.5 (red-team **#7**). **Merge-model decision pending** (monotonic

-  per-node epochs / vector clocks / CRDT-ish / explicit conflict review) — decide at D6

-  authoring with the user; two concurrent active writers = conflict, not stale-write.

+  generalization of 6.5 (red-team **#7**). **Merge-model DECIDED (2026-06-03, best

+  judgment):** per-node **monotonic epoch counters + a version vector** (one counter per

+  node, persisted in branchstore). A write carries the author's vector; on receipt, if the

+  incoming vector strictly dominates → accept; if dominated → drop (stale); if **concurrent**

+  (neither dominates) → **surface as an explicit conflict**, never silently newest-wins.

+  Rationale: text context snapshots written infrequently by ≤N nodes don't need CRDT

+  machinery, but DO need correct causality + concurrent-write detection (#7's core); a

+  version vector gives both cheaply and **unifies with D3b's epoch-lease (#8)** — one

+  per-node monotonic epoch source serves registry leases *and* sync precedence. Wall-clock

+  stays only as a human-facing tiebreaker hint inside a flagged conflict, never as the

+  ordering authority. Confirm with user at D6 build start before locking the conflict-surface UX.

- **D6c** Subnet-exclusive sync honored (hidden ⟹ not synced; per-endpoint membership list).

- **Reqs:** REQ-NET-3, REQ-INST-2, REQ-INST-5; `REQ-HAZARD-DIRECT-WRITE-PRECEDENCE` (extend

coverage to multi-writer/node-id). · **Acceptance:** the mind follows the user across two


<system-reminder>Warning: the file exists but is shorter than the provided offset (1). The file has 1 lines.</system-reminder>


Build running. Waiting for compile to finish.


Bash

noop


waiting marker


<task-notification>
<task-id>baouwt33p</task-id>
<tool-use-id>toolu_01QaWf7xsou6BZ3Zjium2iCA</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\baouwt33p.output</output-file>
<status>completed</status>
<summary>Background command "cargo check spt-net with iroh (heavy first build)" completed (exit code 0)</summary>
</task-notification>


1	    Checking icu_properties v2.2.0
2	    Checking icu_normalizer v2.2.0
3	    Checking hashbrown v0.14.5
4	    Checking tracing v0.1.44
5	    Checking ed25519-dalek v3.0.0-pre.6
6	    Checking hashlink v0.9.1
7	    Checking iroh-metrics v0.38.3
8	    Checking rusqlite v0.32.1
9	    Checking idna_adapter v1.2.2
10	    Checking idna v1.1.0
11	    Checking url v2.5.8
12	    Checking spt-store v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-store)
13	    Checking hickory-proto v0.26.0-beta.4
14	    Checking iroh-base v0.98.0
15	    Checking attohttpc v0.30.1
16	    Checking spt-msg v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-msg)
17	    Checking noq-proto v0.17.0
18	    Checking rustls-platform-verifier v0.7.0
19	    Checking tokio-util v0.7.18
20	    Checking tokio-rustls v0.26.4
21	    Checking tower v0.5.3
22	    Checking backon v1.6.0
23	    Checking swarm-discovery v0.6.0-alpha.2
24	    Checking tower-http v0.6.11
25	    Checking h2 v0.4.14
26	    Checking tokio-stream v0.1.18
27	    Checking tokio-websockets v0.13.2
28	    Checking n0-watcher v0.6.1
29	    Checking iroh-dns v0.98.0
30	    Checking wmi v0.18.4
31	    Checking hyper v1.10.1
32	    Checking hickory-net v0.26.0-beta.4
33	    Checking hickory-resolver v0.26.0-beta.4
34	    Checking hyper-util v0.1.20
35	    Checking hyper-rustls v0.27.9
36	    Checking igd-next v0.17.1
37	    Checking reqwest v0.13.4
38	    Checking portmapper v0.16.0
39	    Checking spt-net v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
40	    Finished `dev` profile [unoptimized + debuginfo] target(s) in 30.06s
41

    Checking icu_properties v2.2.0
    Checking icu_normalizer v2.2.0
    Checking hashbrown v0.14.5
    Checking tracing v0.1.44
    Checking ed25519-dalek v3.0.0-pre.6
    Checking hashlink v0.9.1
    Checking iroh-metrics v0.38.3
    Checking rusqlite v0.32.1
    Checking idna_adapter v1.2.2
    Checking idna v1.1.0
    Checking url v2.5.8
    Checking spt-store v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-store)
    Checking hickory-proto v0.26.0-beta.4
    Checking iroh-base v0.98.0
    Checking attohttpc v0.30.1
    Checking spt-msg v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-msg)
    Checking noq-proto v0.17.0
    Checking rustls-platform-verifier v0.7.0
    Checking tokio-util v0.7.18
    Checking tokio-rustls v0.26.4
    Checking tower v0.5.3
    Checking backon v1.6.0
    Checking swarm-discovery v0.6.0-alpha.2
    Checking tower-http v0.6.11
    Checking h2 v0.4.14
    Checking tokio-stream v0.1.18
    Checking tokio-websockets v0.13.2
    Checking n0-watcher v0.6.1
    Checking iroh-dns v0.98.0
    Checking wmi v0.18.4
    Checking hyper v1.10.1
    Checking hickory-net v0.26.0-beta.4
    Checking hickory-resolver v0.26.0-beta.4
    Checking hyper-util v0.1.20
    Checking hyper-rustls v0.27.9
    Checking igd-next v0.17.1
    Checking reqwest v0.13.4
    Checking portmapper v0.16.0
    Checking spt-net v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 30.06s


Prompt:
<task-notification>
<task-id>baouwt33p</task-id>
<tool-use-id>toolu_01QaWf7xsou6BZ3Zjium2iCA</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\baouwt33p.output</output-file>
<status>completed</status>
<summary>Background command "cargo check spt-net with iroh (heavy first build)" completed (exit code 0)</summary>
</task-notification>


Lib compiles clean — API guesses correct. Running tests (identity-binding + loopback echo) + the no-default-features build.


cd "C:/Users/decid/Documents/projects/spt-core" && echo "===TEST===" && cargo test -p spt-net 2>&1 | tail -25; echo "===NO-DEFAULT===" && cargo check -p spt-net --no-default-features 2>&1 | tail -5

Run spt-net tests + no-default-features check


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


1	===TEST===
2

===TEST===


Compiling test binary. Awaiting result.


I'll wait for the test build to finish.


Test binary still linking iroh (heavy). I'll wait for completion rather than poll.


Test build linking. Standing by for the result notification.


<task-notification>
<task-id>b52ry34ll</task-id>
<tool-use-id>toolu_0124aCaA9A9kVQVNJqv6NXMv</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\b52ry34ll.output</output-file>
<status>completed</status>
<summary>Background command "Run spt-net tests + no-default-features check" completed (exit code 0)</summary>
</task-notification>


I'll stop polling and wait for the completion notification.


1	===TEST===
2	   Compiling hyper v1.10.1
3	   Compiling hickory-net v0.26.0-beta.4
4	   Compiling hyper-util v0.1.20
5	   Compiling hickory-resolver v0.26.0-beta.4
6	   Compiling hyper-rustls v0.27.9
7	   Compiling igd-next v0.17.1
8	   Compiling reqwest v0.13.4
9	   Compiling portmapper v0.16.0
10	   Compiling iroh v0.98.2
11	   Compiling spt-net v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
12	    Finished `test` profile [unoptimized + debuginfo] target(s) in 37.28s
13	     Running unittests src\lib.rs (target\debug\deps\spt_net-c2ef0ec1e48946d8.exe)
14
15	running 2 tests
16	test net::endpoint::tests::endpoint_id_binds_to_identity ... ok
17	test net::endpoint::tests::two_endpoints_loopback_echo ... ok
18
19	test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s
20
21	   Doc-tests spt_net
22
23	running 0 tests
24
25	test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
26
27	===NO-DEFAULT===
28	    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.17s
29

===TEST===
   Compiling hyper v1.10.1
   Compiling hickory-net v0.26.0-beta.4
   Compiling hyper-util v0.1.20
   Compiling hickory-resolver v0.26.0-beta.4
   Compiling hyper-rustls v0.27.9
   Compiling igd-next v0.17.1
   Compiling reqwest v0.13.4
   Compiling portmapper v0.16.0
   Compiling iroh v0.98.2
   Compiling spt-net v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 37.28s
     Running unittests src\lib.rs (target\debug\deps\spt_net-c2ef0ec1e48946d8.exe)

running 2 tests
test net::endpoint::tests::endpoint_id_binds_to_identity ... ok
test net::endpoint::tests::two_endpoints_loopback_echo ... ok

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

   Doc-tests spt_net

running 0 tests

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

===NO-DEFAULT===
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.17s


Prompt:
<task-notification>
<task-id>b52ry34ll</task-id>
<tool-use-id>toolu_0124aCaA9A9kVQVNJqv6NXMv</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\b52ry34ll.output</output-file>
<status>completed</status>
<summary>Background command "Run spt-net tests + no-default-features check" completed (exit code 0)</summary>
</task-notification>


Both tests pass — identity binding proven (iroh EndpointId == spt-proto pubkey) + hermetic loopback QUIC echo. no-default-features clean.

Retagging the loopback test as `unit` (project reserves `int` for cross-node E2E at D9), then activating REQ-NET-1/REQ-NET-2 in the registry.


Edit

    // [int->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
    // QUIC with relays DISABLED — fully hermetic (no network), proving the ALPN +
    // connect/accept path. This is the local-discovery precursor; D1c adds mDNS so
    // the address ticket no longer has to be passed by hand.

    // [unit->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
    // QUIC with relays DISABLED — fully hermetic (no network), proving the ALPN +
    // connect/accept path. (The cross-node WAN `int` stage is the two-host E2E at D9;
    // this hermetic proof is the component-level evidence.) D1c adds mDNS so the
    // address ticket no longer has to be passed by hand.


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

    // [unit->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
    // QUIC with relays DISABLED — fully hermetic (no network), proving the ALPN +
    // connect/accept path. (The cross-node WAN `int` stage is the two-host E2E at D9;
    // this hermetic proof is the component-level evidence.) D1c adds mDNS so the
    // address ticket no longer has to be passed by hand.

//! The Iroh endpoint — the node's WAN/LAN presence (D1).
//!
//! [`NetEndpoint`] wraps an `iroh::Endpoint` bound to the node's **own** Ed25519
//! identity (`spt-proto`'s [`Identity`]): the iroh `EndpointId` *is* the node's
//! public key (same 32-byte seed → same ed25519 keypair), so a node's network
//! address and its stable name (`CONTEXT.md` "SptNode") are one and the same — no
//! second identity to reconcile. Connections speak the [`SPT_NET_ALPN`] protocol.
//!
//! Relay policy ([`RelayPolicy`]) is the ADR-0002 v1 relay stance made concrete:
//! n0's public relays by default for zero-config onboarding, a self-host escape
//! hatch, and a disabled mode (LAN/air-gapped + hermetic tests). See
//! [`RELAY_DISCLOSURE`] for the plain-language disclosure that ships to users.
//!
//! API shape is pinned to iroh 0.98.2 (Spike #2 — the API churns every release).
//! This crate owns the *mechanism*; the broker (ADR-0004 §B, D4) owns the *process*
//! that holds a live endpoint across brain restarts — [`NetEndpoint::iroh`] is the
//! escape hatch the daemon uses to take ownership.

use iroh::endpoint::{presets, Connection, Incoming};
use iroh::{Endpoint, EndpointAddr, RelayMap, RelayMode, SecretKey};
use spt_proto::identity::{Identity, PublicKey};

/// The ALPN every spt-core node-to-node QUIC connection negotiates. Versioned so a
/// future wire break is a new protocol id, not a silent incompatibility.
pub const SPT_NET_ALPN: &[u8] = b"spt-core/net/0";

/// Plain-language disclosure of the soft runtime dependency on n0-operated relay
/// infrastructure (ADR-0002 v1 relay stance). Surfaced to the user by the CLI/setup
/// path; the dependency is operational + metadata-only (traffic is end-to-end
/// encrypted) and escapable via [`RelayPolicy::SelfHosted`].
// [impl->REQ-NET-2]
pub const RELAY_DISCLOSURE: &str = "\
By default spt connects through n0's free public relay servers to find your other \
machines and to assist NAT hole-punching. Relays only help peers locate and connect \
to each other — your traffic between machines is end-to-end encrypted and the relay \
cannot read it. You can run your own relay instead (self-host) or disable relays \
entirely for a LAN-only/air-gapped setup.";

/// Where this endpoint looks for relay assistance (ADR-0002). Maps onto iroh's
/// [`RelayMode`]; the default ([`RelayPolicy::N0Default`]) is the zero-config path.
// [impl->REQ-NET-2]
#[derive(Debug, Clone)]
pub enum RelayPolicy {
    /// n0's public production relays — the zero-config "just works" default.
    N0Default,
    /// Self-hosted relay(s) — the principled escape hatch from the n0 dependency.
    SelfHosted(RelayMap),
    /// No relays: direct/LAN connectivity only. Used by air-gapped LANs and the
    /// hermetic loopback tests (no network reached at all).
    Disabled,
}

/// Failures binding or dialing the endpoint. Wraps iroh's error chain as a message
/// so iroh's churning error types don't leak into this crate's public surface
/// (matches the workspace's hand-rolled, dependency-free error convention).
#[derive(Debug)]
pub enum NetError {
    /// The endpoint could not bind its UDP socket / initialise the transport.
    Bind(String),
    /// Dialing a peer failed (no path, ALPN mismatch, peer down, …).
    Connect(String),
}

impl std::fmt::Display for NetError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NetError::Bind(e) => write!(f, "endpoint bind failed: {e}"),
            NetError::Connect(e) => write!(f, "connect failed: {e}"),
        }
    }
}

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

/// A bound spt-core network endpoint: the node's reachable presence on the subnet.
pub struct NetEndpoint {
    inner: Endpoint,
}

impl NetEndpoint {
    /// Bind an endpoint to this node's identity under the given relay policy.
    ///
    /// The iroh secret key is derived from the identity's 32-byte ed25519 seed, so
    /// the resulting `EndpointId` equals the node's [`PublicKey`] — the address and
    /// the name are the same value.
    // [impl->REQ-NET-1]
    pub async fn bind(identity: &Identity, relay: RelayPolicy) -> Result<NetEndpoint, NetError> {
        // iroh and spt-proto both build the ed25519 keypair from the same 32-byte
        // seed, so this preserves the node's stable public-key name on the wire.
        let secret = SecretKey::from_bytes(&identity.seed());
        let builder = Endpoint::builder(presets::N0)
            .secret_key(secret)
            .alpns(vec![SPT_NET_ALPN.to_vec()]);
        // The N0 preset already wires the n0 relay default; only override it when the
        // policy asks for self-host or disabled.
        let builder = match relay {
            RelayPolicy::N0Default => builder,
            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
        };
        let inner = builder
            .bind()
            .await
            .map_err(|e| NetError::Bind(e.to_string()))?;
        Ok(NetEndpoint { inner })
    }

    /// This node's public key — its stable subnet name, derived from the live
    /// endpoint id. Always a valid ed25519 point (iroh guarantees it).
    // [impl->REQ-NET-1]
    pub fn node_id(&self) -> PublicKey {
        PublicKey::from_bytes(self.inner.id().as_bytes())
            .expect("an iroh EndpointId is always a valid ed25519 public key")
    }

    /// The node's stable name as lowercase hex (the `CONTEXT.md` SptNode string).
    pub fn node_id_hex(&self) -> String {
        self.node_id().to_hex()
    }

    /// This endpoint's dialable address (id + currently-known direct/relay paths).
    /// Serialise it (serde) to hand a peer a connect ticket.
    pub fn addr(&self) -> EndpointAddr {
        self.inner.addr()
    }

    /// Wait until the endpoint has registered with its relays — needed before the
    /// [`addr`](Self::addr) ticket is dialable from off-LAN. Not required for a
    /// direct/loopback dial where the peer already has a direct address.
    pub async fn online(&self) {
        self.inner.online().await;
    }

    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
    /// `None` once the endpoint is closed.
    pub async fn accept(&self) -> Option<Incoming> {
        self.inner.accept().await
    }

    /// Dial a peer by address over [`SPT_NET_ALPN`].
    // [impl->REQ-NET-1]
    pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {
        self.inner
            .connect(addr, SPT_NET_ALPN)
            .await
            .map_err(|e| NetError::Connect(e.to_string()))
    }

    /// Borrow the underlying iroh endpoint — the seam the D4 broker uses to own the
    /// endpoint + its live QUIC streams across brain restarts (ADR-0004 §B).
    pub fn iroh(&self) -> &Endpoint {
        &self.inner
    }
}

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

    // [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the
    // iroh EndpointId equals the node's public key (same seed → same keypair).
    #[tokio::test]
    async fn endpoint_id_binds_to_identity() {
        let id = Identity::generate();
        let ep = NetEndpoint::bind(&id, RelayPolicy::Disabled)
            .await
            .expect("bind");
        assert_eq!(ep.node_id(), id.public_key(), "endpoint id must equal node pubkey");
        assert_eq!(ep.node_id_hex(), id.public_key().to_hex());
    }

    // [int->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
    // QUIC with relays DISABLED — fully hermetic (no network), proving the ALPN +
    // connect/accept path. This is the local-discovery precursor; D1c adds mDNS so
    // the address ticket no longer has to be passed by hand.
    #[tokio::test]
    async fn two_endpoints_loopback_echo() {
        let server = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled)
            .await
            .expect("server bind");
        let server_addr = server.addr();

        let srv = tokio::spawn(async move {
            let incoming = server.accept().await.expect("incoming");
            let conn = incoming.accept().expect("accept").await.expect("connecting");
            let (mut send, mut recv) = conn.accept_bi().await.expect("accept_bi");
            let req = recv.read_to_end(1024).await.expect("read req");
            let mut echo = b"echo:".to_vec();
            echo.extend_from_slice(&req);
            send.write_all(&echo).await.expect("write echo");
            send.finish().expect("finish");
            conn.closed().await;
        });

        let client = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled)
            .await
            .expect("client bind");
        let conn = client.connect(server_addr).await.expect("connect");
        let (mut send, mut recv) = conn.open_bi().await.expect("open_bi");
        send.write_all(b"ping").await.expect("write ping");
        send.finish().expect("finish");
        let resp = recv.read_to_end(1024).await.expect("read resp");
        assert_eq!(resp, b"echo:ping", "bidi echo over loopback QUIC");
        conn.close(0u32.into(), b"done");

        srv.await.expect("server task joins");
    }
}

-    // [int->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback

+    // [unit->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback

-    // connect/accept path. This is the local-discovery precursor; D1c adds mDNS so

-    // the address ticket no longer has to be passed by hand.

+    // this hermetic proof is the component-level evidence.) D1c adds mDNS so the

+    // address ticket no longer has to be passed by hand.


id = "REQ-NET-1"
title = "WAN messaging first-class, behind default-on net feature flag"
required_stages = []

id = "REQ-NET-1"
title = "WAN messaging first-class, behind default-on net feature flag"
required_stages = ["impl", "unit"]  # activated M4-D1: spt-net NetEndpoint binds an iroh endpoint to the node's own spt-proto Ed25519 identity (EndpointId == node pubkey) behind the default-on `net` feature, with connect/accept over the SPT_NET_ALPN; unit proves identity-binding + a hermetic loopback QUIC bidi echo. The cross-node WAN `int` stage is the two-host E2E at D9 (precedent: REQ-MSG-1 int added at its E2E task)


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)

id = "REQ-NET-1"
title = "WAN messaging first-class, behind default-on net feature flag"
required_stages = []

id = "REQ-NET-1"
title = "WAN messaging first-class, behind default-on net feature flag"
required_stages = ["impl", "unit"]  # activated M4-D1: spt-net NetEndpoint binds an iroh endpoint to the node's own spt-proto Ed25519 identity (EndpointId == node pubkey) behind the default-on `net` feature, with connect/accept over the SPT_NET_ALPN; unit proves identity-binding + a hermetic loopback QUIC bidi echo. The cross-node WAN `int` stage is the two-host E2E at D9 (precedent: REQ-MSG-1 int added at its E2E task)

+required_stages = ["impl", "unit"]  # activated M4-D1: spt-net NetEndpoint binds an iroh endpoint to the node's own spt-proto Ed25519 identity (EndpointId == node pubkey) behind the default-on `net` feature, with connect/accept over the SPT_NET_ALPN; unit proves identity-binding + a hermetic loopback QUIC bidi echo. The cross-node WAN `int` stage is the two-host E2E at D9 (precedent: REQ-MSG-1 int added at its E2E task)

title = "n0 relay default + self-host knob + plain-language disclosure"


id = "REQ-NET-2"
title = "n0 relay default + self-host knob + plain-language disclosure"
required_stages = []

id = "REQ-NET-2"
title = "n0 relay default + self-host knob + plain-language disclosure"
required_stages = ["impl"]  # activated M4-D1: RelayPolicy {N0Default | SelfHosted(RelayMap) | Disabled} maps onto iroh RelayMode (n0 public relays are the zero-config default; self-host is the escape hatch; disabled = LAN/air-gapped + hermetic tests), plus the RELAY_DISCLOSURE plain-language string. Config plumbing — no unit/int stage required


id = "REQ-NET-2"
title = "n0 relay default + self-host knob + plain-language disclosure"
required_stages = []

id = "REQ-NET-2"
title = "n0 relay default + self-host knob + plain-language disclosure"
required_stages = ["impl"]  # activated M4-D1: RelayPolicy {N0Default | SelfHosted(RelayMap) | Disabled} maps onto iroh RelayMode (n0 public relays are the zero-config default; self-host is the escape hatch; disabled = LAN/air-gapped + hermetic tests), plus the RELAY_DISCLOSURE plain-language string. Config plumbing — no unit/int stage required

+required_stages = ["impl"]  # activated M4-D1: RelayPolicy {N0Default | SelfHosted(RelayMap) | Disabled} maps onto iroh RelayMode (n0 public relays are the zero-config default; self-host is the escape hatch; disabled = LAN/air-gapped + hermetic tests), plus the RELAY_DISCLOSURE plain-language string. Config plumbing — no unit/int stage required

title = "Cross-node Psyche sync over P2P replaces gh-repo-sync"


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && cargo test -p spt-net 2>&1 | tail -6; echo "===TRACE==="; traceable-reqs check >/dev/null 2>&1; echo "trace_exit=$?"; traceable-reqs check 2>&1 | grep -E "REQ-NET-1|REQ-NET-2"

Re-run tests + traceable-reqs gate for NET reqs


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


<task-notification>
<task-id>blzbovu7e</task-id>
<tool-use-id>toolu_013TQy1S7kjDjxbzFBQzD8Nm</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\blzbovu7e.output</output-file>
<status>completed</status>
<summary>Background command "Re-run tests + traceable-reqs gate for NET reqs" completed (exit code 0)</summary>
</task-notification>


1	   Doc-tests spt_net
2
3	running 0 tests
4
5	test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
6
7	===TRACE===
8	trace_exit=0
9	  [OK] REQ-NET-1  required: [impl, unit]  stages: -doc +impl +unit -int
10	  [OK] REQ-NET-2  required: [impl]  stages: -doc +impl -unit -int
11

   Doc-tests spt_net

running 0 tests

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

===TRACE===
trace_exit=0
  [OK] REQ-NET-1  required: [impl, unit]  stages: -doc +impl +unit -int
  [OK] REQ-NET-2  required: [impl]  stages: -doc +impl -unit -int


Prompt:
<task-notification>
<task-id>blzbovu7e</task-id>
<tool-use-id>toolu_013TQy1S7kjDjxbzFBQzD8Nm</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\blzbovu7e.output</output-file>
<status>completed</status>
<summary>Background command "Re-run tests + traceable-reqs gate for NET reqs" completed (exit code 0)</summary>
</task-notification>


Green: REQ-NET-1 `[impl,unit]`, REQ-NET-2 `[impl]`, trace exit 0, tests pass. Committing — plan decision (D6 merge-model) separately from the D1 code.


cd "C:/Users/decid/Documents/projects/spt-core" && git add M4-PLAN.md && git commit -q -F - <<'EOF'
docs(m4): record D6 cross-node merge-model decision (#7)

Resolve the "decision pending" in D6b: the distributed Psyche-sync merge
uses per-node monotonic epoch counters + a version vector. Incoming write
dominates -> accept; dominated -> drop as stale; concurrent (neither
dominates) -> surface as an explicit conflict, never silent newest-wins.
Closes red-team #7 (concurrent active writers = conflict, not stale-write)
and unifies with D3b's epoch-lease (#8) on one per-node monotonic epoch
source. Wall-clock demoted to a human-facing tiebreaker hint only. Final
conflict-surface UX confirmed with user at D6 build start.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
echo "A=$?"

Commit D6 merge-model decision


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


<task-notification>
<task-id>bdgmkjqad</task-id>
<tool-use-id>toolu_01PYWV885f91rYgo5tgGe3nB</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bdgmkjqad.output</output-file>
<status>completed</status>
<summary>Background command "Commit D6 merge-model decision" completed (exit code 0)</summary>
</task-notification>


1	warning: in the working copy of 'M4-PLAN.md', LF will be replaced by CRLF the next time Git touches it
2	A=0
3

warning: in the working copy of 'M4-PLAN.md', LF will be replaced by CRLF the next time Git touches it
A=0


Prompt:
<task-notification>
<task-id>bdgmkjqad</task-id>
<tool-use-id>toolu_01PYWV885f91rYgo5tgGe3nB</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bdgmkjqad.output</output-file>
<status>completed</status>
<summary>Background command "Commit D6 merge-model decision" completed (exit code 0)</summary>
</task-notification>


cd "C:/Users/decid/Documents/projects/spt-core" && git add crates/spt-net Cargo.lock traceable-reqs.toml && git commit -q -F - <<'EOF'
feat(net): M4-D1 iroh endpoint + identity binding + relay policy

Bind the first real transport: NetEndpoint wraps an iroh 0.98.2 endpoint
(pinned EXACT — the API churns every release, and 0.98.2 is the Spike #2
version validated incl. native cross-NAT hole-punch). The endpoint is keyed
by the node's OWN spt-proto Ed25519 identity — iroh derives its SecretKey
from the same 32-byte seed, so the EndpointId equals the node's public key:
a node's network address and its stable name (SptNode) are one value, no
second identity to reconcile. connect/accept speak SPT_NET_ALPN (versioned).

RelayPolicy {N0Default | SelfHosted(RelayMap) | Disabled} makes the ADR-0002
v1 relay stance concrete (n0 public relays default, self-host escape hatch,
disabled for LAN/air-gapped) + a plain-language RELAY_DISCLOSURE string.

spt-net is the workspace's first async crate (rest is thread-based/sync);
iroh + tokio are pulled in only with the default-on `net` feature, so a
`--no-default-features` lib build stays transport-free. The D4 broker takes
endpoint ownership via NetEndpoint::iroh (ADR-0004 B seam).

Tests (hermetic, relays disabled, zero network): identity-binding equality +
a two-endpoint loopback QUIC bidi echo over the ALPN. The cross-node WAN int
stage is the two-host E2E at D9.

REQ-NET-1 activated [impl,unit]; REQ-NET-2 [impl]. check green (default +
--no-default-features), tests pass, traceable-reqs check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
echo "B=$?"; git log --oneline -5

Commit D1 endpoint code


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


<task-notification>
<task-id>bq390x1f2</task-id>
<tool-use-id>toolu_01P9QqyGCTiB1khSoERzeGXN</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bq390x1f2.output</output-file>
<status>completed</status>
<summary>Background command "Commit D1 endpoint code" completed (exit code 0)</summary>
</task-notification>


1	warning: in the working copy of 'Cargo.lock', LF will be replaced by CRLF the next time Git touches it
2	warning: in the working copy of 'crates/spt-net/Cargo.toml', LF will be replaced by CRLF the next time Git touches it
3	warning: in the working copy of 'crates/spt-net/src/net.rs', LF will be replaced by CRLF the next time Git touches it
4	warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
5	warning: in the working copy of 'crates/spt-net/src/net/endpoint.rs', LF will be replaced by CRLF the next time Git touches it
6	B=0
7	3f726ed feat(net): M4-D1 iroh endpoint + identity binding + relay policy
8	c49e522 docs(m4): record D6 cross-node merge-model decision (#7)
9	c47d2c6 feat(net): M4-D0 scaffold spt-net crate
10	ca6522b docs(m4): expand M4-PLAN D-task layer at M4 start
11	a14639a docs(m3c): M3c-C4 activation sweep + M3 complete + M4 stub
12

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 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/endpoint.rs', LF will be replaced by CRLF the next time Git touches it
B=0
3f726ed feat(net): M4-D1 iroh endpoint + identity binding + relay policy
c49e522 docs(m4): record D6 cross-node merge-model decision (#7)
c47d2c6 feat(net): M4-D0 scaffold spt-net crate
ca6522b docs(m4): expand M4-PLAN D-task layer at M4 start
a14639a docs(m3c): M3c-C4 activation sweep + M3 complete + M4 stub


Prompt:
<task-notification>
<task-id>bq390x1f2</task-id>
<tool-use-id>toolu_01P9QqyGCTiB1khSoERzeGXN</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bq390x1f2.output</output-file>
<status>completed</status>
<summary>Background command "Commit D1 endpoint code" completed (exit code 0)</summary>
</task-notification>


D1a/b landed (3f726ed). Now D1c — mDNS LAN discovery. Checking the exact mDNS type path + how to build an id-only `EndpointAddr` for discovery-driven connect.


IROH="C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2"; echo "===mdns reexport path==="; grep -rn "pub mod mdns\|pub use.*[Mm]dns\|MdnsAddressLookup" "$IROH/src/address_lookup.rs" "$IROH/src/lib.rs" | head; echo "===EndpointAddr ctor from id==="; BASE="C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-base-0.98.0"; grep -rn "impl From<.*> for EndpointAddr\|pub fn new\|pub fn from_parts\|impl EndpointAddr\|pub struct EndpointAddr" "$BASE/src/"*.rs | head; echo "===connect sig==="; grep -n "pub async fn connect\b" "$IROH/src/endpoint.rs"; sed -n "$(grep -n 'pub async fn connect\b' "$IROH/src/endpoint.rs"|head -1|cut -d: -f1),+6p" "$IROH/src/endpoint.rs"

mDNS path + EndpointAddr-from-id + connect sig


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


<task-notification>
<task-id>b3a0qy0b4</task-id>
<tool-use-id>toolu_01LcdSbkYo3tp1kcDSybU8CU</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\b3a0qy0b4.output</output-file>
<status>completed</status>
<summary>Background command "mDNS path + EndpointAddr-from-id + connect sig" completed (exit code 0)</summary>
</task-notification>


Error: File does not exist. Note: your current working directory is C:\Users\decid\Documents\projects\spt-core.


Prompt:
<task-notification>
<task-id>b3a0qy0b4</task-id>
<tool-use-id>toolu_01LcdSbkYo3tp1kcDSybU8CU</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\b3a0qy0b4.output</output-file>
<status>completed</status>
<summary>Background command "mDNS path + EndpointAddr-from-id + connect sig" completed (exit code 0)</summary>
</task-notification>


1	===mdns reexport path===
2	C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/address_lookup.rs:46://! - [`address_lookup::MdnsAddressLookup`]: mdns::MdnsAddressLookup which uses the crate `swarm-discovery`, an
3	C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/address_lookup.rs:92://! To also enable [`address_lookup::MdnsAddressLookup`] it can be added as another service.
4	C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/address_lookup.rs:109://!     .address_lookup(address_lookup::MdnsAddressLookup::builder())
5	C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/address_lookup.rs:127://! [`address_lookup::MdnsAddressLookup`]: crate::address_lookup::MdnsAddressLookup
6	C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/address_lookup.rs:149:pub mod mdns;
7	C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/address_lookup.rs:156:pub use mdns::*;
8	===EndpointAddr ctor from id===
9	C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-base-0.98.0/src/endpoint_addr.rs:42:pub struct EndpointAddr {
10	C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-base-0.98.0/src/endpoint_addr.rs:91:impl EndpointAddr {
11	C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-base-0.98.0/src/endpoint_addr.rs:96:    pub fn new(id: PublicKey) -> Self {
12	C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-base-0.98.0/src/endpoint_addr.rs:104:    pub fn from_parts(id: PublicKey, addrs: impl IntoIterator<Item = TransportAddr>) -> Self {
13	C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-base-0.98.0/src/endpoint_addr.rs:155:impl From<EndpointId> for EndpointAddr {
14	C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-base-0.98.0/src/endpoint_addr.rs:292:    pub fn from_parts(id: u64, data: &[u8]) -> Self {
15	===connect sig===
16	981:    pub async fn connect(
17	    pub async fn connect(
18	        &self,
19	        endpoint_addr: impl Into<EndpointAddr>,
20	        alpn: &[u8],
21	    ) -> Result<Connection, ConnectError> {
22	        let endpoint_addr = endpoint_addr.into();
23	        let remote = endpoint_addr.id;
24

===mdns reexport path===
C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/address_lookup.rs:46://! - [`address_lookup::MdnsAddressLookup`]: mdns::MdnsAddressLookup which uses the crate `swarm-discovery`, an
C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/address_lookup.rs:92://! To also enable [`address_lookup::MdnsAddressLookup`] it can be added as another service.
C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/address_lookup.rs:109://!     .address_lookup(address_lookup::MdnsAddressLookup::builder())
C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/address_lookup.rs:127://! [`address_lookup::MdnsAddressLookup`]: crate::address_lookup::MdnsAddressLookup
C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/address_lookup.rs:149:pub mod mdns;
C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/address_lookup.rs:156:pub use mdns::*;
===EndpointAddr ctor from id===
C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-base-0.98.0/src/endpoint_addr.rs:42:pub struct EndpointAddr {
C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-base-0.98.0/src/endpoint_addr.rs:91:impl EndpointAddr {
C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-base-0.98.0/src/endpoint_addr.rs:96:    pub fn new(id: PublicKey) -> Self {
C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-base-0.98.0/src/endpoint_addr.rs:104:    pub fn from_parts(id: PublicKey, addrs: impl IntoIterator<Item = TransportAddr>) -> Self {
C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-base-0.98.0/src/endpoint_addr.rs:155:impl From<EndpointId> for EndpointAddr {
C:/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-base-0.98.0/src/endpoint_addr.rs:292:    pub fn from_parts(id: u64, data: &[u8]) -> Self {
===connect sig===
981:    pub async fn connect(
    pub async fn connect(
        &self,
        endpoint_addr: impl Into<EndpointAddr>,
        alpn: &[u8],
    ) -> Result<Connection, ConnectError> {
        let endpoint_addr = endpoint_addr.into();
        let remote = endpoint_addr.id;


mDNS path confirmed: `iroh::address_lookup::MdnsAddressLookup::builder()`; `EndpointAddr::new(id)` for id-only connect; `connect()` already takes `impl Into<EndpointAddr>`. Adding `LocalDiscovery` knob + `connect_by_id`, plus an mDNS test (`#[ignore]` for CI multicast-flakiness, validated locally now).


Edit

use iroh::endpoint::{presets, Connection, Incoming};
use iroh::{Endpoint, EndpointAddr, RelayMap, RelayMode, SecretKey};
use spt_proto::identity::{Identity, PublicKey};

use iroh::address_lookup::MdnsAddressLookup;
use iroh::endpoint::{presets, Connection, Incoming};
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMap, RelayMode, SecretKey};
use spt_proto::identity::{Identity, PublicKey};


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

use iroh::address_lookup::MdnsAddressLookup;
use iroh::endpoint::{presets, Connection, Incoming};
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMap, RelayMode, SecretKey};
use spt_proto::identity::{Identity, PublicKey};

//! The Iroh endpoint — the node's WAN/LAN presence (D1).
//!
//! [`NetEndpoint`] wraps an `iroh::Endpoint` bound to the node's **own** Ed25519
//! identity (`spt-proto`'s [`Identity`]): the iroh `EndpointId` *is* the node's
//! public key (same 32-byte seed → same ed25519 keypair), so a node's network
//! address and its stable name (`CONTEXT.md` "SptNode") are one and the same — no
//! second identity to reconcile. Connections speak the [`SPT_NET_ALPN`] protocol.
//!
//! Relay policy ([`RelayPolicy`]) is the ADR-0002 v1 relay stance made concrete:
//! n0's public relays by default for zero-config onboarding, a self-host escape
//! hatch, and a disabled mode (LAN/air-gapped + hermetic tests). See
//! [`RELAY_DISCLOSURE`] for the plain-language disclosure that ships to users.
//!
//! API shape is pinned to iroh 0.98.2 (Spike #2 — the API churns every release).
//! This crate owns the *mechanism*; the broker (ADR-0004 §B, D4) owns the *process*
//! that holds a live endpoint across brain restarts — [`NetEndpoint::iroh`] is the
//! escape hatch the daemon uses to take ownership.

use iroh::endpoint::{presets, Connection, Incoming};
use iroh::{Endpoint, EndpointAddr, RelayMap, RelayMode, SecretKey};
use spt_proto::identity::{Identity, PublicKey};

/// The ALPN every spt-core node-to-node QUIC connection negotiates. Versioned so a
/// future wire break is a new protocol id, not a silent incompatibility.
pub const SPT_NET_ALPN: &[u8] = b"spt-core/net/0";

/// Plain-language disclosure of the soft runtime dependency on n0-operated relay
/// infrastructure (ADR-0002 v1 relay stance). Surfaced to the user by the CLI/setup
/// path; the dependency is operational + metadata-only (traffic is end-to-end
/// encrypted) and escapable via [`RelayPolicy::SelfHosted`].
// [impl->REQ-NET-2]
pub const RELAY_DISCLOSURE: &str = "\
By default spt connects through n0's free public relay servers to find your other \
machines and to assist NAT hole-punching. Relays only help peers locate and connect \
to each other — your traffic between machines is end-to-end encrypted and the relay \
cannot read it. You can run your own relay instead (self-host) or disable relays \
entirely for a LAN-only/air-gapped setup.";

/// Where this endpoint looks for relay assistance (ADR-0002). Maps onto iroh's
/// [`RelayMode`]; the default ([`RelayPolicy::N0Default`]) is the zero-config path.
// [impl->REQ-NET-2]
#[derive(Debug, Clone)]
pub enum RelayPolicy {
    /// n0's public production relays — the zero-config "just works" default.
    N0Default,
    /// Self-hosted relay(s) — the principled escape hatch from the n0 dependency.
    SelfHosted(RelayMap),
    /// No relays: direct/LAN connectivity only. Used by air-gapped LANs and the
    /// hermetic loopback tests (no network reached at all).
    Disabled,
}

/// Failures binding or dialing the endpoint. Wraps iroh's error chain as a message
/// so iroh's churning error types don't leak into this crate's public surface
/// (matches the workspace's hand-rolled, dependency-free error convention).
#[derive(Debug)]
pub enum NetError {
    /// The endpoint could not bind its UDP socket / initialise the transport.
    Bind(String),
    /// Dialing a peer failed (no path, ALPN mismatch, peer down, …).
    Connect(String),
}

impl std::fmt::Display for NetError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NetError::Bind(e) => write!(f, "endpoint bind failed: {e}"),
            NetError::Connect(e) => write!(f, "connect failed: {e}"),
        }
    }
}

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

/// A bound spt-core network endpoint: the node's reachable presence on the subnet.
pub struct NetEndpoint {
    inner: Endpoint,
}

impl NetEndpoint {
    /// Bind an endpoint to this node's identity under the given relay policy.
    ///
    /// The iroh secret key is derived from the identity's 32-byte ed25519 seed, so
    /// the resulting `EndpointId` equals the node's [`PublicKey`] — the address and
    /// the name are the same value.
    // [impl->REQ-NET-1]
    pub async fn bind(identity: &Identity, relay: RelayPolicy) -> Result<NetEndpoint, NetError> {
        // iroh and spt-proto both build the ed25519 keypair from the same 32-byte
        // seed, so this preserves the node's stable public-key name on the wire.
        let secret = SecretKey::from_bytes(&identity.seed());
        let builder = Endpoint::builder(presets::N0)
            .secret_key(secret)
            .alpns(vec![SPT_NET_ALPN.to_vec()]);
        // The N0 preset already wires the n0 relay default; only override it when the
        // policy asks for self-host or disabled.
        let builder = match relay {
            RelayPolicy::N0Default => builder,
            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
        };
        let inner = builder
            .bind()
            .await
            .map_err(|e| NetError::Bind(e.to_string()))?;
        Ok(NetEndpoint { inner })
    }

    /// This node's public key — its stable subnet name, derived from the live
    /// endpoint id. Always a valid ed25519 point (iroh guarantees it).
    // [impl->REQ-NET-1]
    pub fn node_id(&self) -> PublicKey {
        PublicKey::from_bytes(self.inner.id().as_bytes())
            .expect("an iroh EndpointId is always a valid ed25519 public key")
    }

    /// The node's stable name as lowercase hex (the `CONTEXT.md` SptNode string).
    pub fn node_id_hex(&self) -> String {
        self.node_id().to_hex()
    }

    /// This endpoint's dialable address (id + currently-known direct/relay paths).
    /// Serialise it (serde) to hand a peer a connect ticket.
    pub fn addr(&self) -> EndpointAddr {
        self.inner.addr()
    }

    /// Wait until the endpoint has registered with its relays — needed before the
    /// [`addr`](Self::addr) ticket is dialable from off-LAN. Not required for a
    /// direct/loopback dial where the peer already has a direct address.
    pub async fn online(&self) {
        self.inner.online().await;
    }

    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
    /// `None` once the endpoint is closed.
    pub async fn accept(&self) -> Option<Incoming> {
        self.inner.accept().await
    }

    /// Dial a peer by address over [`SPT_NET_ALPN`].
    // [impl->REQ-NET-1]
    pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {
        self.inner
            .connect(addr, SPT_NET_ALPN)
            .await
            .map_err(|e| NetError::Connect(e.to_string()))
    }

    /// Borrow the underlying iroh endpoint — the seam the D4 broker uses to own the
    /// endpoint + its live QUIC streams across brain restarts (ADR-0004 §B).
    pub fn iroh(&self) -> &Endpoint {
        &self.inner
    }
}

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

    // [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the
    // iroh EndpointId equals the node's public key (same seed → same keypair).
    #[tokio::test]
    async fn endpoint_id_binds_to_identity() {
        let id = Identity::generate();
        let ep = NetEndpoint::bind(&id, RelayPolicy::Disabled)
            .await
            .expect("bind");
        assert_eq!(ep.node_id(), id.public_key(), "endpoint id must equal node pubkey");
        assert_eq!(ep.node_id_hex(), id.public_key().to_hex());
    }

    // [unit->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
    // QUIC with relays DISABLED — fully hermetic (no network), proving the ALPN +
    // connect/accept path. (The cross-node WAN `int` stage is the two-host E2E at D9;
    // this hermetic proof is the component-level evidence.) D1c adds mDNS so the
    // address ticket no longer has to be passed by hand.
    #[tokio::test]
    async fn two_endpoints_loopback_echo() {
        let server = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled)
            .await
            .expect("server bind");
        let server_addr = server.addr();

        let srv = tokio::spawn(async move {
            let incoming = server.accept().await.expect("incoming");
            let conn = incoming.accept().expect("accept").await.expect("connecting");
            let (mut send, mut recv) = conn.accept_bi().await.expect("accept_bi");
            let req = recv.read_to_end(1024).await.expect("read req");
            let mut echo = b"echo:".to_vec();
            echo.extend_from_slice(&req);
            send.write_all(&echo).await.expect("write echo");
            send.finish().expect("finish");
            conn.closed().await;
        });

        let client = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled)
            .await
            .expect("client bind");
        let conn = client.connect(server_addr).await.expect("connect");
        let (mut send, mut recv) = conn.open_bi().await.expect("open_bi");
        send.write_all(b"ping").await.expect("write ping");
        send.finish().expect("finish");
        let resp = recv.read_to_end(1024).await.expect("read resp");
        assert_eq!(resp, b"echo:ping", "bidi echo over loopback QUIC");
        conn.close(0u32.into(), b"done");

        srv.await.expect("server task joins");
    }
}

-use iroh::{Endpoint, EndpointAddr, RelayMap, RelayMode, SecretKey};

+use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMap, RelayMode, SecretKey};


    /// No relays: direct/LAN connectivity only. Used by air-gapped LANs and the
    /// hermetic loopback tests (no network reached at all).
    Disabled,
}

    /// No relays: direct/LAN connectivity only. Used by air-gapped LANs and the
    /// hermetic loopback tests (no network reached at all).
    Disabled,
}

/// Whether the endpoint also runs **mDNS** LAN discovery on top of the preset's n0
/// DNS discovery. With mDNS on, a node advertises itself and resolves peers by id
/// alone on the local network, so two machines on the same LAN find each other with
/// no ticket exchange and no relay round-trip ([`NetEndpoint::connect_by_id`]).
// [impl->REQ-NET-1]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalDiscovery {
    /// Advertise + resolve over mDNS (swarm-discovery) in addition to n0 DNS.
    Mdns,
    /// No mDNS — WAN discovery only (and direct addresses). Used by hermetic tests.
    Off,
}


    /// No relays: direct/LAN connectivity only. Used by air-gapped LANs and the
    /// hermetic loopback tests (no network reached at all).
    Disabled,
}

/// Whether the endpoint also runs **mDNS** LAN discovery on top of the preset's n0
/// DNS discovery. With mDNS on, a node advertises itself and resolves peers by id
/// alone on the local network, so two machines on the same LAN find each other with
/// no ticket exchange and no relay round-trip ([`NetEndpoint::connect_by_id`]).
// [impl->REQ-NET-1]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalDiscovery {
    /// Advertise + resolve over mDNS (swarm-discovery) in addition to n0 DNS.
    Mdns,
    /// No mDNS — WAN discovery only (and direct addresses). Used by hermetic tests.
    Off,
}

//! The Iroh endpoint — the node's WAN/LAN presence (D1).
//!
//! [`NetEndpoint`] wraps an `iroh::Endpoint` bound to the node's **own** Ed25519
//! identity (`spt-proto`'s [`Identity`]): the iroh `EndpointId` *is* the node's
//! public key (same 32-byte seed → same ed25519 keypair), so a node's network
//! address and its stable name (`CONTEXT.md` "SptNode") are one and the same — no
//! second identity to reconcile. Connections speak the [`SPT_NET_ALPN`] protocol.
//!
//! Relay policy ([`RelayPolicy`]) is the ADR-0002 v1 relay stance made concrete:
//! n0's public relays by default for zero-config onboarding, a self-host escape
//! hatch, and a disabled mode (LAN/air-gapped + hermetic tests). See
//! [`RELAY_DISCLOSURE`] for the plain-language disclosure that ships to users.
//!
//! API shape is pinned to iroh 0.98.2 (Spike #2 — the API churns every release).
//! This crate owns the *mechanism*; the broker (ADR-0004 §B, D4) owns the *process*
//! that holds a live endpoint across brain restarts — [`NetEndpoint::iroh`] is the
//! escape hatch the daemon uses to take ownership.

use iroh::address_lookup::MdnsAddressLookup;
use iroh::endpoint::{presets, Connection, Incoming};
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMap, RelayMode, SecretKey};
use spt_proto::identity::{Identity, PublicKey};

/// The ALPN every spt-core node-to-node QUIC connection negotiates. Versioned so a
/// future wire break is a new protocol id, not a silent incompatibility.
pub const SPT_NET_ALPN: &[u8] = b"spt-core/net/0";

/// Plain-language disclosure of the soft runtime dependency on n0-operated relay
/// infrastructure (ADR-0002 v1 relay stance). Surfaced to the user by the CLI/setup
/// path; the dependency is operational + metadata-only (traffic is end-to-end
/// encrypted) and escapable via [`RelayPolicy::SelfHosted`].
// [impl->REQ-NET-2]
pub const RELAY_DISCLOSURE: &str = "\
By default spt connects through n0's free public relay servers to find your other \
machines and to assist NAT hole-punching. Relays only help peers locate and connect \
to each other — your traffic between machines is end-to-end encrypted and the relay \
cannot read it. You can run your own relay instead (self-host) or disable relays \
entirely for a LAN-only/air-gapped setup.";

/// Where this endpoint looks for relay assistance (ADR-0002). Maps onto iroh's
/// [`RelayMode`]; the default ([`RelayPolicy::N0Default`]) is the zero-config path.
// [impl->REQ-NET-2]
#[derive(Debug, Clone)]
pub enum RelayPolicy {
    /// n0's public production relays — the zero-config "just works" default.
    N0Default,
    /// Self-hosted relay(s) — the principled escape hatch from the n0 dependency.
    SelfHosted(RelayMap),
    /// No relays: direct/LAN connectivity only. Used by air-gapped LANs and the
    /// hermetic loopback tests (no network reached at all).
    Disabled,
}

/// Failures binding or dialing the endpoint. Wraps iroh's error chain as a message
/// so iroh's churning error types don't leak into this crate's public surface
/// (matches the workspace's hand-rolled, dependency-free error convention).
#[derive(Debug)]
pub enum NetError {
    /// The endpoint could not bind its UDP socket / initialise the transport.
    Bind(String),
    /// Dialing a peer failed (no path, ALPN mismatch, peer down, …).
    Connect(String),
}

impl std::fmt::Display for NetError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NetError::Bind(e) => write!(f, "endpoint bind failed: {e}"),
            NetError::Connect(e) => write!(f, "connect failed: {e}"),
        }
    }
}

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

/// A bound spt-core network endpoint: the node's reachable presence on the subnet.
pub struct NetEndpoint {
    inner: Endpoint,
}

impl NetEndpoint {
    /// Bind an endpoint to this node's identity under the given relay policy.
    ///
    /// The iroh secret key is derived from the identity's 32-byte ed25519 seed, so
    /// the resulting `EndpointId` equals the node's [`PublicKey`] — the address and
    /// the name are the same value.
    // [impl->REQ-NET-1]
    pub async fn bind(identity: &Identity, relay: RelayPolicy) -> Result<NetEndpoint, NetError> {
        // iroh and spt-proto both build the ed25519 keypair from the same 32-byte
        // seed, so this preserves the node's stable public-key name on the wire.
        let secret = SecretKey::from_bytes(&identity.seed());
        let builder = Endpoint::builder(presets::N0)
            .secret_key(secret)
            .alpns(vec![SPT_NET_ALPN.to_vec()]);
        // The N0 preset already wires the n0 relay default; only override it when the
        // policy asks for self-host or disabled.
        let builder = match relay {
            RelayPolicy::N0Default => builder,
            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
        };
        let inner = builder
            .bind()
            .await
            .map_err(|e| NetError::Bind(e.to_string()))?;
        Ok(NetEndpoint { inner })
    }

    /// This node's public key — its stable subnet name, derived from the live
    /// endpoint id. Always a valid ed25519 point (iroh guarantees it).
    // [impl->REQ-NET-1]
    pub fn node_id(&self) -> PublicKey {
        PublicKey::from_bytes(self.inner.id().as_bytes())
            .expect("an iroh EndpointId is always a valid ed25519 public key")
    }

    /// The node's stable name as lowercase hex (the `CONTEXT.md` SptNode string).
    pub fn node_id_hex(&self) -> String {
        self.node_id().to_hex()
    }

    /// This endpoint's dialable address (id + currently-known direct/relay paths).
    /// Serialise it (serde) to hand a peer a connect ticket.
    pub fn addr(&self) -> EndpointAddr {
        self.inner.addr()
    }

    /// Wait until the endpoint has registered with its relays — needed before the
    /// [`addr`](Self::addr) ticket is dialable from off-LAN. Not required for a
    /// direct/loopback dial where the peer already has a direct address.
    pub async fn online(&self) {
        self.inner.online().await;
    }

    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
    /// `None` once the endpoint is closed.
    pub async fn accept(&self) -> Option<Incoming> {
        self.inner.accept().await
    }

    /// Dial a peer by address over [`SPT_NET_ALPN`].
    // [impl->REQ-NET-1]
    pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {
        self.inner
            .connect(addr, SPT_NET_ALPN)
            .await
            .map_err(|e| NetError::Connect(e.to_string()))
    }

    /// Borrow the underlying iroh endpoint — the seam the D4 broker uses to own the
    /// endpoint + its live QUIC streams across brain restarts (ADR-0004 §B).
    pub fn iroh(&self) -> &Endpoint {
        &self.inner
    }
}

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

    // [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the
    // iroh EndpointId equals the node's public key (same seed → same keypair).
    #[tokio::test]
    async fn endpoint_id_binds_to_identity() {
        let id = Identity::generate();
        let ep = NetEndpoint::bind(&id, RelayPolicy::Disabled)
            .await
            .expect("bind");
        assert_eq!(ep.node_id(), id.public_key(), "endpoint id must equal node pubkey");
        assert_eq!(ep.node_id_hex(), id.public_key().to_hex());
    }

    // [unit->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
    // QUIC with relays DISABLED — fully hermetic (no network), proving the ALPN +
    // connect/accept path. (The cross-node WAN `int` stage is the two-host E2E at D9;
    // this hermetic proof is the component-level evidence.) D1c adds mDNS so the
    // address ticket no longer has to be passed by hand.
    #[tokio::test]
    async fn two_endpoints_loopback_echo() {
        let server = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled)
            .await
            .expect("server bind");
        let server_addr = server.addr();

        let srv = tokio::spawn(async move {
            let incoming = server.accept().await.expect("incoming");
            let conn = incoming.accept().expect("accept").await.expect("connecting");
            let (mut send, mut recv) = conn.accept_bi().await.expect("accept_bi");
            let req = recv.read_to_end(1024).await.expect("read req");
            let mut echo = b"echo:".to_vec();
            echo.extend_from_slice(&req);
            send.write_all(&echo).await.expect("write echo");
            send.finish().expect("finish");
            conn.closed().await;
        });

        let client = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled)
            .await
            .expect("client bind");
        let conn = client.connect(server_addr).await.expect("connect");
        let (mut send, mut recv) = conn.open_bi().await.expect("open_bi");
        send.write_all(b"ping").await.expect("write ping");
        send.finish().expect("finish");
        let resp = recv.read_to_end(1024).await.expect("read resp");
        assert_eq!(resp, b"echo:ping", "bidi echo over loopback QUIC");
        conn.close(0u32.into(), b"done");

        srv.await.expect("server task joins");
    }
}

+/// Whether the endpoint also runs **mDNS** LAN discovery on top of the preset's n0

+/// DNS discovery. With mDNS on, a node advertises itself and resolves peers by id

+/// alone on the local network, so two machines on the same LAN find each other with

+/// no ticket exchange and no relay round-trip ([`NetEndpoint::connect_by_id`]).

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

+    /// Advertise + resolve over mDNS (swarm-discovery) in addition to n0 DNS.

+    /// No mDNS — WAN discovery only (and direct addresses). Used by hermetic tests.


    // [impl->REQ-NET-1]
    pub async fn bind(identity: &Identity, relay: RelayPolicy) -> Result<NetEndpoint, NetError> {
        // iroh and spt-proto both build the ed25519 keypair from the same 32-byte
        // seed, so this preserves the node's stable public-key name on the wire.
        let secret = SecretKey::from_bytes(&identity.seed());
        let builder = Endpoint::builder(presets::N0)
            .secret_key(secret)
            .alpns(vec![SPT_NET_ALPN.to_vec()]);
        // The N0 preset already wires the n0 relay default; only override it when the
        // policy asks for self-host or disabled.
        let builder = match relay {
            RelayPolicy::N0Default => builder,
            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
        };
        let inner = builder
            .bind()
            .await
            .map_err(|e| NetError::Bind(e.to_string()))?;
        Ok(NetEndpoint { inner })
    }

    // [impl->REQ-NET-1] [impl->REQ-NET-2]
    pub async fn bind(
        identity: &Identity,
        relay: RelayPolicy,
        discovery: LocalDiscovery,
    ) -> Result<NetEndpoint, NetError> {
        // iroh and spt-proto both build the ed25519 keypair from the same 32-byte
        // seed, so this preserves the node's stable public-key name on the wire.
        let secret = SecretKey::from_bytes(&identity.seed());
        let builder = Endpoint::builder(presets::N0)
            .secret_key(secret)
            .alpns(vec![SPT_NET_ALPN.to_vec()]);
        // The N0 preset already wires the n0 relay default; only override it when the
        // policy asks for self-host or disabled.
        let builder = match relay {
            RelayPolicy::N0Default => builder,
            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
        };
        // mDNS rides alongside the preset's n0 DNS discovery as an extra address
        // lookup, giving LAN-local advertise + resolve-by-id.
        let builder = match discovery {
            LocalDiscovery::Mdns => builder.address_lookup(MdnsAddressLookup::builder().advertise(true)),
            LocalDiscovery::Off => builder,
        };
        let inner = builder
            .bind()
            .await
            .map_err(|e| NetError::Bind(e.to_string()))?;
        Ok(NetEndpoint { inner })
    }