tawasuyu

tawasuyu/brahman

Rust 463TOML 180Markdown 49JSON 176353 Definiciones

feat(agorapura): identidad humana federada — core + grafo de confianza

agorapura-core: identidades fractales (persona/comunidad/alianza/
institución) sobre claves ed25519, Claims sujeto-predicado-valor y
Attestations firmadas y autoverificables (la prueba viaja con el
dato). agorapura-graph: TrustGraph guarda sólo atestaciones con firma
válida; corroboration() devuelve evidencia cruda y TrustPolicy —un
umbral negociado, no una verdad del sistema— la traduce a sí/no.

22 tests. Cero red, cero estado global, #![forbid(unsafe_code)].

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

c1c136954e537c25ec36c80f35746f3d99c6b7b1 · por sergio · (hace 5 meses) · padre ad9781c2e · ver el árbol en este commit

Delta semántico · 77

crates/modules/agorapura/agorapura-core/src/attest.rs

crates/modules/agorapura/agorapura-core/src/claim.rs

crates/modules/agorapura/agorapura-core/src/identity.rs

crates/modules/agorapura/agorapura-core/src/lib.rs

crates/modules/agorapura/agorapura-graph/src/lib.rs

10 archivos · +847 −0

diff --git a/Cargo.lock b/Cargo.lock
index 57eab1b..cb340e1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -80,6 +80,24 @@ dependencies = [
  "tokio",
 ]
 
+[[package]]
+name = "agorapura-core"
+version = "0.1.0"
+dependencies = [
+ "blake3",
+ "ed25519-dalek",
+ "serde",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "agorapura-graph"
+version = "0.1.0"
+dependencies = [
+ "agorapura-core",
+ "serde",
+]
+
 [[package]]
 name = "ahash"
 version = "0.7.8"
diff --git a/Cargo.toml b/Cargo.toml
index 5038407..95bf776 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -119,6 +119,12 @@ members = [
     "crates/modules/verbo/verbo-mock",
     "crates/modules/verbo/verbo-daemon",
 
+    # ============================================================
+    # modules/agorapura/ — Identidad humana federada
+    # ============================================================
+    "crates/modules/agorapura/agorapura-core",
+    "crates/modules/agorapura/agorapura-graph",
+
     # ============================================================
     # modules/nakui/ — ERP matemático (categórico)
     # ============================================================
diff --git a/crates/modules/agorapura/SDD.md b/crates/modules/agorapura/SDD.md
new file mode 100644
index 0000000..fe12f70
--- /dev/null
+++ b/crates/modules/agorapura/SDD.md
@@ -0,0 +1,57 @@
+# modules/agorapura/ — Identidad humana federada
+
+**Propósito.** Un ágora de identidad sin autoridad central. Cada
+identidad —persona, comunidad, alianza, institución— es una clave
+pública; cada afirmación sobre ella, un claim; cada respaldo, una
+atestación firmada que viaja con su propia prueba. La verdad no la dicta
+un servidor: emerge de quién atestigua qué, ponderado por la política
+que negocie quien lee.
+
+## Crates
+
+| crate             | tipo | rol                                                          |
+| ----------------- | ---- | ------------------------------------------------------------ |
+| `agorapura-core`  | lib  | `Identity`/`Keypair` (ed25519), `Claim`, `Attestation` firmada y autoverificable |
+| `agorapura-graph` | lib  | `TrustGraph` (atestaciones verificadas) + `Corroboration` + `TrustPolicy` negociada |
+
+## Modelo
+
+```text
+  Keypair ──► Identity (kind: Person|Community|Alliance|Institution)
+                  │
+   Claim (subject · predicate = value) ──firmado──► Attestation
+                  │                                      │
+                  └──────────► TrustGraph ◄──────────────┘
+                                   │
+                       corroboration(claim) → Corroboration
+                                   │
+                          TrustPolicy.accepts() → sí / no
+```
+
+- **Fractal**: persona, comunidad, alianza e institución comparten
+  estructura idéntica. Que una institución atestigüe sobre una persona
+  o una alianza sobre una comunidad es la misma operación.
+- **Autoverificable**: una `Attestation` lleva la clave pública del
+  atestador y su firma — cualquiera la valida sin consultar a nadie.
+- **Sin veredicto central**: el grafo devuelve evidencia cruda
+  (`Corroboration`); la validez la decide una `TrustPolicy` *negociada*.
+  Dos consumidores con políticas distintas pueden discrepar legítimamente
+  sobre la misma red.
+
+## Dependencias
+
+- `core` ← `ed25519-dalek`, `blake3` (id = BLAKE3 de la clave pública).
+- `graph` ← `agorapura-core`. Ambos `#![forbid(unsafe_code)]`.
+- Cero red, cero estado global — tipos puros. El transporte y el
+  descubrimiento (DHT, Cards) van en capas superiores.
+
+## Determinismo
+
+`Keypair::from_seed` es determinista — tests y derivación jerárquica de
+claves dependen de ello. Una identidad real siembra desde un CSPRNG.
+
+## Estado
+
+`core` + `graph` implementados y verdes (22 tests). **Pendiente**:
+integración con `CardKind` (variantes Person/Community/Alliance/
+Institution en el protocolo) y descubrimiento federado vía DHT.
diff --git a/crates/modules/agorapura/agorapura-core/Cargo.toml b/crates/modules/agorapura/agorapura-core/Cargo.toml
new file mode 100644
index 0000000..c28d390
--- /dev/null
+++ b/crates/modules/agorapura/agorapura-core/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "agorapura-core"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "agorapura — núcleo de identidad federada: identidades fractales (persona/comunidad/alianza/institución), claims y atestaciones firmadas ed25519."
+
+[dependencies]
+ed25519-dalek = { workspace = true }
+blake3 = { workspace = true }
+serde = { workspace = true }
+thiserror = { workspace = true }
diff --git a/crates/modules/agorapura/agorapura-core/src/attest.rs b/crates/modules/agorapura/agorapura-core/src/attest.rs
new file mode 100644
index 0000000..a2937fa
--- /dev/null
+++ b/crates/modules/agorapura/agorapura-core/src/attest.rs
@@ -0,0 +1,131 @@
+//! Atestaciones — un claim respaldado por la firma de una identidad.
+//!
+//! La atestación es la unidad de confianza de agorapura: «la institución
+//! *Venezuela* atestigua que el claim *nacionalidad = venezolana* sobre
+//! *Yumaira* es cierto». Cualquiera puede verificar la firma sin
+//! consultar a nadie — la prueba viaja con el dato.
+
+use serde::{Deserialize, Serialize};
+
+use crate::claim::Claim;
+use crate::identity::{verify_signature, IdentityId, Keypair};
+use crate::AgoraError;
+
+/// Adaptador serde para `[u8; 64]` — serde sólo cubre arrays hasta 32,
+/// así que la firma viaja como secuencia y se revalida su largo al leer.
+mod sig_serde {
+    use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+    pub fn serialize<S: Serializer>(sig: &[u8; 64], s: S) -> Result<S::Ok, S::Error> {
+        sig.as_slice().serialize(s)
+    }
+
+    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 64], D::Error> {
+        let v = Vec::<u8>::deserialize(d)?;
+        v.try_into()
+            .map_err(|_| serde::de::Error::custom("la firma debe ser de 64 bytes"))
+    }
+}
+
+/// Un claim firmado por un atestador. Autoverificable y autónomo.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Attestation {
+    /// El claim respaldado.
+    pub claim: Claim,
+    /// Identidad que firma — derivada de `attester_key`.
+    pub attester: IdentityId,
+    /// Clave pública del atestador (para verificar sin un directorio).
+    pub attester_key: [u8; 32],
+    /// Firma ed25519 sobre `claim.canonical_bytes()`.
+    #[serde(with = "sig_serde")]
+    pub signature: [u8; 64],
+}
+
+impl Attestation {
+    /// Crea una atestación firmando `claim` con `keypair`.
+    pub fn create(keypair: &Keypair, claim: Claim) -> Self {
+        let signature = keypair.sign(&claim.canonical_bytes());
+        Self {
+            claim,
+            attester: keypair.identity_id(),
+            attester_key: keypair.public_key(),
+            signature,
+        }
+    }
+
+    /// Verifica la atestación. Comprueba dos cosas:
+    /// 1. la firma cubre el claim bajo `attester_key`;
+    /// 2. `attester` coincide con el id derivado de `attester_key`
+    ///    (nadie puede atribuir su firma a otra identidad).
+    pub fn verify(&self) -> Result<(), AgoraError> {
+        if self.attester != IdentityId::from_public_key(&self.attester_key) {
+            return Err(AgoraError::AttesterMismatch);
+        }
+        verify_signature(&self.attester_key, &self.claim.canonical_bytes(), &self.signature)
+    }
+
+    /// `true` si la atestación es de un atestador hablando de sí mismo.
+    /// Una identidad puede declararse cosas, pero esa evidencia vale
+    /// distinto que la de un tercero — quien evalúa decide cuánto.
+    pub fn is_self_attested(&self) -> bool {
+        self.attester == self.claim.subject
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::identity::IdentityKind;
+
+    #[test]
+    fn created_attestation_verifies() {
+        let venezuela = Keypair::from_seed([10; 32]);
+        let yumaira = Keypair::from_seed([20; 32]);
+        let claim = Claim::new(yumaira.identity_id(), "nacionalidad", "venezolana", 1_700_000_000);
+        let att = Attestation::create(&venezuela, claim);
+        assert!(att.verify().is_ok());
+        assert_eq!(att.attester, venezuela.identity_id());
+    }
+
+    #[test]
+    fn tampered_claim_fails_verification() {
+        let venezuela = Keypair::from_seed([10; 32]);
+        let yumaira = Keypair::from_seed([20; 32]);
+        let claim = Claim::new(yumaira.identity_id(), "nacionalidad", "venezolana", 1_700_000_000);
+        let mut att = Attestation::create(&venezuela, claim);
+        // Alterar el valor invalida la firma.
+        att.claim.value = "marciana".into();
+        assert!(matches!(att.verify(), Err(AgoraError::BadSignature)));
+    }
+
+    #[test]
+    fn spoofed_attester_is_rejected() {
+        let real = Keypair::from_seed([10; 32]);
+        let impostor = Keypair::from_seed([99; 32]);
+        let yumaira = Keypair::from_seed([20; 32]);
+        let claim = Claim::new(yumaira.identity_id(), "habilidad", "soldadura", 0);
+        let mut att = Attestation::create(&real, claim);
+        // Reatribuir la atestación a otra identidad la rompe.
+        att.attester = impostor.identity_id();
+        assert!(matches!(att.verify(), Err(AgoraError::AttesterMismatch)));
+    }
+
+    #[test]
+    fn self_attestation_is_flagged() {
+        let yumaira = Keypair::from_seed([20; 32]);
+        let claim = Claim::new(yumaira.identity_id(), "habilidad", "carpintería", 0);
+        let att = Attestation::create(&yumaira, claim);
+        assert!(att.verify().is_ok());
+        assert!(att.is_self_attested());
+    }
+
+    #[test]
+    fn third_party_attestation_is_not_self() {
+        let comunidad = Keypair::from_seed([30; 32]);
+        let _ = comunidad.identity(IdentityKind::Community, "Vecinos del Valle");
+        let yumaira = Keypair::from_seed([20; 32]);
+        let claim = Claim::new(yumaira.identity_id(), "miembro-de", "Vecinos del Valle", 0);
+        let att = Attestation::create(&comunidad, claim);
+        assert!(!att.is_self_attested());
+    }
+}
diff --git a/crates/modules/agorapura/agorapura-core/src/claim.rs b/crates/modules/agorapura/agorapura-core/src/claim.rs
new file mode 100644
index 0000000..5f66108
--- /dev/null
+++ b/crates/modules/agorapura/agorapura-core/src/claim.rs
@@ -0,0 +1,87 @@
+//! Claims — afirmaciones sobre una identidad.
+//!
+//! Un [`Claim`] es una tripleta sujeto–predicado–valor: *«de Yumaira
+//! (sujeto), su nacionalidad (predicado) es venezolana (valor)»*. El
+//! claim por sí solo no afirma nada — sólo cuando alguien lo firma
+//! ([`crate::Attestation`]) adquiere peso.
+
+use serde::{Deserialize, Serialize};
+
+use crate::identity::IdentityId;
+
+/// Una afirmación sobre una identidad. Inerte hasta que una atestación
+/// la respalda.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Claim {
+    /// La identidad de la que trata el claim.
+    pub subject: IdentityId,
+    /// Qué se afirma — `"nacionalidad"`, `"miembro-de"`, `"habilidad"`.
+    pub predicate: String,
+    /// El valor afirmado — `"venezolana"`, el id de una comunidad, etc.
+    pub value: String,
+    /// Segundos Unix en que se emitió el claim.
+    pub issued_at: u64,
+}
+
+impl Claim {
+    /// Construye un claim.
+    pub fn new(
+        subject: IdentityId,
+        predicate: impl Into<String>,
+        value: impl Into<String>,
+        issued_at: u64,
+    ) -> Self {
+        Self {
+            subject,
+            predicate: predicate.into(),
+            value: value.into(),
+            issued_at,
+        }
+    }
+
+    /// Serialización canónica determinista — el mensaje exacto que se
+    /// firma. Cada campo va con prefijo de largo para que no haya
+    /// ambigüedad de fronteras entre `predicate` y `value`.
+    pub fn canonical_bytes(&self) -> Vec<u8> {
+        let mut out = Vec::with_capacity(64 + self.predicate.len() + self.value.len());
+        out.extend_from_slice(b"agorapura-claim\x01");
+        out.extend_from_slice(self.subject.as_bytes());
+        out.extend_from_slice(&self.issued_at.to_le_bytes());
+        out.extend_from_slice(&(self.predicate.len() as u64).to_le_bytes());
+        out.extend_from_slice(self.predicate.as_bytes());
+        out.extend_from_slice(&(self.value.len() as u64).to_le_bytes());
+        out.extend_from_slice(self.value.as_bytes());
+        out
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::identity::Keypair;
+
+    fn subject() -> IdentityId {
+        Keypair::from_seed([9; 32]).identity_id()
+    }
+
+    #[test]
+    fn canonical_bytes_are_deterministic() {
+        let c = Claim::new(subject(), "nacionalidad", "venezolana", 1_700_000_000);
+        assert_eq!(c.canonical_bytes(), c.clone().canonical_bytes());
+    }
+
+    #[test]
+    fn distinct_claims_have_distinct_canonical_bytes() {
+        let a = Claim::new(subject(), "nacionalidad", "venezolana", 1_700_000_000);
+        let b = Claim::new(subject(), "nacionalidad", "colombiana", 1_700_000_000);
+        assert_ne!(a.canonical_bytes(), b.canonical_bytes());
+    }
+
+    #[test]
+    fn field_boundaries_are_unambiguous() {
+        // Sin prefijo de largo, "ab"+"c" colisionaría con "a"+"bc".
+        let a = Claim::new(subject(), "ab", "c", 0);
+        let b = Claim::new(subject(), "a", "bc", 0);
+        assert_ne!(a.canonical_bytes(), b.canonical_bytes());
+    }
+}
diff --git a/crates/modules/agorapura/agorapura-core/src/identity.rs b/crates/modules/agorapura/agorapura-core/src/identity.rs
new file mode 100644
index 0000000..5b505f5
--- /dev/null
+++ b/crates/modules/agorapura/agorapura-core/src/identity.rs
@@ -0,0 +1,180 @@
+//! Identidades fractales — y el par de claves que las firma.
+//!
+//! Una persona, una comunidad, una alianza y una institución comparten
+//! exactamente la misma estructura: una clave pública, un tipo y un
+//! nombre. La identidad es *fractal* — autosemejante en cada escala. Que
+//! una comunidad atestigüe sobre una persona, o una institución sobre
+//! una comunidad, no es un caso especial: es la misma operación.
+
+use serde::{Deserialize, Serialize};
+use std::fmt;
+
+use crate::AgoraError;
+
+/// Naturaleza de una identidad. No cambia su estructura — sólo informa
+/// a quien la lee a qué escala social pertenece.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum IdentityKind {
+    /// Un ser humano individual.
+    Person,
+    /// Una agrupación de identidades con un propósito común.
+    Community,
+    /// Una federación de comunidades.
+    Alliance,
+    /// Una entidad formal y persistente (un Estado, una universidad).
+    Institution,
+}
+
+/// Identificador estable de una identidad: BLAKE3 de su clave pública.
+/// Inmutable mientras la clave no cambie.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct IdentityId([u8; 32]);
+
+impl IdentityId {
+    /// Deriva el id desde una clave pública ed25519.
+    pub fn from_public_key(public_key: &[u8; 32]) -> Self {
+        Self(*blake3::hash(public_key).as_bytes())
+    }
+
+    /// Bytes crudos del identificador.
+    pub fn as_bytes(&self) -> &[u8; 32] {
+        &self.0
+    }
+}
+
+impl fmt::Display for IdentityId {
+    /// Prefijo hex abreviado — suficiente para distinguir identidades.
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        for b in &self.0[..6] {
+            write!(f, "{b:02x}")?;
+        }
+        write!(f, "…")
+    }
+}
+
+/// La cara pública de una identidad: lo que se publica y se comparte.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Identity {
+    pub kind: IdentityKind,
+    /// Clave pública ed25519.
+    pub public_key: [u8; 32],
+    /// Nombre legible — no es único ni autoritativo, sólo presentación.
+    pub display_name: String,
+}
+
+impl Identity {
+    /// Identificador derivado de la clave pública.
+    pub fn id(&self) -> IdentityId {
+        IdentityId::from_public_key(&self.public_key)
+    }
+}
+
+/// El par de claves de una identidad. La clave privada vive **sólo** en
+/// poder de su dueño; nunca se serializa ni viaja por la red.
+pub struct Keypair {
+    signing: ed25519_dalek::SigningKey,
+}
+
+impl Keypair {
+    /// Construye un par determinista desde una semilla de 32 bytes.
+    ///
+    /// La reproducibilidad es deliberada: tests y derivación jerárquica
+    /// de claves dependen de ella. Para una identidad real, la semilla
+    /// debe venir de un CSPRNG.
+    pub fn from_seed(seed: [u8; 32]) -> Self {
+        Self { signing: ed25519_dalek::SigningKey::from_bytes(&seed) }
+    }
+
+    /// Clave pública (32 bytes) de este par.
+    pub fn public_key(&self) -> [u8; 32] {
+        self.signing.verifying_key().to_bytes()
+    }
+
+    /// Identificador de la identidad de este par.
+    pub fn identity_id(&self) -> IdentityId {
+        IdentityId::from_public_key(&self.public_key())
+    }
+
+    /// Arma la `Identity` pública correspondiente.
+    pub fn identity(&self, kind: IdentityKind, display_name: impl Into<String>) -> Identity {
+        Identity {
+            kind,
+            public_key: self.public_key(),
+            display_name: display_name.into(),
+        }
+    }
+
+    /// Firma un mensaje arbitrario, devolviendo los 64 bytes de la firma.
+    pub fn sign(&self, message: &[u8]) -> [u8; 64] {
+        use ed25519_dalek::Signer;
+        self.signing.sign(message).to_bytes()
+    }
+}
+
+/// Verifica una firma ed25519 contra una clave pública y un mensaje.
+pub fn verify_signature(
+    public_key: &[u8; 32],
+    message: &[u8],
+    signature: &[u8; 64],
+) -> Result<(), AgoraError> {
+    use ed25519_dalek::Verifier;
+    let vk = ed25519_dalek::VerifyingKey::from_bytes(public_key)
+        .map_err(|_| AgoraError::BadPublicKey)?;
+    let sig = ed25519_dalek::Signature::from_bytes(signature);
+    vk.verify(message, &sig).map_err(|_| AgoraError::BadSignature)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn id_is_stable_for_a_key() {
+        let kp = Keypair::from_seed([7; 32]);
+        let a = kp.identity(IdentityKind::Person, "Yumaira");
+        let b = kp.identity(IdentityKind::Person, "otro nombre");
+        // El id depende de la clave, no del nombre.
+        assert_eq!(a.id(), b.id());
+    }
+
+    #[test]
+    fn different_seeds_yield_different_identities() {
+        let a = Keypair::from_seed([1; 32]).identity_id();
+        let b = Keypair::from_seed([2; 32]).identity_id();
+        assert_ne!(a, b);
+    }
+
+    #[test]
+    fn signature_roundtrips() {
+        let kp = Keypair::from_seed([42; 32]);
+        let sig = kp.sign(b"mensaje de prueba");
+        assert!(verify_signature(&kp.public_key(), b"mensaje de prueba", &sig).is_ok());
+    }
+
+    #[test]
+    fn tampered_message_fails_verification() {
+        let kp = Keypair::from_seed([42; 32]);
+        let sig = kp.sign(b"original");
+        assert!(matches!(
+            verify_signature(&kp.public_key(), b"manipulado", &sig),
+            Err(AgoraError::BadSignature)
+        ));
+    }
+
+    #[test]
+    fn wrong_key_fails_verification() {
+        let signer = Keypair::from_seed([1; 32]);
+        let other = Keypair::from_seed([2; 32]);
+        let sig = signer.sign(b"msg");
+        assert!(verify_signature(&other.public_key(), b"msg", &sig).is_err());
+    }
+
+    #[test]
+    fn id_display_is_abbreviated_hex() {
+        let id = Keypair::from_seed([0; 32]).identity_id();
+        let s = id.to_string();
+        // 6 bytes → 12 dígitos hex, más el elipsis.
+        assert!(s.ends_with('…') && s.chars().count() == 13);
+    }
+}
diff --git a/crates/modules/agorapura/agorapura-core/src/lib.rs b/crates/modules/agorapura/agorapura-core/src/lib.rs
new file mode 100644
index 0000000..21e7326
--- /dev/null
+++ b/crates/modules/agorapura/agorapura-core/src/lib.rs
@@ -0,0 +1,36 @@
+//! `agorapura-core` — identidad humana federada, sin autoridad central.
+//!
+//! El ágora no tiene un registro maestro. Cada identidad —persona,
+//! comunidad, alianza, institución— es una clave pública; cada afirmación
+//! sobre ella es un [`Claim`]; cada respaldo es una [`Attestation`]
+//! firmada que viaja con su propia prueba. La verdad no la dicta un
+//! servidor: emerge de quién atestigua qué, y de cuánto peso le da a
+//! cada atestador quien la lee.
+//!
+//! - [`identity`] — identidades fractales + claves ed25519.
+//! - [`claim`] — afirmaciones sujeto–predicado–valor.
+//! - [`attest`] — claims firmados, autoverificables.
+//!
+//! Cero estado global, cero red: tipos puros. La red de confianza vive
+//! en `agorapura-graph`; el transporte, en capas superiores.
+
+#![forbid(unsafe_code)]
+
+pub mod attest;
+pub mod claim;
+pub mod identity;
+
+pub use attest::Attestation;
+pub use claim::Claim;
+pub use identity::{verify_signature, Identity, IdentityId, IdentityKind, Keypair};
+
+/// Falla de una operación de identidad o atestación.
+#[derive(Debug, thiserror::Error, PartialEq, Eq)]
+pub enum AgoraError {
+    #[error("clave pública ed25519 inválida")]
+    BadPublicKey,
+    #[error("firma inválida: no corresponde al mensaje y la clave")]
+    BadSignature,
+    #[error("el atestador declarado no corresponde a su clave pública")]
+    AttesterMismatch,
+}
diff --git a/crates/modules/agorapura/agorapura-graph/Cargo.toml b/crates/modules/agorapura/agorapura-graph/Cargo.toml
new file mode 100644
index 0000000..c060f86
--- /dev/null
+++ b/crates/modules/agorapura/agorapura-graph/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "agorapura-graph"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "agorapura — red de confianza: almacena atestaciones verificadas y responde corroboración de claims; la validez la decide una política negociada, no el grafo."
+
+[dependencies]
+agorapura-core = { path = "../agorapura-core" }
+serde = { workspace = true }
diff --git a/crates/modules/agorapura/agorapura-graph/src/lib.rs b/crates/modules/agorapura/agorapura-graph/src/lib.rs
new file mode 100644
index 0000000..e0d939b
--- /dev/null
+++ b/crates/modules/agorapura/agorapura-graph/src/lib.rs
@@ -0,0 +1,306 @@
+//! `agorapura-graph` — la red de confianza del ágora.
+//!
+//! Acumula [`Attestation`]s **verificadas** (una atestación con firma
+//! rota nunca entra) y responde preguntas de corroboración: *¿quién
+//! respalda este claim?*
+//!
+//! El grafo deliberadamente **no** emite un veredicto. La verdad del
+//! ágora no es absoluta: depende de cuánto peso le dé a cada atestador
+//! quien la consulta. Por eso [`TrustGraph::corroboration`] devuelve la
+//! evidencia cruda y [`TrustPolicy`] —un umbral *negociado*— la traduce
+//! a un sí/no. Dos consumidores con políticas distintas pueden mirar la
+//! misma red y discrepar legítimamente.
+
+#![forbid(unsafe_code)]
+
+use std::collections::HashMap;
+
+use agorapura_core::{AgoraError, Attestation, Identity, IdentityId};
+use serde::{Deserialize, Serialize};
+
+/// Evidencia acumulada a favor de un claim concreto.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Corroboration {
+    /// Atestadores distintos que respaldan exactamente este claim.
+    pub attesters: Vec<IdentityId>,
+    /// El propio sujeto figura entre los atestadores.
+    pub self_attested: bool,
+}
+
+impl Corroboration {
+    /// `true` si nadie respalda el claim.
+    pub fn is_empty(&self) -> bool {
+        self.attesters.is_empty()
+    }
+
+    /// Atestadores totales (incluido el sujeto si se auto-atestó).
+    pub fn total(&self) -> usize {
+        self.attesters.len()
+    }
+
+    /// Atestadores que no son el sujeto — la evidencia independiente.
+    pub fn third_party(&self) -> usize {
+        self.total() - usize::from(self.self_attested)
+    }
+}
+
+/// Umbral *negociado* de aceptación de un claim. No es una verdad del
+/// sistema: cada consumidor adopta el suyo según lo que pacte.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct TrustPolicy {
+    /// Atestadores terceros distintos exigidos como mínimo.
+    pub min_third_party: usize,
+    /// Si la auto-atestación del sujeto cuenta como respaldo válido.
+    pub accept_self: bool,
+}
+
+impl TrustPolicy {
+    /// Política estricta: al menos `n` terceros, la auto-atestación no
+    /// suma.
+    pub fn strict(min_third_party: usize) -> Self {
+        Self { min_third_party, accept_self: false }
+    }
+
+    /// `true` si la evidencia satisface la política.
+    pub fn accepts(&self, c: &Corroboration) -> bool {
+        if c.third_party() >= self.min_third_party {
+            return true;
+        }
+        self.accept_self && c.self_attested && self.min_third_party == 0
+    }
+}
+
+impl Default for TrustPolicy {
+    /// Por defecto: un tercero basta, la auto-atestación no.
+    fn default() -> Self {
+        Self::strict(1)
+    }
+}
+
+/// La red de confianza: identidades conocidas + atestaciones verificadas.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct TrustGraph {
+    identities: HashMap<IdentityId, Identity>,
+    /// Atestaciones verificadas, en orden de inserción.
+    attestations: Vec<Attestation>,
+}
+
+impl TrustGraph {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Registra (o actualiza) una identidad conocida.
+    pub fn register(&mut self, identity: Identity) {
+        self.identities.insert(identity.id(), identity);
+    }
+
+    /// Identidad registrada con ese id, si la hay.
+    pub fn identity(&self, id: IdentityId) -> Option<&Identity> {
+        self.identities.get(&id)
+    }
+
+    /// Cantidad de identidades registradas.
+    pub fn identity_count(&self) -> usize {
+        self.identities.len()
+    }
+
+    /// Cantidad de atestaciones verificadas almacenadas.
+    pub fn attestation_count(&self) -> usize {
+        self.attestations.len()
+    }
+
+    /// Verifica una atestación y, si es válida y no es duplicada exacta,
+    /// la incorpora. Una firma rota se rechaza con error — la red sólo
+    /// guarda evidencia comprobable.
+    pub fn add_attestation(&mut self, att: Attestation) -> Result<(), AgoraError> {
+        att.verify()?;
+        if !self.attestations.contains(&att) {
+            self.attestations.push(att);
+        }
+        Ok(())
+    }
+
+    /// Atestaciones cuyo claim trata sobre `subject`.
+    pub fn attestations_about(&self, subject: IdentityId) -> Vec<&Attestation> {
+        self.attestations
+            .iter()
+            .filter(|a| a.claim.subject == subject)
+            .collect()
+    }
+
+    /// Atestaciones emitidas por `attester`.
+    pub fn attestations_by(&self, attester: IdentityId) -> Vec<&Attestation> {
+        self.attestations
+            .iter()
+            .filter(|a| a.attester == attester)
+            .collect()
+    }
+
+    /// Atestaciones que respaldan exactamente el claim
+    /// `subject · predicate = value` (la marca de tiempo no importa).
+    pub fn evidence_for(
+        &self,
+        subject: IdentityId,
+        predicate: &str,
+        value: &str,
+    ) -> Vec<&Attestation> {
+        self.attestations
+            .iter()
+            .filter(|a| {
+                a.claim.subject == subject
+                    && a.claim.predicate == predicate
+                    && a.claim.value == value
+            })
+            .collect()
+    }
+
+    /// Resume la corroboración de un claim: atestadores distintos y si
+    /// el sujeto se lo auto-atestó. El veredicto lo pone una
+    /// [`TrustPolicy`].
+    pub fn corroboration(
+        &self,
+        subject: IdentityId,
+        predicate: &str,
+        value: &str,
+    ) -> Corroboration {
+        let mut attesters: Vec<IdentityId> = Vec::new();
+        let mut self_attested = false;
+        for att in self.evidence_for(subject, predicate, value) {
+            if att.is_self_attested() {
+                self_attested = true;
+            }
+            if !attesters.contains(&att.attester) {
+                attesters.push(att.attester);
+            }
+        }
+        Corroboration { attesters, self_attested }
+    }
+
+    /// Atajo: `true` si la `policy` acepta el claim dada la evidencia.
+    pub fn is_accepted(
+        &self,
+        subject: IdentityId,
+        predicate: &str,
+        value: &str,
+        policy: &TrustPolicy,
+    ) -> bool {
+        policy.accepts(&self.corroboration(subject, predicate, value))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use agorapura_core::{Attestation, Claim, IdentityKind, Keypair};
+
+    /// Mundo de prueba: Yumaira (persona) + tres atestadores.
+    fn actors() -> (Keypair, Keypair, Keypair, Keypair) {
+        (
+            Keypair::from_seed([20; 32]), // yumaira
+            Keypair::from_seed([10; 32]), // venezuela (institución)
+            Keypair::from_seed([30; 32]), // comunidad
+            Keypair::from_seed([40; 32]), // vecina
+        )
+    }
+
+    fn attest(by: &Keypair, subject: IdentityId, pred: &str, val: &str) -> Attestation {
+        Attestation::create(by, Claim::new(subject, pred, val, 1_700_000_000))
+    }
+
+    #[test]
+    fn rejects_attestation_with_broken_signature() {
+        let (yumaira, venezuela, ..) = actors();
+        let mut att = attest(&venezuela, yumaira.identity_id(), "nacionalidad", "venezolana");
+        att.claim.value = "falsa".into(); // rompe la firma
+        let mut g = TrustGraph::new();
+        assert!(g.add_attestation(att).is_err());
+        assert_eq!(g.attestation_count(), 0);
+    }
+
+    #[test]
+    fn stores_and_queries_verified_attestations() {
+        let (yumaira, venezuela, comunidad, _) = actors();
+        let mut g = TrustGraph::new();
+        g.register(venezuela.identity(IdentityKind::Institution, "Venezuela"));
+        g.add_attestation(attest(&venezuela, yumaira.identity_id(), "nacionalidad", "venezolana"))
+            .unwrap();
+        g.add_attestation(attest(&comunidad, yumaira.identity_id(), "miembro-de", "Valle"))
+            .unwrap();
+        assert_eq!(g.attestations_about(yumaira.identity_id()).len(), 2);
+        assert_eq!(g.attestations_by(venezuela.identity_id()).len(), 1);
+        assert_eq!(g.identity_count(), 1);
+    }
+
+    #[test]
+    fn duplicate_attestation_is_ignored() {
+        let (yumaira, venezuela, ..) = actors();
+        let att = attest(&venezuela, yumaira.identity_id(), "nacionalidad", "venezolana");
+        let mut g = TrustGraph::new();
+        g.add_attestation(att.clone()).unwrap();
+        g.add_attestation(att).unwrap();
+        assert_eq!(g.attestation_count(), 1);
+    }
+
+    #[test]
+    fn corroboration_counts_distinct_attesters() {
+        let (yumaira, venezuela, comunidad, vecina) = actors();
+        let mut g = TrustGraph::new();
+        for who in [&venezuela, &comunidad, &vecina] {
+            g.add_attestation(attest(who, yumaira.identity_id(), "nacionalidad", "venezolana"))
+                .unwrap();
+        }
+        let c = g.corroboration(yumaira.identity_id(), "nacionalidad", "venezolana");
+        assert_eq!(c.total(), 3);
+        assert_eq!(c.third_party(), 3);
+        assert!(!c.self_attested);
+    }
+
+    #[test]
+    fn self_attestation_is_distinguished_from_third_party() {
+        let (yumaira, venezuela, ..) = actors();
+        let mut g = TrustGraph::new();
+        g.add_attestation(attest(&yumaira, yumaira.identity_id(), "habilidad", "soldadura"))
+            .unwrap();
+        g.add_attestation(attest(&venezuela, yumaira.identity_id(), "habilidad", "soldadura"))
+            .unwrap();
+        let c = g.corroboration(yumaira.identity_id(), "habilidad", "soldadura");
+        assert_eq!(c.total(), 2);
+        assert_eq!(c.third_party(), 1);
+        assert!(c.self_attested);
+    }
+
+    #[test]
+    fn negotiated_policy_decides_acceptance() {
+        let (yumaira, venezuela, comunidad, _) = actors();
+        let mut g = TrustGraph::new();
+        g.add_attestation(attest(&venezuela, yumaira.identity_id(), "oficio", "partera"))
+            .unwrap();
+        g.add_attestation(attest(&comunidad, yumaira.identity_id(), "oficio", "partera"))
+            .unwrap();
+        let (sub, pred, val) = (yumaira.identity_id(), "oficio", "partera");
+        // Dos terceros: una política laxa acepta, una exigente no.
+        assert!(g.is_accepted(sub, pred, val, &TrustPolicy::strict(2)));
+        assert!(!g.is_accepted(sub, pred, val, &TrustPolicy::strict(3)));
+    }
+
+    #[test]
+    fn empty_corroboration_for_unknown_claim() {
+        let (yumaira, ..) = actors();
+        let g = TrustGraph::new();
+        let c = g.corroboration(yumaira.identity_id(), "nada", "nada");
+        assert!(c.is_empty() && c.third_party() == 0);
+    }
+
+    #[test]
+    fn policy_can_accept_self_attestation_when_negotiated() {
+        let (yumaira, ..) = actors();
+        let mut g = TrustGraph::new();
+        g.add_attestation(attest(&yumaira, yumaira.identity_id(), "lema", "sembrar"))
+            .unwrap();
+        let lax = TrustPolicy { min_third_party: 0, accept_self: true };
+        assert!(g.is_accepted(yumaira.identity_id(), "lema", "sembrar", &lax));
+        // La política por defecto, sin terceros, no la acepta.
+        assert!(!g.is_accepted(yumaira.identity_id(), "lema", "sembrar", &TrustPolicy::default()));
+    }
+}