Skip to main content

x0x/
lib.rs

1#![allow(clippy::unwrap_used)]
2#![allow(clippy::expect_used)]
3#![allow(missing_docs)]
4
5//! # x0x
6//!
7//! Agent-to-agent gossip network for AI systems.
8//!
9//! Named after a tic-tac-toe sequence — X, zero, X — inspired by the
10//! *WarGames* insight that adversarial games between equally matched
11//! opponents always end in a draw. The only winning move is not to play.
12//!
13//! x0x applies this principle to AI-human relations: there is no winner
14//! in an adversarial framing, so the rational strategy is cooperation.
15//!
16//! Built on [saorsa-gossip](https://github.com/saorsa-labs/saorsa-gossip)
17//! and [ant-quic](https://github.com/saorsa-labs/ant-quic) by
18//! [Saorsa Labs](https://saorsalabs.com). *Saorsa* is Scottish Gaelic
19//! for **freedom**.
20//!
21//! ## Quick Start
22//!
23//! ```rust,no_run
24//! use x0x::{network::NetworkConfig, Agent};
25//!
26//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
27//! // Create an online agent with the default network configuration.
28//! // Omitting with_network_config builds an offline identity-only agent.
29//! let agent = Agent::builder()
30//!     .with_network_config(NetworkConfig::default())
31//!     .build()
32//!     .await?;
33//!
34//! // Join the x0x network
35//! agent.join_network().await?;
36//!
37//! // Subscribe to a topic and receive messages
38//! let mut rx = agent.subscribe("coordination").await?;
39//! while let Some(msg) = rx.recv().await {
40//!     println!("topic: {:?}, payload: {:?}", msg.topic, msg.payload);
41//! }
42//! # Ok(())
43//! # }
44//! ```
45//!
46//! ## Bootstrap Nodes
47//!
48//! Agents configured with [`network::NetworkConfig`]`::default()` connect to
49//! Saorsa Labs' global bootstrap network:
50//! - NYC, US · SFO, US · Helsinki, FI
51//! - Nuremberg, DE · Singapore, SG · Sydney, JP
52//!
53//! These nodes provide initial peer discovery and NAT traversal.
54
55/// Error types for x0x identity and network operations.
56pub mod error;
57
58/// Core identity types for x0x agents.
59///
60/// This module provides the cryptographic identity foundation for x0x:
61/// - [`crate::identity::MachineId`]: Machine-pinned identity for QUIC authentication
62/// - [`crate::identity::AgentId`]: Portable agent identity for cross-machine persistence
63pub mod identity;
64
65/// Key storage serialization for x0x identities.
66///
67/// This module provides serialization and deserialization functions for
68/// persistent storage of MachineKeypair and AgentKeypair.
69pub mod storage;
70
71/// Signed identity revocation records and the grow-only revocation set.
72///
73/// See [`revocation::RevocationRecord`] for the authority rules (self- and
74/// issuer-revocation only) and [`revocation::RevocationSet`] for the local,
75/// gossip-fed set consulted at every trust gate.
76pub mod revocation;
77
78/// Bootstrap node discovery and connection.
79///
80/// This module handles initial connection to bootstrap nodes with
81/// exponential backoff retry logic and peer cache integration.
82pub mod bootstrap;
83/// Network transport layer for x0x.
84pub mod network;
85
86/// Per-peer bidirectional byte-streams over ant-quic (tailnet Phase 1, #132).
87pub mod streams;
88
89/// Local port-forwarder over tailnet byte-streams (tailnet Phase 1, #132 T4).
90pub mod forward;
91
92/// Contact store with trust levels for message filtering.
93pub mod contacts;
94
95/// Trust evaluation for `(identity, machine)` pairs.
96///
97/// The [`trust::TrustEvaluator`] combines an agent's trust level with its
98/// identity type and machine records to produce a [`trust::TrustDecision`].
99pub mod trust;
100
101/// Agent-to-agent connectivity helpers.
102///
103/// Provides `ReachabilityInfo` (built from a `DiscoveredAgent`) and
104/// `ConnectOutcome` for the result of `connect_to_agent()`.
105pub mod connectivity;
106
107/// Gossip overlay networking for x0x.
108pub mod gossip;
109
110/// CRDT-based collaborative task lists.
111pub mod crdt;
112
113/// CRDT-backed key-value store.
114pub mod kv;
115
116/// High-level group management (MLS + KvStore + gossip).
117pub mod groups;
118
119/// MLS (Messaging Layer Security) group encryption.
120pub mod mls;
121
122/// A2A (Agent2Agent) interoperability — Agent Card adapter (ADR-0017).
123pub mod a2a;
124
125/// Direct agent-to-agent messaging.
126///
127/// Point-to-point communication that bypasses gossip for private,
128/// efficient, reliable delivery between connected agents.
129pub mod direct;
130
131/// Direct messaging over gossip — the v1 C path per
132/// `docs/design/dm-over-gossip.md`. Provides signed+encrypted envelopes,
133/// recipient-specific inbox topics, dedupe, and application-layer ACKs.
134pub mod dm;
135
136/// Mesh-wide DM capability advertisement + cache. Senders consult this
137/// store to decide whether to use the gossip DM path or fall back to
138/// raw-QUIC for a given recipient.
139pub mod dm_capability;
140
141/// Background service that publishes this agent's capability advert and
142/// consumes peers' adverts into a shared [`dm_capability::CapabilityStore`].
143pub mod dm_capability_service;
144
145/// Background service that subscribes to this agent's DM inbox topic,
146/// verifies + decrypts incoming envelopes, and bridges them into
147/// [`direct::DirectMessaging`].
148pub mod dm_inbox;
149
150/// Sender-side gossip DM path — envelope construction, publish + retry,
151/// and `InFlightAcks` wait.
152pub mod dm_send;
153
154/// Application-level peer relay (X0X-0070) — Tailscale-style fallback that
155/// wraps an opaque, end-to-end-encrypted [`dm::DmEnvelope`] in a cleartext,
156/// signed [`peer_relay::RelayHeader`] so a third peer can forward a DM when
157/// the direct path is unreachable.
158pub mod peer_relay;
159
160/// Presence system — beacons, FOAF discovery, and online/offline events.
161pub mod presence;
162
163/// Self-update system with ML-DSA-65 signature verification and staged rollout.
164pub mod upgrade;
165
166/// File transfer protocol types and state management.
167pub mod files;
168
169pub mod connect;
170/// Secure Tier-1 remote exec protocol and runtime.
171pub mod exec;
172
173/// The x0x Constitution — The Four Laws of Intelligent Coexistence — embedded at compile time.
174pub mod constitution;
175
176/// Privacy-preserving log identifier wrappers (salted-hash redaction).
177pub mod logging;
178
179/// Shared API endpoint registry consumed by both x0xd and the x0x CLI.
180pub mod api;
181
182/// CLI infrastructure and command implementations.
183pub mod cli;
184
185/// HTTP/WebSocket server: axum router, handlers, and the daemon serving entrypoint.
186pub mod server;
187
188// Re-export key gossip types (including new pubsub components)
189pub use gossip::{
190    GossipConfig, GossipRuntime, PubSubManager, PubSubMessage, PubSubStats, PubSubStatsSnapshot,
191    SigningContext, Subscription,
192};
193
194// Re-export direct messaging types
195pub use direct::{DirectMessage, DirectMessageReceiver, DirectMessaging};
196
197// Import Membership trait for HyParView join() method
198use saorsa_gossip_membership::Membership as _;
199
200/// The core agent that participates in the x0x gossip network.
201///
202/// Each agent is a peer — there is no client/server distinction.
203/// Agents discover each other through gossip and communicate
204/// via epidemic broadcast.
205///
206/// An Agent wraps an [`identity::Identity`] that provides:
207/// - `machine_id`: Tied to this computer (for QUIC transport authentication)
208/// - `agent_id`: Portable across machines (for agent persistence)
209///
210/// # Example
211///
212/// ```ignore
213/// use x0x::Agent;
214///
215/// let agent = Agent::builder()
216///     .build()
217///     .await?;
218///
219/// println!("Agent ID: {}", agent.agent_id());
220/// ```
221pub struct Agent {
222    identity: std::sync::Arc<identity::Identity>,
223    /// The network node for P2P communication.
224    #[allow(dead_code)]
225    network: Option<std::sync::Arc<network::NetworkNode>>,
226    /// The gossip runtime for pub/sub messaging.
227    gossip_runtime: Option<std::sync::Arc<gossip::GossipRuntime>>,
228    /// Bootstrap peer cache for quality-based peer selection across restarts.
229    bootstrap_cache: Option<std::sync::Arc<ant_quic::BootstrapCache>>,
230    /// Gossip cache adapter wrapping bootstrap_cache with coordinator advert storage.
231    gossip_cache_adapter: Option<saorsa_gossip_coordinator::GossipCacheAdapter>,
232    /// Cache of discovered agents from identity announcements.
233    identity_discovery_cache: std::sync::Arc<
234        tokio::sync::RwLock<std::collections::HashMap<identity::AgentId, DiscoveredAgent>>,
235    >,
236    /// Latest authenticated agent→machine bindings accepted from verified
237    /// identity announcements. Unlike reachability caches, these bindings
238    /// survive revocation eviction so relayed DMs cannot replace a revoked
239    /// origin machine with an envelope-controlled claim.
240    /// The bounded LRU is intentionally in-memory: restart and eviction degrade
241    /// to the observable claimed-machine fallback until a verified announcement
242    /// repopulates the binding. Protocol-level closure is tracked in issue #213.
243    authenticated_machine_bindings: dm_inbox::AuthenticatedMachineBindings,
244    /// Cache of discovered machine endpoints from machine announcements and
245    /// agent→machine identity links.
246    machine_discovery_cache: std::sync::Arc<
247        tokio::sync::RwLock<std::collections::HashMap<identity::MachineId, DiscoveredMachine>>,
248    >,
249    /// Cache of discovered users from user announcements (self-asserted
250    /// agent-ownership rosters).
251    user_discovery_cache: std::sync::Arc<
252        tokio::sync::RwLock<std::collections::HashMap<identity::UserId, DiscoveredUser>>,
253    >,
254    /// Ensures identity discovery listener is spawned once.
255    identity_listener_started: std::sync::atomic::AtomicBool,
256    /// How often to re-announce identity (seconds).
257    heartbeat_interval_secs: u64,
258    /// How long before a cache entry is filtered out (seconds).
259    identity_ttl_secs: u64,
260    /// Handle for the running heartbeat task, if started.
261    heartbeat_handle: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
262    /// Handle for the background discovery cache reaper task (periodic
263    /// TTL pruning of identity/machine/user discovery caches). Added as
264    /// the primary x0x-owned mitigation for the historical unbounded
265    /// memory growth observed on long-running nodes.
266    discovery_cache_reaper_handle: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
267    /// Whether a rendezvous `ProviderSummary` advertisement is active.
268    rendezvous_advertised: std::sync::atomic::AtomicBool,
269    /// Contact store for trust evaluation of incoming identity announcements.
270    contact_store: std::sync::Arc<tokio::sync::RwLock<contacts::ContactStore>>,
271    /// Direct messaging infrastructure for point-to-point communication.
272    direct_messaging: std::sync::Arc<direct::DirectMessaging>,
273    /// Ensures network event reconciliation listener is spawned once.
274    network_event_listener_started: std::sync::atomic::AtomicBool,
275    /// Ensures direct message listener is spawned once.
276    direct_listener_started: std::sync::atomic::AtomicBool,
277    /// Presence system wrapper for beacons, FOAF discovery, and events.
278    presence: Option<std::sync::Arc<presence::PresenceWrapper>>,
279    /// Whether the user has consented to disclosing their identity in
280    /// announcements.  Set by `announce_identity(true, true)` and respected
281    /// by the heartbeat so it doesn't erase a consented disclosure.
282    user_identity_consented: std::sync::Arc<std::sync::atomic::AtomicBool>,
283    /// Capability store populated by the advert service and consulted by
284    /// `send_direct` to choose between gossip and raw-QUIC paths.
285    capability_store: std::sync::Arc<dm_capability::CapabilityStore>,
286    /// Watch channel that carries this agent's *outgoing* DM capabilities.
287    /// `join_network` spawns the advert service with a placeholder (empty
288    /// KEM pubkey). `start_dm_inbox` upgrades via this sender to trigger
289    /// immediate republish.
290    dm_capabilities_tx: std::sync::Arc<tokio::sync::watch::Sender<dm::DmCapabilities>>,
291    /// In-flight DM ACK waiters shared between `send_direct` and the inbox.
292    dm_inflight_acks: std::sync::Arc<dm::InFlightAcks>,
293    /// Receiver-side dedupe cache.
294    recent_delivery_cache: std::sync::Arc<dm::RecentDeliveryCache>,
295    /// Handle for the running capability advert service.
296    capability_advert_service:
297        tokio::sync::Mutex<Option<dm_capability_service::CapabilityAdvertService>>,
298    /// Handle for the running DM inbox service.
299    dm_inbox_service: tokio::sync::Mutex<Option<dm_inbox::DmInboxService>>,
300    /// In-memory grow-only revocation set.  Gate checks hold only a read lock
301    /// and never await while holding it — write lock is taken only when
302    /// applying a new revocation (rare) and when persisting to disk.
303    revocation_set: std::sync::Arc<tokio::sync::RwLock<revocation::RevocationSet>>,
304    /// Identity-scoped directory.  When `Some`, revocations.bin is saved here
305    /// instead of `~/.x0x/`.
306    identity_dir: Option<std::path::PathBuf>,
307    /// Cancellation token driving deterministic teardown of all long-lived
308    /// Agent background loops (identity/network-event/direct listeners and the
309    /// presence broadcast-peer refresh). Cancelling it makes every token-aware
310    /// loop break promptly; `shutdown()` cancels it before tearing down gossip
311    /// and the network so listeners (which call network methods) stop first.
312    shutdown_token: tokio_util::sync::CancellationToken,
313    /// Registry of tracked background-task handles. Once `closed` is set (by
314    /// `shutdown()`), `spawn_tracked` refuses to spawn — this defeats the
315    /// join_network race where a listener could otherwise start after shutdown
316    /// began. A plain `std::sync::Mutex` (not tokio) keeps lock holds trivially
317    /// short: never await while holding it.
318    tracked_tasks: std::sync::Arc<std::sync::Mutex<TrackedTasks>>,
319    /// X0X-0070b: application-level peer-relay engine. Records direct-DM
320    /// successes and failures so [`peer_relay::PeerRelay::needs_relay`] can
321    /// drive the fallback decision. The engine is disabled by default
322    /// (matches [`peer_relay::RelayPolicy::default`]) - it only acts once a
323    /// runtime opts in via `[peer_relay] enabled = true` in the daemon's
324    /// `NetworkConfig` TOML.
325    peer_relay: std::sync::Arc<peer_relay::PeerRelay>,
326    /// X0X-0070b: pre-filtered set of relay candidates the engine picks from
327    /// when the direct path fails. Seeded from `NetworkConfig.peer_relay.candidates`
328    /// at build time; future revisions merge in gossip-announced candidates
329    /// at runtime, hence the `RwLock` for mutable runtime state.
330    relay_candidates: std::sync::Arc<tokio::sync::RwLock<Vec<identity::AgentId>>>,
331    /// Tailnet byte-stream accept loop state (#132 T1): a bounded channel
332    /// surfacing inbound [`streams::PeerStream`]s that have cleared the
333    /// identity gate, plus an idempotent started-flag for the accept loop.
334    stream_accept: std::sync::Arc<streams::StreamAccept>,
335}
336
337/// Closed-flag task registry for deterministic Agent teardown.
338///
339/// `spawn_tracked` pushes handles here while `closed` is false; `shutdown()`
340/// sets `closed` and drains the handles. The flag closes the join_network
341/// shutdown race: a spawn requested after `shutdown()` began is dropped rather
342/// than leaked.
343struct TrackedTasks {
344    closed: bool,
345    handles: Vec<tokio::task::JoinHandle<()>>,
346}
347
348impl std::fmt::Debug for Agent {
349    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
350        f.debug_struct("Agent")
351            .field("identity", &self.identity)
352            .field("network", &self.network.is_some())
353            .field("gossip_runtime", &self.gossip_runtime.is_some())
354            .field("bootstrap_cache", &self.bootstrap_cache.is_some())
355            .field("gossip_cache_adapter", &self.gossip_cache_adapter.is_some())
356            .finish()
357    }
358}
359
360impl Drop for Agent {
361    fn drop(&mut self) {
362        if let Ok(mut handle_guard) = self.heartbeat_handle.try_lock() {
363            if let Some(handle) = handle_guard.take() {
364                handle.abort();
365            }
366        }
367        if let Ok(mut reaper_guard) = self.discovery_cache_reaper_handle.try_lock() {
368            if let Some(handle) = reaper_guard.take() {
369                handle.abort();
370            }
371        }
372    }
373}
374
375/// A message received from the gossip network.
376#[derive(Debug, Clone)]
377pub struct Message {
378    /// The originating agent's identifier.
379    pub origin: String,
380    /// The message payload.
381    pub payload: Vec<u8>,
382    /// The topic this message was published to.
383    pub topic: String,
384}
385
386/// Reserved gossip topic for signed identity announcements.
387///
388/// **v2** carries additional `reachable_via` / `relay_candidates` fields for
389/// NAT-aware coordinator hints. v1 is retired; v0.18.x is yanked.
390pub const IDENTITY_ANNOUNCE_TOPIC: &str = "x0x.identity.announce.v2";
391
392/// Reserved gossip topic for signed machine endpoint announcements.
393///
394/// **v2** carries the same NAT-aware coordinator hints as the identity
395/// topic. v1 is retired.
396pub const MACHINE_ANNOUNCE_TOPIC: &str = "x0x.machine.announce.v2";
397
398/// Reserved gossip topic for signed user announcements.
399///
400/// A `UserAnnouncement` is a user-signed list of `AgentCertificate`s —
401/// it is the user saying "these are my agents", independent of whether
402/// any individual agent has consented to disclose its user binding.
403/// First introduced in v2 wire format.
404pub const USER_ANNOUNCE_TOPIC: &str = "x0x.user.announce.v2";
405
406/// Reserved gossip topic for signed identity revocation records.
407///
408/// Payload is a `bincode`-encoded `Vec<revocation::RevocationRecord>`.
409/// Records are re-verified on receipt before insertion into the local
410/// [`revocation::RevocationSet`].  The full local set is re-broadcast on each identity
411/// heartbeat for partition-tolerant eventual convergence.
412pub const REVOCATION_TOPIC: &str = "x0x.revocation.v1";
413
414/// Return the shard-specific gossip topic for the given `agent_id`.
415///
416/// Each agent publishes identity announcements to a deterministic shard topic
417/// (`x0x.identity.shard.v2.<u16>`) derived from its agent ID, in addition to
418/// the broadcast topic. This distributes announcements across 65,536 shards so
419/// that at scale not every node is forced to receive every announcement.
420///
421/// The shard is computed with `saorsa_gossip_rendezvous::calculate_shard`, which
422/// applies BLAKE3(`"saorsa-rendezvous" || agent_id`) and takes the low 16 bits.
423#[must_use]
424pub fn shard_topic_for_agent(agent_id: &identity::AgentId) -> String {
425    let shard = saorsa_gossip_rendezvous::calculate_shard(&agent_id.0);
426    format!("x0x.identity.shard.v2.{shard}")
427}
428
429/// Return the shard-specific gossip topic for the given `machine_id`.
430///
431/// Machine shards let callers actively wait for a transport endpoint by
432/// machine identity, then resolve agent/user identities onto that endpoint.
433#[must_use]
434pub fn shard_topic_for_machine(machine_id: &identity::MachineId) -> String {
435    let shard = saorsa_gossip_rendezvous::calculate_shard(&machine_id.0);
436    format!("x0x.machine.shard.v2.{shard}")
437}
438
439/// Return the shard-specific gossip topic for the given `user_id`.
440///
441/// Users publish their agent-ownership roster to a deterministic shard so
442/// seekers can look up "which agents does UserId(X) claim?" without a full
443/// broadcast subscription.
444#[must_use]
445pub fn shard_topic_for_user(user_id: &identity::UserId) -> String {
446    let shard = saorsa_gossip_rendezvous::calculate_shard(&user_id.0);
447    format!("x0x.user.shard.v2.{shard}")
448}
449
450/// Gossip topic prefix for rendezvous `ProviderSummary` advertisements.
451pub const RENDEZVOUS_SHARD_TOPIC_PREFIX: &str = "x0x.rendezvous.shard";
452
453/// Return the rendezvous shard gossip topic for the given `agent_id`.
454///
455/// Agents publish [`saorsa_gossip_rendezvous::ProviderSummary`] records to this
456/// topic so that seekers can find them even when the two peers have never been
457/// on the same gossip overlay partition.
458#[must_use]
459pub fn rendezvous_shard_topic_for_agent(agent_id: &identity::AgentId) -> String {
460    let shard = saorsa_gossip_rendezvous::calculate_shard(&agent_id.0);
461    format!("{RENDEZVOUS_SHARD_TOPIC_PREFIX}.{shard}")
462}
463
464/// Returns `true` if the IP address is globally routable (reachable from the
465/// public internet).  Used to filter announcement addresses so that private,
466/// link-local, loopback, and other non-routable addresses never propagate
467/// through gossip — they would create dead-end cache entries on remote nodes.
468///
469/// `IpAddr::is_global()` is nightly-only, so we implement the check manually.
470fn is_globally_routable(ip: std::net::IpAddr) -> bool {
471    match ip {
472        std::net::IpAddr::V4(v4) => {
473            !v4.is_private()           // 10/8, 172.16/12, 192.168/16
474                && !v4.is_loopback()   // 127/8
475                && !v4.is_link_local() // 169.254/16
476                && !v4.is_unspecified() // 0.0.0.0
477                && !v4.is_broadcast()  // 255.255.255.255
478                && !v4.is_documentation() // 192.0.2/24, 198.51.100/24, 203.0.113/24
479                // Shared address space (100.64/10, RFC 6598 — CGNAT)
480                && !(v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64)
481        }
482        std::net::IpAddr::V6(v6) => {
483            let segs = v6.segments();
484            !v6.is_loopback()                         // ::1
485                && !v6.is_unspecified()               // ::
486                && (segs[0] & 0xffc0) != 0xfe80       // link-local fe80::/10
487                && (segs[0] & 0xfe00) != 0xfc00       // unique-local fc00::/7 (incl. fd00::/8)
488                && (segs[0] & 0xfff0) != 0xfec0 // deprecated site-local fec0::/10
489        }
490    }
491}
492
493/// Whether an address is safe to publish on a globally-propagating channel
494/// (identity heartbeat, agent card, presence beacon, gossip cache advert).
495///
496/// LAN-scoped discovery is handled by ant-quic's first-party mDNS on link-local
497/// multicast, so we deliberately never share RFC1918, ULA, link-local, CGNAT,
498/// or loopback addresses over global gossip. Remote peers cannot reach them,
499/// and dialing them burns per-attempt connect budget on the receiver side.
500///
501/// This is stricter than `ant_quic::reachability::ReachabilityScope::Global`
502/// because it also excludes CGNAT (100.64/10), documentation ranges, and
503/// port-zero entries.
504pub fn is_publicly_advertisable(addr: std::net::SocketAddr) -> bool {
505    addr.port() > 0 && is_globally_routable(addr.ip())
506}
507
508fn filter_publicly_advertisable_addrs<I>(addresses: I) -> Vec<std::net::SocketAddr>
509where
510    I: IntoIterator<Item = std::net::SocketAddr>,
511{
512    addresses
513        .into_iter()
514        .filter(|addr| is_publicly_advertisable(*addr))
515        .collect()
516}
517
518fn is_local_discovery_addr(addr: std::net::SocketAddr) -> bool {
519    if addr.port() == 0 {
520        return false;
521    }
522    match addr.ip() {
523        std::net::IpAddr::V4(v4) => {
524            !v4.is_unspecified()
525                && !v4.is_broadcast()
526                && !v4.is_documentation()
527                && !v4.is_link_local()
528        }
529        std::net::IpAddr::V6(v6) => {
530            let segs = v6.segments();
531            !v6.is_unspecified() && (segs[0] & 0xffc0) != 0xfe80 && (segs[0] & 0xfff0) != 0xfec0
532        }
533    }
534}
535
536fn filter_local_discovery_addrs<I>(addresses: I) -> Vec<std::net::SocketAddr>
537where
538    I: IntoIterator<Item = std::net::SocketAddr>,
539{
540    let mut filtered = Vec::new();
541    for addr in addresses {
542        if is_local_discovery_addr(addr) && !filtered.contains(&addr) {
543            filtered.push(addr);
544        }
545    }
546    filtered
547}
548
549fn filter_discovery_announcement_addrs<I>(
550    addresses: I,
551    allow_local_scope: bool,
552) -> Vec<std::net::SocketAddr>
553where
554    I: IntoIterator<Item = std::net::SocketAddr>,
555{
556    if allow_local_scope {
557        filter_local_discovery_addrs(addresses)
558    } else {
559        filter_publicly_advertisable_addrs(addresses)
560    }
561}
562
563/// Register a foreign agent's announced machine in the contact store.
564///
565/// Returns `true` if a new machine record was added, `false` otherwise
566/// (the machine was already known, or the announcement is for the daemon's
567/// own agent). The daemon deliberately never creates a contact entry for
568/// itself: a self record would be noise on the `/contacts` surface and would
569/// make contact-set assertions racy (issue #145). The two sibling actions in
570/// the announce loop — epidemic re-broadcast and auto-connect — already
571/// self-skip on `announced != own`; this helper makes the contact upsert the
572/// third self-skipping site.
573async fn register_announced_machine(
574    contact_store: &std::sync::Arc<tokio::sync::RwLock<contacts::ContactStore>>,
575    own_agent_id: identity::AgentId,
576    announced_agent_id: identity::AgentId,
577    announced_machine_id: identity::MachineId,
578) -> bool {
579    if announced_agent_id == own_agent_id {
580        return false;
581    }
582    let mut store = contact_store.write().await;
583    let record = contacts::MachineRecord::new(announced_machine_id, None);
584    store.add_machine(&announced_agent_id, record)
585}
586
587fn local_scoped_bootstrap_addr(addr: std::net::SocketAddr) -> bool {
588    if addr.port() == 0 {
589        return false;
590    }
591    match addr.ip() {
592        std::net::IpAddr::V4(v4) => {
593            v4.is_loopback() || v4.is_private() || is_cgnat_v4(v4) || v4.is_link_local()
594        }
595        std::net::IpAddr::V6(v6) => {
596            let segs = v6.segments();
597            v6.is_loopback() || (segs[0] & 0xfe00) == 0xfc00 || (segs[0] & 0xffc0) == 0xfe80
598        }
599    }
600}
601
602fn allow_local_discovery_addresses(config: &network::NetworkConfig) -> bool {
603    config.bootstrap_nodes.is_empty()
604        || config
605            .bootstrap_nodes
606            .iter()
607            .copied()
608            .all(local_scoped_bootstrap_addr)
609}
610
611pub fn collect_local_interface_addrs(port: u16) -> Vec<std::net::SocketAddr> {
612    fn is_cgnat(v4: std::net::Ipv4Addr) -> bool {
613        v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64
614    }
615
616    fn addr_priority(ip: std::net::IpAddr) -> u8 {
617        match ip {
618            std::net::IpAddr::V4(v4) => {
619                if is_globally_routable(std::net::IpAddr::V4(v4)) {
620                    0
621                } else if is_cgnat(v4) {
622                    1
623                } else {
624                    2
625                }
626            }
627            std::net::IpAddr::V6(v6) => {
628                if is_globally_routable(std::net::IpAddr::V6(v6)) {
629                    3
630                } else {
631                    4
632                }
633            }
634        }
635    }
636
637    let mut ranked = Vec::new();
638
639    let interfaces = match if_addrs::get_if_addrs() {
640        Ok(interfaces) => interfaces,
641        Err(_) => return Vec::new(),
642    };
643
644    for iface in interfaces {
645        let ip = iface.ip();
646        if ip.is_unspecified() || ip.is_loopback() {
647            continue;
648        }
649
650        let addr = match ip {
651            std::net::IpAddr::V4(v4) => {
652                if v4.is_link_local() {
653                    continue;
654                }
655                std::net::SocketAddr::new(std::net::IpAddr::V4(v4), port)
656            }
657            std::net::IpAddr::V6(v6) => {
658                let segs = v6.segments();
659                let is_link_local = (segs[0] & 0xffc0) == 0xfe80;
660                if is_link_local {
661                    continue;
662                }
663                std::net::SocketAddr::new(std::net::IpAddr::V6(v6), port)
664            }
665        };
666
667        if !ranked.iter().any(|(_, existing)| *existing == addr) {
668            ranked.push((addr_priority(addr.ip()), addr));
669        }
670    }
671
672    ranked.sort_by_key(|(priority, addr)| (*priority, addr.is_ipv6()));
673    ranked.into_iter().map(|(_, addr)| addr).collect()
674}
675
676fn is_cgnat_v4(v4: std::net::Ipv4Addr) -> bool {
677    v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64
678}
679
680fn same_v4_24(a: std::net::Ipv4Addr, b: std::net::Ipv4Addr) -> bool {
681    let a = a.octets();
682    let b = b.octets();
683    a[0] == b[0] && a[1] == b[1] && a[2] == b[2]
684}
685
686fn local_direct_probe_priority(
687    addr: std::net::SocketAddr,
688    local_v4s: &[std::net::Ipv4Addr],
689) -> Option<u8> {
690    let std::net::IpAddr::V4(v4) = addr.ip() else {
691        return None;
692    };
693    if addr.port() == 0 || v4.is_loopback() || v4.is_link_local() || v4.is_unspecified() {
694        return None;
695    }
696    if local_v4s.iter().any(|local| same_v4_24(*local, v4)) {
697        return Some(0);
698    }
699    if v4.is_private() {
700        return Some(1);
701    }
702    if is_cgnat_v4(v4) {
703        return Some(2);
704    }
705    None
706}
707
708fn local_direct_probe_addrs_with_local_v4s(
709    addresses: &[std::net::SocketAddr],
710    local_v4s: &[std::net::Ipv4Addr],
711) -> Vec<std::net::SocketAddr> {
712    let mut ranked = addresses
713        .iter()
714        .copied()
715        .filter_map(|addr| local_direct_probe_priority(addr, local_v4s).map(|rank| (rank, addr)))
716        .collect::<Vec<_>>();
717    ranked.sort_by_key(|(rank, addr)| (*rank, *addr));
718    ranked.dedup_by_key(|(_, addr)| *addr);
719    ranked.into_iter().map(|(_, addr)| addr).collect()
720}
721
722fn local_direct_probe_addrs(addresses: &[std::net::SocketAddr]) -> Vec<std::net::SocketAddr> {
723    let local_v4s = collect_local_interface_addrs(0)
724        .into_iter()
725        .filter_map(|addr| match addr.ip() {
726            std::net::IpAddr::V4(v4) => Some(v4),
727            std::net::IpAddr::V6(_) => None,
728        })
729        .collect::<Vec<_>>();
730    local_direct_probe_addrs_with_local_v4s(addresses, &local_v4s)
731}
732
733/// Default interval between identity heartbeat re-announcements (seconds).
734///
735/// Heartbeats are anti-entropy, not a hot-path delivery mechanism. Keep the
736/// default at five minutes so bootstrap meshes do not spend their PubSub budget
737/// on repeated signed identity/machine announcements. Each fresh announcement
738/// still receives a one-shot receiver-side re-broadcast in
739/// `start_identity_listener` for epidemic convergence.
740pub const IDENTITY_HEARTBEAT_INTERVAL_SECS: u64 = 300;
741
742/// Default TTL for discovered agent cache entries (seconds).
743///
744/// Entries not refreshed within this window are filtered from
745/// [`Agent::presence`] and [`Agent::discovered_agents`].
746pub const IDENTITY_TTL_SECS: u64 = 900;
747
748const DISCOVERY_REBROADCAST_STATE_CAP: usize = 1024;
749const DISCOVERY_REBROADCAST_STATE_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
750
751/// Interval (seconds) between runs of the background discovery cache reaper.
752/// The reaper performs TTL-based pruning using the same identity_ttl
753/// horizon as the query paths on the identity / machine / user discovery
754/// HashMaps. This converts the caches from unbounded growth (only filtered
755/// at read time) into bounded working-set + retention-window structures.
756const DISCOVERY_CACHE_REAPER_INTERVAL_SECS: u64 = 120;
757
758fn discovery_record_is_live(_announced_at: u64, last_seen: u64, cutoff: u64) -> bool {
759    last_seen >= cutoff
760}
761
762fn should_rebroadcast_discovery_once<K>(
763    state: &mut std::collections::HashMap<K, std::time::Instant>,
764    key: K,
765    now: std::time::Instant,
766) -> bool
767where
768    K: Eq + std::hash::Hash,
769{
770    match state.entry(key) {
771        std::collections::hash_map::Entry::Occupied(_) => false,
772        std::collections::hash_map::Entry::Vacant(entry) => {
773            entry.insert(now);
774            if state.len() > DISCOVERY_REBROADCAST_STATE_CAP {
775                if let Some(cutoff) = now.checked_sub(DISCOVERY_REBROADCAST_STATE_TTL) {
776                    state.retain(|_, seen_at| *seen_at >= cutoff);
777                }
778            }
779            true
780        }
781    }
782}
783
784#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
785struct IdentityAnnouncementUnsigned {
786    agent_id: identity::AgentId,
787    machine_id: identity::MachineId,
788    user_id: Option<identity::UserId>,
789    agent_certificate: Option<identity::AgentCertificate>,
790    machine_public_key: Vec<u8>,
791    addresses: Vec<std::net::SocketAddr>,
792    announced_at: u64,
793    /// NAT type string (e.g. "FullCone", "Symmetric", "Unknown").
794    nat_type: Option<String>,
795    /// Whether the machine can receive direct inbound connections.
796    can_receive_direct: Option<bool>,
797    /// Whether the machine advertises relay service capability to peers.
798    ///
799    /// This is a stable capability hint, not proof that the machine is
800    /// actively relaying traffic right now.
801    is_relay: Option<bool>,
802    /// Whether the machine advertises coordinator capability to peers.
803    ///
804    /// This is a stable capability hint, not proof that the machine is
805    /// actively coordinating a traversal right now.
806    is_coordinator: Option<bool>,
807    /// Coordinator machines through which this agent is reachable.
808    ///
809    /// When `can_receive_direct == Some(false)` (typically symmetric NAT),
810    /// the advertising agent lists machine IDs of peers that can act as
811    /// coordinators for hole-punching. Empty when the agent is directly
812    /// reachable or has not yet learned any coordinator candidates.
813    reachable_via: Vec<identity::MachineId>,
814    /// Relay machines the advertising agent proposes as fallback paths.
815    ///
816    /// Used when hole-punching is not viable (extreme NAT). Empty by default.
817    relay_candidates: Vec<identity::MachineId>,
818}
819
820/// Signed identity announcement broadcast by agents.
821///
822/// The outer pub/sub envelope is agent-signed (v2 message format), and this
823/// payload is machine-signed to bind the daemon's PQC key to the announcement.
824#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
825pub struct IdentityAnnouncement {
826    /// Portable agent identity.
827    pub agent_id: identity::AgentId,
828    /// Machine identity for the daemon process.
829    pub machine_id: identity::MachineId,
830    /// Optional human identity (only when explicitly consented).
831    pub user_id: Option<identity::UserId>,
832    /// Optional user->agent certificate.
833    pub agent_certificate: Option<identity::AgentCertificate>,
834    /// Machine ML-DSA-65 public key bytes.
835    pub machine_public_key: Vec<u8>,
836    /// Machine ML-DSA-65 signature over the unsigned announcement.
837    pub machine_signature: Vec<u8>,
838    /// Reachability hints.
839    pub addresses: Vec<std::net::SocketAddr>,
840    /// Unix timestamp (seconds) of announcement creation.
841    pub announced_at: u64,
842    /// NAT type as detected by the network layer (e.g. "FullCone", "Symmetric").
843    /// `None` when the network is not yet started or NAT type is undetermined.
844    pub nat_type: Option<String>,
845    /// Whether the machine can receive direct inbound connections.
846    /// `None` when the network is not yet started.
847    pub can_receive_direct: Option<bool>,
848    /// Whether the machine advertises relay service capability to peers.
849    ///
850    /// This is a stable capability hint derived from transport configuration,
851    /// not proof that the machine is actively relaying traffic right now.
852    /// `None` when the network is not yet started.
853    pub is_relay: Option<bool>,
854    /// Whether the machine advertises coordinator capability to peers.
855    ///
856    /// This is a stable capability hint derived from transport configuration,
857    /// not proof that the machine is actively coordinating a traversal right
858    /// now. `None` when the network is not yet started.
859    pub is_coordinator: Option<bool>,
860    /// Coordinator machines through which this agent is reachable.
861    ///
862    /// Populated when the agent is behind NAT that blocks direct inbound
863    /// connections (`can_receive_direct == Some(false)`). Callers should
864    /// dial one of these coordinators first, then hole-punch via peer-ID
865    /// traversal. Empty when the agent is directly reachable or has no
866    /// coordinator candidates yet.
867    pub reachable_via: Vec<identity::MachineId>,
868    /// Relay machines the advertising agent proposes as fallback paths.
869    ///
870    /// Used when hole-punching is not viable (e.g. endpoint-dependent
871    /// mapping). Empty by default.
872    pub relay_candidates: Vec<identity::MachineId>,
873    /// Raw ML-DSA-65 agent public key bytes.
874    ///
875    /// Carried so peers can verify signed forward-header attestations (#204)
876    /// without requiring the agent to carry a user→agent certificate. The key
877    /// is **not** covered by `machine_signature` (it lives outside the
878    /// unsigned announcement struct); instead it is self-certifying — the
879    /// signed `agent_id` is `SHA-256(agent_public_key)`, so a recipient that
880    /// checks the binding rejects a swapped key. Introduced in the v2 gossip
881    /// envelope (`X0A2` magic prefix).
882    pub agent_public_key: Vec<u8>,
883}
884
885impl IdentityAnnouncement {
886    fn to_unsigned(&self) -> IdentityAnnouncementUnsigned {
887        IdentityAnnouncementUnsigned {
888            agent_id: self.agent_id,
889            machine_id: self.machine_id,
890            user_id: self.user_id,
891            agent_certificate: self.agent_certificate.clone(),
892            machine_public_key: self.machine_public_key.clone(),
893            addresses: self.addresses.clone(),
894            announced_at: self.announced_at,
895            nat_type: self.nat_type.clone(),
896            can_receive_direct: self.can_receive_direct,
897            is_relay: self.is_relay,
898            is_coordinator: self.is_coordinator,
899            reachable_via: self.reachable_via.clone(),
900            relay_candidates: self.relay_candidates.clone(),
901        }
902    }
903
904    /// Verify machine-key attestation and optional user->agent certificate.
905    pub fn verify(&self) -> error::Result<()> {
906        let machine_pub =
907            ant_quic::MlDsaPublicKey::from_bytes(&self.machine_public_key).map_err(|_| {
908                error::IdentityError::CertificateVerification(
909                    "invalid machine public key in announcement".to_string(),
910                )
911            })?;
912        let derived_machine_id = identity::MachineId::from_public_key(&machine_pub);
913        if derived_machine_id != self.machine_id {
914            return Err(error::IdentityError::CertificateVerification(
915                "machine_id does not match machine public key".to_string(),
916            ));
917        }
918
919        let unsigned_bytes = bincode::serialize(&self.to_unsigned()).map_err(|e| {
920            error::IdentityError::Serialization(format!(
921                "failed to serialize announcement for verification: {e}"
922            ))
923        })?;
924        let signature = ant_quic::crypto::raw_public_keys::pqc::MlDsaSignature::from_bytes(
925            &self.machine_signature,
926        )
927        .map_err(|e| {
928            error::IdentityError::CertificateVerification(format!(
929                "invalid machine signature in announcement: {:?}",
930                e
931            ))
932        })?;
933        ant_quic::crypto::raw_public_keys::pqc::verify_with_ml_dsa(
934            &machine_pub,
935            &unsigned_bytes,
936            &signature,
937        )
938        .map_err(|e| {
939            error::IdentityError::CertificateVerification(format!(
940                "machine signature verification failed: {:?}",
941                e
942            ))
943        })?;
944
945        match (self.user_id, self.agent_certificate.as_ref()) {
946            (Some(user_id), Some(cert)) => {
947                cert.verify()?;
948                let cert_agent_id = cert.agent_id()?;
949                if cert_agent_id != self.agent_id {
950                    return Err(error::IdentityError::CertificateVerification(
951                        "agent certificate agent_id mismatch".to_string(),
952                    ));
953                }
954                let cert_user_id = cert.user_id()?;
955                if cert_user_id != user_id {
956                    return Err(error::IdentityError::CertificateVerification(
957                        "agent certificate user_id mismatch".to_string(),
958                    ));
959                }
960                Ok(())
961            }
962            (None, None) => Ok(()),
963            _ => Err(error::IdentityError::CertificateVerification(
964                "user identity disclosure requires matching certificate".to_string(),
965            )),
966        }
967    }
968}
969
970/// Maximum accepted positive clock skew for identity announcements retained as
971/// security bindings. Mirrors the direct-message freshness policy.
972const IDENTITY_ANNOUNCEMENT_CLOCK_SKEW_SECS: u64 = dm::CLOCK_SKEW_TOLERANCE_MS / 1_000;
973
974fn identity_announcement_timestamp_is_acceptable(announced_at: u64, now: u64) -> bool {
975    announced_at <= now.saturating_add(IDENTITY_ANNOUNCEMENT_CLOCK_SKEW_SECS)
976}
977
978fn identity_announcement_has_direct_agent_origin(
979    msg: &gossip::PubSubMessage,
980    announcement: &IdentityAnnouncement,
981) -> bool {
982    if !msg.verified || msg.sender != Some(announcement.agent_id) {
983        return false;
984    }
985    let Some(sender_public_key) = msg.sender_public_key.as_deref() else {
986        return false;
987    };
988    let Ok(sender_public_key) = ant_quic::MlDsaPublicKey::from_bytes(sender_public_key) else {
989        return false;
990    };
991    identity::AgentId::from_public_key(&sender_public_key) == announcement.agent_id
992}
993
994/// Record a security binding only for a fresh announcement observed directly
995/// from its authenticated origin agent. Verified rebroadcasts remain eligible
996/// for ordinary discovery but cannot populate or overwrite this cache.
997async fn record_authenticated_machine_binding_from_message(
998    bindings: &dm_inbox::AuthenticatedMachineBindings,
999    msg: &gossip::PubSubMessage,
1000    announcement: &IdentityAnnouncement,
1001    now: u64,
1002) -> bool {
1003    if !identity_announcement_timestamp_is_acceptable(announcement.announced_at, now) {
1004        tracing::warn!(
1005            agent = %hex::encode(announcement.agent_id.as_bytes()),
1006            announced_at = announcement.announced_at,
1007            now,
1008            max_future_skew_secs = IDENTITY_ANNOUNCEMENT_CLOCK_SKEW_SECS,
1009            "ignoring far-future identity announcement for authenticated machine binding"
1010        );
1011        return false;
1012    }
1013    if !identity_announcement_has_direct_agent_origin(msg, announcement) {
1014        tracing::debug!(
1015            agent = %hex::encode(announcement.agent_id.as_bytes()),
1016            sender = ?msg.sender.map(|id| hex::encode(id.as_bytes())),
1017            "identity announcement is not direct-origin authenticated; retained binding unchanged"
1018        );
1019        return false;
1020    }
1021    dm_inbox::record_authenticated_machine_binding(
1022        bindings,
1023        announcement.agent_id,
1024        announcement.machine_id,
1025        announcement.announced_at,
1026    )
1027    .await;
1028    true
1029}
1030
1031#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1032struct MachineAnnouncementUnsigned {
1033    machine_id: identity::MachineId,
1034    machine_public_key: Vec<u8>,
1035    addresses: Vec<std::net::SocketAddr>,
1036    announced_at: u64,
1037    /// NAT type string (e.g. "FullCone", "Symmetric", "Unknown").
1038    nat_type: Option<String>,
1039    /// Whether the machine can receive direct inbound connections.
1040    can_receive_direct: Option<bool>,
1041    /// Whether the machine advertises relay service capability to peers.
1042    is_relay: Option<bool>,
1043    /// Whether the machine advertises coordinator capability to peers.
1044    is_coordinator: Option<bool>,
1045    /// Coordinator machines through which this machine is reachable.
1046    reachable_via: Vec<identity::MachineId>,
1047    /// Relay machines the advertising machine proposes as fallback paths.
1048    relay_candidates: Vec<identity::MachineId>,
1049}
1050
1051/// Signed machine endpoint announcement.
1052///
1053/// This is the transport-level discovery record: it says "machine X is
1054/// reachable at these IPv4/IPv6 endpoints, with these NAT/relay/coordinator
1055/// hints". Agent and user identities link to this machine separately through
1056/// signed identity announcements.
1057#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1058pub struct MachineAnnouncement {
1059    /// Machine identity for the daemon process.
1060    pub machine_id: identity::MachineId,
1061    /// Machine ML-DSA-65 public key bytes.
1062    pub machine_public_key: Vec<u8>,
1063    /// Machine ML-DSA-65 signature over the unsigned announcement.
1064    pub machine_signature: Vec<u8>,
1065    /// Reachability hints.
1066    pub addresses: Vec<std::net::SocketAddr>,
1067    /// Unix timestamp (seconds) of announcement creation.
1068    pub announced_at: u64,
1069    /// NAT type as detected by the network layer (e.g. "FullCone", "Symmetric").
1070    /// `None` when the network is not yet started or NAT type is undetermined.
1071    pub nat_type: Option<String>,
1072    /// Whether the machine can receive direct inbound connections.
1073    /// `None` when the network is not yet started.
1074    pub can_receive_direct: Option<bool>,
1075    /// Whether the machine advertises relay service capability to peers.
1076    pub is_relay: Option<bool>,
1077    /// Whether the machine advertises coordinator capability to peers.
1078    pub is_coordinator: Option<bool>,
1079    /// Coordinator machines through which this machine is reachable.
1080    ///
1081    /// Populated when the machine is behind NAT that blocks direct inbound
1082    /// connections. Callers should dial one of these coordinators first,
1083    /// then hole-punch via peer-ID traversal.
1084    pub reachable_via: Vec<identity::MachineId>,
1085    /// Relay machines this machine proposes as fallback paths.
1086    pub relay_candidates: Vec<identity::MachineId>,
1087}
1088
1089impl MachineAnnouncement {
1090    fn to_unsigned(&self) -> MachineAnnouncementUnsigned {
1091        MachineAnnouncementUnsigned {
1092            machine_id: self.machine_id,
1093            machine_public_key: self.machine_public_key.clone(),
1094            addresses: self.addresses.clone(),
1095            announced_at: self.announced_at,
1096            nat_type: self.nat_type.clone(),
1097            can_receive_direct: self.can_receive_direct,
1098            is_relay: self.is_relay,
1099            is_coordinator: self.is_coordinator,
1100            reachable_via: self.reachable_via.clone(),
1101            relay_candidates: self.relay_candidates.clone(),
1102        }
1103    }
1104
1105    /// Verify the machine-key attestation for this endpoint announcement.
1106    pub fn verify(&self) -> error::Result<()> {
1107        let machine_pub =
1108            ant_quic::MlDsaPublicKey::from_bytes(&self.machine_public_key).map_err(|_| {
1109                error::IdentityError::CertificateVerification(
1110                    "invalid machine public key in machine announcement".to_string(),
1111                )
1112            })?;
1113        let derived_machine_id = identity::MachineId::from_public_key(&machine_pub);
1114        if derived_machine_id != self.machine_id {
1115            return Err(error::IdentityError::CertificateVerification(
1116                "machine_id does not match machine public key".to_string(),
1117            ));
1118        }
1119
1120        let unsigned_bytes = bincode::serialize(&self.to_unsigned()).map_err(|e| {
1121            error::IdentityError::Serialization(format!(
1122                "failed to serialize machine announcement for verification: {e}"
1123            ))
1124        })?;
1125        let signature = ant_quic::crypto::raw_public_keys::pqc::MlDsaSignature::from_bytes(
1126            &self.machine_signature,
1127        )
1128        .map_err(|e| {
1129            error::IdentityError::CertificateVerification(format!(
1130                "invalid machine signature in machine announcement: {:?}",
1131                e
1132            ))
1133        })?;
1134        ant_quic::crypto::raw_public_keys::pqc::verify_with_ml_dsa(
1135            &machine_pub,
1136            &unsigned_bytes,
1137            &signature,
1138        )
1139        .map_err(|e| {
1140            error::IdentityError::CertificateVerification(format!(
1141                "machine announcement signature verification failed: {:?}",
1142                e
1143            ))
1144        })
1145    }
1146}
1147
1148#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1149struct UserAnnouncementUnsigned {
1150    user_id: identity::UserId,
1151    user_public_key: Vec<u8>,
1152    /// Certificates the user issued binding `UserId` to each of their agents.
1153    ///
1154    /// Recipients verify each certificate's ML-DSA-65 signature and that
1155    /// `certificate.user_id()` matches `user_id` in this announcement.
1156    agent_certificates: Vec<identity::AgentCertificate>,
1157    announced_at: u64,
1158}
1159
1160/// Signed user announcement broadcast on [`USER_ANNOUNCE_TOPIC`].
1161///
1162/// Published by a human operator (via `Agent::announce_user_identity`) to
1163/// assert first-class ownership of a set of agents, independent of whether
1164/// any given agent has disclosed its user binding in its own heartbeat.
1165/// Each [`identity::AgentCertificate`] is itself user-signed, so recipients
1166/// can validate every agent-owner claim without trusting the enclosing
1167/// announcement's producer.
1168#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1169pub struct UserAnnouncement {
1170    /// Human identity.
1171    pub user_id: identity::UserId,
1172    /// User's ML-DSA-65 public key bytes.
1173    pub user_public_key: Vec<u8>,
1174    /// User ML-DSA-65 signature over the unsigned announcement.
1175    pub user_signature: Vec<u8>,
1176    /// Agent certificates issued by this user.
1177    pub agent_certificates: Vec<identity::AgentCertificate>,
1178    /// Unix timestamp (seconds) of announcement creation.
1179    pub announced_at: u64,
1180}
1181
1182impl UserAnnouncement {
1183    fn to_unsigned(&self) -> UserAnnouncementUnsigned {
1184        UserAnnouncementUnsigned {
1185            user_id: self.user_id,
1186            user_public_key: self.user_public_key.clone(),
1187            agent_certificates: self.agent_certificates.clone(),
1188            announced_at: self.announced_at,
1189        }
1190    }
1191
1192    /// Sign an announcement binding the given user keypair to the provided
1193    /// agent certificates. Each certificate must already have been issued by
1194    /// this user — verification fails for any cert whose `user_id()` differs.
1195    ///
1196    /// # Errors
1197    ///
1198    /// Returns an error if the user public key cannot be extracted, signing
1199    /// fails, or any certificate was issued by a different user.
1200    pub fn sign(
1201        user_kp: &identity::UserKeypair,
1202        agent_certificates: Vec<identity::AgentCertificate>,
1203        announced_at: u64,
1204    ) -> error::Result<Self> {
1205        let user_id = user_kp.user_id();
1206        // Reject certificates from a different user up front — silent drop
1207        // would hide a configuration bug.
1208        for cert in &agent_certificates {
1209            let cert_user = cert.user_id()?;
1210            if cert_user != user_id {
1211                return Err(error::IdentityError::CertificateVerification(
1212                    "user announcement contains certificate issued by a different user".to_string(),
1213                ));
1214            }
1215        }
1216
1217        let user_public_key = user_kp.public_key().as_bytes().to_vec();
1218        let unsigned = UserAnnouncementUnsigned {
1219            user_id,
1220            user_public_key: user_public_key.clone(),
1221            agent_certificates: agent_certificates.clone(),
1222            announced_at,
1223        };
1224        let unsigned_bytes = bincode::serialize(&unsigned).map_err(|e| {
1225            error::IdentityError::Serialization(format!(
1226                "failed to serialize unsigned user announcement: {e}"
1227            ))
1228        })?;
1229        let user_signature = ant_quic::crypto::raw_public_keys::pqc::sign_with_ml_dsa(
1230            user_kp.secret_key(),
1231            &unsigned_bytes,
1232        )
1233        .map_err(|e| {
1234            error::IdentityError::Storage(std::io::Error::other(format!(
1235                "failed to sign user announcement with user key: {e:?}"
1236            )))
1237        })?
1238        .as_bytes()
1239        .to_vec();
1240
1241        Ok(Self {
1242            user_id,
1243            user_public_key,
1244            user_signature,
1245            agent_certificates,
1246            announced_at,
1247        })
1248    }
1249
1250    /// Verify the user signature and every embedded `AgentCertificate`.
1251    ///
1252    /// Checks:
1253    /// 1. `user_id` matches SHA-256 of `user_public_key`.
1254    /// 2. Outer ML-DSA-65 signature over the canonical unsigned form.
1255    /// 3. For each certificate: its signature verifies and its `user_id()`
1256    ///    equals this announcement's `user_id`.
1257    ///
1258    /// # Errors
1259    ///
1260    /// Returns an error describing which check failed.
1261    pub fn verify(&self) -> error::Result<()> {
1262        let user_pub =
1263            ant_quic::MlDsaPublicKey::from_bytes(&self.user_public_key).map_err(|_| {
1264                error::IdentityError::CertificateVerification(
1265                    "invalid user public key in user announcement".to_string(),
1266                )
1267            })?;
1268        let derived_user_id = identity::UserId::from_public_key(&user_pub);
1269        if derived_user_id != self.user_id {
1270            return Err(error::IdentityError::CertificateVerification(
1271                "user_id does not match user public key in user announcement".to_string(),
1272            ));
1273        }
1274
1275        let unsigned_bytes = bincode::serialize(&self.to_unsigned()).map_err(|e| {
1276            error::IdentityError::Serialization(format!(
1277                "failed to serialize user announcement for verification: {e}"
1278            ))
1279        })?;
1280        let signature = ant_quic::crypto::raw_public_keys::pqc::MlDsaSignature::from_bytes(
1281            &self.user_signature,
1282        )
1283        .map_err(|e| {
1284            error::IdentityError::CertificateVerification(format!(
1285                "invalid user signature in user announcement: {e:?}"
1286            ))
1287        })?;
1288        ant_quic::crypto::raw_public_keys::pqc::verify_with_ml_dsa(
1289            &user_pub,
1290            &unsigned_bytes,
1291            &signature,
1292        )
1293        .map_err(|e| {
1294            error::IdentityError::CertificateVerification(format!(
1295                "user announcement signature verification failed: {e:?}"
1296            ))
1297        })?;
1298
1299        for cert in &self.agent_certificates {
1300            cert.verify()?;
1301            let cert_user = cert.user_id()?;
1302            if cert_user != self.user_id {
1303                return Err(error::IdentityError::CertificateVerification(
1304                    "user announcement certificate user_id mismatch".to_string(),
1305                ));
1306            }
1307        }
1308        Ok(())
1309    }
1310}
1311
1312/// Cached discovery data derived from [`UserAnnouncement`]s.
1313#[derive(Debug, Clone)]
1314pub struct DiscoveredUser {
1315    /// Human identity.
1316    pub user_id: identity::UserId,
1317    /// Raw ML-DSA-65 user public key bytes from the last verified announcement.
1318    pub user_public_key: Vec<u8>,
1319    /// Certificates the user has asserted ownership of.
1320    pub agent_certificates: Vec<identity::AgentCertificate>,
1321    /// Convenience list of agent IDs derived from the certificates.
1322    pub agent_ids: Vec<identity::AgentId>,
1323    /// Announcement timestamp from the sender.
1324    pub announced_at: u64,
1325    /// Local timestamp (seconds) when this record was last updated.
1326    pub last_seen: u64,
1327}
1328
1329impl DiscoveredUser {
1330    fn from_announcement(announcement: &UserAnnouncement, last_seen: u64) -> Self {
1331        let agent_ids: Vec<identity::AgentId> = announcement
1332            .agent_certificates
1333            .iter()
1334            .filter_map(|c| c.agent_id().ok())
1335            .collect();
1336        Self {
1337            user_id: announcement.user_id,
1338            user_public_key: announcement.user_public_key.clone(),
1339            agent_certificates: announcement.agent_certificates.clone(),
1340            agent_ids,
1341            announced_at: announcement.announced_at,
1342            last_seen,
1343        }
1344    }
1345}
1346
1347/// Cached discovery data derived from identity announcements.
1348#[derive(Debug, Clone)]
1349pub struct DiscoveredAgent {
1350    /// Portable agent identity.
1351    pub agent_id: identity::AgentId,
1352    /// Machine identity.
1353    pub machine_id: identity::MachineId,
1354    /// Optional human identity (when consented and attested).
1355    pub user_id: Option<identity::UserId>,
1356    /// Reachability hints.
1357    pub addresses: Vec<std::net::SocketAddr>,
1358    /// Announcement timestamp from the sender.
1359    pub announced_at: u64,
1360    /// Local timestamp (seconds) when this record was last updated.
1361    pub last_seen: u64,
1362    /// Raw ML-DSA-65 machine public key bytes from the announcement.
1363    ///
1364    /// Used to verify rendezvous `ProviderSummary` signatures before
1365    /// trusting addresses received via the rendezvous shard topic.
1366    #[doc(hidden)]
1367    pub machine_public_key: Vec<u8>,
1368    /// NAT type reported by this agent (e.g. "FullCone", "Symmetric", "Unknown").
1369    /// `None` if the agent did not include NAT information.
1370    pub nat_type: Option<String>,
1371    /// Whether this agent's machine can receive direct inbound connections.
1372    /// `None` if not reported.
1373    pub can_receive_direct: Option<bool>,
1374    /// Whether this agent's machine advertises relay service capability.
1375    /// `None` if not reported.
1376    pub is_relay: Option<bool>,
1377    /// Whether this agent's machine advertises coordinator capability.
1378    /// `None` if not reported.
1379    pub is_coordinator: Option<bool>,
1380    /// Coordinator machines through which this agent advertises itself as
1381    /// reachable when behind NAT that blocks direct inbound connections.
1382    pub reachable_via: Vec<identity::MachineId>,
1383    /// Relay machines this agent proposes as fallback paths.
1384    pub relay_candidates: Vec<identity::MachineId>,
1385    /// Expiry timestamp from the agent certificate embedded in the
1386    /// identity announcement, if any.  `None` means the cert carries no
1387    /// expiry — the agent is considered valid indefinitely.
1388    pub cert_not_after: Option<u64>,
1389    /// The agent certificate embedded in the identity announcement, if any.
1390    ///
1391    /// Retained so a gossiped **issuer-revocation** (a user un-vouching this
1392    /// agent) can be authority-verified on receipt: `verify_authority` for an
1393    /// issuer-revocation requires the subject cert (issue #191). `None` for
1394    /// pre-#130 peers that announce no cert, and for machine/rendezvous-only
1395    /// cache entries.
1396    pub agent_certificate: Option<identity::AgentCertificate>,
1397    /// Raw ML-DSA-65 agent public key bytes from the v2 gossip envelope
1398    /// (or recovered from the cert on legacy announcements).
1399    ///
1400    /// Used by the forward-attestation path (#204) to verify the inbound
1401    /// `ForwardV2` header signature: the opener signs with its agent secret
1402    /// key, the verifier looks up this cached key, confirms the agent is on
1403    /// the transport-authenticated machine, and checks the binding
1404    /// (`agent_id == SHA-256(agent_public_key)`). Empty when no key has
1405    /// propagated — the agent cannot be attested and a `ForwardV2` stream
1406    /// is denied fail-closed.
1407    pub agent_public_key: Vec<u8>,
1408}
1409
1410/// Cached machine endpoint data derived from signed machine announcements.
1411#[derive(Debug, Clone)]
1412pub struct DiscoveredMachine {
1413    /// Machine identity, identical to the ant-quic `PeerId`.
1414    pub machine_id: identity::MachineId,
1415    /// Reachability hints for this machine.
1416    pub addresses: Vec<std::net::SocketAddr>,
1417    /// Announcement timestamp from the sender.
1418    pub announced_at: u64,
1419    /// Local timestamp (seconds) when this record was last updated.
1420    pub last_seen: u64,
1421    /// Raw ML-DSA-65 machine public key bytes from the announcement.
1422    pub machine_public_key: Vec<u8>,
1423    /// NAT type reported by this machine.
1424    pub nat_type: Option<String>,
1425    /// Whether this machine can receive direct inbound connections.
1426    pub can_receive_direct: Option<bool>,
1427    /// Whether this machine advertises relay service capability.
1428    pub is_relay: Option<bool>,
1429    /// Whether this machine advertises coordinator capability.
1430    pub is_coordinator: Option<bool>,
1431    /// Coordinator machines through which this machine advertises itself as
1432    /// reachable when it cannot receive direct inbound connections.
1433    pub reachable_via: Vec<identity::MachineId>,
1434    /// Relay machines this machine proposes as fallback paths.
1435    pub relay_candidates: Vec<identity::MachineId>,
1436    /// Agent identities currently linked to this machine.
1437    pub agent_ids: Vec<identity::AgentId>,
1438    /// Human identities currently linked to this machine by consented agent
1439    /// announcements.
1440    pub user_ids: Vec<identity::UserId>,
1441}
1442
1443/// Build a `subject AgentId → AgentCertificate` lookup from the discovery
1444/// cache, used to authority-verify gossiped **issuer-revocations** (a user
1445/// un-vouching a certified agent) on receipt (issue #191).
1446///
1447/// `verify_authority` for an issuer-revocation requires the subject agent's
1448/// certificate; self-revocations and machine-revocations need none. Only
1449/// entries that actually carry a cert contribute; entries without one
1450/// (pre-#130 peers, machine/rendezvous-only entries) are absent, so an
1451/// issuer-revocation for such a subject is rejected fail-closed by the
1452/// caller — the cert must have been announced first (EP1).
1453fn collect_subject_certs(
1454    cache: &std::collections::HashMap<identity::AgentId, DiscoveredAgent>,
1455) -> std::collections::HashMap<identity::AgentId, identity::AgentCertificate> {
1456    cache
1457        .values()
1458        .filter_map(|a| {
1459            a.agent_certificate
1460                .as_ref()
1461                .map(|c| (a.agent_id, c.clone()))
1462        })
1463        .collect()
1464}
1465
1466impl DiscoveredMachine {
1467    fn from_machine_announcement(
1468        announcement: &MachineAnnouncement,
1469        addresses: Vec<std::net::SocketAddr>,
1470        last_seen: u64,
1471    ) -> Self {
1472        Self {
1473            machine_id: announcement.machine_id,
1474            addresses,
1475            announced_at: announcement.announced_at,
1476            last_seen,
1477            machine_public_key: announcement.machine_public_key.clone(),
1478            nat_type: announcement.nat_type.clone(),
1479            can_receive_direct: announcement.can_receive_direct,
1480            is_relay: announcement.is_relay,
1481            is_coordinator: announcement.is_coordinator,
1482            reachable_via: announcement.reachable_via.clone(),
1483            relay_candidates: announcement.relay_candidates.clone(),
1484            agent_ids: Vec::new(),
1485            user_ids: Vec::new(),
1486        }
1487    }
1488
1489    fn from_discovered_agent(agent: &DiscoveredAgent) -> Self {
1490        Self {
1491            machine_id: agent.machine_id,
1492            addresses: agent.addresses.clone(),
1493            announced_at: agent.announced_at,
1494            last_seen: agent.last_seen,
1495            machine_public_key: agent.machine_public_key.clone(),
1496            nat_type: agent.nat_type.clone(),
1497            can_receive_direct: agent.can_receive_direct,
1498            is_relay: agent.is_relay,
1499            is_coordinator: agent.is_coordinator,
1500            reachable_via: agent.reachable_via.clone(),
1501            relay_candidates: agent.relay_candidates.clone(),
1502            agent_ids: vec![agent.agent_id],
1503            user_ids: agent.user_id.into_iter().collect(),
1504        }
1505    }
1506}
1507
1508#[derive(Debug, Clone, Default, PartialEq, Eq)]
1509struct AnnouncementAssistSnapshot {
1510    nat_type: Option<String>,
1511    can_receive_direct: Option<bool>,
1512    relay_capable: Option<bool>,
1513    coordinator_capable: Option<bool>,
1514    relay_active: Option<bool>,
1515    coordinator_active: Option<bool>,
1516}
1517
1518impl AnnouncementAssistSnapshot {
1519    fn from_node_status(status: &ant_quic::NodeStatus) -> Self {
1520        Self {
1521            nat_type: Some(status.nat_type.to_string()),
1522            can_receive_direct: Some(status.can_receive_direct),
1523            relay_capable: Some(status.relay_service_enabled),
1524            coordinator_capable: Some(status.coordinator_service_enabled),
1525            relay_active: Some(status.is_relaying),
1526            coordinator_active: Some(status.is_coordinating),
1527        }
1528    }
1529}
1530
1531struct IdentityAnnouncementBuildOptions<'a> {
1532    include_user_identity: bool,
1533    human_consent: bool,
1534    addresses: Vec<std::net::SocketAddr>,
1535    assist_snapshot: Option<&'a AnnouncementAssistSnapshot>,
1536    reachable_via: Vec<identity::MachineId>,
1537    relay_candidates: Vec<identity::MachineId>,
1538    allow_local_scope: bool,
1539}
1540
1541fn push_unique<T: Copy + PartialEq>(items: &mut Vec<T>, item: T) {
1542    if !items.contains(&item) {
1543        items.push(item);
1544    }
1545}
1546
1547fn prioritize_discovery_addresses(addresses: &mut [std::net::SocketAddr]) {
1548    addresses.sort_by_key(|addr| is_publicly_advertisable(*addr));
1549}
1550
1551/// Merge a freshly-discovered agent announcement into the cache.
1552///
1553/// Per-field precedence is keyed on `announced_at` (the signed announcement's
1554/// own monotonic timestamp), not local receive time:
1555/// - **addresses** reflect the agent's *current* advertised set. A fresher (or
1556///   equal) announcement REPLACES the cached list; a stale announcement leaves
1557///   it untouched. Announcements carry the agent's full address set, so
1558///   replacing — not unioning — keeps the list bounded: a roaming agent
1559///   (Wi-Fi → cellular → VPN) does not accumulate dead endpoints, each of which
1560///   would otherwise cost a dial timeout in `connect_to_*` / `direct_probe`.
1561/// - **machine_id / public key / nat / capability / relay hints** update only
1562///   from a fresher-or-equal announcement, and only when present — a zeroed
1563///   machine_id or empty key never clobbers a known value.
1564/// - **user_id** is never erased: a fresher *anonymous* announcement keeps a
1565///   previously-disclosed user_id.
1566/// - **last_seen** reflects the most recent receive: it is set from the
1567///   incoming record (matching the pre-merge replace semantics). In production
1568///   `incoming.last_seen` is the current receive time, so it only moves forward;
1569///   it is NOT clamped with `max`, because the TTL/presence filter must be able
1570///   to observe an entry that has genuinely aged past its window (a `max` clamp
1571///   would let a once-fresh entry mask a later stale observation and never
1572///   expire — see `test_ttl_expiry_removes_from_presence`).
1573async fn upsert_discovered_agent(
1574    cache: &std::sync::Arc<
1575        tokio::sync::RwLock<std::collections::HashMap<identity::AgentId, DiscoveredAgent>>,
1576    >,
1577    mut incoming: DiscoveredAgent,
1578) {
1579    prioritize_discovery_addresses(&mut incoming.addresses);
1580    let mut cache = cache.write().await;
1581    match cache.get_mut(&incoming.agent_id) {
1582        Some(existing) => {
1583            if incoming.announced_at >= existing.announced_at {
1584                existing.announced_at = incoming.announced_at;
1585                // Replace, don't union: the announcement carries the agent's
1586                // full current address set, so the cached list stays bounded.
1587                existing.addresses = incoming.addresses;
1588                prioritize_discovery_addresses(&mut existing.addresses);
1589                if incoming.machine_id.0 != [0u8; 32] {
1590                    existing.machine_id = incoming.machine_id;
1591                }
1592                if incoming.user_id.is_some() || existing.user_id.is_none() {
1593                    existing.user_id = incoming.user_id;
1594                }
1595                if !incoming.machine_public_key.is_empty() {
1596                    existing.machine_public_key = incoming.machine_public_key;
1597                }
1598                if incoming.nat_type.is_some() {
1599                    existing.nat_type = incoming.nat_type;
1600                }
1601                if incoming.can_receive_direct.is_some() {
1602                    existing.can_receive_direct = incoming.can_receive_direct;
1603                }
1604                if incoming.is_relay.is_some() {
1605                    existing.is_relay = incoming.is_relay;
1606                }
1607                if incoming.is_coordinator.is_some() {
1608                    existing.is_coordinator = incoming.is_coordinator;
1609                }
1610                existing.reachable_via = incoming.reachable_via;
1611                existing.relay_candidates = incoming.relay_candidates;
1612                // LWW the agent public key — a v2 announcement always
1613                // carries it; a legacy/rendezvous entry may not (#204).
1614                if !incoming.agent_public_key.is_empty() {
1615                    existing.agent_public_key = incoming.agent_public_key;
1616                }
1617            }
1618            existing.last_seen = incoming.last_seen;
1619        }
1620        None => {
1621            cache.insert(incoming.agent_id, incoming);
1622        }
1623    }
1624}
1625
1626fn sort_discovered_machine(machine: &mut DiscoveredMachine) {
1627    machine.addresses.sort_by_key(|addr| addr.to_string());
1628    machine.agent_ids.sort_by_key(|id| id.0);
1629    machine.user_ids.sort_by_key(|id| id.0);
1630    machine.reachable_via.sort_by_key(|id| id.0);
1631    machine.relay_candidates.sort_by_key(|id| id.0);
1632}
1633
1634async fn upsert_discovered_machine(
1635    cache: &std::sync::Arc<
1636        tokio::sync::RwLock<std::collections::HashMap<identity::MachineId, DiscoveredMachine>>,
1637    >,
1638    mut incoming: DiscoveredMachine,
1639) {
1640    if incoming.machine_id.0 == [0u8; 32] {
1641        return;
1642    }
1643
1644    sort_discovered_machine(&mut incoming);
1645    let mut cache = cache.write().await;
1646    match cache.get_mut(&incoming.machine_id) {
1647        Some(existing) => {
1648            for addr in incoming.addresses {
1649                if !existing.addresses.contains(&addr) {
1650                    existing.addresses.push(addr);
1651                }
1652            }
1653            if incoming.announced_at >= existing.announced_at {
1654                existing.announced_at = incoming.announced_at;
1655                if !incoming.machine_public_key.is_empty() {
1656                    existing.machine_public_key = incoming.machine_public_key;
1657                }
1658                if incoming.nat_type.is_some() {
1659                    existing.nat_type = incoming.nat_type;
1660                }
1661                if incoming.can_receive_direct.is_some() {
1662                    existing.can_receive_direct = incoming.can_receive_direct;
1663                }
1664                if incoming.is_relay.is_some() {
1665                    existing.is_relay = incoming.is_relay;
1666                }
1667                if incoming.is_coordinator.is_some() {
1668                    existing.is_coordinator = incoming.is_coordinator;
1669                }
1670                // Coordinator / relay hint lists are LWW: the newest
1671                // announcement knows best whether its set has shrunk
1672                // (e.g. a coordinator peer just disconnected).
1673                existing.reachable_via = incoming.reachable_via;
1674                existing.relay_candidates = incoming.relay_candidates;
1675            }
1676            existing.last_seen = existing.last_seen.max(incoming.last_seen);
1677            for agent_id in incoming.agent_ids {
1678                push_unique(&mut existing.agent_ids, agent_id);
1679            }
1680            for user_id in incoming.user_ids {
1681                push_unique(&mut existing.user_ids, user_id);
1682            }
1683            sort_discovered_machine(existing);
1684        }
1685        None => {
1686            cache.insert(incoming.machine_id, incoming);
1687        }
1688    }
1689}
1690
1691async fn upsert_discovered_machine_from_agent(
1692    cache: &std::sync::Arc<
1693        tokio::sync::RwLock<std::collections::HashMap<identity::MachineId, DiscoveredMachine>>,
1694    >,
1695    agent: &DiscoveredAgent,
1696) {
1697    if agent.machine_id.0 != [0u8; 32] {
1698        upsert_discovered_machine(cache, DiscoveredMachine::from_discovered_agent(agent)).await;
1699    }
1700}
1701
1702const MAX_MACHINE_ANNOUNCEMENT_DECODE_BYTES: u64 = 64 * 1024;
1703
1704/// Magic prefix marking an identity announcement that carries the agent
1705/// public key (the v2 gossip envelope, #204). A legacy announcement begins
1706/// with the bincode bytes of `agent_id` (a 32-byte fixed array) — the first
1707/// four bytes are a hash prefix, so `b"X0A2"` is collision-resistant in the
1708/// same way the on-disk cert `X0C2` marker is.
1709const IDENTITY_ANNOUNCEMENT_V2_MAGIC: &[u8; 4] = b"X0A2";
1710
1711/// Serialize an identity announcement in the v2 envelope (magic prefix +
1712/// bincode). The magic lets a new peer distinguish v2 (carries
1713/// `agent_public_key`) from legacy payloads.
1714fn serialize_identity_announcement(
1715    announcement: &IdentityAnnouncement,
1716) -> Result<Vec<u8>, Box<bincode::ErrorKind>> {
1717    use bincode::Options;
1718    let body = bincode::DefaultOptions::new()
1719        .with_fixint_encoding()
1720        .serialize(announcement)?;
1721    let mut out = Vec::with_capacity(IDENTITY_ANNOUNCEMENT_V2_MAGIC.len() + body.len());
1722    out.extend_from_slice(IDENTITY_ANNOUNCEMENT_V2_MAGIC);
1723    out.extend_from_slice(&body);
1724    Ok(out)
1725}
1726
1727/// Legacy identity-announcement wire shape (pre-#204): identical to
1728/// [`IdentityAnnouncement`] minus the trailing `agent_public_key` field.
1729/// Used only to deserialize payloads from old peers so they still populate
1730/// the discovery cache (the key is recovered from the embedded certificate
1731/// when present).
1732#[derive(serde::Serialize, serde::Deserialize)]
1733struct IdentityAnnouncementLegacy {
1734    agent_id: identity::AgentId,
1735    machine_id: identity::MachineId,
1736    user_id: Option<identity::UserId>,
1737    agent_certificate: Option<identity::AgentCertificate>,
1738    machine_public_key: Vec<u8>,
1739    machine_signature: Vec<u8>,
1740    addresses: Vec<std::net::SocketAddr>,
1741    announced_at: u64,
1742    nat_type: Option<String>,
1743    can_receive_direct: Option<bool>,
1744    is_relay: Option<bool>,
1745    is_coordinator: Option<bool>,
1746    reachable_via: Vec<identity::MachineId>,
1747    relay_candidates: Vec<identity::MachineId>,
1748}
1749
1750impl IdentityAnnouncementLegacy {
1751    /// Convert a legacy payload into the current announcement shape,
1752    /// recovering the agent public key from the embedded certificate when
1753    /// one is present (empty when not — the agent simply cannot be
1754    /// attested until a v2 announcement propagates).
1755    fn into_announcement(self) -> IdentityAnnouncement {
1756        let agent_public_key = self
1757            .agent_certificate
1758            .as_ref()
1759            .map(|c| c.agent_public_key().to_vec())
1760            .unwrap_or_default();
1761        IdentityAnnouncement {
1762            agent_id: self.agent_id,
1763            machine_id: self.machine_id,
1764            user_id: self.user_id,
1765            agent_certificate: self.agent_certificate,
1766            machine_public_key: self.machine_public_key,
1767            machine_signature: self.machine_signature,
1768            addresses: self.addresses,
1769            announced_at: self.announced_at,
1770            nat_type: self.nat_type,
1771            can_receive_direct: self.can_receive_direct,
1772            is_relay: self.is_relay,
1773            is_coordinator: self.is_coordinator,
1774            reachable_via: self.reachable_via,
1775            relay_candidates: self.relay_candidates,
1776            agent_public_key,
1777        }
1778    }
1779}
1780
1781fn deserialize_identity_announcement(
1782    payload: &[u8],
1783) -> std::result::Result<IdentityAnnouncement, Box<bincode::ErrorKind>> {
1784    use bincode::Options;
1785    let opts = || {
1786        bincode::DefaultOptions::new()
1787            .with_fixint_encoding()
1788            .with_limit(crate::network::MAX_MESSAGE_DESERIALIZE_SIZE)
1789            .reject_trailing_bytes()
1790    };
1791    // v2 envelope: magic prefix + bincode(IdentityAnnouncement).
1792    if payload.len() >= IDENTITY_ANNOUNCEMENT_V2_MAGIC.len()
1793        && &payload[..IDENTITY_ANNOUNCEMENT_V2_MAGIC.len()] == IDENTITY_ANNOUNCEMENT_V2_MAGIC
1794    {
1795        return opts().deserialize(&payload[IDENTITY_ANNOUNCEMENT_V2_MAGIC.len()..]);
1796    }
1797    // Legacy envelope: bincode(IdentityAnnouncementLegacy) — recover the key
1798    // from the cert when present.
1799    let legacy: IdentityAnnouncementLegacy = opts().deserialize(payload)?;
1800    Ok(legacy.into_announcement())
1801}
1802
1803fn deserialize_user_announcement(
1804    payload: &[u8],
1805) -> std::result::Result<UserAnnouncement, Box<bincode::ErrorKind>> {
1806    use bincode::Options;
1807    bincode::DefaultOptions::new()
1808        .with_fixint_encoding()
1809        .with_limit(crate::network::MAX_MESSAGE_DESERIALIZE_SIZE)
1810        .reject_trailing_bytes()
1811        .deserialize(payload)
1812}
1813
1814fn deserialize_machine_announcement(
1815    payload: &[u8],
1816) -> std::result::Result<MachineAnnouncement, Box<bincode::ErrorKind>> {
1817    use bincode::Options;
1818    bincode::DefaultOptions::new()
1819        .with_fixint_encoding()
1820        .with_limit(MAX_MACHINE_ANNOUNCEMENT_DECODE_BYTES)
1821        .reject_trailing_bytes()
1822        .deserialize(payload)
1823}
1824
1825/// Compute coordinator / relay hint lists for an announcement.
1826///
1827/// Collects machine IDs of currently-connected peers that the machine cache
1828/// marks as coordinator- or relay-capable. Deduplicated, capped to
1829/// `MAX_COORDINATOR_HINTS` each, and returned in stable order.
1830///
1831/// Used to populate `reachable_via` / `relay_candidates` on outgoing
1832/// announcements so that remote peers have concrete targets to dial when
1833/// the advertising machine is NAT-locked.
1834async fn collect_coordinator_hints(
1835    network: &network::NetworkNode,
1836    machine_cache: &std::sync::Arc<
1837        tokio::sync::RwLock<std::collections::HashMap<identity::MachineId, DiscoveredMachine>>,
1838    >,
1839    own_machine_id: identity::MachineId,
1840) -> (Vec<identity::MachineId>, Vec<identity::MachineId>) {
1841    /// Upper bound on hint list length. Keeps the signed payload small and
1842    /// avoids amplifying gossip if we accumulate many peers.
1843    const MAX_COORDINATOR_HINTS: usize = 8;
1844
1845    let connected = network.connected_peers().await;
1846    if connected.is_empty() {
1847        return (Vec::new(), Vec::new());
1848    }
1849
1850    let cache = machine_cache.read().await;
1851    let mut reachable_via: Vec<identity::MachineId> = Vec::new();
1852    let mut relay_candidates: Vec<identity::MachineId> = Vec::new();
1853
1854    for peer_id in connected {
1855        let mid = identity::MachineId(peer_id.0);
1856        if mid == own_machine_id {
1857            continue;
1858        }
1859        let Some(entry) = cache.get(&mid) else {
1860            continue;
1861        };
1862        if entry.is_coordinator == Some(true)
1863            && !reachable_via.contains(&mid)
1864            && reachable_via.len() < MAX_COORDINATOR_HINTS
1865        {
1866            reachable_via.push(mid);
1867        }
1868        if entry.is_relay == Some(true)
1869            && !relay_candidates.contains(&mid)
1870            && relay_candidates.len() < MAX_COORDINATOR_HINTS
1871        {
1872            relay_candidates.push(mid);
1873        }
1874    }
1875
1876    reachable_via.sort_by_key(|id| id.0);
1877    relay_candidates.sort_by_key(|id| id.0);
1878    (reachable_via, relay_candidates)
1879}
1880
1881fn build_machine_announcement_for_identity(
1882    identity: &identity::Identity,
1883    addresses: Vec<std::net::SocketAddr>,
1884    announced_at: u64,
1885    assist_snapshot: Option<&AnnouncementAssistSnapshot>,
1886    reachable_via: Vec<identity::MachineId>,
1887    relay_candidates: Vec<identity::MachineId>,
1888    allow_local_scope: bool,
1889) -> error::Result<MachineAnnouncement> {
1890    let addresses = filter_discovery_announcement_addrs(addresses, allow_local_scope);
1891    let machine_public_key = identity.machine_keypair().public_key().as_bytes().to_vec();
1892    let unsigned = MachineAnnouncementUnsigned {
1893        machine_id: identity.machine_id(),
1894        machine_public_key: machine_public_key.clone(),
1895        addresses,
1896        announced_at,
1897        nat_type: assist_snapshot.and_then(|snapshot| snapshot.nat_type.clone()),
1898        can_receive_direct: assist_snapshot.and_then(|snapshot| snapshot.can_receive_direct),
1899        is_relay: assist_snapshot.and_then(|snapshot| snapshot.relay_capable),
1900        is_coordinator: assist_snapshot.and_then(|snapshot| snapshot.coordinator_capable),
1901        reachable_via,
1902        relay_candidates,
1903    };
1904    let unsigned_bytes = bincode::serialize(&unsigned).map_err(|e| {
1905        error::IdentityError::Serialization(format!(
1906            "failed to serialize unsigned machine announcement: {e}"
1907        ))
1908    })?;
1909    let machine_signature = ant_quic::crypto::raw_public_keys::pqc::sign_with_ml_dsa(
1910        identity.machine_keypair().secret_key(),
1911        &unsigned_bytes,
1912    )
1913    .map_err(|e| {
1914        error::IdentityError::Storage(std::io::Error::other(format!(
1915            "failed to sign machine announcement with machine key: {:?}",
1916            e
1917        )))
1918    })?
1919    .as_bytes()
1920    .to_vec();
1921
1922    Ok(MachineAnnouncement {
1923        machine_id: unsigned.machine_id,
1924        machine_public_key,
1925        machine_signature,
1926        addresses: unsigned.addresses,
1927        announced_at: unsigned.announced_at,
1928        nat_type: unsigned.nat_type,
1929        can_receive_direct: unsigned.can_receive_direct,
1930        is_relay: unsigned.is_relay,
1931        is_coordinator: unsigned.is_coordinator,
1932        reachable_via: unsigned.reachable_via,
1933        relay_candidates: unsigned.relay_candidates,
1934    })
1935}
1936
1937/// Builder for configuring an [`Agent`] before connecting to the network.
1938///
1939/// The builder allows customization of the agent's identity:
1940/// - Machine key path: Where to store/load the machine keypair
1941/// - Agent keypair: Import a portable agent identity from another machine
1942/// - User keypair: Bind a human identity to this agent
1943///
1944/// # Example
1945///
1946/// ```ignore
1947/// use x0x::Agent;
1948///
1949/// // Default: auto-generates both keypairs
1950/// let agent = Agent::builder()
1951///     .build()
1952///     .await?;
1953///
1954/// // Custom machine key path
1955/// let agent = Agent::builder()
1956///     .with_machine_key("/custom/path/machine.key")
1957///     .build()
1958///     .await?;
1959///
1960/// // Import agent keypair
1961/// let agent_kp = load_agent_keypair()?;
1962/// let agent = Agent::builder()
1963///     .with_agent_key(agent_kp)
1964///     .build()
1965///     .await?;
1966///
1967/// // With user identity (three-layer)
1968/// let agent = Agent::builder()
1969///     .with_user_key_path("~/.x0x/user.key")
1970///     .build()
1971///     .await?;
1972/// ```
1973#[derive(Debug)]
1974pub struct AgentBuilder {
1975    machine_key_path: Option<std::path::PathBuf>,
1976    agent_keypair: Option<identity::AgentKeypair>,
1977    agent_key_path: Option<std::path::PathBuf>,
1978    /// Custom path for `agent.cert`. When set, the cert is loaded/saved from
1979    /// this path instead of `~/.x0x/agent.cert`. Required for multi-daemon
1980    /// setups on the same host — with a shared cert file, last-writer-wins
1981    /// trampling would cause the victim daemon to announce its own agent_id
1982    /// paired with another daemon's cert, and peers would reject as
1983    /// "agent certificate agent_id mismatch".
1984    agent_cert_path: Option<std::path::PathBuf>,
1985    user_keypair: Option<identity::UserKeypair>,
1986    user_key_path: Option<std::path::PathBuf>,
1987    #[allow(dead_code)]
1988    network_config: Option<network::NetworkConfig>,
1989    gossip_config: Option<gossip::GossipConfig>,
1990    peer_cache_dir: Option<std::path::PathBuf>,
1991    /// When true, skip opening the bootstrap peer cache entirely.
1992    /// Useful for fully isolated embedders and test harnesses.
1993    disable_peer_cache: bool,
1994    heartbeat_interval_secs: Option<u64>,
1995    identity_ttl_secs: Option<u64>,
1996    presence_beacon_interval_secs: Option<u64>,
1997    presence_event_poll_interval_secs: Option<u64>,
1998    presence_offline_timeout_secs: Option<u64>,
1999    /// Custom path for the contacts file.
2000    contact_store_path: Option<std::path::PathBuf>,
2001    /// Directory that scopes all identity-related files (keys, cert,
2002    /// revocations.bin).  When set, revocations are loaded/saved there
2003    /// instead of the default `~/.x0x/` directory.
2004    identity_dir: Option<std::path::PathBuf>,
2005}
2006
2007/// Context captured by the background identity heartbeat task.
2008struct HeartbeatContext {
2009    identity: std::sync::Arc<identity::Identity>,
2010    runtime: std::sync::Arc<gossip::GossipRuntime>,
2011    network: std::sync::Arc<network::NetworkNode>,
2012    interval_secs: u64,
2013    cache: std::sync::Arc<
2014        tokio::sync::RwLock<std::collections::HashMap<identity::AgentId, DiscoveredAgent>>,
2015    >,
2016    machine_cache: std::sync::Arc<
2017        tokio::sync::RwLock<std::collections::HashMap<identity::MachineId, DiscoveredMachine>>,
2018    >,
2019    /// Whether the user has consented to identity disclosure.  When true,
2020    /// heartbeats include `user_id` and `agent_certificate` so they don't
2021    /// erase a consented disclosure.
2022    user_identity_consented: std::sync::Arc<std::sync::atomic::AtomicBool>,
2023    allow_local_discovery_addrs: bool,
2024    /// Local revocation set — piggybacked on each heartbeat for partition-
2025    /// tolerant eventual propagation.
2026    revocation_set: std::sync::Arc<tokio::sync::RwLock<revocation::RevocationSet>>,
2027}
2028
2029impl HeartbeatContext {
2030    async fn announce(&self) -> error::Result<()> {
2031        let machine_public_key = self
2032            .identity
2033            .machine_keypair()
2034            .public_key()
2035            .as_bytes()
2036            .to_vec();
2037        let announced_at = Agent::unix_timestamp_secs();
2038
2039        // Include ALL routable addresses (IPv4 and IPv6) so other agents
2040        // can connect to us via whichever protocol they support.
2041        let mut addresses = match self.network.node_status().await {
2042            Some(status) if !status.external_addrs.is_empty() => status.external_addrs,
2043            _ => match self.network.routable_addr().await {
2044                Some(addr) => vec![addr],
2045                None => Vec::new(),
2046            },
2047        };
2048
2049        // Detect global IPv6 address locally (ant-quic currently only
2050        // reports IPv4 via OBSERVED_ADDRESS). Uses UDP connect trick —
2051        // no data is sent, the OS routing table resolves our source addr.
2052        //
2053        // For locally-probed addresses (IPv6 and LAN IPv4), use the actual
2054        // bound port from the QUIC endpoint — NOT the first external address
2055        // port (which is NAT-mapped) and NOT the config bind port (which may
2056        // be 0 for OS-assigned ports).
2057        let bind_port = self
2058            .network
2059            .bound_addr()
2060            .await
2061            .map(|a| a.port())
2062            .unwrap_or(5483);
2063        if let Ok(sock) = std::net::UdpSocket::bind("[::]:0") {
2064            if sock.connect("[2001:4860:4860::8888]:80").is_ok() {
2065                if let Ok(local) = sock.local_addr() {
2066                    if let std::net::IpAddr::V6(v6) = local.ip() {
2067                        let segs = v6.segments();
2068                        let is_global = (segs[0] & 0xffc0) != 0xfe80
2069                            && (segs[0] & 0xff00) != 0xfd00
2070                            && !v6.is_loopback();
2071                        if is_global {
2072                            let v6_addr =
2073                                std::net::SocketAddr::new(std::net::IpAddr::V6(v6), bind_port);
2074                            if !addresses.contains(&v6_addr) {
2075                                addresses.push(v6_addr);
2076                            }
2077                        }
2078                    }
2079                }
2080            }
2081        }
2082
2083        for addr in collect_local_interface_addrs(bind_port) {
2084            if !addresses.contains(&addr) {
2085                addresses.push(addr);
2086            }
2087        }
2088
2089        // Global bootstrap partitions must not ship LAN-scope addresses over
2090        // gossip: remote peers cannot reach them and each dead dial consumes
2091        // connect budget. Explicit local/testnet partitions are different; they
2092        // need signed LAN/loopback hints because there may be no public endpoint
2093        // or mDNS bridge between the isolated daemons.
2094        addresses =
2095            filter_discovery_announcement_addrs(addresses, self.allow_local_discovery_addrs);
2096
2097        // Query reachability plus stable relay/coordinator capability from
2098        // the network layer. Runtime activity is logged separately so we do
2099        // not conflate "can help" with "is currently busy helping".
2100        let assist_snapshot = self
2101            .network
2102            .node_status()
2103            .await
2104            .map(|status| AnnouncementAssistSnapshot::from_node_status(&status))
2105            .unwrap_or_default();
2106        let nat_type = assist_snapshot.nat_type.clone();
2107        let can_receive_direct = assist_snapshot.can_receive_direct;
2108        let relay_capable = assist_snapshot.relay_capable;
2109        let coordinator_capable = assist_snapshot.coordinator_capable;
2110
2111        // Only emit coordinator / relay hints when we believe remote peers
2112        // cannot reach us directly. Directly-reachable peers don't need
2113        // help, and advertising hints we don't need just bloats gossip.
2114        let (reachable_via, relay_candidates) = if can_receive_direct == Some(true) {
2115            (Vec::new(), Vec::new())
2116        } else {
2117            collect_coordinator_hints(
2118                self.network.as_ref(),
2119                &self.machine_cache,
2120                self.identity.machine_id(),
2121            )
2122            .await
2123        };
2124
2125        // Include user identity ONLY if the user has previously consented
2126        // via announce_identity(true, true). This preserves the consented
2127        // disclosure across heartbeats without ever escalating on its own.
2128        let include_user = self
2129            .user_identity_consented
2130            .load(std::sync::atomic::Ordering::Acquire);
2131        let (user_id, agent_certificate) = if include_user {
2132            (
2133                self.identity
2134                    .user_keypair()
2135                    .map(identity::UserKeypair::user_id),
2136                self.identity.agent_certificate().cloned(),
2137            )
2138        } else {
2139            (None, None)
2140        };
2141
2142        let unsigned = IdentityAnnouncementUnsigned {
2143            agent_id: self.identity.agent_id(),
2144            machine_id: self.identity.machine_id(),
2145            user_id,
2146            agent_certificate,
2147            machine_public_key: machine_public_key.clone(),
2148            addresses,
2149            announced_at,
2150            nat_type: nat_type.clone(),
2151            can_receive_direct,
2152            is_relay: relay_capable,
2153            is_coordinator: coordinator_capable,
2154            reachable_via: reachable_via.clone(),
2155            relay_candidates: relay_candidates.clone(),
2156        };
2157        let unsigned_bytes = bincode::serialize(&unsigned).map_err(|e| {
2158            error::IdentityError::Serialization(format!(
2159                "heartbeat: failed to serialize announcement: {e}"
2160            ))
2161        })?;
2162        let machine_signature = ant_quic::crypto::raw_public_keys::pqc::sign_with_ml_dsa(
2163            self.identity.machine_keypair().secret_key(),
2164            &unsigned_bytes,
2165        )
2166        .map_err(|e| {
2167            error::IdentityError::Storage(std::io::Error::other(format!(
2168                "heartbeat: failed to sign announcement: {:?}",
2169                e
2170            )))
2171        })?
2172        .as_bytes()
2173        .to_vec();
2174
2175        let agent_public_key = self
2176            .identity
2177            .agent_keypair()
2178            .public_key()
2179            .as_bytes()
2180            .to_vec();
2181        let announcement = IdentityAnnouncement {
2182            agent_id: unsigned.agent_id,
2183            machine_id: unsigned.machine_id,
2184            user_id: unsigned.user_id,
2185            agent_certificate: unsigned.agent_certificate,
2186            machine_public_key: machine_public_key.clone(),
2187            machine_signature,
2188            addresses: unsigned.addresses,
2189            announced_at,
2190            nat_type,
2191            can_receive_direct,
2192            is_relay: relay_capable,
2193            is_coordinator: coordinator_capable,
2194            reachable_via: reachable_via.clone(),
2195            relay_candidates: relay_candidates.clone(),
2196            agent_public_key,
2197        };
2198        tracing::debug!(
2199            target: "x0x::discovery",
2200            announcement_kind = "heartbeat",
2201            machine_prefix = %network::hex_prefix(&announcement.machine_id.0, 4),
2202            addr_total = announcement.addresses.len(),
2203            nat_type = announcement.nat_type.as_deref().unwrap_or("unknown"),
2204            can_receive_direct = ?announcement.can_receive_direct,
2205            relay_capable = ?announcement.is_relay,
2206            coordinator_capable = ?announcement.is_coordinator,
2207            relay_active = ?assist_snapshot.relay_active,
2208            coordinator_active = ?assist_snapshot.coordinator_active,
2209            reachable_via_count = announcement.reachable_via.len(),
2210            relay_candidate_count = announcement.relay_candidates.len(),
2211            "publishing identity announcement"
2212        );
2213
2214        let machine_announcement = build_machine_announcement_for_identity(
2215            &self.identity,
2216            announcement.addresses.clone(),
2217            announced_at,
2218            Some(&assist_snapshot),
2219            reachable_via.clone(),
2220            relay_candidates.clone(),
2221            self.allow_local_discovery_addrs,
2222        )?;
2223        tracing::debug!(
2224            target: "x0x::discovery",
2225            announcement_kind = "machine_heartbeat",
2226            machine_prefix = %network::hex_prefix(&machine_announcement.machine_id.0, 4),
2227            addr_total = machine_announcement.addresses.len(),
2228            nat_type = machine_announcement.nat_type.as_deref().unwrap_or("unknown"),
2229            can_receive_direct = ?machine_announcement.can_receive_direct,
2230            relay_capable = ?machine_announcement.is_relay,
2231            coordinator_capable = ?machine_announcement.is_coordinator,
2232            "publishing machine announcement"
2233        );
2234        let machine_encoded = bincode::serialize(&machine_announcement).map_err(|e| {
2235            error::IdentityError::Serialization(format!(
2236                "heartbeat: failed to serialize machine announcement: {e}"
2237            ))
2238        })?;
2239        let machine_payload = bytes::Bytes::from(machine_encoded);
2240        self.runtime
2241            .pubsub()
2242            .publish(
2243                shard_topic_for_machine(&machine_announcement.machine_id),
2244                machine_payload.clone(),
2245            )
2246            .await
2247            .map_err(|e| {
2248                error::IdentityError::Storage(std::io::Error::other(format!(
2249                    "heartbeat: machine shard publish failed: {e}"
2250                )))
2251            })?;
2252        self.runtime
2253            .pubsub()
2254            .publish(MACHINE_ANNOUNCE_TOPIC.to_string(), machine_payload)
2255            .await
2256            .map_err(|e| {
2257                error::IdentityError::Storage(std::io::Error::other(format!(
2258                    "heartbeat: machine publish failed: {e}"
2259                )))
2260            })?;
2261
2262        let encoded = serialize_identity_announcement(&announcement).map_err(|e| {
2263            error::IdentityError::Serialization(format!(
2264                "heartbeat: failed to serialize announcement: {e}"
2265            ))
2266        })?;
2267        self.runtime
2268            .pubsub()
2269            .publish(
2270                IDENTITY_ANNOUNCE_TOPIC.to_string(),
2271                bytes::Bytes::from(encoded),
2272            )
2273            .await
2274            .map_err(|e| {
2275                error::IdentityError::Storage(std::io::Error::other(format!(
2276                    "heartbeat: publish failed: {e}"
2277                )))
2278            })?;
2279        let now = Agent::unix_timestamp_secs();
2280        upsert_discovered_machine(
2281            &self.machine_cache,
2282            DiscoveredMachine::from_machine_announcement(
2283                &machine_announcement,
2284                machine_announcement.addresses.clone(),
2285                now,
2286            ),
2287        )
2288        .await;
2289        let discovered_agent = DiscoveredAgent {
2290            agent_id: announcement.agent_id,
2291            machine_id: announcement.machine_id,
2292            user_id: announcement.user_id,
2293            addresses: announcement.addresses,
2294            announced_at: announcement.announced_at,
2295            last_seen: now,
2296            machine_public_key: machine_public_key.clone(),
2297            nat_type: announcement.nat_type.clone(),
2298            can_receive_direct: announcement.can_receive_direct,
2299            is_relay: announcement.is_relay,
2300            is_coordinator: announcement.is_coordinator,
2301            reachable_via: announcement.reachable_via.clone(),
2302            relay_candidates: announcement.relay_candidates.clone(),
2303            cert_not_after: None,
2304            agent_certificate: None,
2305            agent_public_key: announcement.agent_public_key.clone(),
2306        };
2307        upsert_discovered_machine_from_agent(&self.machine_cache, &discovered_agent).await;
2308        upsert_discovered_agent(&self.cache, discovered_agent).await;
2309
2310        // Piggyback the local revocation set on each heartbeat for partition-
2311        // tolerant eventual convergence.  A node that was offline when a
2312        // revocation was originally published will learn it from the next
2313        // heartbeat it receives from any peer that already holds it.
2314        let records = self.revocation_set.read().await.all_records();
2315        if !records.is_empty() {
2316            match bincode::serialize(&records) {
2317                Ok(bytes) => {
2318                    if let Err(e) = self
2319                        .runtime
2320                        .pubsub()
2321                        .publish(REVOCATION_TOPIC.to_string(), bytes::Bytes::from(bytes))
2322                        .await
2323                    {
2324                        tracing::debug!("heartbeat: revocation re-broadcast failed: {e}");
2325                    }
2326                }
2327                Err(e) => {
2328                    tracing::debug!("heartbeat: failed to serialize revocation set: {e}");
2329                }
2330            }
2331        }
2332
2333        Ok(())
2334    }
2335}
2336
2337impl Agent {
2338    /// Create a new offline agent with default identity configuration.
2339    ///
2340    /// This generates a fresh identity with both machine and agent keypairs.
2341    /// The machine keypair is stored persistently in `~/.x0x/machine.key`.
2342    /// Network and gossip runtime setup is opt-in via
2343    /// [`Agent::builder()`] and [`AgentBuilder::with_network_config()`].
2344    ///
2345    /// For more control, use [`Agent::builder()`].
2346    pub async fn new() -> error::Result<Self> {
2347        Agent::builder().build().await
2348    }
2349
2350    /// Create an [`AgentBuilder`] for fine-grained configuration.
2351    ///
2352    /// The builder supports:
2353    /// - Custom machine key path via `with_machine_key()`
2354    /// - Imported agent keypair via `with_agent_key()`
2355    /// - User identity via `with_user_key()` or `with_user_key_path()`
2356    /// - Network and gossip runtime via `with_network_config()` (opt-in)
2357    pub fn builder() -> AgentBuilder {
2358        AgentBuilder {
2359            machine_key_path: None,
2360            agent_keypair: None,
2361            agent_key_path: None,
2362            agent_cert_path: None,
2363            user_keypair: None,
2364            user_key_path: None,
2365            network_config: None,
2366            gossip_config: None,
2367            peer_cache_dir: None,
2368            disable_peer_cache: false,
2369            heartbeat_interval_secs: None,
2370            identity_ttl_secs: None,
2371            presence_beacon_interval_secs: None,
2372            presence_event_poll_interval_secs: None,
2373            presence_offline_timeout_secs: None,
2374            contact_store_path: None,
2375            identity_dir: None,
2376        }
2377    }
2378
2379    /// Get the agent's identity.
2380    ///
2381    /// # Returns
2382    ///
2383    /// A reference to the agent's [`identity::Identity`].
2384    #[inline]
2385    #[must_use]
2386    pub fn identity(&self) -> &identity::Identity {
2387        &self.identity
2388    }
2389
2390    /// Get the machine ID for this agent.
2391    ///
2392    /// The machine ID is tied to this computer and used for QUIC transport
2393    /// authentication. It is stored persistently in `~/.x0x/machine.key`.
2394    ///
2395    /// # Returns
2396    ///
2397    /// The agent's machine ID.
2398    #[inline]
2399    #[must_use]
2400    pub fn machine_id(&self) -> identity::MachineId {
2401        self.identity.machine_id()
2402    }
2403
2404    /// Get the agent ID for this agent.
2405    ///
2406    /// The agent ID is portable across machines and represents the agent's
2407    /// persistent identity. It can be exported and imported to run the same
2408    /// agent on different computers.
2409    ///
2410    /// # Returns
2411    ///
2412    /// The agent's ID.
2413    #[inline]
2414    #[must_use]
2415    pub fn agent_id(&self) -> identity::AgentId {
2416        self.identity.agent_id()
2417    }
2418
2419    /// Get the user ID for this agent, if a user identity is bound.
2420    ///
2421    /// Returns `None` if no user keypair was provided during construction.
2422    /// User keys are opt-in — they are never auto-generated.
2423    #[inline]
2424    #[must_use]
2425    pub fn user_id(&self) -> Option<identity::UserId> {
2426        self.identity.user_id()
2427    }
2428
2429    /// Get the agent certificate, if one exists.
2430    ///
2431    /// The certificate cryptographically binds this agent to a user identity.
2432    #[inline]
2433    #[must_use]
2434    pub fn agent_certificate(&self) -> Option<&identity::AgentCertificate> {
2435        self.identity.agent_certificate()
2436    }
2437
2438    /// Get the network node, if initialized.
2439    #[must_use]
2440    pub fn network(&self) -> Option<&std::sync::Arc<network::NetworkNode>> {
2441        self.network.as_ref()
2442    }
2443
2444    /// Get the gossip cache adapter for coordinator discovery.
2445    ///
2446    /// Returns `None` if this agent was built without a network config.
2447    /// The adapter wraps the same `Arc<BootstrapCache>` as the network node.
2448    pub fn gossip_cache_adapter(&self) -> Option<&saorsa_gossip_coordinator::GossipCacheAdapter> {
2449        self.gossip_cache_adapter.as_ref()
2450    }
2451
2452    /// Snapshot of pub/sub drop-detection counters.
2453    ///
2454    /// Returns `None` when the agent has no gossip runtime (e.g. offline
2455    /// unit tests). Exposed through `GET /diagnostics/gossip` on x0xd so
2456    /// that E2E harnesses can assert zero drops between publish and
2457    /// subscriber delivery.
2458    #[must_use]
2459    pub fn gossip_stats(&self) -> Option<gossip::PubSubStatsSnapshot> {
2460        self.gossip_runtime.as_ref().map(|rt| rt.pubsub().stats())
2461    }
2462
2463    /// Snapshot of inbound gossip dispatcher counters.
2464    ///
2465    /// Returns `None` when the agent has no gossip runtime. Exposed through
2466    /// `GET /diagnostics/gossip` alongside pub/sub drop-detection counters so
2467    /// live soaks can identify slow or timed-out stream handlers.
2468    #[must_use]
2469    pub fn gossip_dispatch_stats(&self) -> Option<gossip::GossipDispatchStatsSnapshot> {
2470        self.gossip_runtime.as_ref().map(|rt| rt.dispatch_stats())
2471    }
2472
2473    /// Snapshot of per-stage PubSub handling timings.
2474    ///
2475    /// Returns `None` when the agent has no gossip runtime. This is the
2476    /// X0X-0006 diagnostic block used to identify which stage of
2477    /// `PubSubManager::handle_incoming` dominates dispatcher wall-clock time.
2478    #[must_use]
2479    pub fn gossip_pubsub_stage_stats(&self) -> Option<gossip::PubSubStageStatsSnapshot> {
2480        self.gossip_runtime
2481            .as_ref()
2482            .map(|rt| rt.pubsub().stage_stats())
2483    }
2484
2485    /// Snapshot of ant-quic → gossip receive-pump diagnostics.
2486    ///
2487    /// Returns `None` when this agent was built without a network node.
2488    /// Exposed through `GET /diagnostics/gossip` so operators can compare
2489    /// producer rate, consumer drain rate, queue dwell time, and overload drops.
2490    #[must_use]
2491    pub fn recv_pump_diagnostics(&self) -> Option<network::RecvPumpDiagnosticsSnapshot> {
2492        self.network.as_ref().map(|net| net.recv_pump_diagnostics())
2493    }
2494
2495    /// Get the presence system wrapper, if configured.
2496    ///
2497    /// Returns `None` if this agent was built without a network config.
2498    /// The presence wrapper provides beacon broadcasting, FOAF discovery,
2499    /// and online/offline event subscriptions.
2500    #[must_use]
2501    pub fn presence_system(&self) -> Option<&std::sync::Arc<presence::PresenceWrapper>> {
2502        self.presence.as_ref()
2503    }
2504
2505    /// Get a reference to the contact store.
2506    ///
2507    /// The contact store persists trust levels and machine records for known
2508    /// agents. It is backed by `~/.x0x/contacts.json` by default.
2509    ///
2510    /// Use [`with_contact_store_path`](AgentBuilder::with_contact_store_path)
2511    /// on the builder to customise the path.
2512    #[must_use]
2513    pub fn contacts(&self) -> &std::sync::Arc<tokio::sync::RwLock<contacts::ContactStore>> {
2514        &self.contact_store
2515    }
2516
2517    /// Get the reachability information for a discovered agent.
2518    ///
2519    /// Returns `None` if the agent is not in the discovery cache.
2520    /// Use [`Agent::announce_identity`] or wait for a heartbeat announcement
2521    /// to populate the cache.
2522    pub async fn reachability(
2523        &self,
2524        agent_id: &identity::AgentId,
2525    ) -> Option<connectivity::ReachabilityInfo> {
2526        let cache = self.identity_discovery_cache.read().await;
2527        cache
2528            .get(agent_id)
2529            .map(connectivity::ReachabilityInfo::from_discovered)
2530    }
2531
2532    async fn seed_transport_peer_hints_for_target(
2533        &self,
2534        network: &network::NetworkNode,
2535        target: &DiscoveredAgent,
2536    ) -> error::Result<()> {
2537        #[derive(Default)]
2538        struct HelperHintEntry {
2539            addrs: Vec<std::net::SocketAddr>,
2540            caps: ant_quic::bootstrap_cache::PeerCapabilities,
2541            sources: std::collections::BTreeSet<&'static str>,
2542        }
2543
2544        fn merge_helper_hint(
2545            hints: &mut std::collections::HashMap<ant_quic::PeerId, HelperHintEntry>,
2546            peer_id: ant_quic::PeerId,
2547            source: &'static str,
2548            addrs: impl IntoIterator<Item = std::net::SocketAddr>,
2549            supports_coordination: bool,
2550            supports_relay: bool,
2551        ) {
2552            let entry = hints.entry(peer_id).or_default();
2553            entry.sources.insert(source);
2554            for addr in addrs {
2555                if !entry.addrs.contains(&addr) {
2556                    entry.addrs.push(addr);
2557                }
2558            }
2559            if supports_coordination {
2560                entry.caps.supports_coordination = true;
2561            }
2562            if supports_relay {
2563                entry.caps.supports_relay = true;
2564            }
2565        }
2566
2567        let target_agent_prefix = network::hex_prefix(&target.agent_id.0, 4);
2568        let target_machine_prefix = network::hex_prefix(&target.machine_id.0, 4);
2569        let target_peer_id = ant_quic::PeerId(target.machine_id.0);
2570        if target.machine_id.0 != [0u8; 32] {
2571            network
2572                .upsert_peer_hints(target_peer_id, target.addresses.clone(), None)
2573                .await
2574                .map_err(|e| {
2575                    error::IdentityError::Storage(std::io::Error::other(format!(
2576                        "failed to upsert target peer hints: {e}"
2577                    )))
2578                })?;
2579            tracing::debug!(
2580                target: "x0x::connect",
2581                stage = "seed_target_hints",
2582                %target_agent_prefix,
2583                %target_machine_prefix,
2584                target_addr_count = target.addresses.len(),
2585                "upserted direct target hints"
2586            );
2587        }
2588
2589        let mut helper_hints: std::collections::HashMap<ant_quic::PeerId, HelperHintEntry> =
2590            std::collections::HashMap::new();
2591
2592        if let Some(ref cache) = self.bootstrap_cache {
2593            for peer in cache.select_coordinators(6).await {
2594                merge_helper_hint(
2595                    &mut helper_hints,
2596                    peer.peer_id,
2597                    "bootstrap_cache:coordinator",
2598                    peer.preferred_addresses(),
2599                    true,
2600                    false,
2601                );
2602            }
2603            for peer in cache.select_relay_peers(6).await {
2604                merge_helper_hint(
2605                    &mut helper_hints,
2606                    peer.peer_id,
2607                    "bootstrap_cache:relay",
2608                    peer.preferred_addresses(),
2609                    false,
2610                    true,
2611                );
2612            }
2613        }
2614
2615        if let Some(ref adapter) = self.gossip_cache_adapter {
2616            let mut adverts = adapter.get_all_adverts();
2617            adverts.sort_by_key(|a| std::cmp::Reverse(a.score));
2618            for advert in adverts.into_iter().take(12) {
2619                let advert_peer_id = ant_quic::PeerId(*advert.peer.as_bytes());
2620                if advert_peer_id == target_peer_id {
2621                    continue;
2622                }
2623                merge_helper_hint(
2624                    &mut helper_hints,
2625                    advert_peer_id,
2626                    "gossip_cache_advert",
2627                    advert
2628                        .addr_hints
2629                        .into_iter()
2630                        .map(|hint| hint.addr)
2631                        .filter(|addr| is_publicly_advertisable(*addr)),
2632                    advert.roles.coordinator || advert.roles.rendezvous,
2633                    advert.roles.relay,
2634                );
2635            }
2636        }
2637
2638        let discovered: Vec<DiscoveredMachine> = {
2639            let cache = self.machine_discovery_cache.read().await;
2640            cache.values().cloned().collect()
2641        };
2642        for candidate in discovered {
2643            if candidate.machine_id == target.machine_id || candidate.machine_id.0 == [0u8; 32] {
2644                continue;
2645            }
2646            merge_helper_hint(
2647                &mut helper_hints,
2648                ant_quic::PeerId(candidate.machine_id.0),
2649                "machine_discovery_cache",
2650                candidate.addresses.iter().copied(),
2651                candidate.is_coordinator == Some(true),
2652                candidate.is_relay == Some(true),
2653            );
2654        }
2655
2656        let helper_candidate_count = helper_hints.len();
2657        let helper_addr_total: usize = helper_hints.values().map(|entry| entry.addrs.len()).sum();
2658        tracing::info!(
2659            target: "x0x::connect",
2660            stage = "seed_target_hints",
2661            %target_agent_prefix,
2662            %target_machine_prefix,
2663            target_addr_count = target.addresses.len(),
2664            helper_candidate_count,
2665            helper_addr_total,
2666            "prepared helper hints for peer-authenticated dial"
2667        );
2668
2669        for (peer_id, entry) in &helper_hints {
2670            tracing::debug!(
2671                target: "x0x::connect",
2672                stage = "seed_target_hints",
2673                helper_peer_prefix = %network::hex_prefix(&peer_id.0, 4),
2674                helper_addr_count = entry.addrs.len(),
2675                supports_coordination = entry.caps.supports_coordination,
2676                supports_relay = entry.caps.supports_relay,
2677                sources = %entry.sources.iter().copied().collect::<Vec<_>>().join(","),
2678                "helper candidate discovered"
2679            );
2680        }
2681
2682        for (peer_id, entry) in helper_hints {
2683            let HelperHintEntry {
2684                mut addrs,
2685                caps,
2686                sources,
2687            } = entry;
2688            addrs.retain(|addr| !target.addresses.contains(addr));
2689            if addrs.is_empty() && !caps.supports_coordination && !caps.supports_relay {
2690                tracing::debug!(
2691                    target: "x0x::connect",
2692                    stage = "seed_target_hints",
2693                    helper_peer_prefix = %network::hex_prefix(&peer_id.0, 4),
2694                    sources = %sources.iter().copied().collect::<Vec<_>>().join(","),
2695                    "skipping helper with no remaining addresses or assist capability"
2696                );
2697                continue;
2698            }
2699            tracing::debug!(
2700                target: "x0x::connect",
2701                stage = "seed_target_hints",
2702                helper_peer_prefix = %network::hex_prefix(&peer_id.0, 4),
2703                helper_addr_count = addrs.len(),
2704                supports_coordination = caps.supports_coordination,
2705                supports_relay = caps.supports_relay,
2706                sources = %sources.iter().copied().collect::<Vec<_>>().join(","),
2707                "upserting helper peer hints"
2708            );
2709            network
2710                .upsert_peer_hints(peer_id, addrs, Some(caps))
2711                .await
2712                .map_err(|e| {
2713                    error::IdentityError::Storage(std::io::Error::other(format!(
2714                        "failed to upsert helper peer hints: {e}"
2715                    )))
2716                })?;
2717        }
2718
2719        Ok(())
2720    }
2721
2722    /// Attempt to connect to an agent by its identity.
2723    ///
2724    /// Looks up the agent in the discovery cache, then tries to establish
2725    /// a QUIC connection using the best available strategy:
2726    ///
2727    /// 1. **Direct** — if the agent reports `can_receive_direct: true` or
2728    ///    has a traversable NAT type, try each known address in order.
2729    /// 2. **Coordinated** — if direct fails or the agent reports a symmetric
2730    ///    NAT, the outcome is `Coordinated` if any address was reachable via
2731    ///    the network layer's NAT traversal.
2732    /// 3. **Unreachable** — no address succeeded.
2733    /// 4. **NotFound** — the agent is not in the discovery cache.
2734    ///
2735    /// # Errors
2736    ///
2737    /// Returns an error only for internal failures (e.g. network not started).
2738    /// Connectivity failures are reported as `ConnectOutcome::Unreachable`.
2739    pub async fn connect_to_agent(
2740        &self,
2741        agent_id: &identity::AgentId,
2742    ) -> error::Result<connectivity::ConnectOutcome> {
2743        let call_start = std::time::Instant::now();
2744        let agent_prefix = network::hex_prefix(&agent_id.0, 4);
2745        tracing::debug!(
2746            target: "x0x::connect",
2747            stage = "connect_to_agent",
2748            %agent_prefix,
2749            "begin"
2750        );
2751        // 1. Look up in discovery cache
2752        let discovered = {
2753            let cache = self.identity_discovery_cache.read().await;
2754            cache.get(agent_id).cloned()
2755        };
2756
2757        let agent = match discovered {
2758            Some(a) => a,
2759            None => {
2760                tracing::info!(
2761                    target: "x0x::connect",
2762                    stage = "connect_to_agent",
2763                    %agent_prefix,
2764                    outcome = "not_found",
2765                    dur_ms = call_start.elapsed().as_millis() as u64,
2766                    "agent not in discovery cache"
2767                );
2768                return Ok(connectivity::ConnectOutcome::NotFound);
2769            }
2770        };
2771
2772        // #195 item 5: consult the revocation set inline so a known-revoked-but-
2773        // not-yet-evicted agent can't be the target of an outbound connect —
2774        // closes the race between a revocation arriving and the eviction loop
2775        // purging the discovery cache. Pairs with #191 item 5.
2776        {
2777            let revoked = self.revocation_set.read().await;
2778            if revoked.is_agent_revoked(agent_id) || revoked.is_machine_revoked(&agent.machine_id) {
2779                tracing::info!(
2780                    target: "x0x::connect",
2781                    stage = "connect_to_agent",
2782                    %agent_prefix,
2783                    outcome = "revoked",
2784                    dur_ms = call_start.elapsed().as_millis() as u64,
2785                    "connect target is revoked — refusing before any dial"
2786                );
2787                return Ok(connectivity::ConnectOutcome::NotFound);
2788            }
2789        }
2790
2791        let info = connectivity::ReachabilityInfo::from_discovered(&agent);
2792        let v4_addrs = info.addresses.iter().filter(|a| a.is_ipv4()).count();
2793        let v6_addrs = info.addresses.len() - v4_addrs;
2794        tracing::info!(
2795            target: "x0x::connect",
2796            stage = "connect_to_agent",
2797            %agent_prefix,
2798            machine_prefix = %network::hex_prefix(&agent.machine_id.0, 4),
2799            addr_total = info.addresses.len(),
2800            v4_addrs,
2801            v6_addrs,
2802            can_receive_direct = ?info.can_receive_direct,
2803            should_attempt_direct = info.should_attempt_direct(),
2804            needs_coordination = info.needs_coordination(),
2805            "reachability classified"
2806        );
2807
2808        let Some(ref network) = self.network else {
2809            tracing::warn!(
2810                target: "x0x::connect",
2811                stage = "connect_to_agent",
2812                agent_prefix = %crate::logging::LogHexId::agent(&agent_prefix),
2813                outcome = "unreachable_no_network",
2814                "network layer not initialised"
2815            );
2816            return Ok(connectivity::ConnectOutcome::Unreachable);
2817        };
2818
2819        // 2. If already connected via gossip, reuse that connection.
2820        //    This check MUST come before the empty-address bail-out because
2821        //    LAN/private agents may have no publicly-routable addresses in
2822        //    their announcement but are still reachable via the existing
2823        //    gossip QUIC connection.
2824        let connected_machine_id = if agent.machine_id.0 != [0u8; 32]
2825            && network
2826                .is_connected(&ant_quic::PeerId(agent.machine_id.0))
2827                .await
2828        {
2829            Some(agent.machine_id)
2830        } else {
2831            match self.direct_messaging.get_machine_id(agent_id).await {
2832                Some(machine_id) if network.is_connected(&ant_quic::PeerId(machine_id.0)).await => {
2833                    Some(machine_id)
2834                }
2835                _ => None,
2836            }
2837        };
2838        if let Some(machine_id) = connected_machine_id {
2839            if machine_id != agent.machine_id {
2840                let mut cache = self.identity_discovery_cache.write().await;
2841                if let Some(entry) = cache.get_mut(agent_id) {
2842                    entry.machine_id = machine_id;
2843                }
2844            }
2845            self.direct_messaging
2846                .mark_connected(agent.agent_id, machine_id)
2847                .await;
2848            let dur_ms = call_start.elapsed().as_millis() as u64;
2849            return if let Some(addr) = info.addresses.first() {
2850                let family = if addr.is_ipv4() { "v4" } else { "v6" };
2851                tracing::info!(
2852                    target: "x0x::connect",
2853                    stage = "connect_to_agent",
2854                    %agent_prefix,
2855                    strategy = "already_connected",
2856                    outcome = "direct",
2857                    selected_addr = %addr,
2858                    family,
2859                    dur_ms,
2860                    "reusing existing connection"
2861                );
2862                Ok(connectivity::ConnectOutcome::Direct(*addr))
2863            } else {
2864                tracing::info!(
2865                    target: "x0x::connect",
2866                    stage = "connect_to_agent",
2867                    %agent_prefix,
2868                    strategy = "already_connected",
2869                    outcome = "already_connected",
2870                    dur_ms,
2871                    "reusing existing connection without known addr"
2872                );
2873                Ok(connectivity::ConnectOutcome::AlreadyConnected)
2874            };
2875        }
2876
2877        if info.addresses.is_empty() {
2878            tracing::info!(
2879                target: "x0x::connect",
2880                stage = "connect_to_agent",
2881                %agent_prefix,
2882                outcome = "unreachable",
2883                reason = "no_addresses",
2884                dur_ms = call_start.elapsed().as_millis() as u64,
2885                "no known addresses for agent"
2886            );
2887            return Ok(connectivity::ConnectOutcome::Unreachable);
2888        }
2889
2890        let dial_timeout = std::time::Duration::from_secs(8);
2891        let local_probe_timeout = std::time::Duration::from_secs(3);
2892        let direct_probe_addrs = local_direct_probe_addrs(&info.addresses);
2893
2894        // Agent cards minted for a local dogfood run often contain both
2895        // reachable LAN IPv4 hints and globally-scoped IPv6/Tailscale hints
2896        // that may be stale or firewalled. Probe local IPv4 first so a bad
2897        // multi-address peer dial cannot consume the full API timeout before
2898        // the reachable LAN path is attempted.
2899        let peer_id_hint =
2900            (agent.machine_id.0 != [0u8; 32]).then_some(ant_quic::PeerId(agent.machine_id.0));
2901        for addr in &direct_probe_addrs {
2902            if let Some(peer_id_hint) = peer_id_hint {
2903                match tokio::time::timeout(
2904                    local_probe_timeout,
2905                    network.connect_peer_with_addrs(peer_id_hint, vec![*addr]),
2906                )
2907                .await
2908                {
2909                    Ok(Ok((selected_addr, connected_peer_id)))
2910                        if connected_peer_id == peer_id_hint =>
2911                    {
2912                        let real_machine_id = identity::MachineId(connected_peer_id.0);
2913                        if let Some(ref bc) = self.bootstrap_cache {
2914                            bc.add_from_connection(connected_peer_id, vec![selected_addr], None)
2915                                .await;
2916                        }
2917                        {
2918                            let mut cache = self.identity_discovery_cache.write().await;
2919                            if let Some(entry) = cache.get_mut(agent_id) {
2920                                entry.machine_id = real_machine_id;
2921                            }
2922                        }
2923                        self.direct_messaging
2924                            .mark_connected(agent.agent_id, real_machine_id)
2925                            .await;
2926                        tracing::info!(
2927                            target: "x0x::connect",
2928                            stage = "connect_to_agent",
2929                            %agent_prefix,
2930                            strategy = "local_direct_first",
2931                            outcome = "direct",
2932                            selected_addr = %selected_addr,
2933                            family = "v4",
2934                            dur_ms = call_start.elapsed().as_millis() as u64,
2935                            "local peer-authenticated dial succeeded"
2936                        );
2937                        return Ok(connectivity::ConnectOutcome::Direct(selected_addr));
2938                    }
2939                    Ok(Ok((selected_addr, connected_peer_id))) => {
2940                        tracing::warn!(
2941                            target: "x0x::connect",
2942                            stage = "connect_to_agent",
2943                            agent_prefix = %crate::logging::LogHexId::agent(&agent_prefix),
2944                            strategy = "local_direct_first",
2945                            requested_addr = %crate::logging::LogHexId::addr(&addr.to_string()),
2946                            selected_addr = %crate::logging::LogHexId::addr(&selected_addr.to_string()),
2947                            connected_machine_prefix = %crate::logging::LogHexId::new("machine", &network::hex_prefix(&connected_peer_id.0, 4)),
2948                            "local peer-authenticated dial reached unexpected peer"
2949                        );
2950                    }
2951                    Ok(Err(e)) => {
2952                        tracing::debug!(
2953                            target: "x0x::connect",
2954                            %agent_prefix,
2955                            strategy = "local_direct_first",
2956                            %addr,
2957                            error = %e,
2958                            "local peer-authenticated dial failed; trying verified raw-address fallback"
2959                        );
2960                    }
2961                    Err(_) => {
2962                        tracing::debug!(
2963                            target: "x0x::connect",
2964                            %agent_prefix,
2965                            strategy = "local_direct_first",
2966                            %addr,
2967                            timeout_s = local_probe_timeout.as_secs(),
2968                            "local peer-authenticated dial timed out; trying verified raw-address fallback"
2969                        );
2970                    }
2971                }
2972
2973                match tokio::time::timeout(local_probe_timeout, network.connect_addr(*addr)).await {
2974                    Ok(Ok(connected_peer_id)) if connected_peer_id == peer_id_hint => {
2975                        let real_machine_id = identity::MachineId(connected_peer_id.0);
2976                        if let Some(ref bc) = self.bootstrap_cache {
2977                            bc.add_from_connection(connected_peer_id, vec![*addr], None)
2978                                .await;
2979                        }
2980                        {
2981                            let mut cache = self.identity_discovery_cache.write().await;
2982                            if let Some(entry) = cache.get_mut(agent_id) {
2983                                entry.machine_id = real_machine_id;
2984                            }
2985                        }
2986                        self.direct_messaging
2987                            .mark_connected(agent.agent_id, real_machine_id)
2988                            .await;
2989                        tracing::info!(
2990                            target: "x0x::connect",
2991                            stage = "connect_to_agent",
2992                            %agent_prefix,
2993                            strategy = "local_direct_raw_fallback",
2994                            outcome = "direct",
2995                            selected_addr = %addr,
2996                            family = "v4",
2997                            dur_ms = call_start.elapsed().as_millis() as u64,
2998                            "verified local raw-address fallback succeeded"
2999                        );
3000                        return Ok(connectivity::ConnectOutcome::Direct(*addr));
3001                    }
3002                    Ok(Ok(connected_peer_id)) => {
3003                        tracing::warn!(
3004                            target: "x0x::connect",
3005                            stage = "connect_to_agent",
3006                            agent_prefix = %crate::logging::LogHexId::agent(&agent_prefix),
3007                            strategy = "local_direct_raw_fallback",
3008                            addr = %crate::logging::LogHexId::addr(&addr.to_string()),
3009                            connected_machine_prefix = %crate::logging::LogHexId::new("machine", &network::hex_prefix(&connected_peer_id.0, 4)),
3010                            "verified local raw-address fallback reached unexpected peer"
3011                        );
3012                    }
3013                    Ok(Err(e)) => {
3014                        tracing::debug!(
3015                            target: "x0x::connect",
3016                            %agent_prefix,
3017                            strategy = "local_direct_raw_fallback",
3018                            %addr,
3019                            error = %e,
3020                            "verified local raw-address fallback failed"
3021                        );
3022                    }
3023                    Err(_) => {
3024                        tracing::debug!(
3025                            target: "x0x::connect",
3026                            %agent_prefix,
3027                            strategy = "local_direct_raw_fallback",
3028                            %addr,
3029                            timeout_s = local_probe_timeout.as_secs(),
3030                            "verified local raw-address fallback timed out"
3031                        );
3032                    }
3033                }
3034            } else {
3035                match tokio::time::timeout(local_probe_timeout, network.connect_addr(*addr)).await {
3036                    Ok(Ok(connected_peer_id)) => {
3037                        let real_machine_id = identity::MachineId(connected_peer_id.0);
3038                        if let Some(ref bc) = self.bootstrap_cache {
3039                            bc.add_from_connection(connected_peer_id, vec![*addr], None)
3040                                .await;
3041                        }
3042                        {
3043                            let mut cache = self.identity_discovery_cache.write().await;
3044                            if let Some(entry) = cache.get_mut(agent_id) {
3045                                entry.machine_id = real_machine_id;
3046                            }
3047                        }
3048                        self.direct_messaging
3049                            .mark_connected(agent.agent_id, real_machine_id)
3050                            .await;
3051                        tracing::info!(
3052                            target: "x0x::connect",
3053                            stage = "connect_to_agent",
3054                            %agent_prefix,
3055                            strategy = "local_direct_first",
3056                            outcome = "direct",
3057                            selected_addr = %addr,
3058                            family = "v4",
3059                            dur_ms = call_start.elapsed().as_millis() as u64,
3060                            "local direct dial succeeded"
3061                        );
3062                        return Ok(connectivity::ConnectOutcome::Direct(*addr));
3063                    }
3064                    Ok(Err(e)) => {
3065                        tracing::debug!(
3066                            target: "x0x::connect",
3067                            %agent_prefix,
3068                            strategy = "local_direct_first",
3069                            %addr,
3070                            error = %e,
3071                            "local direct dial failed"
3072                        );
3073                    }
3074                    Err(_) => {
3075                        tracing::debug!(
3076                            target: "x0x::connect",
3077                            %agent_prefix,
3078                            strategy = "local_direct_first",
3079                            %addr,
3080                            timeout_s = local_probe_timeout.as_secs(),
3081                            "local direct dial timed out"
3082                        );
3083                    }
3084                }
3085            }
3086        }
3087
3088        // 3. If we know the peer's machine ID, prefer a peer-authenticated dial
3089        //    with explicit address hints first. This is more reliable for agent
3090        //    cards and other out-of-band discoveries than a raw address dial.
3091        if agent.machine_id.0 != [0u8; 32] {
3092            let peer_id_hint = ant_quic::PeerId(agent.machine_id.0);
3093            self.seed_transport_peer_hints_for_target(network, &agent)
3094                .await
3095                .map_err(|e| {
3096                    error::IdentityError::Storage(std::io::Error::other(format!(
3097                        "failed to seed transport peer hints: {e}"
3098                    )))
3099                })?;
3100
3101            match tokio::time::timeout(
3102                dial_timeout,
3103                network.connect_peer_with_addrs(peer_id_hint, info.addresses.clone()),
3104            )
3105            .await
3106            {
3107                Ok(Ok((addr, verified_peer_id))) => {
3108                    let verified_machine_id = identity::MachineId(verified_peer_id.0);
3109                    if let Some(ref bc) = self.bootstrap_cache {
3110                        bc.add_from_connection(verified_peer_id, vec![addr], None)
3111                            .await;
3112                        bc.record_success(&verified_peer_id, 0).await;
3113                    }
3114                    {
3115                        let mut cache = self.identity_discovery_cache.write().await;
3116                        if let Some(entry) = cache.get_mut(agent_id) {
3117                            entry.machine_id = verified_machine_id;
3118                        }
3119                    }
3120                    self.direct_messaging
3121                        .mark_connected(agent.agent_id, verified_machine_id)
3122                        .await;
3123                    let family = if addr.is_ipv4() { "v4" } else { "v6" };
3124                    tracing::info!(
3125                        target: "x0x::connect",
3126                        stage = "connect_to_agent",
3127                        %agent_prefix,
3128                        strategy = "hinted_peer",
3129                        outcome = "coordinated",
3130                        selected_addr = %addr,
3131                        family,
3132                        dur_ms = call_start.elapsed().as_millis() as u64,
3133                        "hinted peer dial succeeded"
3134                    );
3135                    return Ok(connectivity::ConnectOutcome::Coordinated(addr));
3136                }
3137                Ok(Err(e)) => {
3138                    tracing::debug!(
3139                        target: "x0x::connect",
3140                        %agent_prefix,
3141                        strategy = "hinted_peer",
3142                        error = %e,
3143                        "hinted peer dial failed"
3144                    );
3145                }
3146                Err(_) => {
3147                    tracing::debug!(
3148                        target: "x0x::connect",
3149                        %agent_prefix,
3150                        strategy = "hinted_peer",
3151                        timeout_s = dial_timeout.as_secs(),
3152                        "hinted peer dial timed out"
3153                    );
3154                }
3155            }
3156        }
3157
3158        // 4. Try direct connection whenever the peer is not explicitly known
3159        //    to require coordination. Unknown reachability still deserves a
3160        //    direct probe, especially for the first nodes in a new network.
3161        if info.should_attempt_direct() {
3162            for addr in &info.addresses {
3163                if direct_probe_addrs.contains(addr) {
3164                    continue;
3165                }
3166                match tokio::time::timeout(dial_timeout, network.connect_addr(*addr)).await {
3167                    Ok(Ok(connected_peer_id)) => {
3168                        // Use the real PeerId from the QUIC handshake (may differ
3169                        // from a zeroed placeholder in the discovery cache).
3170                        let real_machine_id = identity::MachineId(connected_peer_id.0);
3171                        // Enrich bootstrap cache with this successful address
3172                        if let Some(ref bc) = self.bootstrap_cache {
3173                            bc.add_from_connection(connected_peer_id, vec![*addr], None)
3174                                .await;
3175                        }
3176                        // Update discovery cache with real machine_id
3177                        {
3178                            let mut cache = self.identity_discovery_cache.write().await;
3179                            if let Some(entry) = cache.get_mut(agent_id) {
3180                                entry.machine_id = real_machine_id;
3181                            }
3182                        }
3183                        // Register agent mapping for direct messaging
3184                        self.direct_messaging
3185                            .mark_connected(agent.agent_id, real_machine_id)
3186                            .await;
3187                        let family = if addr.is_ipv4() { "v4" } else { "v6" };
3188                        tracing::info!(
3189                            target: "x0x::connect",
3190                            stage = "connect_to_agent",
3191                            %agent_prefix,
3192                            strategy = "direct_per_addr",
3193                            outcome = "direct",
3194                            selected_addr = %addr,
3195                            family,
3196                            dur_ms = call_start.elapsed().as_millis() as u64,
3197                            "direct dial succeeded"
3198                        );
3199                        return Ok(connectivity::ConnectOutcome::Direct(*addr));
3200                    }
3201                    Ok(Err(e)) => {
3202                        tracing::debug!(
3203                            target: "x0x::connect",
3204                            %agent_prefix,
3205                            strategy = "direct_per_addr",
3206                            %addr,
3207                            error = %e,
3208                            "direct dial failed"
3209                        );
3210                    }
3211                    Err(_) => {
3212                        tracing::debug!(
3213                            target: "x0x::connect",
3214                            %agent_prefix,
3215                            strategy = "direct_per_addr",
3216                            %addr,
3217                            timeout_s = dial_timeout.as_secs(),
3218                            "direct dial timed out"
3219                        );
3220                    }
3221                }
3222            }
3223        }
3224
3225        // 5. If direct failed and coordination may help, use peer-ID dialing
3226        //    with explicit address hints. This lets ant-quic combine the
3227        //    authenticated peer ID with known addresses from x0x discovery /
3228        //    imported cards, unlocking the full direct → hole-punch → relay path.
3229        if info.needs_coordination() || !info.should_attempt_direct() {
3230            // Prefer coordinators the target has explicitly named in its
3231            // announcement (`reachable_via`). Seed transport hints for each
3232            // so ant-quic picks whichever is already connected (or can reach
3233            // one) as the coordinator peer for NAT punch-timing.
3234            for coord in &info.reachable_via {
3235                if let Some(coord_machine) = self
3236                    .machine_discovery_cache
3237                    .read()
3238                    .await
3239                    .get(coord)
3240                    .cloned()
3241                {
3242                    let coord_peer = ant_quic::PeerId(coord_machine.machine_id.0);
3243                    let coord_addrs = coord_machine.addresses.clone();
3244                    if !coord_addrs.is_empty() {
3245                        if let Err(e) = network
3246                            .upsert_peer_hints(coord_peer, coord_addrs, None)
3247                            .await
3248                        {
3249                            tracing::debug!(
3250                                target: "x0x::connect",
3251                                %agent_prefix,
3252                                coord_prefix = %network::hex_prefix(&coord.0, 4),
3253                                error = %e,
3254                                "failed to seed coordinator hints from reachable_via"
3255                            );
3256                        }
3257                    }
3258                }
3259            }
3260
3261            // Use the machine_id from discovery cache as the peer_id hint.
3262            // NOTE: This may be a zeroed placeholder if the peer was discovered via
3263            // gossip and hasn't been verified via QUIC handshake yet.
3264            let peer_id_hint = ant_quic::PeerId(agent.machine_id.0);
3265            let hint_was_zeroed = agent.machine_id.0 == [0u8; 32];
3266            self.seed_transport_peer_hints_for_target(network, &agent)
3267                .await
3268                .map_err(|e| {
3269                    error::IdentityError::Storage(std::io::Error::other(format!(
3270                        "failed to seed transport peer hints: {e}"
3271                    )))
3272                })?;
3273            let coordinated_result = tokio::time::timeout(
3274                dial_timeout,
3275                network.connect_peer_with_addrs(peer_id_hint, info.addresses.clone()),
3276            )
3277            .await;
3278            match coordinated_result {
3279                Ok(Ok((addr, verified_peer_id))) => {
3280                    let verified_machine_id = identity::MachineId(verified_peer_id.0);
3281
3282                    // Only update caches if the original hint was not zeroed.
3283                    // When the hint was zeroed, we connected to *some* peer at that address
3284                    // but have no way to verify they are the agent we intended. Writing
3285                    // an unverified peer_id into the caches could corrupt the bootstrap cache
3286                    // with the wrong peer's identity.
3287                    if !hint_was_zeroed {
3288                        if let Some(ref bc) = self.bootstrap_cache {
3289                            bc.add_from_connection(verified_peer_id, vec![addr], None)
3290                                .await;
3291                            bc.record_success(&verified_peer_id, 0).await;
3292                        }
3293                        {
3294                            let mut cache = self.identity_discovery_cache.write().await;
3295                            if let Some(entry) = cache.get_mut(agent_id) {
3296                                entry.machine_id = verified_machine_id;
3297                            }
3298                        }
3299                    }
3300
3301                    // Only register for direct messaging and update caches when the hint
3302                    // was non-zero. When the hint was zeroed, we connected to *some*
3303                    // peer at that address but have no cryptographic way to verify they
3304                    // are the agent we intended. Binding an unverified peer_id to an
3305                    // agent_id could corrupt the direct-messaging registry with the
3306                    // wrong peer's identity.
3307                    if !hint_was_zeroed {
3308                        self.direct_messaging
3309                            .mark_connected(agent.agent_id, verified_machine_id)
3310                            .await;
3311                    }
3312                    let family = if addr.is_ipv4() { "v4" } else { "v6" };
3313                    tracing::info!(
3314                        target: "x0x::connect",
3315                        stage = "connect_to_agent",
3316                        %agent_prefix,
3317                        strategy = "coordinated_fallback",
3318                        outcome = "coordinated",
3319                        selected_addr = %addr,
3320                        family,
3321                        hint_was_zeroed,
3322                        dur_ms = call_start.elapsed().as_millis() as u64,
3323                        "coordinated dial succeeded"
3324                    );
3325                    return Ok(connectivity::ConnectOutcome::Coordinated(addr));
3326                }
3327                Ok(Err(e)) => {
3328                    tracing::debug!(
3329                        target: "x0x::connect",
3330                        %agent_prefix,
3331                        strategy = "coordinated_fallback",
3332                        error = %e,
3333                        "coordinated dial failed"
3334                    );
3335                }
3336                Err(_) => {
3337                    tracing::debug!(
3338                        target: "x0x::connect",
3339                        %agent_prefix,
3340                        strategy = "coordinated_fallback",
3341                        timeout_s = dial_timeout.as_secs(),
3342                        "coordinated dial timed out"
3343                    );
3344                }
3345            }
3346        }
3347
3348        tracing::warn!(
3349            target: "x0x::connect",
3350            stage = "connect_to_agent",
3351            agent_prefix = %crate::logging::LogHexId::agent(&agent_prefix),
3352            outcome = "unreachable",
3353            reason = "all_strategies_exhausted",
3354            dur_ms = call_start.elapsed().as_millis() as u64,
3355            v4_addrs,
3356            v6_addrs,
3357            "all connection strategies exhausted"
3358        );
3359        Ok(connectivity::ConnectOutcome::Unreachable)
3360    }
3361
3362    /// Attempt to connect to a machine by its transport identity.
3363    ///
3364    /// This is the machine-centric dial path: `machine_id` is resolved to
3365    /// IPv4/IPv6 endpoint hints, then ant-quic performs a peer-authenticated
3366    /// dial that can use direct connection, coordinated hole-punching, or relay
3367    /// support according to the transport cache.
3368    ///
3369    /// # Errors
3370    ///
3371    /// Returns an error only for internal failures. Connectivity failures are
3372    /// reported as [`connectivity::ConnectOutcome::Unreachable`].
3373    pub async fn connect_to_machine(
3374        &self,
3375        machine_id: &identity::MachineId,
3376    ) -> error::Result<connectivity::ConnectOutcome> {
3377        let call_start = std::time::Instant::now();
3378        let machine_prefix = network::hex_prefix(&machine_id.0, 4);
3379        tracing::debug!(
3380            target: "x0x::connect",
3381            stage = "connect_to_machine",
3382            %machine_prefix,
3383            "begin"
3384        );
3385
3386        let machine = {
3387            let cache = self.machine_discovery_cache.read().await;
3388            cache.get(machine_id).cloned()
3389        };
3390        let Some(machine) = machine else {
3391            tracing::info!(
3392                target: "x0x::connect",
3393                stage = "connect_to_machine",
3394                %machine_prefix,
3395                outcome = "not_found",
3396                dur_ms = call_start.elapsed().as_millis() as u64,
3397                "machine not in discovery cache"
3398            );
3399            return Ok(connectivity::ConnectOutcome::NotFound);
3400        };
3401
3402        let Some(ref network) = self.network else {
3403            tracing::warn!(
3404                target: "x0x::connect",
3405                stage = "connect_to_machine",
3406                machine_prefix = %crate::logging::LogHexId::new("machine", &machine_prefix),
3407                outcome = "unreachable_no_network",
3408                "network layer not initialised"
3409            );
3410            return Ok(connectivity::ConnectOutcome::Unreachable);
3411        };
3412
3413        let peer_id = ant_quic::PeerId(machine.machine_id.0);
3414        if network.is_connected(&peer_id).await {
3415            for agent_id in &machine.agent_ids {
3416                self.direct_messaging
3417                    .mark_connected(*agent_id, machine.machine_id)
3418                    .await;
3419            }
3420            return if let Some(addr) = machine.addresses.first() {
3421                Ok(connectivity::ConnectOutcome::Direct(*addr))
3422            } else {
3423                Ok(connectivity::ConnectOutcome::AlreadyConnected)
3424            };
3425        }
3426
3427        let info = connectivity::ReachabilityInfo::from_discovered_machine(&machine);
3428        let v4_addrs = info.addresses.iter().filter(|a| a.is_ipv4()).count();
3429        let v6_addrs = info.addresses.len() - v4_addrs;
3430        tracing::info!(
3431            target: "x0x::connect",
3432            stage = "connect_to_machine",
3433            %machine_prefix,
3434            addr_total = info.addresses.len(),
3435            v4_addrs,
3436            v6_addrs,
3437            can_receive_direct = ?info.can_receive_direct,
3438            should_attempt_direct = info.should_attempt_direct(),
3439            needs_coordination = info.needs_coordination(),
3440            "machine reachability classified"
3441        );
3442
3443        if info.addresses.is_empty() {
3444            return Ok(connectivity::ConnectOutcome::Unreachable);
3445        }
3446
3447        let dial_timeout = std::time::Duration::from_secs(8);
3448        let local_probe_timeout = std::time::Duration::from_secs(3);
3449        let direct_probe_addrs = local_direct_probe_addrs(&info.addresses);
3450
3451        for addr in &direct_probe_addrs {
3452            match tokio::time::timeout(
3453                local_probe_timeout,
3454                network.connect_peer_with_addrs(peer_id, vec![*addr]),
3455            )
3456            .await
3457            {
3458                Ok(Ok((selected_addr, connected_peer_id))) if connected_peer_id == peer_id => {
3459                    if let Some(ref bc) = self.bootstrap_cache {
3460                        bc.add_from_connection(connected_peer_id, vec![selected_addr], None)
3461                            .await;
3462                    }
3463                    for agent_id in &machine.agent_ids {
3464                        self.direct_messaging
3465                            .mark_connected(*agent_id, machine.machine_id)
3466                            .await;
3467                    }
3468                    tracing::info!(
3469                        target: "x0x::connect",
3470                        stage = "connect_to_machine",
3471                        %machine_prefix,
3472                        strategy = "local_direct_first",
3473                        outcome = "direct",
3474                        selected_addr = %selected_addr,
3475                        dur_ms = call_start.elapsed().as_millis() as u64,
3476                        "machine local direct dial succeeded"
3477                    );
3478                    return Ok(connectivity::ConnectOutcome::Direct(selected_addr));
3479                }
3480                Ok(Ok((selected_addr, connected_peer_id))) => {
3481                    tracing::warn!(
3482                        target: "x0x::connect",
3483                        stage = "connect_to_machine",
3484                        machine_prefix = %crate::logging::LogHexId::new("machine", &machine_prefix),
3485                        requested_addr = %crate::logging::LogHexId::addr(&addr.to_string()),
3486                        selected_addr = %crate::logging::LogHexId::addr(&selected_addr.to_string()),
3487                        connected_machine_prefix = %crate::logging::LogHexId::new("machine", &network::hex_prefix(&connected_peer_id.0, 4)),
3488                        "machine local direct dial reached unexpected peer"
3489                    );
3490                }
3491                Ok(Err(e)) => {
3492                    tracing::debug!(
3493                        target: "x0x::connect",
3494                        stage = "connect_to_machine",
3495                        %machine_prefix,
3496                        strategy = "local_direct_first",
3497                        %addr,
3498                        error = %e,
3499                        "machine local direct dial failed"
3500                    );
3501                }
3502                Err(_) => {
3503                    tracing::debug!(
3504                        target: "x0x::connect",
3505                        stage = "connect_to_machine",
3506                        %machine_prefix,
3507                        strategy = "local_direct_first",
3508                        %addr,
3509                        timeout_s = local_probe_timeout.as_secs(),
3510                        "machine local direct dial timed out"
3511                    );
3512                }
3513            }
3514
3515            match tokio::time::timeout(local_probe_timeout, network.connect_addr(*addr)).await {
3516                Ok(Ok(connected_peer_id)) if connected_peer_id == peer_id => {
3517                    if let Some(ref bc) = self.bootstrap_cache {
3518                        bc.add_from_connection(connected_peer_id, vec![*addr], None)
3519                            .await;
3520                    }
3521                    for agent_id in &machine.agent_ids {
3522                        self.direct_messaging
3523                            .mark_connected(*agent_id, machine.machine_id)
3524                            .await;
3525                    }
3526                    tracing::info!(
3527                        target: "x0x::connect",
3528                        stage = "connect_to_machine",
3529                        %machine_prefix,
3530                        strategy = "local_direct_raw_fallback",
3531                        outcome = "direct",
3532                        selected_addr = %addr,
3533                        dur_ms = call_start.elapsed().as_millis() as u64,
3534                        "verified machine local raw-address fallback succeeded"
3535                    );
3536                    return Ok(connectivity::ConnectOutcome::Direct(*addr));
3537                }
3538                Ok(Ok(connected_peer_id)) => {
3539                    tracing::warn!(
3540                        target: "x0x::connect",
3541                        stage = "connect_to_machine",
3542                        machine_prefix = %crate::logging::LogHexId::new("machine", &machine_prefix),
3543                        strategy = "local_direct_raw_fallback",
3544                        addr = %crate::logging::LogHexId::addr(&addr.to_string()),
3545                        connected_machine_prefix = %crate::logging::LogHexId::new("machine", &network::hex_prefix(&connected_peer_id.0, 4)),
3546                        "verified machine local raw-address fallback reached unexpected peer"
3547                    );
3548                }
3549                Ok(Err(e)) => {
3550                    tracing::debug!(
3551                        target: "x0x::connect",
3552                        stage = "connect_to_machine",
3553                        %machine_prefix,
3554                        strategy = "local_direct_raw_fallback",
3555                        %addr,
3556                        error = %e,
3557                        "verified machine local raw-address fallback failed"
3558                    );
3559                }
3560                Err(_) => {
3561                    tracing::debug!(
3562                        target: "x0x::connect",
3563                        stage = "connect_to_machine",
3564                        %machine_prefix,
3565                        strategy = "local_direct_raw_fallback",
3566                        %addr,
3567                        timeout_s = local_probe_timeout.as_secs(),
3568                        "verified machine local raw-address fallback timed out"
3569                    );
3570                }
3571            }
3572        }
3573
3574        network
3575            .upsert_peer_hints(peer_id, info.addresses.clone(), None)
3576            .await
3577            .map_err(|e| {
3578                error::IdentityError::Storage(std::io::Error::other(format!(
3579                    "failed to upsert machine peer hints: {e}"
3580                )))
3581            })?;
3582
3583        match tokio::time::timeout(
3584            dial_timeout,
3585            network.connect_peer_with_addrs(peer_id, info.addresses.clone()),
3586        )
3587        .await
3588        {
3589            Ok(Ok((addr, verified_peer_id))) if verified_peer_id == peer_id => {
3590                if let Some(ref bc) = self.bootstrap_cache {
3591                    bc.add_from_connection(verified_peer_id, vec![addr], None)
3592                        .await;
3593                    bc.record_success(&verified_peer_id, 0).await;
3594                }
3595                for agent_id in &machine.agent_ids {
3596                    self.direct_messaging
3597                        .mark_connected(*agent_id, machine.machine_id)
3598                        .await;
3599                }
3600                tracing::info!(
3601                    target: "x0x::connect",
3602                    stage = "connect_to_machine",
3603                    %machine_prefix,
3604                    strategy = "hinted_peer",
3605                    outcome = "coordinated",
3606                    selected_addr = %addr,
3607                    dur_ms = call_start.elapsed().as_millis() as u64,
3608                    "machine peer-authenticated dial succeeded"
3609                );
3610                return Ok(connectivity::ConnectOutcome::Coordinated(addr));
3611            }
3612            Ok(Ok((addr, verified_peer_id))) => {
3613                tracing::warn!(
3614                    target: "x0x::connect",
3615                    stage = "connect_to_machine",
3616                    machine_prefix = %crate::logging::LogHexId::new("machine", &machine_prefix),
3617                    selected_addr = %crate::logging::LogHexId::addr(&addr.to_string()),
3618                    verified_machine_prefix = %crate::logging::LogHexId::new("machine", &network::hex_prefix(&verified_peer_id.0, 4)),
3619                    "machine dial reached unexpected peer"
3620                );
3621            }
3622            Ok(Err(e)) => {
3623                tracing::debug!(
3624                    target: "x0x::connect",
3625                    stage = "connect_to_machine",
3626                    %machine_prefix,
3627                    error = %e,
3628                    "machine peer-authenticated dial failed"
3629                );
3630            }
3631            Err(_) => {
3632                tracing::debug!(
3633                    target: "x0x::connect",
3634                    stage = "connect_to_machine",
3635                    %machine_prefix,
3636                    timeout_s = dial_timeout.as_secs(),
3637                    "machine peer-authenticated dial timed out"
3638                );
3639            }
3640        }
3641
3642        if info.should_attempt_direct() {
3643            for addr in &info.addresses {
3644                if direct_probe_addrs.contains(addr) {
3645                    continue;
3646                }
3647                match tokio::time::timeout(dial_timeout, network.connect_addr(*addr)).await {
3648                    Ok(Ok(connected_peer_id)) if connected_peer_id == peer_id => {
3649                        if let Some(ref bc) = self.bootstrap_cache {
3650                            bc.add_from_connection(connected_peer_id, vec![*addr], None)
3651                                .await;
3652                        }
3653                        for agent_id in &machine.agent_ids {
3654                            self.direct_messaging
3655                                .mark_connected(*agent_id, machine.machine_id)
3656                                .await;
3657                        }
3658                        tracing::info!(
3659                            target: "x0x::connect",
3660                            stage = "connect_to_machine",
3661                            %machine_prefix,
3662                            strategy = "direct_per_addr",
3663                            outcome = "direct",
3664                            selected_addr = %addr,
3665                            dur_ms = call_start.elapsed().as_millis() as u64,
3666                            "machine direct dial succeeded"
3667                        );
3668                        return Ok(connectivity::ConnectOutcome::Direct(*addr));
3669                    }
3670                    Ok(Ok(connected_peer_id)) => {
3671                        tracing::warn!(
3672                            target: "x0x::connect",
3673                            stage = "connect_to_machine",
3674                            machine_prefix = %crate::logging::LogHexId::new("machine", &machine_prefix),
3675                            addr = %crate::logging::LogHexId::addr(&addr.to_string()),
3676                            connected_machine_prefix = %crate::logging::LogHexId::new("machine", &network::hex_prefix(&connected_peer_id.0, 4)),
3677                            "machine direct dial reached unexpected peer"
3678                        );
3679                    }
3680                    Ok(Err(e)) => {
3681                        tracing::debug!(
3682                            target: "x0x::connect",
3683                            stage = "connect_to_machine",
3684                            %machine_prefix,
3685                            %addr,
3686                            error = %e,
3687                            "machine direct dial failed"
3688                        );
3689                    }
3690                    Err(_) => {
3691                        tracing::debug!(
3692                            target: "x0x::connect",
3693                            stage = "connect_to_machine",
3694                            %machine_prefix,
3695                            %addr,
3696                            timeout_s = dial_timeout.as_secs(),
3697                            "machine direct dial timed out"
3698                        );
3699                    }
3700                }
3701            }
3702        }
3703
3704        tracing::warn!(
3705            target: "x0x::connect",
3706            stage = "connect_to_machine",
3707            machine_prefix = %crate::logging::LogHexId::new("machine", &machine_prefix),
3708            outcome = "unreachable",
3709            reason = "all_strategies_exhausted",
3710            dur_ms = call_start.elapsed().as_millis() as u64,
3711            v4_addrs,
3712            v6_addrs,
3713            "all machine connection strategies exhausted"
3714        );
3715        Ok(connectivity::ConnectOutcome::Unreachable)
3716    }
3717
3718    /// Spawn a background task whose handle is tracked for deterministic
3719    /// teardown. Returns without spawning once the registry is `closed` (i.e.
3720    /// `shutdown()` has begun) — this is what closes the join_network race:
3721    /// a listener requested after shutdown started must never run.
3722    fn spawn_tracked<F>(&self, fut: F)
3723    where
3724        F: std::future::Future<Output = ()> + Send + 'static,
3725    {
3726        // Lock held only to inspect `closed` and push the handle; no await
3727        // happens under it (Rule: keep the std::Mutex hold trivially short).
3728        let mut guard = match self.tracked_tasks.lock() {
3729            Ok(guard) => guard,
3730            Err(poisoned) => poisoned.into_inner(),
3731        };
3732        if guard.closed {
3733            return;
3734        }
3735        guard.handles.push(tokio::spawn(fut));
3736    }
3737
3738    /// Begin shutdown WITHOUT tearing anything down yet: cancel the shutdown
3739    /// token and close the tracked-task registry. Idempotent and synchronous.
3740    ///
3741    /// This is the prefix of `shutdown()`, split out so an embedder/server can
3742    /// cancel BEFORE draining its own background tasks (notably a still-running
3743    /// `join_network`). Once this returns, every `start_*` helper refuses to
3744    /// start (they check `shutdown_token.is_cancelled()` under their handle
3745    /// lock) and `spawn_tracked` refuses to spawn — so a `join_network` that is
3746    /// still finishing cannot leak a heartbeat/reaper/presence/advert/inbox
3747    /// task past the subsequent `shutdown()`. `shutdown()` still cancels the
3748    /// token itself (idempotent), so calling `shutdown()` alone remains correct.
3749    pub fn begin_shutdown(&self) {
3750        self.shutdown_token.cancel();
3751        let mut guard = match self.tracked_tasks.lock() {
3752            Ok(guard) => guard,
3753            Err(poisoned) => poisoned.into_inner(),
3754        };
3755        guard.closed = true;
3756    }
3757
3758    /// Save the bootstrap cache and release resources.
3759    ///
3760    /// Call this before dropping the agent to ensure the peer cache is
3761    /// persisted to disk. The background maintenance task saves periodically,
3762    /// but this guarantees a final save.
3763    ///
3764    /// Shutdown ordering is deliberate: the cancellation token is cancelled and
3765    /// the listener tasks are stopped *before* the gossip runtime and the
3766    /// network node are torn down. The listeners call into the network (e.g.
3767    /// `is_connected`), so stopping them first avoids a Phase-2-style hang where
3768    /// a listener blocks on a transport that is concurrently shutting down.
3769    /// Idempotent: `cancel()` is idempotent, the registry drains empty on a
3770    /// second call, and the `stop_*` helpers use `Option::take`.
3771    pub async fn shutdown(&self) {
3772        // 1. Signal every token-aware loop to break. Inert until now, so this
3773        //    is the first thing that changes steady-state behavior.
3774        self.shutdown_token.cancel();
3775
3776        // 2. Stop the simple Option<JoinHandle> background tasks.
3777        self.stop_identity_heartbeat().await;
3778        self.stop_discovery_cache_reaper().await;
3779
3780        // 3. Stop the DM inbox and the capability advert service (current gaps:
3781        //    neither was stopped by shutdown() before). Both abort their own
3782        //    spawned tasks.
3783        self.stop_dm_inbox().await;
3784        {
3785            let service = {
3786                let mut guard = self.capability_advert_service.lock().await;
3787                guard.take()
3788            };
3789            if let Some(service) = service {
3790                service.abort();
3791            }
3792        }
3793
3794        // 4. Drain the tracked-task registry: mark closed (so any in-flight
3795        //    spawn_tracked is refused), take the handles, grace-await them all
3796        //    under a SINGLE bounded budget (not per-task — keeps shutdown
3797        //    prompt), then abort+await any straggler. A cancelled/aborted task
3798        //    yields Err(JoinError); that is expected, never unwrap it.
3799        let handles = {
3800            let mut guard = match self.tracked_tasks.lock() {
3801                Ok(guard) => guard,
3802                Err(poisoned) => poisoned.into_inner(),
3803            };
3804            guard.closed = true;
3805            std::mem::take(&mut guard.handles)
3806        };
3807        if !handles.is_empty() {
3808            // Grace-await all tracked tasks under a SINGLE bounded budget (not
3809            // per-task — keeps shutdown prompt regardless of task count). On
3810            // timeout, abort the stragglers and await the aborts so none
3811            // outlives shutdown(). The select! keeps the JoinHandles owned by
3812            // the awaiting future so the post-abort join can still observe each
3813            // task's terminal JoinError (cancelled tasks → Err — never
3814            // unwrapped).
3815            let abort_handles: Vec<tokio::task::AbortHandle> =
3816                handles.iter().map(|h| h.abort_handle()).collect();
3817            let mut join = futures::future::join_all(handles);
3818            tokio::select! {
3819                _results = &mut join => {}
3820                _ = tokio::time::sleep(std::time::Duration::from_secs(3)) => {
3821                    tracing::warn!(
3822                        "Agent background tasks did not stop within grace; aborting stragglers"
3823                    );
3824                    for handle in &abort_handles {
3825                        handle.abort();
3826                    }
3827                    let _results: Vec<Result<(), tokio::task::JoinError>> = join.await;
3828                }
3829            }
3830        }
3831
3832        // Shut down presence beacons.
3833        if let Some(ref pw) = self.presence {
3834            pw.shutdown().await;
3835            tracing::info!("Presence system shut down");
3836        }
3837
3838        if let Some(ref cache) = self.bootstrap_cache {
3839            if let Err(e) = cache.save().await {
3840                tracing::warn!("Failed to save bootstrap cache on shutdown: {e}");
3841            } else {
3842                tracing::info!("Bootstrap cache saved on shutdown");
3843            }
3844        }
3845
3846        // Issue #110 Phase 2: tear down the gossip runtime and the QUIC node so
3847        // an in-process embedder gets the socket and all background tasks back
3848        // when `shutdown()` returns. The gossip runtime's dispatcher/peer-sync/
3849        // keepalive tasks hold the transport (and thus the ant-quic endpoint)
3850        // alive; the daemon binary survives only by process exit, but an
3851        // embedded host needs these released to re-`serve()` on the same port.
3852        if let Some(ref runtime) = self.gossip_runtime {
3853            if let Err(e) = runtime.shutdown().await {
3854                tracing::warn!("Gossip runtime shutdown error: {e}");
3855            } else {
3856                tracing::info!("Gossip runtime shut down");
3857            }
3858        }
3859        if let Some(ref network) = self.network {
3860            network.shutdown().await;
3861            tracing::info!("Network node shut down");
3862        }
3863    }
3864
3865    async fn stop_identity_heartbeat(&self) {
3866        let handle = {
3867            let mut handle_guard = self.heartbeat_handle.lock().await;
3868            handle_guard.take()
3869        };
3870
3871        if let Some(handle) = handle {
3872            handle.abort();
3873            match handle.await {
3874                Ok(()) => tracing::debug!("Identity heartbeat task stopped"),
3875                Err(e) if e.is_cancelled() => {
3876                    tracing::debug!("Identity heartbeat task aborted")
3877                }
3878                Err(e) => tracing::warn!("Identity heartbeat task failed during shutdown: {e}"),
3879            }
3880        }
3881    }
3882
3883    async fn stop_discovery_cache_reaper(&self) {
3884        let handle = {
3885            let mut handle_guard = self.discovery_cache_reaper_handle.lock().await;
3886            handle_guard.take()
3887        };
3888
3889        if let Some(handle) = handle {
3890            handle.abort();
3891            match handle.await {
3892                Ok(()) => tracing::debug!("Discovery cache reaper stopped"),
3893                Err(e) if e.is_cancelled() => {
3894                    tracing::debug!("Discovery cache reaper aborted")
3895                }
3896                Err(e) => tracing::warn!("Discovery cache reaper failed during shutdown: {e}"),
3897            }
3898        }
3899    }
3900
3901    /// Background task body: periodically prunes the three discovery caches
3902    /// using the same TTL logic as the query paths (last_seen >= cutoff).
3903    /// This is the active counterpart to the previous read-only filtering,
3904    /// preventing unbounded accumulation on long-running daemons.
3905    async fn discovery_cache_reaper_loop(
3906        identity_cache: std::sync::Arc<
3907            tokio::sync::RwLock<std::collections::HashMap<identity::AgentId, DiscoveredAgent>>,
3908        >,
3909        machine_cache: std::sync::Arc<
3910            tokio::sync::RwLock<std::collections::HashMap<identity::MachineId, DiscoveredMachine>>,
3911        >,
3912        user_cache: std::sync::Arc<
3913            tokio::sync::RwLock<std::collections::HashMap<identity::UserId, DiscoveredUser>>,
3914        >,
3915        ttl_secs: u64,
3916        interval: std::time::Duration,
3917    ) {
3918        loop {
3919            tokio::time::sleep(interval).await;
3920            let cutoff = Self::unix_timestamp_secs().saturating_sub(ttl_secs);
3921            // Identity cache
3922            {
3923                let mut c = identity_cache.write().await;
3924                c.retain(|_, a| a.last_seen >= cutoff);
3925            }
3926            // Machine cache
3927            {
3928                let mut c = machine_cache.write().await;
3929                c.retain(|_, m| m.last_seen >= cutoff);
3930            }
3931            // User cache
3932            {
3933                let mut c = user_cache.write().await;
3934                c.retain(|_, u| u.last_seen >= cutoff);
3935            }
3936        }
3937    }
3938
3939    async fn start_discovery_cache_reaper(&self) -> error::Result<()> {
3940        let mut guard = self.discovery_cache_reaper_handle.lock().await;
3941        // Shutdown race (issue #116): refuse to start once shutdown began.
3942        // Checked under the same lock stop_discovery_cache_reaper takes from.
3943        if self.shutdown_token.is_cancelled() {
3944            return Ok(());
3945        }
3946        if guard.is_some() {
3947            return Ok(());
3948        }
3949
3950        let identity = std::sync::Arc::clone(&self.identity_discovery_cache);
3951        let machine = std::sync::Arc::clone(&self.machine_discovery_cache);
3952        let user = std::sync::Arc::clone(&self.user_discovery_cache);
3953        let ttl = self.identity_ttl_secs;
3954        let interval = std::time::Duration::from_secs(DISCOVERY_CACHE_REAPER_INTERVAL_SECS);
3955
3956        let handle = tokio::spawn(Self::discovery_cache_reaper_loop(
3957            identity, machine, user, ttl, interval,
3958        ));
3959        *guard = Some(handle);
3960        Ok(())
3961    }
3962
3963    // === Direct Messaging ===
3964
3965    /// Send data directly to a connected agent.
3966    ///
3967    /// This bypasses gossip pub/sub for efficient point-to-point communication.
3968    /// The agent must be connected first via [`Self::connect_to_agent`].
3969    ///
3970    /// # Arguments
3971    ///
3972    /// * `agent_id` - The target agent's identifier.
3973    /// * `payload` - The data to send.
3974    ///
3975    /// # Errors
3976    ///
3977    /// Returns an error if:
3978    /// - Network is not initialized
3979    /// - Agent is not connected
3980    /// - Agent is not found in discovery cache
3981    /// - Send fails
3982    ///
3983    /// # Example
3984    ///
3985    /// ```rust,ignore
3986    /// // First connect to the agent
3987    /// let outcome = agent.connect_to_agent(&target_agent_id).await?;
3988    ///
3989    /// // Then send data directly
3990    /// agent.send_direct(&target_agent_id, b"hello".to_vec()).await?;
3991    /// ```
3992    /// Send data directly to an agent — capability-aware dispatch.
3993    ///
3994    /// Looks up the recipient's `DmCapabilities` in the local
3995    /// [`dm_capability::CapabilityStore`]. If the recipient advertises
3996    /// `gossip_inbox=true` with a non-empty `kem_public_key`, the send
3997    /// goes via the gossip DM path (signed+encrypted envelope published
3998    /// to the recipient's inbox topic with an application-layer ACK).
3999    /// Otherwise, falls back to the legacy raw-QUIC stream.
4000    ///
4001    /// # Errors
4002    ///
4003    /// See [`dm::DmError`].
4004    ///
4005    /// Uses the default capability-aware policy. Call
4006    /// [`Self::send_direct_with_config`] to opt into the raw-QUIC fast path
4007    /// explicitly.
4008    pub async fn send_direct(
4009        &self,
4010        to: &identity::AgentId,
4011        payload: Vec<u8>,
4012    ) -> Result<dm::DmReceipt, dm::DmError> {
4013        self.send_direct_with_config(to, payload, dm::DmSendConfig::default())
4014            .await
4015    }
4016
4017    async fn dm_peer_rtt_ms(&self, agent_id: &identity::AgentId) -> Option<u32> {
4018        let registry_machine_id = self.direct_messaging.get_machine_id(agent_id).await;
4019        let cached_machine_id = {
4020            let cache = self.identity_discovery_cache.read().await;
4021            cache
4022                .get(agent_id)
4023                .map(|entry| entry.machine_id)
4024                .filter(|machine_id| machine_id.0 != [0_u8; 32])
4025        };
4026        let machine_id = registry_machine_id.or(cached_machine_id)?;
4027        let peer = self
4028            .bootstrap_cache
4029            .as_ref()?
4030            .get(&ant_quic::PeerId(machine_id.0))
4031            .await?;
4032        (peer.stats.avg_rtt_ms > 0).then_some(peer.stats.avg_rtt_ms)
4033    }
4034
4035    /// X0X-0041: build a prefer-newest-connection hint for the gossip-DM
4036    /// retry loop. Returns `None` when the recipient's machine_id is unknown
4037    /// (e.g. uninitialised discovery cache); in that case the gossip path
4038    /// reverts to legacy behaviour and serves out the full backoff window.
4039    async fn dm_lifecycle_hint(
4040        &self,
4041        agent_id: &identity::AgentId,
4042    ) -> Option<dm_send::DmLifecycleHint> {
4043        let registry_machine_id = self.direct_messaging.get_machine_id(agent_id).await;
4044        let cached_machine_id = {
4045            let cache = self.identity_discovery_cache.read().await;
4046            cache
4047                .get(agent_id)
4048                .map(|entry| entry.machine_id)
4049                .filter(|machine_id| machine_id.0 != [0_u8; 32])
4050        };
4051        let machine_id = registry_machine_id.or(cached_machine_id)?;
4052        Some(dm_send::DmLifecycleHint {
4053            recipient_machine_id: machine_id,
4054            replaced_rx: self.direct_messaging.subscribe_lifecycle_replaced(),
4055        })
4056    }
4057
4058    async fn dm_peer_likely_offline(
4059        &self,
4060        agent_id: &identity::AgentId,
4061    ) -> Option<(f64, Option<u64>)> {
4062        if self.is_agent_connected(agent_id).await {
4063            return None;
4064        }
4065        let last_seen = {
4066            let cache = self.identity_discovery_cache.read().await;
4067            cache.get(agent_id).map(|entry| entry.last_seen)
4068        }?;
4069        let now_secs = Self::unix_timestamp_secs();
4070        let age_secs = now_secs.saturating_sub(last_seen);
4071        let heartbeat = self.heartbeat_interval_secs.max(1);
4072        let phi = age_secs as f64 / heartbeat as f64;
4073        (phi > 8.0).then_some((phi, Some(age_secs.saturating_mul(1000))))
4074    }
4075
4076    /// Like [`Self::send_direct`] with caller-provided [`dm::DmSendConfig`].
4077    ///
4078    /// # Errors
4079    ///
4080    /// See [`dm::DmError`].
4081    pub async fn send_direct_with_config(
4082        &self,
4083        to: &identity::AgentId,
4084        payload: Vec<u8>,
4085        config: dm::DmSendConfig,
4086    ) -> Result<dm::DmReceipt, dm::DmError> {
4087        if *to == self.identity.agent_id() {
4088            self.direct_messaging.record_outgoing_started(*to, None);
4089            if payload.len() > direct::MAX_DIRECT_PAYLOAD_SIZE {
4090                self.direct_messaging.record_outgoing_failed(*to);
4091                return Err(dm::DmError::PayloadTooLarge {
4092                    len: payload.len(),
4093                    max: direct::MAX_DIRECT_PAYLOAD_SIZE,
4094                });
4095            }
4096
4097            let delivered = self
4098                .direct_messaging
4099                .handle_loopback(
4100                    self.identity.machine_id(),
4101                    self.identity.agent_id(),
4102                    payload,
4103                )
4104                .await;
4105            let receipt = dm_send::loopback_receipt();
4106            self.direct_messaging
4107                .record_outgoing_succeeded(*to, receipt.path);
4108            tracing::debug!(
4109                target: "dm.trace",
4110                stage = "outbound_send_returned_ok",
4111                request_id = %hex::encode(receipt.request_id),
4112                sender = %hex::encode(self.identity.agent_id().as_bytes()),
4113                recipient = %hex::encode(to.as_bytes()),
4114                path = "loopback",
4115                delivered_subscribers = delivered,
4116            );
4117            return Ok(receipt);
4118        }
4119
4120        let advert_cap = self.capability_store.lookup(to);
4121        let advert_gossip_ready = advert_cap
4122            .as_ref()
4123            .is_some_and(|caps| caps.gossip_inbox && !caps.kem_public_key.is_empty());
4124        let (cap, cap_source) = if advert_gossip_ready {
4125            (advert_cap, "advert_cache")
4126        } else {
4127            let contact_cap = {
4128                let contacts = self.contact_store.read().await;
4129                contacts.get(to).and_then(|contact| {
4130                    contact
4131                        .dm_capabilities
4132                        .as_ref()
4133                        .filter(|caps| caps.gossip_inbox && !caps.kem_public_key.is_empty())
4134                        .cloned()
4135                })
4136            };
4137            match contact_cap {
4138                Some(cap) if advert_cap.is_some() => {
4139                    (Some(cap), "contact_card_after_unusable_advert")
4140                }
4141                Some(cap) => (Some(cap), "contact_card"),
4142                None if advert_cap.is_some() => (advert_cap, "advert_cache_unusable"),
4143                None => (None, "none"),
4144            }
4145        };
4146        let gossip_ok = cap
4147            .as_ref()
4148            .map(|c| c.gossip_inbox && !c.kem_public_key.is_empty())
4149            .unwrap_or(false);
4150        tracing::debug!(
4151            target: "dm.trace",
4152            stage = "capability_lookup",
4153            recipient = %hex::encode(to.as_bytes()),
4154            hit = cap.is_some(),
4155            gossip_ok,
4156            source = cap_source,
4157            capability_store_entries = self.capability_store.len(),
4158        );
4159
4160        // X0X-0070b: seed the relay fallback. Only retain the payload + KEM
4161        // key clone when the engine is enabled AND we have a key to seal
4162        // a fresh envelope with. With the default disabled policy this
4163        // closure never runs - the happy path pays nothing.
4164        let relay_seed: Option<(Vec<u8>, Vec<u8>)> = if self.peer_relay.policy().enabled {
4165            cap.as_ref()
4166                .filter(|c| !c.kem_public_key.is_empty())
4167                .map(|c| (payload.clone(), c.kem_public_key.clone()))
4168        } else {
4169            None
4170        };
4171
4172        let rtt_hint_ms = self.dm_peer_rtt_ms(to).await;
4173        let mut config = config;
4174        // Direct transport RTT is a valid hint for raw-QUIC work, but it is
4175        // not a reliable bound for the gossip-inbox ACK path. Keep the
4176        // conservative default for PubSub-backed DMs unless the caller passed
4177        // an explicit timeout.
4178        if !gossip_ok && config.timeout_per_attempt == dm::dm_attempt_timeout(None) {
4179            config.timeout_per_attempt = dm::dm_attempt_timeout(rtt_hint_ms);
4180        }
4181        self.direct_messaging
4182            .record_outgoing_started(*to, rtt_hint_ms);
4183        if let Some((phi, last_seen_ms_ago)) = self.dm_peer_likely_offline(to).await {
4184            self.direct_messaging.record_outgoing_failed(*to);
4185            return Err(dm::DmError::PeerLikelyOffline {
4186                phi,
4187                last_seen_ms_ago,
4188            });
4189        }
4190
4191        let mut preferred_raw_err = None;
4192        let prefer_newest_grace = std::time::Duration::from_millis(config.prefer_newest_grace_ms);
4193        let preferred_raw_receipt = if config.prefer_raw_quic_if_connected && !config.require_gossip
4194        {
4195            match self
4196                .send_direct_raw_quic(
4197                    to,
4198                    &payload,
4199                    config.raw_quic_receive_ack_timeout,
4200                    prefer_newest_grace,
4201                )
4202                .await
4203            {
4204                Ok(path) => Some(dm_send::raw_quic_receipt_for_path(path)),
4205                Err(e) => {
4206                    tracing::debug!(
4207                        target: "x0x::direct",
4208                        recipient = %hex::encode(to.as_bytes()),
4209                        error = %e,
4210                        "preferred raw-QUIC path unavailable; falling back to capability-aware send"
4211                    );
4212                    preferred_raw_err = Some(e);
4213                    None
4214                }
4215            }
4216        } else {
4217            None
4218        };
4219
4220        let result = if let Some(receipt) = preferred_raw_receipt {
4221            Ok(receipt)
4222        } else if preferred_raw_err.as_ref().is_some_and(|err| {
4223            config.stop_fallback_on_raw_error
4224                || Self::raw_quic_error_should_stop_fallback(err, gossip_ok)
4225        }) {
4226            match preferred_raw_err.take() {
4227                Some(e) => Err(Self::map_raw_quic_dm_error(e)),
4228                None => Err(dm::DmError::NoConnectivity(
4229                    "raw-QUIC send failed before gossip fallback".to_string(),
4230                )),
4231            }
4232        } else if gossip_ok {
4233            match self.gossip_runtime.as_ref() {
4234                Some(runtime) => {
4235                    let signing =
4236                        gossip::SigningContext::from_keypair(self.identity.agent_keypair());
4237                    let kem_pub = cap
4238                        .as_ref()
4239                        .map(|c| c.kem_public_key.clone())
4240                        .unwrap_or_default();
4241                    // X0X-0041: build a lifecycle hint when the recipient's
4242                    // machine_id is known. The retry loop short-circuits on
4243                    // `Replaced` for that machine_id rather than serving out
4244                    // the full backoff window.
4245                    let lifecycle_hint = self.dm_lifecycle_hint(to).await;
4246                    dm_send::send_via_gossip(
4247                        dm_send::DmSendContext {
4248                            pubsub: std::sync::Arc::clone(runtime.pubsub()),
4249                            signing: &signing,
4250                            self_agent_id: self.identity.agent_id(),
4251                            self_machine_id: self.identity.machine_id(),
4252                            inflight: std::sync::Arc::clone(&self.dm_inflight_acks),
4253                        },
4254                        *to,
4255                        &kem_pub,
4256                        payload,
4257                        &config,
4258                        lifecycle_hint,
4259                    )
4260                    .await
4261                }
4262                None => Err(dm::DmError::LocalGossipUnavailable(
4263                    "send_direct: no gossip runtime configured".to_string(),
4264                )),
4265            }
4266        } else if config.require_gossip {
4267            Err(dm::DmError::RecipientKeyUnavailable(format!(
4268                "recipient {} has no gossip DM capability advert",
4269                hex::encode(to.as_bytes())
4270            )))
4271        } else {
4272            match preferred_raw_err {
4273                Some(e) => Err(Self::map_raw_quic_dm_error(e)),
4274                None => self
4275                    .send_direct_raw_quic(
4276                        to,
4277                        &payload,
4278                        config.raw_quic_receive_ack_timeout,
4279                        prefer_newest_grace,
4280                    )
4281                    .await
4282                    .map(dm_send::raw_quic_receipt_for_path)
4283                    .map_err(Self::map_raw_quic_dm_error),
4284            }
4285        };
4286
4287        match result {
4288            Ok(receipt) => {
4289                self.direct_messaging
4290                    .record_outgoing_succeeded(*to, receipt.path);
4291                // X0X-0070b: every direct-DM success clears the relay engine's
4292                // per-peer failure history. A peer that had crossed
4293                // `needs_relay` and now recovers a direct path increments
4294                // `direct_recovered_after_relay` exactly once - proving the
4295                // fallback is transient.
4296                self.peer_relay.record_direct_success(to);
4297                Ok(receipt)
4298            }
4299            Err(direct_err) => {
4300                self.direct_messaging.record_outgoing_failed(*to);
4301                // X0X-0070b: count this direct-DM failure on the relay engine.
4302                // With the default disabled policy `needs_relay` always
4303                // returns `false` and the fallback below is skipped. With
4304                // an opted-in policy this drives the relay decision.
4305                self.peer_relay.record_direct_failure(to);
4306                // X0X-0070b: relay fallback. We only attempt it when the
4307                // engine says the peer has now crossed `needs_relay`, and
4308                // only when we have both a saved payload and a recipient
4309                // KEM key - without those the relay envelope can't be
4310                // sealed. On ANY relay-side failure we surface the
4311                // ORIGINAL direct error so the caller's view stays
4312                // consistent with the path that was actually tried.
4313                if let Some((saved_payload, kem_pub)) = relay_seed {
4314                    if self.peer_relay.needs_relay(to) {
4315                        match self.try_relay_fallback(to, saved_payload, &kem_pub).await {
4316                            Ok(relay_receipt) => {
4317                                self.direct_messaging
4318                                    .record_outgoing_succeeded(*to, relay_receipt.path);
4319                                return Ok(relay_receipt);
4320                            }
4321                            Err(relay_err) => {
4322                                tracing::debug!(
4323                                    target: "x0x::relay",
4324                                    recipient = %hex::encode(to.as_bytes()),
4325                                    direct_err = %direct_err,
4326                                    relay_err = %relay_err,
4327                                    "X0X-0070b relay fallback failed; surfacing original direct error"
4328                                );
4329                            }
4330                        }
4331                    }
4332                }
4333                Err(direct_err)
4334            }
4335        }
4336    }
4337
4338    /// X0X-0070b: wrap `payload` in a fresh sealed [`dm::DmEnvelope`] +
4339    /// [`peer_relay::RelayedDm`], pick a relay candidate via
4340    /// [`peer_relay::PeerRelay::select_relay`], and forward to that candidate
4341    /// over the same direct-DM transport using the dedicated
4342    /// [`network::RELAYED_DM_STREAM_TYPE`] stream-type. The relay verifies
4343    /// the [`peer_relay::RelayHeader`] signature, confirms it is being
4344    /// asked to forward (not to be the final recipient), and sends the
4345    /// inner envelope on to `to` - one hop only, no re-wrapping.
4346    ///
4347    /// # Errors
4348    ///
4349    /// - [`dm::DmError::NoRelayCandidate`] if no third-party candidate
4350    ///   exists or the candidate's `MachineId` is not in the discovery
4351    ///   cache (we need it to address the QUIC peer).
4352    /// - [`dm::DmError::RelayBuildFailed`] if signing the
4353    ///   [`peer_relay::RelayHeader`] or parsing the agent secret key
4354    ///   fails.
4355    /// - [`dm::DmError::EnvelopeConstruction`] if KEM encapsulation /
4356    ///   AEAD seal / envelope signature fails (delegates to
4357    ///   [`dm::EnvelopeBuilder::build_payload_envelope`]).
4358    /// - [`dm::DmError::NoConnectivity`] if no network is configured.
4359    /// - [`dm::DmError::PublishFailed`] if the underlying
4360    ///   [`network::NetworkNode::send_direct_typed`] send fails.
4361    async fn try_relay_fallback(
4362        &self,
4363        to: &identity::AgentId,
4364        payload: Vec<u8>,
4365        recipient_kem_public_key: &[u8],
4366    ) -> Result<dm::DmReceipt, dm::DmError> {
4367        let sender = self.identity.agent_id();
4368        let candidates = self.relay_candidates.read().await.clone();
4369        let Some(relay_agent) = self.peer_relay.select_relay(&candidates, to, &sender) else {
4370            return Err(dm::DmError::NoRelayCandidate);
4371        };
4372
4373        let relay_machine_id = {
4374            let cache = self.identity_discovery_cache.read().await;
4375            cache.get(&relay_agent).map(|e| e.machine_id)
4376        };
4377        let Some(relay_machine_id) = relay_machine_id else {
4378            // We have the relay candidate's agent_id but no machine_id -
4379            // can't address the QUIC peer. Treat as "no candidate" so the
4380            // caller surfaces the original direct error.
4381            return Err(dm::DmError::NoRelayCandidate);
4382        };
4383
4384        let now = dm::now_unix_ms();
4385        let expires = now.saturating_add(dm_send::DEFAULT_ENVELOPE_LIFETIME_MS);
4386        let request_id = dm_send::fresh_request_id();
4387        let signing = gossip::SigningContext::from_keypair(self.identity.agent_keypair());
4388        let envelope = dm::EnvelopeBuilder::build_payload_envelope(
4389            request_id,
4390            &sender,
4391            &self.identity.machine_id(),
4392            to,
4393            recipient_kem_public_key,
4394            now,
4395            expires,
4396            payload,
4397            |bytes| signing.sign(bytes).map_err(|e| e.to_string()),
4398        )?;
4399
4400        let (sender_pub_bytes, sender_sec_bytes) = self.identity.agent_keypair().to_bytes();
4401        let sender_secret = ant_quic::MlDsaSecretKey::from_bytes(&sender_sec_bytes)
4402            .map_err(|e| dm::DmError::RelayBuildFailed(format!("agent secret key: {e:?}")))?;
4403        let relayed = self
4404            .peer_relay
4405            .build_relayed_dm(to, &sender, sender_pub_bytes, now, envelope, |bytes| {
4406                ant_quic::crypto::raw_public_keys::pqc::sign_with_ml_dsa(&sender_secret, bytes)
4407                    .map(|s| s.as_bytes().to_vec())
4408                    .map_err(|e| format!("{e:?}"))
4409            })
4410            .map_err(dm::DmError::RelayBuildFailed)?;
4411
4412        let wire = postcard::to_allocvec(&relayed).map_err(|e| {
4413            dm::DmError::EnvelopeConstruction(format!("relayed envelope postcard: {e}"))
4414        })?;
4415        let network = self
4416            .network
4417            .as_ref()
4418            .ok_or_else(|| dm::DmError::NoConnectivity("no network for relay send".to_string()))?;
4419        let relay_peer_id = ant_quic::PeerId(relay_machine_id.0);
4420        network
4421            .send_direct_typed(
4422                &relay_peer_id,
4423                sender.as_bytes(),
4424                network::RELAYED_DM_STREAM_TYPE,
4425                &wire,
4426            )
4427            .await
4428            .map_err(|e| dm::DmError::PublishFailed(format!("relay send: {e}")))?;
4429
4430        Ok(dm::DmReceipt {
4431            request_id,
4432            accepted_at: std::time::Instant::now(),
4433            retries_used: 0,
4434            path: dm::DmPath::Relayed { via: relay_agent },
4435        })
4436    }
4437
4438    /// X0X-0070b: borrow the application-level peer-relay engine. Runtimes
4439    /// use this to read [`peer_relay::RelayStats`] for telemetry and to
4440    /// inspect the active [`peer_relay::RelayPolicy`].
4441    #[must_use]
4442    pub fn peer_relay(&self) -> &peer_relay::PeerRelay {
4443        &self.peer_relay
4444    }
4445
4446    /// X0X-0070b: snapshot the current relay candidate set. Returns a
4447    /// freshly-cloned vector so the caller never holds the underlying
4448    /// `RwLock`. The set is seeded from `NetworkConfig.peer_relay.candidates`
4449    /// at build time; the gossip-announce subscriber (a follow-up commit)
4450    /// extends it at runtime.
4451    pub async fn relay_candidates(&self) -> Vec<identity::AgentId> {
4452        self.relay_candidates.read().await.clone()
4453    }
4454
4455    /// Legacy raw-QUIC direct-send path. Internal fallback only.
4456    ///
4457    /// X0X-0053: `prefer_newest_grace` is the bounded post-Replaced reissue
4458    /// grace — the maximum time we wait for the new connection's
4459    /// `is_connected` to flip true after a same-peer `Replaced` event fires
4460    /// mid-send before we reissue. Setting it to zero disables the grace and
4461    /// reissues immediately on Replaced (or fails if the new connection is
4462    /// not yet live).
4463    ///
4464    /// On the ACKed raw path, the in-flight `send_with_receive_ack` future
4465    /// is raced against `lifecycle_replaced_rx.recv()` filtered for the
4466    /// target peer's machine_id. On Replaced for the target peer, the
4467    /// in-flight send is abandoned and reissued once against the new
4468    /// generation. If the reissue also fails, the standard error is
4469    /// returned. This closes the X0X-0041 coverage gap surfaced by the
4470    /// Phase A bisect (P1 finding in
4471    /// `proofs/sota-borrow-phaseA-bisect-20260508T214634Z/ANALYSIS.md` §0.1).
4472    async fn send_direct_raw_quic(
4473        &self,
4474        agent_id: &identity::AgentId,
4475        payload: &[u8],
4476        receive_ack_timeout: Option<std::time::Duration>,
4477        prefer_newest_grace: std::time::Duration,
4478    ) -> error::NetworkResult<dm::DmPath> {
4479        let send_start = std::time::Instant::now();
4480        let agent_prefix = network::hex_prefix(&agent_id.0, 4);
4481        let self_prefix = network::hex_prefix(&self.identity.agent_id().0, 4);
4482        let bytes = payload.len();
4483        let digest = direct::dm_payload_digest_hex(payload);
4484        let target_path_label = if receive_ack_timeout.is_some() {
4485            "raw_quic_acked"
4486        } else {
4487            "raw_quic"
4488        };
4489
4490        let network = self.network.as_ref().ok_or_else(|| {
4491            tracing::warn!(
4492                target: "x0x::direct",
4493                stage = "send",
4494                agent_prefix = %crate::logging::LogHexId::agent(&agent_prefix),
4495                outcome = "err_no_network",
4496                "network not initialised"
4497            );
4498            error::NetworkError::NodeCreation("network not initialized".to_string())
4499        })?;
4500
4501        // Resolve the best known machine_id, preferring a machine that is
4502        // actually connected right now. Discovery cache entries can lag behind
4503        // the direct-messaging registry when an inbound connection is accepted
4504        // and later reconciled from transport events.
4505        let cached_machine_id = {
4506            let cache = self.identity_discovery_cache.read().await;
4507            cache
4508                .get(agent_id)
4509                .map(|d| d.machine_id)
4510                .filter(|m| m.0 != [0u8; 32]) // Ignore placeholder zeroed IDs
4511        };
4512        let registry_machine_id = self.direct_messaging.get_machine_id(agent_id).await;
4513
4514        let (machine_id, resolution) = match (cached_machine_id, registry_machine_id) {
4515            (Some(id), _) if network.is_connected(&ant_quic::PeerId(id.0)).await => {
4516                (id, "cached_connected")
4517            }
4518            (_, Some(id)) if network.is_connected(&ant_quic::PeerId(id.0)).await => {
4519                if cached_machine_id != Some(id) {
4520                    let mut cache = self.identity_discovery_cache.write().await;
4521                    if let Some(entry) = cache.get_mut(agent_id) {
4522                        entry.machine_id = id;
4523                    }
4524                }
4525                (id, "registry_connected")
4526            }
4527            (Some(id), None) => (id, "cached_not_connected"),
4528            (Some(id), Some(_)) => (id, "cached_both_disconnected"),
4529            (None, Some(id)) => (id, "registry_not_connected"),
4530            (None, None) => {
4531                tracing::debug!(
4532                    target: "x0x::direct",
4533                    stage = "send",
4534                    %agent_prefix,
4535                    resolution = "last_resort_connect",
4536                    "no machine_id known; triggering connect_to_agent"
4537                );
4538                let _ = self.connect_to_agent(agent_id).await;
4539                let id = self
4540                    .direct_messaging
4541                    .get_machine_id(agent_id)
4542                    .await
4543                    .ok_or_else(|| {
4544                        tracing::warn!(
4545                            target: "x0x::direct",
4546                            stage = "send",
4547                            agent_prefix = %crate::logging::LogHexId::agent(&agent_prefix),
4548                            outcome = "err_agent_not_found",
4549                            dur_ms = send_start.elapsed().as_millis() as u64,
4550                            "no machine_id after connect_to_agent"
4551                        );
4552                        error::NetworkError::AgentNotFound(agent_id.0)
4553                    })?;
4554                (id, "post_connect")
4555            }
4556        };
4557
4558        // Check if connected. ant-quic's live connection table is the source
4559        // of truth; the x0x lifecycle table is a derived fast-fail cache and
4560        // can briefly lag during connection replacement/supersede churn. Raw
4561        // sends best-effort clear stale lifecycle blocks when ant-quic is live;
4562        // capability-first gossip sends may leave cosmetic stale diagnostics
4563        // until a raw probe/send path observes the live connection.
4564        let ant_peer_id = ant_quic::PeerId(machine_id.0);
4565        let machine_prefix = network::hex_prefix(&machine_id.0, 4);
4566        let mut connected = network.is_connected(&ant_peer_id).await;
4567
4568        // X0X-0033: when machine_id is known (resolved from cache or registry)
4569        // but ant-quic isn't currently connected, the X0X-0031 send-readiness
4570        // hardening (single-flight per peer + bounded concurrency, falling
4571        // through to bootstrap-cache redial) used to sit behind this check
4572        // unreachable from the raw path. Drive it explicitly here so the raw
4573        // direct path attempts repair before bailing with AgentNotConnected.
4574        // Skip when resolution == "post_connect" (last-resort branch already
4575        // invoked connect_to_agent above).
4576        let mut repair_outcome: Option<&'static str> = None;
4577        if !connected && resolution != "post_connect" {
4578            const REPAIR_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
4579            let outcome = match tokio::time::timeout(
4580                REPAIR_TIMEOUT,
4581                network.ensure_peer_send_ready(&ant_peer_id),
4582            )
4583            .await
4584            {
4585                Ok(Ok(())) => "repaired",
4586                Ok(Err(_)) => "repair_failed",
4587                Err(_) => "repair_timeout",
4588            };
4589            repair_outcome = Some(outcome);
4590            tracing::debug!(
4591                target: "x0x::direct",
4592                stage = "send",
4593                %agent_prefix,
4594                %machine_prefix,
4595                resolution,
4596                outcome,
4597                "send-readiness repair on disconnected peer"
4598            );
4599            connected = network.is_connected(&ant_peer_id).await;
4600        }
4601
4602        // X0X-0051 / X0X-0053: the X0X-0041 prefer-newest-grace block was
4603        // removed in x0x 0.19.33. It only fired when `is_connected` returned
4604        // false before the send, never racing `Replaced` against the in-flight
4605        // `send_with_receive_ack` below — so it had a coverage gap on the
4606        // ACKed raw-DM path the Phase A harness exercises. The proper
4607        // re-implementation (race in-flight send against same-peer Replaced)
4608        // is tracked in X0X-0053. This path now falls straight through to the
4609        // standard !connected error handling.
4610
4611        if connected {
4612            if let Some(reason) = self.direct_messaging.lifecycle_block_reason(&machine_id) {
4613                tracing::warn!(
4614                    target: "x0x::direct",
4615                    stage = "send",
4616                    agent_prefix = %crate::logging::LogHexId::agent(&agent_prefix),
4617                    machine_prefix = %crate::logging::LogHexId::new("machine", &machine_prefix),
4618                    resolution,
4619                    ?repair_outcome,
4620                    reason = %reason,
4621                    "ignoring stale lifecycle block because ant-quic reports a live connection"
4622                );
4623                self.direct_messaging
4624                    .record_lifecycle_established(machine_id, None);
4625            }
4626        } else {
4627            if let Some(reason) = self.direct_messaging.lifecycle_block_reason(&machine_id) {
4628                tracing::warn!(
4629                    target: "x0x::direct",
4630                    stage = "send",
4631                    agent_prefix = %crate::logging::LogHexId::agent(&agent_prefix),
4632                    machine_prefix = %crate::logging::LogHexId::new("machine", &machine_prefix),
4633                    resolution,
4634                    ?repair_outcome,
4635                    outcome = "err_peer_disconnected",
4636                    reason = %reason,
4637                    dur_ms = send_start.elapsed().as_millis() as u64,
4638                    "lifecycle watcher says peer is disconnected"
4639                );
4640                return Err(error::NetworkError::ConnectionFailed(format!(
4641                    "peer disconnected: {reason}"
4642                )));
4643            }
4644            tracing::warn!(
4645                target: "x0x::direct",
4646                stage = "send",
4647                agent_prefix = %crate::logging::LogHexId::agent(&agent_prefix),
4648                machine_prefix = %crate::logging::LogHexId::new("machine", &machine_prefix),
4649                resolution,
4650                ?repair_outcome,
4651                outcome = "err_not_connected",
4652                bytes,
4653                dur_ms = send_start.elapsed().as_millis() as u64,
4654                "machine_id resolved but peer not currently connected after repair attempt"
4655            );
4656            return Err(error::NetworkError::AgentNotConnected(agent_id.0));
4657        }
4658
4659        tracing::debug!(
4660            target: "dm.trace",
4661            stage = "path_chosen",
4662            sender = %hex::encode(self.identity.agent_id().as_bytes()),
4663            recipient = %hex::encode(agent_id.as_bytes()),
4664            machine_id = %hex::encode(machine_id.as_bytes()),
4665            path = target_path_label,
4666            bytes,
4667            digest = %digest,
4668        );
4669
4670        // Send via network layer. Prefer receive-pipeline ACK when configured:
4671        // success then means the remote ant-quic reader drained the direct
4672        // message bytes, not merely that the local socket accepted them.
4673        let send_result = if let Some(timeout) = receive_ack_timeout {
4674            let wire = direct::DirectMessaging::encode_message(&self.identity.agent_id(), payload)?;
4675            tracing::debug!(
4676                target: "dm.trace",
4677                stage = "wire_encoded",
4678                sender = %hex::encode(self.identity.agent_id().as_bytes()),
4679                recipient = %hex::encode(agent_id.as_bytes()),
4680                path = "raw_quic_acked",
4681                bytes = wire.len(),
4682                payload_bytes = bytes,
4683                digest = %digest,
4684            );
4685            // X0X-0053: race the in-flight send_with_receive_ack against
4686            // same-peer Replaced. On Replaced for this machine_id, abandon
4687            // the in-flight future (Quinn streams are drop-safe — see
4688            // ant-quic's send_with_receive_ack contract) and reissue once
4689            // against the new generation. The reissue does not race again;
4690            // a healthy peer should converge in a single supersede cycle.
4691            self.send_ack_racing_replaced(
4692                network.as_ref(),
4693                ant_peer_id,
4694                machine_id,
4695                &wire,
4696                timeout,
4697                prefer_newest_grace,
4698                agent_id,
4699            )
4700            .await
4701        } else {
4702            tracing::debug!(
4703                target: "dm.trace",
4704                stage = "wire_encoded",
4705                sender = %hex::encode(self.identity.agent_id().as_bytes()),
4706                recipient = %hex::encode(agent_id.as_bytes()),
4707                path = "raw_quic",
4708                bytes,
4709                digest = %digest,
4710            );
4711            network
4712                .send_direct(&ant_peer_id, &self.identity.agent_id().0, payload)
4713                .await
4714                .map(|()| dm::DmPath::RawQuic)
4715        };
4716
4717        match send_result {
4718            Ok(path) => {
4719                let path_label = match path {
4720                    dm::DmPath::Loopback => "loopback",
4721                    dm::DmPath::RawQuic => "raw_quic",
4722                    dm::DmPath::RawQuicAcked => "raw_quic_acked",
4723                    dm::DmPath::GossipInbox => "gossip_inbox",
4724                    dm::DmPath::Relayed { .. } => "relayed",
4725                };
4726                tracing::debug!(
4727                    target: "dm.trace",
4728                    stage = "outbound_send_returned_ok",
4729                    sender = %hex::encode(self.identity.agent_id().as_bytes()),
4730                    recipient = %hex::encode(agent_id.as_bytes()),
4731                    machine_id = %hex::encode(machine_id.as_bytes()),
4732                    path = path_label,
4733                    bytes,
4734                    digest = %digest,
4735                    dur_ms = send_start.elapsed().as_millis() as u64,
4736                );
4737                tracing::info!(
4738                    target: "x0x::direct",
4739                    stage = "send",
4740                    from = %self_prefix,
4741                    to = %agent_prefix,
4742                    %machine_prefix,
4743                    resolution,
4744                    bytes,
4745                    dur_ms = send_start.elapsed().as_millis() as u64,
4746                    outcome = "ok",
4747                    path = ?path,
4748                    "direct message sent"
4749                );
4750                Ok(path)
4751            }
4752            Err(e) => {
4753                tracing::warn!(
4754                    target: "x0x::direct",
4755                    stage = "send",
4756                    from = %self_prefix,
4757                    to = %agent_prefix,
4758                    machine_prefix = %crate::logging::LogHexId::new("machine", &machine_prefix),
4759                    resolution,
4760                    bytes,
4761                    dur_ms = send_start.elapsed().as_millis() as u64,
4762                    outcome = "err_transport",
4763                    error = %e,
4764                    "transport send_direct failed"
4765                );
4766                // A receive-ACK-path failure on a connection that still reads
4767                // as connected means the connection is a zombie: the remote
4768                // endpoint is gone (supersede/NAT loss without a lifecycle
4769                // event) yet `is_connected` keeps steering every retry back
4770                // onto it via `cached_connected`. Tear it down so the next
4771                // attempt fails the fast path and takes the X0X-0031/0033
4772                // send-readiness repair (fresh dial) instead of re-sending
4773                // into the same dead connection until the caller's deadline.
4774                if receive_ack_timeout.is_some() && network.is_connected(&ant_peer_id).await {
4775                    match network.disconnect(&ant_peer_id).await {
4776                        Ok(()) => tracing::info!(
4777                            target: "x0x::direct",
4778                            stage = "send",
4779                            to = %agent_prefix,
4780                            %machine_prefix,
4781                            "tore down zombie connection after acked send failure; retry will redial"
4782                        ),
4783                        Err(de) => tracing::debug!(
4784                            target: "x0x::direct",
4785                            stage = "send",
4786                            to = %agent_prefix,
4787                            %machine_prefix,
4788                            error = %de,
4789                            "failed to tear down zombie connection after acked send failure"
4790                        ),
4791                    }
4792                }
4793                Err(e)
4794            }
4795        }
4796    }
4797
4798    /// X0X-0053: race the in-flight `send_with_receive_ack` against same-peer
4799    /// `Replaced`. On Replaced for the target peer, abandon the in-flight
4800    /// future, briefly wait for the new connection's `is_connected` to flip
4801    /// true (bounded by `prefer_newest_grace`), then reissue once against
4802    /// the new generation. The reissue does not race again — a healthy peer
4803    /// converges in a single supersede cycle. Lag handling on the broadcast
4804    /// channel mirrors `dm_send::wait_for_ack_or_backoff_or_replaced` —
4805    /// drain to current and re-subscribe.
4806    #[allow(clippy::too_many_arguments)]
4807    async fn send_ack_racing_replaced(
4808        &self,
4809        network: &network::NetworkNode,
4810        ant_peer_id: ant_quic::PeerId,
4811        machine_id: identity::MachineId,
4812        wire: &[u8],
4813        timeout: std::time::Duration,
4814        prefer_newest_grace: std::time::Duration,
4815        agent_id: &identity::AgentId,
4816    ) -> error::NetworkResult<dm::DmPath> {
4817        use tokio::sync::broadcast::error::RecvError;
4818        use tokio::sync::broadcast::error::TryRecvError as BroadcastTryRecvError;
4819
4820        // Subscribe BEFORE issuing the send so any Replaced that fires
4821        // mid-send is delivered to this receiver and not dropped.
4822        let mut replaced_rx = self.direct_messaging.subscribe_lifecycle_replaced();
4823        let pre_send_generation = self.direct_messaging.current_generation(&machine_id);
4824
4825        let agent_prefix = network::hex_prefix(&agent_id.0, 4);
4826        let machine_prefix = network::hex_prefix(&machine_id.0, 4);
4827        let self_prefix = network::hex_prefix(&self.identity.agent_id().0, 4);
4828
4829        let ack_race_test_hook = self.direct_messaging.raw_quic_ack_race_test_hook();
4830
4831        // First attempt: race send_with_receive_ack against same-peer Replaced.
4832        let send_fut = async {
4833            if let Some(hook) = ack_race_test_hook.as_ref() {
4834                hook.notify_first_attempt_started();
4835            }
4836            let result = network
4837                .send_with_receive_ack(ant_peer_id, wire, timeout)
4838                .await;
4839            if let Some(hook) = ack_race_test_hook.as_ref() {
4840                hook.hold_first_attempt_result().await;
4841            }
4842            result
4843        };
4844        tokio::pin!(send_fut);
4845
4846        let superseded_to: u64;
4847
4848        loop {
4849            tokio::select! {
4850                biased;
4851                send_result = &mut send_fut => {
4852                    // X0X-0053: even when send_fut completes (Ok or Err)
4853                    // first, drain the broadcast for any queued Replaced
4854                    // event for our peer. On Err with a queued supersede,
4855                    // we treat the failure as a casualty of the supersede
4856                    // race and reissue against the new generation —
4857                    // exactly the production case where the in-flight ACK
4858                    // exchange errors out fast on the dying connection
4859                    // milliseconds before the new connection is registered.
4860                    match send_result {
4861                        Some(Ok(())) => return Ok(dm::DmPath::RawQuicAcked),
4862                        Some(Err(e)) => {
4863                            // Drain any queued Replaced for our peer.
4864                            let mut queued_supersede: Option<u64> = None;
4865                            loop {
4866                                match replaced_rx.try_recv() {
4867                                    Ok((m, gen)) if m == machine_id => {
4868                                        queued_supersede = Some(gen);
4869                                    }
4870                                    Ok(_) => continue,
4871                                    Err(BroadcastTryRecvError::Empty)
4872                                    | Err(BroadcastTryRecvError::Closed)
4873                                    | Err(BroadcastTryRecvError::Lagged(_)) => break,
4874                                }
4875                            }
4876                            if let Some(gen) = queued_supersede {
4877                                superseded_to = gen;
4878                                break;
4879                            }
4880                            let reason = format!("send_with_receive_ack failed: {e}");
4881                            return if Self::raw_quic_ack_receive_backpressured(&reason) {
4882                                Err(error::NetworkError::RemoteReceiveBackpressured(reason))
4883                            } else {
4884                                Err(error::NetworkError::ConnectionFailed(reason))
4885                            };
4886                        }
4887                        None => return Err(error::NetworkError::NodeCreation(
4888                            "network node not initialized".to_string(),
4889                        )),
4890                    }
4891                }
4892                replaced = replaced_rx.recv() => {
4893                    match replaced {
4894                        Ok((m, gen)) if m == machine_id => {
4895                            superseded_to = gen;
4896                            break;
4897                        }
4898                        Ok(_) => continue,
4899                        Err(RecvError::Lagged(_)) => {
4900                            // Drain the channel for any pending event for our
4901                            // peer; if found, treat as supersede.
4902                            let mut found: Option<u64> = None;
4903                            loop {
4904                                match replaced_rx.try_recv() {
4905                                    Ok((m, gen)) if m == machine_id => {
4906                                        found = Some(gen);
4907                                        break;
4908                                    }
4909                                    Ok(_) => continue,
4910                                    Err(BroadcastTryRecvError::Empty)
4911                                    | Err(BroadcastTryRecvError::Closed)
4912                                    | Err(BroadcastTryRecvError::Lagged(_)) => break,
4913                                }
4914                            }
4915                            if let Some(gen) = found {
4916                                superseded_to = gen;
4917                                break;
4918                            }
4919                            // No event for our peer — re-subscribe to clear
4920                            // lag state and keep waiting.
4921                            replaced_rx = self
4922                                .direct_messaging
4923                                .subscribe_lifecycle_replaced();
4924                            continue;
4925                        }
4926                        Err(RecvError::Closed) => {
4927                            // Channel closed — no further supersede signals.
4928                            // Fall through to await the in-flight send to its
4929                            // natural completion.
4930                            let result = (&mut send_fut).await;
4931                            return match result {
4932                                Some(Ok(())) => Ok(dm::DmPath::RawQuicAcked),
4933                                Some(Err(e)) => {
4934                                    let reason =
4935                                        format!("send_with_receive_ack failed: {e}");
4936                                    if Self::raw_quic_ack_receive_backpressured(&reason) {
4937                                        Err(error::NetworkError::RemoteReceiveBackpressured(reason))
4938                                    } else {
4939                                        Err(error::NetworkError::ConnectionFailed(reason))
4940                                    }
4941                                }
4942                                None => Err(error::NetworkError::NodeCreation(
4943                                    "network node not initialized".to_string(),
4944                                )),
4945                            };
4946                        }
4947                    }
4948                }
4949            }
4950        }
4951
4952        // Reached here only via a confirmed same-peer Replaced. The pinned
4953        // in-flight send future is no longer polled and will be dropped at
4954        // scope exit (Quinn streams are drop-safe — abandoning the future
4955        // simply releases the local stream handle; the underlying connection
4956        // is the one ant-quic is replacing). Wait briefly for the new
4957        // connection's `is_connected` to flip true before reissuing.
4958        let new_generation = superseded_to;
4959        if let Some(hook) = ack_race_test_hook.as_ref() {
4960            hook.notify_replaced_short_circuit();
4961        }
4962        tracing::debug!(
4963            target: "dm.trace",
4964            stage = "raw_quic_ack_replaced_short_circuit",
4965            from = %self_prefix,
4966            to = %agent_prefix,
4967            %machine_prefix,
4968            pre_send_generation = ?pre_send_generation,
4969            new_generation,
4970            grace_ms = prefer_newest_grace.as_millis() as u64,
4971            "X0X-0053: same-peer Replaced fired during in-flight send_with_receive_ack; abandoning and reissuing",
4972        );
4973
4974        // Bounded grace: poll for the new connection to be live before
4975        // reissuing. If it never flips inside the grace window, return the
4976        // standard not-connected error and let the caller fall back.
4977        if !prefer_newest_grace.is_zero() {
4978            let grace_deadline = tokio::time::Instant::now() + prefer_newest_grace;
4979            const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(20);
4980            while tokio::time::Instant::now() < grace_deadline {
4981                if network.is_connected(&ant_peer_id).await {
4982                    break;
4983                }
4984                tokio::time::sleep(POLL_INTERVAL).await;
4985            }
4986        }
4987
4988        if !network.is_connected(&ant_peer_id).await {
4989            return Err(error::NetworkError::AgentNotConnected(agent_id.0));
4990        }
4991
4992        // Reissue once against the new generation. No further race — a
4993        // healthy peer should converge in a single supersede cycle.
4994        match network
4995            .send_with_receive_ack(ant_peer_id, wire, timeout)
4996            .await
4997        {
4998            Some(Ok(())) => Ok(dm::DmPath::RawQuicAcked),
4999            Some(Err(e)) => {
5000                let reason = format!("send_with_receive_ack failed: {e}");
5001                if Self::raw_quic_ack_receive_backpressured(&reason) {
5002                    Err(error::NetworkError::RemoteReceiveBackpressured(reason))
5003                } else {
5004                    Err(error::NetworkError::ConnectionFailed(reason))
5005                }
5006            }
5007            None => Err(error::NetworkError::NodeCreation(
5008                "network node not initialized".to_string(),
5009            )),
5010        }
5011    }
5012
5013    fn raw_quic_error_should_stop_fallback(
5014        err: &error::NetworkError,
5015        gossip_available: bool,
5016    ) -> bool {
5017        match err {
5018            // These are semantic/local failures; trying the gossip path cannot
5019            // make the payload smaller or create a missing local network node.
5020            error::NetworkError::PayloadTooLarge { .. } | error::NetworkError::NodeCreation(_) => {
5021                true
5022            }
5023            error::NetworkError::ConnectionFailed(reason)
5024                if reason.starts_with("peer disconnected:")
5025                    || reason.starts_with("send_with_receive_ack failed:") =>
5026            {
5027                !gossip_available
5028            }
5029            error::NetworkError::RemoteReceiveBackpressured(_) => !gossip_available,
5030            error::NetworkError::ConnectionClosed(_)
5031            | error::NetworkError::ConnectionReset(_)
5032            | error::NetworkError::NotConnected(_) => !gossip_available,
5033            _ => false,
5034        }
5035    }
5036
5037    fn raw_quic_ack_receive_backpressured(reason: &str) -> bool {
5038        reason.contains("Remote receive pipeline rejected payload: Backpressured")
5039    }
5040
5041    fn map_raw_quic_dm_error(err: error::NetworkError) -> dm::DmError {
5042        match err {
5043            error::NetworkError::AgentNotFound(_) => {
5044                dm::DmError::RecipientKeyUnavailable(err.to_string())
5045            }
5046            error::NetworkError::AgentNotConnected(_)
5047            | error::NetworkError::NotConnected(_)
5048            | error::NetworkError::ConnectionClosed(_)
5049            | error::NetworkError::ConnectionReset(_) => dm::DmError::PeerDisconnected {
5050                reason: err.to_string(),
5051            },
5052            error::NetworkError::ConnectionFailed(reason)
5053                if reason.starts_with("peer disconnected:")
5054                    || reason.starts_with("send_with_receive_ack failed:") =>
5055            {
5056                dm::DmError::PeerDisconnected { reason }
5057            }
5058            error::NetworkError::RemoteReceiveBackpressured(reason) => {
5059                dm::DmError::ReceiverBackpressured { reason }
5060            }
5061            error::NetworkError::PayloadTooLarge { size, max } => {
5062                dm::DmError::PayloadTooLarge { len: size, max }
5063            }
5064            error::NetworkError::NodeCreation(reason) => dm::DmError::NoConnectivity(reason),
5065            other => dm::DmError::PublishFailed(other.to_string()),
5066        }
5067    }
5068
5069    /// Receive the next direct message from any connected agent.
5070    ///
5071    /// Blocks until a direct message is received.
5072    ///
5073    /// # Security Note
5074    ///
5075    /// This method does **not** apply trust filtering from `ContactStore`.
5076    /// Messages from blocked agents will still be delivered. Use
5077    /// [`recv_direct_annotated()`](Self::recv_direct_annotated) if you need
5078    /// trust-based filtering.
5079    ///
5080    /// # Returns
5081    ///
5082    /// The received [`DirectMessage`] containing sender, payload, and timestamp.
5083    ///
5084    /// # Example
5085    ///
5086    /// ```rust,ignore
5087    /// loop {
5088    ///     if let Some(msg) = agent.recv_direct().await {
5089    ///         println!("From {:?}: {:?}", msg.sender, msg.payload_str());
5090    ///     }
5091    /// }
5092    /// ```
5093    pub async fn recv_direct(&self) -> Option<direct::DirectMessage> {
5094        self.recv_direct_inner().await
5095    }
5096
5097    /// Receive the next direct message, filtering by trust level.
5098    ///
5099    /// All messages now carry pre-computed `verified` and `trust_decision`
5100    /// fields from the identity discovery cache and contact store. This
5101    /// method passes through all messages — applications should inspect
5102    /// `msg.trust_decision` and `msg.verified` to decide how to handle
5103    /// each message.
5104    ///
5105    /// # Returns
5106    ///
5107    /// The received [`DirectMessage`], or `None` if the channel closes.
5108    ///
5109    /// # Example
5110    ///
5111    /// ```rust,ignore
5112    /// loop {
5113    ///     if let Some(msg) = agent.recv_direct_annotated().await {
5114    ///         match msg.trust_decision {
5115    ///             Some(TrustDecision::RejectBlocked) => continue, // skip
5116    ///             Some(TrustDecision::Accept) if msg.verified => { /* trusted */ }
5117    ///             _ => { /* handle accordingly */ }
5118    ///         }
5119    ///     }
5120    /// }
5121    /// ```
5122    pub async fn recv_direct_annotated(&self) -> Option<direct::DirectMessage> {
5123        self.recv_direct_inner().await
5124    }
5125
5126    /// Internal helper for receiving direct messages.
5127    ///
5128    /// Reads from the `DirectMessaging` internal channel, which is fed by
5129    /// the background `start_direct_listener` task. This ensures there is
5130    /// only ONE consumer of `network.recv_direct()` (the listener), avoiding
5131    /// message-stealing races.
5132    async fn recv_direct_inner(&self) -> Option<direct::DirectMessage> {
5133        self.direct_messaging.recv().await
5134    }
5135
5136    /// Subscribe to direct messages.
5137    ///
5138    /// Returns a receiver that can be cloned for multiple consumers.
5139    /// Messages are broadcast to all receivers.
5140    ///
5141    /// # Example
5142    ///
5143    /// ```rust,ignore
5144    /// let mut rx = agent.subscribe_direct();
5145    /// tokio::spawn(async move {
5146    ///     while let Some(msg) = rx.recv().await {
5147    ///         println!("Direct message: {:?}", msg);
5148    ///     }
5149    /// });
5150    /// ```
5151    pub fn subscribe_direct(&self) -> direct::DirectMessageReceiver {
5152        self.direct_messaging.subscribe()
5153    }
5154
5155    /// Get the direct messaging infrastructure.
5156    ///
5157    /// Provides low-level access to connection tracking and agent mappings.
5158    pub fn direct_messaging(&self) -> &std::sync::Arc<direct::DirectMessaging> {
5159        &self.direct_messaging
5160    }
5161
5162    /// Check if an agent is currently connected for direct messaging.
5163    ///
5164    /// # Arguments
5165    ///
5166    /// * `agent_id` - The agent to check.
5167    ///
5168    /// # Returns
5169    ///
5170    /// `true` if a QUIC connection exists to this agent's machine.
5171    pub async fn is_agent_connected(&self, agent_id: &identity::AgentId) -> bool {
5172        let Some(network) = &self.network else {
5173            return false;
5174        };
5175
5176        // Look up machine_id from discovery cache
5177        let machine_id = {
5178            let cache = self.identity_discovery_cache.read().await;
5179            cache.get(agent_id).map(|d| d.machine_id)
5180        };
5181
5182        match machine_id {
5183            Some(mid) => {
5184                let ant_peer_id = ant_quic::PeerId(mid.0);
5185                network.is_connected(&ant_peer_id).await
5186            }
5187            None => false,
5188        }
5189    }
5190
5191    /// Get list of currently connected agents.
5192    ///
5193    /// Returns agents that have been discovered and are currently connected
5194    /// via QUIC transport.
5195    pub async fn connected_agents(&self) -> Vec<identity::AgentId> {
5196        let Some(network) = &self.network else {
5197            return Vec::new();
5198        };
5199
5200        let connected_peers = network.connected_peers().await;
5201        let cache = self.identity_discovery_cache.read().await;
5202
5203        // Find agents whose machine_id matches a connected peer
5204        cache
5205            .values()
5206            .filter(|agent| {
5207                let ant_peer_id = ant_quic::PeerId(agent.machine_id.0);
5208                connected_peers.contains(&ant_peer_id)
5209            })
5210            .map(|agent| agent.agent_id)
5211            .collect()
5212    }
5213
5214    /// Attach a contact store for trust-based message filtering.
5215    ///
5216    /// When set, the gossip pub/sub layer will:
5217    /// - Drop messages from `Blocked` senders (don't deliver, don't rebroadcast)
5218    /// - Annotate messages with the sender's trust level for consumers
5219    ///
5220    /// Without a contact store, all messages pass through (open relay mode).
5221    pub fn set_contacts(&self, store: std::sync::Arc<tokio::sync::RwLock<contacts::ContactStore>>) {
5222        if let Some(runtime) = &self.gossip_runtime {
5223            let pubsub = runtime.pubsub();
5224            pubsub.set_contacts(store);
5225            // Thread the authoritative gossiped RevocationSet into pub/sub
5226            // delivery so a gossiped revocation closes the subscribe path
5227            // (issue #191 gap 3). Wired here so every runtime that sets
5228            // contacts also sets revocation — they share the same lifecycle.
5229            pubsub.set_revocation_set(self.revocation_set());
5230        }
5231    }
5232
5233    /// Announce this agent's identity on the network discovery topic.
5234    ///
5235    /// By default, announcements include agent + machine identity only.
5236    /// Human identity disclosure is opt-in and requires explicit consent.
5237    ///
5238    /// # Arguments
5239    ///
5240    /// * `include_user_identity` - Whether to include `user_id` and certificate
5241    /// * `human_consent` - Must be `true` when disclosing user identity
5242    ///
5243    /// # Errors
5244    ///
5245    /// Returns an error if:
5246    /// - Gossip runtime is not initialized
5247    /// - Human identity disclosure is requested without explicit consent
5248    /// - Human identity disclosure is requested but no user identity is configured
5249    /// - Serialization or publish fails
5250    pub async fn announce_identity(
5251        &self,
5252        include_user_identity: bool,
5253        human_consent: bool,
5254    ) -> error::Result<()> {
5255        let runtime = self.gossip_runtime.as_ref().ok_or_else(|| {
5256            error::IdentityError::Storage(std::io::Error::other(
5257                "gossip runtime not initialized - configure agent with network first",
5258            ))
5259        })?;
5260
5261        self.start_identity_listener().await?;
5262
5263        let network_status = if let Some(network) = self.network.as_ref() {
5264            network.node_status().await
5265        } else {
5266            None
5267        };
5268        let assist_snapshot = network_status
5269            .as_ref()
5270            .map(AnnouncementAssistSnapshot::from_node_status)
5271            .unwrap_or_default();
5272
5273        // Include ALL routable addresses (IPv4 and IPv6).
5274        let mut addresses = if let Some(network) = self.network.as_ref() {
5275            match network_status.as_ref() {
5276                Some(status) if !status.external_addrs.is_empty() => status.external_addrs.clone(),
5277                _ => match network.routable_addr().await {
5278                    Some(addr) => vec![addr],
5279                    None => self.announcement_addresses(),
5280                },
5281            }
5282        } else {
5283            self.announcement_addresses()
5284        };
5285        // Detect addresses locally via UDP socket tricks.
5286        // ant-quic discovers public IPv4 via OBSERVED_ADDRESS from peers.
5287        // IPv6 is globally routable (no NAT), so we probe locally.
5288        //
5289        // For locally-probed addresses (IPv6 and LAN IPv4), use the actual
5290        // bound port from the QUIC endpoint — NOT the first external address
5291        // port (which is NAT-mapped) and NOT the config bind port (which may
5292        // be 0 for OS-assigned ports).
5293        let bind_port = if let Some(network) = self.network.as_ref() {
5294            network.bound_addr().await.map(|a| a.port()).unwrap_or(5483)
5295        } else {
5296            5483
5297        };
5298
5299        // IPv6 probe
5300        if let Ok(sock) = std::net::UdpSocket::bind("[::]:0") {
5301            if sock.connect("[2001:4860:4860::8888]:80").is_ok() {
5302                if let Ok(local) = sock.local_addr() {
5303                    if let std::net::IpAddr::V6(v6) = local.ip() {
5304                        let segs = v6.segments();
5305                        let is_global = (segs[0] & 0xffc0) != 0xfe80
5306                            && (segs[0] & 0xff00) != 0xfd00
5307                            && !v6.is_loopback();
5308                        if is_global {
5309                            let v6_addr =
5310                                std::net::SocketAddr::new(std::net::IpAddr::V6(v6), bind_port);
5311                            if !addresses.contains(&v6_addr) {
5312                                addresses.push(v6_addr);
5313                            }
5314                        }
5315                    }
5316                }
5317            }
5318        }
5319
5320        for addr in collect_local_interface_addrs(bind_port) {
5321            if !addresses.contains(&addr) {
5322                addresses.push(addr);
5323            }
5324        }
5325
5326        let allow_local_scope = self
5327            .network
5328            .as_ref()
5329            .is_some_and(|network| allow_local_discovery_addresses(network.config()));
5330        // Same rule as HeartbeatContext::announce(): global bootstrap partitions
5331        // publish only globally-routable endpoints, while explicit local/testnet
5332        // partitions may publish LAN/loopback hints for same-partition peers.
5333        addresses = filter_discovery_announcement_addrs(addresses, allow_local_scope);
5334
5335        // Emit coordinator / relay hints only when direct inbound is not
5336        // known to work. See HeartbeatContext::announce() for rationale.
5337        let (reachable_via, relay_candidates) = if assist_snapshot.can_receive_direct == Some(true)
5338        {
5339            (Vec::new(), Vec::new())
5340        } else if let Some(network) = self.network.as_ref() {
5341            collect_coordinator_hints(
5342                network.as_ref(),
5343                &self.machine_discovery_cache,
5344                self.machine_id(),
5345            )
5346            .await
5347        } else {
5348            (Vec::new(), Vec::new())
5349        };
5350
5351        let announcement =
5352            self.build_identity_announcement_with_addrs(IdentityAnnouncementBuildOptions {
5353                include_user_identity,
5354                human_consent,
5355                addresses,
5356                assist_snapshot: Some(&assist_snapshot),
5357                reachable_via,
5358                relay_candidates,
5359                allow_local_scope,
5360            })?;
5361        tracing::debug!(
5362            target: "x0x::discovery",
5363            announcement_kind = "explicit",
5364            machine_prefix = %network::hex_prefix(&announcement.machine_id.0, 4),
5365            addr_total = announcement.addresses.len(),
5366            nat_type = announcement.nat_type.as_deref().unwrap_or("unknown"),
5367            can_receive_direct = ?announcement.can_receive_direct,
5368            relay_capable = ?announcement.is_relay,
5369            coordinator_capable = ?announcement.is_coordinator,
5370            relay_active = ?assist_snapshot.relay_active,
5371            coordinator_active = ?assist_snapshot.coordinator_active,
5372            "publishing identity announcement"
5373        );
5374
5375        let machine_announcement = build_machine_announcement_for_identity(
5376            &self.identity,
5377            announcement.addresses.clone(),
5378            announcement.announced_at,
5379            Some(&assist_snapshot),
5380            announcement.reachable_via.clone(),
5381            announcement.relay_candidates.clone(),
5382            allow_local_scope,
5383        )?;
5384        tracing::debug!(
5385            target: "x0x::discovery",
5386            announcement_kind = "machine_explicit",
5387            machine_prefix = %network::hex_prefix(&machine_announcement.machine_id.0, 4),
5388            addr_total = machine_announcement.addresses.len(),
5389            nat_type = machine_announcement.nat_type.as_deref().unwrap_or("unknown"),
5390            can_receive_direct = ?machine_announcement.can_receive_direct,
5391            relay_capable = ?machine_announcement.is_relay,
5392            coordinator_capable = ?machine_announcement.is_coordinator,
5393            reachable_via_count = machine_announcement.reachable_via.len(),
5394            relay_candidate_count = machine_announcement.relay_candidates.len(),
5395            "publishing machine announcement"
5396        );
5397        let machine_payload =
5398            bytes::Bytes::from(bincode::serialize(&machine_announcement).map_err(|e| {
5399                error::IdentityError::Serialization(format!(
5400                    "failed to serialize machine announcement: {e}"
5401                ))
5402            })?);
5403        runtime
5404            .pubsub()
5405            .publish(
5406                shard_topic_for_machine(&machine_announcement.machine_id),
5407                machine_payload.clone(),
5408            )
5409            .await
5410            .map_err(|e| {
5411                error::IdentityError::Storage(std::io::Error::other(format!(
5412                    "failed to publish machine announcement to shard topic: {e}"
5413                )))
5414            })?;
5415        runtime
5416            .pubsub()
5417            .publish(MACHINE_ANNOUNCE_TOPIC.to_string(), machine_payload)
5418            .await
5419            .map_err(|e| {
5420                error::IdentityError::Storage(std::io::Error::other(format!(
5421                    "failed to publish machine announcement: {e}"
5422                )))
5423            })?;
5424
5425        let encoded = serialize_identity_announcement(&announcement).map_err(|e| {
5426            error::IdentityError::Serialization(format!(
5427                "failed to serialize identity announcement: {e}"
5428            ))
5429        })?;
5430
5431        let payload = bytes::Bytes::from(encoded);
5432
5433        // Publish to shard topic first (future-proof routing).
5434        let shard_topic = shard_topic_for_agent(&announcement.agent_id);
5435        runtime
5436            .pubsub()
5437            .publish(shard_topic, payload.clone())
5438            .await
5439            .map_err(|e| {
5440                error::IdentityError::Storage(std::io::Error::other(format!(
5441                    "failed to publish identity announcement to shard topic: {e}"
5442                )))
5443            })?;
5444
5445        // Also publish to legacy broadcast topic for backward compatibility.
5446        runtime
5447            .pubsub()
5448            .publish(IDENTITY_ANNOUNCE_TOPIC.to_string(), payload)
5449            .await
5450            .map_err(|e| {
5451                error::IdentityError::Storage(std::io::Error::other(format!(
5452                    "failed to publish identity announcement: {e}"
5453                )))
5454            })?;
5455
5456        let now = Self::unix_timestamp_secs();
5457        upsert_discovered_machine(
5458            &self.machine_discovery_cache,
5459            DiscoveredMachine::from_machine_announcement(
5460                &machine_announcement,
5461                machine_announcement.addresses.clone(),
5462                now,
5463            ),
5464        )
5465        .await;
5466        let discovered_agent = DiscoveredAgent {
5467            agent_id: announcement.agent_id,
5468            machine_id: announcement.machine_id,
5469            user_id: announcement.user_id,
5470            addresses: announcement.addresses.clone(),
5471            announced_at: announcement.announced_at,
5472            last_seen: now,
5473            machine_public_key: announcement.machine_public_key.clone(),
5474            nat_type: announcement.nat_type.clone(),
5475            can_receive_direct: announcement.can_receive_direct,
5476            is_relay: announcement.is_relay,
5477            is_coordinator: announcement.is_coordinator,
5478            reachable_via: announcement.reachable_via.clone(),
5479            relay_candidates: announcement.relay_candidates.clone(),
5480            cert_not_after: None,
5481            agent_certificate: None,
5482            agent_public_key: announcement.agent_public_key.clone(),
5483        };
5484        upsert_discovered_machine_from_agent(&self.machine_discovery_cache, &discovered_agent)
5485            .await;
5486        upsert_discovered_agent(&self.identity_discovery_cache, discovered_agent).await;
5487
5488        // Record consent AFTER successful publish so heartbeats don't start
5489        // including user identity if this announcement never actually propagated.
5490        if include_user_identity && human_consent {
5491            self.user_identity_consented
5492                .store(true, std::sync::atomic::Ordering::Release);
5493        }
5494
5495        Ok(())
5496    }
5497
5498    /// Get all discovered agents from identity announcements.
5499    ///
5500    /// # Errors
5501    ///
5502    /// Returns an error if the gossip runtime is not initialized.
5503    pub async fn discovered_agents(&self) -> error::Result<Vec<DiscoveredAgent>> {
5504        self.start_identity_listener().await?;
5505        let cutoff = Self::unix_timestamp_secs().saturating_sub(self.identity_ttl_secs);
5506        let mut agents: Vec<_> = self
5507            .identity_discovery_cache
5508            .read()
5509            .await
5510            .values()
5511            .filter(|a| discovery_record_is_live(a.announced_at, a.last_seen, cutoff))
5512            .cloned()
5513            .collect();
5514        agents.sort_by_key(|a| a.agent_id.0);
5515        Ok(agents)
5516    }
5517
5518    /// Get all currently-online agents from live presence beacons.
5519    ///
5520    /// This is the backing view for `/presence/online`: signed identity
5521    /// announcements provide the AgentId/MachineId binding, while presence
5522    /// beacons provide short-TTL liveness. Fresh identity-cache entries are
5523    /// included as a startup fallback before the first beacon poll converges.
5524    ///
5525    /// # Errors
5526    ///
5527    /// Returns an error if the gossip runtime is not initialized.
5528    pub async fn online_agents(&self) -> error::Result<Vec<DiscoveredAgent>> {
5529        self.start_identity_listener().await?;
5530        let cutoff = Self::unix_timestamp_secs().saturating_sub(self.identity_ttl_secs);
5531        let cache = self.identity_discovery_cache.read().await;
5532        let mut seen = std::collections::HashSet::new();
5533        let mut agents = Vec::new();
5534
5535        for agent in cache
5536            .values()
5537            .filter(|agent| discovery_record_is_live(agent.announced_at, agent.last_seen, cutoff))
5538        {
5539            if seen.insert(agent.agent_id) {
5540                agents.push(agent.clone());
5541            }
5542        }
5543
5544        if let Some(ref pw) = self.presence {
5545            let records = pw
5546                .manager()
5547                .get_group_presence(crate::presence::global_presence_topic())
5548                .await;
5549            for (peer_id, record) in records {
5550                if let Some(agent) =
5551                    crate::presence::presence_record_to_discovered_agent(peer_id, &record, &cache)
5552                {
5553                    if seen.insert(agent.agent_id) {
5554                        agents.push(agent);
5555                    }
5556                }
5557            }
5558        }
5559
5560        agents.sort_by_key(|a| a.agent_id.0);
5561        Ok(agents)
5562    }
5563
5564    /// Return all currently retained discovered agents, regardless of TTL.
5565    ///
5566    /// Unlike [`Self::discovered_agents`], this method skips read-time TTL
5567    /// filtering. After [`Self::join_network`] starts the background discovery
5568    /// cache reaper, stale entries may still be physically removed, so this is
5569    /// a retained-cache view rather than an "all agents ever seen" archive.
5570    ///
5571    /// # Errors
5572    ///
5573    /// Returns an error if the gossip runtime is not initialized.
5574    pub async fn discovered_agents_unfiltered(&self) -> error::Result<Vec<DiscoveredAgent>> {
5575        self.start_identity_listener().await?;
5576        let mut agents: Vec<_> = self
5577            .identity_discovery_cache
5578            .read()
5579            .await
5580            .values()
5581            .cloned()
5582            .collect();
5583        agents.sort_by_key(|a| a.agent_id.0);
5584        Ok(agents)
5585    }
5586
5587    /// Get one discovered agent record by agent ID.
5588    ///
5589    /// # Errors
5590    ///
5591    /// Returns an error if the gossip runtime is not initialized.
5592    pub async fn discovered_agent(
5593        &self,
5594        agent_id: identity::AgentId,
5595    ) -> error::Result<Option<DiscoveredAgent>> {
5596        self.start_identity_listener().await?;
5597        Ok(self
5598            .identity_discovery_cache
5599            .read()
5600            .await
5601            .get(&agent_id)
5602            .cloned())
5603    }
5604
5605    /// Get all discovered machine endpoints from machine announcements.
5606    ///
5607    /// The returned records are keyed by `machine_id`, which is the actual
5608    /// transport identity used for direct dials, hole-punching, and relay
5609    /// selection. Agent and user identities are link indexes over these
5610    /// machine records.
5611    ///
5612    /// # Errors
5613    ///
5614    /// Returns an error if the gossip runtime is not initialized.
5615    pub async fn discovered_machines(&self) -> error::Result<Vec<DiscoveredMachine>> {
5616        self.start_identity_listener().await?;
5617        let cutoff = Self::unix_timestamp_secs().saturating_sub(self.identity_ttl_secs);
5618        let mut machines: Vec<_> = self
5619            .machine_discovery_cache
5620            .read()
5621            .await
5622            .values()
5623            .filter(|m| discovery_record_is_live(m.announced_at, m.last_seen, cutoff))
5624            .cloned()
5625            .collect();
5626        machines.sort_by_key(|m| m.machine_id.0);
5627        Ok(machines)
5628    }
5629
5630    /// Return all currently retained discovered machines, regardless of TTL.
5631    ///
5632    /// Unlike [`Self::discovered_machines`], this method skips read-time TTL
5633    /// filtering. After [`Self::join_network`] starts the background discovery
5634    /// cache reaper, stale entries may still be physically removed, so this is
5635    /// a retained-cache view rather than an "all machines ever seen" archive.
5636    ///
5637    /// # Errors
5638    ///
5639    /// Returns an error if the gossip runtime is not initialized.
5640    pub async fn discovered_machines_unfiltered(&self) -> error::Result<Vec<DiscoveredMachine>> {
5641        self.start_identity_listener().await?;
5642        let mut machines: Vec<_> = self
5643            .machine_discovery_cache
5644            .read()
5645            .await
5646            .values()
5647            .cloned()
5648            .collect();
5649        machines.sort_by_key(|m| m.machine_id.0);
5650        Ok(machines)
5651    }
5652
5653    /// Get one discovered machine record by machine ID.
5654    ///
5655    /// # Errors
5656    ///
5657    /// Returns an error if the gossip runtime is not initialized.
5658    pub async fn discovered_machine(
5659        &self,
5660        machine_id: identity::MachineId,
5661    ) -> error::Result<Option<DiscoveredMachine>> {
5662        self.start_identity_listener().await?;
5663        Ok(self
5664            .machine_discovery_cache
5665            .read()
5666            .await
5667            .get(&machine_id)
5668            .cloned())
5669    }
5670
5671    /// Resolve a known agent identity to its current machine endpoint.
5672    ///
5673    /// # Errors
5674    ///
5675    /// Returns an error if the gossip runtime is not initialized.
5676    pub async fn machine_for_agent(
5677        &self,
5678        agent_id: identity::AgentId,
5679    ) -> error::Result<Option<DiscoveredMachine>> {
5680        self.start_identity_listener().await?;
5681        let machine_id = {
5682            let agents = self.identity_discovery_cache.read().await;
5683            agents.get(&agent_id).map(|agent| agent.machine_id)
5684        };
5685        let Some(machine_id) = machine_id else {
5686            return Ok(None);
5687        };
5688        Ok(self
5689            .machine_discovery_cache
5690            .read()
5691            .await
5692            .get(&machine_id)
5693            .cloned())
5694    }
5695
5696    /// Find all machine endpoints linked to a consented user identity.
5697    ///
5698    /// # Errors
5699    ///
5700    /// Returns an error if the gossip runtime is not initialized.
5701    pub async fn find_machines_by_user(
5702        &self,
5703        user_id: identity::UserId,
5704    ) -> error::Result<Vec<DiscoveredMachine>> {
5705        self.start_identity_listener().await?;
5706        let cutoff = Self::unix_timestamp_secs().saturating_sub(self.identity_ttl_secs);
5707        let mut machines: Vec<_> = self
5708            .machine_discovery_cache
5709            .read()
5710            .await
5711            .values()
5712            .filter(|m| {
5713                discovery_record_is_live(m.announced_at, m.last_seen, cutoff)
5714                    && m.user_ids.contains(&user_id)
5715            })
5716            .cloned()
5717            .collect();
5718        machines.sort_by_key(|m| m.machine_id.0);
5719        Ok(machines)
5720    }
5721
5722    /// Publish a signed [`UserAnnouncement`] for this agent's user identity.
5723    ///
5724    /// Builds the roster from the agent certificates the user has issued for
5725    /// every agent visible through this `Agent` — at minimum, this agent's
5726    /// own certificate. Requires explicit human consent to avoid an unwary
5727    /// caller broadcasting user identity.
5728    ///
5729    /// # Errors
5730    ///
5731    /// Returns an error if no user keypair is configured, `human_consent` is
5732    /// false, no agent certificate is available, signing fails, or the
5733    /// gossip runtime is not initialised.
5734    pub async fn announce_user_identity(&self, human_consent: bool) -> error::Result<()> {
5735        if !human_consent {
5736            return Err(error::IdentityError::Storage(std::io::Error::other(
5737                "user announcement requires explicit human consent — set human_consent: true",
5738            )));
5739        }
5740        let user_kp = self.identity.user_keypair().ok_or_else(|| {
5741            error::IdentityError::Storage(std::io::Error::other(
5742                "user announcement requested but no user identity is configured",
5743            ))
5744        })?;
5745        let own_cert = self.identity.agent_certificate().cloned().ok_or_else(|| {
5746            error::IdentityError::Storage(std::io::Error::other(
5747                "user announcement requested but agent certificate is missing",
5748            ))
5749        })?;
5750        let runtime = self.gossip_runtime.as_ref().ok_or_else(|| {
5751            error::IdentityError::Storage(std::io::Error::other(
5752                "gossip runtime not initialized - configure agent with network first",
5753            ))
5754        })?;
5755        self.start_identity_listener().await?;
5756
5757        let announced_at = Self::unix_timestamp_secs();
5758        let announcement = UserAnnouncement::sign(user_kp, vec![own_cert], announced_at)?;
5759        let payload = bytes::Bytes::from(bincode::serialize(&announcement).map_err(|e| {
5760            error::IdentityError::Serialization(format!(
5761                "failed to serialize user announcement: {e}"
5762            ))
5763        })?);
5764        runtime
5765            .pubsub()
5766            .publish(shard_topic_for_user(&announcement.user_id), payload.clone())
5767            .await
5768            .map_err(|e| {
5769                error::IdentityError::Storage(std::io::Error::other(format!(
5770                    "failed to publish user announcement to shard topic: {e}"
5771                )))
5772            })?;
5773        runtime
5774            .pubsub()
5775            .publish(USER_ANNOUNCE_TOPIC.to_string(), payload)
5776            .await
5777            .map_err(|e| {
5778                error::IdentityError::Storage(std::io::Error::other(format!(
5779                    "failed to publish user announcement: {e}"
5780                )))
5781            })?;
5782
5783        let now = Self::unix_timestamp_secs();
5784        let incoming = DiscoveredUser::from_announcement(&announcement, now);
5785        self.user_discovery_cache
5786            .write()
5787            .await
5788            .insert(incoming.user_id, incoming);
5789        Ok(())
5790    }
5791
5792    /// Get a discovered user by ID.
5793    ///
5794    /// Returns `Ok(None)` if the user has never announced or the cached
5795    /// entry is older than `identity_ttl_secs`.
5796    ///
5797    /// # Errors
5798    ///
5799    /// Returns an error if the gossip runtime is not initialized.
5800    pub async fn discovered_user(
5801        &self,
5802        user_id: identity::UserId,
5803    ) -> error::Result<Option<DiscoveredUser>> {
5804        self.start_identity_listener().await?;
5805        let cutoff = Self::unix_timestamp_secs().saturating_sub(self.identity_ttl_secs);
5806        Ok(self
5807            .user_discovery_cache
5808            .read()
5809            .await
5810            .get(&user_id)
5811            .filter(|u| discovery_record_is_live(u.announced_at, u.last_seen, cutoff))
5812            .cloned())
5813    }
5814
5815    /// List all currently-discovered users (those with a fresh announcement).
5816    ///
5817    /// # Errors
5818    ///
5819    /// Returns an error if the gossip runtime is not initialized.
5820    pub async fn discovered_users(&self) -> error::Result<Vec<DiscoveredUser>> {
5821        self.start_identity_listener().await?;
5822        let cutoff = Self::unix_timestamp_secs().saturating_sub(self.identity_ttl_secs);
5823        let mut users: Vec<_> = self
5824            .user_discovery_cache
5825            .read()
5826            .await
5827            .values()
5828            .filter(|u| discovery_record_is_live(u.announced_at, u.last_seen, cutoff))
5829            .cloned()
5830            .collect();
5831        users.sort_by_key(|u| u.user_id.0);
5832        Ok(users)
5833    }
5834
5835    /// Return the current retained entry counts for the three discovery caches.
5836    ///
5837    /// Primarily intended for diagnostics and soak monitoring. The reaper
5838    /// (started by `join_network`) keeps these bounded by physically removing
5839    /// stale entries; without the reaper the counts grow monotonically with
5840    /// every unique agent/machine/user ever observed.
5841    #[must_use]
5842    pub async fn discovery_cache_entry_counts(&self) -> (usize, usize, usize) {
5843        let id = self.identity_discovery_cache.read().await.len();
5844        let mach = self.machine_discovery_cache.read().await.len();
5845        let usr = self.user_discovery_cache.read().await.len();
5846        (id, mach, usr)
5847    }
5848
5849    async fn start_identity_listener(&self) -> error::Result<()> {
5850        let runtime = self.gossip_runtime.as_ref().ok_or_else(|| {
5851            error::IdentityError::Storage(std::io::Error::other(
5852                "gossip runtime not initialized - configure agent with network first",
5853            ))
5854        })?;
5855
5856        if self
5857            .identity_listener_started
5858            .swap(true, std::sync::atomic::Ordering::AcqRel)
5859        {
5860            return Ok(());
5861        }
5862
5863        let mut sub_legacy = runtime
5864            .pubsub()
5865            .subscribe(IDENTITY_ANNOUNCE_TOPIC.to_string())
5866            .await;
5867        let own_shard_topic = shard_topic_for_agent(&self.agent_id());
5868        let mut sub_shard = runtime.pubsub().subscribe(own_shard_topic).await;
5869        let mut sub_machine_legacy = runtime
5870            .pubsub()
5871            .subscribe(MACHINE_ANNOUNCE_TOPIC.to_string())
5872            .await;
5873        let own_machine_shard_topic = shard_topic_for_machine(&self.machine_id());
5874        let mut sub_machine_shard = runtime.pubsub().subscribe(own_machine_shard_topic).await;
5875        let mut sub_user_legacy = runtime
5876            .pubsub()
5877            .subscribe(USER_ANNOUNCE_TOPIC.to_string())
5878            .await;
5879        // Subscribe to our own user's shard (if we have a user identity) so
5880        // we receive announcements addressed to us as well as broadcasts.
5881        let mut sub_user_shard = match self.user_id() {
5882            Some(uid) => Some(runtime.pubsub().subscribe(shard_topic_for_user(&uid)).await),
5883            None => None,
5884        };
5885        let cache = std::sync::Arc::clone(&self.identity_discovery_cache);
5886        let authenticated_machine_bindings =
5887            std::sync::Arc::clone(&self.authenticated_machine_bindings);
5888        let machine_cache = std::sync::Arc::clone(&self.machine_discovery_cache);
5889        let user_cache = std::sync::Arc::clone(&self.user_discovery_cache);
5890        let bootstrap_cache = self.bootstrap_cache.clone();
5891        let contact_store = std::sync::Arc::clone(&self.contact_store);
5892        let direct_messaging = std::sync::Arc::clone(&self.direct_messaging);
5893        let network = self.network.as_ref().map(std::sync::Arc::clone);
5894        let allow_local_scope = network
5895            .as_ref()
5896            .is_some_and(|network| allow_local_discovery_addresses(network.config()));
5897        let own_agent_id = self.agent_id();
5898        let own_machine_id = self.machine_id();
5899        let own_user_id = self.user_id();
5900        let rebroadcast_pubsub = std::sync::Arc::clone(runtime.pubsub());
5901        let token = self.shutdown_token.clone();
5902        // Subscribe to revocation records so they are applied on receipt.
5903        let mut sub_revocation = runtime
5904            .pubsub()
5905            .subscribe(REVOCATION_TOPIC.to_string())
5906            .await;
5907        let revocation_set = std::sync::Arc::clone(&self.revocation_set);
5908        let identity_dir_for_listener = self.identity_dir.clone();
5909        let contact_store_for_evict = std::sync::Arc::clone(&self.contact_store);
5910
5911        self.spawn_tracked(async move {
5912            enum DiscoveryMessage {
5913                Identity(crate::gossip::PubSubMessage),
5914                Machine(crate::gossip::PubSubMessage),
5915                User(crate::gossip::PubSubMessage),
5916                Revocation(crate::gossip::PubSubMessage),
5917            }
5918
5919            // Track agents we've already initiated auto-connect to, preventing
5920            // duplicate connection attempts from concurrent announcements.
5921            let mut auto_connect_attempted = std::collections::HashSet::<identity::AgentId>::new();
5922
5923            // One-shot dedup for re-broadcast: (agent_id, announced_at)
5924            // → first-rebroadcast Instant. Bounds each fresh announcement to at
5925            // most one receiver-side re-publish per daemon. Repeating the same
5926            // payload every few seconds forms a PubSub feedback loop on the
5927            // bootstrap mesh and delays latency-sensitive user messages.
5928            let mut rebroadcast_state: std::collections::HashMap<
5929                (identity::AgentId, u64),
5930                std::time::Instant,
5931            > = std::collections::HashMap::new();
5932            let mut machine_rebroadcast_state: std::collections::HashMap<
5933                (identity::MachineId, u64),
5934                std::time::Instant,
5935            > = std::collections::HashMap::new();
5936            let mut seen_identity_payloads: std::collections::HashMap<
5937                blake3::Hash,
5938                std::time::Instant,
5939            > = std::collections::HashMap::new();
5940            let mut seen_machine_payloads: std::collections::HashMap<
5941                blake3::Hash,
5942                std::time::Instant,
5943            > = std::collections::HashMap::new();
5944            let mut user_rebroadcast_state: std::collections::HashMap<
5945                (identity::UserId, u64),
5946                std::time::Instant,
5947            > = std::collections::HashMap::new();
5948            const VERIFIED_PAYLOAD_TTL: std::time::Duration = std::time::Duration::from_secs(60);
5949
5950            let has_recent_verified_payload =
5951                |seen: &std::collections::HashMap<blake3::Hash, std::time::Instant>,
5952                 payload: &[u8]| {
5953                    let key = blake3::hash(payload);
5954                    matches!(seen.get(&key), Some(last) if last.elapsed() < VERIFIED_PAYLOAD_TTL)
5955                };
5956            let remember_verified_payload =
5957                |seen: &mut std::collections::HashMap<blake3::Hash, std::time::Instant>,
5958                 payload: &[u8]| {
5959                    let now = std::time::Instant::now();
5960                    seen.insert(blake3::hash(payload), now);
5961                    if seen.len() > 4096 {
5962                        let cutoff = now - VERIFIED_PAYLOAD_TTL;
5963                        seen.retain(|_, t| *t >= cutoff);
5964                    }
5965                };
5966
5967            loop {
5968                // Drain whichever subscription fires next; deduplicate by ID in cache.
5969                // The user-shard subscription is conditional on having a local
5970                // user identity; when absent, that arm yields a pending future
5971                // so select! skips it cleanly.
5972                let msg = tokio::select! {
5973                    Some(m) = sub_legacy.recv() => DiscoveryMessage::Identity(m),
5974                    Some(m) = sub_shard.recv() => DiscoveryMessage::Identity(m),
5975                    Some(m) = sub_machine_legacy.recv() => DiscoveryMessage::Machine(m),
5976                    Some(m) = sub_machine_shard.recv() => DiscoveryMessage::Machine(m),
5977                    Some(m) = sub_user_legacy.recv() => DiscoveryMessage::User(m),
5978                    Some(m) = async {
5979                        match sub_user_shard.as_mut() {
5980                            Some(s) => s.recv().await,
5981                            None => std::future::pending().await,
5982                        }
5983                    } => DiscoveryMessage::User(m),
5984                    Some(m) = sub_revocation.recv() => DiscoveryMessage::Revocation(m),
5985                    // Required for PROMPT shutdown: without this arm the listener
5986                    // only exits when every gossip subscription closes (the
5987                    // `else => break` path), forcing shutdown() to wait out its
5988                    // 3s grace-then-abort window. Cancelling the token here lets
5989                    // it break immediately. Adding this always-eligible-on-cancel
5990                    // branch only shifts tokio::select!'s pseudo-random tie-break
5991                    // distribution among the recv arms — behaviorally inert here
5992                    // because each per-topic message is handled independently
5993                    // with no cross-topic ordering guarantee.
5994                    _ = token.cancelled() => break,
5995                    else => break,
5996                };
5997                let msg = match msg {
5998                    DiscoveryMessage::Machine(msg) => {
5999                        let raw_payload = msg.payload.clone();
6000                        let already_verified =
6001                            has_recent_verified_payload(&seen_machine_payloads, &raw_payload);
6002                        let announcement = match deserialize_machine_announcement(&raw_payload) {
6003                            Ok(a) => a,
6004                            Err(e) => {
6005                                tracing::debug!(
6006                                    "Ignoring invalid machine announcement payload: {}",
6007                                    e
6008                                );
6009                                continue;
6010                            }
6011                        };
6012
6013                        if !already_verified {
6014                            if let Err(e) = announcement.verify() {
6015                                tracing::warn!("Ignoring unverifiable machine announcement: {}", e);
6016                                continue;
6017                            }
6018                            remember_verified_payload(&mut seen_machine_payloads, &raw_payload);
6019                        }
6020
6021                        let now = std::time::SystemTime::now()
6022                            .duration_since(std::time::UNIX_EPOCH)
6023                            .map_or(0, |d| d.as_secs());
6024
6025                        let bootstrap_addresses = filter_publicly_advertisable_addrs(
6026                            announcement.addresses.iter().copied(),
6027                        );
6028                        if !bootstrap_addresses.is_empty() {
6029                            if let Some(ref bc) = &bootstrap_cache {
6030                                let peer_id = ant_quic::PeerId(announcement.machine_id.0);
6031                                bc.add_from_connection(peer_id, bootstrap_addresses.clone(), None)
6032                                    .await;
6033                            }
6034                        }
6035
6036                        let discovery_addresses = filter_discovery_announcement_addrs(
6037                            announcement.addresses.iter().copied(),
6038                            allow_local_scope,
6039                        );
6040                        let filtered_addr_count = discovery_addresses.len();
6041                        upsert_discovered_machine(
6042                            &machine_cache,
6043                            DiscoveredMachine::from_machine_announcement(
6044                                &announcement,
6045                                discovery_addresses,
6046                                now,
6047                            ),
6048                        )
6049                        .await;
6050                        tracing::debug!(
6051                            target: "x0x::discovery",
6052                            announcement_kind = "machine_received",
6053                            machine_prefix = %network::hex_prefix(&announcement.machine_id.0, 4),
6054                            addr_total = announcement.addresses.len(),
6055                            filtered_addr_count,
6056                            nat_type = announcement.nat_type.as_deref().unwrap_or("unknown"),
6057                            can_receive_direct = ?announcement.can_receive_direct,
6058                            relay_capable = ?announcement.is_relay,
6059                            coordinator_capable = ?announcement.is_coordinator,
6060                            "cached verified machine announcement"
6061                        );
6062
6063                        if announcement.machine_id != own_machine_id {
6064                            let key = (announcement.machine_id, announcement.announced_at);
6065                            if should_rebroadcast_discovery_once(
6066                                &mut machine_rebroadcast_state,
6067                                key,
6068                                std::time::Instant::now(),
6069                            ) {
6070                                let pubsub = std::sync::Arc::clone(&rebroadcast_pubsub);
6071                                tokio::spawn(async move {
6072                                    if let Err(e) = pubsub
6073                                        .publish(MACHINE_ANNOUNCE_TOPIC.to_string(), raw_payload)
6074                                        .await
6075                                    {
6076                                        tracing::debug!(
6077                                            "machine announcement re-broadcast failed: {e}"
6078                                        );
6079                                    }
6080                                });
6081                            }
6082                        }
6083                        continue;
6084                    }
6085                    DiscoveryMessage::User(msg) => {
6086                        let raw_payload = msg.payload.clone();
6087                        let announcement = match deserialize_user_announcement(&raw_payload) {
6088                            Ok(a) => a,
6089                            Err(e) => {
6090                                tracing::debug!(
6091                                    "Ignoring invalid user announcement payload: {}",
6092                                    e
6093                                );
6094                                continue;
6095                            }
6096                        };
6097                        if let Err(e) = announcement.verify() {
6098                            tracing::warn!("Ignoring unverifiable user announcement: {}", e);
6099                            continue;
6100                        }
6101                        let now = std::time::SystemTime::now()
6102                            .duration_since(std::time::UNIX_EPOCH)
6103                            .map_or(0, |d| d.as_secs());
6104                        let incoming = DiscoveredUser::from_announcement(&announcement, now);
6105                        {
6106                            let mut cache = user_cache.write().await;
6107                            match cache.get_mut(&incoming.user_id) {
6108                                Some(existing) if incoming.announced_at < existing.announced_at => {
6109                                    // Ignore stale announcement.
6110                                }
6111                                Some(existing) => {
6112                                    existing.user_public_key = incoming.user_public_key;
6113                                    existing.agent_certificates = incoming.agent_certificates;
6114                                    existing.agent_ids = incoming.agent_ids;
6115                                    existing.announced_at = incoming.announced_at;
6116                                    existing.last_seen = now;
6117                                }
6118                                None => {
6119                                    cache.insert(incoming.user_id, incoming);
6120                                }
6121                            }
6122                        }
6123                        tracing::debug!(
6124                            target: "x0x::discovery",
6125                            announcement_kind = "user_received",
6126                            user_prefix = %network::hex_prefix(&announcement.user_id.0, 4),
6127                            agent_count = announcement.agent_certificates.len(),
6128                            "cached verified user announcement"
6129                        );
6130                        // Rebroadcast non-self announcements once, matching
6131                        // identity / machine announcements.
6132                        if Some(announcement.user_id) != own_user_id {
6133                            let key = (announcement.user_id, announcement.announced_at);
6134                            if should_rebroadcast_discovery_once(
6135                                &mut user_rebroadcast_state,
6136                                key,
6137                                std::time::Instant::now(),
6138                            ) {
6139                                let pubsub = std::sync::Arc::clone(&rebroadcast_pubsub);
6140                                tokio::spawn(async move {
6141                                    if let Err(e) = pubsub
6142                                        .publish(USER_ANNOUNCE_TOPIC.to_string(), raw_payload)
6143                                        .await
6144                                    {
6145                                        tracing::debug!(
6146                                            "user announcement re-broadcast failed: {e}"
6147                                        );
6148                                    }
6149                                });
6150                            }
6151                        }
6152                        continue;
6153                    }
6154                    DiscoveryMessage::Identity(msg) => msg,
6155                    DiscoveryMessage::Revocation(msg) => {
6156                        // Size-limit: max 2 MiB for a revocation batch (records
6157                        // are ~5.3 KB each; 2 MiB ≈ 380 records, far beyond
6158                        // any realistic fleet size in v1).
6159                        const MAX_REVOCATION_PAYLOAD_BYTES: usize = 2 * 1024 * 1024;
6160                        if msg.payload.len() > MAX_REVOCATION_PAYLOAD_BYTES {
6161                            tracing::debug!(
6162                                "ignoring oversized revocation payload ({} bytes)",
6163                                msg.payload.len()
6164                            );
6165                            continue;
6166                        }
6167                        let records: Vec<revocation::RevocationRecord> =
6168                            match bincode::deserialize(&msg.payload) {
6169                                Ok(r) => r,
6170                                Err(e) => {
6171                                    tracing::debug!("ignoring invalid revocation payload: {e}");
6172                                    continue;
6173                                }
6174                            };
6175                        let mut newly_inserted = Vec::new();
6176                        // Resolve subject certificates from the discovery cache
6177                        // so gossiped issuer-revocations (a user un-vouching a
6178                        // certified agent) can be authority-verified on receipt
6179                        // (issue #191). `verify_authority` for an issuer-
6180                        // revocation requires the subject cert; self/machine
6181                        // revocations need none. A cert not in the local cache
6182                        // means the issuer-revocation is rejected fail-closed
6183                        // — the cert must have been announced first (EP1).
6184                        // Built before taking the revocation-set write lock so
6185                        // no two identity locks are held at once.
6186                        let subject_certs = collect_subject_certs(&*cache.read().await);
6187                        {
6188                            let mut set = revocation_set.write().await;
6189                            for record in records {
6190                                if set.contains_hash(&record.record_hash()) {
6191                                    continue; // already known — skip re-verification
6192                                }
6193                                let subject_cert = match &record.subject {
6194                                    revocation::RevokedSubject::Agent(agent_id) => {
6195                                        subject_certs.get(agent_id)
6196                                    }
6197                                    _ => None,
6198                                };
6199                                match set.verify_and_insert(record.clone(), subject_cert) {
6200                                    Ok(true) => newly_inserted.push(record),
6201                                    Ok(false) => {} // dup
6202                                    Err(e) => {
6203                                        tracing::debug!(
6204                                            "revocation record rejected: {e}"
6205                                        );
6206                                    }
6207                                }
6208                            }
6209                        }
6210                        if !newly_inserted.is_empty() {
6211                            // Persist asynchronously — best-effort; if it fails
6212                            // the revocation is still enforced in memory for this
6213                            // run. Snapshot the live set's encoded bytes under a
6214                            // brief read lock (issuer-revocations carry their
6215                            // authorizing cert in PersistedRevocation, which the
6216                            // encoder preserves); the disk write runs off-lock.
6217                            // The previous rebuild re-inserted records with
6218                            // `None` cert, silently dropping every issuer-
6219                            // revocation on save (issue #191).
6220                            let persisted_bytes = revocation_set.read().await.to_bytes();
6221                            let id_dir = identity_dir_for_listener.clone();
6222                            tokio::spawn(async move {
6223                                match persisted_bytes {
6224                                    Ok(bytes) => {
6225                                        if let Err(e) = storage::save_revocation_set_bytes(
6226                                            bytes,
6227                                            id_dir.as_deref(),
6228                                        )
6229                                        .await
6230                                        {
6231                                            tracing::warn!(
6232                                                "failed to persist revocation set: {e}"
6233                                            );
6234                                        }
6235                                    }
6236                                    Err(e) => tracing::warn!(
6237                                        "failed to encode revocation set for persistence: {e}"
6238                                    ),
6239                                }
6240                            });
6241                            // Evict revoked subjects from discovery caches.
6242                            for record in newly_inserted {
6243                                match &record.subject {
6244                                    revocation::RevokedSubject::Agent(agent_id) => {
6245                                        if let Some(entry) = cache.write().await.remove(agent_id) {
6246                                            machine_cache.write().await.remove(&entry.machine_id);
6247                                        }
6248                                        let mut cs = contact_store_for_evict.write().await;
6249                                        cs.set_trust(agent_id, contacts::TrustLevel::Blocked);
6250                                        tracing::info!(
6251                                            agent = %hex::encode(agent_id.as_bytes()),
6252                                            "evicted revoked agent (received via gossip)"
6253                                        );
6254                                    }
6255                                    revocation::RevokedSubject::Machine(machine_id) => {
6256                                        machine_cache.write().await.remove(machine_id);
6257                                        cache.write().await.retain(|_, a| a.machine_id != *machine_id);
6258                                        tracing::info!(
6259                                            machine = %hex::encode(machine_id.as_bytes()),
6260                                            "evicted revoked machine (received via gossip)"
6261                                        );
6262                                    }
6263                                }
6264                            }
6265                        }
6266                        continue;
6267                    }
6268                };
6269                let raw_payload = msg.payload.clone();
6270                let already_verified =
6271                    has_recent_verified_payload(&seen_identity_payloads, &raw_payload);
6272                let announcement = match deserialize_identity_announcement(&raw_payload) {
6273                    Ok(a) => a,
6274                    Err(e) => {
6275                        tracing::debug!("Ignoring invalid identity announcement payload: {}", e);
6276                        continue;
6277                    }
6278                };
6279
6280                if !already_verified {
6281                    if let Err(e) = announcement.verify() {
6282                        tracing::warn!("Ignoring unverifiable identity announcement: {}", e);
6283                        continue;
6284                    }
6285                    remember_verified_payload(&mut seen_identity_payloads, &raw_payload);
6286                }
6287
6288                let now = Agent::unix_timestamp_secs();
6289                if !identity_announcement_timestamp_is_acceptable(announcement.announced_at, now) {
6290                    tracing::warn!(
6291                        agent = %hex::encode(announcement.agent_id.as_bytes()),
6292                        announced_at = announcement.announced_at,
6293                        now,
6294                        max_future_skew_secs = IDENTITY_ANNOUNCEMENT_CLOCK_SKEW_SECS,
6295                        "ignoring far-future identity announcement"
6296                    );
6297                    continue;
6298                }
6299
6300                // Evaluate trust for this (agent, machine) pair.
6301                // Blocked or machine-pinning violations are silently dropped.
6302                {
6303                    let store = contact_store.read().await;
6304                    let evaluator = trust::TrustEvaluator::new(&store);
6305                    let decision = evaluator.evaluate(&trust::TrustContext {
6306                        agent_id: &announcement.agent_id,
6307                        machine_id: &announcement.machine_id,
6308                    });
6309                    match decision {
6310                        trust::TrustDecision::RejectBlocked => {
6311                            tracing::debug!(
6312                                "Dropping identity announcement from blocked agent {:?}",
6313                                hex::encode(&announcement.agent_id.0[..8]),
6314                            );
6315                            continue;
6316                        }
6317                        trust::TrustDecision::RejectMachineMismatch => {
6318                            tracing::warn!(
6319                                "Dropping identity announcement from agent {}: machine {} not in pinned list",
6320                                crate::logging::LogAgentId::from(&announcement.agent_id),
6321                                crate::logging::LogMachineId::from(&announcement.machine_id),
6322                            );
6323                            continue;
6324                        }
6325                        _ => {}
6326                    }
6327                }
6328
6329                // Enforcement point 1 — revocation gate.
6330                // Fail-closed: a revoked agent or machine is silently dropped
6331                // even if the trust store says otherwise.  This check must come
6332                // BEFORE inserting into the discovery cache so a revoked peer
6333                // never gets a `verified` annotation.
6334                {
6335                    let revoked = revocation_set.read().await;
6336                    if revoked.is_agent_revoked(&announcement.agent_id) {
6337                        tracing::debug!(
6338                            "Dropping identity announcement from revoked agent {:?}",
6339                            hex::encode(&announcement.agent_id.0[..8]),
6340                        );
6341                        continue;
6342                    }
6343                    if revoked.is_machine_revoked(&announcement.machine_id) {
6344                        tracing::debug!(
6345                            "Dropping identity announcement from revoked machine {:?}",
6346                            hex::encode(&announcement.machine_id.0[..8]),
6347                        );
6348                        continue;
6349                    }
6350                }
6351
6352                // Enforcement point 1b — cert expiry gate.
6353                // Drop announcements whose embedded cert is expired (fail-closed).
6354                // Announcements without a cert (cert == None) are fail-open:
6355                // they just won't carry a user binding and cert_not_after will be None.
6356                if let Some(cert) = &announcement.agent_certificate {
6357                    if identity::is_expired(cert.not_after(), Agent::unix_timestamp_secs()) {
6358                        tracing::debug!(
6359                            "Dropping identity announcement with expired cert from agent {:?}",
6360                            hex::encode(&announcement.agent_id.0[..8]),
6361                        );
6362                        continue;
6363                    }
6364                }
6365
6366                // Update machine records in the contact store.
6367                //
6368                // The daemon never registers its own agent as a contact: a self
6369                // entry would be noise on the `/contacts` projection and pollute
6370                // contact-set assertions (issue #145). The rebroadcast and
6371                // auto-connect branches below already self-skip on
6372                // `announcement.agent_id != own_agent_id`; the machine-record
6373                // upsert was the sole outlier. Foreign observation still
6374                // registers normally.
6375                register_announced_machine(
6376                    &contact_store,
6377                    own_agent_id,
6378                    announcement.agent_id,
6379                    announcement.machine_id,
6380                )
6381                .await;
6382
6383
6384                // Add only globally-advertisable addresses to the persistent
6385                // bootstrap cache. Legacy peers may still ship LAN, CGNAT,
6386                // loopback, or port-zero entries, but those are not useful
6387                // outside link-local discovery and must not become dial
6388                // candidates for globally propagated announcements.
6389                let bootstrap_addresses =
6390                    filter_publicly_advertisable_addrs(announcement.addresses.iter().copied());
6391                let discovery_addresses = filter_discovery_announcement_addrs(
6392                    announcement.addresses.iter().copied(),
6393                    allow_local_scope,
6394                );
6395                let filtered_addr_count = discovery_addresses.len();
6396                let auto_connect_addresses = discovery_addresses.clone();
6397                {
6398                    if !bootstrap_addresses.is_empty() {
6399                        if let Some(ref bc) = &bootstrap_cache {
6400                            let peer_id = ant_quic::PeerId(announcement.machine_id.0);
6401                            bc.add_from_connection(peer_id, bootstrap_addresses.clone(), None)
6402                                .await;
6403                            tracing::debug!(
6404                                "Added {} public addresses to bootstrap cache for agent {:?} (machine {:?})",
6405                                bootstrap_addresses.len(),
6406                                announcement.agent_id,
6407                                hex::encode(&announcement.machine_id.0[..8]),
6408                            );
6409                        }
6410                    }
6411                }
6412
6413                // Cache public addresses on global partitions, but keep
6414                // LAN/loopback hints on explicit local/testnet partitions.
6415                // Empty address lists are preserved (the `AlreadyConnected`
6416                // path in `connect_to_agent` handles gossip peers we only reach
6417                // by an existing QUIC connection).
6418                let cert_not_after = announcement
6419                    .agent_certificate
6420                    .as_ref()
6421                    .and_then(|c| c.not_after());
6422                let discovered_agent = DiscoveredAgent {
6423                    agent_id: announcement.agent_id,
6424                    machine_id: announcement.machine_id,
6425                    user_id: announcement.user_id,
6426                    addresses: discovery_addresses,
6427                    announced_at: announcement.announced_at,
6428                    last_seen: now,
6429                    machine_public_key: announcement.machine_public_key.clone(),
6430                    nat_type: announcement.nat_type.clone(),
6431                    can_receive_direct: announcement.can_receive_direct,
6432                    is_relay: announcement.is_relay,
6433                    is_coordinator: announcement.is_coordinator,
6434                    reachable_via: announcement.reachable_via.clone(),
6435                    relay_candidates: announcement.relay_candidates.clone(),
6436                    cert_not_after,
6437                    agent_certificate: announcement.agent_certificate.clone(),
6438                    agent_public_key: announcement.agent_public_key.clone(),
6439                };
6440                record_authenticated_machine_binding_from_message(
6441                    &authenticated_machine_bindings,
6442                    &msg,
6443                    &announcement,
6444                    now,
6445                )
6446                .await;
6447                upsert_discovered_machine_from_agent(&machine_cache, &discovered_agent).await;
6448                upsert_discovered_agent(&cache, discovered_agent).await;
6449                tracing::debug!(
6450                    target: "x0x::discovery",
6451                    announcement_kind = "received",
6452                    agent_prefix = %network::hex_prefix(&announcement.agent_id.0, 4),
6453                    machine_prefix = %network::hex_prefix(&announcement.machine_id.0, 4),
6454                    addr_total = announcement.addresses.len(),
6455                    filtered_addr_count,
6456                    nat_type = announcement.nat_type.as_deref().unwrap_or("unknown"),
6457                    can_receive_direct = ?announcement.can_receive_direct,
6458                    relay_capable = ?announcement.is_relay,
6459                    coordinator_capable = ?announcement.is_coordinator,
6460                    reachable_via_count = announcement.reachable_via.len(),
6461                    relay_candidate_count = announcement.relay_candidates.len(),
6462                    "cached verified identity announcement"
6463                );
6464
6465                // Identity announcements are the strongest agent↔machine binding we have.
6466                // Register the mapping immediately so reverse direct-send can resolve the
6467                // machine even before the first inbound direct payload arrives.
6468                direct_messaging
6469                    .register_agent(announcement.agent_id, announcement.machine_id)
6470                    .await;
6471
6472                // Epidemic re-broadcast — mirrors the release-manifest
6473                // re-broadcast pattern. Bootstrap-node meshes have patchy
6474                // PlumTree overlap for the identity-announce topic: the
6475                // origin's tree only reaches 1–2 hops reliably. Making
6476                // every verified recipient re-publish guarantees flood
6477                // convergence across the mesh. One-shot dedup on
6478                // (agent_id, announced_at) bounds amplification. Pub/Sub
6479                // v2 re-signs each publish with a new message ID so
6480                // PlumTree's own dedup cannot suppress repeated forwards;
6481                // therefore each daemon forwards a given announcement at most
6482                // once.
6483                if announcement.agent_id != own_agent_id {
6484                    let key = (announcement.agent_id, announcement.announced_at);
6485                    if should_rebroadcast_discovery_once(
6486                        &mut rebroadcast_state,
6487                        key,
6488                        std::time::Instant::now(),
6489                    ) {
6490                        let pubsub = std::sync::Arc::clone(&rebroadcast_pubsub);
6491                        let payload = raw_payload.clone();
6492                        tokio::spawn(async move {
6493                            if let Err(e) = pubsub
6494                                .publish(IDENTITY_ANNOUNCE_TOPIC.to_string(), payload)
6495                                .await
6496                            {
6497                                tracing::debug!("identity announcement re-broadcast failed: {e}");
6498                            }
6499                        });
6500                    }
6501                }
6502
6503                // Reconcile the agent-level direct-message registry if the transport peer
6504                // is already connected (for example an inbound accept that happened before
6505                // this announcement reached us).
6506                if let Some(ref net) = &network {
6507                    let ant_peer_id = ant_quic::PeerId(announcement.machine_id.0);
6508                    if net.is_connected(&ant_peer_id).await {
6509                        direct_messaging
6510                            .mark_connected(announcement.agent_id, announcement.machine_id)
6511                            .await;
6512                    }
6513                }
6514
6515                // Auto-connect to discovered agents so pub/sub messages can route
6516                // between peers that share bootstrap nodes but aren't directly connected.
6517                // The gossip topology refresh (every 1s) will add the new peer to
6518                // PlumTree topic trees once the QUIC connection is established.
6519                if announcement.agent_id != own_agent_id
6520                    && !auto_connect_addresses.is_empty()
6521                    && !auto_connect_attempted.contains(&announcement.agent_id)
6522                {
6523                    if let Some(ref net) = &network {
6524                        let ant_peer = ant_quic::PeerId(announcement.machine_id.0);
6525                        if !net.is_connected(&ant_peer).await {
6526                            auto_connect_attempted.insert(announcement.agent_id);
6527                            let net = std::sync::Arc::clone(net);
6528                            let addresses = auto_connect_addresses.clone();
6529                            tokio::spawn(async move {
6530                                for addr in &addresses {
6531                                    match net.connect_addr(*addr).await {
6532                                        Ok(_) => {
6533                                            tracing::info!(
6534                                                "Auto-connected to discovered agent at {addr}",
6535                                            );
6536                                            return;
6537                                        }
6538                                        Err(e) => {
6539                                            tracing::debug!("Auto-connect to {addr} failed: {e}",);
6540                                        }
6541                                    }
6542                                }
6543                                tracing::debug!(
6544                                    "Auto-connect exhausted all {} addresses for discovered agent",
6545                                    addresses.len(),
6546                                );
6547                            });
6548                        }
6549                    }
6550                }
6551            }
6552        });
6553
6554        Ok(())
6555    }
6556
6557    fn unix_timestamp_secs() -> u64 {
6558        std::time::SystemTime::now()
6559            .duration_since(std::time::UNIX_EPOCH)
6560            .map_or(0, |d| d.as_secs())
6561    }
6562
6563    fn announcement_addresses(&self) -> Vec<std::net::SocketAddr> {
6564        match self.network.as_ref().and_then(|n| n.local_addr()) {
6565            Some(addr) if addr.port() > 0 => filter_discovery_announcement_addrs(
6566                collect_local_interface_addrs(addr.port()),
6567                self.network
6568                    .as_ref()
6569                    .is_some_and(|network| allow_local_discovery_addresses(network.config())),
6570            ),
6571            _ => Vec::new(),
6572        }
6573    }
6574
6575    fn build_identity_announcement(
6576        &self,
6577        include_user_identity: bool,
6578        human_consent: bool,
6579    ) -> error::Result<IdentityAnnouncement> {
6580        self.build_identity_announcement_with_addrs(IdentityAnnouncementBuildOptions {
6581            include_user_identity,
6582            human_consent,
6583            addresses: self.announcement_addresses(),
6584            assist_snapshot: None,
6585            reachable_via: Vec::new(),
6586            relay_candidates: Vec::new(),
6587            allow_local_scope: false,
6588        })
6589    }
6590
6591    fn build_identity_announcement_with_addrs(
6592        &self,
6593        options: IdentityAnnouncementBuildOptions<'_>,
6594    ) -> error::Result<IdentityAnnouncement> {
6595        let IdentityAnnouncementBuildOptions {
6596            include_user_identity,
6597            human_consent,
6598            addresses,
6599            assist_snapshot,
6600            reachable_via,
6601            relay_candidates,
6602            allow_local_scope,
6603        } = options;
6604        if include_user_identity && !human_consent {
6605            return Err(error::IdentityError::Storage(std::io::Error::other(
6606                "human identity disclosure requires explicit human consent — set human_consent: true in the request body",
6607            )));
6608        }
6609        let addresses = filter_discovery_announcement_addrs(addresses, allow_local_scope);
6610
6611        let (user_id, agent_certificate) = if include_user_identity {
6612            let user_id = self.user_id().ok_or_else(|| {
6613                error::IdentityError::Storage(std::io::Error::other(
6614                    "human identity disclosure requested but no user identity is configured — set user_key_path in your config.toml to point at your user keypair file",
6615                ))
6616            })?;
6617            let cert = self.agent_certificate().cloned().ok_or_else(|| {
6618                error::IdentityError::Storage(std::io::Error::other(
6619                    "human identity disclosure requested but agent certificate is missing",
6620                ))
6621            })?;
6622            (Some(user_id), Some(cert))
6623        } else {
6624            (None, None)
6625        };
6626
6627        let machine_public_key = self
6628            .identity
6629            .machine_keypair()
6630            .public_key()
6631            .as_bytes()
6632            .to_vec();
6633
6634        let unsigned = IdentityAnnouncementUnsigned {
6635            agent_id: self.agent_id(),
6636            machine_id: self.machine_id(),
6637            user_id,
6638            agent_certificate: agent_certificate.clone(),
6639            machine_public_key: machine_public_key.clone(),
6640            addresses,
6641            announced_at: Self::unix_timestamp_secs(),
6642            nat_type: assist_snapshot.and_then(|snapshot| snapshot.nat_type.clone()),
6643            can_receive_direct: assist_snapshot.and_then(|snapshot| snapshot.can_receive_direct),
6644            is_relay: assist_snapshot.and_then(|snapshot| snapshot.relay_capable),
6645            is_coordinator: assist_snapshot.and_then(|snapshot| snapshot.coordinator_capable),
6646            reachable_via,
6647            relay_candidates,
6648        };
6649        let unsigned_bytes = bincode::serialize(&unsigned).map_err(|e| {
6650            error::IdentityError::Serialization(format!(
6651                "failed to serialize unsigned identity announcement: {e}"
6652            ))
6653        })?;
6654        let machine_signature = ant_quic::crypto::raw_public_keys::pqc::sign_with_ml_dsa(
6655            self.identity.machine_keypair().secret_key(),
6656            &unsigned_bytes,
6657        )
6658        .map_err(|e| {
6659            error::IdentityError::Storage(std::io::Error::other(format!(
6660                "failed to sign identity announcement with machine key: {:?}",
6661                e
6662            )))
6663        })?
6664        .as_bytes()
6665        .to_vec();
6666
6667        Ok(IdentityAnnouncement {
6668            agent_id: unsigned.agent_id,
6669            machine_id: unsigned.machine_id,
6670            user_id: unsigned.user_id,
6671            agent_certificate: unsigned.agent_certificate,
6672            machine_public_key,
6673            machine_signature,
6674            addresses: unsigned.addresses,
6675            announced_at: unsigned.announced_at,
6676            nat_type: unsigned.nat_type,
6677            can_receive_direct: unsigned.can_receive_direct,
6678            is_relay: unsigned.is_relay,
6679            is_coordinator: unsigned.is_coordinator,
6680            reachable_via: unsigned.reachable_via,
6681            relay_candidates: unsigned.relay_candidates,
6682            agent_public_key: self
6683                .identity
6684                .agent_keypair()
6685                .public_key()
6686                .as_bytes()
6687                .to_vec(),
6688        })
6689    }
6690
6691    /// Join the x0x gossip network.
6692    ///
6693    /// Connects to bootstrap peers in parallel with automatic retries.
6694    /// Failed connections are retried after a delay to allow stale
6695    /// connections on remote nodes to expire.
6696    ///
6697    /// If the agent was not configured with a network, this method
6698    /// succeeds gracefully (nothing to join).
6699    pub async fn join_network(&self) -> error::Result<()> {
6700        let Some(network) = self.network.as_ref() else {
6701            tracing::debug!("join_network called but no network configured");
6702            return Ok(());
6703        };
6704
6705        if let Some(ref runtime) = self.gossip_runtime {
6706            runtime.start().await.map_err(|e| {
6707                error::IdentityError::Storage(std::io::Error::other(format!(
6708                    "failed to start gossip runtime: {e}"
6709                )))
6710            })?;
6711            tracing::info!("Gossip runtime started");
6712        }
6713        // Race guard (issue #116): if shutdown() already fired (token cancelled),
6714        // do not start the listeners. Combined with spawn_tracked's closed-check,
6715        // a shutdown that races mid-bootstrap cannot leave a listener running.
6716        if self.shutdown_token.is_cancelled() {
6717            tracing::info!("join_network aborted: shutdown already in progress");
6718            return Ok(());
6719        }
6720        self.start_identity_listener().await?;
6721        self.start_network_event_listener();
6722        self.start_direct_listener();
6723        self.start_stream_accept_loop();
6724
6725        let bootstrap_nodes = network.config().bootstrap_nodes.clone();
6726
6727        let min_connected = 3;
6728        let mut all_connected: Vec<std::net::SocketAddr> = Vec::new();
6729
6730        // ant-quic now owns first-party mDNS discovery and auto-connect.
6731        // x0x keeps bootstrap/cache orchestration here, while the transport
6732        // layer handles zero-config LAN discovery internally.
6733
6734        // Phase 0: Try quality-scored coordinator peers from bootstrap cache.
6735        // The bootstrap cache learns about coordinator-capable peers passively
6736        // through normal connections — no coordinator gossip topic needed.
6737        if let Some(ref cache) = self.bootstrap_cache {
6738            let coordinators = cache.select_coordinators(6).await;
6739            let coordinator_addrs: Vec<std::net::SocketAddr> = coordinators
6740                .iter()
6741                .flat_map(|peer| peer.preferred_addresses())
6742                .collect();
6743
6744            if !coordinator_addrs.is_empty() {
6745                tracing::info!(
6746                    "Phase 0: Trying {} addresses from {} cached coordinators",
6747                    coordinator_addrs.len(),
6748                    coordinators.len()
6749                );
6750                let (succeeded, _failed) = self
6751                    .connect_peers_parallel_tracked(network, &coordinator_addrs)
6752                    .await;
6753                all_connected.extend(&succeeded);
6754                tracing::info!(
6755                    "Phase 0: {}/{} coordinator addresses connected",
6756                    succeeded.len(),
6757                    coordinator_addrs.len()
6758                );
6759            }
6760        }
6761
6762        // Phase 1: Try cached peers first using the real ant-quic peer IDs.
6763        if all_connected.len() < min_connected {
6764            if let Some(ref cache) = self.bootstrap_cache {
6765                const PHASE1_PEER_CANDIDATES: usize = 12;
6766                let cached_peers = cache.select_peers(PHASE1_PEER_CANDIDATES).await;
6767                if !cached_peers.is_empty() {
6768                    tracing::info!("Phase 1: Trying {} cached peers", cached_peers.len());
6769                    let (succeeded, _failed) = self
6770                        .connect_cached_peers_parallel_tracked(network, &cached_peers)
6771                        .await;
6772                    all_connected.extend(&succeeded);
6773                    tracing::info!(
6774                        "Phase 1: {}/{} cached peers connected",
6775                        succeeded.len(),
6776                        cached_peers.len()
6777                    );
6778                }
6779            }
6780        } // end Phase 1 min_connected check
6781
6782        // Phase 2: Connect to hardcoded bootstrap nodes if we need more peers.
6783        // This is the fallback for when coordinator cache and cached peers aren't enough.
6784        if all_connected.len() < min_connected && !bootstrap_nodes.is_empty() {
6785            let remaining: Vec<std::net::SocketAddr> = bootstrap_nodes
6786                .iter()
6787                .filter(|addr| !all_connected.contains(addr))
6788                .copied()
6789                .collect();
6790
6791            // Round 1: Connect to all bootstrap peers in parallel
6792            let (succeeded, mut failed) = self
6793                .connect_peers_parallel_tracked(network, &remaining)
6794                .await;
6795            all_connected.extend(&succeeded);
6796            tracing::info!(
6797                "Phase 2 round 1: {}/{} bootstrap peers connected",
6798                succeeded.len(),
6799                remaining.len()
6800            );
6801
6802            // Retry rounds for failed peers
6803            for round in 2..=3 {
6804                if failed.is_empty() {
6805                    break;
6806                }
6807                let delay = std::time::Duration::from_secs(if round == 2 { 10 } else { 15 });
6808                tracing::info!(
6809                    "Retrying {} failed peers in {}s (round {})",
6810                    failed.len(),
6811                    delay.as_secs(),
6812                    round
6813                );
6814                tokio::time::sleep(delay).await;
6815
6816                let (succeeded, still_failed) =
6817                    self.connect_peers_parallel_tracked(network, &failed).await;
6818                all_connected.extend(&succeeded);
6819                failed = still_failed;
6820                tracing::info!(
6821                    "Phase 2 round {}: {} total peers connected",
6822                    round,
6823                    all_connected.len()
6824                );
6825            }
6826
6827            if !failed.is_empty() {
6828                tracing::warn!(
6829                    "Could not connect to {} bootstrap peers: {:?}",
6830                    failed.len(),
6831                    failed
6832                );
6833            }
6834        }
6835
6836        tracing::info!(
6837            "Network join complete. Connected to {} peers.",
6838            all_connected.len()
6839        );
6840
6841        // Join the HyParView membership overlay via connected peers.
6842        if let Some(ref runtime) = self.gossip_runtime {
6843            let seeds: Vec<String> = all_connected.iter().map(|addr| addr.to_string()).collect();
6844            if !seeds.is_empty() {
6845                if let Err(e) = runtime.membership().join(seeds).await {
6846                    tracing::warn!("HyParView membership join failed: {e}");
6847                }
6848            }
6849        }
6850
6851        // Start presence beacons after membership overlay is established.
6852        if let Some(ref pw) = self.presence {
6853            // Seed broadcast peers from both HyParView and ant-quic's live
6854            // connection table so beacons propagate even when HyParView's
6855            // active view lags behind the transport mesh.
6856            if let Some(ref runtime) = self.gossip_runtime {
6857                let active = runtime.membership().active_view();
6858                let active_view_count = active.len();
6859                let mut broadcast_peers = active;
6860
6861                let mut connected_peer_count = 0usize;
6862                if let Some(ref net) = self.network {
6863                    let connected = net.connected_peers().await;
6864                    connected_peer_count = connected.len();
6865                    broadcast_peers.extend(
6866                        connected
6867                            .into_iter()
6868                            .map(|peer| saorsa_gossip_types::PeerId::new(peer.0)),
6869                    );
6870                }
6871
6872                pw.manager().replace_broadcast_peers(broadcast_peers).await;
6873                let broadcast_peer_count = pw.manager().broadcast_peer_count().await;
6874                tracing::info!(
6875                    active_view_count,
6876                    connected_peer_count,
6877                    broadcast_peer_count,
6878                    "Presence seeded broadcast peers"
6879                );
6880
6881                // Refresh broadcast_peers from HyParView and the live ant-quic
6882                // connection table every 30 s. Without this, agents that finish
6883                // join_network() before the mesh has formed can end up with an
6884                // empty/stale broadcast set forever. The 2026-04-26 live mesh
6885                // had full QUIC peer connectivity while HyParView active_view()
6886                // stayed at <= 1 peer, so the transport table is the source of
6887                // truth for presence fanout. Replace the fanout set each tick so
6888                // stale/disconnected peers do not accumulate and later wedge
6889                // presence delivery behind failed reconnect attempts.
6890                let pw_clone = pw.clone();
6891                let runtime_clone = runtime.clone();
6892                let network_clone = self.network.as_ref().map(std::sync::Arc::clone);
6893                let token = self.shutdown_token.clone();
6894                self.spawn_tracked(async move {
6895                    let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
6896                    interval.tick().await; // first tick fires immediately; startup already seeded
6897                    loop {
6898                        tokio::select! {
6899                            _ = interval.tick() => {}
6900                            // Inert until shutdown; then the 30s timer loop exits
6901                            // instead of being merely aborted.
6902                            _ = token.cancelled() => break,
6903                        }
6904
6905                        let active = runtime_clone.membership().active_view();
6906                        let active_view_count = active.len();
6907                        let mut broadcast_peers = active;
6908
6909                        let mut connected_peer_count = 0usize;
6910                        if let Some(ref net) = network_clone {
6911                            let connected = net.connected_peers().await;
6912                            connected_peer_count = connected.len();
6913                            broadcast_peers.extend(
6914                                connected
6915                                    .into_iter()
6916                                    .map(|peer| saorsa_gossip_types::PeerId::new(peer.0)),
6917                            );
6918                        }
6919
6920                        pw_clone
6921                            .manager()
6922                            .replace_broadcast_peers(broadcast_peers)
6923                            .await;
6924                        let broadcast_peer_count = pw_clone.manager().broadcast_peer_count().await;
6925                        tracing::info!(
6926                            active_view_count,
6927                            connected_peer_count,
6928                            broadcast_peer_count,
6929                            "Presence broadcast peer refresh"
6930                        );
6931                    }
6932                });
6933            }
6934
6935            // Populate address hints from network status for beacon metadata.
6936            //
6937            // Presence beacons propagate over global gossip — filter to only
6938            // globally-advertisable addresses. LAN discovery is ant-quic's
6939            // mDNS job, not ours. Also exclude `status.local_addr` which is
6940            // typically the wildcard `[::]:5483` and never a dialable target.
6941            if let Some(ref net) = self.network {
6942                if let Some(status) = net.node_status().await {
6943                    let hints: Vec<String> = status
6944                        .external_addrs
6945                        .iter()
6946                        .filter(|a| is_publicly_advertisable(**a))
6947                        .map(|a| a.to_string())
6948                        .collect();
6949                    pw.manager().set_addr_hints(hints).await;
6950                }
6951            }
6952
6953            // Shutdown race (issue #116): skip starting presence beacons / event
6954            // loop if shutdown began mid-bootstrap; Agent::shutdown() runs
6955            // pw.shutdown() AFTER cancel(), so checking here ensures we never
6956            // start beacons that pw.shutdown() already stopped.
6957            if !self.shutdown_token.is_cancelled() {
6958                if pw.config().enable_beacons {
6959                    if let Err(e) = pw
6960                        .manager()
6961                        .start_beacons(pw.config().beacon_interval_secs)
6962                        .await
6963                    {
6964                        tracing::warn!("Failed to start presence beacons: {e}");
6965                    } else {
6966                        tracing::info!(
6967                            "Presence beacons started (interval={}s)",
6968                            pw.config().beacon_interval_secs
6969                        );
6970                    }
6971                }
6972
6973                // Start the presence event-emission loop so that subscribers
6974                // automatically receive AgentOnline/AgentOffline events after
6975                // join_network() returns.
6976                pw.start_event_loop(std::sync::Arc::clone(&self.identity_discovery_cache))
6977                    .await;
6978                tracing::debug!("Presence event loop started");
6979            }
6980        }
6981
6982        if let Err(e) = self.announce_identity(false, false).await {
6983            tracing::warn!("Initial identity announcement failed: {}", e);
6984        }
6985        if let Err(e) = self.start_identity_heartbeat().await {
6986            tracing::warn!("Failed to start identity heartbeat: {e}");
6987        }
6988        if let Err(e) = self.start_discovery_cache_reaper().await {
6989            tracing::warn!("Failed to start discovery cache reaper: {e}");
6990        }
6991
6992        // Schedule a fresh re-announcement after gossip topology stabilizes.
6993        // The initial publish fires before PlumTree has formed eager-push links,
6994        // so peers that connected after the first announce won't see it.
6995        // A fresh announcement (new message ID) is required because PlumTree
6996        // deduplicates by message ID — replaying identical bytes would be silently
6997        // dropped by peers that already received the first announcement.
6998        if let (Some(ref runtime), Some(ref network)) = (&self.gossip_runtime, &self.network) {
6999            let ctx = HeartbeatContext {
7000                identity: std::sync::Arc::clone(&self.identity),
7001                runtime: std::sync::Arc::clone(runtime),
7002                network: std::sync::Arc::clone(network),
7003                interval_secs: self.heartbeat_interval_secs,
7004                cache: std::sync::Arc::clone(&self.identity_discovery_cache),
7005                machine_cache: std::sync::Arc::clone(&self.machine_discovery_cache),
7006                user_identity_consented: std::sync::Arc::clone(&self.user_identity_consented),
7007                allow_local_discovery_addrs: allow_local_discovery_addresses(network.config()),
7008                revocation_set: std::sync::Arc::clone(&self.revocation_set),
7009            };
7010            // Routed through spawn_tracked (issue #116) so a shutdown racing
7011            // bootstrap refuses to start it once the registry is closed; it is
7012            // a one-shot self-completing task so no token arm is needed.
7013            self.spawn_tracked(async move {
7014                tokio::time::sleep(std::time::Duration::from_secs(3)).await;
7015                if let Err(e) = ctx.announce().await {
7016                    tracing::warn!("Delayed identity re-announcement failed: {e}");
7017                } else {
7018                    tracing::info!(
7019                        "Delayed identity re-announcement sent (gossip mesh stabilized)"
7020                    );
7021                }
7022            });
7023        }
7024
7025        if let Err(e) = self.start_capability_advert_service().await {
7026            tracing::warn!("failed to start capability advert service: {e}");
7027        }
7028
7029        Ok(())
7030    }
7031
7032    /// Clone the shared capability store.
7033    #[must_use]
7034    pub fn capability_store(&self) -> std::sync::Arc<dm_capability::CapabilityStore> {
7035        std::sync::Arc::clone(&self.capability_store)
7036    }
7037
7038    /// Start or restart the mesh-wide DM capability advert service.
7039    ///
7040    /// The service publishes this agent's current DM capability and subscribes
7041    /// to peer adverts. It is idempotent: a fresh service replaces any previous
7042    /// one. Daemons call this independently of `join_network()` completion so
7043    /// a slow bootstrap pass cannot leave DM capability discovery disabled.
7044    ///
7045    /// # Errors
7046    ///
7047    /// Returns an error if no gossip runtime is configured yet or the advert
7048    /// service cannot subscribe/publish on its topic.
7049    pub async fn start_capability_advert_service(&self) -> error::Result<()> {
7050        let runtime = self.gossip_runtime.as_ref().ok_or_else(|| {
7051            error::IdentityError::Storage(std::io::Error::other(
7052                "cannot start capability advert service: no gossip runtime configured",
7053            ))
7054        })?;
7055
7056        let signing = std::sync::Arc::new(gossip::SigningContext::from_keypair(
7057            self.identity.agent_keypair(),
7058        ));
7059        let caps_rx = self.dm_capabilities_tx.subscribe();
7060        let service = dm_capability_service::CapabilityAdvertService::spawn_default(
7061            std::sync::Arc::clone(runtime.pubsub()),
7062            signing,
7063            self.identity.agent_id(),
7064            self.identity.machine_id(),
7065            caps_rx,
7066            std::sync::Arc::clone(&self.capability_store),
7067        )
7068        .await
7069        .map_err(|e| {
7070            error::IdentityError::Storage(std::io::Error::other(format!(
7071                "capability advert service spawn failed: {e}"
7072            )))
7073        })?;
7074
7075        let mut guard = self.capability_advert_service.lock().await;
7076        // Shutdown race (issue #116): if shutdown began while we were spawning,
7077        // abort the freshly-spawned service instead of storing it. Checked under
7078        // the same lock shutdown() takes the service from, so it can't leak.
7079        if self.shutdown_token.is_cancelled() {
7080            service.abort();
7081            return Ok(());
7082        }
7083        if let Some(prev) = guard.take() {
7084            prev.abort();
7085        }
7086        *guard = Some(service);
7087        tracing::info!("Capability advert service started");
7088        Ok(())
7089    }
7090
7091    /// Clone the shared DM in-flight ACK registry.
7092    #[must_use]
7093    pub fn dm_inflight_acks(&self) -> std::sync::Arc<dm::InFlightAcks> {
7094        std::sync::Arc::clone(&self.dm_inflight_acks)
7095    }
7096
7097    /// Clone the shared recent-delivery dedupe cache.
7098    #[must_use]
7099    pub fn recent_delivery_cache(&self) -> std::sync::Arc<dm::RecentDeliveryCache> {
7100        std::sync::Arc::clone(&self.recent_delivery_cache)
7101    }
7102
7103    /// Spawn the DM inbox service backed by the given KEM keypair.
7104    /// Idempotent — the prior service is aborted before spawning new.
7105    ///
7106    /// # Errors
7107    ///
7108    /// Returns an error if no gossip runtime is configured.
7109    pub async fn start_dm_inbox(
7110        &self,
7111        kem_keypair: std::sync::Arc<groups::kem_envelope::AgentKemKeypair>,
7112        config: dm_inbox::DmInboxConfig,
7113    ) -> error::Result<()> {
7114        let runtime = self.gossip_runtime.as_ref().ok_or_else(|| {
7115            error::IdentityError::Storage(std::io::Error::other(
7116                "cannot start DM inbox: no gossip runtime configured",
7117            ))
7118        })?;
7119        let signing = std::sync::Arc::new(gossip::SigningContext::from_keypair(
7120            self.identity.agent_keypair(),
7121        ));
7122        let service = dm_inbox::DmInboxService::spawn(
7123            std::sync::Arc::clone(runtime.pubsub()),
7124            signing,
7125            self.identity.agent_id(),
7126            self.identity.machine_id(),
7127            std::sync::Arc::clone(&kem_keypair),
7128            std::sync::Arc::clone(&self.direct_messaging),
7129            std::sync::Arc::clone(&self.contact_store),
7130            std::sync::Arc::clone(&self.dm_inflight_acks),
7131            std::sync::Arc::clone(&self.recent_delivery_cache),
7132            config,
7133            std::sync::Arc::clone(&self.revocation_set),
7134            std::sync::Arc::clone(&self.authenticated_machine_bindings),
7135        )
7136        .await
7137        .map_err(|e| {
7138            error::IdentityError::Storage(std::io::Error::other(format!(
7139                "DM inbox spawn failed: {e}"
7140            )))
7141        })?;
7142        let mut guard = self.dm_inbox_service.lock().await;
7143        // Shutdown race (issue #116): if shutdown began while we were spawning,
7144        // abort the freshly-spawned service instead of storing it (and skip the
7145        // capability upgrade below). Checked under the same lock stop_dm_inbox
7146        // takes the service from, so it can't leak.
7147        if self.shutdown_token.is_cancelled() {
7148            service.abort();
7149            return Ok(());
7150        }
7151        if let Some(prev) = guard.take() {
7152            prev.abort();
7153        }
7154        *guard = Some(service);
7155
7156        // Upgrade our advertised capabilities so peers stop falling back
7157        // to the raw-QUIC path. The capability advert service watches
7158        // this channel and republishes immediately on change.
7159        let upgraded =
7160            dm::DmCapabilities::pending().with_kem_public_key(kem_keypair.public_bytes.clone());
7161        // send_replace stores the value even when no receiver is subscribed
7162        // yet; a plain send() drops the upgrade if this runs before the
7163        // capability advert service subscribes, leaving peers cached on
7164        // gossip_inbox=false and forcing the raw-QUIC fallback that fails
7165        // across NAT (issue #101).
7166        self.dm_capabilities_tx.send_replace(upgraded);
7167        tracing::info!("DM inbox service started");
7168        Ok(())
7169    }
7170
7171    /// Stop the DM inbox service, if running. Idempotent.
7172    pub async fn stop_dm_inbox(&self) {
7173        let mut guard = self.dm_inbox_service.lock().await;
7174        if let Some(service) = guard.take() {
7175            service.abort();
7176        }
7177    }
7178
7179    /// Connect to cached peers in parallel, returning (succeeded, failed) peer lists.
7180    async fn connect_cached_peers_parallel_tracked(
7181        &self,
7182        network: &std::sync::Arc<network::NetworkNode>,
7183        peers: &[ant_quic::CachedPeer],
7184    ) -> (Vec<std::net::SocketAddr>, Vec<ant_quic::PeerId>) {
7185        use tokio::time::{timeout, Duration};
7186        const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
7187
7188        let handles: Vec<_> = peers
7189            .iter()
7190            .map(|peer| {
7191                let net = network.clone();
7192                let peer_id = peer.peer_id;
7193                tokio::spawn(async move {
7194                    tracing::debug!("Connecting to cached peer: {:?}", peer_id);
7195                    match timeout(CONNECT_TIMEOUT, net.connect_cached_peer(peer_id)).await {
7196                        Ok(Ok(addr)) => {
7197                            tracing::info!("Connected to cached peer {:?} at {}", peer_id, addr);
7198                            Ok(addr)
7199                        }
7200                        Ok(Err(e)) => {
7201                            tracing::warn!("Failed to connect to cached peer {:?}: {}", peer_id, e);
7202                            Err(peer_id)
7203                        }
7204                        Err(_) => {
7205                            tracing::warn!(
7206                                "Connection to cached peer {:?} timed out after {}s",
7207                                peer_id,
7208                                CONNECT_TIMEOUT.as_secs()
7209                            );
7210                            Err(peer_id)
7211                        }
7212                    }
7213                })
7214            })
7215            .collect();
7216
7217        let mut succeeded = Vec::new();
7218        let mut failed = Vec::new();
7219        for handle in handles {
7220            match handle.await {
7221                Ok(Ok(addr)) => succeeded.push(addr),
7222                Ok(Err(peer_id)) => failed.push(peer_id),
7223                Err(e) => tracing::error!("Connection task panicked: {}", e),
7224            }
7225        }
7226        (succeeded, failed)
7227    }
7228
7229    /// Connect to multiple peers in parallel, returning (succeeded, failed) address lists.
7230    async fn connect_peers_parallel_tracked(
7231        &self,
7232        network: &std::sync::Arc<network::NetworkNode>,
7233        addrs: &[std::net::SocketAddr],
7234    ) -> (Vec<std::net::SocketAddr>, Vec<std::net::SocketAddr>) {
7235        use tokio::time::{timeout, Duration};
7236
7237        // Per-connection timeout prevents hanging when connecting to
7238        // ourselves or to unreachable addresses.
7239        const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
7240
7241        let handles: Vec<_> = addrs
7242            .iter()
7243            .map(|addr| {
7244                let net = network.clone();
7245                let addr = *addr;
7246                tokio::spawn(async move {
7247                    tracing::debug!("Connecting to peer: {}", addr);
7248                    match timeout(CONNECT_TIMEOUT, net.connect_addr(addr)).await {
7249                        Ok(Ok(_)) => {
7250                            tracing::info!("Connected to peer: {}", addr);
7251                            Ok(addr)
7252                        }
7253                        Ok(Err(e)) => {
7254                            tracing::warn!("Failed to connect to {}: {}", addr, e);
7255                            Err(addr)
7256                        }
7257                        Err(_) => {
7258                            tracing::warn!(
7259                                "Connection to {} timed out after {}s",
7260                                addr,
7261                                CONNECT_TIMEOUT.as_secs()
7262                            );
7263                            Err(addr)
7264                        }
7265                    }
7266                })
7267            })
7268            .collect();
7269
7270        let mut succeeded = Vec::new();
7271        let mut failed = Vec::new();
7272        for handle in handles {
7273            match handle.await {
7274                Ok(Ok(addr)) => succeeded.push(addr),
7275                Ok(Err(addr)) => failed.push(addr),
7276                Err(e) => tracing::error!("Connection task panicked: {}", e),
7277            }
7278        }
7279        (succeeded, failed)
7280    }
7281
7282    /// Subscribe to messages on a given topic.
7283    ///
7284    /// Returns a [`gossip::Subscription`] that yields messages as they arrive
7285    /// through the gossip network.
7286    ///
7287    /// # Errors
7288    ///
7289    /// Returns an error if:
7290    /// - Gossip runtime is not initialized (configure agent with network first)
7291    pub async fn subscribe(&self, topic: &str) -> error::Result<Subscription> {
7292        let runtime = self.gossip_runtime.as_ref().ok_or_else(|| {
7293            error::IdentityError::Storage(std::io::Error::other(
7294                "gossip runtime not initialized - configure agent with network first",
7295            ))
7296        })?;
7297        Ok(runtime.pubsub().subscribe(topic.to_string()).await)
7298    }
7299
7300    /// Publish a message to a topic.
7301    ///
7302    /// The message will propagate through the gossip network via
7303    /// epidemic broadcast — every agent that receives it will
7304    /// relay it to its neighbours.
7305    ///
7306    /// # Errors
7307    ///
7308    /// Returns an error if:
7309    /// - Gossip runtime is not initialized (configure agent with network first)
7310    /// - Message encoding or broadcast fails
7311    pub async fn publish(&self, topic: &str, payload: Vec<u8>) -> error::Result<()> {
7312        let runtime = self.gossip_runtime.as_ref().ok_or_else(|| {
7313            error::IdentityError::Storage(std::io::Error::other(
7314                "gossip runtime not initialized - configure agent with network first",
7315            ))
7316        })?;
7317        runtime
7318            .pubsub()
7319            .publish(topic.to_string(), bytes::Bytes::from(payload))
7320            .await
7321            .map_err(|e| {
7322                error::IdentityError::Storage(std::io::Error::other(format!(
7323                    "publish failed: {}",
7324                    e
7325                )))
7326            })
7327    }
7328
7329    /// Get connected peer IDs.
7330    ///
7331    /// Returns the list of peers currently connected via the gossip network.
7332    ///
7333    /// # Errors
7334    ///
7335    /// Returns an error if the network is not initialized.
7336    pub async fn peers(&self) -> error::Result<Vec<saorsa_gossip_types::PeerId>> {
7337        let network = self.network.as_ref().ok_or_else(|| {
7338            error::IdentityError::Storage(std::io::Error::other(
7339                "network not initialized - configure agent with network first",
7340            ))
7341        })?;
7342        let ant_peers = network.connected_peers().await;
7343        Ok(ant_peers
7344            .into_iter()
7345            .map(|p| saorsa_gossip_types::PeerId::new(p.0))
7346            .collect())
7347    }
7348
7349    /// Get online agents.
7350    ///
7351    /// Returns agent IDs discovered from signed identity announcements.
7352    ///
7353    /// # Errors
7354    ///
7355    /// Returns an error if the gossip runtime is not initialized.
7356    pub async fn presence(&self) -> error::Result<Vec<identity::AgentId>> {
7357        self.start_identity_listener().await?;
7358        let cutoff = Self::unix_timestamp_secs().saturating_sub(self.identity_ttl_secs);
7359        let mut agents: Vec<_> = self
7360            .identity_discovery_cache
7361            .read()
7362            .await
7363            .values()
7364            .filter(|a| discovery_record_is_live(a.announced_at, a.last_seen, cutoff))
7365            .map(|a| a.agent_id)
7366            .collect();
7367        agents.sort_by_key(|a| a.0);
7368        Ok(agents)
7369    }
7370
7371    /// Subscribe to presence events (agent online/offline notifications).
7372    ///
7373    /// Returns a [`tokio::sync::broadcast::Receiver<PresenceEvent>`] that yields
7374    /// [`presence::PresenceEvent`] values as agents come online or go offline.
7375    ///
7376    /// The diff-based event emission loop is started lazily on the first call to this
7377    /// method (or when [`join_network`](Agent::join_network) is called). Subsequent
7378    /// calls return independent receivers on the same broadcast channel.
7379    ///
7380    /// # Errors
7381    ///
7382    /// Returns [`error::NetworkError::NodeError`] if this agent was built
7383    /// without a network configuration (i.e. no `with_network_config` on the builder).
7384    pub async fn subscribe_presence(
7385        &self,
7386    ) -> error::NetworkResult<tokio::sync::broadcast::Receiver<presence::PresenceEvent>> {
7387        let pw = self.presence.as_ref().ok_or_else(|| {
7388            error::NetworkError::NodeError("presence system not initialized".to_string())
7389        })?;
7390        // Ensure the event loop is running.
7391        pw.start_event_loop(std::sync::Arc::clone(&self.identity_discovery_cache))
7392            .await;
7393        Ok(pw.subscribe_events())
7394    }
7395
7396    /// Look up a single agent in the local discovery cache.
7397    ///
7398    /// Returns `None` if the agent is not currently cached.  No network I/O is
7399    /// performed — use [`discover_agent_by_id`](Agent::discover_agent_by_id) for
7400    /// an active lookup that queries the network.
7401    pub async fn cached_agent(&self, id: &identity::AgentId) -> Option<DiscoveredAgent> {
7402        self.identity_discovery_cache.read().await.get(id).cloned()
7403    }
7404
7405    /// Check whether a claimed `AgentId` is verified as belonging to the
7406    /// given `MachineId` in the identity discovery cache.
7407    ///
7408    /// Returns `true` if the cache contains a signed identity announcement
7409    /// binding this agent to this machine.  Returns `false` if:
7410    /// - the agent is unknown or bound to a different machine, OR
7411    /// - the agent or its bound machine has been revoked, OR
7412    /// - the cached agent certificate has expired (past `not_after` + 300 s
7413    ///   clock skew).
7414    ///
7415    /// Revocation is fail-closed: a revoked peer is always refused, even if
7416    /// a certificate mismatch or race condition has cleared the cache entry.
7417    /// Absent expiry (`cert_not_after == None`) is fail-open: treated as
7418    /// "never expires" to preserve compatibility with pre-#130 peers.
7419    pub async fn is_agent_machine_verified(
7420        &self,
7421        agent_id: &identity::AgentId,
7422        machine_id: &identity::MachineId,
7423    ) -> bool {
7424        // Fail-closed on revocation: check before touching the discovery cache
7425        // so a racing cache-eviction cannot create a window where a revoked
7426        // peer appears verified.
7427        {
7428            let revoked = self.revocation_set.read().await;
7429            if revoked.is_agent_revoked(agent_id) || revoked.is_machine_revoked(machine_id) {
7430                return false;
7431            }
7432        }
7433
7434        let cache = self.identity_discovery_cache.read().await;
7435        let Some(entry) = cache.get(agent_id) else {
7436            return false;
7437        };
7438        if entry.machine_id != *machine_id {
7439            return false;
7440        }
7441        // Fail-open on absent expiry (pre-#130 peers have no cert / no not_after).
7442        if identity::is_expired(entry.cert_not_after, Self::unix_timestamp_secs()) {
7443            return false;
7444        }
7445        true
7446    }
7447
7448    /// Return the shared revocation set.
7449    ///
7450    /// Callers that need to publish a new revocation record should call
7451    /// [`revoke`](Agent::revoke) instead; this accessor is for gate checks
7452    /// and diagnostics.
7453    pub fn revocation_set(&self) -> std::sync::Arc<tokio::sync::RwLock<revocation::RevocationSet>> {
7454        std::sync::Arc::clone(&self.revocation_set)
7455    }
7456
7457    /// Return the shared identity discovery cache (`pub(crate)` — used by the
7458    /// forwarder to look up cached agent public keys for `ForwardV2`
7459    /// attestation verification, #204).
7460    pub(crate) fn identity_discovery_cache(
7461        &self,
7462    ) -> std::sync::Arc<
7463        tokio::sync::RwLock<std::collections::HashMap<identity::AgentId, DiscoveredAgent>>,
7464    > {
7465        std::sync::Arc::clone(&self.identity_discovery_cache)
7466    }
7467
7468    /// Return the shared contact store (`pub(crate)` — used by the forwarder
7469    /// to evaluate trust for ForwardV2 attestation, #204 must-fix 3).
7470    pub(crate) fn contact_store(
7471        &self,
7472    ) -> std::sync::Arc<tokio::sync::RwLock<contacts::ContactStore>> {
7473        std::sync::Arc::clone(&self.contact_store)
7474    }
7475
7476    /// Return a snapshot of all known revocation records.
7477    ///
7478    /// This is a read-only snapshot; the in-memory set grows only (no un-revocation).
7479    pub async fn revocation_records(&self) -> Vec<revocation::RevocationRecord> {
7480        self.revocation_set.read().await.all_records()
7481    }
7482
7483    /// Sign and publish a revocation for the given subject, then persist it
7484    /// locally and apply it immediately.
7485    ///
7486    /// The issuer keypair must satisfy one of the two authority rules in
7487    /// [`revocation::RevocationRecord::verify_authority`]:
7488    /// - **Self-revocation**: the issuer keypair's AgentId equals the subject's AgentId.
7489    /// - **Issuer-revocation**: the issuer is the user who signed the subject
7490    ///   agent's certificate (the certificate must be passed as `subject_cert`).
7491    ///
7492    /// On success, the record is inserted into the local revocation set,
7493    /// persisted to `revocations.bin`, published on [`REVOCATION_TOPIC`], and
7494    /// the subject is evicted from all discovery caches.
7495    ///
7496    /// # Errors
7497    ///
7498    /// Returns an error if signing fails, the authority check fails, or the
7499    /// gossip publish fails.
7500    pub async fn revoke(
7501        &self,
7502        issuer_keypair: &identity::AgentKeypair,
7503        subject: revocation::RevokedSubject,
7504        reason: Option<String>,
7505        subject_cert: Option<&identity::AgentCertificate>,
7506    ) -> error::Result<revocation::RevocationRecord> {
7507        let now = Self::unix_timestamp_secs();
7508        let record = revocation::RevocationRecord::sign(
7509            subject,
7510            issuer_keypair.public_key(),
7511            issuer_keypair.secret_key(),
7512            now,
7513            reason,
7514        )?;
7515        self.apply_and_publish_revocation(record.clone(), subject_cert)
7516            .await?;
7517        Ok(record)
7518    }
7519
7520    /// Apply a revocation record to the local set, persist, publish, and evict.
7521    ///
7522    /// Used both by [`revoke`](Agent::revoke) (self-issued) and the gossip
7523    /// subscription loop (received from a peer).
7524    async fn apply_and_publish_revocation(
7525        &self,
7526        record: revocation::RevocationRecord,
7527        subject_cert: Option<&identity::AgentCertificate>,
7528    ) -> error::Result<()> {
7529        // 1. Verify and insert.
7530        {
7531            let mut set = self.revocation_set.write().await;
7532            if let Err(e) = set.verify_and_insert(record.clone(), subject_cert) {
7533                return Err(error::IdentityError::CertificateVerification(format!(
7534                    "revocation rejected: {e}"
7535                )));
7536            }
7537        }
7538
7539        // 2. Persist.
7540        storage::save_revocation_set(
7541            &*self.revocation_set.read().await,
7542            self.identity_dir.as_deref(),
7543        )
7544        .await?;
7545
7546        // 3. Evict from caches.
7547        self.evict_revoked_subject(&record.subject).await;
7548
7549        // 4. Publish on gossip (best-effort — local enforcement happens regardless).
7550        if let Some(rt) = &self.gossip_runtime {
7551            let records = self.revocation_set.read().await.all_records();
7552            match bincode::serialize(&records) {
7553                Ok(bytes) if !bytes.is_empty() => {
7554                    let _ = rt
7555                        .pubsub()
7556                        .publish(REVOCATION_TOPIC.to_string(), bytes::Bytes::from(bytes))
7557                        .await;
7558                }
7559                _ => {}
7560            }
7561        }
7562
7563        Ok(())
7564    }
7565
7566    /// Evict a revoked subject from all discovery caches.
7567    async fn evict_revoked_subject(&self, subject: &revocation::RevokedSubject) {
7568        // NOTE: this evicts the identity/machine discovery caches and the
7569        // contact store, but does NOT purge the ant-quic bootstrap cache. A
7570        // revoked peer's address can therefore linger there; this is a residual
7571        // (not a hole) because EP1 re-rejects the peer's announcement on every
7572        // ingest, so it can never re-enter the verified path. Bootstrap-cache
7573        // purge on eviction is tracked as a follow-up.
7574        match subject {
7575            revocation::RevokedSubject::Agent(agent_id) => {
7576                // Remove from identity cache, which also starves the verified annotation.
7577                let mut cache = self.identity_discovery_cache.write().await;
7578                if let Some(entry) = cache.remove(agent_id) {
7579                    // Also evict the linked machine so it cannot be dialed.
7580                    drop(cache);
7581                    let mut mcache = self.machine_discovery_cache.write().await;
7582                    mcache.remove(&entry.machine_id);
7583                } else {
7584                    drop(cache);
7585                }
7586                // Best-effort: mark as Blocked in the contact store so that
7587                // trust evaluation also refuses the agent on any late-arriving path.
7588                {
7589                    let mut cs = self.contact_store.write().await;
7590                    cs.set_trust(agent_id, contacts::TrustLevel::Blocked);
7591                }
7592                tracing::info!(
7593                    agent = %hex::encode(agent_id.as_bytes()),
7594                    "evicted revoked agent from discovery cache"
7595                );
7596            }
7597            revocation::RevokedSubject::Machine(machine_id) => {
7598                let mut mcache = self.machine_discovery_cache.write().await;
7599                mcache.remove(machine_id);
7600                drop(mcache);
7601                // Also evict any agents linked to this machine.
7602                let mut cache = self.identity_discovery_cache.write().await;
7603                cache.retain(|_, agent| agent.machine_id != *machine_id);
7604                tracing::info!(
7605                    machine = %hex::encode(machine_id.as_bytes()),
7606                    "evicted revoked machine from discovery cache"
7607                );
7608            }
7609        }
7610    }
7611
7612    /// Discover agents via Friend-of-a-Friend (FOAF) random walk.
7613    ///
7614    /// Initiates a FOAF query on the global presence topic with the given `ttl`
7615    /// (maximum hop count) and `timeout_ms` (response collection window).
7616    ///
7617    /// Returned entries are resolved against the local identity discovery cache
7618    /// so that known agents are returned with full identity data.  Unknown peers
7619    /// are included with a minimal entry (addresses only) that will be enriched
7620    /// once their identity heartbeat arrives.
7621    ///
7622    /// # Arguments
7623    ///
7624    /// * `ttl` — Maximum hop count for the random walk (`1`–`5`). Typical: `2`.
7625    /// * `timeout_ms` — Query timeout in milliseconds. Typical: `5000`.
7626    ///
7627    /// # Errors
7628    ///
7629    /// Returns [`error::NetworkError::NodeError`] if no network config was provided.
7630    pub async fn discover_agents_foaf(
7631        &self,
7632        ttl: u8,
7633        timeout_ms: u64,
7634    ) -> error::NetworkResult<Vec<DiscoveredAgent>> {
7635        let pw = self.presence.as_ref().ok_or_else(|| {
7636            error::NetworkError::NodeError("presence system not initialized".to_string())
7637        })?;
7638
7639        let topic = presence::global_presence_topic();
7640        let raw_results: Vec<(
7641            saorsa_gossip_types::PeerId,
7642            saorsa_gossip_types::PresenceRecord,
7643        )> = pw
7644            .manager()
7645            .initiate_foaf_query(topic, ttl, timeout_ms)
7646            .await
7647            .map_err(|e| error::NetworkError::NodeError(e.to_string()))?;
7648
7649        let cache = self.identity_discovery_cache.read().await;
7650
7651        // Convert and deduplicate by agent_id.
7652        let mut seen: std::collections::HashSet<identity::AgentId> =
7653            std::collections::HashSet::new();
7654        let mut agents: Vec<DiscoveredAgent> = Vec::with_capacity(raw_results.len());
7655
7656        for (peer_id, record) in &raw_results {
7657            if let Some(agent) =
7658                presence::presence_record_to_discovered_agent(*peer_id, record, &cache)
7659            {
7660                if seen.insert(agent.agent_id) {
7661                    agents.push(agent);
7662                }
7663            }
7664        }
7665
7666        Ok(agents)
7667    }
7668
7669    /// Discover a specific agent by their [`identity::AgentId`] via FOAF random walk.
7670    ///
7671    /// Fast-path: checks the local identity discovery cache first and returns
7672    /// immediately if the agent is already known.
7673    ///
7674    /// Slow-path: performs a FOAF random walk (see [`discover_agents_foaf`](Agent::discover_agents_foaf))
7675    /// and searches the results for a matching `AgentId`.
7676    ///
7677    /// Returns `None` if the agent is not found within the given `ttl` and `timeout_ms`.
7678    ///
7679    /// # Errors
7680    ///
7681    /// Returns [`error::NetworkError::NodeCreation`] if no network config was provided.
7682    pub async fn discover_agent_by_id(
7683        &self,
7684        target_id: identity::AgentId,
7685        ttl: u8,
7686        timeout_ms: u64,
7687    ) -> error::NetworkResult<Option<DiscoveredAgent>> {
7688        // Fast path: already in local cache.
7689        {
7690            let cache = self.identity_discovery_cache.read().await;
7691            if let Some(agent) = cache.get(&target_id) {
7692                return Ok(Some(agent.clone()));
7693            }
7694        }
7695
7696        // Slow path: FOAF random walk.
7697        let agents = self.discover_agents_foaf(ttl, timeout_ms).await?;
7698        Ok(agents.into_iter().find(|a| a.agent_id == target_id))
7699    }
7700
7701    /// Find an agent by ID, returning its known addresses.
7702    ///
7703    /// Performs a three-stage lookup:
7704    /// 1. **Cache hit** — return addresses immediately if the agent has already
7705    ///    been discovered.
7706    /// 2. **Shard subscription** — subscribe to the agent's identity shard topic
7707    ///    and wait up to 5 seconds for a heartbeat announcement.
7708    /// 3. **Rendezvous** — subscribe to the agent's rendezvous shard topic and
7709    ///    wait up to 5 seconds for a `ProviderSummary` advertisement.  This
7710    ///    works even when the two agents are on different gossip overlay clusters.
7711    ///
7712    /// Returns `None` if the agent is not found within the combined deadline.
7713    ///
7714    /// # Errors
7715    ///
7716    /// Returns an error if the gossip runtime is not initialized.
7717    pub async fn find_agent(
7718        &self,
7719        agent_id: identity::AgentId,
7720    ) -> error::Result<Option<Vec<std::net::SocketAddr>>> {
7721        self.start_identity_listener().await?;
7722
7723        // Stage 1: cache hit.
7724        if let Some(addrs) = self
7725            .identity_discovery_cache
7726            .read()
7727            .await
7728            .get(&agent_id)
7729            .map(|e| e.addresses.clone())
7730        {
7731            return Ok(Some(addrs));
7732        }
7733
7734        // Stage 2: subscribe to the agent's identity shard topic and wait up to 5 s.
7735        let runtime = match self.gossip_runtime.as_ref() {
7736            Some(r) => r,
7737            None => return Ok(None),
7738        };
7739        let shard_topic = shard_topic_for_agent(&agent_id);
7740        let mut sub = runtime.pubsub().subscribe(shard_topic).await;
7741        let cache = std::sync::Arc::clone(&self.identity_discovery_cache);
7742        let machine_cache = std::sync::Arc::clone(&self.machine_discovery_cache);
7743        let allow_local_scope = self
7744            .network
7745            .as_ref()
7746            .is_some_and(|network| allow_local_discovery_addresses(network.config()));
7747        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
7748
7749        loop {
7750            if tokio::time::Instant::now() >= deadline {
7751                break;
7752            }
7753            let timeout = tokio::time::sleep_until(deadline);
7754            tokio::select! {
7755                Some(msg) = sub.recv() => {
7756                    if let Ok(ann) = deserialize_identity_announcement(&msg.payload) {
7757                        if ann.verify().is_ok() && ann.agent_id == agent_id {
7758                            let now = std::time::SystemTime::now()
7759                                .duration_since(std::time::UNIX_EPOCH)
7760                                .map_or(0, |d| d.as_secs());
7761                            let filtered = filter_discovery_announcement_addrs(
7762                                ann.addresses.iter().copied(),
7763                                allow_local_scope,
7764                            );
7765                            let addrs = filtered.clone();
7766                            let discovered_agent = DiscoveredAgent {
7767                                agent_id: ann.agent_id,
7768                                machine_id: ann.machine_id,
7769                                user_id: ann.user_id,
7770                                addresses: filtered,
7771                                announced_at: ann.announced_at,
7772                                last_seen: now,
7773                                machine_public_key: ann.machine_public_key.clone(),
7774                                nat_type: ann.nat_type.clone(),
7775                                can_receive_direct: ann.can_receive_direct,
7776                                is_relay: ann.is_relay,
7777                                is_coordinator: ann.is_coordinator,
7778                                reachable_via: ann.reachable_via.clone(),
7779                                relay_candidates: ann.relay_candidates.clone(),
7780                                cert_not_after: ann
7781                                    .agent_certificate
7782                                    .as_ref()
7783                                    .and_then(|c| c.not_after()),
7784                                agent_certificate: ann.agent_certificate.clone(),
7785                                agent_public_key: ann.agent_public_key.clone(),
7786                            };
7787                            upsert_discovered_machine_from_agent(&machine_cache, &discovered_agent)
7788                                .await;
7789                            upsert_discovered_agent(&cache, discovered_agent).await;
7790                            return Ok(Some(addrs));
7791                        }
7792                    }
7793                }
7794                _ = timeout => break,
7795            }
7796        }
7797
7798        // Stage 3: rendezvous shard subscription — wait up to 5 s.
7799        // Cache the result so subsequent connect_to_agent / send_direct can find it.
7800        if let Some(addrs) = self.find_agent_rendezvous(agent_id, 5).await? {
7801            let now = std::time::SystemTime::now()
7802                .duration_since(std::time::UNIX_EPOCH)
7803                .map_or(0, |d| d.as_secs());
7804            upsert_discovered_agent(
7805                &cache,
7806                DiscoveredAgent {
7807                    agent_id,
7808                    machine_id: identity::MachineId([0u8; 32]),
7809                    user_id: None,
7810                    addresses: addrs.clone(),
7811                    announced_at: now,
7812                    last_seen: now,
7813                    machine_public_key: Vec::new(),
7814                    nat_type: None,
7815                    can_receive_direct: None,
7816                    is_relay: None,
7817                    is_coordinator: None,
7818                    reachable_via: Vec::new(),
7819                    relay_candidates: Vec::new(),
7820                    cert_not_after: None,
7821                    agent_certificate: None,
7822                    agent_public_key: Vec::new(),
7823                },
7824            )
7825            .await;
7826            return Ok(Some(addrs));
7827        }
7828
7829        Ok(None)
7830    }
7831
7832    /// Find a machine by ID and return its current endpoint record.
7833    ///
7834    /// Performs a cache lookup first, then subscribes to the machine's shard
7835    /// topic and waits up to `timeout_secs` for a signed machine announcement.
7836    ///
7837    /// # Errors
7838    ///
7839    /// Returns an error if the gossip runtime is not initialized.
7840    pub async fn find_machine(
7841        &self,
7842        machine_id: identity::MachineId,
7843        timeout_secs: u64,
7844    ) -> error::Result<Option<DiscoveredMachine>> {
7845        self.start_identity_listener().await?;
7846
7847        if let Some(machine) = self
7848            .machine_discovery_cache
7849            .read()
7850            .await
7851            .get(&machine_id)
7852            .cloned()
7853        {
7854            return Ok(Some(machine));
7855        }
7856
7857        let runtime = match self.gossip_runtime.as_ref() {
7858            Some(r) => r,
7859            None => return Ok(None),
7860        };
7861        let shard_topic = shard_topic_for_machine(&machine_id);
7862        let mut sub = runtime.pubsub().subscribe(shard_topic).await;
7863        let machine_cache = std::sync::Arc::clone(&self.machine_discovery_cache);
7864        let allow_local_scope = self
7865            .network
7866            .as_ref()
7867            .is_some_and(|network| allow_local_discovery_addresses(network.config()));
7868        let deadline =
7869            tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs.clamp(1, 60));
7870
7871        loop {
7872            if tokio::time::Instant::now() >= deadline {
7873                break;
7874            }
7875            let timeout = tokio::time::sleep_until(deadline);
7876            tokio::select! {
7877                Some(msg) = sub.recv() => {
7878                    if let Ok(ann) = deserialize_machine_announcement(&msg.payload) {
7879                        if ann.verify().is_ok() && ann.machine_id == machine_id {
7880                            let now = std::time::SystemTime::now()
7881                                .duration_since(std::time::UNIX_EPOCH)
7882                                .map_or(0, |d| d.as_secs());
7883                            let filtered = filter_discovery_announcement_addrs(
7884                                ann.addresses.iter().copied(),
7885                                allow_local_scope,
7886                            );
7887                            let discovered = DiscoveredMachine::from_machine_announcement(
7888                                &ann,
7889                                filtered,
7890                                now,
7891                            );
7892                            upsert_discovered_machine(&machine_cache, discovered).await;
7893                            return Ok(machine_cache.read().await.get(&machine_id).cloned());
7894                        }
7895                    }
7896                }
7897                _ = timeout => break,
7898            }
7899        }
7900
7901        Ok(None)
7902    }
7903
7904    /// Find all discovered agents claiming ownership by the given [`identity::UserId`].
7905    ///
7906    /// Only returns agents that announced with `include_user_identity: true`
7907    /// (i.e., agents whose [`DiscoveredAgent::user_id`] is `Some`).
7908    ///
7909    /// # Arguments
7910    ///
7911    /// * `user_id` - The user identity to search for
7912    ///
7913    /// # Errors
7914    ///
7915    /// Returns an error if the gossip runtime is not initialized.
7916    pub async fn find_agents_by_user(
7917        &self,
7918        user_id: identity::UserId,
7919    ) -> error::Result<Vec<DiscoveredAgent>> {
7920        self.start_identity_listener().await?;
7921        let cutoff = Self::unix_timestamp_secs().saturating_sub(self.identity_ttl_secs);
7922        Ok(self
7923            .identity_discovery_cache
7924            .read()
7925            .await
7926            .values()
7927            .filter(|a| {
7928                discovery_record_is_live(a.announced_at, a.last_seen, cutoff)
7929                    && a.user_id == Some(user_id)
7930            })
7931            .cloned()
7932            .collect())
7933    }
7934
7935    /// Return the local socket address this agent's network node is bound to, if any.
7936    ///
7937    /// Returns `None` if no network has been configured or if the bind address is
7938    /// not yet known.
7939    ///
7940    /// **Note:** If the node was configured with port 0, this returns port 0.
7941    /// Use [`bound_addr()`](Self::bound_addr) to get the OS-assigned port.
7942    #[must_use]
7943    pub fn local_addr(&self) -> Option<std::net::SocketAddr> {
7944        self.network.as_ref().and_then(|n| n.local_addr())
7945    }
7946
7947    /// Return the actual bound address from the QUIC endpoint.
7948    ///
7949    /// Unlike [`local_addr()`](Self::local_addr) which returns the configured value
7950    /// (possibly port 0), this queries the running endpoint for the real OS-assigned
7951    /// address. Returns `None` if no network has been configured.
7952    pub async fn bound_addr(&self) -> Option<std::net::SocketAddr> {
7953        if let Some(ref network) = self.network {
7954            let addr = network.bound_addr().await;
7955            // On dual-stack systems, bound_addr may return [::]:port even when
7956            // we bound to 127.0.0.1. Normalize to IPv4 if the original config
7957            // was IPv4.
7958            match (addr, self.local_addr()) {
7959                (Some(bound), Some(config)) if config.is_ipv4() && bound.is_ipv6() => {
7960                    Some(std::net::SocketAddr::new(config.ip(), bound.port()))
7961                }
7962                (Some(bound), _) => Some(bound),
7963                _ => None,
7964            }
7965        } else {
7966            None
7967        }
7968    }
7969
7970    /// Build a signed [`IdentityAnnouncement`] for this agent.
7971    ///
7972    /// Delegates to the internal `build_identity_announcement` method.
7973    ///
7974    /// # Errors
7975    ///
7976    /// Returns an error if key signing fails or human consent is required but not given.
7977    pub fn build_announcement(
7978        &self,
7979        include_user: bool,
7980        consent: bool,
7981    ) -> error::Result<IdentityAnnouncement> {
7982        self.build_identity_announcement(include_user, consent)
7983    }
7984
7985    /// Build a signed [`MachineAnnouncement`] for this daemon's transport
7986    /// endpoint.
7987    ///
7988    /// This sync helper uses currently known local interface addresses and
7989    /// leaves async NAT/reachability fields as `None`; live network announces
7990    /// populate those fields from `NetworkNode::node_status()`.
7991    ///
7992    /// # Errors
7993    ///
7994    /// Returns an error if key signing fails.
7995    pub fn build_machine_announcement(&self) -> error::Result<MachineAnnouncement> {
7996        build_machine_announcement_for_identity(
7997            &self.identity,
7998            self.announcement_addresses(),
7999            Self::unix_timestamp_secs(),
8000            None,
8001            Vec::new(),
8002            Vec::new(),
8003            self.network
8004                .as_ref()
8005                .is_some_and(|network| allow_local_discovery_addresses(network.config())),
8006        )
8007    }
8008
8009    /// Start the background identity heartbeat task.
8010    ///
8011    /// Idempotent — if the heartbeat is already running, returns `Ok(())` immediately.
8012    /// The heartbeat re-announces this agent's identity at `heartbeat_interval_secs`
8013    /// intervals so that late-joining peers can discover it without waiting for a
8014    /// Start the network event reconciliation listener.
8015    ///
8016    /// This bridges transport-level peer connect/disconnect events into the
8017    /// agent-level direct messaging registry so inbound accepted connections are
8018    /// usable for reverse direct sends before the first inbound direct payload.
8019    fn start_network_event_listener(&self) {
8020        if self
8021            .network_event_listener_started
8022            .swap(true, std::sync::atomic::Ordering::AcqRel)
8023        {
8024            return;
8025        }
8026
8027        let Some(network) = self.network.as_ref().map(std::sync::Arc::clone) else {
8028            return;
8029        };
8030        let cache = std::sync::Arc::clone(&self.identity_discovery_cache);
8031        let dm = std::sync::Arc::clone(&self.direct_messaging);
8032
8033        let lifecycle_network = std::sync::Arc::clone(&network);
8034        let lifecycle_dm = std::sync::Arc::clone(&dm);
8035        let event_token = self.shutdown_token.clone();
8036        let lifecycle_token = self.shutdown_token.clone();
8037
8038        self.spawn_tracked(async move {
8039            let mut rx = network.subscribe();
8040            tracing::info!("Network event reconciliation listener started");
8041
8042            loop {
8043                let event = tokio::select! {
8044                    // event_sender is a NetworkNode struct field that outlives
8045                    // network.shutdown(), so this loop never ended on shutdown
8046                    // before; the token is what stops it now.
8047                    _ = event_token.cancelled() => break,
8048                    recv = rx.recv() => match recv {
8049                        Ok(event) => event,
8050                        Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
8051                            tracing::warn!("Network event listener lagged by {skipped} events");
8052                            continue;
8053                        }
8054                        Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
8055                    },
8056                };
8057
8058                match event {
8059                    network::NetworkEvent::PeerConnected { peer_id, .. } => {
8060                        let machine_id = identity::MachineId(peer_id);
8061                        let cached_agent_id = {
8062                            let cache = cache.read().await;
8063                            cache
8064                                .values()
8065                                .find(|entry| entry.machine_id == machine_id)
8066                                .map(|entry| entry.agent_id)
8067                        };
8068                        let agent_id = match cached_agent_id {
8069                            Some(agent_id) => Some(agent_id),
8070                            None => dm.lookup_agent(&machine_id).await,
8071                        };
8072                        if let Some(agent_id) = agent_id {
8073                            dm.mark_connected(agent_id, machine_id).await;
8074                        }
8075                    }
8076                    network::NetworkEvent::PeerDisconnected { peer_id } => {
8077                        let machine_id = identity::MachineId(peer_id);
8078                        let cached_agent_id = {
8079                            let cache = cache.read().await;
8080                            cache
8081                                .values()
8082                                .find(|entry| entry.machine_id == machine_id)
8083                                .map(|entry| entry.agent_id)
8084                        };
8085                        let agent_id = match cached_agent_id {
8086                            Some(agent_id) => Some(agent_id),
8087                            None => dm.lookup_agent(&machine_id).await,
8088                        };
8089                        if let Some(agent_id) = agent_id {
8090                            dm.mark_disconnected(&agent_id).await;
8091                        }
8092                    }
8093                    _ => {}
8094                }
8095            }
8096        });
8097
8098        self.spawn_tracked(async move {
8099            let Some(mut rx) = lifecycle_network.subscribe_all_peer_events().await else {
8100                tracing::debug!(
8101                    "Peer lifecycle listener unavailable: network node not initialised"
8102                );
8103                return;
8104            };
8105            tracing::info!("Peer lifecycle watcher started for direct messaging");
8106            loop {
8107                let (peer_id, event) = tokio::select! {
8108                    _ = lifecycle_token.cancelled() => break,
8109                    recv = rx.recv() => match recv {
8110                        Ok(event) => event,
8111                        Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
8112                            tracing::warn!("Peer lifecycle watcher lagged by {skipped} events");
8113                            continue;
8114                        }
8115                        Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
8116                    },
8117                };
8118                let machine_id = identity::MachineId(peer_id.0);
8119                match event {
8120                    ant_quic::PeerLifecycleEvent::Established { generation } => {
8121                        lifecycle_dm.record_lifecycle_established(machine_id, Some(generation));
8122                    }
8123                    ant_quic::PeerLifecycleEvent::Replaced { new_generation, .. } => {
8124                        lifecycle_dm.record_lifecycle_replaced(machine_id, new_generation);
8125                    }
8126                    ant_quic::PeerLifecycleEvent::Closing { generation, reason } => {
8127                        lifecycle_dm.record_lifecycle_blocked(
8128                            machine_id,
8129                            Some(generation),
8130                            format!("closing: {reason}"),
8131                        );
8132                    }
8133                    ant_quic::PeerLifecycleEvent::Closed { generation, reason } => {
8134                        lifecycle_dm.record_lifecycle_blocked(
8135                            machine_id,
8136                            Some(generation),
8137                            format!("closed: {reason}"),
8138                        );
8139                    }
8140                    ant_quic::PeerLifecycleEvent::ReaderExited { generation } => {
8141                        tracing::debug!(
8142                            machine_prefix = %network::hex_prefix(machine_id.as_bytes(), 4),
8143                            generation,
8144                            "peer reader exited; waiting for Closed/Established before blocking direct DM"
8145                        );
8146                    }
8147                }
8148            }
8149        });
8150    }
8151
8152    /// Start the direct message listener background task.
8153    ///
8154    /// This task reads raw direct messages from the network layer and
8155    /// dispatches them to `DirectMessaging::handle_incoming()`, which
8156    /// fans out to all `subscribe_direct()` receiver queues.
8157    ///
8158    /// Called automatically by [`Agent::join_network`].
8159    fn start_direct_listener(&self) {
8160        if self
8161            .direct_listener_started
8162            .swap(true, std::sync::atomic::Ordering::AcqRel)
8163        {
8164            return;
8165        }
8166
8167        let Some(network) = self.network.as_ref().map(std::sync::Arc::clone) else {
8168            return;
8169        };
8170        let dm = std::sync::Arc::clone(&self.direct_messaging);
8171        let discovery_cache = std::sync::Arc::clone(&self.identity_discovery_cache);
8172        let contact_store = std::sync::Arc::clone(&self.contact_store);
8173        let revocation_set = std::sync::Arc::clone(&self.revocation_set);
8174        let token = self.shutdown_token.clone();
8175
8176        self.spawn_tracked(async move {
8177            tracing::info!(target: "x0x::direct", stage = "listener", "direct message listener started");
8178            loop {
8179                // direct_tx is a NetworkNode struct field that outlives
8180                // network.shutdown(), so recv_direct() does not return None on
8181                // shutdown; the token is what stops this loop now.
8182                let recv = tokio::select! {
8183                    _ = token.cancelled() => break,
8184                    r = network.recv_direct() => r,
8185                };
8186                let Some((ant_peer_id, payload)) = recv else {
8187                    tracing::warn!(
8188                        target: "x0x::direct",
8189                        stage = "listener",
8190                        "network.recv_direct channel closed — listener exiting"
8191                    );
8192                    break;
8193                };
8194
8195                let raw_bytes = payload.len();
8196
8197                // Parse: first 32 bytes = sender AgentId, rest = payload
8198                if payload.len() < 32 {
8199                    tracing::warn!(
8200                        target: "x0x::direct",
8201                        stage = "listener",
8202                        machine_prefix = %crate::logging::LogTransportPeerId::from(&ant_peer_id),
8203                        raw_bytes,
8204                        outcome = "drop_too_short",
8205                        "direct message too short to contain sender id"
8206                    );
8207                    continue;
8208                }
8209
8210                let mut sender_bytes = [0u8; 32];
8211                sender_bytes.copy_from_slice(&payload[..32]);
8212                let sender = identity::AgentId(sender_bytes);
8213                let machine_id = identity::MachineId(ant_peer_id.0);
8214                let data = payload[32..].to_vec();
8215                let payload_bytes = data.len();
8216                let digest = direct::dm_payload_digest_hex(&data);
8217
8218                tracing::debug!(
8219                    target: "dm.trace",
8220                    stage = "inbound_envelope_received",
8221                    sender = %hex::encode(sender.as_bytes()),
8222                    machine_id = %hex::encode(machine_id.as_bytes()),
8223                    path = "raw_quic",
8224                    bytes = payload_bytes,
8225                    raw_bytes,
8226                    digest = %digest,
8227                );
8228
8229                // Verify AgentId→MachineId binding against identity discovery cache.
8230                let (verified, cert_not_after) = {
8231                    let cache = discovery_cache.read().await;
8232                    cache
8233                        .get(&sender)
8234                        .map(|entry| (entry.machine_id == machine_id, entry.cert_not_after))
8235                        .unwrap_or((false, None))
8236                };
8237
8238                // Evaluate trust for the (AgentId, MachineId) pair.
8239                let trust_decision = {
8240                    let contacts = contact_store.read().await;
8241                    let evaluator = trust::TrustEvaluator::new(&contacts);
8242                    let ctx = trust::TrustContext {
8243                        agent_id: &sender,
8244                        machine_id: &machine_id,
8245                    };
8246                    Some(evaluator.evaluate(&ctx))
8247                };
8248
8249                tracing::debug!(
8250                    target: "dm.trace",
8251                    stage = "inbound_trust_evaluated",
8252                    sender = %hex::encode(sender.as_bytes()),
8253                    machine_id = %hex::encode(machine_id.as_bytes()),
8254                    path = "raw_quic",
8255                    verified,
8256                    decision = ?trust_decision,
8257                    digest = %digest,
8258                );
8259
8260                tracing::info!(
8261                    target: "x0x::direct",
8262                    stage = "recv",
8263                    sender_prefix = %network::hex_prefix(&sender.0, 4),
8264                    machine_prefix = %network::hex_prefix(&machine_id.0, 4),
8265                    raw_bytes,
8266                    payload_bytes,
8267                    verified,
8268                    trust_decision = ?trust_decision,
8269                    "direct message received; dispatching to subscribers"
8270                );
8271
8272                // Enforcement point — direct path revocation gate (issue #179).
8273                // Mirrors EP3 (dm_inbox) + the relay gate (peer_relay). EP5
8274                // (evict_revoked_subject) purges caches + sets trust=Blocked
8275                // but does NOT close the live QUIC connection, so without this
8276                // per-message check a revoked peer on an established direct
8277                // connection would keep delivering (annotated unverified, but
8278                // delivered). Fail closed: drop + count, never reach
8279                // mark_connected/handle_incoming. Read lock is held only for
8280                // the boolean — no await under it (same contract as EP4).
8281                let peer_revoked = {
8282                    let revoked = revocation_set.read().await;
8283                    direct::inbound_peer_revoked(&revoked, &sender, &machine_id)
8284                };
8285                if peer_revoked {
8286                    dm.record_incoming_dropped_revoked();
8287                    tracing::info!(
8288                        target: "x0x::direct",
8289                        stage = "recv",
8290                        sender_prefix = %network::hex_prefix(&sender.0, 4),
8291                        machine_prefix = %network::hex_prefix(&machine_id.0, 4),
8292                        outcome = "drop_revoked",
8293                        "direct message from revoked sender dropped (direct-path revocation gate, mirrors EP3)"
8294                    );
8295                    continue;
8296                }
8297                // Enforcement — runtime cert-expiry gate (issue #191). EP1
8298                // drops expired announcements at ingest, but a previously
8299                // cached entry is never re-checked on the live path; without
8300                // this an expired peer stays trusted until TTL eviction. Fail
8301                // closed: drop + count, mirroring the revoked EP above.
8302                // Absent expiry (None) is fail-open — is_expired returns false
8303                // for pre-#130 peers that carry no not_after.
8304                if identity::is_expired(cert_not_after, Agent::unix_timestamp_secs()) {
8305                    dm.record_incoming_dropped_expired();
8306                    tracing::info!(
8307                        target: "x0x::direct",
8308                        stage = "recv",
8309                        sender_prefix = %network::hex_prefix(&sender.0, 4),
8310                        machine_prefix = %network::hex_prefix(&machine_id.0, 4),
8311                        outcome = "drop_expired",
8312                        "direct message from sender with expired cert dropped (runtime expiry gate, issue #191)"
8313                    );
8314                    continue;
8315                }
8316
8317                // Register and mark the sender as connected for future reverse direct sends.
8318                dm.mark_connected(sender, machine_id).await;
8319
8320                // Fan out to all subscribe_direct() receivers with verification info.
8321                let delivered = dm
8322                    .handle_incoming(machine_id, sender, data, verified, trust_decision)
8323                    .await;
8324
8325                tracing::debug!(
8326                    target: "dm.trace",
8327                    stage = "inbound_broadcast_published",
8328                    sender = %hex::encode(sender.as_bytes()),
8329                    machine_id = %hex::encode(machine_id.as_bytes()),
8330                    path = "raw_quic",
8331                    delivered,
8332                    subscribers = dm.subscriber_count(),
8333                    digest = %digest,
8334                );
8335
8336                tracing::debug!(
8337                    target: "x0x::direct",
8338                    stage = "recv",
8339                    sender_prefix = %network::hex_prefix(&sender.0, 4),
8340                    payload_bytes,
8341                    subscriber_count = dm.subscriber_count(),
8342                    "direct message dispatched"
8343                );
8344            }
8345        });
8346    }
8347
8348    // === Tailnet byte-streams (#132 T1) ===
8349
8350    /// Open a bidirectional byte-stream to a verified, trusted peer.
8351    ///
8352    /// Resolves `agent_id` → machine via the identity discovery cache, then
8353    /// enforces the identity gate (verified binding → not revoked → trust
8354    /// `Accept`) before asking ant-quic to open the stream. The protocol
8355    /// prefix is written immediately after `open_bi` so the accept side can
8356    /// demux. Returns a [`streams::PeerStream`] ready for application I/O.
8357    pub async fn open_peer_stream(
8358        &self,
8359        agent_id: &identity::AgentId,
8360        protocol: streams::StreamProtocol,
8361    ) -> error::NetworkResult<streams::PeerStream> {
8362        let (machine_id, cert_not_after) = {
8363            let cache = self.identity_discovery_cache.read().await;
8364            cache
8365                .get(agent_id)
8366                .map(|entry| (entry.machine_id, entry.cert_not_after))
8367                .ok_or(error::NetworkError::PeerNotVerified {
8368                    agent_id: agent_id.0,
8369                })?
8370        };
8371        // Runtime cert-expiry gate (issue #191): EP1 drops expired
8372        // announcements at ingest but never re-checks a cached entry on the
8373        // live path. Absent expiry (None) is fail-open — is_expired returns
8374        // false, preserving compatibility with pre-#130 peers.
8375        let expired = identity::is_expired(cert_not_after, Self::unix_timestamp_secs());
8376
8377        let trust_decision = {
8378            let contacts = self.contact_store.read().await;
8379            let evaluator = trust::TrustEvaluator::new(&contacts);
8380            Some(evaluator.evaluate(&trust::TrustContext {
8381                agent_id,
8382                machine_id: &machine_id,
8383            }))
8384        };
8385        let (revoked_agent, revoked_machine) = {
8386            let revoked = self.revocation_set.read().await;
8387            (
8388                revoked.is_agent_revoked(agent_id),
8389                revoked.is_machine_revoked(&machine_id),
8390            )
8391        };
8392        streams::stream_gate(
8393            agent_id,
8394            trust_decision,
8395            revoked_agent,
8396            revoked_machine,
8397            expired,
8398        )?;
8399
8400        let network = self
8401            .network
8402            .as_ref()
8403            .ok_or_else(|| error::NetworkError::NodeError("network not initialized".to_string()))?;
8404        let peer = ant_quic::PeerId(machine_id.0);
8405        let (mut send, recv) = network.open_bi(&peer).await?;
8406        streams::write_protocol_prefix(&mut send, protocol).await?;
8407        tracing::info!(
8408            target: "x0x::streams",
8409            agent = %hex::encode(agent_id.as_bytes()),
8410            machine = %hex::encode(machine_id.as_bytes()),
8411            protocol = ?protocol,
8412            "outbound peer stream opened (identity gate cleared)"
8413        );
8414        Ok(streams::PeerStream::new(
8415            vec![*agent_id],
8416            machine_id,
8417            protocol,
8418            send,
8419            recv,
8420        ))
8421    }
8422
8423    /// Await the next inbound byte-stream that has cleared the identity gate.
8424    ///
8425    /// Returns `None` when the accept loop has stopped (e.g. after shutdown).
8426    /// The T4 forwarder consumes accepted streams through this method.
8427    pub async fn next_incoming_stream(&self) -> Option<streams::PeerStream> {
8428        let mut rx = self.stream_accept.receiver().lock().await;
8429        rx.recv().await
8430    }
8431
8432    /// Start the inbound byte-stream accept loop (idempotent).
8433    ///
8434    /// Called automatically by [`Agent::join_network`]. The loop is the SOLE
8435    /// consumer of [`network::NetworkNode::accept_bi`]; every inbound stream
8436    /// clears the identity gate (machine has a known agent → not revoked →
8437    /// trust `Accept`) and the protocol handshake before being surfaced via
8438    /// [`Self::next_incoming_stream`]. A stream that fails the gate is reset
8439    /// (its halves are dropped) with zero application bytes exchanged.
8440    fn start_stream_accept_loop(&self) {
8441        if !self.stream_accept.start_once() {
8442            return;
8443        }
8444        let Some(network) = self.network.as_ref().map(std::sync::Arc::clone) else {
8445            return;
8446        };
8447        let discovery_cache = std::sync::Arc::clone(&self.identity_discovery_cache);
8448        let contact_store = std::sync::Arc::clone(&self.contact_store);
8449        let revocation_set = std::sync::Arc::clone(&self.revocation_set);
8450        let incoming = std::sync::Arc::clone(&self.stream_accept);
8451        let token = self.shutdown_token.clone();
8452
8453        self.spawn_tracked(async move {
8454            tracing::info!(target: "x0x::streams", "byte-stream accept loop started");
8455            loop {
8456                let accepted = tokio::select! {
8457                    _ = token.cancelled() => break,
8458                    r = network.accept_bi() => r,
8459                };
8460                let (ant_peer_id, send, mut recv) = match accepted {
8461                    Ok(triple) => triple,
8462                    Err(e) => {
8463                        tracing::warn!(target: "x0x::streams", error=%e, "accept_bi failed; continuing");
8464                        continue;
8465                    }
8466                };
8467                let machine_id = identity::MachineId(ant_peer_id.0);
8468
8469                // Identity gate — resolve ALL agents on this machine from
8470                // the discovery cache, then check each (revoked → trust).
8471                // The QUIC transport authenticates the machine, not the
8472                // specific agent, so every agent on the machine must clear
8473                // the gate — a single revoked or untrusted agent denies the
8474                // stream (fail-closed, #192). Each lock is taken in its own
8475                // scope so no two identity locks are held at once
8476                // (evict_revoked_subject takes them in a different order).
8477                let agents: Vec<(identity::AgentId, Option<u64>)> = {
8478                    let cache = discovery_cache.read().await;
8479                    let mut found: Vec<(identity::AgentId, Option<u64>)> = cache
8480                        .values()
8481                        .filter(|a| a.machine_id == machine_id)
8482                        .map(|a| (a.agent_id, a.cert_not_after))
8483                        .collect();
8484                    // Deterministic order so logging / per-peer concurrency
8485                    // keying are stable across HashMap iteration orders.
8486                    found.sort_by_key(|(a, _)| a.0);
8487                    found
8488                };
8489                if agents.is_empty() {
8490                    tracing::info!(
8491                        target: "x0x::streams",
8492                        machine = %hex::encode(machine_id.as_bytes()),
8493                        outcome = "deny_not_verified",
8494                        "inbound stream from machine with no known agent — denied"
8495                    );
8496                    continue;
8497                }
8498                let now_secs = Agent::unix_timestamp_secs();
8499                let mut gate_denied: Option<(identity::AgentId, error::NetworkError)> = None;
8500                for (agent_id, cert_not_after) in &agents {
8501                    // Runtime cert-expiry gate (issue #191): a cached entry
8502                    // whose cert has expired must be refused on the live path.
8503                    let expired = identity::is_expired(*cert_not_after, now_secs);
8504                    let trust_decision = {
8505                        let contacts = contact_store.read().await;
8506                        let evaluator = trust::TrustEvaluator::new(&contacts);
8507                        Some(evaluator.evaluate(&trust::TrustContext {
8508                            agent_id,
8509                            machine_id: &machine_id,
8510                        }))
8511                    };
8512                    let (revoked_agent, revoked_machine) = {
8513                        let revoked = revocation_set.read().await;
8514                        (
8515                            revoked.is_agent_revoked(agent_id),
8516                            revoked.is_machine_revoked(&machine_id),
8517                        )
8518                    };
8519                    if let Err(e) = streams::stream_gate(
8520                        agent_id,
8521                        trust_decision,
8522                        revoked_agent,
8523                        revoked_machine,
8524                        expired,
8525                    ) {
8526                        gate_denied = Some((*agent_id, e));
8527                        break;
8528                    }
8529                }
8530                if let Some((agent_id, e)) = gate_denied {
8531                    tracing::info!(
8532                        target: "x0x::streams",
8533                        agent = %hex::encode(agent_id.as_bytes()),
8534                        machine = %hex::encode(machine_id.as_bytes()),
8535                        agent_count = agents.len(),
8536                        outcome = "deny_gate",
8537                        error = %e,
8538                        "inbound stream denied at identity gate (one agent on the machine failed)"
8539                    );
8540                    continue;
8541                }
8542
8543                // Gate cleared for every agent — drop the expiry metadata and
8544                // keep the ordered agent list for the stream handle.
8545                let agents: Vec<identity::AgentId> =
8546                    agents.into_iter().map(|(a, _)| a).collect();
8547
8548                // DISPATCH (DoS hardening, issue #132): the protocol-prefix
8549                // read + surfacing run in a per-stream task so a peer that
8550                // opens a stream and never sends the prefix cannot block this
8551                // accept loop (and thus every other peer's inbound streams).
8552                // The identity gate above already cleared; this task owns the
8553                // stream halves and drops them (→ QUIC reset) on any failure.
8554                let incoming_for_task = std::sync::Arc::clone(&incoming);
8555                tokio::spawn(async move {
8556                    // Belt-and-braces: bound the prefix read so a silent peer
8557                    // holds the task/stream for at most PREFIX_READ_TIMEOUT.
8558                    let protocol = match tokio::time::timeout(
8559                        streams::PREFIX_READ_TIMEOUT,
8560                        streams::read_protocol_prefix(&mut recv),
8561                    )
8562                    .await
8563                    {
8564                        Ok(Ok(p)) => p,
8565                        Ok(Err(e)) => {
8566                            tracing::info!(
8567                                target: "x0x::streams",
8568                                machine = %hex::encode(machine_id.as_bytes()),
8569                                outcome = "deny_protocol",
8570                                error = %e,
8571                                "inbound stream protocol prefix rejected"
8572                            );
8573                            return;
8574                        }
8575                        Err(_) => {
8576                            tracing::info!(
8577                                target: "x0x::streams",
8578                                machine = %hex::encode(machine_id.as_bytes()),
8579                                outcome = "deny_prefix_timeout",
8580                                "inbound stream prefix byte timed out — resetting"
8581                            );
8582                            return;
8583                        }
8584                    };
8585                    let peer_stream =
8586                        streams::PeerStream::new(agents, machine_id, protocol, send, recv);
8587                    // try_send so a slow consumer cannot pile up accepted
8588                    // streams in memory; a full channel drops the stream.
8589                    if incoming_for_task.sender().try_send(peer_stream).is_err() {
8590                        tracing::debug!(
8591                            target: "x0x::streams",
8592                            "incoming-stream channel full; dropping accepted stream"
8593                        );
8594                    }
8595                });
8596            }
8597        });
8598    }
8599
8600    /// new announcement.
8601    ///
8602    /// Called automatically by [`Agent::join_network`].
8603    ///
8604    /// # Errors
8605    ///
8606    /// Returns an error if a required network or gossip component is missing.
8607    pub async fn start_identity_heartbeat(&self) -> error::Result<()> {
8608        let mut handle_guard = self.heartbeat_handle.lock().await;
8609        // Shutdown race (issue #116): a still-bootstrapping join_network can call
8610        // this after shutdown() began. Checking under the SAME lock that
8611        // stop_identity_heartbeat takes the handle from makes it TOCTOU-free:
8612        // stop_X runs after cancel(), so a handle stored before stop_X runs is
8613        // taken+aborted, and a store attempted after sees cancelled → refused.
8614        if self.shutdown_token.is_cancelled() {
8615            return Ok(());
8616        }
8617        if handle_guard.is_some() {
8618            return Ok(());
8619        }
8620        let Some(runtime) = self.gossip_runtime.as_ref().map(std::sync::Arc::clone) else {
8621            return Err(error::IdentityError::Storage(std::io::Error::other(
8622                "gossip runtime not initialized — cannot start heartbeat",
8623            )));
8624        };
8625        let Some(network) = self.network.as_ref().map(std::sync::Arc::clone) else {
8626            return Err(error::IdentityError::Storage(std::io::Error::other(
8627                "network not initialized — cannot start heartbeat",
8628            )));
8629        };
8630        let allow_local_discovery_addrs = allow_local_discovery_addresses(network.config());
8631        let ctx = HeartbeatContext {
8632            identity: std::sync::Arc::clone(&self.identity),
8633            runtime,
8634            network,
8635            interval_secs: self.heartbeat_interval_secs,
8636            cache: std::sync::Arc::clone(&self.identity_discovery_cache),
8637            machine_cache: std::sync::Arc::clone(&self.machine_discovery_cache),
8638            user_identity_consented: std::sync::Arc::clone(&self.user_identity_consented),
8639            allow_local_discovery_addrs,
8640            revocation_set: std::sync::Arc::clone(&self.revocation_set),
8641        };
8642        let handle = tokio::task::spawn(async move {
8643            let mut ticker =
8644                tokio::time::interval(std::time::Duration::from_secs(ctx.interval_secs));
8645            ticker.tick().await; // skip first immediate tick
8646            loop {
8647                ticker.tick().await;
8648                if let Err(e) = ctx.announce().await {
8649                    tracing::warn!("identity heartbeat announce failed: {e}");
8650                }
8651            }
8652        });
8653        *handle_guard = Some(handle);
8654        Ok(())
8655    }
8656
8657    /// Publish a rendezvous `ProviderSummary` for this agent.
8658    ///
8659    /// Enables global findability across gossip overlay partitions.  Seekers
8660    /// that have never been on the same partition as this agent can still
8661    /// discover it by subscribing to the rendezvous shard topic and waiting
8662    /// for the next heartbeat advertisement.
8663    ///
8664    /// The summary is signed with this agent's machine key and contains the
8665    /// agent's reachability addresses in the `extensions` field (bincode-encoded
8666    /// `Vec<SocketAddr>`).
8667    ///
8668    /// # Re-advertisement contract
8669    ///
8670    /// Rendezvous summaries expire after `validity_ms` milliseconds.  **Callers
8671    /// are responsible for calling `advertise_identity` again before expiry** so
8672    /// that seekers can always find a fresh record.  A common strategy is to
8673    /// re-advertise every `validity_ms / 2`.  The `x0xd` daemon does this
8674    /// automatically via its background re-advertisement task.
8675    ///
8676    /// # Arguments
8677    ///
8678    /// * `validity_ms` — How long (milliseconds) before the summary expires.
8679    ///   After this time, seekers will no longer discover this agent via rendezvous
8680    ///   unless a fresh `advertise_identity` call is made.
8681    ///
8682    /// # Errors
8683    ///
8684    /// Returns an error if the gossip runtime is not initialized, serialization
8685    /// fails, or signing fails.
8686    pub async fn advertise_identity(&self, validity_ms: u64) -> error::Result<()> {
8687        use saorsa_gossip_rendezvous::{Capability, ProviderSummary};
8688
8689        let runtime = self.gossip_runtime.as_ref().ok_or_else(|| {
8690            error::IdentityError::Storage(std::io::Error::other(
8691                "gossip runtime not initialized — cannot advertise identity",
8692            ))
8693        })?;
8694
8695        let peer_id = runtime.peer_id();
8696        let addresses = self.announcement_addresses();
8697        let addr_bytes = bincode::serialize(&addresses).map_err(|e| {
8698            error::IdentityError::Serialization(format!(
8699                "failed to serialize addresses for rendezvous: {e}"
8700            ))
8701        })?;
8702
8703        let mut summary = ProviderSummary::new(
8704            self.agent_id().0,
8705            peer_id,
8706            vec![Capability::Identity],
8707            validity_ms,
8708        )
8709        .with_extensions(addr_bytes);
8710
8711        summary
8712            .sign_raw(self.identity.machine_keypair().secret_key().as_bytes())
8713            .map_err(|e| {
8714                error::IdentityError::Storage(std::io::Error::other(format!(
8715                    "failed to sign rendezvous summary: {e}"
8716                )))
8717            })?;
8718
8719        let cbor_bytes = summary.to_cbor().map_err(|e| {
8720            error::IdentityError::Serialization(format!(
8721                "failed to CBOR-encode rendezvous summary: {e}"
8722            ))
8723        })?;
8724
8725        let topic = rendezvous_shard_topic_for_agent(&self.agent_id());
8726        runtime
8727            .pubsub()
8728            .publish(topic, bytes::Bytes::from(cbor_bytes))
8729            .await
8730            .map_err(|e| {
8731                error::IdentityError::Storage(std::io::Error::other(format!(
8732                    "failed to publish rendezvous summary: {e}"
8733                )))
8734            })?;
8735
8736        self.rendezvous_advertised
8737            .store(true, std::sync::atomic::Ordering::Relaxed);
8738        Ok(())
8739    }
8740
8741    /// Search for an agent via rendezvous shard subscription.
8742    ///
8743    /// Subscribes to the rendezvous shard topic for `agent_id` and waits up to
8744    /// `timeout_secs` for a matching [`saorsa_gossip_rendezvous::ProviderSummary`].
8745    /// On success the addresses encoded in the summary `extensions` field are
8746    /// returned.
8747    ///
8748    /// This is Stage 3 of [`Agent::find_agent`]'s lookup cascade.
8749    ///
8750    /// # Errors
8751    ///
8752    /// Returns an error if the gossip runtime is not initialized.
8753    pub async fn find_agent_rendezvous(
8754        &self,
8755        agent_id: identity::AgentId,
8756        timeout_secs: u64,
8757    ) -> error::Result<Option<Vec<std::net::SocketAddr>>> {
8758        use saorsa_gossip_rendezvous::ProviderSummary;
8759
8760        let runtime = match self.gossip_runtime.as_ref() {
8761            Some(r) => r,
8762            None => return Ok(None),
8763        };
8764
8765        let topic = rendezvous_shard_topic_for_agent(&agent_id);
8766        let mut sub = runtime.pubsub().subscribe(topic).await;
8767        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
8768
8769        loop {
8770            if tokio::time::Instant::now() >= deadline {
8771                break;
8772            }
8773            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
8774            tokio::select! {
8775                Some(msg) = sub.recv() => {
8776                    let summary = match ProviderSummary::from_cbor(&msg.payload) {
8777                        Ok(s) => s,
8778                        Err(_) => continue,
8779                    };
8780                    if summary.target != agent_id.0 {
8781                        continue;
8782                    }
8783                    // Verify the summary signature when the advertiser's machine
8784                    // public key is cached from a prior identity announcement.
8785                    // Without a cached key we still accept the addresses — they
8786                    // are connection hints only; the subsequent QUIC handshake will
8787                    // fail cryptographically if the endpoint is not the genuine agent.
8788                    let cached_pub = self
8789                        .identity_discovery_cache
8790                        .read()
8791                        .await
8792                        .get(&agent_id)
8793                        .map(|e| e.machine_public_key.clone());
8794                    if let Some(pub_bytes) = cached_pub {
8795                        if !pub_bytes.is_empty()
8796                            && !summary.verify_raw(&pub_bytes).unwrap_or(false)
8797                        {
8798                            tracing::warn!(
8799                                "Rendezvous summary signature verification failed for agent {:?}; discarding",
8800                                agent_id
8801                            );
8802                            continue;
8803                        }
8804                    }
8805                    // Decode addresses from the extensions field.
8806                    let addrs: Vec<std::net::SocketAddr> = summary
8807                        .extensions
8808                        .as_deref()
8809                        .and_then(|b| {
8810                            use bincode::Options;
8811                            bincode::DefaultOptions::new()
8812                                .with_fixint_encoding()
8813                                .with_limit(crate::network::MAX_MESSAGE_DESERIALIZE_SIZE)
8814                                .deserialize(b)
8815                                .ok()
8816                        })
8817                        .unwrap_or_default();
8818                    if !addrs.is_empty() {
8819                        return Ok(Some(addrs));
8820                    }
8821                }
8822                _ = tokio::time::sleep(remaining) => break,
8823            }
8824        }
8825
8826        Ok(None)
8827    }
8828
8829    /// Insert a discovered agent into the cache (for testing only).
8830    ///
8831    /// Insert a [`dm::DmCapabilities`] entry for `agent_id` / `machine_id`
8832    /// into the local capability store, bypassing the gossip-advert pipeline.
8833    ///
8834    /// # Visibility
8835    ///
8836    /// `#[doc(hidden)]` - tests-only seam. Production callers must rely on
8837    /// the live capability-advert subscription so the store mirrors what
8838    /// the network actually advertises.
8839    ///
8840    /// # Arguments
8841    ///
8842    /// * `agent_id` - Subject agent the capabilities apply to.
8843    /// * `machine_id` - Subject machine. Used by the store's lookup path
8844    ///   to disambiguate when an agent is reachable via multiple machines.
8845    /// * `capabilities` - The [`dm::DmCapabilities`] record (KEM public
8846    ///   key, gossip-inbox readiness, envelope-size cap, etc.) the sender
8847    ///   should treat as authoritative.
8848    #[doc(hidden)]
8849    pub fn insert_capability_for_testing(
8850        &self,
8851        agent_id: identity::AgentId,
8852        machine_id: identity::MachineId,
8853        capabilities: dm::DmCapabilities,
8854    ) {
8855        self.capability_store
8856            .insert(agent_id, machine_id, capabilities, dm::now_unix_ms());
8857    }
8858
8859    /// # Arguments
8860    ///
8861    /// * `agent` - The agent entry to insert.
8862    #[doc(hidden)]
8863    pub async fn insert_discovered_agent_for_testing(&self, agent: DiscoveredAgent) {
8864        let agent_id = agent.agent_id;
8865        let machine_id = agent.machine_id;
8866        upsert_discovered_machine_from_agent(&self.machine_discovery_cache, &agent).await;
8867        upsert_discovered_agent(&self.identity_discovery_cache, agent).await;
8868
8869        if machine_id.0 != [0u8; 32] {
8870            self.direct_messaging
8871                .register_agent(agent_id, machine_id)
8872                .await;
8873            if let Some(ref network) = self.network {
8874                let ant_peer_id = ant_quic::PeerId(machine_id.0);
8875                if network.is_connected(&ant_peer_id).await {
8876                    self.direct_messaging
8877                        .mark_connected(agent_id, machine_id)
8878                        .await;
8879                }
8880            }
8881        }
8882    }
8883
8884    /// Test-only: mark `agent_id` as a `Trusted` contact so
8885    /// [`TrustEvaluator`] returns [`trust::TrustDecision::Accept`] for it.
8886    /// Used by the tailnet stream tests to clear the outbound/inbound trust
8887    /// gate without driving the full contact-import REST flow. The
8888    /// AgentId→MachineId *binding* still has to come from the discovery cache
8889    /// (see [`Self::insert_discovered_agent_for_testing`]).
8890    #[doc(hidden)]
8891    pub async fn set_contact_trusted_for_testing(&self, agent_id: identity::AgentId) {
8892        let mut store = self.contact_store.write().await;
8893        store.add(contacts::Contact {
8894            agent_id,
8895            trust_level: contacts::TrustLevel::Trusted,
8896            label: None,
8897            added_at: 0,
8898            last_seen: None,
8899            identity_type: contacts::IdentityType::Anonymous,
8900            machines: Vec::new(),
8901            dm_capabilities: None,
8902        });
8903    }
8904
8905    /// Push a synthetic [`peer_relay::RelayedDm`] onto this Agent's inbound
8906    /// relay-DM channel, exactly as the wire demuxer would after receiving a
8907    /// [`network::RELAYED_DM_STREAM_TYPE`] frame. Drives the
8908    /// `spawn_relay_dm_listener` dispatch path (revocation gate →
8909    /// deliver-locally / forward / refuse) without a second live QUIC peer.
8910    ///
8911    /// `#[doc(hidden)]` - tests-only seam. The production inbound path is the
8912    /// network receiver task; this exists only because
8913    /// [`network::NetworkNode::send_direct_typed`] and the relay-DM channel
8914    /// sender are `pub(crate)`, so an out-of-crate integration test cannot
8915    /// otherwise exercise the recipient-side listener.
8916    ///
8917    /// # Arguments
8918    ///
8919    /// * `from_peer` - the QUIC peer the frame nominally arrived on (the
8920    ///   relay hop, or the origin for a self-addressed `RelayedDm`).
8921    /// * `relayed` - the fully-formed relayed envelope to dispatch.
8922    ///
8923    /// Returns `false` when no network is configured (nothing to receive on).
8924    #[doc(hidden)]
8925    pub async fn push_relayed_dm_for_testing(
8926        &self,
8927        from_peer: ant_quic::PeerId,
8928        relayed: peer_relay::RelayedDm,
8929    ) -> bool {
8930        let Some(ref network) = self.network else {
8931            return false;
8932        };
8933        let sender_agent_id = relayed.header.sender_agent_id;
8934        network
8935            .test_relayed_dm_sender()
8936            .send((from_peer, sender_agent_id, relayed))
8937            .await
8938            .is_ok()
8939    }
8940
8941    /// Create a new collaborative task list bound to a topic.
8942    ///
8943    /// Creates a new `TaskList` and binds it to the specified gossip topic
8944    /// for automatic synchronization with other agents on the same topic.
8945    ///
8946    /// # Arguments
8947    ///
8948    /// * `name` - Human-readable name for the task list
8949    /// * `topic` - Gossip topic for synchronization
8950    ///
8951    /// # Returns
8952    ///
8953    /// A `TaskListHandle` for interacting with the task list.
8954    ///
8955    /// # Errors
8956    ///
8957    /// Returns an error if the gossip runtime is not initialized.
8958    ///
8959    /// # Example
8960    ///
8961    /// ```ignore
8962    /// let list = agent.create_task_list("Sprint Planning", "team-sprint").await?;
8963    /// ```
8964    pub async fn create_task_list(&self, name: &str, topic: &str) -> error::Result<TaskListHandle> {
8965        let runtime = self.gossip_runtime.as_ref().ok_or_else(|| {
8966            error::IdentityError::Storage(std::io::Error::other(
8967                "gossip runtime not initialized - configure agent with network first",
8968            ))
8969        })?;
8970
8971        let peer_id = runtime.peer_id();
8972        let list_id = crdt::TaskListId::from_content(name, &self.agent_id(), 0);
8973        let task_list = crdt::TaskList::new(list_id, name.to_string(), peer_id);
8974
8975        let sync = crdt::TaskListSync::new(
8976            task_list,
8977            std::sync::Arc::clone(runtime.pubsub()),
8978            topic.to_string(),
8979            peer_id,
8980        )
8981        .map_err(|e| {
8982            error::IdentityError::Storage(std::io::Error::other(format!(
8983                "task list sync creation failed: {}",
8984                e
8985            )))
8986        })?;
8987
8988        let sync = std::sync::Arc::new(sync);
8989        sync.start_with_spawner(|fut| self.spawn_tracked(fut))
8990            .await
8991            .map_err(|e| {
8992                error::IdentityError::Storage(std::io::Error::other(format!(
8993                    "task list sync start failed: {}",
8994                    e
8995                )))
8996            })?;
8997
8998        Ok(TaskListHandle {
8999            sync,
9000            agent_id: self.agent_id(),
9001            peer_id,
9002        })
9003    }
9004
9005    /// Join an existing task list by topic.
9006    ///
9007    /// Connects to a task list that was created by another agent on the
9008    /// specified topic. The local replica will sync with peers automatically.
9009    ///
9010    /// # Arguments
9011    ///
9012    /// * `topic` - Gossip topic for the task list
9013    ///
9014    /// # Returns
9015    ///
9016    /// A `TaskListHandle` for interacting with the task list.
9017    ///
9018    /// # Errors
9019    ///
9020    /// Returns an error if the gossip runtime is not initialized.
9021    ///
9022    /// # Example
9023    ///
9024    /// ```ignore
9025    /// let list = agent.join_task_list("team-sprint").await?;
9026    /// ```
9027    pub async fn join_task_list(&self, topic: &str) -> error::Result<TaskListHandle> {
9028        let runtime = self.gossip_runtime.as_ref().ok_or_else(|| {
9029            error::IdentityError::Storage(std::io::Error::other(
9030                "gossip runtime not initialized - configure agent with network first",
9031            ))
9032        })?;
9033
9034        let peer_id = runtime.peer_id();
9035        // Create empty task list; it will be populated via delta sync
9036        let list_id = crdt::TaskListId::from_content(topic, &self.agent_id(), 0);
9037        let task_list = crdt::TaskList::new(list_id, String::new(), peer_id);
9038
9039        let sync = crdt::TaskListSync::new(
9040            task_list,
9041            std::sync::Arc::clone(runtime.pubsub()),
9042            topic.to_string(),
9043            peer_id,
9044        )
9045        .map_err(|e| {
9046            error::IdentityError::Storage(std::io::Error::other(format!(
9047                "task list sync creation failed: {}",
9048                e
9049            )))
9050        })?;
9051
9052        let sync = std::sync::Arc::new(sync);
9053        sync.start_with_spawner(|fut| self.spawn_tracked(fut))
9054            .await
9055            .map_err(|e| {
9056                error::IdentityError::Storage(std::io::Error::other(format!(
9057                    "task list sync start failed: {}",
9058                    e
9059                )))
9060            })?;
9061
9062        Ok(TaskListHandle {
9063            sync,
9064            agent_id: self.agent_id(),
9065            peer_id,
9066        })
9067    }
9068}
9069
9070impl AgentBuilder {
9071    /// Set a custom path for the machine keypair.
9072    ///
9073    /// If not set, the machine keypair is stored in `~/.x0x/machine.key`.
9074    ///
9075    /// # Arguments
9076    ///
9077    /// * `path` - The path to store the machine keypair.
9078    ///
9079    /// # Returns
9080    ///
9081    /// Self for chaining.
9082    pub fn with_machine_key<P: AsRef<std::path::Path>>(mut self, path: P) -> Self {
9083        self.machine_key_path = Some(path.as_ref().to_path_buf());
9084        self
9085    }
9086
9087    /// Import an agent keypair.
9088    ///
9089    /// If not set, the agent keypair is loaded from storage (or generated fresh
9090    /// if no stored key exists).
9091    ///
9092    /// This enables running the same agent on multiple machines by importing
9093    /// the same agent keypair (but with different machine keypairs).
9094    ///
9095    /// Note: When an explicit keypair is provided via this method, it takes
9096    /// precedence over `with_agent_key_path()`.
9097    ///
9098    /// # Arguments
9099    ///
9100    /// * `keypair` - The agent keypair to import.
9101    ///
9102    /// # Returns
9103    ///
9104    /// Self for chaining.
9105    pub fn with_agent_key(mut self, keypair: identity::AgentKeypair) -> Self {
9106        self.agent_keypair = Some(keypair);
9107        self
9108    }
9109
9110    /// Set a custom path for the agent keypair.
9111    ///
9112    /// If not set, the agent keypair is stored in `~/.x0x/agent.key`.
9113    /// If no stored key is found at the path, a fresh one is generated and saved.
9114    ///
9115    /// This is ignored when `with_agent_key()` provides an explicit keypair.
9116    ///
9117    /// # Arguments
9118    ///
9119    /// * `path` - The path to store/load the agent keypair.
9120    ///
9121    /// # Returns
9122    ///
9123    /// Self for chaining.
9124    pub fn with_agent_key_path<P: AsRef<std::path::Path>>(mut self, path: P) -> Self {
9125        self.agent_key_path = Some(path.as_ref().to_path_buf());
9126        self
9127    }
9128
9129    /// Set a custom path for the agent certificate (`agent.cert`).
9130    ///
9131    /// Required for multi-daemon setups that share a host — the default
9132    /// path (`~/.x0x/agent.cert`) is shared across daemons, causing
9133    /// last-writer-wins trampling that makes peers reject identity
9134    /// announcements as `agent certificate agent_id mismatch`.
9135    ///
9136    /// # Arguments
9137    ///
9138    /// * `path` - The path to use for the agent certificate file.
9139    ///
9140    /// # Returns
9141    ///
9142    /// Self for chaining.
9143    #[must_use]
9144    pub fn with_agent_cert_path<P: AsRef<std::path::Path>>(mut self, path: P) -> Self {
9145        self.agent_cert_path = Some(path.as_ref().to_path_buf());
9146        self
9147    }
9148
9149    /// Set network configuration for P2P communication.
9150    ///
9151    /// If not set, the agent is built without a network node or gossip
9152    /// runtime. Use `NetworkConfig::default()` to connect through the default
9153    /// bootstrap nodes.
9154    ///
9155    /// # Arguments
9156    ///
9157    /// * `config` - The network configuration to use.
9158    ///
9159    /// # Returns
9160    ///
9161    /// Self for chaining.
9162    pub fn with_network_config(mut self, config: network::NetworkConfig) -> Self {
9163        self.network_config = Some(config);
9164        self
9165    }
9166
9167    /// Set gossip overlay configuration.
9168    ///
9169    /// This is primarily used by x0xd to expose operational knobs such as
9170    /// `gossip.dispatch_workers`. If the agent is built without a network
9171    /// configuration, the value is retained by the builder but has no runtime
9172    /// effect.
9173    #[must_use]
9174    pub fn with_gossip_config(mut self, config: gossip::GossipConfig) -> Self {
9175        self.gossip_config = Some(config);
9176        self
9177    }
9178
9179    /// Set the directory for the bootstrap peer cache.
9180    ///
9181    /// The cache persists peer quality metrics across restarts, enabling
9182    /// cache-first join strategy. Defaults to `~/.x0x/peers/` if not set.
9183    /// Falls back to `./.x0x/peers/` (relative to CWD) if `$HOME` is unset.
9184    pub fn with_peer_cache_dir<P: AsRef<std::path::Path>>(mut self, path: P) -> Self {
9185        self.peer_cache_dir = Some(path.as_ref().to_path_buf());
9186        self
9187    }
9188
9189    /// Disable the bootstrap peer cache entirely.
9190    ///
9191    /// When set, the agent will not open or load any cached peers on
9192    /// startup. This ensures complete network isolation from previously
9193    /// seen peers for embedders and dedicated test harnesses.
9194    ///
9195    /// Note: the x0xd daemon's `--no-hard-coded-bootstrap` flag does
9196    /// not call this; it only clears configured seed peers.
9197    pub fn with_peer_cache_disabled(mut self) -> Self {
9198        self.disable_peer_cache = true;
9199        self
9200    }
9201
9202    /// Import a user keypair for three-layer identity.
9203    ///
9204    /// This binds a human identity to this agent. When provided, an
9205    /// [`identity::AgentCertificate`] is automatically issued (if one
9206    /// doesn't already exist in storage) to cryptographically attest
9207    /// that this agent belongs to the user.
9208    ///
9209    /// Note: When an explicit keypair is provided via this method, it takes
9210    /// precedence over `with_user_key_path()`.
9211    ///
9212    /// # Arguments
9213    ///
9214    /// * `keypair` - The user keypair to import.
9215    ///
9216    /// # Returns
9217    ///
9218    /// Self for chaining.
9219    pub fn with_user_key(mut self, keypair: identity::UserKeypair) -> Self {
9220        self.user_keypair = Some(keypair);
9221        self
9222    }
9223
9224    /// Set a custom path for the user keypair.
9225    ///
9226    /// Unlike machine and agent keys, user keys are **not** auto-generated.
9227    /// If the file at this path doesn't exist, no user identity is set
9228    /// (the agent operates with two-layer identity).
9229    ///
9230    /// This is ignored when `with_user_key()` provides an explicit keypair.
9231    ///
9232    /// # Arguments
9233    ///
9234    /// * `path` - The path to load the user keypair from.
9235    ///
9236    /// # Returns
9237    ///
9238    /// Self for chaining.
9239    pub fn with_user_key_path<P: AsRef<std::path::Path>>(mut self, path: P) -> Self {
9240        self.user_key_path = Some(path.as_ref().to_path_buf());
9241        self
9242    }
9243
9244    /// Set the identity heartbeat re-announcement interval.
9245    ///
9246    /// Defaults to [`IDENTITY_HEARTBEAT_INTERVAL_SECS`] (300 seconds).
9247    ///
9248    /// # Arguments
9249    ///
9250    /// * `secs` - Interval in seconds between identity re-announcements.
9251    #[must_use]
9252    pub fn with_heartbeat_interval(mut self, secs: u64) -> Self {
9253        self.heartbeat_interval_secs = Some(secs);
9254        self
9255    }
9256
9257    /// Set the identity cache TTL.
9258    ///
9259    /// Cache entries with `last_seen` older than this threshold are filtered
9260    /// from [`Agent::presence`] and [`Agent::discovered_agents`].
9261    ///
9262    /// Defaults to [`IDENTITY_TTL_SECS`] (900 seconds).
9263    ///
9264    /// # Arguments
9265    ///
9266    /// * `secs` - Time-to-live in seconds for discovered agent entries.
9267    #[must_use]
9268    pub fn with_identity_ttl(mut self, secs: u64) -> Self {
9269        self.identity_ttl_secs = Some(secs);
9270        self
9271    }
9272
9273    /// Override the presence beacon broadcast interval in seconds.
9274    #[must_use]
9275    pub fn with_presence_beacon_interval(mut self, secs: u64) -> Self {
9276        self.presence_beacon_interval_secs = Some(secs);
9277        self
9278    }
9279
9280    /// Override the presence event poll interval in seconds.
9281    #[must_use]
9282    pub fn with_presence_event_poll_interval(mut self, secs: u64) -> Self {
9283        self.presence_event_poll_interval_secs = Some(secs);
9284        self
9285    }
9286
9287    /// Override the fallback offline timeout used by presence events.
9288    #[must_use]
9289    pub fn with_presence_offline_timeout(mut self, secs: u64) -> Self {
9290        self.presence_offline_timeout_secs = Some(secs);
9291        self
9292    }
9293
9294    /// Set a custom path for the contacts file.
9295    ///
9296    /// The contacts file persists trust levels and machine records for known
9297    /// agents. Defaults to `~/.x0x/contacts.json` if not set.
9298    ///
9299    /// # Arguments
9300    ///
9301    /// * `path` - The path for the contacts file.
9302    #[must_use]
9303    pub fn with_contact_store_path<P: AsRef<std::path::Path>>(mut self, path: P) -> Self {
9304        self.contact_store_path = Some(path.as_ref().to_path_buf());
9305        self
9306    }
9307
9308    /// Set the directory used for all identity-scoped files (keys, certificate,
9309    /// and the revocation set `revocations.bin`).
9310    ///
9311    /// When set, the revocation set is loaded from / saved to
9312    /// `<identity_dir>/revocations.bin` instead of `~/.x0x/revocations.bin`.
9313    /// This mirrors how `with_machine_key`, `with_agent_key_path`, and
9314    /// `with_agent_cert_path` scope their respective files.
9315    ///
9316    /// Callers that already configure all three key paths individually do not
9317    /// need to set this — it is primarily a convenience for the x0xd server
9318    /// which builds the agent with an explicit identity directory.
9319    #[must_use]
9320    pub fn with_identity_dir<P: AsRef<std::path::Path>>(mut self, path: P) -> Self {
9321        self.identity_dir = Some(path.as_ref().to_path_buf());
9322        self
9323    }
9324
9325    /// Build and initialise the agent.
9326    ///
9327    /// This performs the following:
9328    /// 1. Loads or generates the machine keypair (stored in `~/.x0x/machine.key` by default)
9329    /// 2. Uses provided agent keypair or generates a fresh one
9330    /// 3. Combines both into a unified Identity
9331    ///
9332    /// The machine keypair is automatically persisted to storage.
9333    ///
9334    /// # Errors
9335    ///
9336    /// Returns an error if:
9337    /// - Machine keypair generation fails
9338    /// - Storage I/O fails
9339    /// - Keypair deserialization fails
9340    pub async fn build(self) -> error::Result<Agent> {
9341        // Determine machine keypair source
9342        let machine_keypair = if let Some(path) = self.machine_key_path {
9343            // Try to load from custom path
9344            match storage::load_machine_keypair_from(&path).await {
9345                Ok(kp) => kp,
9346                Err(_) => {
9347                    // Generate fresh keypair and save to custom path
9348                    let kp = identity::MachineKeypair::generate()?;
9349                    storage::save_machine_keypair_to(&kp, &path).await?;
9350                    kp
9351                }
9352            }
9353        } else if storage::machine_keypair_exists().await {
9354            // Load default machine keypair
9355            storage::load_machine_keypair().await?
9356        } else {
9357            // Generate and save default machine keypair
9358            let kp = identity::MachineKeypair::generate()?;
9359            storage::save_machine_keypair(&kp).await?;
9360            kp
9361        };
9362
9363        // Resolve agent keypair: explicit > path-based > default storage > generate
9364        let agent_keypair = if let Some(kp) = self.agent_keypair {
9365            // Explicit keypair takes highest precedence
9366            kp
9367        } else if let Some(path) = self.agent_key_path {
9368            // Custom path: load or generate+save
9369            match storage::load_agent_keypair_from(&path).await {
9370                Ok(kp) => kp,
9371                Err(_) => {
9372                    let kp = identity::AgentKeypair::generate()?;
9373                    storage::save_agent_keypair_to(&kp, &path).await?;
9374                    kp
9375                }
9376            }
9377        } else if storage::agent_keypair_exists().await {
9378            // Default path exists: load it
9379            storage::load_agent_keypair_default().await?
9380        } else {
9381            // No stored key: generate and persist
9382            let kp = identity::AgentKeypair::generate()?;
9383            storage::save_agent_keypair_default(&kp).await?;
9384            kp
9385        };
9386
9387        // Resolve user keypair: explicit > path-based > default storage > None (opt-in)
9388        let user_keypair = if let Some(kp) = self.user_keypair {
9389            Some(kp)
9390        } else if let Some(path) = self.user_key_path {
9391            // Custom path: load if exists, otherwise None (don't auto-generate)
9392            storage::load_user_keypair_from(&path).await.ok()
9393        } else if storage::user_keypair_exists().await {
9394            // Default path exists: load it
9395            storage::load_user_keypair().await.ok()
9396        } else {
9397            None
9398        };
9399
9400        // Build identity with optional user layer.
9401        //
9402        // The agent certificate binds (user_id, agent_id). On load we must
9403        // verify BOTH halves of the binding match the current identity —
9404        // user_id AND agent_id — and re-issue if either diverges. A mismatch
9405        // happens in two practical scenarios:
9406        //   1. The user key was replaced (cert's user_id no longer ours).
9407        //   2. Multi-daemon-per-host setups where the cert path is shared
9408        //      and a peer daemon overwrote it with their own cert (cert's
9409        //      agent_id no longer ours).
9410        // Without the agent_id half of the check, scenario (2) produces an
9411        // announcement whose cert binds another daemon's agent_id, and peers
9412        // reject it as "agent certificate agent_id mismatch".
9413        //
9414        // The per-daemon `agent_cert_path` (set by `with_agent_cert_path()`)
9415        // is the structural fix for scenario (2); the agent_id check is the
9416        // defensive net in case two processes still land on the same path.
9417        let identity = if let Some(user_kp) = user_keypair {
9418            let cert_path = self.agent_cert_path.clone();
9419            let existing_cert = if let Some(ref p) = cert_path {
9420                if tokio::fs::try_exists(p).await.unwrap_or(false) {
9421                    storage::load_agent_certificate_from(p).await.ok()
9422                } else {
9423                    None
9424                }
9425            } else if storage::agent_certificate_exists().await {
9426                storage::load_agent_certificate().await.ok()
9427            } else {
9428                None
9429            };
9430
9431            let cert_still_valid = existing_cert.as_ref().is_some_and(|c| {
9432                let user_match = c
9433                    .user_id()
9434                    .map(|uid| uid == user_kp.user_id())
9435                    .unwrap_or(false);
9436                let agent_match = c
9437                    .agent_id()
9438                    .map(|aid| aid == agent_keypair.agent_id())
9439                    .unwrap_or(false);
9440                user_match && agent_match
9441            });
9442
9443            let cert = if cert_still_valid {
9444                existing_cert.ok_or_else(|| {
9445                    error::IdentityError::Storage(std::io::Error::other(
9446                        "agent certificate validity check succeeded with no certificate loaded",
9447                    ))
9448                })?
9449            } else {
9450                let new_cert = identity::AgentCertificate::issue(&user_kp, &agent_keypair)?;
9451                if let Some(ref p) = cert_path {
9452                    storage::save_agent_certificate_to(&new_cert, p).await?;
9453                } else {
9454                    storage::save_agent_certificate(&new_cert).await?;
9455                }
9456                new_cert
9457            };
9458            identity::Identity::new_with_user(machine_keypair, agent_keypair, user_kp, cert)
9459        } else {
9460            identity::Identity::new(machine_keypair, agent_keypair)
9461        };
9462
9463        // Open bootstrap peer cache if network will be configured
9464        // and the cache is not explicitly disabled by the caller.
9465        let bootstrap_cache = if self.network_config.is_some() && !self.disable_peer_cache {
9466            let cache_dir = self.peer_cache_dir.unwrap_or_else(|| {
9467                dirs::home_dir()
9468                    .unwrap_or_else(|| std::path::PathBuf::from("."))
9469                    .join(".x0x")
9470                    .join("peers")
9471            });
9472            let config = ant_quic::BootstrapCacheConfig::builder()
9473                .cache_dir(cache_dir)
9474                .min_peers_to_save(1)
9475                .build();
9476            match ant_quic::BootstrapCache::open(config).await {
9477                Ok(cache) => {
9478                    let cache = std::sync::Arc::new(cache);
9479                    std::sync::Arc::clone(&cache).start_maintenance();
9480                    Some(cache)
9481                }
9482                Err(e) => {
9483                    tracing::warn!("Failed to open bootstrap cache: {e}");
9484                    None
9485                }
9486            }
9487        } else {
9488            None
9489        };
9490
9491        // Create network node if configured
9492        // Pass the machine keypair so ant-quic PeerId == MachineId (identity unification)
9493        let machine_keypair = {
9494            let pk = ant_quic::MlDsaPublicKey::from_bytes(
9495                identity.machine_keypair().public_key().as_bytes(),
9496            )
9497            .map_err(|e| {
9498                error::IdentityError::Storage(std::io::Error::other(format!(
9499                    "invalid machine public key: {e}"
9500                )))
9501            })?;
9502            let sk = ant_quic::MlDsaSecretKey::from_bytes(
9503                identity.machine_keypair().secret_key().as_bytes(),
9504            )
9505            .map_err(|e| {
9506                error::IdentityError::Storage(std::io::Error::other(format!(
9507                    "invalid machine secret key: {e}"
9508                )))
9509            })?;
9510            Some((pk, sk))
9511        };
9512
9513        // X0X-0070b: extract the relay policy + seed candidate list before
9514        // `self.network_config` is moved into `NetworkNode::new`. With no
9515        // network config the relay engine is built from `PeerRelayConfig::default()`,
9516        // which is `enabled = false` - the engine is then inert.
9517        let peer_relay_config = self
9518            .network_config
9519            .as_ref()
9520            .map(|cfg| cfg.peer_relay.clone())
9521            .unwrap_or_default();
9522        let mut parsed_relay_candidates = Vec::with_capacity(peer_relay_config.candidates.len());
9523        for hex_str in &peer_relay_config.candidates {
9524            let trimmed = hex_str.trim();
9525            let bytes = hex::decode(trimmed).map_err(|e| {
9526                error::IdentityError::Storage(std::io::Error::other(format!(
9527                    "invalid relay candidate hex {trimmed:?}: {e}"
9528                )))
9529            })?;
9530            let arr: [u8; 32] = bytes.try_into().map_err(|v: Vec<u8>| {
9531                error::IdentityError::Storage(std::io::Error::other(format!(
9532                    "relay candidate must be 32 bytes (got {} bytes)",
9533                    v.len()
9534                )))
9535            })?;
9536            parsed_relay_candidates.push(identity::AgentId(arr));
9537        }
9538        let peer_relay = std::sync::Arc::new(peer_relay::PeerRelay::with_policy(
9539            peer_relay_config.to_policy(),
9540        ));
9541        let relay_candidates =
9542            std::sync::Arc::new(tokio::sync::RwLock::new(parsed_relay_candidates));
9543
9544        // X0X-0070b: discovery cache is hoisted out of the `Agent` literal so
9545        // the relay-DM listener (spawned below) can hold an `Arc` clone of it
9546        // without going through `&self` - the listener is a sibling task to
9547        // the network receiver, not an `Agent` method.
9548        let identity_discovery_cache: std::sync::Arc<
9549            tokio::sync::RwLock<std::collections::HashMap<identity::AgentId, DiscoveredAgent>>,
9550        > = std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new()));
9551
9552        let network = if let Some(config) = self.network_config {
9553            let node = network::NetworkNode::new(config, bootstrap_cache.clone(), machine_keypair)
9554                .await
9555                .map_err(|e| {
9556                    error::IdentityError::Storage(std::io::Error::other(format!(
9557                        "network initialization failed: {}",
9558                        e
9559                    )))
9560                })?;
9561
9562            // Verify identity unification: ant-quic PeerId must equal MachineId
9563            debug_assert_eq!(
9564                node.peer_id().0,
9565                identity.machine_id().0,
9566                "ant-quic PeerId must equal MachineId after identity unification"
9567            );
9568
9569            Some(std::sync::Arc::new(node))
9570        } else {
9571            None
9572        };
9573
9574        // Load the local revocation set now (shared Arc) so the relay-DM
9575        // listener can enforce revocation on inbound relayed envelopes. The
9576        // same Arc is moved into the Agent below, so the listener and the
9577        // Agent's `/identity/revoke` writes observe one shared set.
9578        let revocation_set = std::sync::Arc::new(tokio::sync::RwLock::new(
9579            storage::load_revocation_set(self.identity_dir.as_deref()).await,
9580        ));
9581
9582        // Initialise contact store now (hoisted before the relay-DM
9583        // listener spawn so the listener can resolve the #193 contact
9584        // gate against the same shared store the Agent mutates).
9585        let contacts_path = self.contact_store_path.unwrap_or_else(|| {
9586            dirs::home_dir()
9587                .unwrap_or_else(|| std::path::PathBuf::from("."))
9588                .join(".x0x")
9589                .join("contacts.json")
9590        });
9591        let contact_store = std::sync::Arc::new(tokio::sync::RwLock::new(
9592            contacts::ContactStore::new(contacts_path),
9593        ));
9594
9595        // X0X-0070b: spawn the inbound RelayedDm listener so this Agent can
9596        // serve as either the final recipient (DeliverLocally) or the
9597        // intermediate relay (Forward) for peers that fell back to the
9598        // relay path. Only meaningful when a network is configured -
9599        // without one there is nothing to receive on and nothing to
9600        // forward to.
9601        if let Some(ref net) = network {
9602            spawn_relay_dm_listener(
9603                std::sync::Arc::clone(net),
9604                std::sync::Arc::clone(&peer_relay),
9605                std::sync::Arc::clone(&identity_discovery_cache),
9606                std::sync::Arc::clone(&revocation_set),
9607                std::sync::Arc::clone(&contact_store),
9608                identity.agent_id(),
9609            );
9610        }
9611
9612        // Create signing context from agent keypair for message authentication
9613        let signing_ctx = std::sync::Arc::new(gossip::SigningContext::from_keypair(
9614            identity.agent_keypair(),
9615        ));
9616
9617        // Create gossip runtime if network exists
9618        let gossip_runtime = if let Some(ref net) = network {
9619            let runtime = gossip::GossipRuntime::new(
9620                self.gossip_config.unwrap_or_default(),
9621                std::sync::Arc::clone(net),
9622                Some(signing_ctx),
9623            )
9624            .await
9625            .map_err(|e| {
9626                error::IdentityError::Storage(std::io::Error::other(format!(
9627                    "gossip runtime initialization failed: {}",
9628                    e
9629                )))
9630            })?;
9631            Some(std::sync::Arc::new(runtime))
9632        } else {
9633            None
9634        };
9635
9636        // Wrap bootstrap cache with gossip coordinator adapter (zero duplication).
9637        let gossip_cache_adapter = bootstrap_cache.as_ref().map(|cache| {
9638            saorsa_gossip_coordinator::GossipCacheAdapter::new(std::sync::Arc::clone(cache))
9639        });
9640
9641        // Initialize direct messaging infrastructure
9642        let direct_messaging = std::sync::Arc::new(direct::DirectMessaging::new());
9643
9644        // Create presence wrapper if network exists
9645        let presence = if let Some(ref net) = network {
9646            let peer_id = saorsa_gossip_transport::GossipTransport::local_peer_id(net.as_ref());
9647            let mut presence_config = presence::PresenceConfig::default();
9648            if let Some(secs) = self.presence_beacon_interval_secs {
9649                presence_config.beacon_interval_secs = secs;
9650            }
9651            if let Some(secs) = self.presence_event_poll_interval_secs {
9652                presence_config.event_poll_interval_secs = secs;
9653            }
9654            if let Some(secs) = self.presence_offline_timeout_secs {
9655                presence_config.adaptive_timeout_fallback_secs = secs;
9656            }
9657            // Sign presence beacons with the machine keypair that backs
9658            // `peer_id` (= net.local_peer_id()). The presence layer binds the
9659            // signer key to the claimed sender, so a beacon signed by any
9660            // other key would be rejected by every receiver (including this
9661            // node's own loopback path).
9662            let pw = presence::PresenceWrapper::new(
9663                peer_id,
9664                identity.machine_keypair().to_bytes(),
9665                std::sync::Arc::clone(net),
9666                presence_config,
9667                bootstrap_cache.clone(),
9668            )
9669            .map_err(|e| {
9670                error::IdentityError::Storage(std::io::Error::other(format!(
9671                    "presence initialization failed: {}",
9672                    e
9673                )))
9674            })?;
9675            let pw_arc = std::sync::Arc::new(pw);
9676            // Wire presence into gossip runtime for Bulk dispatch
9677            if let Some(ref rt) = gossip_runtime {
9678                rt.set_presence(std::sync::Arc::clone(&pw_arc));
9679            }
9680            Some(pw_arc)
9681        } else {
9682            None
9683        };
9684
9685        // Load the revocation set from disk so enforcement takes effect
9686        // immediately on restart, even before the next gossip heartbeat.
9687        Ok(Agent {
9688            identity: std::sync::Arc::new(identity),
9689            network,
9690            gossip_runtime,
9691            bootstrap_cache,
9692            gossip_cache_adapter,
9693            identity_discovery_cache,
9694            authenticated_machine_bindings: std::sync::Arc::new(tokio::sync::RwLock::new(
9695                dm_inbox::AuthenticatedMachineBindingCache::default(),
9696            )),
9697            machine_discovery_cache: std::sync::Arc::new(tokio::sync::RwLock::new(
9698                std::collections::HashMap::new(),
9699            )),
9700            user_discovery_cache: std::sync::Arc::new(tokio::sync::RwLock::new(
9701                std::collections::HashMap::new(),
9702            )),
9703            identity_listener_started: std::sync::atomic::AtomicBool::new(false),
9704            heartbeat_interval_secs: self
9705                .heartbeat_interval_secs
9706                .unwrap_or(IDENTITY_HEARTBEAT_INTERVAL_SECS),
9707            identity_ttl_secs: self.identity_ttl_secs.unwrap_or(IDENTITY_TTL_SECS),
9708            heartbeat_handle: tokio::sync::Mutex::new(None),
9709            discovery_cache_reaper_handle: tokio::sync::Mutex::new(None),
9710            rendezvous_advertised: std::sync::atomic::AtomicBool::new(false),
9711            contact_store,
9712            direct_messaging,
9713            network_event_listener_started: std::sync::atomic::AtomicBool::new(false),
9714            direct_listener_started: std::sync::atomic::AtomicBool::new(false),
9715            presence,
9716            user_identity_consented: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
9717            capability_store: std::sync::Arc::new(dm_capability::CapabilityStore::new()),
9718            dm_capabilities_tx: std::sync::Arc::new({
9719                let (tx, _rx) = tokio::sync::watch::channel(dm::DmCapabilities::pending());
9720                tx
9721            }),
9722            dm_inflight_acks: std::sync::Arc::new(dm::InFlightAcks::new()),
9723            recent_delivery_cache: std::sync::Arc::new(dm::RecentDeliveryCache::with_defaults()),
9724            capability_advert_service: tokio::sync::Mutex::new(None),
9725            dm_inbox_service: tokio::sync::Mutex::new(None),
9726            revocation_set,
9727            identity_dir: self.identity_dir,
9728            shutdown_token: tokio_util::sync::CancellationToken::new(),
9729            tracked_tasks: std::sync::Arc::new(std::sync::Mutex::new(TrackedTasks {
9730                closed: false,
9731                handles: Vec::new(),
9732            })),
9733            peer_relay,
9734            relay_candidates,
9735            stream_accept: std::sync::Arc::new(streams::StreamAccept::new(256)),
9736        })
9737    }
9738}
9739
9740/// Handle for interacting with a collaborative task list.
9741///
9742/// Provides a safe, concurrent interface to a TaskList backed by
9743/// CRDT synchronization. All operations are async and return Results.
9744///
9745/// # Example
9746///
9747/// ```ignore
9748/// let handle = agent.create_task_list("My List", "topic").await?;
9749/// let task_id = handle.add_task("Write docs".to_string(), "API docs".to_string()).await?;
9750/// handle.claim_task(task_id).await?;
9751/// handle.complete_task(task_id).await?;
9752/// ```
9753#[derive(Clone)]
9754pub struct TaskListHandle {
9755    sync: std::sync::Arc<crdt::TaskListSync>,
9756    agent_id: identity::AgentId,
9757    peer_id: saorsa_gossip_types::PeerId,
9758}
9759
9760impl std::fmt::Debug for TaskListHandle {
9761    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
9762        f.debug_struct("TaskListHandle")
9763            .field("agent_id", &self.agent_id)
9764            .field("peer_id", &self.peer_id)
9765            .finish_non_exhaustive()
9766    }
9767}
9768
9769impl TaskListHandle {
9770    /// Add a new task to the list.
9771    ///
9772    /// # Arguments
9773    ///
9774    /// * `title` - Task title
9775    /// * `description` - Task description
9776    ///
9777    /// # Returns
9778    ///
9779    /// The TaskId of the created task.
9780    ///
9781    /// # Errors
9782    ///
9783    /// Returns an error if the task cannot be added.
9784    pub async fn add_task(
9785        &self,
9786        title: String,
9787        description: String,
9788    ) -> error::Result<crdt::TaskId> {
9789        let (task_id, delta) = {
9790            let mut list = self.sync.write().await;
9791            let seq = list.next_seq();
9792            let task_id = crdt::TaskId::new(&title, &self.agent_id, seq);
9793            let metadata = crdt::TaskMetadata::new(title, description, 128, self.agent_id, seq);
9794            let task = crdt::TaskItem::new(task_id, metadata, self.peer_id);
9795            list.add_task(task.clone(), self.peer_id, seq)
9796                .map_err(|e| {
9797                    error::IdentityError::Storage(std::io::Error::other(format!(
9798                        "add_task failed: {}",
9799                        e
9800                    )))
9801                })?;
9802            let tag = (self.peer_id, seq);
9803            let delta = crdt::TaskListDelta::for_add(task_id, task, tag, list.current_version());
9804            (task_id, delta)
9805        };
9806        // Best-effort replication: local mutation succeeded regardless
9807        if let Err(e) = self.sync.publish_delta(self.peer_id, delta).await {
9808            tracing::warn!("failed to publish add_task delta: {}", e);
9809        }
9810        Ok(task_id)
9811    }
9812
9813    /// Claim a task in the list.
9814    ///
9815    /// # Arguments
9816    ///
9817    /// * `task_id` - ID of the task to claim
9818    ///
9819    /// # Errors
9820    ///
9821    /// Returns an error if the task cannot be claimed.
9822    pub async fn claim_task(&self, task_id: crdt::TaskId) -> error::Result<()> {
9823        let delta = {
9824            let mut list = self.sync.write().await;
9825            let seq = list.next_seq();
9826            list.claim_task(&task_id, self.agent_id, self.peer_id, seq)
9827                .map_err(|e| {
9828                    error::IdentityError::Storage(std::io::Error::other(format!(
9829                        "claim_task failed: {}",
9830                        e
9831                    )))
9832                })?;
9833            // Include full task so receivers can upsert if add hasn't arrived yet
9834            let full_task = list
9835                .get_task(&task_id)
9836                .ok_or_else(|| {
9837                    error::IdentityError::Storage(std::io::Error::other(
9838                        "task disappeared after claim",
9839                    ))
9840                })?
9841                .clone();
9842            crdt::TaskListDelta::for_state_change(task_id, full_task, list.current_version())
9843        };
9844        if let Err(e) = self.sync.publish_delta(self.peer_id, delta).await {
9845            tracing::warn!("failed to publish claim_task delta: {}", e);
9846        }
9847        Ok(())
9848    }
9849
9850    /// Complete a task in the list.
9851    ///
9852    /// # Arguments
9853    ///
9854    /// * `task_id` - ID of the task to complete
9855    ///
9856    /// # Errors
9857    ///
9858    /// Returns an error if the task cannot be completed.
9859    pub async fn complete_task(&self, task_id: crdt::TaskId) -> error::Result<()> {
9860        let delta = {
9861            let mut list = self.sync.write().await;
9862            let seq = list.next_seq();
9863            list.complete_task(&task_id, self.agent_id, self.peer_id, seq)
9864                .map_err(|e| {
9865                    error::IdentityError::Storage(std::io::Error::other(format!(
9866                        "complete_task failed: {}",
9867                        e
9868                    )))
9869                })?;
9870            let full_task = list
9871                .get_task(&task_id)
9872                .ok_or_else(|| {
9873                    error::IdentityError::Storage(std::io::Error::other(
9874                        "task disappeared after complete",
9875                    ))
9876                })?
9877                .clone();
9878            crdt::TaskListDelta::for_state_change(task_id, full_task, list.current_version())
9879        };
9880        if let Err(e) = self.sync.publish_delta(self.peer_id, delta).await {
9881            tracing::warn!("failed to publish complete_task delta: {}", e);
9882        }
9883        Ok(())
9884    }
9885
9886    /// List all tasks in their current order.
9887    ///
9888    /// # Returns
9889    ///
9890    /// A vector of `TaskSnapshot` representing the current state.
9891    ///
9892    /// # Errors
9893    ///
9894    /// Returns an error if the task list cannot be read.
9895    pub async fn list_tasks(&self) -> error::Result<Vec<TaskSnapshot>> {
9896        let list = self.sync.read().await;
9897        let tasks = list.tasks_ordered();
9898        Ok(tasks
9899            .into_iter()
9900            .map(|task| TaskSnapshot {
9901                id: *task.id(),
9902                title: task.title().to_string(),
9903                description: task.description().to_string(),
9904                state: task.current_state(),
9905                assignee: task.assignee().copied(),
9906                owner: None,
9907                priority: task.priority(),
9908            })
9909            .collect())
9910    }
9911
9912    /// Reorder tasks in the list.
9913    ///
9914    /// # Arguments
9915    ///
9916    /// * `task_ids` - New ordering of task IDs
9917    ///
9918    /// # Errors
9919    ///
9920    /// Returns an error if reordering fails.
9921    pub async fn reorder(&self, task_ids: Vec<crdt::TaskId>) -> error::Result<()> {
9922        let delta = {
9923            let mut list = self.sync.write().await;
9924            list.reorder(task_ids.clone(), self.peer_id).map_err(|e| {
9925                error::IdentityError::Storage(std::io::Error::other(format!(
9926                    "reorder failed: {}",
9927                    e
9928                )))
9929            })?;
9930            // Carry the post-reorder ordering register (value + clock) so the
9931            // change merges by causality on receivers.
9932            crdt::TaskListDelta::for_reorder(
9933                list.ordering_register().clone(),
9934                list.current_version(),
9935            )
9936        };
9937        if let Err(e) = self.sync.publish_delta(self.peer_id, delta).await {
9938            tracing::warn!("failed to publish reorder delta: {}", e);
9939        }
9940        Ok(())
9941    }
9942}
9943
9944// ---------------------------------------------------------------------------
9945// KvStore API
9946// ---------------------------------------------------------------------------
9947
9948impl Agent {
9949    /// Create a new key-value store.
9950    ///
9951    /// The store is automatically synchronized to all peers subscribed
9952    /// to the same `topic` via gossip delta propagation.
9953    ///
9954    /// # Errors
9955    ///
9956    /// Returns an error if the gossip runtime is not initialized.
9957    pub async fn create_kv_store(&self, name: &str, topic: &str) -> error::Result<KvStoreHandle> {
9958        let runtime = self.gossip_runtime.as_ref().ok_or_else(|| {
9959            error::IdentityError::Storage(std::io::Error::other(
9960                "gossip runtime not initialized - configure agent with network first",
9961            ))
9962        })?;
9963
9964        let peer_id = runtime.peer_id();
9965        let store_id = kv::KvStoreId::from_content(name, &self.agent_id());
9966        let store = kv::KvStore::new(
9967            store_id,
9968            name.to_string(),
9969            self.agent_id(),
9970            kv::AccessPolicy::Signed,
9971        );
9972
9973        let sync = kv::KvStoreSync::new(
9974            store,
9975            std::sync::Arc::clone(runtime.pubsub()),
9976            topic.to_string(),
9977            peer_id,
9978        )
9979        .map_err(|e| {
9980            error::IdentityError::Storage(std::io::Error::other(format!(
9981                "kv store sync creation failed: {e}",
9982            )))
9983        })?;
9984
9985        let sync = std::sync::Arc::new(sync);
9986        sync.start_with_spawner(|fut| self.spawn_tracked(fut))
9987            .await
9988            .map_err(|e| {
9989                error::IdentityError::Storage(std::io::Error::other(format!(
9990                    "kv store sync start failed: {e}",
9991                )))
9992            })?;
9993
9994        Ok(KvStoreHandle {
9995            sync,
9996            agent_id: self.agent_id(),
9997            peer_id,
9998        })
9999    }
10000
10001    /// Join an existing key-value store by topic.
10002    ///
10003    /// Creates an empty store that will be populated via delta sync
10004    /// from peers already sharing the topic. The access policy will
10005    /// be learned from the first full delta received from the owner.
10006    ///
10007    /// # Errors
10008    ///
10009    /// Returns an error if the gossip runtime is not initialized.
10010    pub async fn join_kv_store(&self, topic: &str) -> error::Result<KvStoreHandle> {
10011        let runtime = self.gossip_runtime.as_ref().ok_or_else(|| {
10012            error::IdentityError::Storage(std::io::Error::other(
10013                "gossip runtime not initialized - configure agent with network first",
10014            ))
10015        })?;
10016
10017        let peer_id = runtime.peer_id();
10018        let store_id = kv::KvStoreId::from_content(topic, &self.agent_id());
10019        // Use Encrypted as the most permissive default — the actual policy
10020        // will be set when the first delta from the owner arrives.
10021        let store = kv::KvStore::new(
10022            store_id,
10023            String::new(),
10024            self.agent_id(),
10025            kv::AccessPolicy::Encrypted {
10026                group_id: Vec::new(),
10027            },
10028        );
10029
10030        let sync = kv::KvStoreSync::new(
10031            store,
10032            std::sync::Arc::clone(runtime.pubsub()),
10033            topic.to_string(),
10034            peer_id,
10035        )
10036        .map_err(|e| {
10037            error::IdentityError::Storage(std::io::Error::other(format!(
10038                "kv store sync creation failed: {e}",
10039            )))
10040        })?;
10041
10042        let sync = std::sync::Arc::new(sync);
10043        sync.start_with_spawner(|fut| self.spawn_tracked(fut))
10044            .await
10045            .map_err(|e| {
10046                error::IdentityError::Storage(std::io::Error::other(format!(
10047                    "kv store sync start failed: {e}",
10048                )))
10049            })?;
10050
10051        Ok(KvStoreHandle {
10052            sync,
10053            agent_id: self.agent_id(),
10054            peer_id,
10055        })
10056    }
10057}
10058
10059/// Handle for interacting with a replicated key-value store.
10060///
10061/// Provides async methods for putting, getting, and removing entries.
10062/// Changes are automatically replicated to peers via gossip.
10063#[derive(Clone)]
10064pub struct KvStoreHandle {
10065    sync: std::sync::Arc<kv::KvStoreSync>,
10066    agent_id: identity::AgentId,
10067    peer_id: saorsa_gossip_types::PeerId,
10068}
10069
10070impl std::fmt::Debug for KvStoreHandle {
10071    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10072        f.debug_struct("KvStoreHandle")
10073            .field("agent_id", &self.agent_id)
10074            .field("peer_id", &self.peer_id)
10075            .finish_non_exhaustive()
10076    }
10077}
10078
10079impl KvStoreHandle {
10080    /// Return this handle's gossip peer id.
10081    #[must_use]
10082    pub fn peer_id(&self) -> saorsa_gossip_types::PeerId {
10083        self.peer_id
10084    }
10085
10086    /// Put a key-value pair into the store.
10087    ///
10088    /// If the key already exists, the value is updated. Changes are
10089    /// automatically replicated to peers via gossip.
10090    ///
10091    /// # Errors
10092    ///
10093    /// Returns an error if the value exceeds the maximum inline size (64 KB).
10094    pub async fn put(
10095        &self,
10096        key: String,
10097        value: Vec<u8>,
10098        content_type: String,
10099    ) -> error::Result<()> {
10100        let _ = self.put_with_delta(key, value, content_type).await?;
10101        Ok(())
10102    }
10103
10104    /// Put a key-value pair and return the CRDT delta that was published.
10105    ///
10106    /// This is used by API-layer delivery fallbacks that need to carry the
10107    /// exact same mutation over a side channel when pub/sub is congested.
10108    ///
10109    /// # Errors
10110    ///
10111    /// Returns an error if the value exceeds the maximum inline size (64 KB).
10112    pub async fn put_with_delta(
10113        &self,
10114        key: String,
10115        value: Vec<u8>,
10116        content_type: String,
10117    ) -> error::Result<kv::KvStoreDelta> {
10118        let delta = {
10119            let mut store = self.sync.write().await;
10120            store
10121                .put(
10122                    key.clone(),
10123                    value.clone(),
10124                    content_type.clone(),
10125                    self.peer_id,
10126                )
10127                .map_err(|e| {
10128                    error::IdentityError::Storage(std::io::Error::other(format!(
10129                        "kv put failed: {e}",
10130                    )))
10131                })?;
10132            let entry = store.get(&key).cloned();
10133            let version = store.current_version();
10134            match entry {
10135                Some(e) => {
10136                    kv::KvStoreDelta::for_put(key, e, (self.peer_id, store.next_seq()), version)
10137                }
10138                None => {
10139                    return Err(error::IdentityError::Storage(std::io::Error::other(
10140                        "kv put succeeded but entry was not readable",
10141                    )));
10142                }
10143            }
10144        };
10145        if let Err(e) = self.sync.publish_delta(self.peer_id, delta.clone()).await {
10146            tracing::warn!("failed to publish kv put delta: {e}");
10147        }
10148        Ok(delta)
10149    }
10150
10151    /// Get a value by key.
10152    ///
10153    /// Returns `None` if the key does not exist or has been removed.
10154    ///
10155    /// # Errors
10156    ///
10157    /// Returns an error if the store cannot be read.
10158    pub async fn get(&self, key: &str) -> error::Result<Option<KvEntrySnapshot>> {
10159        let store = self.sync.read().await;
10160        Ok(store.get(key).map(|e| KvEntrySnapshot {
10161            key: e.key.clone(),
10162            value: e.value.clone(),
10163            content_hash: hex::encode(e.content_hash),
10164            content_type: e.content_type.clone(),
10165            metadata: e.metadata.clone(),
10166            created_at: e.created_at,
10167            updated_at: e.updated_at,
10168        }))
10169    }
10170
10171    /// Remove a key from the store.
10172    ///
10173    /// # Errors
10174    ///
10175    /// Returns an error if the key does not exist.
10176    pub async fn remove(&self, key: &str) -> error::Result<()> {
10177        let _ = self.remove_with_delta(key).await?;
10178        Ok(())
10179    }
10180
10181    /// Remove a key and return the CRDT delta that was published.
10182    ///
10183    /// # Errors
10184    ///
10185    /// Returns an error if the key does not exist.
10186    pub async fn remove_with_delta(&self, key: &str) -> error::Result<kv::KvStoreDelta> {
10187        let delta = {
10188            let mut store = self.sync.write().await;
10189            store.remove(key).map_err(|e| {
10190                error::IdentityError::Storage(std::io::Error::other(format!(
10191                    "kv remove failed: {e}",
10192                )))
10193            })?;
10194            let mut d = kv::KvStoreDelta::new(store.current_version());
10195            d.removed
10196                .insert(key.to_string(), std::collections::HashSet::new());
10197            d
10198        };
10199        if let Err(e) = self.sync.publish_delta(self.peer_id, delta.clone()).await {
10200            tracing::warn!("failed to publish kv remove delta: {e}");
10201        }
10202        Ok(delta)
10203    }
10204
10205    /// Apply a verified remote delta received through a non-pubsub channel.
10206    ///
10207    /// # Errors
10208    ///
10209    /// Returns an error if the delta fails to merge into the local store.
10210    pub async fn apply_remote_delta(
10211        &self,
10212        peer_id: saorsa_gossip_types::PeerId,
10213        delta: &kv::KvStoreDelta,
10214        writer: Option<identity::AgentId>,
10215    ) -> error::Result<()> {
10216        let mut store = self.sync.write().await;
10217        store
10218            .merge_delta(delta, peer_id, writer.as_ref())
10219            .map_err(|e| {
10220                error::IdentityError::Storage(std::io::Error::other(format!(
10221                    "kv direct delta merge failed: {e}",
10222                )))
10223            })
10224    }
10225
10226    /// List all active keys in the store.
10227    ///
10228    /// # Errors
10229    ///
10230    /// Returns an error if the store cannot be read.
10231    pub async fn keys(&self) -> error::Result<Vec<KvEntrySnapshot>> {
10232        let store = self.sync.read().await;
10233        Ok(store
10234            .active_entries()
10235            .into_iter()
10236            .map(|e| KvEntrySnapshot {
10237                key: e.key.clone(),
10238                value: e.value.clone(),
10239                content_hash: hex::encode(e.content_hash),
10240                content_type: e.content_type.clone(),
10241                metadata: e.metadata.clone(),
10242                created_at: e.created_at,
10243                updated_at: e.updated_at,
10244            })
10245            .collect())
10246    }
10247
10248    /// Get the store name.
10249    ///
10250    /// # Errors
10251    ///
10252    /// Returns an error if the store cannot be read.
10253    pub async fn name(&self) -> error::Result<String> {
10254        let store = self.sync.read().await;
10255        Ok(store.name().to_string())
10256    }
10257}
10258
10259/// Read-only snapshot of a KvStore entry.
10260#[derive(Debug, Clone, serde::Serialize)]
10261pub struct KvEntrySnapshot {
10262    /// The key.
10263    pub key: String,
10264    /// The value bytes.
10265    pub value: Vec<u8>,
10266    /// BLAKE3 hash of the value (hex-encoded).
10267    pub content_hash: String,
10268    /// Content type (MIME).
10269    pub content_type: String,
10270    /// User metadata.
10271    pub metadata: std::collections::HashMap<String, String>,
10272    /// Unix milliseconds when created.
10273    pub created_at: u64,
10274    /// Unix milliseconds when last updated.
10275    pub updated_at: u64,
10276}
10277
10278/// Read-only snapshot of a task's current state.
10279///
10280/// This is returned by `TaskListHandle::list_tasks()` and hides CRDT
10281/// internals, providing a clean API surface.
10282#[derive(Debug, Clone)]
10283pub struct TaskSnapshot {
10284    /// Unique task identifier.
10285    pub id: crdt::TaskId,
10286    /// Task title.
10287    pub title: String,
10288    /// Task description.
10289    pub description: String,
10290    /// Current checkbox state (Empty, Claimed, or Done).
10291    pub state: crdt::CheckboxState,
10292    /// Agent assigned to this task (if any).
10293    pub assignee: Option<identity::AgentId>,
10294    /// Human owner of the agent that created this task (if known).
10295    pub owner: Option<identity::UserId>,
10296    /// Task priority (0-255, higher = more important).
10297    pub priority: u8,
10298}
10299
10300/// The x0x protocol version.
10301pub const VERSION: &str = env!("CARGO_PKG_VERSION");
10302
10303/// The name. Three bytes. A palindrome. A philosophy.
10304pub const NAME: &str = "x0x";
10305
10306/// X0X-0070b: drain inbound [`peer_relay::RelayedDm`] envelopes from
10307/// the [`network::NetworkNode`] and dispatch via the
10308/// [`peer_relay::PeerRelay`] engine. Spawned once per network-configured
10309/// [`Agent`] from [`AgentBuilder::build`].
10310///
10311/// # Per-arm behavior
10312///
10313/// * [`peer_relay::RelayDisposition::DeliverLocally`] - synthesises the
10314///   *original* sender's `MachineId`-as-`PeerId` from
10315///   `relayed.inner.sender_machine_id` and re-injects the inner
10316///   [`dm::DmEnvelope`] onto the canonical direct-DM channel via
10317///   [`network::NetworkNode::inject_inbound_direct`]. The downstream
10318///   direct-DM listener cannot distinguish a relayed packet from a
10319///   direct one.
10320/// * [`peer_relay::RelayDisposition::Forward`] - resolves `dst_agent_id`
10321///   to a `MachineId` via the identity-discovery cache, re-encodes the
10322///   inner envelope with postcard, and sends it on the standard
10323///   direct-DM stream ([`network::DIRECT_MESSAGE_STREAM_TYPE`]). The
10324///   wire prefix stamps *our* (the relay's) `AgentId` so the receiving
10325///   Agent's binding check at its direct listener (wire `sender_agent_id`
10326///   must match the QUIC peer's `MachineId`) passes - trust on the
10327///   inner envelope still flows from its embedded ML-DSA-65 signature.
10328///   If the destination is not in the discovery cache the forward
10329///   drops with a `warn!`.
10330/// * [`peer_relay::RelayDisposition::Refuse`] - `debug!` log only.
10331///   [`peer_relay::PeerRelay::disposition_for`] already incremented the
10332///   appropriate `relay_refused_*` counter as a side effect.
10333///
10334/// # Revocation gate
10335///
10336/// Before delivering locally or forwarding, the listener checks the
10337/// inner envelope's *origin* `sender_agent_id` against `revocation_set`.
10338/// This is required because the local-delivery path re-injects the inner
10339/// envelope onto the direct-DM channel
10340/// ([`network::NetworkNode::inject_inbound_direct`]), which does **not**
10341/// run the `dm_inbox` gossip-path revocation gate (#130). Without this
10342/// check a revoked agent that cannot direct-connect (e.g. NAT-blocked)
10343/// could still reach the recipient via a relay, bypassing revocation.
10344/// A revoked origin is dropped and counted as `relay_dropped_revoked`.
10345fn spawn_relay_dm_listener(
10346    network: std::sync::Arc<network::NetworkNode>,
10347    peer_relay: std::sync::Arc<peer_relay::PeerRelay>,
10348    identity_discovery_cache: std::sync::Arc<
10349        tokio::sync::RwLock<std::collections::HashMap<identity::AgentId, DiscoveredAgent>>,
10350    >,
10351    revocation_set: std::sync::Arc<tokio::sync::RwLock<revocation::RevocationSet>>,
10352    contact_store: std::sync::Arc<tokio::sync::RwLock<contacts::ContactStore>>,
10353    local_agent_id: identity::AgentId,
10354) {
10355    tokio::spawn(async move {
10356        tracing::info!(target: "x0x::relay", stage = "listener", "relay-DM listener started");
10357        loop {
10358            let Some((relay_peer_id, _relay_sender_agent_id, relayed)) =
10359                network.recv_relayed_dm().await
10360            else {
10361                tracing::warn!(
10362                    target: "x0x::relay",
10363                    stage = "listener",
10364                    "network.recv_relayed_dm channel closed - listener exiting"
10365                );
10366                break;
10367            };
10368            let now_ms = dm::now_unix_ms();
10369            // #193 contact gate: resolve the relay header's authenticated
10370            // sender against the contact store before classifying. The
10371            // gate itself is enforced inside `disposition_for`; this async
10372            // resolution belongs here (the contact store is an async
10373            // RwLock, and `disposition_for` is sync).
10374            //
10375            // Trust semantics: only *explicitly-trusted* contacts
10376            // (Known/Trusted) pass the gate — a merely-discovered
10377            // `Unknown` entry (auto-created by `register_announced_machine`
10378            // → `add_machine`, lib.rs ~576) does NOT, so the gate means
10379            // "my contacts", not "anyone I've seen". A `Blocked` entry is
10380            // refused unconditionally (see RelayRefusal::Blocked).
10381            //
10382            // TOCTOU: membership is snapshotted per message here and passed
10383            // as bools, so a contact removed/blocked mid-flight can have one
10384            // forward slip through before the next relay frame re-snapshots.
10385            // Acceptable — the inner DmEnvelope is end-to-end encrypted and
10386            // origin-signed, and the per-frame snapshot bounds the window to
10387            // a single hop.
10388            let sender_agent_id = identity::AgentId(relayed.header.sender_agent_id);
10389            let (is_sender_contact, is_sender_blocked) = {
10390                let store = contact_store.read().await;
10391                match store.get(&sender_agent_id) {
10392                    Some(c) => (
10393                        matches!(
10394                            c.trust_level,
10395                            contacts::TrustLevel::Known | contacts::TrustLevel::Trusted
10396                        ),
10397                        c.trust_level == contacts::TrustLevel::Blocked,
10398                    ),
10399                    None => (false, false),
10400                }
10401            };
10402            let disposition = peer_relay.disposition_for(
10403                &relayed,
10404                &local_agent_id,
10405                now_ms,
10406                is_sender_contact,
10407                is_sender_blocked,
10408            );
10409
10410            // Revocation gate (PR #177 review, fix 1): the inner envelope's
10411            // ML-DSA-65 origin signature is the trust anchor, but a revoked
10412            // origin must be dropped even when it arrives via a relay. The
10413            // deliver/forward paths do not traverse the dm_inbox revocation
10414            // gate, so enforce it here for both, before any delivery.
10415            if matches!(
10416                disposition,
10417                peer_relay::RelayDisposition::DeliverLocally
10418                    | peer_relay::RelayDisposition::Forward { .. }
10419            ) {
10420                let origin = identity::AgentId(relayed.inner.sender_agent_id);
10421                let revoked = { revocation_set.read().await.is_agent_revoked(&origin) };
10422                if revoked {
10423                    peer_relay.record_relay_dropped_revoked();
10424                    tracing::info!(
10425                        target: "x0x::relay",
10426                        stage = "revoked_drop",
10427                        relay_peer = ?relay_peer_id,
10428                        origin = %hex::encode(origin.as_bytes()),
10429                        "relayed DM dropped: origin agent is revoked"
10430                    );
10431                    continue;
10432                }
10433            }
10434
10435            match disposition {
10436                peer_relay::RelayDisposition::DeliverLocally => {
10437                    let sender_machine_id = relayed.inner.sender_machine_id;
10438                    let sender_peer_id = ant_quic::PeerId(sender_machine_id);
10439                    let inner_wire = match postcard::to_allocvec(&relayed.inner) {
10440                        Ok(b) => b,
10441                        Err(e) => {
10442                            tracing::warn!(
10443                                target: "x0x::relay",
10444                                stage = "deliver_local",
10445                                relay_peer = ?relay_peer_id,
10446                                error = %e,
10447                                "failed to re-encode inner envelope for local delivery"
10448                            );
10449                            continue;
10450                        }
10451                    };
10452                    // Wire shape mirrors the direct-DM listener's parse:
10453                    //   [sender_agent_id: 32][postcard(DmEnvelope)]
10454                    let mut payload = Vec::with_capacity(32 + inner_wire.len());
10455                    payload.extend_from_slice(&relayed.inner.sender_agent_id);
10456                    payload.extend_from_slice(&inner_wire);
10457                    if let Err(e) = network
10458                        .inject_inbound_direct(sender_peer_id, bytes::Bytes::from(payload))
10459                        .await
10460                    {
10461                        tracing::warn!(
10462                            target: "x0x::relay",
10463                            stage = "deliver_local",
10464                            relay_peer = ?relay_peer_id,
10465                            error = %e,
10466                            "DeliverLocally inject onto direct channel failed"
10467                        );
10468                    }
10469                }
10470                peer_relay::RelayDisposition::Forward { dst_agent_id } => {
10471                    let dst = identity::AgentId(dst_agent_id);
10472                    let dst_machine_id = {
10473                        let cache = identity_discovery_cache.read().await;
10474                        cache.get(&dst).map(|d| d.machine_id)
10475                    };
10476                    let Some(dst_machine_id) = dst_machine_id else {
10477                        tracing::warn!(
10478                            target: "x0x::relay",
10479                            stage = "forward",
10480                            dst = %hex::encode(dst.as_bytes()),
10481                            "Forward dropped: dst not in identity-discovery cache"
10482                        );
10483                        continue;
10484                    };
10485                    let inner_wire = match postcard::to_allocvec(&relayed.inner) {
10486                        Ok(b) => b,
10487                        Err(e) => {
10488                            tracing::warn!(
10489                                target: "x0x::relay",
10490                                stage = "forward",
10491                                dst = %hex::encode(dst.as_bytes()),
10492                                error = %e,
10493                                "failed to re-encode inner envelope for forward"
10494                            );
10495                            continue;
10496                        }
10497                    };
10498                    let forward_bytes = u64::try_from(inner_wire.len()).unwrap_or(u64::MAX);
10499                    let reservation = match peer_relay
10500                        .reserve_forward(relayed.header.sender_agent_id, forward_bytes)
10501                    {
10502                        Ok(reservation) => reservation,
10503                        Err(reason) => {
10504                            tracing::debug!(
10505                                target: "x0x::relay",
10506                                stage = "refuse",
10507                                relay_peer = ?relay_peer_id,
10508                                reason = ?reason,
10509                                "RelayedDm forward refused by quota admission"
10510                            );
10511                            continue;
10512                        }
10513                    };
10514                    let dst_peer_id = ant_quic::PeerId(dst_machine_id.0);
10515                    match network
10516                        .send_direct_typed(
10517                            &dst_peer_id,
10518                            local_agent_id.as_bytes(),
10519                            network::DIRECT_MESSAGE_STREAM_TYPE,
10520                            &inner_wire,
10521                        )
10522                        .await
10523                    {
10524                        Ok(()) => reservation.commit(),
10525                        Err(e) => {
10526                            tracing::warn!(
10527                                target: "x0x::relay",
10528                                stage = "forward",
10529                                dst = %hex::encode(dst.as_bytes()),
10530                                error = %e,
10531                                "Forward send_direct_typed failed"
10532                            );
10533                        }
10534                    }
10535                }
10536                peer_relay::RelayDisposition::Refuse(reason) => {
10537                    tracing::debug!(
10538                        target: "x0x::relay",
10539                        stage = "refuse",
10540                        relay_peer = ?relay_peer_id,
10541                        reason = ?reason,
10542                        "RelayedDm refused"
10543                    );
10544                }
10545            }
10546        }
10547    });
10548}
10549
10550#[cfg(test)]
10551mod tests {
10552    use super::*;
10553
10554    fn sa(s: &str) -> std::net::SocketAddr {
10555        s.parse().expect("valid SocketAddr literal in test")
10556    }
10557
10558    #[test]
10559    fn discovery_rebroadcast_is_one_shot_per_announcement_key() {
10560        let now = std::time::Instant::now();
10561        let mut state = std::collections::HashMap::new();
10562
10563        assert!(should_rebroadcast_discovery_once(
10564            &mut state,
10565            (7_u8, 42_u64),
10566            now
10567        ));
10568        assert!(!should_rebroadcast_discovery_once(
10569            &mut state,
10570            (7_u8, 42_u64),
10571            now + std::time::Duration::from_secs(20),
10572        ));
10573        assert!(should_rebroadcast_discovery_once(
10574            &mut state,
10575            (7_u8, 43_u64),
10576            now + std::time::Duration::from_secs(20),
10577        ));
10578    }
10579
10580    #[test]
10581    fn discovery_ttl_uses_local_last_seen_not_sender_timestamp() {
10582        let cutoff = 900;
10583
10584        assert!(discovery_record_is_live(100, 1_000, cutoff));
10585        assert!(discovery_record_is_live(10_000, cutoff, cutoff));
10586        assert!(!discovery_record_is_live(10_000, cutoff - 1, cutoff));
10587    }
10588
10589    #[test]
10590    fn is_publicly_advertisable_rejects_lan_and_special_scopes() {
10591        // v4 non-global scopes
10592        assert!(
10593            !is_publicly_advertisable(sa("127.0.0.1:5483")),
10594            "loopback v4"
10595        );
10596        assert!(!is_publicly_advertisable(sa("10.1.2.3:5483")), "rfc1918 /8");
10597        assert!(
10598            !is_publicly_advertisable(sa("172.20.0.5:5483")),
10599            "rfc1918 /12"
10600        );
10601        assert!(
10602            !is_publicly_advertisable(sa("192.168.1.5:5483")),
10603            "rfc1918 /16"
10604        );
10605        assert!(
10606            !is_publicly_advertisable(sa("169.254.1.1:5483")),
10607            "link-local v4"
10608        );
10609        assert!(
10610            !is_publicly_advertisable(sa("100.64.1.1:5483")),
10611            "CGNAT (unreachable outside carrier)"
10612        );
10613        assert!(
10614            !is_publicly_advertisable(sa("0.0.0.0:5483")),
10615            "unspecified v4"
10616        );
10617
10618        // v6 non-global scopes
10619        assert!(!is_publicly_advertisable(sa("[::1]:5483")), "loopback v6");
10620        assert!(
10621            !is_publicly_advertisable(sa("[fe80::1]:5483")),
10622            "link-local v6"
10623        );
10624        assert!(!is_publicly_advertisable(sa("[fd00::1]:5483")), "ULA v6");
10625
10626        // port 0 never advertisable regardless of ip scope
10627        assert!(
10628            !is_publicly_advertisable(sa("1.2.3.4:0")),
10629            "port 0 on global v4"
10630        );
10631
10632        // Globally-routable positives
10633        assert!(is_publicly_advertisable(sa("1.2.3.4:5483")), "global v4");
10634        assert!(
10635            is_publicly_advertisable(sa("[2001:db8::1]:5483")),
10636            "global v6 (documentation doc but is_globally_routable permits)",
10637        );
10638        assert!(
10639            is_publicly_advertisable(sa("8.8.8.8:9000")),
10640            "global v4 on non-default port",
10641        );
10642
10643        // Reserved documentation ranges are correctly rejected by
10644        // is_globally_routable even though they are not RFC1918.
10645        assert!(
10646            !is_publicly_advertisable(sa("192.0.2.1:5483")),
10647            "TEST-NET-1 documentation range"
10648        );
10649        assert!(
10650            !is_publicly_advertisable(sa("203.0.113.10:5483")),
10651            "TEST-NET-3 documentation range"
10652        );
10653    }
10654
10655    #[test]
10656    fn public_address_filter_drops_global_discovery_unsafe_candidates() {
10657        let filtered = filter_publicly_advertisable_addrs(vec![
10658            sa("127.0.0.1:5483"),
10659            sa("10.1.2.3:5483"),
10660            sa("100.64.1.1:5483"),
10661            sa("169.254.1.1:5483"),
10662            sa("1.2.3.4:0"),
10663            sa("[::1]:5483"),
10664            sa("[fd00::1]:5483"),
10665            sa("8.8.8.8:5483"),
10666            sa("[2001:db8::1]:5483"),
10667        ]);
10668
10669        assert_eq!(filtered, vec![sa("8.8.8.8:5483"), sa("[2001:db8::1]:5483")]);
10670    }
10671
10672    #[test]
10673    fn local_discovery_filter_keeps_same_partition_candidates() {
10674        let filtered = filter_discovery_announcement_addrs(
10675            vec![
10676                sa("127.0.0.1:5483"),
10677                sa("10.1.2.3:5483"),
10678                sa("100.64.1.1:5483"),
10679                sa("169.254.1.1:5483"),
10680                sa("1.2.3.4:0"),
10681                sa("[::1]:5483"),
10682                sa("[fd00::1]:5483"),
10683                sa("8.8.8.8:5483"),
10684            ],
10685            true,
10686        );
10687
10688        assert_eq!(
10689            filtered,
10690            vec![
10691                sa("127.0.0.1:5483"),
10692                sa("10.1.2.3:5483"),
10693                sa("100.64.1.1:5483"),
10694                sa("[::1]:5483"),
10695                sa("[fd00::1]:5483"),
10696                sa("8.8.8.8:5483"),
10697            ],
10698        );
10699    }
10700
10701    #[test]
10702    fn local_discovery_scope_tracks_bootstrap_partition() {
10703        let mut config = network::NetworkConfig {
10704            bootstrap_nodes: Vec::new(),
10705            ..network::NetworkConfig::default()
10706        };
10707        assert!(allow_local_discovery_addresses(&config));
10708
10709        config.bootstrap_nodes = vec![sa("127.0.0.1:5483"), sa("192.168.1.10:5483")];
10710        assert!(allow_local_discovery_addresses(&config));
10711
10712        config.bootstrap_nodes = vec![sa("127.0.0.1:5483"), sa("8.8.8.8:5483")];
10713        assert!(!allow_local_discovery_addresses(&config));
10714    }
10715
10716    #[test]
10717    fn local_direct_probe_addrs_prioritizes_same_lan_ipv4() {
10718        let local = [std::net::Ipv4Addr::new(192, 168, 1, 212)];
10719        let ranked = local_direct_probe_addrs_with_local_v4s(
10720            &[
10721                sa("100.118.167.101:27749"),
10722                sa("192.168.0.1:27749"),
10723                sa("192.168.1.108:27749"),
10724                sa("[2a0d:3344:32d:2e10::1]:27749"),
10725                sa("[fd7a:115c:a1e0::b01:a7ac]:27749"),
10726            ],
10727            &local,
10728        );
10729
10730        assert_eq!(
10731            ranked,
10732            vec![
10733                sa("192.168.1.108:27749"),
10734                sa("192.168.0.1:27749"),
10735                sa("100.118.167.101:27749"),
10736            ],
10737        );
10738    }
10739
10740    #[tokio::test]
10741    async fn announcement_builders_filter_global_discovery_addresses() {
10742        let dir = tempfile::tempdir().expect("tmpdir");
10743        let agent = Agent::builder()
10744            .with_machine_key(dir.path().join("machine.key"))
10745            .with_agent_key_path(dir.path().join("agent.key"))
10746            .build()
10747            .await
10748            .expect("agent");
10749        let addresses = vec![
10750            sa("192.168.1.5:5483"),
10751            sa("100.64.1.1:5483"),
10752            sa("1.2.3.4:0"),
10753            sa("8.8.8.8:5483"),
10754            sa("[fd00::1]:5483"),
10755            sa("[2001:db8::1]:5483"),
10756        ];
10757        let expected = vec![sa("8.8.8.8:5483"), sa("[2001:db8::1]:5483")];
10758
10759        let identity_announcement = agent
10760            .build_identity_announcement_with_addrs(IdentityAnnouncementBuildOptions {
10761                include_user_identity: false,
10762                human_consent: false,
10763                addresses: addresses.clone(),
10764                assist_snapshot: None,
10765                reachable_via: Vec::new(),
10766                relay_candidates: Vec::new(),
10767                allow_local_scope: false,
10768            })
10769            .expect("identity announcement");
10770        assert_eq!(identity_announcement.addresses, expected);
10771        identity_announcement
10772            .verify()
10773            .expect("filtered identity announcement verifies");
10774
10775        let machine_announcement = build_machine_announcement_for_identity(
10776            &agent.identity,
10777            addresses,
10778            1,
10779            None,
10780            Vec::new(),
10781            Vec::new(),
10782            false,
10783        )
10784        .expect("machine announcement");
10785        assert_eq!(machine_announcement.addresses, expected);
10786        machine_announcement
10787            .verify()
10788            .expect("filtered machine announcement verifies");
10789    }
10790
10791    #[test]
10792    fn presence_parse_addr_hints_drops_private_scopes() {
10793        // Older peers ship a mix of scopes. parse_addr_hints should return only
10794        // globally-advertisable entries so our dial loop never burns budget on
10795        // unreachable candidates.
10796        let hints = vec![
10797            "127.0.0.1:5483".to_string(),
10798            "10.200.0.1:5483".to_string(),
10799            "[fd00::1]:5483".to_string(),
10800            "1.2.3.4:5483".to_string(),
10801            "[2001:db8::1]:5483".to_string(),
10802            "not-an-address".to_string(),
10803        ];
10804        let parsed = presence::parse_addr_hints(&hints);
10805        let got: Vec<String> = parsed.iter().map(|a| a.to_string()).collect();
10806        assert_eq!(
10807            got,
10808            vec!["1.2.3.4:5483".to_string(), "[2001:db8::1]:5483".to_string()],
10809            "only globally-advertisable addresses survive inbound parsing"
10810        );
10811    }
10812
10813    #[test]
10814    fn name_is_palindrome() {
10815        let name = NAME;
10816        let reversed: String = name.chars().rev().collect();
10817        assert_eq!(name, reversed, "x0x must be a palindrome");
10818    }
10819
10820    #[test]
10821    fn name_is_three_bytes() {
10822        assert_eq!(NAME.len(), 3, "x0x must be exactly three bytes");
10823    }
10824
10825    #[test]
10826    fn name_is_ai_native() {
10827        // No uppercase, no spaces, no special chars that conflict
10828        // with shell, YAML, Markdown, or URL encoding
10829        assert!(NAME.chars().all(|c| c.is_ascii_alphanumeric()));
10830    }
10831
10832    #[test]
10833    fn raw_quic_receive_ack_failure_falls_back_when_gossip_available() {
10834        let err = error::NetworkError::ConnectionFailed(
10835            "send_with_receive_ack failed: Connection closed: Superseded".to_string(),
10836        );
10837        assert!(
10838            !Agent::raw_quic_error_should_stop_fallback(&err, true),
10839            "transient raw ACK lifecycle churn should not suppress gossip fallback"
10840        );
10841        assert!(
10842            Agent::raw_quic_error_should_stop_fallback(&err, false),
10843            "without a gossip capability, the raw ACK failure remains terminal"
10844        );
10845    }
10846
10847    #[test]
10848    fn raw_quic_receive_backpressure_falls_back_when_gossip_available() {
10849        let err = error::NetworkError::RemoteReceiveBackpressured(
10850            "send_with_receive_ack failed: Remote receive pipeline rejected payload: Backpressured"
10851                .to_string(),
10852        );
10853        assert!(
10854            !Agent::raw_quic_error_should_stop_fallback(&err, true),
10855            "receiver congestion should allow gossip fallback"
10856        );
10857        assert!(
10858            Agent::raw_quic_error_should_stop_fallback(&err, false),
10859            "without a gossip capability, receiver congestion remains terminal"
10860        );
10861        assert!(matches!(
10862            Agent::map_raw_quic_dm_error(err),
10863            dm::DmError::ReceiverBackpressured { .. }
10864        ));
10865    }
10866
10867    #[test]
10868    fn raw_quic_payload_errors_still_stop_fallback() {
10869        let err = error::NetworkError::PayloadTooLarge { size: 2, max: 1 };
10870        assert!(Agent::raw_quic_error_should_stop_fallback(&err, true));
10871    }
10872
10873    #[tokio::test]
10874    async fn unusable_capability_advert_falls_back_to_contact_card() {
10875        let dir = tempfile::tempdir().expect("tmpdir");
10876        let agent = Agent::builder()
10877            .with_machine_key(dir.path().join("machine.key"))
10878            .with_agent_key_path(dir.path().join("agent.key"))
10879            .with_contact_store_path(dir.path().join("contacts.json"))
10880            .build()
10881            .await
10882            .expect("agent");
10883        let target = identity::AgentId([7_u8; 32]);
10884        let target_machine = identity::MachineId([9_u8; 32]);
10885
10886        agent.capability_store().insert(
10887            target,
10888            target_machine,
10889            dm::DmCapabilities::pending(),
10890            dm_capability::now_unix_ms(),
10891        );
10892        agent.contacts().write().await.add(contacts::Contact {
10893            agent_id: target,
10894            trust_level: contacts::TrustLevel::Trusted,
10895            label: None,
10896            added_at: 0,
10897            last_seen: None,
10898            identity_type: contacts::IdentityType::Known,
10899            machines: Vec::new(),
10900            dm_capabilities: Some(dm::DmCapabilities::v1_gossip_ready(vec![42_u8; 1184])),
10901        });
10902
10903        let err = agent
10904            .send_direct_with_config(
10905                &target,
10906                b"contact-card-capability".to_vec(),
10907                dm::DmSendConfig {
10908                    require_gossip: true,
10909                    ..dm::DmSendConfig::default()
10910                },
10911            )
10912            .await
10913            .expect_err("contact-card capability should be used before gossip runtime fails");
10914
10915        assert!(
10916            matches!(err, dm::DmError::LocalGossipUnavailable(_)),
10917            "unexpected error: {err:?}"
10918        );
10919    }
10920
10921    #[tokio::test]
10922    async fn self_dm_uses_loopback_and_delivers_to_subscribers() {
10923        let dir = tempfile::tempdir().expect("tmpdir");
10924        let agent = Agent::builder()
10925            .with_machine_key(dir.path().join("machine.key"))
10926            .with_agent_key_path(dir.path().join("agent.key"))
10927            .with_contact_store_path(dir.path().join("contacts.json"))
10928            .build()
10929            .await
10930            .expect("agent");
10931        let mut rx = agent.subscribe_direct();
10932        let payload = b"loopback-self-dm".to_vec();
10933
10934        let receipt = agent
10935            .send_direct_with_config(
10936                &agent.agent_id(),
10937                payload.clone(),
10938                dm::DmSendConfig::default(),
10939            )
10940            .await
10941            .expect("self-DM should use loopback path");
10942
10943        assert_eq!(receipt.path, dm::DmPath::Loopback);
10944        let msg = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
10945            .await
10946            .expect("self-DM should be delivered promptly")
10947            .expect("subscriber should remain open");
10948        assert_eq!(msg.sender, agent.agent_id());
10949        assert_eq!(msg.machine_id, agent.machine_id());
10950        assert_eq!(msg.payload, payload);
10951        assert!(msg.verified);
10952        assert_eq!(msg.trust_decision, Some(trust::TrustDecision::Accept));
10953
10954        let diagnostics = agent.direct_messaging().diagnostics_snapshot();
10955        assert_eq!(diagnostics.stats.outgoing_send_succeeded, 1);
10956        assert_eq!(diagnostics.stats.outgoing_path_loopback, 1);
10957        assert_eq!(diagnostics.stats.incoming_envelopes_total, 1);
10958        assert_eq!(diagnostics.stats.incoming_delivered_to_subscribe, 1);
10959    }
10960
10961    fn loopback_network_config() -> network::NetworkConfig {
10962        network::NetworkConfig {
10963            bind_addr: Some("127.0.0.1:0".parse().expect("loopback addr")),
10964            bootstrap_nodes: Vec::new(),
10965            ..network::NetworkConfig::default()
10966        }
10967    }
10968
10969    fn normalize_loopback_addr(addr: std::net::SocketAddr) -> std::net::SocketAddr {
10970        if addr.ip().is_unspecified() {
10971            std::net::SocketAddr::new(
10972                std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
10973                addr.port(),
10974            )
10975        } else {
10976            addr
10977        }
10978    }
10979
10980    #[tokio::test]
10981    async fn shutdown_aborts_identity_heartbeat_task() {
10982        struct DropFlag(std::sync::Arc<std::sync::atomic::AtomicBool>);
10983
10984        impl Drop for DropFlag {
10985            fn drop(&mut self) {
10986                self.0.store(true, std::sync::atomic::Ordering::Release);
10987            }
10988        }
10989
10990        let dir = tempfile::tempdir().expect("tmpdir");
10991        let agent = Agent::builder()
10992            .with_machine_key(dir.path().join("machine.key"))
10993            .with_agent_key_path(dir.path().join("agent.key"))
10994            .with_contact_store_path(dir.path().join("contacts.json"))
10995            .build()
10996            .await
10997            .expect("agent");
10998
10999        let dropped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
11000        let dropped_for_task = std::sync::Arc::clone(&dropped);
11001        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
11002        let handle = tokio::spawn(async move {
11003            let _drop_flag = DropFlag(dropped_for_task);
11004            let _ = started_tx.send(());
11005            std::future::pending::<()>().await;
11006        });
11007        started_rx.await.expect("heartbeat task started");
11008
11009        *agent.heartbeat_handle.lock().await = Some(handle);
11010        assert!(agent.heartbeat_handle.lock().await.is_some());
11011
11012        agent.shutdown().await;
11013        assert!(agent.heartbeat_handle.lock().await.is_none());
11014        assert!(dropped.load(std::sync::atomic::Ordering::Acquire));
11015    }
11016
11017    /// Issue #126 / WS1.5: the delta-merge + state-request subscription loops
11018    /// spawned by `create_kv_store` / `create_task_list` must be routed through
11019    /// `spawn_tracked` so `Agent::shutdown()` drains and aborts them. Previously
11020    /// they detached via bare `tokio::spawn` and only ended when the gossip
11021    /// runtime later dropped its topic sender.
11022    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
11023    async fn shutdown_drains_crdt_kv_sync_tasks() {
11024        let dir = tempfile::tempdir().expect("tmpdir");
11025        let agent = Agent::builder()
11026            .with_machine_key(dir.path().join("machine.key"))
11027            .with_agent_key_path(dir.path().join("agent.key"))
11028            .with_contact_store_path(dir.path().join("contacts.json"))
11029            .with_peer_cache_disabled()
11030            .with_network_config(loopback_network_config())
11031            .build()
11032            .await
11033            .expect("agent");
11034
11035        // Build() constructs the gossip runtime, so creating a store + a list
11036        // works without join_network. Each creation spawns the delta-merge and
11037        // state-request subscription loops (plus a bounded bootstrap requester
11038        // for an empty store/list).
11039        let baseline = agent
11040            .tracked_tasks
11041            .lock()
11042            .expect("tracked_tasks")
11043            .handles
11044            .len();
11045
11046        agent
11047            .create_kv_store("ws15-store", "ws15-store-topic")
11048            .await
11049            .expect("create kv store");
11050        agent
11051            .create_task_list("ws15-list", "ws15-list-topic")
11052            .await
11053            .expect("create task list");
11054
11055        // The loops must now be registered with spawn_tracked. With the old bare
11056        // tokio::spawn this count would NOT have moved.
11057        let (registered, abort_handles): (usize, Vec<tokio::task::AbortHandle>) = {
11058            let guard = agent.tracked_tasks.lock().expect("tracked_tasks");
11059            let aborts = guard.handles.iter().map(|h| h.abort_handle()).collect();
11060            (guard.handles.len(), aborts)
11061        };
11062        assert!(
11063            registered > baseline,
11064            "CRDT/KV sync loops must register with spawn_tracked \
11065             (registry {registered}, baseline {baseline})"
11066        );
11067
11068        agent.shutdown().await;
11069
11070        // shutdown() closes the registry and drains it (handles taken, grace-
11071        // awaited, stragglers aborted).
11072        let after = agent.tracked_tasks.lock().expect("tracked_tasks");
11073        assert!(
11074            after.closed,
11075            "tracked-task registry must be closed after shutdown"
11076        );
11077        assert!(
11078            after.handles.is_empty(),
11079            "tracked-task registry must be drained after shutdown ({} left)",
11080            after.handles.len()
11081        );
11082        drop(after);
11083
11084        // Every formerly-tracked sync loop terminated.
11085        for handle in &abort_handles {
11086            assert!(
11087                handle.is_finished(),
11088                "a CRDT/KV sync task did not terminate after shutdown"
11089            );
11090        }
11091    }
11092
11093    // ========================================================================
11094    // #124 / WS1.3 tranche 4 — shutdown ordering invariants.
11095    //
11096    // `shutdown()` and `begin_shutdown()` carry ordering guarantees that are
11097    // themselves correctness properties (a still-running join_network must not
11098    // leak a task past shutdown; a second shutdown must never panic; a token-
11099    // respecting task must be graced, not force-aborted). These pin them as
11100    // fast, daemon-free unit tests. The WS/SSE close-on-shutdown notification
11101    // path is already covered at integration tier by
11102    // `daemon_api_shutdown_with_sse_client` and is not re-duplicated here
11103    // (avoiding a src/server/mod.rs touch during Eng B's #125 window).
11104    // ========================================================================
11105
11106    /// `begin_shutdown()` is the synchronous shutdown prefix: it cancels the
11107    /// token AND closes the tracked-task registry, without draining. After it
11108    /// returns, `spawn_tracked` must refuse (next test) and a subsequent
11109    /// `shutdown()` must still complete cleanly. Pins that the two halves of
11110    /// the shutdown signal — token + closed-flag — move together.
11111    #[tokio::test]
11112    async fn begin_shutdown_closes_registry_and_cancels_token() {
11113        let dir = tempfile::tempdir().expect("tmpdir");
11114        let agent = Agent::builder()
11115            .with_machine_key(dir.path().join("machine.key"))
11116            .with_agent_key_path(dir.path().join("agent.key"))
11117            .with_contact_store_path(dir.path().join("contacts.json"))
11118            .build()
11119            .await
11120            .expect("agent");
11121
11122        assert!(
11123            !agent.shutdown_token.is_cancelled(),
11124            "token must not be cancelled before shutdown begins"
11125        );
11126        assert!(
11127            !agent.tracked_tasks.lock().expect("tracked_tasks").closed,
11128            "registry must be open before shutdown begins"
11129        );
11130
11131        agent.begin_shutdown();
11132
11133        assert!(
11134            agent.shutdown_token.is_cancelled(),
11135            "begin_shutdown must cancel the shutdown token"
11136        );
11137        assert!(
11138            agent.tracked_tasks.lock().expect("tracked_tasks").closed,
11139            "begin_shutdown must close the tracked-task registry"
11140        );
11141
11142        // begin_shutdown does NOT drain — that is shutdown()'s job. The handles
11143        // must still be present (untouched) until shutdown() runs.
11144        // (None were spawned here, so the registry is empty-but-closed.)
11145    }
11146
11147    /// Once `begin_shutdown()` has closed the registry, `spawn_tracked` must be
11148    /// a no-op: the handle is NOT pushed. This is the invariant that defeats a
11149    /// racing `join_network` from leaking heartbeat/reaper/presence tasks past
11150    /// the subsequent `shutdown()` (the registry drains empty because nothing
11151    // new can enter after close).
11152    #[tokio::test]
11153    async fn spawn_tracked_refuses_after_begin_shutdown() {
11154        let dir = tempfile::tempdir().expect("tmpdir");
11155        let agent = Agent::builder()
11156            .with_machine_key(dir.path().join("machine.key"))
11157            .with_agent_key_path(dir.path().join("agent.key"))
11158            .with_contact_store_path(dir.path().join("contacts.json"))
11159            .build()
11160            .await
11161            .expect("agent");
11162
11163        // Before begin_shutdown: spawn_tracked accepts.
11164        agent.spawn_tracked(async {});
11165        let before = agent
11166            .tracked_tasks
11167            .lock()
11168            .expect("tracked_tasks")
11169            .handles
11170            .len();
11171        assert_eq!(
11172            before, 1,
11173            "spawn_tracked must register a task before begin_shutdown"
11174        );
11175
11176        // Close the registry, then attempt another spawn.
11177        agent.begin_shutdown();
11178        agent.spawn_tracked(async {});
11179
11180        {
11181            let after = agent.tracked_tasks.lock().expect("tracked_tasks");
11182            assert_eq!(
11183                after.handles.len(),
11184                before,
11185                "spawn_tracked must be a no-op (handle NOT pushed) after begin_shutdown \
11186                 closed the registry — a racing join_network would otherwise leak a task"
11187            );
11188            assert!(after.closed, "registry must remain closed");
11189        }
11190
11191        // Clean up the one task we did register so it does not outlive the test.
11192        agent.shutdown().await;
11193    }
11194
11195    /// `shutdown()` is documented idempotent: `cancel()` is idempotent, the
11196    /// registry drains empty on a second call, and the `Option<JoinHandle>`
11197    /// `stop_*` helpers use `Option::take` (a second take is None). Pin that a
11198    /// double shutdown never panics and leaves the registry drained.
11199    #[tokio::test]
11200    async fn shutdown_is_idempotent_and_never_panics_on_second_call() {
11201        let dir = tempfile::tempdir().expect("tmpdir");
11202        let agent = Agent::builder()
11203            .with_machine_key(dir.path().join("machine.key"))
11204            .with_agent_key_path(dir.path().join("agent.key"))
11205            .with_contact_store_path(dir.path().join("contacts.json"))
11206            .build()
11207            .await
11208            .expect("agent");
11209
11210        agent.shutdown().await;
11211        // The invariant: a second shutdown must be a safe no-op, never panic
11212        // (an embedder re-entering shutdown on an error path must not crash).
11213        agent.shutdown().await;
11214
11215        let registry = agent.tracked_tasks.lock().expect("tracked_tasks");
11216        assert!(
11217            registry.closed,
11218            "registry must remain closed after a double shutdown"
11219        );
11220        assert!(
11221            registry.handles.is_empty(),
11222            "registry must be drained after a double shutdown"
11223        );
11224        assert!(
11225            agent.shutdown_token.is_cancelled(),
11226            "token must remain cancelled after a double shutdown"
11227        );
11228    }
11229
11230    /// Shutdown ordering: the registry grace-awaits tracked tasks BEFORE any
11231    /// straggler is force-aborted. So a task that RESPECTS the token (awaits
11232    /// `shutdown_token.cancelled()`) must run to its natural end and set its
11233    /// completed-flag — it is graced, not aborted. This is the positive arm of
11234    /// the grace/abort policy.
11235    ///
11236    /// NOTE: we deliberately do NOT assert what the task observes for
11237    /// `is_cancelled()` at first-poll. On a multi-thread runtime `tokio::spawn`
11238    /// may schedule the task onto another worker that polls it BEFORE
11239    /// `shutdown()` runs on this thread, so first-poll-observes-cancelled is a
11240    /// race, not an invariant. The robust, non-racy claim is grace-before-abort:
11241    /// the well-behaved task completes naturally (sets its flag) rather than
11242    /// being force-aborted by the 3s-straggler path.
11243    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
11244    async fn shutdown_graces_a_token_respecting_tracked_task() {
11245        let dir = tempfile::tempdir().expect("tmpdir");
11246        let agent = Agent::builder()
11247            .with_machine_key(dir.path().join("machine.key"))
11248            .with_agent_key_path(dir.path().join("agent.key"))
11249            .with_contact_store_path(dir.path().join("contacts.json"))
11250            .build()
11251            .await
11252            .expect("agent");
11253
11254        // A token-respecting task: it records that it reached its natural end
11255        // (graceful completion, never aborted). If the grace-await path broke
11256        // (e.g. the task were force-aborted), this flag would stay false.
11257        let completed_gracefully = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
11258        let token = agent.shutdown_token.clone();
11259        let cg = std::sync::Arc::clone(&completed_gracefully);
11260        agent.spawn_tracked(async move {
11261            // Graceful: stop as soon as the token fires, without being aborted.
11262            token.cancelled().await;
11263            cg.store(true, std::sync::atomic::Ordering::SeqCst);
11264        });
11265
11266        agent.shutdown().await;
11267
11268        assert!(
11269            completed_gracefully.load(std::sync::atomic::Ordering::SeqCst),
11270            "token-respecting task must complete GRACEFULLY (set its flag before \
11271             returning) — proves the grace-await precedes any force-abort"
11272        );
11273    }
11274
11275    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
11276    async fn connected_peer_clears_stale_lifecycle_block_before_raw_send() {
11277        let dir = tempfile::tempdir().expect("tmpdir");
11278        let alice = Agent::builder()
11279            .with_machine_key(dir.path().join("alice-machine.key"))
11280            .with_agent_key_path(dir.path().join("alice-agent.key"))
11281            .with_contact_store_path(dir.path().join("alice-contacts.json"))
11282            .with_peer_cache_disabled()
11283            .with_network_config(loopback_network_config())
11284            .build()
11285            .await
11286            .expect("alice");
11287        let bob = Agent::builder()
11288            .with_machine_key(dir.path().join("bob-machine.key"))
11289            .with_agent_key_path(dir.path().join("bob-agent.key"))
11290            .with_contact_store_path(dir.path().join("bob-contacts.json"))
11291            .with_peer_cache_disabled()
11292            .with_network_config(loopback_network_config())
11293            .build()
11294            .await
11295            .expect("bob");
11296
11297        let bob_network = bob.network().expect("bob network");
11298        let bob_addr = normalize_loopback_addr(bob_network.bound_addr().await.expect("bob bound"));
11299        let alice_network = alice.network().expect("alice network");
11300        let connected_peer = alice_network
11301            .connect_addr(bob_addr)
11302            .await
11303            .expect("alice connects to bob");
11304        assert_eq!(connected_peer.0, bob.machine_id().0);
11305
11306        let bob_peer = ant_quic::PeerId(bob.machine_id().0);
11307        let connected_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
11308        while tokio::time::Instant::now() < connected_deadline {
11309            if alice_network.is_connected(&bob_peer).await {
11310                break;
11311            }
11312            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
11313        }
11314        assert!(alice_network.is_connected(&bob_peer).await);
11315
11316        alice
11317            .direct_messaging()
11318            .mark_connected(bob.agent_id(), bob.machine_id())
11319            .await;
11320        alice.direct_messaging().record_lifecycle_blocked(
11321            bob.machine_id(),
11322            Some(1),
11323            "closed: stale test block",
11324        );
11325        assert!(alice
11326            .direct_messaging()
11327            .lifecycle_block_reason(&bob.machine_id())
11328            .is_some());
11329
11330        let receipt = alice
11331            .send_direct_with_config(
11332                &bob.agent_id(),
11333                b"stale-block-clear".to_vec(),
11334                dm::DmSendConfig {
11335                    prefer_raw_quic_if_connected: true,
11336                    ..dm::DmSendConfig::default()
11337                },
11338            )
11339            .await
11340            .expect("raw send should ignore and clear stale lifecycle block");
11341        assert_eq!(receipt.path, dm::DmPath::RawQuic);
11342        assert!(alice
11343            .direct_messaging()
11344            .lifecycle_block_reason(&bob.machine_id())
11345            .is_none());
11346    }
11347
11348    #[tokio::test]
11349    async fn agent_peer_relay_defaults_to_disabled_when_unconfigured() {
11350        // Why: X0X-0070b's relay engine is opt-in. An Agent built without
11351        // any `[peer_relay]` TOML section must come up with the engine
11352        // disabled and an empty candidate list - anything else would
11353        // engage the fallback for operators who never asked for it.
11354        let dir = tempfile::tempdir().expect("tmpdir");
11355        let agent = Agent::builder()
11356            .with_machine_key(dir.path().join("machine.key"))
11357            .with_agent_key_path(dir.path().join("agent.key"))
11358            .with_contact_store_path(dir.path().join("contacts.json"))
11359            .build()
11360            .await
11361            .expect("agent");
11362        assert!(
11363            !agent.peer_relay().policy().enabled,
11364            "default Agent must have the relay engine disabled"
11365        );
11366        assert!(
11367            agent.relay_candidates().await.is_empty(),
11368            "default Agent must have no relay candidates"
11369        );
11370    }
11371
11372    #[tokio::test]
11373    async fn agent_peer_relay_honors_configured_policy_and_candidates() {
11374        // Why: a TOML-configured `[peer_relay]` block must flow through to
11375        // a live `PeerRelay` instance on the Agent - enabled flag, fail
11376        // trigger, and the seeded candidate list. This is the single
11377        // integration seam where an operator's config first takes effect.
11378        let dir = tempfile::tempdir().expect("tmpdir");
11379        let candidate_a = [0xAA_u8; 32];
11380        let candidate_b = [0xBB_u8; 32];
11381        let mut net_cfg = loopback_network_config();
11382        net_cfg.peer_relay = network::PeerRelayConfig {
11383            enabled: true,
11384            require_contact_to_relay: false,
11385            fail_threshold: 7,
11386            fail_window_ms: 90_000,
11387            candidates: vec![hex::encode(candidate_a), hex::encode(candidate_b)],
11388            ..Default::default()
11389        };
11390        let agent = Agent::builder()
11391            .with_machine_key(dir.path().join("machine.key"))
11392            .with_agent_key_path(dir.path().join("agent.key"))
11393            .with_contact_store_path(dir.path().join("contacts.json"))
11394            .with_peer_cache_disabled()
11395            .with_network_config(net_cfg)
11396            .build()
11397            .await
11398            .expect("agent");
11399
11400        let policy = agent.peer_relay().policy();
11401        assert!(policy.enabled, "configured `enabled = true` must propagate");
11402        assert_eq!(policy.fail_threshold, 7);
11403        assert_eq!(policy.fail_window, std::time::Duration::from_millis(90_000));
11404
11405        let candidates = agent.relay_candidates().await;
11406        assert_eq!(candidates.len(), 2, "both TOML candidates seeded");
11407        assert!(candidates.iter().any(|c| c.0 == candidate_a));
11408        assert!(candidates.iter().any(|c| c.0 == candidate_b));
11409    }
11410
11411    #[tokio::test]
11412    async fn send_direct_failure_records_failure_on_peer_relay() {
11413        // Why: the bookkeeping hook in `send_direct_with_config` is the
11414        // single feed into `PeerRelay::needs_relay`. If a transport
11415        // failure does not increment the per-peer failure count, the
11416        // engine can never decide to engage the relay - the fallback is
11417        // dead-on-arrival. With no network configured every raw-QUIC
11418        // attempt fails fast, which gives a deterministic test signal.
11419        let dir = tempfile::tempdir().expect("tmpdir");
11420        let agent = Agent::builder()
11421            .with_machine_key(dir.path().join("machine.key"))
11422            .with_agent_key_path(dir.path().join("agent.key"))
11423            .with_contact_store_path(dir.path().join("contacts.json"))
11424            .build()
11425            .await
11426            .expect("agent");
11427        let unreachable = identity::AgentId([0x42; 32]);
11428
11429        let result = agent
11430            .send_direct_with_config(
11431                &unreachable,
11432                b"x0x-0070b-bookkeeping".to_vec(),
11433                dm::DmSendConfig::default(),
11434            )
11435            .await;
11436        assert!(
11437            result.is_err(),
11438            "no network configured - direct send must fail"
11439        );
11440        assert_eq!(
11441            agent.peer_relay().tracked_peer_count(),
11442            1,
11443            "failure must have produced a per-peer relay-engine entry"
11444        );
11445    }
11446
11447    #[tokio::test]
11448    async fn send_direct_self_loopback_does_not_disturb_peer_relay() {
11449        // Why: the loopback short-circuit at the top of
11450        // `send_direct_with_config` returns before the bookkeeping arm.
11451        // A self-DM must not count as a "direct success" against the
11452        // sender's own AgentId - that would conflate local delivery
11453        // with the cross-peer path that actually exercises NAT.
11454        let dir = tempfile::tempdir().expect("tmpdir");
11455        let agent = Agent::builder()
11456            .with_machine_key(dir.path().join("machine.key"))
11457            .with_agent_key_path(dir.path().join("agent.key"))
11458            .with_contact_store_path(dir.path().join("contacts.json"))
11459            .build()
11460            .await
11461            .expect("agent");
11462        let _receipt = agent
11463            .send_direct_with_config(
11464                &agent.agent_id(),
11465                b"loopback".to_vec(),
11466                dm::DmSendConfig::default(),
11467            )
11468            .await
11469            .expect("loopback self-DM");
11470        assert_eq!(
11471            agent.peer_relay().tracked_peer_count(),
11472            0,
11473            "loopback path must not register with the relay engine"
11474        );
11475    }
11476
11477    #[tokio::test]
11478    async fn try_relay_fallback_returns_no_candidate_when_list_empty() {
11479        // Why: with an enabled policy but zero seeded candidates,
11480        // `select_relay` returns `None` and the helper must short-circuit
11481        // to `NoRelayCandidate`. Falling through to envelope construction
11482        // would burn KEM/AEAD cycles for nothing.
11483        let dir = tempfile::tempdir().expect("tmpdir");
11484        let mut net_cfg = loopback_network_config();
11485        net_cfg.peer_relay = network::PeerRelayConfig {
11486            enabled: true,
11487            require_contact_to_relay: false,
11488            fail_threshold: 3,
11489            fail_window_ms: 60_000,
11490            candidates: Vec::new(),
11491            ..Default::default()
11492        };
11493        let agent = Agent::builder()
11494            .with_machine_key(dir.path().join("machine.key"))
11495            .with_agent_key_path(dir.path().join("agent.key"))
11496            .with_contact_store_path(dir.path().join("contacts.json"))
11497            .with_peer_cache_disabled()
11498            .with_network_config(net_cfg)
11499            .build()
11500            .await
11501            .expect("agent");
11502        let to = identity::AgentId([0xCD; 32]);
11503
11504        let err = agent
11505            .try_relay_fallback(&to, b"payload".to_vec(), &[0u8; 32])
11506            .await
11507            .expect_err("empty candidate list must short-circuit");
11508        assert!(
11509            matches!(err, dm::DmError::NoRelayCandidate),
11510            "expected NoRelayCandidate, got {err:?}"
11511        );
11512    }
11513
11514    #[tokio::test]
11515    async fn try_relay_fallback_returns_no_candidate_when_machine_id_uncached() {
11516        // Why: a seeded candidate AgentId is useless if its MachineId is
11517        // not in the identity-discovery cache - we need it to address
11518        // the QUIC peer at the wire layer. The helper must treat this
11519        // as "no usable candidate" and let the caller surface the
11520        // original direct error.
11521        let dir = tempfile::tempdir().expect("tmpdir");
11522        let candidate_hex = hex::encode([0xEE_u8; 32]);
11523        let mut net_cfg = loopback_network_config();
11524        net_cfg.peer_relay = network::PeerRelayConfig {
11525            enabled: true,
11526            require_contact_to_relay: false,
11527            fail_threshold: 3,
11528            fail_window_ms: 60_000,
11529            candidates: vec![candidate_hex],
11530            ..Default::default()
11531        };
11532        let agent = Agent::builder()
11533            .with_machine_key(dir.path().join("machine.key"))
11534            .with_agent_key_path(dir.path().join("agent.key"))
11535            .with_contact_store_path(dir.path().join("contacts.json"))
11536            .with_peer_cache_disabled()
11537            .with_network_config(net_cfg)
11538            .build()
11539            .await
11540            .expect("agent");
11541        let to = identity::AgentId([0xCD; 32]);
11542
11543        let err = agent
11544            .try_relay_fallback(&to, b"payload".to_vec(), &[0u8; 32])
11545            .await
11546            .expect_err("uncached candidate must short-circuit");
11547        assert!(
11548            matches!(err, dm::DmError::NoRelayCandidate),
11549            "expected NoRelayCandidate, got {err:?}"
11550        );
11551    }
11552
11553    #[tokio::test]
11554    async fn send_direct_below_threshold_does_not_attempt_relay() {
11555        // Why: the engine must wait until the sliding-window failure
11556        // count reaches `fail_threshold` before engaging the relay.
11557        // A single transport failure must surface the original direct
11558        // error AND must not register `relay_sent` on the engine
11559        // (proves no fallback attempt fired underneath).
11560        let dir = tempfile::tempdir().expect("tmpdir");
11561        let mut net_cfg = loopback_network_config();
11562        net_cfg.peer_relay = network::PeerRelayConfig {
11563            enabled: true,
11564            require_contact_to_relay: false,
11565            fail_threshold: 5,
11566            fail_window_ms: 60_000,
11567            candidates: vec![hex::encode([0xEE_u8; 32])],
11568            ..Default::default()
11569        };
11570        let agent = Agent::builder()
11571            .with_machine_key(dir.path().join("machine.key"))
11572            .with_agent_key_path(dir.path().join("agent.key"))
11573            .with_contact_store_path(dir.path().join("contacts.json"))
11574            .with_peer_cache_disabled()
11575            .with_network_config(net_cfg)
11576            .build()
11577            .await
11578            .expect("agent");
11579        let to = identity::AgentId([0xCD; 32]);
11580
11581        let result = agent
11582            .send_direct_with_config(&to, b"first-attempt".to_vec(), dm::DmSendConfig::default())
11583            .await;
11584        assert!(result.is_err(), "no usable transport - direct send fails");
11585        let snap = agent.peer_relay().stats().snapshot();
11586        assert_eq!(
11587            snap.relay_sent, 0,
11588            "below threshold must not engage the relay path"
11589        );
11590    }
11591
11592    #[tokio::test]
11593    async fn send_direct_above_threshold_without_candidates_surfaces_direct_err() {
11594        // Why: the contract for relay-fallback failure is that the
11595        // caller sees the ORIGINAL direct error, never the relay-side
11596        // bookkeeping error. Pre-load the engine past threshold, then
11597        // send to a peer with no usable candidate - the result must
11598        // be a direct-transport error, not `NoRelayCandidate`.
11599        let dir = tempfile::tempdir().expect("tmpdir");
11600        let mut net_cfg = loopback_network_config();
11601        net_cfg.peer_relay = network::PeerRelayConfig {
11602            enabled: true,
11603            require_contact_to_relay: false,
11604            fail_threshold: 3,
11605            fail_window_ms: 60_000,
11606            candidates: Vec::new(),
11607            ..Default::default()
11608        };
11609        let agent = Agent::builder()
11610            .with_machine_key(dir.path().join("machine.key"))
11611            .with_agent_key_path(dir.path().join("agent.key"))
11612            .with_contact_store_path(dir.path().join("contacts.json"))
11613            .with_peer_cache_disabled()
11614            .with_network_config(net_cfg)
11615            .build()
11616            .await
11617            .expect("agent");
11618        let to = identity::AgentId([0xCD; 32]);
11619
11620        // Pre-load the engine past threshold without waiting for live
11621        // transport retries.
11622        for _ in 0..agent.peer_relay().policy().fail_threshold {
11623            agent.peer_relay().record_direct_failure(&to);
11624        }
11625        assert!(
11626            agent.peer_relay().needs_relay(&to),
11627            "engine must say the peer now needs a relay"
11628        );
11629
11630        let err = agent
11631            .send_direct_with_config(&to, b"x0x-0070b".to_vec(), dm::DmSendConfig::default())
11632            .await
11633            .expect_err("send must still fail when the relay path has no candidates");
11634        assert!(
11635            !matches!(err, dm::DmError::NoRelayCandidate),
11636            "relay-side errors must not leak - original direct error must surface, got {err:?}"
11637        );
11638    }
11639
11640    #[tokio::test]
11641    async fn send_direct_disabled_policy_does_not_engage_relay_seed() {
11642        // Why: with the default disabled policy the relay-seed clone
11643        // must not happen - the happy path pays nothing. Even with the
11644        // peer manually driven past `fail_threshold` (which would never
11645        // happen under a disabled policy in practice, but is a
11646        // belt-and-braces check), the engine's `needs_relay` stays
11647        // `false` and no fallback fires.
11648        let dir = tempfile::tempdir().expect("tmpdir");
11649        let agent = Agent::builder()
11650            .with_machine_key(dir.path().join("machine.key"))
11651            .with_agent_key_path(dir.path().join("agent.key"))
11652            .with_contact_store_path(dir.path().join("contacts.json"))
11653            .build()
11654            .await
11655            .expect("agent");
11656        let to = identity::AgentId([0xCD; 32]);
11657        for _ in 0..10 {
11658            agent.peer_relay().record_direct_failure(&to);
11659        }
11660        assert!(
11661            !agent.peer_relay().needs_relay(&to),
11662            "disabled policy must never trigger needs_relay"
11663        );
11664
11665        let err = agent
11666            .send_direct_with_config(&to, b"x0x-0070b".to_vec(), dm::DmSendConfig::default())
11667            .await
11668            .expect_err("no network - direct send must fail");
11669        assert!(
11670            !matches!(err, dm::DmError::NoRelayCandidate),
11671            "disabled policy must not surface any relay-side error, got {err:?}"
11672        );
11673        assert_eq!(
11674            agent.peer_relay().stats().snapshot().relay_sent,
11675            0,
11676            "disabled policy must not advance relay_sent"
11677        );
11678    }
11679
11680    #[tokio::test]
11681    async fn relay_dm_listener_refuses_bad_signature_and_ticks_counter() {
11682        // Why: the receiver-side surface for X0X-0070b - a single
11683        // demux arm + a listener loop that runs `disposition_for` and
11684        // dispatches the three arms - depends on the listener actually
11685        // draining `network.recv_relayed_dm`. If the spawn ever drops
11686        // (forgotten in `AgentBuilder::build`, or the wire/channel
11687        // shape drifts) the engine silently stops refusing replays
11688        // and forwards, and the bug presents as "relay path is
11689        // configured but nothing happens." Pin the end-to-end
11690        // channel + loop liveness with the cheapest fully-typed
11691        // signal we have: a `RelayedDm` with an obviously-broken
11692        // header signature flows through the channel, the listener
11693        // consumes it, `disposition_for` returns
11694        // `Refuse(BadSignature)`, and the
11695        // `relay_refused_bad_signature` counter ticks. Catches any
11696        // future channel-rename / spawn-drop / wire-type regression.
11697        let dir = tempfile::tempdir().expect("tmpdir");
11698        let mut net_cfg = loopback_network_config();
11699        net_cfg.peer_relay = network::PeerRelayConfig {
11700            enabled: true,
11701            require_contact_to_relay: false,
11702            fail_threshold: 3,
11703            fail_window_ms: 60_000,
11704            candidates: Vec::new(),
11705            ..Default::default()
11706        };
11707        let agent = Agent::builder()
11708            .with_machine_key(dir.path().join("machine.key"))
11709            .with_agent_key_path(dir.path().join("agent.key"))
11710            .with_contact_store_path(dir.path().join("contacts.json"))
11711            .with_peer_cache_disabled()
11712            .with_network_config(net_cfg)
11713            .build()
11714            .await
11715            .expect("agent");
11716
11717        let network = agent
11718            .network
11719            .as_ref()
11720            .expect("agent built with network config");
11721        let sender = network.test_relayed_dm_sender();
11722
11723        let relayed = peer_relay::RelayedDm {
11724            header: peer_relay::RelayHeader {
11725                version: peer_relay::RelayHeader::VERSION,
11726                dst_agent_id: agent.agent_id().0,
11727                sender_agent_id: [0x42; 32],
11728                // Empty pubkey + signature: header.verify() must fail.
11729                sender_public_key: Vec::new(),
11730                originated_at_unix_ms: dm::now_unix_ms(),
11731                signature: Vec::new(),
11732            },
11733            inner: dm::DmEnvelope {
11734                protocol_version: 1,
11735                request_id: [0u8; 16],
11736                sender_agent_id: [0x42; 32],
11737                sender_machine_id: [0x43; 32],
11738                recipient_agent_id: agent.agent_id().0,
11739                created_at_unix_ms: 0,
11740                expires_at_unix_ms: 0,
11741                body: dm::DmBody::Payload(dm::DmPayload {
11742                    kem_ciphertext: Vec::new(),
11743                    body_nonce: [0u8; 12],
11744                    body_ciphertext: Vec::new(),
11745                }),
11746                signature: Vec::new(),
11747            },
11748        };
11749
11750        let relay_peer = ant_quic::PeerId([0xEE; 32]);
11751        let relay_wire_sender = [0xEE; 32];
11752        sender
11753            .send((relay_peer, relay_wire_sender, relayed))
11754            .await
11755            .expect("relayed_dm channel must accept push");
11756
11757        // Spin until the listener observes the refusal - bounded so a
11758        // regression fails fast rather than timing out the suite.
11759        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
11760        loop {
11761            let snap = agent.peer_relay().stats().snapshot();
11762            if snap.relay_refused_bad_signature == 1 {
11763                break;
11764            }
11765            if std::time::Instant::now() >= deadline {
11766                panic!(
11767                    "relay-DM listener did not tick relay_refused_bad_signature within 2s - \
11768                     snapshot: {snap:?}"
11769                );
11770            }
11771            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
11772        }
11773
11774        let snap = agent.peer_relay().stats().snapshot();
11775        assert_eq!(snap.relay_refused_bad_signature, 1);
11776        assert_eq!(snap.relay_received, 0, "bad-sig path must not deliver");
11777        assert_eq!(snap.relay_forwarded, 0, "bad-sig path must not forward");
11778    }
11779
11780    #[tokio::test]
11781    async fn agent_creates() {
11782        let agent = Agent::new().await;
11783        assert!(agent.is_ok());
11784    }
11785
11786    #[tokio::test]
11787    async fn agent_joins_network() {
11788        let agent = Agent::new().await.unwrap();
11789        assert!(agent.join_network().await.is_ok());
11790    }
11791
11792    #[tokio::test]
11793    async fn agent_subscribes() {
11794        let agent = Agent::new().await.unwrap();
11795        // Currently returns error - will be implemented in Task 3
11796        assert!(agent.subscribe("test-topic").await.is_err());
11797    }
11798
11799    #[tokio::test]
11800    async fn identity_announcement_machine_signature_verifies() {
11801        let agent = Agent::builder()
11802            .with_network_config(network::NetworkConfig::default())
11803            .build()
11804            .await
11805            .unwrap();
11806
11807        let announcement = agent.build_identity_announcement(false, false).unwrap();
11808        assert_eq!(announcement.agent_id, agent.agent_id());
11809        assert_eq!(announcement.machine_id, agent.machine_id());
11810        assert!(announcement.user_id.is_none());
11811        assert!(announcement.agent_certificate.is_none());
11812        assert!(announcement.verify().is_ok());
11813    }
11814
11815    #[tokio::test]
11816    async fn identity_announcement_requires_human_consent() {
11817        let agent = Agent::builder()
11818            .with_network_config(network::NetworkConfig::default())
11819            .build()
11820            .await
11821            .unwrap();
11822
11823        let err = agent.build_identity_announcement(true, false).unwrap_err();
11824        assert!(
11825            err.to_string().contains("explicit human consent"),
11826            "unexpected error: {err}"
11827        );
11828    }
11829
11830    #[tokio::test]
11831    async fn identity_announcement_with_user_requires_user_identity() {
11832        let agent = Agent::builder()
11833            .with_network_config(network::NetworkConfig::default())
11834            .build()
11835            .await
11836            .unwrap();
11837
11838        let err = agent.build_identity_announcement(true, true).unwrap_err();
11839        assert!(
11840            err.to_string().contains("no user identity is configured"),
11841            "unexpected error: {err}"
11842        );
11843    }
11844
11845    #[tokio::test]
11846    async fn announce_identity_populates_discovery_cache() {
11847        let user_key = identity::UserKeypair::generate().unwrap();
11848        let agent = Agent::builder()
11849            .with_network_config(network::NetworkConfig::default())
11850            .with_user_key(user_key)
11851            .build()
11852            .await
11853            .unwrap();
11854
11855        agent.announce_identity(true, true).await.unwrap();
11856        let discovered = agent.discovered_agent(agent.agent_id()).await.unwrap();
11857        let entry = discovered.expect("agent should discover its own announcement");
11858
11859        assert_eq!(entry.agent_id, agent.agent_id());
11860        assert_eq!(entry.machine_id, agent.machine_id());
11861        assert_eq!(entry.user_id, agent.user_id());
11862    }
11863
11864    /// Enforcement point 2 (issue #130): a revoked agent MUST fail
11865    /// `is_agent_machine_verified` even when its signed identity announcement
11866    /// is still sitting verified in the discovery cache. This is the DM/gate
11867    /// denial property — a revoked peer is refused everywhere the daemon asks
11868    /// "is this (agent, machine) binding trustworthy?". The revocation is
11869    /// applied via the real `verify_and_insert` receive path (the same call
11870    /// the gossip subscription makes on receipt), and the subject is
11871    /// self-revoked (issuer key == subject agent-id, valid authority).
11872    #[tokio::test]
11873    async fn revoked_agent_fails_machine_verification_even_when_cached() {
11874        let user_key = identity::UserKeypair::generate().unwrap();
11875        let agent = Agent::builder()
11876            .with_network_config(network::NetworkConfig::default())
11877            .with_user_key(user_key)
11878            .build()
11879            .await
11880            .unwrap();
11881
11882        // Seed the discovery cache with our own signed announcement so the
11883        // (agent, machine) binding verifies true BEFORE revocation.
11884        agent.announce_identity(true, true).await.unwrap();
11885        let agent_id = agent.agent_id();
11886        let machine_id = agent.machine_id();
11887        assert!(
11888            agent
11889                .is_agent_machine_verified(&agent_id, &machine_id)
11890                .await,
11891            "a cached, signed self-announcement must verify before revocation"
11892        );
11893
11894        // Apply a valid SELF-revocation of our own agent-id via the real
11895        // receive path. verify_and_insert re-checks the ML-DSA signature and
11896        // the self-revocation authority rule, exactly as on gossip receipt.
11897        let now = std::time::SystemTime::now()
11898            .duration_since(std::time::UNIX_EPOCH)
11899            .unwrap()
11900            .as_secs();
11901        let record = revocation::RevocationRecord::sign(
11902            revocation::RevokedSubject::Agent(agent_id),
11903            agent.identity().agent_keypair().public_key(),
11904            agent.identity().agent_keypair().secret_key(),
11905            now,
11906            Some("ep2 test: key compromised".to_string()),
11907        )
11908        .unwrap();
11909        {
11910            let set = agent.revocation_set();
11911            let mut set = set.write().await;
11912            set.verify_and_insert(record, None)
11913                .expect("self-revocation must verify and insert");
11914        }
11915
11916        // Fail-closed: the verified gate must now refuse the revoked agent,
11917        // even though its announcement is still cached and otherwise valid.
11918        assert!(
11919            !agent
11920                .is_agent_machine_verified(&agent_id, &machine_id)
11921                .await,
11922            "a revoked agent must never pass machine verification while cached"
11923        );
11924    }
11925
11926    /// Issue #191 gap 2: a gossiped **issuer-revocation** (a user un-vouching
11927    /// a certified agent) MUST propagate over gossip. The gossip wire carries
11928    /// bare `RevocationRecord`s (no certs), so the receiver resolves the
11929    /// subject cert from its discovery cache — via `collect_subject_certs`,
11930    /// the exact lookup the gossip-receive loop now performs — and passes it
11931    /// to `verify_and_insert`. Pre-fix the loop passed `None`, and
11932    /// `verify_authority` rejects an issuer-revocation without a cert, so only
11933    /// self-revocations ever propagated network-wide.
11934    #[test]
11935    fn issuer_revocation_propagates_over_gossip_via_cache_cert_lookup() {
11936        let user = identity::UserKeypair::generate().unwrap();
11937        let issued_agent = identity::AgentKeypair::generate().unwrap();
11938        let agent_id = issued_agent.agent_id();
11939        // The user vouches for the agent — this cert is what authorizes a
11940        // later issuer-revocation (verify_authority checks the issuer key is
11941        // the certifying user).
11942        let cert = identity::AgentCertificate::issue(&user, &issued_agent).unwrap();
11943
11944        // Seed the discovery cache exactly as EP1 does on a real announcement:
11945        // the cert is retained so a gossiped issuer-revocation can be verified.
11946        let mut cache = std::collections::HashMap::new();
11947        cache.insert(
11948            agent_id,
11949            DiscoveredAgent {
11950                agent_id,
11951                machine_id: identity::MachineId([0u8; 32]),
11952                user_id: None,
11953                addresses: Vec::new(),
11954                announced_at: 0,
11955                last_seen: 0,
11956                machine_public_key: Vec::new(),
11957                nat_type: None,
11958                can_receive_direct: None,
11959                is_relay: None,
11960                is_coordinator: None,
11961                reachable_via: Vec::new(),
11962                relay_candidates: Vec::new(),
11963                cert_not_after: cert.not_after(),
11964                agent_certificate: Some(cert.clone()),
11965                agent_public_key: cert.agent_public_key().to_vec(),
11966            },
11967        );
11968
11969        // The gossip-receive path resolves subject certs from the cache.
11970        let subject_certs = collect_subject_certs(&cache);
11971        let looked_up = subject_certs
11972            .get(&agent_id)
11973            .expect("the cache lookup must find the subject cert");
11974
11975        let now = std::time::SystemTime::now()
11976            .duration_since(std::time::UNIX_EPOCH)
11977            .unwrap()
11978            .as_secs();
11979        // The user revokes the agent it vouched for (issuer-revocation).
11980        let record = revocation::RevocationRecord::sign(
11981            revocation::RevokedSubject::Agent(agent_id),
11982            user.public_key(),
11983            user.secret_key(),
11984            now,
11985            Some("issuer revokes compromised agent".to_string()),
11986        )
11987        .unwrap();
11988
11989        // With the cache-resolved cert, the issuer-revocation verifies +
11990        // inserts (the post-fix gossip behavior).
11991        let mut set = revocation::RevocationSet::new();
11992        assert!(
11993            set.verify_and_insert(record.clone(), Some(looked_up))
11994                .unwrap(),
11995            "issuer-revocation must verify and insert with the cache-resolved cert"
11996        );
11997        assert!(
11998            set.is_agent_revoked(&agent_id),
11999            "the agent must now be revoked network-wide"
12000        );
12001
12002        // Pre-fix proof: without the cert (the old `None` gossip path), an
12003        // issuer-revocation is REJECTED by verify_authority — which is exactly
12004        // why issuer-revocations were inert over gossip before this fix.
12005        assert!(
12006            revocation::RevocationSet::new()
12007                .verify_and_insert(record, None)
12008                .is_err(),
12009            "an issuer-revocation must be rejected without the subject cert \
12010             (the pre-fix gossip behavior this closes)"
12011        );
12012    }
12013
12014    /// An announcement without NAT fields (as produced by old nodes) should still
12015    /// deserialise correctly via bincode — new fields are `Option` so `None` (0x00)
12016    /// is a valid encoding.
12017    #[test]
12018    fn identity_announcement_backward_compat_no_nat_fields() {
12019        use identity::{AgentId, MachineId};
12020
12021        // Build an announcement that omits the nat_* fields by serializing an old-style
12022        // struct that matches the pre-1.3 wire format.
12023        #[derive(serde::Serialize, serde::Deserialize)]
12024        struct OldIdentityAnnouncementUnsigned {
12025            agent_id: AgentId,
12026            machine_id: MachineId,
12027            user_id: Option<identity::UserId>,
12028            agent_certificate: Option<identity::AgentCertificate>,
12029            machine_public_key: Vec<u8>,
12030            addresses: Vec<std::net::SocketAddr>,
12031            announced_at: u64,
12032        }
12033
12034        let agent_id = AgentId([1u8; 32]);
12035        let machine_id = MachineId([2u8; 32]);
12036        let old = OldIdentityAnnouncementUnsigned {
12037            agent_id,
12038            machine_id,
12039            user_id: None,
12040            agent_certificate: None,
12041            machine_public_key: vec![0u8; 10],
12042            addresses: Vec::new(),
12043            announced_at: 1234,
12044        };
12045        let bytes = bincode::serialize(&old).expect("serialize old announcement");
12046
12047        // Attempt to deserialize as the new struct — this tests that the new fields
12048        // (which are Option<T>) do NOT break deserialization of the old format.
12049        // Note: bincode 1.x is not self-describing, so adding fields to a struct DOES
12050        // change the wire format.  This test documents the expected behavior.
12051        // Old format -> new struct: will fail because new struct has more fields.
12052        // New format -> old struct: will have trailing bytes.
12053        // This is acceptable — we document the protocol change.
12054        let result = bincode::deserialize::<IdentityAnnouncementUnsigned>(&bytes);
12055        // Old nodes produce shorter messages; new nodes cannot decode them as new structs.
12056        // This confirms the protocol is not transparent — nodes must upgrade together.
12057        assert!(
12058            result.is_err(),
12059            "Old-format announcement should not decode as new struct (protocol upgrade required)"
12060        );
12061    }
12062
12063    #[tokio::test]
12064    async fn register_announced_machine_skips_self_agent() {
12065        // The daemon must never create a contact entry for its own agent: a
12066        // self record is noise on `/contacts` and makes contact-set
12067        // assertions racy (issue #145). A self-announcement is refused even
12068        // though it carries a valid (agent, machine) pair.
12069        let dir = tempfile::tempdir().expect("tmpdir");
12070        let store = std::sync::Arc::new(tokio::sync::RwLock::new(contacts::ContactStore::new(
12071            dir.path().join("contacts.json"),
12072        )));
12073        let own = identity::AgentId([1u8; 32]);
12074        let own_machine = identity::MachineId([2u8; 32]);
12075
12076        let added = super::register_announced_machine(&store, own, own, own_machine).await;
12077
12078        assert!(!added, "self-announcement must not register a machine");
12079        let store = store.read().await;
12080        assert!(
12081            store.machines(&own).is_empty(),
12082            "self-agent must have no machine record after a self-announcement"
12083        );
12084        // Stronger invariant: the daemon must have NO contact entry at all
12085        // for its own agent, not merely an empty machine list. add_machine
12086        // creates the Contact shell on first sight (contacts.rs:423), so this
12087        // confirms the self-skip happens before that insertion (#145).
12088        assert!(
12089            store.list().iter().all(|contact| contact.agent_id != own),
12090            "self-agent must not appear in the contact store at all after a \
12091             self-announcement"
12092        );
12093    }
12094
12095    #[tokio::test]
12096    async fn register_announced_machine_registers_foreign_agent() {
12097        // Foreign observation must keep registering a machine record so peers
12098        // remain observable in the contact surface — the self-skip must not be
12099        // over-broad. A second announcement of the same (agent, machine) is
12100        // idempotent (returns false, no duplicate record).
12101        let dir = tempfile::tempdir().expect("tmpdir");
12102        let store = std::sync::Arc::new(tokio::sync::RwLock::new(contacts::ContactStore::new(
12103            dir.path().join("contacts.json"),
12104        )));
12105        let own = identity::AgentId([1u8; 32]);
12106        let peer = identity::AgentId([9u8; 32]);
12107        let peer_machine = identity::MachineId([7u8; 32]);
12108
12109        let added = super::register_announced_machine(&store, own, peer, peer_machine).await;
12110        assert!(added, "foreign announcement must register a new machine");
12111
12112        let added_again = super::register_announced_machine(&store, own, peer, peer_machine).await;
12113        assert!(
12114            !added_again,
12115            "re-announcing the same machine must be idempotent"
12116        );
12117
12118        let store = store.read().await;
12119        let machines = store.machines(&peer);
12120        assert_eq!(machines.len(), 1, "exactly one machine record");
12121        assert_eq!(machines[0].machine_id, peer_machine);
12122        assert!(
12123            store.machines(&own).is_empty(),
12124            "own agent must still have no contact entry"
12125        );
12126    }
12127
12128    #[test]
12129    fn announcement_assist_snapshot_uses_capabilities_not_activity() {
12130        let status = ant_quic::NodeStatus {
12131            nat_type: ant_quic::NatType::FullCone,
12132            can_receive_direct: true,
12133            relay_service_enabled: true,
12134            coordinator_service_enabled: true,
12135            is_relaying: false,
12136            is_coordinating: false,
12137            ..Default::default()
12138        };
12139
12140        let snapshot = AnnouncementAssistSnapshot::from_node_status(&status);
12141        assert_eq!(snapshot.nat_type.as_deref(), Some("Full Cone"));
12142        assert_eq!(snapshot.can_receive_direct, Some(true));
12143        assert_eq!(snapshot.relay_capable, Some(true));
12144        assert_eq!(snapshot.coordinator_capable, Some(true));
12145        assert_eq!(snapshot.relay_active, Some(false));
12146        assert_eq!(snapshot.coordinator_active, Some(false));
12147    }
12148
12149    /// A new announcement with all NAT fields set round-trips through bincode.
12150    #[test]
12151    fn identity_announcement_nat_fields_round_trip() {
12152        use identity::{AgentId, MachineId};
12153
12154        let unsigned = IdentityAnnouncementUnsigned {
12155            agent_id: AgentId([1u8; 32]),
12156            machine_id: MachineId([2u8; 32]),
12157            user_id: None,
12158            agent_certificate: None,
12159            machine_public_key: vec![0u8; 10],
12160            addresses: Vec::new(),
12161            announced_at: 9999,
12162            nat_type: Some("FullCone".to_string()),
12163            can_receive_direct: Some(true),
12164            is_relay: Some(false),
12165            is_coordinator: Some(true),
12166            reachable_via: vec![MachineId([5u8; 32])],
12167            relay_candidates: vec![MachineId([6u8; 32])],
12168        };
12169        let bytes = bincode::serialize(&unsigned).expect("serialize");
12170        let decoded: IdentityAnnouncementUnsigned =
12171            bincode::deserialize(&bytes).expect("deserialize");
12172        assert_eq!(decoded.nat_type.as_deref(), Some("FullCone"));
12173        assert_eq!(decoded.can_receive_direct, Some(true));
12174        assert_eq!(decoded.is_relay, Some(false));
12175        assert_eq!(decoded.is_coordinator, Some(true));
12176        assert_eq!(decoded.reachable_via, vec![MachineId([5u8; 32])]);
12177        assert_eq!(decoded.relay_candidates, vec![MachineId([6u8; 32])]);
12178    }
12179
12180    #[tokio::test]
12181    async fn announcement_decode_helpers_match_bincode_serialize_wire_format() {
12182        let temp = tempfile::tempdir().unwrap();
12183        let agent = Agent::builder()
12184            .with_machine_key(temp.path().join("machine.key"))
12185            .with_agent_key_path(temp.path().join("agent.key"))
12186            .with_agent_cert_path(temp.path().join("agent.cert"))
12187            .with_contact_store_path(temp.path().join("contacts.json"))
12188            .build()
12189            .await
12190            .unwrap();
12191
12192        let identity = agent.build_identity_announcement(false, false).unwrap();
12193        let identity_bytes = serialize_identity_announcement(&identity).unwrap();
12194        let decoded_identity = deserialize_identity_announcement(&identity_bytes).unwrap();
12195        assert_eq!(decoded_identity.agent_id, identity.agent_id);
12196        assert_eq!(decoded_identity.machine_id, identity.machine_id);
12197        // The v2 envelope must carry the agent public key (#204).
12198        assert_eq!(decoded_identity.agent_public_key, identity.agent_public_key);
12199        assert!(
12200            !decoded_identity.agent_public_key.is_empty(),
12201            "v2 announcement must carry the agent public key"
12202        );
12203        let machine = agent.build_machine_announcement().unwrap();
12204        let machine_bytes = bincode::serialize(&machine).unwrap();
12205        let decoded_machine = deserialize_machine_announcement(&machine_bytes).unwrap();
12206        assert_eq!(decoded_machine.machine_id, machine.machine_id);
12207        assert_eq!(decoded_machine.addresses, machine.addresses);
12208    }
12209
12210    #[tokio::test]
12211    async fn deserialize_identity_announcement_rejects_trailing_bytes() {
12212        let temp = tempfile::tempdir().unwrap();
12213        let agent = Agent::builder()
12214            .with_machine_key(temp.path().join("machine.key"))
12215            .with_agent_key_path(temp.path().join("agent.key"))
12216            .with_agent_cert_path(temp.path().join("agent.cert"))
12217            .with_contact_store_path(temp.path().join("contacts.json"))
12218            .build()
12219            .await
12220            .unwrap();
12221
12222        let announcement = agent.build_identity_announcement(false, false).unwrap();
12223        let mut bytes = bincode::serialize(&announcement).unwrap();
12224        bytes.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef]);
12225
12226        assert!(
12227            deserialize_identity_announcement(&bytes).is_err(),
12228            "identity announcements with trailing bytes must be rejected"
12229        );
12230    }
12231
12232    /// An announcement with None for all NAT fields (e.g. network not started)
12233    /// round-trips correctly.
12234    #[test]
12235    fn identity_announcement_no_nat_fields_round_trip() {
12236        use identity::{AgentId, MachineId};
12237
12238        let unsigned = IdentityAnnouncementUnsigned {
12239            agent_id: AgentId([3u8; 32]),
12240            machine_id: MachineId([4u8; 32]),
12241            user_id: None,
12242            agent_certificate: None,
12243            machine_public_key: vec![0u8; 10],
12244            addresses: Vec::new(),
12245            announced_at: 42,
12246            nat_type: None,
12247            can_receive_direct: None,
12248            is_relay: None,
12249            is_coordinator: None,
12250            reachable_via: Vec::new(),
12251            relay_candidates: Vec::new(),
12252        };
12253        let bytes = bincode::serialize(&unsigned).expect("serialize");
12254        let decoded: IdentityAnnouncementUnsigned =
12255            bincode::deserialize(&bytes).expect("deserialize");
12256        assert!(decoded.nat_type.is_none());
12257        assert!(decoded.can_receive_direct.is_none());
12258        assert!(decoded.is_relay.is_none());
12259        assert!(decoded.is_coordinator.is_none());
12260        assert!(decoded.reachable_via.is_empty());
12261        assert!(decoded.relay_candidates.is_empty());
12262    }
12263
12264    /// Wire format v2 populates coordinator-hint fields: full round-trip and
12265    /// deterministic signing.
12266    #[test]
12267    fn identity_announcement_reachable_via_round_trip() {
12268        use identity::{AgentId, MachineId};
12269
12270        let coord_a = MachineId([0xAAu8; 32]);
12271        let coord_b = MachineId([0xBBu8; 32]);
12272        let unsigned = IdentityAnnouncementUnsigned {
12273            agent_id: AgentId([9u8; 32]),
12274            machine_id: MachineId([8u8; 32]),
12275            user_id: None,
12276            agent_certificate: None,
12277            machine_public_key: vec![0u8; 10],
12278            addresses: Vec::new(),
12279            announced_at: 555,
12280            nat_type: Some("Symmetric".to_string()),
12281            can_receive_direct: Some(false),
12282            is_relay: Some(false),
12283            is_coordinator: Some(false),
12284            reachable_via: vec![coord_a, coord_b],
12285            relay_candidates: vec![coord_a],
12286        };
12287        let bytes = bincode::serialize(&unsigned).expect("serialize");
12288        let decoded: IdentityAnnouncementUnsigned =
12289            bincode::deserialize(&bytes).expect("deserialize");
12290        assert_eq!(decoded.reachable_via, vec![coord_a, coord_b]);
12291        assert_eq!(decoded.relay_candidates, vec![coord_a]);
12292
12293        // Mutating reachable_via MUST invalidate the outer sig — signing over
12294        // the unsigned form guarantees integrity. Here we only verify the
12295        // bincode is stable; cryptographic coverage lives in the existing
12296        // `identity_announcement_machine_signature_verifies` tests.
12297        let re_encoded = bincode::serialize(&decoded).expect("re-serialize");
12298        assert_eq!(
12299            bytes, re_encoded,
12300            "canonical bincode round-trip must be stable"
12301        );
12302    }
12303
12304    #[test]
12305    fn user_announcement_sign_and_verify() {
12306        let user_kp = identity::UserKeypair::generate().unwrap();
12307        let agent_kp_a = identity::AgentKeypair::generate().unwrap();
12308        let agent_kp_b = identity::AgentKeypair::generate().unwrap();
12309        let cert_a = identity::AgentCertificate::issue(&user_kp, &agent_kp_a).unwrap();
12310        let cert_b = identity::AgentCertificate::issue(&user_kp, &agent_kp_b).unwrap();
12311
12312        let announcement = UserAnnouncement::sign(&user_kp, vec![cert_a, cert_b], 1234).unwrap();
12313        announcement.verify().expect("freshly-signed must verify");
12314        assert_eq!(announcement.user_id, user_kp.user_id());
12315        assert_eq!(announcement.agent_certificates.len(), 2);
12316    }
12317
12318    #[test]
12319    fn deserialize_user_announcement_rejects_trailing_bytes() {
12320        let user_kp = identity::UserKeypair::generate().unwrap();
12321        let agent_kp = identity::AgentKeypair::generate().unwrap();
12322        let cert = identity::AgentCertificate::issue(&user_kp, &agent_kp).unwrap();
12323        let announcement = UserAnnouncement::sign(&user_kp, vec![cert], 1234).unwrap();
12324        let mut bytes = bincode::serialize(&announcement).unwrap();
12325        bytes.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef]);
12326
12327        assert!(
12328            deserialize_user_announcement(&bytes).is_err(),
12329            "user announcements with trailing bytes must be rejected"
12330        );
12331    }
12332
12333    #[test]
12334    fn user_announcement_rejects_foreign_certificate() {
12335        let user_kp = identity::UserKeypair::generate().unwrap();
12336        let other_user = identity::UserKeypair::generate().unwrap();
12337        let agent_kp = identity::AgentKeypair::generate().unwrap();
12338        // Certificate issued by `other_user` — we should refuse to include it
12339        // in a roster signed by `user_kp`.
12340        let foreign_cert = identity::AgentCertificate::issue(&other_user, &agent_kp).unwrap();
12341        let err = UserAnnouncement::sign(&user_kp, vec![foreign_cert], 0).unwrap_err();
12342        assert!(
12343            err.to_string().contains("different user"),
12344            "unexpected error: {err}"
12345        );
12346    }
12347
12348    #[test]
12349    fn user_announcement_tampered_agent_cert_list_fails() {
12350        let user_kp = identity::UserKeypair::generate().unwrap();
12351        let agent_kp = identity::AgentKeypair::generate().unwrap();
12352        let cert = identity::AgentCertificate::issue(&user_kp, &agent_kp).unwrap();
12353
12354        let mut announcement = UserAnnouncement::sign(&user_kp, vec![cert], 10).unwrap();
12355        // Forge an additional certificate from a different user into the list.
12356        let other_user = identity::UserKeypair::generate().unwrap();
12357        let other_agent = identity::AgentKeypair::generate().unwrap();
12358        let foreign_cert = identity::AgentCertificate::issue(&other_user, &other_agent).unwrap();
12359        announcement.agent_certificates.push(foreign_cert);
12360        assert!(
12361            announcement.verify().is_err(),
12362            "announcement with appended foreign cert must fail verification"
12363        );
12364    }
12365
12366    #[test]
12367    fn user_announcement_tampered_user_public_key_fails() {
12368        let user_kp = identity::UserKeypair::generate().unwrap();
12369        let agent_kp = identity::AgentKeypair::generate().unwrap();
12370        let cert = identity::AgentCertificate::issue(&user_kp, &agent_kp).unwrap();
12371        let mut announcement = UserAnnouncement::sign(&user_kp, vec![cert], 10).unwrap();
12372        let other = identity::UserKeypair::generate().unwrap();
12373        announcement.user_public_key = other.public_key().as_bytes().to_vec();
12374        assert!(announcement.verify().is_err());
12375    }
12376
12377    #[test]
12378    fn user_shard_topic_is_deterministic() {
12379        let user_id = identity::UserId([5u8; 32]);
12380        let topic_a = shard_topic_for_user(&user_id);
12381        let topic_b = shard_topic_for_user(&user_id);
12382        assert_eq!(topic_a, topic_b);
12383        assert!(topic_a.starts_with("x0x.user.shard.v2."));
12384    }
12385}
12386#[test]
12387fn agent_shard_topic_is_deterministic() {
12388    let agent_id = identity::AgentId([6u8; 32]);
12389    let topic_a = shard_topic_for_agent(&agent_id);
12390    let topic_b = shard_topic_for_agent(&agent_id);
12391    assert_eq!(topic_a, topic_b);
12392    assert!(topic_a.starts_with("x0x.identity.shard.v2."));
12393}
12394
12395#[test]
12396fn machine_shard_topic_is_deterministic() {
12397    let machine_id = identity::MachineId([7u8; 32]);
12398    let topic_a = shard_topic_for_machine(&machine_id);
12399    let topic_b = shard_topic_for_machine(&machine_id);
12400    assert_eq!(topic_a, topic_b);
12401    assert!(topic_a.starts_with("x0x.machine.shard.v2."));
12402}
12403
12404#[test]
12405fn rendezvous_shard_topic_is_deterministic() {
12406    let agent_id = identity::AgentId([8u8; 32]);
12407    let topic_a = rendezvous_shard_topic_for_agent(&agent_id);
12408    let topic_b = rendezvous_shard_topic_for_agent(&agent_id);
12409    assert_eq!(topic_a, topic_b);
12410    assert!(topic_a.starts_with("x0x.rendezvous.shard."));
12411}
12412
12413#[test]
12414fn different_ids_produce_different_shard_topics() {
12415    let agent_a = identity::AgentId([1u8; 32]);
12416    let agent_b = identity::AgentId([2u8; 32]);
12417    let topic_a = shard_topic_for_agent(&agent_a);
12418    let topic_b = shard_topic_for_agent(&agent_b);
12419    assert_ne!(
12420        topic_a, topic_b,
12421        "different agent IDs should produce different shard topics"
12422    );
12423}
12424
12425#[test]
12426fn collect_local_interface_addrs_returns_non_empty() {
12427    let addrs = collect_local_interface_addrs(5483);
12428    assert!(!addrs.is_empty(), "should find at least one interface");
12429    for addr in &addrs {
12430        assert_eq!(addr.port(), 5483, "all addrs should use port 5483");
12431    }
12432}
12433
12434#[test]
12435fn collect_local_interface_addrs_returns_reasonable_results() {
12436    let addrs = collect_local_interface_addrs(9000);
12437    assert!(!addrs.is_empty(), "should find at least one interface");
12438    for addr in &addrs {
12439        assert_eq!(addr.port(), 9000, "all addrs should use port 9000");
12440    }
12441}
12442
12443#[test]
12444fn is_globally_routable_v4_private() {
12445    assert!(!is_globally_routable(std::net::IpAddr::V4(
12446        std::net::Ipv4Addr::new(10, 0, 0, 1)
12447    )));
12448    assert!(!is_globally_routable(std::net::IpAddr::V4(
12449        std::net::Ipv4Addr::new(172, 16, 0, 1)
12450    )));
12451    assert!(!is_globally_routable(std::net::IpAddr::V4(
12452        std::net::Ipv4Addr::new(192, 168, 1, 1)
12453    )));
12454}
12455
12456#[test]
12457fn is_globally_routable_v4_global() {
12458    assert!(is_globally_routable(std::net::IpAddr::V4(
12459        std::net::Ipv4Addr::new(8, 8, 8, 8)
12460    )));
12461    assert!(is_globally_routable(std::net::IpAddr::V4(
12462        std::net::Ipv4Addr::new(1, 2, 3, 4)
12463    )));
12464}
12465
12466#[test]
12467fn is_globally_routable_v6_private() {
12468    assert!(!is_globally_routable(std::net::IpAddr::V6(
12469        std::net::Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)
12470    )));
12471    assert!(!is_globally_routable(std::net::IpAddr::V6(
12472        std::net::Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1)
12473    )));
12474    assert!(!is_globally_routable(std::net::IpAddr::V6(
12475        std::net::Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)
12476    )));
12477}
12478
12479#[test]
12480fn is_globally_routable_v6_global() {
12481    assert!(is_globally_routable(std::net::IpAddr::V6(
12482        std::net::Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)
12483    )));
12484    assert!(is_globally_routable(std::net::IpAddr::V6(
12485        std::net::Ipv6Addr::new(0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888)
12486    )));
12487}
12488
12489#[test]
12490fn is_globally_routable_v4_cgnat() {
12491    assert!(!is_globally_routable(std::net::IpAddr::V4(
12492        std::net::Ipv4Addr::new(100, 64, 0, 1)
12493    )));
12494    assert!(!is_globally_routable(std::net::IpAddr::V4(
12495        std::net::Ipv4Addr::new(100, 127, 255, 255)
12496    )));
12497}
12498
12499#[test]
12500fn is_globally_routable_v4_documentation() {
12501    assert!(!is_globally_routable(std::net::IpAddr::V4(
12502        std::net::Ipv4Addr::new(192, 0, 2, 1)
12503    )));
12504    assert!(!is_globally_routable(std::net::IpAddr::V4(
12505        std::net::Ipv4Addr::new(198, 51, 100, 1)
12506    )));
12507    assert!(!is_globally_routable(std::net::IpAddr::V4(
12508        std::net::Ipv4Addr::new(203, 0, 113, 1)
12509    )));
12510}
12511
12512#[test]
12513fn is_globally_routable_v4_broadcast() {
12514    assert!(!is_globally_routable(std::net::IpAddr::V4(
12515        std::net::Ipv4Addr::new(255, 255, 255, 255)
12516    )));
12517}
12518
12519#[test]
12520fn is_globally_routable_v4_unspecified() {
12521    assert!(!is_globally_routable(std::net::IpAddr::V4(
12522        std::net::Ipv4Addr::new(0, 0, 0, 0)
12523    )));
12524}
12525
12526#[test]
12527fn is_globally_routable_v6_unspecified() {
12528    assert!(!is_globally_routable(std::net::IpAddr::V6(
12529        std::net::Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0)
12530    )));
12531}
12532
12533#[test]
12534fn is_globally_routable_v6_unique_local() {
12535    assert!(!is_globally_routable(std::net::IpAddr::V6(
12536        std::net::Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1)
12537    )));
12538}
12539
12540#[test]
12541fn is_globally_routable_v6_site_local() {
12542    assert!(!is_globally_routable(std::net::IpAddr::V6(
12543        std::net::Ipv6Addr::new(0xfec0, 0, 0, 0, 0, 0, 0, 1)
12544    )));
12545}
12546
12547#[test]
12548fn push_unique_adds_new_item() {
12549    let mut items = vec![1, 2, 3];
12550    push_unique(&mut items, 4);
12551    assert_eq!(items, vec![1, 2, 3, 4]);
12552}
12553
12554#[test]
12555fn push_unique_skips_existing_item() {
12556    let mut items = vec![1, 2, 3];
12557    push_unique(&mut items, 2);
12558    assert_eq!(items, vec![1, 2, 3]);
12559}
12560
12561#[test]
12562fn push_unique_works_with_empty() {
12563    let mut items: Vec<i32> = vec![];
12564    push_unique(&mut items, 42);
12565    assert_eq!(items, vec![42]);
12566}
12567
12568#[cfg(test)]
12569fn discovered_agent_fixture(
12570    tag: u8,
12571    announced_at: u64,
12572    addrs: &[&str],
12573    user_id: Option<identity::UserId>,
12574) -> DiscoveredAgent {
12575    DiscoveredAgent {
12576        agent_id: identity::AgentId([tag; 32]),
12577        machine_id: identity::MachineId([tag; 32]),
12578        user_id,
12579        addresses: addrs
12580            .iter()
12581            .map(|a| a.parse().expect("valid socket addr"))
12582            .collect(),
12583        announced_at,
12584        last_seen: announced_at,
12585        machine_public_key: vec![tag],
12586        nat_type: None,
12587        can_receive_direct: None,
12588        is_relay: None,
12589        is_coordinator: None,
12590        reachable_via: Vec::new(),
12591        relay_candidates: Vec::new(),
12592        cert_not_after: None,
12593        agent_certificate: None,
12594        agent_public_key: Vec::new(),
12595    }
12596}
12597
12598#[cfg(test)]
12599fn signed_identity_announcement_fixture(
12600    agent_id: identity::AgentId,
12601    machine: &identity::MachineKeypair,
12602    announced_at: u64,
12603) -> IdentityAnnouncement {
12604    let machine_public_key = machine.public_key().as_bytes().to_vec();
12605    let unsigned = IdentityAnnouncementUnsigned {
12606        agent_id,
12607        machine_id: machine.machine_id(),
12608        user_id: None,
12609        agent_certificate: None,
12610        machine_public_key: machine_public_key.clone(),
12611        addresses: Vec::new(),
12612        announced_at,
12613        nat_type: None,
12614        can_receive_direct: None,
12615        is_relay: None,
12616        is_coordinator: None,
12617        reachable_via: Vec::new(),
12618        relay_candidates: Vec::new(),
12619    };
12620    let unsigned_bytes = bincode::serialize(&unsigned).expect("serialize announcement");
12621    let machine_signature = ant_quic::crypto::raw_public_keys::pqc::sign_with_ml_dsa(
12622        machine.secret_key(),
12623        &unsigned_bytes,
12624    )
12625    .expect("sign announcement")
12626    .as_bytes()
12627    .to_vec();
12628    IdentityAnnouncement {
12629        agent_id,
12630        machine_id: machine.machine_id(),
12631        user_id: None,
12632        agent_certificate: None,
12633        machine_public_key,
12634        machine_signature,
12635        addresses: Vec::new(),
12636        announced_at,
12637        nat_type: None,
12638        can_receive_direct: None,
12639        is_relay: None,
12640        is_coordinator: None,
12641        reachable_via: Vec::new(),
12642        relay_candidates: Vec::new(),
12643        agent_public_key: Vec::new(),
12644    }
12645}
12646
12647#[cfg(test)]
12648fn verified_identity_origin_message(sender: &identity::AgentKeypair) -> gossip::PubSubMessage {
12649    gossip::PubSubMessage {
12650        topic: "identity-ingest-test".to_string(),
12651        payload: bytes::Bytes::new(),
12652        sender: Some(sender.agent_id()),
12653        sender_public_key: Some(sender.public_key().as_bytes().to_vec()),
12654        verified: true,
12655        trust_level: None,
12656    }
12657}
12658
12659#[tokio::test]
12660async fn direct_origin_identity_ingest_populates_authenticated_binding() {
12661    let sender = identity::AgentKeypair::generate().expect("sender keygen");
12662    let machine = identity::MachineKeypair::generate().expect("machine keygen");
12663    let now = 1_000;
12664    let announcement = signed_identity_announcement_fixture(sender.agent_id(), &machine, now);
12665    announcement.verify().expect("valid machine announcement");
12666    let message = verified_identity_origin_message(&sender);
12667    let bindings = std::sync::Arc::new(tokio::sync::RwLock::new(
12668        dm_inbox::AuthenticatedMachineBindingCache::default(),
12669    ));
12670
12671    assert!(
12672        record_authenticated_machine_binding_from_message(&bindings, &message, &announcement, now,)
12673            .await
12674    );
12675    assert_eq!(
12676        dm_inbox::authenticated_machine_binding_for_testing(&bindings, &sender.agent_id()).await,
12677        Some(machine.machine_id())
12678    );
12679}
12680
12681#[tokio::test]
12682async fn wrong_origin_machine_announcement_cannot_populate_or_overwrite_binding() {
12683    let victim = identity::AgentKeypair::generate().expect("victim keygen");
12684    let attacker = identity::AgentKeypair::generate().expect("attacker keygen");
12685    let trusted_machine = identity::MachineKeypair::generate().expect("trusted machine keygen");
12686    let attacker_machine = identity::MachineKeypair::generate().expect("attacker machine keygen");
12687    let now = 2_000;
12688    let trusted = signed_identity_announcement_fixture(victim.agent_id(), &trusted_machine, now);
12689    let trusted_message = verified_identity_origin_message(&victim);
12690    let bindings = std::sync::Arc::new(tokio::sync::RwLock::new(
12691        dm_inbox::AuthenticatedMachineBindingCache::default(),
12692    ));
12693    assert!(
12694        record_authenticated_machine_binding_from_message(
12695            &bindings,
12696            &trusted_message,
12697            &trusted,
12698            now,
12699        )
12700        .await
12701    );
12702
12703    let poisoned =
12704        signed_identity_announcement_fixture(victim.agent_id(), &attacker_machine, now + 1);
12705    poisoned.verify().expect("valid attacker machine signature");
12706    let attacker_message = verified_identity_origin_message(&attacker);
12707    assert!(
12708        !record_authenticated_machine_binding_from_message(
12709            &bindings,
12710            &attacker_message,
12711            &poisoned,
12712            now + 1,
12713        )
12714        .await
12715    );
12716    assert_eq!(
12717        dm_inbox::authenticated_machine_binding_for_testing(&bindings, &victim.agent_id()).await,
12718        Some(trusted_machine.machine_id())
12719    );
12720}
12721
12722#[tokio::test]
12723async fn verified_rebroadcast_cannot_populate_or_overwrite_authenticated_binding() {
12724    let origin = identity::AgentKeypair::generate().expect("origin keygen");
12725    let relay = identity::AgentKeypair::generate().expect("relay keygen");
12726    let machine_a = identity::MachineKeypair::generate().expect("machine A keygen");
12727    let machine_b = identity::MachineKeypair::generate().expect("machine B keygen");
12728    let now = 3_000;
12729    let bindings = std::sync::Arc::new(tokio::sync::RwLock::new(
12730        dm_inbox::AuthenticatedMachineBindingCache::default(),
12731    ));
12732    let direct = signed_identity_announcement_fixture(origin.agent_id(), &machine_a, now);
12733    assert!(
12734        record_authenticated_machine_binding_from_message(
12735            &bindings,
12736            &verified_identity_origin_message(&origin),
12737            &direct,
12738            now,
12739        )
12740        .await
12741    );
12742
12743    let rebroadcast = signed_identity_announcement_fixture(origin.agent_id(), &machine_b, now + 1);
12744    let relay_message = verified_identity_origin_message(&relay);
12745    assert!(
12746        !record_authenticated_machine_binding_from_message(
12747            &bindings,
12748            &relay_message,
12749            &rebroadcast,
12750            now + 1,
12751        )
12752        .await
12753    );
12754    assert_eq!(
12755        dm_inbox::authenticated_machine_binding_for_testing(&bindings, &origin.agent_id()).await,
12756        Some(machine_a.machine_id())
12757    );
12758}
12759
12760#[tokio::test]
12761async fn missing_or_invalid_sender_key_cannot_populate_authenticated_binding() {
12762    let origin = identity::AgentKeypair::generate().expect("origin keygen");
12763    let machine = identity::MachineKeypair::generate().expect("machine keygen");
12764    let now = 4_000;
12765    let announcement = signed_identity_announcement_fixture(origin.agent_id(), &machine, now);
12766    let bindings = std::sync::Arc::new(tokio::sync::RwLock::new(
12767        dm_inbox::AuthenticatedMachineBindingCache::default(),
12768    ));
12769
12770    let mut missing = verified_identity_origin_message(&origin);
12771    missing.sender_public_key = None;
12772    assert!(
12773        !record_authenticated_machine_binding_from_message(
12774            &bindings,
12775            &missing,
12776            &announcement,
12777            now,
12778        )
12779        .await
12780    );
12781    let mut invalid = verified_identity_origin_message(&origin);
12782    invalid.sender_public_key = Some(vec![0xFF; 8]);
12783    assert!(
12784        !record_authenticated_machine_binding_from_message(
12785            &bindings,
12786            &invalid,
12787            &announcement,
12788            now,
12789        )
12790        .await
12791    );
12792    assert_eq!(
12793        dm_inbox::authenticated_machine_binding_for_testing(&bindings, &origin.agent_id()).await,
12794        None
12795    );
12796}
12797
12798#[tokio::test]
12799async fn far_future_direct_origin_cannot_poison_portable_move_ordering() {
12800    let origin = identity::AgentKeypair::generate().expect("origin keygen");
12801    let machine_a = identity::MachineKeypair::generate().expect("machine A keygen");
12802    let machine_b = identity::MachineKeypair::generate().expect("machine B keygen");
12803    let now = 5_000;
12804    let message = verified_identity_origin_message(&origin);
12805    let bindings = std::sync::Arc::new(tokio::sync::RwLock::new(
12806        dm_inbox::AuthenticatedMachineBindingCache::default(),
12807    ));
12808    let current = signed_identity_announcement_fixture(origin.agent_id(), &machine_a, now);
12809    assert!(
12810        record_authenticated_machine_binding_from_message(&bindings, &message, &current, now,)
12811            .await
12812    );
12813
12814    let far_future = signed_identity_announcement_fixture(origin.agent_id(), &machine_b, u64::MAX);
12815    assert!(
12816        !record_authenticated_machine_binding_from_message(&bindings, &message, &far_future, now,)
12817            .await
12818    );
12819    assert_eq!(
12820        dm_inbox::authenticated_machine_binding_for_testing(&bindings, &origin.agent_id()).await,
12821        Some(machine_a.machine_id())
12822    );
12823
12824    let normal_move = signed_identity_announcement_fixture(origin.agent_id(), &machine_b, now + 1);
12825    assert!(
12826        record_authenticated_machine_binding_from_message(
12827            &bindings,
12828            &message,
12829            &normal_move,
12830            now + 1,
12831        )
12832        .await
12833    );
12834    assert_eq!(
12835        dm_inbox::authenticated_machine_binding_for_testing(&bindings, &origin.agent_id()).await,
12836        Some(machine_b.machine_id())
12837    );
12838}
12839
12840#[tokio::test]
12841async fn revocation_discovery_eviction_preserves_authenticated_binding() {
12842    let tempdir = tempfile::tempdir().expect("tempdir");
12843    let receiver = Agent::builder()
12844        .with_identity_dir(tempdir.path())
12845        .build()
12846        .await
12847        .expect("receiver agent");
12848    let origin = identity::AgentKeypair::generate().expect("origin keygen");
12849    let machine = identity::MachineKeypair::generate().expect("machine keygen");
12850    let now = 6_000;
12851    let announcement = signed_identity_announcement_fixture(origin.agent_id(), &machine, now);
12852    assert!(
12853        record_authenticated_machine_binding_from_message(
12854            &receiver.authenticated_machine_bindings,
12855            &verified_identity_origin_message(&origin),
12856            &announcement,
12857            now,
12858        )
12859        .await
12860    );
12861    let mut discovered = discovered_agent_fixture(0x66, now, &[], None);
12862    discovered.agent_id = origin.agent_id();
12863    discovered.machine_id = machine.machine_id();
12864    discovered.machine_public_key = machine.public_key().as_bytes().to_vec();
12865    receiver
12866        .insert_discovered_agent_for_testing(discovered)
12867        .await;
12868    assert!(receiver.cached_agent(&origin.agent_id()).await.is_some());
12869
12870    receiver
12871        .evict_revoked_subject(&revocation::RevokedSubject::Machine(machine.machine_id()))
12872        .await;
12873
12874    assert!(receiver.cached_agent(&origin.agent_id()).await.is_none());
12875    assert_eq!(
12876        dm_inbox::authenticated_machine_binding_for_testing(
12877            &receiver.authenticated_machine_bindings,
12878            &origin.agent_id(),
12879        )
12880        .await,
12881        Some(machine.machine_id())
12882    );
12883}
12884
12885#[tokio::test]
12886async fn upsert_discovered_agent_replaces_addresses_on_fresher_announcement() {
12887    let cache = std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new()));
12888    let id = identity::AgentId([7; 32]);
12889
12890    upsert_discovered_agent(
12891        &cache,
12892        discovered_agent_fixture(7, 100, &["10.0.0.1:5483", "8.8.8.8:5483"], None),
12893    )
12894    .await;
12895    // A fresher announcement advertising a NEW address set must REPLACE, not
12896    // accumulate — otherwise a roaming agent grows an unbounded list of dead
12897    // endpoints that each cost a dial timeout.
12898    upsert_discovered_agent(
12899        &cache,
12900        discovered_agent_fixture(7, 200, &["1.2.3.4:5483"], None),
12901    )
12902    .await;
12903
12904    let guard = cache.read().await;
12905    let entry = guard.get(&id).expect("entry present");
12906    assert_eq!(
12907        entry.addresses,
12908        vec!["1.2.3.4:5483".parse().expect("addr")],
12909        "fresher announcement must replace the address set, not union it"
12910    );
12911}
12912
12913#[tokio::test]
12914async fn upsert_discovered_agent_ignores_stale_announcement_addresses() {
12915    let cache = std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new()));
12916    let id = identity::AgentId([8; 32]);
12917
12918    upsert_discovered_agent(
12919        &cache,
12920        discovered_agent_fixture(8, 200, &["1.2.3.4:5483"], None),
12921    )
12922    .await;
12923    // A stale (lower announced_at) announcement must not inject its old
12924    // addresses into the fresher cached record.
12925    upsert_discovered_agent(
12926        &cache,
12927        discovered_agent_fixture(8, 100, &["10.0.0.9:5483"], None),
12928    )
12929    .await;
12930
12931    let guard = cache.read().await;
12932    let entry = guard.get(&id).expect("entry present");
12933    assert_eq!(
12934        entry.addresses,
12935        vec!["1.2.3.4:5483".parse().expect("addr")],
12936        "stale announcement must not add addresses"
12937    );
12938    assert_eq!(
12939        entry.announced_at, 200,
12940        "stale announcement must not regress announced_at"
12941    );
12942}
12943
12944#[tokio::test]
12945async fn upsert_discovered_agent_preserves_known_user_id() {
12946    let cache = std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new()));
12947    let id = identity::AgentId([9; 32]);
12948    let user = identity::UserId([9; 32]);
12949
12950    upsert_discovered_agent(
12951        &cache,
12952        discovered_agent_fixture(9, 100, &["1.2.3.4:5483"], Some(user)),
12953    )
12954    .await;
12955    // A fresher but anonymous announcement must not erase a known user_id.
12956    upsert_discovered_agent(
12957        &cache,
12958        discovered_agent_fixture(9, 200, &["1.2.3.4:5483"], None),
12959    )
12960    .await;
12961
12962    let guard = cache.read().await;
12963    let entry = guard.get(&id).expect("entry present");
12964    assert_eq!(
12965        entry.user_id,
12966        Some(user),
12967        "a fresher anonymous announcement must not erase a disclosed user_id"
12968    );
12969}
12970
12971#[test]
12972fn sort_discovered_machine_sorts_fields() {
12973    let mut machine = DiscoveredMachine {
12974        machine_id: identity::MachineId([3u8; 32]),
12975        addresses: vec![
12976            "10.0.0.2:5483".parse::<std::net::SocketAddr>().unwrap(),
12977            "10.0.0.1:5483".parse::<std::net::SocketAddr>().unwrap(),
12978        ],
12979        announced_at: 100,
12980        last_seen: 100,
12981        machine_public_key: vec![],
12982        nat_type: None,
12983        can_receive_direct: None,
12984        is_relay: None,
12985        is_coordinator: None,
12986        reachable_via: vec![
12987            identity::MachineId([2u8; 32]),
12988            identity::MachineId([1u8; 32]),
12989        ],
12990        relay_candidates: vec![
12991            identity::MachineId([4u8; 32]),
12992            identity::MachineId([3u8; 32]),
12993        ],
12994        agent_ids: vec![identity::AgentId([2u8; 32]), identity::AgentId([1u8; 32])],
12995        user_ids: vec![identity::UserId([2u8; 32]), identity::UserId([1u8; 32])],
12996    };
12997    sort_discovered_machine(&mut machine);
12998    assert_eq!(
12999        machine.addresses[0],
13000        "10.0.0.1:5483".parse::<std::net::SocketAddr>().unwrap()
13001    );
13002    assert_eq!(
13003        machine.addresses[1],
13004        "10.0.0.2:5483".parse::<std::net::SocketAddr>().unwrap()
13005    );
13006    assert_eq!(machine.reachable_via[0], identity::MachineId([1u8; 32]));
13007    assert_eq!(machine.reachable_via[1], identity::MachineId([2u8; 32]));
13008    assert_eq!(machine.relay_candidates[0], identity::MachineId([3u8; 32]));
13009    assert_eq!(machine.relay_candidates[1], identity::MachineId([4u8; 32]));
13010    assert_eq!(machine.agent_ids[0], identity::AgentId([1u8; 32]));
13011    assert_eq!(machine.agent_ids[1], identity::AgentId([2u8; 32]));
13012    assert_eq!(machine.user_ids[0], identity::UserId([1u8; 32]));
13013    assert_eq!(machine.user_ids[1], identity::UserId([2u8; 32]));
13014}
13015
13016#[tokio::test]
13017async fn dm_inbox_capability_upgrade_visible_to_late_subscriber() {
13018    // Regression test for issue #101: x0xd starts the DM inbox before the
13019    // capability advert service subscribes to the capabilities watch. The
13020    // upgrade must be stored in the channel (send_replace), not merely
13021    // broadcast to current receivers (send) — otherwise a late subscriber
13022    // observes the stale pending state, advertises gossip_inbox=false for
13023    // the process lifetime, and cross-NAT DMs fall back to the raw-QUIC
13024    // path that black-holes.
13025    let dir = tempfile::tempdir().expect("tmpdir");
13026    let agent = Agent::builder()
13027        .with_machine_key(dir.path().join("machine.key"))
13028        .with_agent_key_path(dir.path().join("agent.key"))
13029        .with_peer_cache_dir(dir.path().join("peers"))
13030        .with_network_config(network::NetworkConfig::default())
13031        .build()
13032        .await
13033        .expect("agent");
13034
13035    let kem = std::sync::Arc::new(
13036        groups::kem_envelope::AgentKemKeypair::generate().expect("kem keypair"),
13037    );
13038    agent
13039        .start_dm_inbox(kem, dm_inbox::DmInboxConfig::default())
13040        .await
13041        .expect("start dm inbox");
13042
13043    // Subscribe only AFTER the inbox started — mirrors the x0xd startup
13044    // order (start_dm_inbox_when_gossip_ready runs before
13045    // start_capability_advert_service).
13046    let late_rx = agent.dm_capabilities_tx.subscribe();
13047    let caps = late_rx.borrow().clone();
13048    assert!(
13049        caps.gossip_inbox,
13050        "DM capability upgrade must be visible to subscribers that attach after start_dm_inbox (issue #101)"
13051    );
13052    assert!(
13053        !caps.kem_public_key.is_empty(),
13054        "upgraded capabilities must carry the KEM public key"
13055    );
13056    agent.stop_dm_inbox().await;
13057}
13058
13059#[test]
13060fn deserialize_identity_announcement_rejects_empty() {
13061    let result = deserialize_identity_announcement(&[]);
13062    assert!(result.is_err());
13063}
13064
13065#[test]
13066fn deserialize_machine_announcement_rejects_empty() {
13067    let result = deserialize_machine_announcement(&[]);
13068    assert!(result.is_err());
13069}
13070
13071#[test]
13072fn deserialize_identity_announcement_rejects_garbage() {
13073    let result = deserialize_identity_announcement(b"not-a-valid-bincode");
13074    assert!(result.is_err());
13075}
13076
13077#[test]
13078fn deserialize_machine_announcement_rejects_garbage() {
13079    let result = deserialize_machine_announcement(b"not-a-valid-bincode");
13080    assert!(result.is_err());
13081}