From 27603c906df4258a10ba0f8e05b36126b481f6f5 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 15:11:40 +0000 Subject: [PATCH] =?UTF-8?q?feat(brahman-dht):=20B3=20=E2=80=94=20discovery?= =?UTF-8?q?=20typed=20sobre=20el=20Kademlia=20compartido?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit brahman-net corre un único Kademlia para todo el ecosistema. brahman-dht le pone arriba un esquema de claves namespaced para que distintos dominios coexistan sin colisión en la misma malla. - key — RecordKind (Code/Card/Persona/Service/Custom) + DhtKey. Wire: [kind_tag] ++ blake3(id) = 33 bytes longitud fija. Custom(n) usa 0x80|n: nunca choca con los kinds estándar. - Dht — wrapper sobre BrahmanNet: announce/withdraw/find (modelo de provider records). Consumidores: minga (Code), brahman-card-discovery (Card), agorapura (Persona). 5 tests verdes (incl. smoke async sobre un nodo libp2p real). Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 11 +++ Cargo.toml | 1 + crates/protocol/brahman-dht/Cargo.toml | 18 ++++ crates/protocol/brahman-dht/src/key.rs | 124 +++++++++++++++++++++++++ crates/protocol/brahman-dht/src/lib.rs | 72 ++++++++++++++ 5 files changed, 226 insertions(+) create mode 100644 crates/protocol/brahman-dht/Cargo.toml create mode 100644 crates/protocol/brahman-dht/src/key.rs create mode 100644 crates/protocol/brahman-dht/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 042b4a4..e0556cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1746,6 +1746,17 @@ dependencies = [ "ulid", ] +[[package]] +name = "brahman-dht" +version = "0.1.0" +dependencies = [ + "blake3", + "brahman-net", + "libp2p", + "serde", + "tokio", +] + [[package]] name = "brahman-handshake" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 14a3afd..e50a9ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/protocol/brahman-admin", "crates/protocol/brahman-sidecar", "crates/protocol/brahman-net", + "crates/protocol/brahman-dht", "crates/protocol/arje-card", # ============================================================ diff --git a/crates/protocol/brahman-dht/Cargo.toml b/crates/protocol/brahman-dht/Cargo.toml new file mode 100644 index 0000000..4037d3a --- /dev/null +++ b/crates/protocol/brahman-dht/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "brahman-dht" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Brahman — capa de discovery typed sobre el Kademlia compartido de brahman-net. Claves namespaced por kind (Code/Card/Persona/Service) sin colisión en una sola malla." + +[dependencies] +brahman-net = { path = "../brahman-net" } +libp2p = { workspace = true } +blake3 = { workspace = true } +serde = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/protocol/brahman-dht/src/key.rs b/crates/protocol/brahman-dht/src/key.rs new file mode 100644 index 0000000..8f79600 --- /dev/null +++ b/crates/protocol/brahman-dht/src/key.rs @@ -0,0 +1,124 @@ +//! Claves namespaced del DHT compartido. +//! +//! El ecosistema brahman corre UN solo Kademlia (en `brahman-net`). Para +//! que distintos dominios — código indexado (minga), Cards, Personas +//! (ágora) — coexistan sin colisión, cada clave lleva un byte de `kind` +//! como prefijo. La representación en wire es de longitud fija: +//! `[kind_tag] ++ blake3(id)` = 33 bytes. + +use serde::{Deserialize, Serialize}; + +/// Tipo de registro — el namespace de una clave en el DHT. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RecordKind { + /// Bloque de código indexado (minga). + Code, + /// `Card` brahman (módulo, ente, etc.). + Card, + /// `Persona` de ágora (identidad humana federada). + Persona, + /// Endpoint de servicio. + Service, + /// Dominio definido por el consumidor. + Custom(u8), +} + +impl RecordKind { + /// Byte de etiqueta. `Custom(n)` ocupa `0x80 | n` (top bit) para no + /// chocar nunca con los kinds estándar (`0x00..`). + pub fn tag(&self) -> u8 { + match self { + RecordKind::Code => 0x01, + RecordKind::Card => 0x02, + RecordKind::Persona => 0x03, + RecordKind::Service => 0x04, + RecordKind::Custom(n) => 0x80 | (n & 0x7f), + } + } +} + +/// Longitud fija de la clave en wire: 1 byte de kind + 32 de hash. +pub const DHT_KEY_LEN: usize = 33; + +/// Clave de DHT namespaced. Se construye con un `id` legible; la +/// representación en wire hashea el `id` con blake3. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DhtKey { + kind: RecordKind, + id: String, +} + +impl DhtKey { + pub fn new(kind: RecordKind, id: impl Into) -> Self { + Self { kind, id: id.into() } + } + + /// Clave para un bloque de código. + pub fn code(id: impl Into) -> Self { + Self::new(RecordKind::Code, id) + } + + /// Clave para una Card. + pub fn card(id: impl Into) -> Self { + Self::new(RecordKind::Card, id) + } + + /// Clave para una Persona. + pub fn persona(id: impl Into) -> Self { + Self::new(RecordKind::Persona, id) + } + + pub fn kind(&self) -> RecordKind { + self.kind + } + + pub fn id(&self) -> &str { + &self.id + } + + /// Representación en wire: `[kind_tag] ++ blake3(id)`, 33 bytes. + pub fn to_bytes(&self) -> Vec { + let mut out = Vec::with_capacity(DHT_KEY_LEN); + out.push(self.kind.tag()); + out.extend_from_slice(blake3::hash(self.id.as_bytes()).as_bytes()); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_key_has_fixed_length() { + assert_eq!(DhtKey::card("modulo-x").to_bytes().len(), DHT_KEY_LEN); + assert_eq!(DhtKey::code("fn-hash").to_bytes().len(), DHT_KEY_LEN); + } + + #[test] + fn same_id_different_kind_does_not_collide() { + let a = DhtKey::card("foo").to_bytes(); + let b = DhtKey::code("foo").to_bytes(); + let c = DhtKey::persona("foo").to_bytes(); + assert_ne!(a, b); + assert_ne!(b, c); + assert_ne!(a, c); + // El hash del id es el mismo; sólo difiere el byte de kind. + assert_eq!(a[1..], b[1..]); + assert_ne!(a[0], b[0]); + } + + #[test] + fn same_kind_and_id_is_stable() { + assert_eq!(DhtKey::card("x").to_bytes(), DhtKey::card("x").to_bytes()); + } + + #[test] + fn custom_kind_never_collides_with_standard() { + for std in [RecordKind::Code, RecordKind::Card, RecordKind::Persona, RecordKind::Service] { + for n in 0..=127u8 { + assert_ne!(std.tag(), RecordKind::Custom(n).tag()); + } + } + } +} diff --git a/crates/protocol/brahman-dht/src/lib.rs b/crates/protocol/brahman-dht/src/lib.rs new file mode 100644 index 0000000..2eb0a45 --- /dev/null +++ b/crates/protocol/brahman-dht/src/lib.rs @@ -0,0 +1,72 @@ +//! `brahman-dht` — capa de discovery typed sobre el Kademlia compartido. +//! +//! `brahman-net` corre un único Kademlia para todo el ecosistema. Este +//! crate le pone arriba un esquema de claves namespaced ([`DhtKey`]): +//! `minga` publica bloques de código, `brahman-card-discovery` publica +//! Cards, `agorapura` publica Personas — todo sobre la misma malla sin +//! colisión, porque cada clave lleva un byte de `kind`. +//! +//! El modelo es de **provider records**: un nodo `announce`-a que provee +//! una clave; otros `find`-an quién la provee y abren un stream directo. + +#![forbid(unsafe_code)] + +pub mod key; + +pub use key::{DhtKey, RecordKind, DHT_KEY_LEN}; + +use brahman_net::BrahmanNet; +use libp2p::PeerId; +use std::sync::Arc; + +/// Discovery typed sobre `brahman-net`. +#[derive(Clone)] +pub struct Dht { + net: Arc, +} + +impl Dht { + /// Crea la capa DHT sobre un nodo `brahman-net` ya inicializado. + pub fn new(net: Arc) -> Self { + Self { net } + } + + /// Anuncia que este nodo provee `key`. El registro de provider se + /// renueva solo mientras el nodo siga vivo en la malla. + pub fn announce(&self, key: &DhtKey) { + self.net.start_providing(&key.to_bytes()); + } + + /// Retira el anuncio de `key`. + pub fn withdraw(&self, key: &DhtKey) { + self.net.stop_providing(&key.to_bytes()); + } + + /// Busca los peers que proveen `key`. + pub async fn find(&self, key: &DhtKey) -> Vec { + self.net.find_providers(&key.to_bytes()).await + } + + /// El nodo `brahman-net` subyacente (para abrir streams a un provider). + pub fn net(&self) -> &Arc { + &self.net + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn announce_find_withdraw_on_a_live_node() { + // Smoke: un nodo solo. `find` de una clave que nadie provee + // devuelve vacío; announce/withdraw no panickean. + let net = Arc::new(BrahmanNet::new().expect("nodo libp2p")); + let dht = Dht::new(net); + let key = DhtKey::card("modulo-inexistente"); + dht.announce(&key); + dht.withdraw(&key); + let found = dht.find(&DhtKey::card("nadie-lo-provee")).await; + assert!(found.is_empty()); + } +}