Arquitectura del relay: crates, datos y pipeline de eventos

Por: Artiko
buzznostrarquitecturarelayrustpostgresrediswebsocket

Arquitectura del relay: crates, datos y pipeline de eventos

En el capítulo anterior vimos qué es Buzz y por qué tiene sentido operar un relay propio. Este capítulo abre la caja: qué procesos corren, qué datos viven dónde, qué crates componen el binario y qué le pasa exactamente a un evento desde que entra por el WebSocket hasta que queda en Postgres.

Todo lo que sigue sale de ARCHITECTURE.md y del código del repositorio block/buzz; cuando la documentación y el código no coinciden, lo digo y me quedo con el código. Este capítulo es de lectura, no de teclado: la instalación viene en el capítulo 3.


Vista aérea del sistema

Buzz es un monorepo Rust. El artefacto que operas es un binario, buzz-relay, un servidor Axum que habla WebSocket y HTTP, y que depende de tres almacenes externos.

flowchart TD
    subgraph clientes["Clientes"]
        desk["App desktop<br/>Tauri y React"]
        agent["Agentes IA<br/>via buzz-acp"]
        cli["buzz-cli y scripts<br/>JSON in, JSON out"]
    end

    relay["buzz-relay<br/>Axum WS y REST<br/>NIP-01, NIP-42, media, git, workflows, audit"]

    pg["Postgres 17<br/>eventos, canales, tokens,<br/>workflows, audit, FTS"]
    redis["Redis 7<br/>pub/sub, presencia,<br/>typing, rate limits"]
    s3["S3 o MinIO<br/>media Blossom y<br/>objetos git"]

    desk -->|WebSocket| relay
    agent -->|WS mas REST| relay
    cli -->|WS mas REST| relay

    relay --> pg
    relay --> redis
    relay --> s3

Tres afirmaciones explican casi todas las decisiones de diseño que vienen después:

  1. El relay es la única fuente de verdad. Todas las lecturas y escrituras pasan por él. ARCHITECTURE.md lo dice sin ambigüedad: no hay intercambio de eventos peer-to-peer, no hay gossip, no hay replicación. El relay aplica autenticación, verifica firmas, persiste, hace fan-out, indexa para búsqueda y dispara automatización.
  2. Toda acción es un evento Nostr firmado. Un mensaje, una reacción, un paso de workflow, una actualización de canvas: el mismo formato NIP-01, identificado por un entero kind.
  3. La comunidad se resuelve desde el host de la petición. En multi-tenant, req.community = resolve_host(connection.host) se establece antes de que ningún handler pueda observar datos, y un host desconocido falla cerrado, nunca cae a una comunidad por defecto. Esto se profundiza en el capítulo 11.

Los tres almacenes

AlmacénImagen de referenciaQué guarda
Postgrespostgres:17-alpineEventos, canales, membresías, tokens, workflows, log de auditoría y el índice de búsqueda full-text
Redisredis:7-alpineFan-out pub/sub entre instancias, presencia con SET … EX 180, cuotas de rate limiting con un script Lua atómico
MinIO o S3minio/minioBlobs de media Blossom y objetos git sobre object storage

No son opcionales: el README del bundle de producción dice que el stack usa Postgres, Redis, MinIO y un volumen git porque hoy son dependencias reales, y que un “modo mínimo” queda para más adelante.


Mapa de crates

buzz-core es la base sin I/O. Todos los crates de servicio dependen de él, y buzz-relay es el único que los importa y orquesta a todos.

flowchart TD
    core["buzz-core<br/>cero I/O"]
    core --> db["buzz-db"]
    core --> auth["buzz-auth"]
    core --> pubsub["buzz-pubsub"]
    core --> search["buzz-search"]
    core --> audit["buzz-audit"]
    core --> wf["buzz-workflow"]
    db --> relay["buzz-relay<br/>el servidor"]
    auth --> relay
    pubsub --> relay
    search --> relay
    audit --> relay
    wf --> relay

El principio es explícito en ARCHITECTURE.md: los subsistemas están aislados entre sí. buzz-workflow nunca llama a buzz-pubsub; buzz-search nunca llama a buzz-db. La coordinación inter-subsistema ocurre solo a través del relay. En modo multi-comunidad el relay además posee la propagación del TenantContext: los crates de servicio reciben entradas ya delimitadas por comunidad, no derivan la tenencia por su cuenta desde tags controlados por el cliente.

Protocolo

CrateResponsabilidadLo que NO hace
buzz-coreTipos compartidos, verificación de eventos y matching de filtros. Define StoredEvent, CommunityId, TenantContext; las funciones filters_match, verify_event e is_private_ip; y el registro de kinds en crates/buzz-core/src/kind.rs.No almacena eventos, no hace llamadas de red, no lanza tasks, no depende de ningún runtime async
buzz-relayEl servidor Axum. Único crate que importa y orquesta todos los subsistemas. Aloja además el audio de huddle en src/audio/ y el servidor git smart HTTP.”No implementa lógica de negocio: delega al crate apropiado para cada operación”

Que buzz-core sea cero I/O no es una aspiración: su Cargo.toml cierra la lista de dependencias con un comentario que es una regla, # NO tokio, NO sqlx, NO redis, NO axum — zero I/O dependencies.

Del otro lado, los módulos de crates/buzz-relay/src/lib.rs dan la medida de la superficie del servidor. Los públicos son api, audio, config, conformance, connection, error, handlers, invite_token, mesh_boot, metrics, nip11, protocol, push_runtime, router, state, storage_sweep, subscription, telemetry, tenant, tunnel, webhook_secret y workflow_sink. Hay además uno privado, declarado como mod admission;: la admisión de conexiones no forma parte de la API pública del crate.

Servicios

CrateResponsabilidadDetalle operativo
buzz-dbTodo el acceso a Postgres. Módulos event.rs, channel.rs, feed.rs, workflow.rs, partition.rs, dm.rs, reaction.rs, thread.rs, user.rsUsa sqlx::query() en runtime, no las macros de compile-time. No hay directorio .sqlx/
buzz-authNIP-42, NIP-98, scopes y cuotas. Módulos access, nip42, nip98, nip98_replay, rate_limit, scopeEl trait RateLimiter vive aquí; la implementación Redis vive en buzz-pubsub
buzz-pubsubFan-out por Redis, presencia e indicadores de escritura. Módulos cache_invalidation, conn_control, nip98_replay, presence, publisher, rate_limiter, subscriber, topicPubSubManager no es Clone: los llamadores usan Arc<PubSubManager>
buzz-searchSolo el lado de consulta de la búsqueda full-text de PostgresNo aplica control de acceso: devuelve candidatos y el relay reautoriza cada hit
buzz-auditLog append-only encadenado con SHA-256, con una cadena por comunidadNo registra eventos AUTH ni efímeros
buzz-workflowMotor de automatización YAML-as-code, con workflows delimitados por canalNo resuelve templates recursivamente; no encola runs al llegar a capacidad
buzz-mediaAlmacenamiento Blossom y S3. Módulos auth, bucket_index, storage, thumbnail, upload, upload_record, validationCrate de librería sin dependencia de Axum: los handlers viven en buzz-relay

Un detalle que sorprende: buzz-pubsub usa un pool deadpool-redis para PUBLISH, SET y ZADD, pero mantiene además una conexión redis::aio::PubSub dedicada, fuera del pool, porque las conexiones de un pool no pueden mantener estado PSUBSCRIBE. Esa conexión alimenta un broadcast::channel(4096) y reconecta con backoff exponencial de 1 s a 30 s.

Superficie de agentes

CratePara qué sirve
buzz-cliCLI agent-first, JSON de entrada y JSON de salida, diseñada para tool calls de LLM
buzz-acpHarness que hace de puente entre las @menciones del relay y agentes IA vía ACP sobre JSON-RPC. Soporta Goose, Codex y Claude Code
buzz-agentAgente ACP mínimo, no streaming, tool-calls-as-output
buzz-dev-mcpServidor MCP de desarrollo: herramientas de shell y edición de archivos
buzz-workflowMotor de automatización YAML, compartido con la familia de servicios
buzz-personaParser y loader de archivos de persona pack .persona.md

buzz-acp es un binario independiente: se conecta al relay por WebSocket con NIP-42, descubre canales por REST y encola eventos de @mención por canal. Como máximo hay un prompt en vuelo por canal, y los eventos encolados se agrupan en un único session/prompt. El pool va de 1 a 32 subprocesos de agente con ciclo de vida claim y return. No persiste estado.

Git y pairing

CratePara qué sirve
git-sign-nostrPrograma de firma de commits y tags de git con claves secp256k1 de Nostr, según el borrador NIP-GS
git-credential-nostrCredential helper de git que produce cabeceras de autenticación NIP-98 para el servidor git de Buzz
buzz-pair-relayRelay sidecar efímero para los handshakes de emparejamiento de dispositivos NIP-AB
buzz-pairing-cliCLI de pruebas de interoperabilidad de pairing NIP-AB

Compartidos y tooling

CratePara qué sirve
buzz-sdkConstructores tipados de eventos Nostr, usados por buzz-acp y buzz-cli
buzz-mediaAlmacenamiento Blossom y S3
buzz-adminCLI de operador: membresía del relay, generación de claves, migraciones
buzz-test-clientCliente de integración y suite E2E
buzz-conformanceEsquema de traza runtime y verificador de replay contra la especificación TLA+

Dos notas sobre esta última familia. buzz-conformance trae una advertencia que el propio crate escribe: no es una prueba; la conformidad de trazas solo verifica las ejecuciones que corriste. Y buzz-admin es el crate que más vas a usar como operador: se despacha dentro de la imagen Docker del relay, en /usr/local/bin/buzz-admin, y es el camino recomendado para gestionar membresía en producción, tema del capítulo 8.

El monorepo tiene más crates, entre ellos buzz-push-gateway, buzz-relay-mesh, buzz-voice, buzz-backend-kubernetes, buzz-ws-client y sprig. El listado completo vive en los Cargo.toml de crates/.


Ciclo de vida de una conexión

ARCHITECTURE.md es tajante: “toda conexión WebSocket sigue esta secuencia exacta”.

sequenceDiagram
    participant C as Cliente
    participant R as buzz-relay
    participant DB as Postgres

    Note over R: Paso 0 — community binding desde el host
    Note over R: Paso 1 — conn_semaphore.try_acquire_owned
    R->>C: Paso 2 — AUTH challenge
    C->>R: Paso 3 — AUTH con evento firmado
    R->>R: Pending pasa a Authenticated o Failed
    Note over R: Paso 4 — recv_loop, send_loop, heartbeat_loop
    C->>R: EVENT, REQ o CLOSE
    R->>DB: lectura o escritura
    R->>C: OK, EVENT, EOSE o CLOSED
    Note over R: Paso 5 — cleanup ordenado

Paso 0. Community binding. El servidor resuelve el TenantContext desde el host antes de que ningún handler pueda observar datos de tenant. La URL manda. Los tags #h que envía el cliente siguen siendo identificadores de canal, y deben resolver a un canal dentro de la comunidad derivada del host: nunca seleccionan tenant.

Paso 1. Semáforo de conexión. state.conn_semaphore.try_acquire_owned(). Si el relay está al tope de capacidad, la conexión se rechaza inmediatamente antes de leer un solo byte. El permiso se sostiene durante toda la vida de la conexión y se libera en el cleanup. El tope se controla con BUZZ_MAX_CONNECTIONS, cuyo valor por defecto es 10000.

Pasos 2 y 3. Desafío y autenticación. El relay envía de inmediato ["AUTH", "<challenge>"] con un string aleatorio, y registra la conexión en el ConnectionManager después de enviarlo. El cliente responde ["AUTH", <evento-firmado>] antes de poder enviar eventos o suscripciones: en éxito ConnectionState.auth_state pasa de Pending a Authenticated(AuthContext), en fallo a Failed. Los EVENT y REQ no autenticados se rechazan con ["CLOSED", ...] o con ["OK", ..., false, "auth-required: ..."]. El detalle está en el capítulo 7.

Paso 4. Loops activos. Tres tareas concurrentes viven mientras dure la conexión:

  • recv_loop, inline: lee frames, parsea ClientMessage y despacha a los handlers.
  • send_loop, spawned: drena el canal mpsc y escribe frames al WebSocket.
  • heartbeat_loop, spawned: envía un ping WebSocket cada 30 segundos. Tres pongs perdidos cortan la conexión. El intervalo está fijado en crates/buzz-relay/src/connection.rs.

Un CancellationToken coordina el apagado de los tres.

Clientes lentos: ConnectionState::send() usa try_send. Si el buffer de envío está lleno, sube un contador de gracia; tras N eventos consecutivos de buffer lleno la conexión se cancela, y un envío exitoso resetea el contador. El buffer se configura con BUZZ_SEND_BUFFER, por defecto 1000, y la gracia con BUZZ_SLOW_CLIENT_GRACE_LIMIT, por defecto 15.

Paso 5. Cleanup. En cualquier desconexión, en este orden exacto:

  1. cancel.cancel() señaliza a todos los loops.
  2. Se espera con await a send_loop y heartbeat_loop.
  3. sub_registry.remove_connection(conn_id) quita todas las suscripciones de los índices DashMap.
  4. conn_manager.deregister(conn_id) la quita del mapa de canales de envío.
  5. drop(permit) libera el slot del semáforo de conexiones.

Pipeline de un evento

Cuando llega ["EVENT", <event>], el handler de handlers/event.rs corre estos doce pasos en orden.

flowchart TD
    in["EVENT entrante"] --> a1["Pasos 1 a 3<br/>auth, pubkey match,<br/>rechazo de kind 22242"]
    a1 --> a4{"Paso 4<br/>kind entre 20000 y 29999"}
    a4 -->|si| eph["Sub-pipeline efimero"]
    a4 -->|no| a5["Paso 5 — verify<br/>firma Schnorr y hash del ID"]
    a5 --> a6["Paso 6 — membership"]
    a6 --> a7["Paso 7 — DB insert<br/>ON CONFLICT DO NOTHING"]
    a7 --> a8["Paso 8 — Redis publish"]
    a8 --> a9["Paso 9 — fan-out"]
    a9 --> ff["Pasos 10 a 12<br/>search, audit, workflow<br/>fire-and-forget"]
    a9 --> ok["OK true al cliente"]
PasoQué hace
1. AUTH CHECK¿AuthState::Authenticated? ¿scope MessagesWrite?
2. PUBKEY MATCHevent.pubkey debe coincidir con la pubkey del AuthContext
3. KIND_AUTH REJECTkind == 22242: los eventos AUTH nunca se almacenan
4. EPHEMERAL ROUTEKinds 20000 a 29999 se desvían al sub-pipeline efímero
5. VERIFYspawn_blocking(verify_event): firma Schnorr más hash SHA-256 del ID
6. MEMBERSHIPSi hay channel_id en los tags, se comprueba membresía
7. DB INSERTdb.insert_event con ON CONFLICT DO NOTHING, idempotente
8. REDIS PUBLISHpubsub.publish_event, solo si el evento está delimitado a un canal
9. FAN-OUTsub_registry.fan_out y luego conn_manager.send_to
10. SEARCH INDEXsearch_index_tx.send, cola acotada, no bloqueante
11. AUDIT LOGaudit.log, tarea async independiente
12. WORKFLOW TRIGGERwf.on_event, tarea async, excluye kinds 46001 a 46012

Cuatro precisiones que importan al operar y al depurar:

  • Los pasos 10 a 12 son fire-and-forget. Un fallo en indexado, auditoría o disparo de workflow no falla el envío del evento: si un evento aparece en el canal pero no en la búsqueda, eso es consistente con el diseño, no un error de ingesta.
  • El ["OK", <id>, true, ""] se envía al final del pipeline, no apenas se inserta en la base.
  • El paso 9 excluye explícitamente las suscripciones globales de los eventos delimitados a un canal. Una suscripción sin restricción de channel_id no recibe eventos de canales privados, coincida o no el filtro. ARCHITECTURE.md lo llama una frontera de seguridad deliberada.
  • Prevención de loops de workflow: los kinds de ejecución de workflow 46001 a 46012, los mensajes firmados por el relay con tag buzz:workflow y KIND_GIFT_WRAP están excluidos de disparar workflows. Todo el resto de eventos almacenados, incluidos los kind 9, sí evalúan workflows.

Sub-pipeline efímero

Los kinds 20000 a 29999 se saltan almacenamiento, auditoría y búsqueda. Hay dos caminos:

flowchart TD
    entra["Evento efimero<br/>kind 20000 a 29999"] --> tipo{"Es kind 20001<br/>presencia?"}

    tipo -->|si| p1["1. VERIFY firma"]
    p1 --> p2["2. REDIS PRESENCE<br/>set o clear segun el content"]
    p2 --> p3["3. LOCAL FAN-OUT<br/>sin Redis PUBLISH"]

    tipo -->|no| e1["1. VERIFY firma"]
    e1 --> e2["2. MEMBERSHIP<br/>solo si es channel-scoped"]
    e2 --> e3["3. MARK LOCAL<br/>dedup antes del round-trip"]
    e3 --> e4["4. REDIS PUBLISH<br/>sin escritura en DB"]
    e4 --> e5["5. LOCAL FAN-OUT"]

La presencia salta las verificaciones de membresía y usa fan-out solo local: es la excepción, y su fan-out multinodo sigue documentado como trabajo futuro. Los efímeros nunca se guardan en Postgres y nunca aparecen en consultas históricas REQ.

Semáforo de handlers

Además del semáforo por conexión hay un handler_semaphore que limita el procesamiento concurrente de EVENT y REQ en todas las conexiones a la vez. Su capacidad se configura con BUZZ_MAX_CONCURRENT_HANDLERS, por defecto 1024. Cuando satura, el rechazo es rate-limited: too many concurrent requests. Los mensajes CLOSE no pasan por este semáforo.


Suscripciones y filtros NIP-01

El SubscriptionRegistry de subscription.rs está respaldado por DashMap y mantiene tres índices:

pub struct SubscriptionRegistry {
    subs: DashMap<ConnId, HashMap<SubId, SubEntry>>,
    channel_kind_index: DashMap<IndexKey, Vec<(ConnId, SubId)>>,   // IndexKey = { channel_id, kind }
    channel_wildcard_index: DashMap<Uuid, Vec<(ConnId, SubId)>>,
}

Cuando llega un evento, fan_out consulta los tres índices en orden.

TierÍndiceClaveCaso de uso
1channel_kind_index(channel_id, kind)Suscripciones con canal y kind explícitos: lookup O(1)
2channel_wildcard_indexchannel_idSuscripciones con canal pero sin restricción de kinds
3subsescaneo linealSuscripciones globales sin channel_id

El tier 3 solo se consulta para eventos que no están delimitados a un canal.

Dos casos borde de NIP-01 que hay que tener grabados

  • kinds: [], es decir un arreglo vacío explícito, significa “no coincidir con nada”, no un comodín. Esas suscripciones no se indexan ni en tier 1 ni en tier 2 y no reciben nunca eventos.
  • kinds ausente, sin el campo, significa “coincidir con todos los kinds”: se indexa en tier 2 si hay canal, o en tier 3 si es global.

Hay además un gotcha operativo documentado en AGENTS.md: las consultas al relay deben especificar kinds, y omitirlas dispara el p-gate con un 403. Los REQ globales que puedan coincidir con kinds p-gated —44100, 44101 y 1059— deben incluir un filtro #p donde todos los valores coincidan con la pubkey autenticada; si no, el relay responde restricted: p-gated events require #p matching your pubkey. El matching en sí lo hace filters_match en buzz-core: OR entre filtros, AND dentro de cada filtro, con prefix-matching NIP-01 sobre los IDs de evento.

Control de acceso en el handler REQ

La verificación de acceso ocurre antes de registrar la suscripción:

flowchart TD
    r1["1. Parsear filtros y extraer channel_id"]
    r1 --> r2["2. Cargar accessible_channel_ids<br/>para la pubkey de esta conexion"]
    r2 --> r3{"3. channel_id esta<br/>en la lista?"}
    r3 -->|no| deny["CLOSED<br/>restricted: not a channel member"]
    r3 -->|si| r4["4. sub_registry.register<br/>conn_id, sub_id, filters, channel_id"]

El orden no es cosmético. Evita una carrera en la que un no-miembro reciba eventos vivos de un canal privado entre el registro de la suscripción y la verificación de acceso.

Consulta histórica, EOSE y límites

Tras registrar, el handler REQ consulta Postgres por los eventos almacenados que coincidan con los filtros. Se envían como frames ["EVENT", sub_id, event] y luego ["EOSE", sub_id]. Todo lo que llegue después del EOSE viaja por el camino de fan-out. El tope de página lo define DEFAULT_MAX_PAGE_LIMIT en crates/buzz-db/src/event.rs, cuyo valor es 1000; ARCHITECTURE.md todavía menciona 500 por filtro, y el código manda.

crates/buzz-relay/src/nip11.rs publica en el documento NIP-11: max_subscriptions 1024, max_filters 10, max_limit igual a buzz_db::DEFAULT_MAX_PAGE_LIMIT, max_subid_length 256, min_pow_difficulty ninguno, payment_required en false, y —esto importa— auth_required y restricted_writes siempre en true.

El tope de 1024 suscripciones por conexión también está como constante en crates/buzz-relay/src/handlers/req.rs. El tamaño máximo de frame se controla con BUZZ_MAX_FRAME_BYTES; la constante DEFAULT_MAX_FRAME_BYTES de crates/buzz-relay/src/config.rs vale 512 KiB. ARCHITECTURE.md documenta 65.536 bytes, cifra que ya no corresponde al código.


Rangos de kinds

El kind es el único switch de despacho del sistema. El relay enruta, almacena y hace fan-out según él, y los clientes filtran suscripciones por él. Una feature nueva es un número de kind nuevo: cero cambios rompientes para los clientes existentes.

RangoSignificado
0 a 9999Kinds estándar de Nostr, de NIP-01 en adelante
10000 a 19999Eventos reemplazables, NIP-16
20000 a 29999Eventos efímeros: no se almacenan, no se auditan
30000 a 39999Eventos reemplazables parametrizados
40000 a 49999Kinds propios de Buzz

Los kinds son u32 y el registro completo se exporta como ALL_KINDS: &[u32] desde crates/buzz-core/src/kind.rs, la fuente de verdad del listado vigente. No te fíes de ninguna cifra total que aparezca en la documentación: ARCHITECTURE.md se contradice a sí mismo sobre cuántos kinds hay. Una muestra útil para orientarse:

KindConstanteQué es
7KIND_REACTIONReacción emoji, NIP-25 estándar
9KIND_STREAM_MESSAGEMensaje de chat en un canal Stream, grupo NIP-29
1059KIND_GIFT_WRAPDM gift-wrap NIP-17
9030 a 9032RELAY_ADMIN_*Agregar miembro, quitar miembro, cambiar rol
13534KIND_NIP43_MEMBERSHIP_LISTRoster de membresía firmado por el relay
20001 y 20002KIND_PRESENCE_UPDATE y KIND_TYPING_INDICATORPresencia y escritura, ambos efímeros
22242KIND_AUTHAUTH NIP-42, nunca almacenado
27235KIND_HTTP_AUTHNIP-98 HTTP Auth
39000 a 39002KIND_NIP29_GROUP_*Metadata, admins y miembros de grupo, firmados por el relay
43001 a 43006KIND_JOB_*Ciclo de vida de trabajos de agente
45001 y 45003KIND_FORUM_POST y KIND_FORUM_COMMENTRaíz y respuesta de hilo de foro
46001 a 46012KIND_WORKFLOW_*Eventos de ejecución de workflow

Un error clásico de quien viene de otros relays Nostr: la metadata de canal en Buzz es kind 39000, no kind 41. El kind 41 existe en el registro pero Buzz no lo usa para eso.


Dónde vive cada dato

Tablas clave de Postgres

TablaContenido
eventsTodos los eventos almacenados; particionada mensualmente por rango sobre created_at
channelsCanales con tipo, visibilidad, canvas y topic
channel_membersMembresías con rol; borrado suave vía removed_at
workflows, workflow_runs, workflow_approvalsDefiniciones en JSON canónico, ejecuciones con traza y compuertas cuyo token se guarda como hash SHA-256
audit_logEntradas de la cadena de auditoría, con cadena y head por comunidad
delivery_logSeguimiento de entregas, particionada

Los tipos de canal son Stream, Forum, Dm y Workflow; los roles de miembro son Owner, Admin, Member, Guest y Bot. El repositorio trae 28 archivos en migrations/, embebidos en el binario mediante sqlx::migrate! y cubiertos en el capítulo 13.

La búsqueda no es un servicio aparte

No hay Elasticsearch, ni Meilisearch, ni servicio de búsqueda dedicado, ni indexador fuera de banda. La búsqueda corre sobre una columna generada de la propia tabla events:

search_tsv TSVECTOR GENERATED ALWAYS AS (
    CASE WHEN kind IN (0, 9, 40002, 45001, 45003)
         THEN to_tsvector('simple', content)
         ELSE NULL::tsvector
    END
) STORED;
CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv);

Como la columna es GENERATED ALWAYS, cada escritura de fila es la actualización del índice: no hay cola de reindexado ni ventana de consistencia. Y los kinds fuera de la lista producen NULL, así que son inbuscables a nivel de almacenamiento, porque un tsvector nulo nunca coincide con @@. Más en el capítulo 10.

Patrones de claves en Redis

Esta es una de las secciones donde ARCHITECTURE.md y el código no coinciden, y como avisé al principio me quedo con el código. La tabla del documento lista tres patrones sin delimitar por comunidad (buzz:channel:{uuid}, buzz:presence:{pubkey_hex}, buzz:typing:{channel_uuid}) y añade como advertencia que un Redis compartido en multi-comunidad “debe” delimitar por comunidad. Dos correcciones sobre eso.

Primera: el código ya delimita siempre, no es un “debe”. crates/buzz-pubsub/src/topic.rs construye los nombres de canal pub/sub con EventTopicKey::redis_channel(), que solo tiene dos ramas, y ambas incluyen el CommunityId:

EventTopic::Channel(channel_id) => {
    format!("{BUZZ_PREFIX}:{}:channel:{channel_id}", self.community_id)
}
EventTopic::Global => format!("{BUZZ_PREFIX}:{}:global", self.community_id),

BUZZ_PREFIX es la constante "buzz". Y el encabezado de crates/buzz-pubsub/src/presence.rs documenta la escritura literal: SET buzz:{community}:presence:{pubkey_hex} "online" EX 180.

Segunda: el sorted set de typing no existe en el código. No hay ningún ZADD, ningún ZREMRANGEBYSCORE ni ninguna clave buzz:*:typing:* en todo el workspace; la única mención de ZADD es un comentario de cabecera en crates/buzz-pubsub/src/lib.rs que enumera qué operaciones puede hacer el pool. Los indicadores de escritura son eventos efímeros de kind 20002 y viajan por el mismo canal pub/sub de siempre: la propia tabla de limitaciones de ARCHITECTURE.md lo confirma cuando dice que “los indicadores de escritura (kind 20002) se entregan por fan-out local y por pub/sub de Redis”. No hay estado de typing consultable en Redis, y por eso tampoco hay endpoint REST para preguntar quién está escribiendo.

Los patrones que realmente existen son estos dos:

Patrón real en el códigoTipoTTLPropósito
buzz:{community}:channel:{uuid}Canal Pub/SubFan-out de eventos delimitados a un canal
buzz:{community}:globalCanal Pub/SubFan-out de eventos sin canal
buzz:{community}:presence:{pubkey_hex}String180 sEstado online o away

Consecuencia operativa: no tienes que prefijar nada a mano; el CommunityId va en la clave por construcción, así que dos comunidades sobre un mismo Redis no pueden pisarse. Y si estás depurando typing, no busques claves en Redis: mira el flujo de eventos efímeros.

Los 180 segundos de presencia son exactamente el triple del heartbeat de 60 segundos —hay un test que lo fija, presence_ttl_is_three_one_minute_heartbeat_windows, con PRESENCE_TTL_SECS == 3 * 60—, de modo que un solo latido perdido no produce flapping.

Fan-out multinodo

El bucle suscriptor se lanza en buzz-relay/src/main.rs y llena el canal broadcast. Una tarea consumidora se suscribe con pubsub.subscribe_local(), llama a sub_registry.fan_out() por evento y entrega a las conexiones WebSocket locales con conn_manager.send_to(). Está cableado de extremo a extremo, y la deduplicación del eco local usa AppState.local_event_ids, una caché moka con clave (CommunityId, [u8; 32]).


Limitaciones conocidas

ARCHITECTURE.md cierra con una sección de gaps verificados, con un encabezado que vale la pena citar: “son gaps verificados en la implementación actual, no aspiraciones de diseño”. Estas son las que siguen vigentes:

LimitaciónQué significa para ti
Sin caché offline de consultas sqlxSe usa sqlx::query() en runtime, no sqlx::query!(). No hay directorio .sqlx/ y las consultas no se validan en compilación
Sin endpoint REST dedicado de typingLos indicadores de escritura llegan por fan-out y Redis, pero no hay endpoint para consultar quién está escribiendo. /api/presence devuelve solo online y away
Grabación y tracks de huddle no construidosLa voz, el ciclo de vida de sala y los eventos de join, leave y end funcionan. Grabación y publicación por track tienen kinds reservados y ningún productor
Compuertas de aprobación no cableadas de extremo a extremoEl ejecutor devuelve StepResult::Suspended y el relay tiene endpoints de grant y deny con CRUD, pero el motor intercepta antes de crear filas WaitingApproval: los runs que llegan a una aprobación se marcan como Failed
Acciones de workflow parcialmente stubsend_dm y set_channel_topic están en el esquema pero devuelven NotImplemented: un run que llega a una de ellas falla en ejecución

Hay una limitación en esa lista que ya no aplica: la entrada que dice que no existe implementación de rate limiting. crates/buzz-pubsub/src/rate_limiter.rs implementa RedisRateLimiter con un script Lua atómico que combina INCR y EXPIRE, y el camino de admisión del relay lo usa tanto en WebSocket como en el puente HTTP. Eso sí: es un limitador de ventana fija, y el propio código advierte que permite hasta el doble de ráfaga en los bordes de la ventana.

El README agrega su propia tabla de estado: los clientes móviles, las compuertas de aprobación de workflow y los eventos de ciclo de vida de huddle están en construcción, mientras que la reputación web-of-trust entre relays, las notificaciones push y las “culture features” están en la columna de opiniones sin código. No planifiques sobre esa última columna.

Y una limitación estructural que no es un bug sino una decisión de diseño: sin replicación, sin gossip y sin intercambio P2P, cuando tu relay está caído tu comunidad está caída. De eso tratan el capítulo 12 y el capítulo 14.


Resumen

  • buzz-relay es un servidor Axum que depende de Postgres, Redis y object storage S3 o MinIO. Los tres son dependencias reales, no opcionales.
  • buzz-core es la base sin I/O y su Cargo.toml prohíbe explícitamente tokio, sqlx, redis y axum. buzz-relay es el único crate que orquesta todos los subsistemas; los subsistemas están aislados entre sí. Las familias son protocolo, servicios, superficie de agentes, git y pairing, compartidos y tooling; como operador el que más usarás es buzz-admin, que viaja dentro de la imagen Docker.
  • Una conexión sigue seis pasos exactos: community binding desde el host, semáforo, desafío NIP-42, autenticación, tres loops concurrentes con heartbeat de 30 segundos y un cleanup ordenado.
  • Un evento recorre doce pasos. Los pasos 10 a 12 —búsqueda, auditoría y workflows— son fire-and-forget: su fallo no rechaza el evento. El OK se envía al final del pipeline, no tras el insert.
  • Los kinds 20000 a 29999 son efímeros: nunca tocan Postgres, nunca se auditan y nunca aparecen en consultas históricas REQ.
  • El fan-out consulta tres índices: canal más kind, comodín de canal y global. Las suscripciones globales están excluidas por diseño de los eventos delimitados a canal: es una frontera de seguridad.
  • En NIP-01, kinds: [] significa “no coincidir con nada”, no comodín; omitir kinds dispara el p-gate y devuelve 403.
  • El registro de kinds vive en crates/buzz-core/src/kind.rs y es la única fuente de verdad. La metadata de canal es kind 39000, no kind 41.
  • La búsqueda es FTS de Postgres sobre una columna GENERATED ALWAYS, sin servicio ni indexador aparte: cada escritura de fila es la actualización del índice.
  • Hay gaps verificados: sin caché offline de sqlx, sin endpoint REST de typing, sin grabación de huddle, aprobaciones de workflow sin cablear de punta a punta y dos acciones de workflow que devuelven NotImplemented.

Siguiente: Tu primer relay: instalación desde el código fuente