Búsqueda y registro de auditoría
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, conGIN (search_tsv)como camino de acceso. Como la columna esGENERATED 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:
| Kind | Constante | Por qué se excluye | Migración |
|---|---|---|---|
| 1059 | KIND_GIFT_WRAP | Texto cifrado NIP-17 | 0001 |
| 30300 | KIND_EVENT_REMINDER | Kinds solo-para-el-autor, defensa en profundidad | 0001 |
| 30622 | KIND_DM_VISIBILITY | Estado privado de ocultamiento por observador | 0001 |
| 44100 | KIND_MEMBER_ADDED_NOTIFICATION | Aviso de membresía p-gated | 0001 |
| 44101 | KIND_MEMBER_REMOVED_NOTIFICATION | Aviso de membresía p-gated | 0001 |
| 44200 | KIND_AGENT_TURN_METRIC | Texto cifrado NIP-44; NIP-AM exige no indexar | 0005 |
| 30350 | KIND_PUSH_LEASE | Texto cifrado con endpoints, solo-autor | 0014 |
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:
| Kind | Constante | Qué es |
|---|---|---|
| 0 | KIND_PROFILE | Metadata de usuario |
| 9 | KIND_STREAM_MESSAGE | Mensaje de canal |
| 40002 | KIND_STREAM_MESSAGE_V2 | Mensaje de canal v2 |
| 45001 | KIND_FORUM_POST | Publicación de foro |
| 45003 | KIND_FORUM_COMMENT | Comentario 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:
- Son de una sola vez. “Search subscriptions are one-shot — no persistent
subscription is registered.” Producen resultados ordenados por relevancia
seguidos de
EOSEy no se registran para fan-out: un evento nuevo que coincida con tu texto no te va a llegar solo. - No se pueden mezclar filtros con y sin
searchen 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). - 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#ppropio, la respuesta esrestricted: 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:
| Campo | Tipo | Qué hace |
|---|---|---|
community | CommunityId | Comunidad resuelta por el servidor. Obligatoria a nivel de tipo |
q | String | Texto NIP-50. Vacío se rechaza antes de tocar Postgres |
channel_scope | ChannelScope | Restricción por canal, cuatro variantes |
kinds | Option<Vec<i32>> | Filtro de kinds NIP-01 |
authors | Option<Vec<Vec<u8>>> | Pubkeys de 32 bytes |
since / until | Option<i64> | Cotas inclusivas sobre created_at, en segundos Unix |
page / per_page | u32 | Paginación. per_page se topa en 500, page en 1000 |
mode | SearchMode | FullText 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::FullTextusawebsearch_to_tsquery('simple', $texto). Es el modo del REQ NIP-50.SearchMode::Prefixnormaliza cada token con el parsersimpley 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 quequote_literalpreviene 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 accesibles | Incluir globales | Variante | SQL emitido |
|---|---|---|---|
| No vacío | sí | ChannelsOrChannelLess | channel_id = ANY($1) OR channel_id IS NULL |
| No vacío | no | Channels | channel_id = ANY($1) |
| Vacío | sí | ChannelLessOnly | channel_id IS NULL |
| Vacío | no | — | El 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.
| Orden | Campo | Forma |
|---|---|---|
| 1 | community_id | Bytes del UUID. Va primero, a propósito |
| 2 | seq | Bytes big-endian |
| 3 | created_at | RFC3339, normalizado a precisión de microsegundo |
| 4 | action | El string estable, p. ej. event_created |
| 5 | actor_pubkey | Byte de presencia 1 + bytes, o byte 0 si es None |
| 6 | object_id | Byte de presencia 1 + bytes, o byte 0 si es None |
| 7 | detail | JSON canónico con claves ordenadas |
| 8 | prev_hash | El 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, notry_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
OKde 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étrica | Tipo | Significado |
|---|---|---|
buzz_audit_enabled | gauge | 1 o 0 según BUZZ_AUDIT_ENABLED |
buzz_audit_send_errors_total | counter | Entradas perdidas por canal cerrado |
buzz_audit_log_errors_total | counter | Fallos de escritura en el worker |
buzz_audit_log_seconds | histogram | Latencia 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:
- Que el
prev_hashde la entrada sea igual alhashde la anterior. Si no:AuditError::ChainViolation { seq }. - Que el hash recomputado sobre los campos de la fila coincida con el
hashalmacenado. 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ón | Qué pasa | Cómo se ve |
|---|---|---|
| La consulta FTS falla | El handler loguea NIP-50 search failed y corta el bucle de páginas | El cliente recibe EOSE con resultados parciales o vacíos, no un error |
| El fetch por lote de eventos falla | Loguea NIP-50 batch fetch failed y corta | Igual: EOSE |
| Un hit no supera la re-autorización | Se descarta en silencio | Menos resultados de los que pediste, sin explicación al cliente |
| La cola de auditoría está llena | Contrapresión: el handler de eventos espera | Latencia de ingesta que sube |
| El worker de auditoría falla al escribir | buzz_audit_log_errors_total sube, sin reintento | La 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á cerrado | buzz_audit_send_errors_total sube y se loguea Audit channel closed — entry lost | Entrada perdida |
BUZZ_AUDIT_ENABLED=false | No se abre el pool de auditoría | buzz_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:
- El índice FTS cubre
content, notags. Metadata que viaje en tags no es full-text-buscable; hay que filtrarla con los filtros NIP-01 que correspondan. - En una instalación nueva el FTS solo cubre cinco kinds. Un patch
kind:1617en un relay recién instalado no es buscable por texto: aparece por filtros de kind, autor y tiempo, no porsearch. Ensanchar eso es una decisión consciente sobre la expresión generada, con la migración fuera de banda que implica. - 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. - 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,deploysno encuentradeploysalvo en modoPrefix.
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,sinceyuntilse empujan al mismo SQL concommunity_id = $ctxcomo primer predicado innegociable. buzz-searchdevuelve 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_idliderando el hash,detailen JSON canónico y advisory lock por comunidad liberado incluso ante pánico. verify_chaindistingueChainViolationdeHashMismatch, 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:
EventCreatedyMediaUploaded. El worker usa un canal acotado de 1000 con contrapresión deliberada, drena 5 segundos al apagar y se controla conBUZZ_AUDIT_ENABLED,truepor defecto.
Siguiente: Multi-tenant: varias comunidades sobre una misma infraestructura