Búsqueda y registro de auditoría

Por: Artiko
buzznostrpostgresftsauditorianip-50operacion

Búsqueda y registro de auditoría

Los dos subsistemas de este capítulo suelen confundirse porque ambos “guardan lo que pasó”, pero son cosas distintas. buzz-search responde “¿dónde se dijo esto?” y es solo el lado de consulta de una búsqueda full-text que corre íntegramente dentro de Postgres. buzz-audit responde “¿quién hizo qué, en qué orden, y esa secuencia fue alterada?” y es un log append-only encadenado por SHA-256.

Hay un tercer registro que no es ninguno de los dos: la tabla moderation_actions y su endpoint GET /moderation/audit, del trabajo de moderación cubierto en Membresía, roles y moderación. El comentario de configuración del relay los separa explícitamente: BUZZ_AUDIT_ENABLED “no controla el rastro de auditoría separado moderation_actions (crates/buzz-relay/src/config.rs:231-232).

Búsqueda: el índice ES la fila

La decisión de diseño central está escrita en la cabecera del crate (crates/buzz-search/src/lib.rs:3-15):

El índice vive en la tabla events: search_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED, con GIN (search_tsv) como camino de acceso. Como la columna es GENERATED ALWAYS, cada escritura de fila es la actualización del índice: no hay indexador separado, ni cola mpsc, ni job de reindex, ni ventana de consistencia que razonar.

Consecuencia para el operador: no existe un servicio de búsqueda que monitorear, reiniciar o resincronizar. Si el INSERT del evento tuvo éxito, el evento es buscable en la misma transacción; si falló, no hay nada desincronizado que arreglar. Y una consecuencia de seguridad, en palabras del crate: “Un cliente no puede forjar el tsvector fuera de sincronía con el contenido que firmó.”

flowchart LR
    ev["EVENT firmado<br/>llega al relay"] --> ver["verify_event<br/>Schnorr + hash del id"]
    ver --> ins["buzz-db insert_event<br/>ON CONFLICT DO NOTHING"]
    ins --> row["fila en events"]
    row --> tsv["search_tsv<br/>columna GENERATED STORED"]
    tsv --> gin["idx_events_search_tsv<br/>índice GIN"]
    gin --> q["consultas NIP-50"]

El pipeline de ingesta ya no tiene paso de indexado. El comentario en crates/buzz-relay/src/handlers/event.rs:501-506: “El indexado de búsqueda ya no es un paso de worker separado: bajo Postgres FTS la fila buscable ES la fila del evento persistido. El viejo worker index_event de Typesense y su mpsc search_index_tx se fueron con el backend Typesense.” Si lees diagramas antiguos con un paso SEARCH INDEX — search_index_tx.send, están desactualizados.

Qué queda fuera del índice, por construcción

Los kinds sensibles a privacidad se excluyen a nivel de almacenamiento: la expresión produce NULL::tsvector, y un tsvector nulo nunca coincide con el operador @@. No es un filtro que se pueda saltar: la fila no tiene qué buscar. La expresión original, en migrations/0001_initial_schema.sql:222-226:

search_tsv  TSVECTOR GENERATED ALWAYS AS (
    CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101) THEN NULL::tsvector
         ELSE to_tsvector('simple', content)
    END
) STORED,

Las migraciones posteriores fueron añadiendo exclusiones. Como Postgres no permite alterar una expresión GENERATED en sitio, cada una hace DROP COLUMN + ADD COLUMN + recrear el índice GIN:

KindConstantePor qué se excluyeMigración
1059KIND_GIFT_WRAPTexto cifrado NIP-170001
30300KIND_EVENT_REMINDERKinds solo-para-el-autor, defensa en profundidad0001
30622KIND_DM_VISIBILITYEstado privado de ocultamiento por observador0001
44100KIND_MEMBER_ADDED_NOTIFICATIONAviso de membresía p-gated0001
44101KIND_MEMBER_REMOVED_NOTIFICATIONAviso de membresía p-gated0001
44200KIND_AGENT_TURN_METRICTexto cifrado NIP-44; NIP-AM exige no indexar0005
30350KIND_PUSH_LEASETexto cifrado con endpoints, solo-autor0014

La migración 0005 deja el criterio de mantenimiento por escrito: las constantes viven en buzz_core::kind, pero se copian literales porque “una migración sqlx es SQL congelado y no puede importar la constante de Rust”. Un kind sensible nuevo exige una migración aditiva con el mismo patrón y un test de regresión en crates/buzz-search/tests/fts_integration.rs.

La allowlist positiva de las instalaciones nuevas

Aquí hay un detalle operativo que cambia por completo lo que tu relay puede buscar, según cuándo lo instalaste.

La migración 0008_fresh_install_search_allowlist.sql invierte la lógica: en lugar de una lista negra de kinds excluidos, aplica una lista blanca de kinds indexados. Pero solo lo hace si la tabla events está vacía, comprobándolo bajo LOCK TABLE events IN SHARE ROW EXCLUSIVE MODE:

IF NOT EXISTS (SELECT 1 FROM events LIMIT 1) THEN
    ALTER TABLE events DROP COLUMN search_tsv;
    ALTER TABLE events ADD COLUMN 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);
END IF;

Es decir: un relay recién instalado indexa exactamente cinco kinds:

KindConstanteQué es
0KIND_PROFILEMetadata de usuario
9KIND_STREAM_MESSAGEMensaje de canal
40002KIND_STREAM_MESSAGE_V2Mensaje de canal v2
45001KIND_FORUM_POSTPublicación de foro
45003KIND_FORUM_COMMENTComentario de foro

Un relay que ya tenía datos cuando aplicó 0008 conserva su expresión anterior (lista negra), y por lo tanto indexa mucho más. La migración es deliberadamente asimétrica porque reescribir la columna generada sobre una tabla poblada bloquea la tabla entera.

Para convergir una instalación poblada a la allowlist positiva existe un script de mantenimiento fuera de banda, scripts/maintenance/nip_rs_search_allowlist.sql, cuya cabecera es la advertencia entera: “MANTENIMIENTO FUERA DE BANDA: no ejecutar desde las migraciones de arranque del relay. Esto reescribe cada partición de events y reconstruye el índice GIN particionado. Ejecutar solo en una ventana de mantenimiento tras confirmar espacio libre suficiente para los archivos de reemplazo de heap, TOAST e índice más el WAL. ALTER TABLE toma ACCESS EXCLUSIVE, así que las lecturas y escrituras de eventos se bloquean hasta que esta transacción hace commit.” El script se envuelve en BEGIN / SET LOCAL lock_timeout = '5s' / COMMIT.

Ese script es lo más parecido a un “reindexado” que existe en Buzz.

Consultar: NIP-50 sobre el WebSocket

NIP-50 está en la lista de NIPs anunciada incondicionalmente en el documento NIP-11 del relay (crates/buzz-relay/src/nip11.rs:15), junto a 1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42 y 56.

Un REQ de búsqueda es un REQ normal con el campo search en el filtro. Ejemplo verificado en NOSTR.md:178-180:

nak req -k 9 --tag "h=<channel-uuid>" --search "search query" -l 20 \
  --auth --sec <privkey> ws://localhost:3000

Tres propiedades del camino de búsqueda que conviene tener claras:

  1. Son de una sola vez. “Search subscriptions are one-shot — no persistent subscription is registered.” Producen resultados ordenados por relevancia seguidos de EOSE y no se registran para fan-out: un evento nuevo que coincida con tu texto no te va a llegar solo.
  2. No se pueden mezclar filtros con y sin search en el mismo REQ. El handler responde ["CLOSED", sub_id, "error: mixed search and non-search filters not supported"] (crates/buzz-relay/src/handlers/req.rs:212-216).
  3. Los p-gates corren antes de la rama de búsqueda (req.rs:172-179): “Aplicado ANTES de la rama de búsqueda NIP-50 para que un miembro autenticado no pueda usar {"search":"...","kinds":[30174]} para cosechar eventos sensibles indexados pero almacenados globalmente.” Si el filtro es global y pide kinds p-gated sin #p propio, la respuesta es restricted: p-gated events require #p matching your pubkey.

Esto se conecta con un gotcha documentado que vas a encontrar en cuanto uses agentes o el CLI (AGENTS.md:429-430): “Las consultas al relay deben especificar kinds — omitir kinds dispara el p-gate y devuelve 403.” La guía recomienda pasar al menos --kinds 9,45001,45003.

Qué constraints se empujan al SQL

SearchQuery (crates/buzz-search/src/query.rs:73-99) es la superficie completa de la consulta:

CampoTipoQué hace
communityCommunityIdComunidad resuelta por el servidor. Obligatoria a nivel de tipo
qStringTexto NIP-50. Vacío se rechaza antes de tocar Postgres
channel_scopeChannelScopeRestricción por canal, cuatro variantes
kindsOption<Vec<i32>>Filtro de kinds NIP-01
authorsOption<Vec<Vec<u8>>>Pubkeys de 32 bytes
since / untilOption<i64>Cotas inclusivas sobre created_at, en segundos Unix
page / per_pageu32Paginación. per_page se topa en 500, page en 1000
modeSearchModeFullText o Prefix

Consultar por autor y por rango temporal, entonces, no es una función aparte: son authors, since y until empujados al mismo WHERE. La forma del SQL emitido está documentada literalmente sobre search() (query.rs:200-215):

SELECT id, kind, pubkey, channel_id,
       EXTRACT(EPOCH FROM created_at)::bigint AS created_at_s,
       ts_rank_cd(search_tsv, query) AS rank
FROM events,
     <tsquery segun el modo> AS query
WHERE community_id = $ctx
  AND deleted_at IS NULL
  AND search_tsv @@ query
  [+ channel scope, kinds, authors, since, until]
ORDER BY rank DESC, created_at DESC, id
LIMIT $per_page OFFSET (($page - 1) * $per_page)

El comentario que sigue no admite lectura suelta: community_id = $ctx es el primer predicado y es innegociable. No hay camino de código a través de esta función que lo omita.”

Los dos modos de matching:

  • SearchMode::FullText usa websearch_to_tsquery('simple', $texto). Es el modo del REQ NIP-50.
  • SearchMode::Prefix normaliza cada token con el parser simple y añade :* solo al último, para typeahead. Está pensado para superficies acotadas como la barra superior del escritorio. El propio doc-comment aclara que “este modo cambia solo la tsquery candidata, no la frontera de acceso”, y que quote_literal previene inyección de sintaxis tsquery desde la puntuación del input.

Scoping por canal: las cuatro variantes

ChannelScope reemplaza a la matriz vieja (accessible_channels: &[Uuid], include_global: bool) que venía del relay con Typesense:

Canales accesiblesIncluir globalesVarianteSQL emitido
No vacíoChannelsOrChannelLesschannel_id = ANY($1) OR channel_id IS NULL
No vacíonoChannelschannel_id = ANY($1)
VacíoChannelLessOnlychannel_id IS NULL
VacíonoEl llamador corta y responde EOSE sin tocar la base

ChannelLessOnly es la variante que la forma antigua no podía expresar: con canales accesibles vacíos y include_global = true, tanto Some(vec![]) + true como None + true se ensanchaban a todos los canales de la comunidad en vez de restringirse a eventos sin canal. El enum cierra ese agujero a nivel de tipos.

La búsqueda nunca es la frontera de acceso

Esta es la frase que más se repite en el crate y la que hay que interiorizar: “search is never the access boundary”.

buzz-search devuelve candidatos: id de evento, kind, pubkey, channel_id, created_at y el rank de ts_rank_cd. Nada más. El relay hace después:

sequenceDiagram
    participant C as Cliente
    participant R as buzz-relay
    participant S as buzz-search
    participant DB as buzz-db

    C->>R: REQ con filtro search
    R->>R: gates de canal, p-gate, engram, author-only
    R->>S: SearchQuery con community resuelta por host
    S->>DB: SELECT sobre events con GIN
    S-->>R: hits candidatos, solo ids y rank
    R->>DB: get_events_by_ids_routed con community e ids
    DB-->>R: StoredEvent canónicos
    R->>R: filters_match del filtro, membresía de canal, visibilidad por lector
    R-->>C: EVENT por cada hit aceptado
    R-->>C: EOSE

En el puente HTTP la misma lógica está en search_hit_accepted (crates/buzz-relay/src/api/bridge.rs:1625-1643), con tres comprobaciones en orden: filters_match contra el evento completo, pertenencia del channel_id al conjunto accesible del lector, y reader_authorized_for_event. El comentario explica el ataque que previene: sin ese paso, una búsqueda autorizada como {"kinds":[30174],"#p":[self]} filtraría sobres que coinciden por texto pero cuyo #p pertenece a otro dueño.

Paginación y presupuesto de escaneo

El handler pagina internamente porque el post-filtrado descarta una proporción impredecible de cada página. SEARCH_PAGE_SIZE = 100 (req.rs:427) y MAX_SEARCH_PAGES = DEFAULT_MAX_PAGE_LIMIT.div_ceil(SEARCH_PAGE_SIZE) (req.rs:440), con DEFAULT_MAX_PAGE_LIMIT = 1_000 (crates/buzz-db/src/event.rs:25): diez páginas de cien. Una página corta es la última, así que el bucle corta al agotarse el conjunto. Un test ata ese presupuesto de escaneo al límite anunciado en NIP-11 (req.rs:1500-1513).

Sobre HTTP, POST /query enruta automáticamente los filtros con search a buzz-search (AGENTS.md:133-134) y acepta dos extensiones propias del puente leídas del filtro crudo: search_mode / searchMode con valor "prefix", y page / search_page / searchPage (bridge.rs:337-356).

Desde el CLI (crates/buzz-cli/src/lib.rs:479-496), donde --author acepta hex de 64 caracteres, npub o nombre visible:

buzz messages search --author Aaron --query checkout --limit 20

¿Y Typesense?

En .env.example:42-45 siguen apareciendo TYPESENSE_API_KEY=buzz_dev_key y TYPESENSE_URL=http://localhost:8108. Son restos. No hay servicio Typesense en docker-compose.yml ni en deploy/compose/; el README del chart de Helm dice que la búsqueda full-text corre en Postgres y que no se aprovisiona un servicio de búsqueda separado (deploy/charts/buzz/README.md:225-226); y las únicas menciones en el código Rust son comentarios históricos que explican qué se eliminó. Definir esas variables no enciende nada.

buzz-audit: el log encadenado por hash

buzz-audit es “un log de auditoría append-only a prueba de manipulación, por comunidad” (crates/buzz-audit/src/lib.rs:3-18). La tabla la crea la migración consolidada 0001; el crate es lógica de cadena pura y no envía DDL.

CREATE TABLE audit_log (
    community_id    UUID NOT NULL REFERENCES communities(id),
    seq             BIGINT NOT NULL,
    hash            BYTEA NOT NULL,
    prev_hash       BYTEA,
    action          VARCHAR(64) NOT NULL,
    actor_pubkey    BYTEA,
    object_id       TEXT,
    detail          JSONB,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (community_id, seq)
);

CREATE UNIQUE INDEX idx_audit_log_hash ON audit_log (community_id, hash);

Una cadena por comunidad. Las filas se identifican por (community_id, seq), seq es monótono dentro de una comunidad, y cada entrada encadena con la anterior de la misma comunidad. Esto importa para multi-tenant, que verás en Multi-tenant: varias comunidades sobre una misma infraestructura.

flowchart LR
    subgraph A["Cadena de la comunidad A"]
        a1["seq 1<br/>prev_hash NULL"] --> a2["seq 2<br/>prev_hash = hash de seq 1"] --> a3["seq 3"]
    end
    subgraph B["Cadena de la comunidad B"]
        b1["seq 1<br/>prev_hash NULL"] --> b2["seq 2"]
    end
    a3 -. "sin enlace entre cadenas" .- b1

Qué cubre el hash, exactamente

compute_hash (crates/buzz-audit/src/hash.rs:42-73) alimenta el SHA-256 en este orden fijo. El orden es parte del contrato: cambiarlo invalida todas las cadenas existentes.

OrdenCampoForma
1community_idBytes del UUID. Va primero, a propósito
2seqBytes big-endian
3created_atRFC3339, normalizado a precisión de microsegundo
4actionEl string estable, p. ej. event_created
5actor_pubkeyByte de presencia 1 + bytes, o byte 0 si es None
6object_idByte de presencia 1 + bytes, o byte 0 si es None
7detailJSON canónico con claves ordenadas
8prev_hashEl hash previo, o GENESIS_HASH = 32 bytes en cero

El community_id lidera el hash para que la identidad de la cadena lleve el tenant. Una fila sacada de la cadena de una comunidad nunca puede verificar dentro de otra: al recomputar con el community_id nuevo, el digest no coincide. Hay un test que lo modela copiando la fila de la comunidad A dentro de la cadena de B y comprobando que devuelve HashMismatch. El byte de presencia distingue Some(vacío) de None, que si no colisionarían.

El timestamp se normaliza con trunc_subsecs(6), y esta es la clase de detalle que rompe una cadena entera en producción. audit_log.created_at es TIMESTAMPTZ, que Postgres guarda a microsegundos, mientras que Utc::now() en Linux devuelve nanosegundos; y to_rfc3339() de chrono emite 0, 3, 6 o 9 dígitos fraccionarios según el valor, así que el timestamp con nanosegundos y su truncamiento a microsegundos son strings distintos. Hashear sin truncar produce un digest irreproducible desde la fila almacenada, y todas las entradas fallan verify_chain con HashMismatch. La normalización vive dentro del propio compute_hash, no solo en el camino de escritura, para que ningún llamador futuro reintroduzca el bug por olvido.

El detail se serializa con claves ordenadas para que el hash sea estable entre máquinas y versiones de Rust, y un fallo de serialización es un error duro. El tipo advierte además (entry.rs:65-71) que detail nunca debe llevar material de tokens: “las entradas AuthSuccess y AuthFailure cargan solo metadata de resultado — el token no tiene ranura en este tipo, y detail no debe convertirse en una.”

Escritor único: advisory lock por comunidad

Las escrituras de una comunidad se serializan con SELECT pg_advisory_lock(hashtextextended($1, 0)) sobre una clave buzz_audit:<community_id> (service.rs:53-80). Que el lock sea por comunidad y no global no es cosmético: un lock global sería a la vez un cuello de botella de throughput y un oráculo de temporización entre tenants.

El append corre dentro de una transacción y el lock se libera en todas las ramas, incluida la de pánico: el futuro se envuelve en AssertUnwindSafe(...).catch_unwind() para que un pánico no devuelva la conexión al pool con el lock tomado.

Las once acciones

AuditAction (crates/buzz-audit/src/action.rs:8-31) define once variantes, con su string estable usado tanto en el hash como en la columna action: event_created, event_deleted, channel_created, channel_updated, channel_deleted, member_added, member_removed, auth_success, auth_failure, rate_limit_exceeded y media_uploaded.

Honestidad sobre qué se registra hoy: en este commit, los únicos dos puntos del relay que construyen una NewAuditEntry son crates/buzz-relay/src/handlers/event.rs:583 con EventCreated y crates/buzz-relay/src/api/media.rs:428 con MediaUploaded. Las otras nueve variantes existen en el enum y se parsean de vuelta desde la base, pero no las verás aparecer solas en tu audit_log. No planifiques un procedimiento de cumplimiento asumiendo que AuthFailure se está escribiendo.

El actor registrado en EventCreated es el principal autenticado que provocó la acción, no stored_event.event.pubkey. Para eventos firmados por el relay —sink de workflow, emisiones de efecto lateral— el autor declarado es la clave del relay, así que derivarlo del evento “borraría del rastro de auditoría al humano detrás de la acción”.

El worker de auditoría

La escritura no ocurre en línea. El relay mantiene un canal mpsc acotado y un worker dedicado (crates/buzz-relay/src/state.rs:746-781):

  • Capacidad del canal: 1000 entradas.
  • El productor usa send().await, no try_send: las entradas nunca se descartan en silencio y la contrapresión llega hasta el handler de eventos. “El advisory lock ya serializa las escrituras, así que una cola llena significa que la base de datos de auditoría está genuinamente sobrecargada y el relay debería frenar en vez de acumular estado en memoria sin límite.”
  • El encolado es lo único que se espera antes de responder: el OK de NIP-01 significa que el evento se aceptó durablemente, no que el publish a Redis, el fan-out o los triggers de workflow hayan terminado.
  • Los fallos de escritura en el worker se registran pero no se reintentan.
  • Al apagar, audit_shutdown.drain(Duration::from_secs(5)) vacía el buffer (main.rs:1082-1084).

Métricas expuestas:

MétricaTipoSignificado
buzz_audit_enabledgauge1 o 0 según BUZZ_AUDIT_ENABLED
buzz_audit_send_errors_totalcounterEntradas perdidas por canal cerrado
buzz_audit_log_errors_totalcounterFallos de escritura en el worker
buzz_audit_log_secondshistogramLatencia del append

La única variable de entorno del subsistema es BUZZ_AUDIT_ENABLED, que por defecto es true (config.rs:910). Cuando está activa, el relay abre un pool Postgres dedicado de máximo 5 conexiones y mínimo 1 solo para auditoría (main.rs:349-357); cuando no, loguea Audit logging disabled by BUZZ_AUDIT_ENABLED y AppState.audit queda en None. Repasa el resto de variables en Configuración: referencia de variables de entorno.

Verificar la cadena

verify_chain(community, from_seq, to_seq) lee exactamente la cadena de esa comunidad, ordenada por seq ascendente, y hace dos comprobaciones por fila:

  1. Que el prev_hash de la entrada sea igual al hash de la anterior. Si no: AuditError::ChainViolation { seq }.
  2. Que el hash recomputado sobre los campos de la fila coincida con el hash almacenado. Si no: AuditError::HashMismatch { seq }.

Devuelve Ok(false) si el rango está vacío y Ok(true) si el segmento es internamente consistente.

Los mensajes de error están deliberadamente pelados. El módulo de errores lleva un test que fija la obligación: el texto de un error de auditoría no puede convertirse en un identificador entre comunidades — ni community_id, ni nombres de constraint, ni audit_log_pkey. Solo puede aparecer seq, “y seq es por comunidad y no significa nada sin la cadena que indexa”.

El límite honesto

SECURITY.md:67-74 no lo esconde: “Como la cadena no tiene clave, es tamper-evident pero no tamper-resistant: detecta corrupción accidental o la edición de una sola fila, pero un atacante con acceso de escritura a la base de datos puede recomputar la cadena entera tras editar.”

Traducido a operación: la cadena demuestra que nadie tocó una fila suelta entre dos snapshots que ya tenías, no que nadie con UPDATE sobre audit_log y tiempo suficiente haya reescrito la historia. Para que la propiedad sirva en una investigación real hay que sacar el head de la cadena fuera del sistema periódicamente —backup inmutable, log externo— y verificar contra ese head. Los procedimientos de respaldo están en Backups, migraciones y actualizaciones.

Para una investigación

-- Head actual de la cadena, y ventana temporal de una comunidad.
SELECT seq, encode(hash, 'hex') AS hash, action, created_at
FROM audit_log WHERE community_id = '<uuid>' ORDER BY seq DESC LIMIT 1;

SELECT seq, action, encode(actor_pubkey, 'hex') AS actor, object_id, detail, created_at
FROM audit_log
WHERE community_id = '<uuid>'
  AND created_at BETWEEN '2026-08-01' AND '2026-08-02'
ORDER BY seq ASC;

Para EventCreated, el object_id es el id hex del evento y el detail lleva event_kind y channel_id, así que se pivota de la entrada de auditoría al evento real con WHERE community_id = '<uuid>' AND id = decode('<object_id>', 'hex') sobre events. Ese pivote es lo que convierte una línea del log en la conversación completa.

Operación

Tamaño del índice

El índice GIN es una relación normal de Postgres, así que se mide con las herramientas normales:

SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) AS total
FROM pg_catalog.pg_statio_user_tables
WHERE relname LIKE 'events_p%'
ORDER BY pg_total_relation_size(relid) DESC;

Se consulta por partición porque events está particionada por rango mensual sobre created_at (migrations/0001_initial_schema.sql:236-252), así que el índice también vive por partición. El relay llama a ensure_future_partitions(3) al arrancar (main.rs:200) — crea las particiones de los próximos tres meses — y el módulo recomienda ejecutarlo “al arrancar y mensualmente vía cron”.

El crecimiento del índice sigue al volumen de eventos y no hay compactación separada que ejecutar. Las palancas reales son la retención de particiones antiguas y qué kinds están dentro de la expresión generada.

Modos de degradación

SituaciónQué pasaCómo se ve
La consulta FTS fallaEl handler loguea NIP-50 search failed y corta el bucle de páginasEl cliente recibe EOSE con resultados parciales o vacíos, no un error
El fetch por lote de eventos fallaLoguea NIP-50 batch fetch failed y cortaIgual: EOSE
Un hit no supera la re-autorizaciónSe descarta en silencioMenos resultados de los que pediste, sin explicación al cliente
La cola de auditoría está llenaContrapresión: el handler de eventos esperaLatencia de ingesta que sube
El worker de auditoría falla al escribirbuzz_audit_log_errors_total sube, sin reintentoLa transacción no llega a insertar, así que no se consume seq y la cadena sigue verificando: lo que se pierde es el registro del hecho, no la integridad
El canal de auditoría está cerradobuzz_audit_send_errors_total sube y se loguea Audit channel closed — entry lostEntrada perdida
BUZZ_AUDIT_ENABLED=falseNo se abre el pool de auditoríabuzz_audit_enabled en 0

Como el post-filtrado descarta una parte impredecible de cada página, el handler “continúa más allá de los rendimientos cortos para dar a la exploración una oportunidad — no una garantía— de llenar el límite pedido”. Un limit de 50 puede devolver 12 resultados aunque existan más de 50 hits textuales: es correcto, no un bug. Métricas y salud del relay en general, en Observabilidad.

Por qué se puede buscar la conversación, el patch y el workflow en un solo lugar

El mecanismo es el que ya viste en Arquitectura del relay: en Buzz todo es un evento Nostr y el entero kind es el único switch de despacho. Un mensaje de canal es kind:9; un patch git NIP-34, kind:1617; un issue, kind:1621; una definición de workflow, kind:30620; un otorgamiento de aprobación, kind:46030; una publicación de foro, kind:45001.

Ninguno vive en una tabla distinta. Todos son filas de events con las mismas columnas — community_id, id, pubkey, created_at, kind, tags, content, channel_id — así que una sola consulta los cruza:

{"kinds":[9,1617,45001,30620],"authors":["<pubkey hex>"],"since":1785000000}

Ese filtro NIP-01 no depende del índice full-text: lo resuelven los btree con community_id a la cabeza, idx_events_community_pubkey_kind_created e idx_events_community_kind_created. Consultar por autor y rango temporal a través de tipos de objeto heterogéneos es gratis estructuralmente, porque no hay tipos heterogéneos: hay un solo tipo con un entero distinto.

Las advertencias son igual de importantes:

  1. El índice FTS cubre content, no tags. Metadata que viaje en tags no es full-text-buscable; hay que filtrarla con los filtros NIP-01 que correspondan.
  2. En una instalación nueva el FTS solo cubre cinco kinds. Un patch kind:1617 en un relay recién instalado no es buscable por texto: aparece por filtros de kind, autor y tiempo, no por search. Ensanchar eso es una decisión consciente sobre la expresión generada, con la migración fuera de banda que implica.
  3. Las compuertas de aprobación de workflow están en la columna ”🚧 Being wired up” del README (README.md:103): “la infraestructura existe, el pegamento sigue secándose”. Los eventos que existan son consultables como cualquier otro, pero no montes un proceso de cumplimiento sobre esa pieza todavía.
  4. El diccionario es simple: sin stemming ni stopwords. La migración lo justifica como paridad con la semántica anterior “tipo substring” y deja abierto revisarlo “con evidencia”. En la práctica, deploys no encuentra deploy salvo en modo Prefix.

La afirmación defendible es esta: el modelo de datos hace posible consultar conversación, patch, workflow y aprobación con un solo filtro, y la frontera de acceso es la misma para todos. La búsqueda full-text encima es una capa opcional, con su propia lista de kinds, que cada operador decide cuán ancha quiere.

Resumen

  • El índice de búsqueda es la columna generada events.search_tsv: cada escritura de fila es la actualización del índice. No hay indexador, cola, job de reindex ni servicio que operar. Typesense está muerto en este código.
  • Los kinds sensibles se excluyen a nivel de almacenamiento con NULL::tsvector — 1059, 30300, 30622, 44100, 44101, más 44200 y 30350 por migraciones posteriores — y una instalación nueva aplica en cambio una allowlist positiva de solo cinco: 0, 9, 40002, 45001, 45003.
  • La única operación parecida a un reindexado es scripts/maintenance/nip_rs_search_allowlist.sql, que toma ACCESS EXCLUSIVE y exige ventana de mantenimiento.
  • Los REQ NIP-50 son de una sola vez, no se mezclan con filtros sin search, y los p-gates corren antes de la rama de búsqueda. kinds, authors, since y until se empujan al mismo SQL con community_id = $ctx como primer predicado innegociable.
  • buzz-search devuelve candidatos; el relay refetchea el evento canónico y re-autoriza cada hit. “Search is never the access boundary.”
  • El log de auditoría es una cadena SHA-256 por comunidad, con (community_id, seq) como clave, community_id liderando el hash, detail en JSON canónico y advisory lock por comunidad liberado incluso ante pánico.
  • verify_chain distingue ChainViolation de HashMismatch, sus errores no filtran identificadores entre comunidades, y la cadena es tamper-evident, no tamper-resistant.
  • Hoy solo se escriben dos de las once acciones: EventCreated y MediaUploaded. El worker usa un canal acotado de 1000 con contrapresión deliberada, drena 5 segundos al apagar y se controla con BUZZ_AUDIT_ENABLED, true por defecto.

Siguiente: Multi-tenant: varias comunidades sobre una misma infraestructura