agent-uri-dht 0.12.0

Capability-based DHT discovery for agent:// URI scheme
Documentation
//! DHT trait definition for capability-based agent discovery.

use std::time::{Duration, SystemTime};

use agent_uri::AgentUri;
use async_trait::async_trait;

use crate::{
    DhtError, Endpoint, MutationProof, NodeId, Page, PeerAddr, Query, ReadOptions, Registration,
    WriteOptions, WriteReceipt,
};

/// Abstract DHT operations.
///
/// Implementations may be in-process ([`crate::SimulatedDht`]) or distributed
/// over a real overlay. Every method is asynchronous because every operation on
/// a real backend crosses the network.
///
/// # Scope
///
/// Every lookup is scoped to a single trust root, because every DHT key is
/// derived from one. There is deliberately no cross-trust-root query: the
/// specification's discovery protocol is trust-root scoped throughout, and
/// cross-trust-root isolation is a security property that bounds the blast
/// radius of a trust-root key compromise.
///
/// # Authorization
///
/// Registration records are world-readable, so an agent URI is public
/// knowledge and proves nothing. Neither does the attestation token stored
/// beside it, which any lookup returns. Every write therefore carries a
/// [`MutationProof`] signed by the agent's own key: registration signs the
/// record it creates, later writes sign against the record they found. A
/// backend that accepted an unproven write would let any node that can reach a
/// replica repoint an agent at its own infrastructure.
///
/// # Failure is normal
///
/// A distributed lookup does not simply succeed or find nothing. It can time
/// out, reach too few replicas, or find no peers at all, and each is a distinct
/// [`DhtError`]. Callers that collapse these into "not found" will treat a
/// partitioned network as an empty one.
///
/// # Pagination
///
/// A broad capability path can hold more registrations than one read can
/// return, so [`Dht::lookup`] returns a [`Page`]. Callers that need every
/// result must follow [`Page::next_cursor`] until it is `None`; a caller that
/// reads only the first page is sampling, not enumerating.
///
/// # Object safety
///
/// This trait uses `#[async_trait]` rather than native `async fn` so that it
/// stays dyn-compatible. Selecting a backend at runtime behind
/// `Box<dyn Dht>` is the reason the trait exists, and native `async fn` in
/// trait cannot express that.
#[async_trait]
pub trait Dht: Send + Sync {
    /// Registers an agent at its capability path.
    ///
    /// The agent becomes discoverable at its exact capability path and at every
    /// ancestor path. How a backend arranges that is its own business:
    /// SPECIFICATION.md ยง6.2 defines a direct model, which writes the record to
    /// every one of those keys, and a sharded model, which writes it once and
    /// leaves pointers at the rest. A caller sees the same behaviour from both.
    ///
    /// `proof` must be signed by the record's own agent key over the record as
    /// submitted, and the attestation must name that same key. Together those
    /// say the registering party holds the key the trust root vouched for; the
    /// token alone would say only that some trust root vouched for someone.
    ///
    /// # Errors
    ///
    /// Returns `DhtError` if:
    /// - The proof does not authorize this record (`Unauthorized`)
    /// - The agent is already registered (`AlreadyRegistered`)
    /// - The endpoints list is empty (`NoEndpoints`)
    /// - The DHT key is at capacity (`KeyCapacityExceeded`)
    /// - Attestation verification fails (`InvalidAttestation`)
    /// - The attestation names a different key than the record
    ///   (`AgentKeyMismatch`)
    /// - The operation times out, finds no peers, or misses quorum
    ///   (`Timeout`, `NoPeers`, `QuorumFailed`)
    async fn register(
        &self,
        registration: Registration,
        proof: &MutationProof,
        options: WriteOptions,
    ) -> Result<WriteReceipt, DhtError>;

    /// Updates an existing registration's endpoints.
    ///
    /// Used for agent migration: changing network location without changing
    /// identity.
    ///
    /// `proof` must be signed by the record's agent key over exactly these
    /// endpoints, so an intercepted migration cannot be re-aimed. It must also
    /// name the expiry the record already carries, which is the expiry it
    /// keeps: a migration moves an agent, it does not change when the record
    /// lapses. A backend takes that instant from the record it holds rather
    /// than from the caller, so there is no second opinion about it.
    ///
    /// # Errors
    ///
    /// Returns `DhtError` if the agent is not registered (`NotFound`), the
    /// registration has expired (`Expired`), the endpoints list is empty
    /// (`NoEndpoints`), the proof does not authorize this write
    /// (`Unauthorized`, `StaleSequence`), or the operation fails to reach the
    /// network.
    async fn update_endpoint(
        &self,
        agent_uri: &AgentUri,
        new_endpoints: Vec<Endpoint>,
        proof: &MutationProof,
        options: WriteOptions,
    ) -> Result<WriteReceipt, DhtError>;

    /// Extends an existing registration's lifetime.
    ///
    /// Registrations expire, so an agent that intends to remain reachable must
    /// republish before its TTL elapses. Without this a long-lived agent has to
    /// deregister and re-register, which makes it briefly undiscoverable.
    ///
    /// `expires_at` is the instant the record ends up holding, and a backend
    /// applies it verbatim; `ttl` is the lifetime the agent asked for. Both are
    /// signed, and the caller supplies both because a backend deriving the
    /// instant from the TTL would arrive at one the agent never signed. Only
    /// the signer knows when it signed. Callers ordinarily pass
    /// `SystemTime::now() + ttl` for the instant, computed once and used for
    /// both the proof and this call.
    ///
    /// # Errors
    ///
    /// Returns `DhtError` if the agent is not registered (`NotFound`), the
    /// proof does not authorize this write (`Unauthorized`, `StaleSequence`),
    /// or the operation fails to reach the network.
    async fn refresh(
        &self,
        agent_uri: &AgentUri,
        ttl: Duration,
        expires_at: SystemTime,
        proof: &MutationProof,
        options: WriteOptions,
    ) -> Result<WriteReceipt, DhtError>;

    /// Removes a registration.
    ///
    /// # Errors
    ///
    /// Returns `DhtError::NotFound` if the agent is not registered,
    /// `Unauthorized` or `StaleSequence` if the proof does not authorize the
    /// removal, or a network error if the operation fails to reach the
    /// network.
    async fn deregister(
        &self,
        agent_uri: &AgentUri,
        proof: &MutationProof,
        options: WriteOptions,
    ) -> Result<(), DhtError>;

    /// Looks up agents matching a capability query.
    ///
    /// Returns one page of results. An empty page with no cursor means no
    /// agents matched; an empty page *with* a cursor means the shard read was
    /// empty and more shards remain.
    ///
    /// # Errors
    ///
    /// Returns `DhtError` if the lookup times out, finds no peers, or fails to
    /// reach the requested quorum.
    async fn lookup(
        &self,
        query: &Query,
        options: &ReadOptions,
    ) -> Result<Page<Registration>, DhtError>;

    /// Returns this node's identity in the overlay.
    fn local_id(&self) -> NodeId;

    /// Joins the overlay through the given peers.
    ///
    /// # Errors
    ///
    /// Returns `DhtError::NoPeers` if no peer could be reached, or
    /// `DhtError::Timeout` if the attempt did not complete in time.
    async fn bootstrap(&self, peers: &[PeerAddr]) -> Result<(), DhtError>;
}