Multi-tenant: varias comunidades sobre una misma infraestructura

Por: Artiko
buzznostrrelaymulti-tenantaislamientopostgresconformanceadministracion

Multi-tenant: varias comunidades sobre una misma infraestructura

Hasta aquí el curso asumió el caso simple: un relay, un dominio, una comunidad — el despliegue por defecto, el que montaste en Despliegue en un VPS con Docker Compose. Pero el esquema de base de datos de Buzz no es single-tenant con un parche multi-tenant encima: la migración 0001_initial_schema.sql se llama, literalmente, “Buzz initial Postgres schema — multi-tenant”, y community_id es una columna de primera clase en prácticamente cada tabla. El despliegue de una sola comunidad es el caso degenerado de ese modelo, no un modo distinto.

El modelo: la URL es el selector

La regla de cara al cliente está en el README: “A Buzz community is the workspace a user reaches by URL.” En el despliegue de un solo relay que se envía hoy, la URL selecciona exactamente una comunidad. Un operador hospedado puede servir muchas comunidades detrás de muchos dominios o subdominios, y la regla no cambia: la URL es autoritativa sobre el workspace, y todo el estado bajo esa URL es local a esa comunidad.

Esto es la “fila cero” del documento de conformance (docs/multi-tenant-conformance.md):

req.community = resolve_host(connection.host)

Resuelta antes de que cualquier EVENT/REQ de WebSocket, handler REST, handler de medios, transporte git, webhook, efecto de workflow, consulta de búsqueda o fan-out de pub/sub observe datos de tenant. Tres consecuencias operativas:

  • El host de la URL es el selector autoritativo. Nada que venga del cliente lo puede sobreescribir.
  • Un host desconocido o no mapeado falla cerrado con un rechazo genérico. Nunca cae a un tenant por defecto.
  • Un token NIP-98 o de API puede estrechar o autenticar la autoridad, pero jamás la puede cambiar de comunidad. Un token cuyo sello de comunidad no coincide con req.community se rechaza.
flowchart TD
    A["Cliente conecta a wss://equipo-a.example"] --> B["Header Host: equipo-a.example"]
    B --> C["normalize_host en buzz-core"]
    C --> D{"lookup en tabla communities por lower host"}
    D -->|"encontrado"| E["TenantContext resuelto: community + host"]
    D -->|"no mapeado"| F["404 generico, sin upgrade a WebSocket"]
    D -->|"error de lookup"| F
    E --> G["Upgrade a WebSocket con el tenant ya fijado"]
    G --> H["Handlers: EVENT, REQ, REST, media, git, search, pubsub"]

El código del seam vive en crates/buzz-relay/src/tenant.rs, con la función bind_community, y se llama desde crates/buzz-relay/src/router.rs:300 antes del upgrade a WebSocket. El comentario del código lo dice sin adornos: “no frame is ever read on an unbound connection”. Cuando falla, la respuesta es un 404 con el texto relay: no community is configured for this host, deliberadamente indistinguible entre “host no mapeado” y “la base de datos no respondió”, y sin eco del host: así nadie puede usar el relay como oráculo para enumerar comunidades.

Normalización del host: la trampa silenciosa

buzz_core::tenant::normalize_host es la única regla de normalización, compartida por los dos lados de la cerca: la columna communities.host se guarda ya normalizada y la resolución normaliza el Host entrante con la misma función antes de buscar. Las reglas: ASCII-lowercase, se quita un único punto final del FQDN, se quitan los puertos por defecto :80 y :443 —un puerto no-default se conserva, porque un despliegue puede servir comunidades distintas en puertos distintos del mismo nombre— y se recorta el whitespace. Los tests del crate lo fijan: relay.example, Relay.Example, RELAY.EXAMPLE, relay.example., relay.example:443, relay.example:80 y Relay.Example.:443 colapsan todos al mismo tenant. relay.example:8443 y relay.example:3000 no colapsan: son selectores distintos. Los literales IPv6 quedan intactos: [::1]:443 colapsa a [::1].

Un host vacío resuelve a cadena vacía y bind_community corta antes de consultar la base: el esquema no prohíbe una fila con host = '', así que sin esa guarda una petición con Host ausente se ligaría en silencio a una comunidad mal configurada.

La consecuencia para el proxy inverso

El relay lee el header Host estándar (axum::http::header::HOST en router.rs:265). No lee X-Forwarded-Host: si tu proxy reescribe el Host hacia el upstream, todas las peticiones llegan con el host equivocado y fallan cerradas con el 404 genérico. El Caddyfile del bundle de deploy/compose/ es un {$BUZZ_DOMAIN} { reverse_proxy relay:3000 } de una línea y preserva el Host por defecto. Si usas nginx, HAProxy o un ingress de Kubernetes, verifica que el Host llegue intacto: es el fallo de configuración número uno de este modelo.

El bootstrap: de dónde sale la primera comunidad

En el arranque, crates/buzz-relay/src/main.rs:260 deriva el host desde RELAY_URL —la variable del relay, no la BUZZ_RELAY_URL del harness ACP— usando relay_url_authority y llama a db.ensure_configured_community(&host) (main.rs:274), que es idempotente. El comentario del código explica el porqué: sembrar la comunidad del despliegue antes de cualquier backfill de membresía, “so the owner lands in exactly the community that live requests for this host will resolve to”. Si el host derivado queda vacío y BUZZ_REQUIRE_RELAY_MEMBERSHIP está activo, el arranque falla en duro; si la membresía no es obligatoria, registra un warning y sigue sin sembrar.

Aquí está el segundo error clásico de operación: RELAY_URL y el dominio público tienen que coincidir. En deploy/compose/.env.example conviven BUZZ_DOMAIN, RELAY_URL, BUZZ_MEDIA_BASE_URL, BUZZ_MEDIA_SERVER_DOMAIN y BUZZ_CORS_ORIGINS, todos apuntando al mismo nombre. Si RELAY_URL apunta a buzz.example.com pero los clientes llegan a relay.example.com, el arranque siembra la comunidad del primero y las conexiones reales se rechazan con 404: el relay está sano, la base está sana, y nadie puede conectarse.

El CLI usa el mismo mecanismo: buzz-admin resuelve su tenant desde RELAY_URL (por defecto ws://localhost:3000) con la misma relay_url_authority, y si el host no está mapeado falla con un error explícito en vez de operar sobre un tenant por defecto. Es single-community por invocación; no existe un barrido cross-community.

Qué queda scopeado por comunidad

El punto central del modelo: la infraestructura compartida es un detalle de implementación, no un workspace global. Un Postgres, un Redis y un bucket S3 compartidos no producen un espacio de nombres compartido, y un proceso relay es cómputo sin estado: no posee datos de ninguna comunidad, sirve a cualquiera, y N procesos comparten el almacén.

flowchart LR
    subgraph Hosts["Hosts publicos"]
        H1["equipo-a.example"]
        H2["equipo-b.example"]
    end
    subgraph Relay["Procesos relay sin estado"]
        R1["relay pod 1"]
        R2["relay pod 2"]
    end
    subgraph Infra["Infraestructura compartida"]
        PG["Postgres con community_id en cada fila"]
        RD["Redis con claves buzz:community:..."]
        S3["Object storage con punteros por comunidad"]
    end
    H1 --> R1
    H1 --> R2
    H2 --> R1
    H2 --> R2
    R1 --> PG & RD & S3
    R2 --> PG & RD & S3
SuperficieCómo queda scopeadaEvidencia en el repositorio
Registro de tenantscommunities(id, host, signing_key, created_at), UNIQUE (lower(host))migrations/0001_initial_schema.sql:53-61
Log de eventosevents con PK (community_id, created_at, id), particionada por rango de created_at0001:191-235
Índice de lookup directoidx_events_community_id sobre (community_id, id, created_at DESC)0001:257
CanalesPK (community_id, id); el mismo UUID puede existir en dos comunidades0001:97
Inmutabilidad de canalTrigger trg_channels_community_id_immutable que lanza excepción en cualquier UPDATE que cambie community_id0001:112-127
Usuarios y NIP-05PK (community_id, pubkey); único (community_id, lower(nip05_handle))0001:155-180
Auditoríaaudit_log con PK (community_id, seq) y único (community_id, hash)0001:606-619
Cadenas de auditoría (cap. 10)Una cadena hash independiente por comunidad, serializada con pg_advisory_lockcrates/buzz-audit/src/service.rs:25-108
Búsqueda FTSPostgres FTS con community_id como primer predicado de toda consultacrates/buzz-search/src/lib.rs:3-50
Claves de pub/subbuzz:{community}:channel:{channel_id}, buzz:{community}:globalcrates/buzz-pubsub/src/topic.rs:44-48
Presencia, caché y conexionesbuzz:{community}:presence:{pubkey}, buzz:{community}:cache-invalidate, buzz:{community}:conn-controlpresence.rs:123, cache_invalidation.rs:193, conn_control.rs:178
Punteros git (cap. 9)repos/{community}/{owner}/{repo}/pointercrates/buzz-relay/src/api/git/manifest.rs:174-183

Los bytes crudos de un blob de medios pueden seguir siendo direccionados por contenido y deduplicados a nivel de operador. Lo que no es compartido son los descriptores, la autorización de subida, las cuotas, las filas de auditoría y cualquier política de lectura: un hash compartido puede ser una optimización de almacenamiento, pero los metadatos y los errores no pueden revelar la subida privada de otra comunidad.

Qué es deliberadamente operator-global

No todo puede ser por comunidad. El esquema lleva un registro explícito de tablas operator-global, y es una tabla de la base de datos, no una lista hardcodeada en el linter:

INSERT INTO _operator_global_tables (table_name, reason) VALUES
    ('communities',           'the tenant registry itself; id IS the community key'),
    ('rate_limit_violations', 'deployment abuse/health; never tenant-observable; community_id is an attribution label only'),
    ('_operator_global_tables', 'the registry table itself');

El linter de buzz-db reconoce además las tablas del gateway de push (push_gateway_challenges, push_gateway_installations, push_gateway_delegations, push_gateway_endpoint_quotas, push_gateway_delivery_auth_replays, push_gateway_delivery_request_replays), product_feedback y replica_heartbeat. Cualquier tabla que no esté en ese registro debe llevar community_id NOT NULL y encabezar sus uniques con esa columna.

La cerca: el h tag es entrada adversaria

El documento formal docs/multi-tenant-relay.md es explícito sobre el peligro que este diseño tiene que cerrar: el diputado confundido (Hardy, 1988). El relay tiene autoridad amplia sobre una base compartida; si actúa bajo un nombre que suministra el cliente, el cliente escapa de su comunidad. La defensa es disciplina de capacidades: la autoridad se liga al objeto resuelto, nunca a un tag que trae el cliente.

sequenceDiagram
    participant C as Cliente en host A
    participant R as Relay
    participant DB as Postgres
    C->>R: Conexion WebSocket, Host: a.example
    R->>DB: resolve_host normalizado
    DB-->>R: community A
    Note over R: TenantContext fijado antes del primer frame
    C->>R: EVENT con h tag apuntando a canal de comunidad B
    R->>DB: resolve del canal bajo el mismo snapshot
    DB-->>R: el canal pertenece a la comunidad B
    R-->>C: rechazo generico, restricted
    Note over R: nunca se actua como B, ni siquiera en la ruta de duplicado

Dos reglas gobiernan la composición:

  • Evento con h tag: se exige que resolve(h) sea igual a resolve_host(host). Un host de A presentando un evento de un canal de B se rechaza cerrado; no se actúa como B.
  • Evento sin h tag (perfiles, DMs, long-form, listas, read-state): se guarda community_id = resolve_host(host) con channel_id = NULL. La admisión que decide quién puede hacerlo es la de Membresía, roles y moderación, ahora con clave (community_id, pubkey).

Y hay una tercera regla que suele pasar desapercibida: la ruta del duplicado también está cercada. Si un host de A pudiera provocar un INSERT ... ON CONFLICT DO NOTHING contra un id de canal de B, el resultado “duplicado” sería un oráculo de existencia cross-tenant; el fence corta antes de la búsqueda de conflicto.

En el nivel de tipos, TenantContext no tiene Default ni Deserialize, y CommunityId no tiene From<Uuid> público para parsear entrada de cliente. Pero el propio código lo declara con honestidad: “This is a lint-and-review fence, not a compiler fence.” Ambos constructores son públicos porque la resolución vive en otro crate: el tipo elimina el camino accidental, y el deliberado lo cierran el lint y la revisión.

El plano de operador

Las comunidades no se crean con DDL: crear una comunidad es un INSERT. El plano que lo hace está fuera del plano de datos de Nostr y tiene su propia autenticación, gobernada por dos variables (declaradas en crates/buzz-relay/src/config.rs:186-205 y leídas en config.rs:656-686):

VariableQué hace
RELAY_OPERATOR_PUBKEYSLista separada por comas de pubkeys hex de 64 caracteres autorizadas a usar /operator/communities. Vacío (el default) desactiva el aprovisionamiento por completo: falla cerrado. Una entrada inválida es un error de configuración en el arranque, no una entrada saltada en silencio.
RELAY_OPERATOR_API_ORIGINOrigen HTTP canónico del API de operador. Cada tag u de NIP-98 de operador se verifica contra este origen, con independencia del header Host entrante y del registro de tenants. Es obligatorio cuando RELAY_OPERATOR_PUBKEYS no está vacío.

El operador es distinto de RELAY_OWNER_PUBKEY: el owner es un rol dentro de la comunidad del despliegue, mientras que el operador cruza tenants —crea comunidades y hace bootstrap de owners iniciales— sin fila de membresía implícita en ninguna. Rutas en router.rs:76-93:

Método y rutaHandler
GET /operator/communitieslist_owned_communities, con query owner_pubkey
POST /operator/communitiesprovision_community
GET /operator/communities/availabilitycommunity_availability, con query host
POST /operator/communities/archivearchive_community
POST /operator/communities/unarchiveunarchive_community
POST /operator/communities/transfertransfer_community

Todas exigen NIP-98 sin excepción —“always require NIP-98; no X-Pubkey dev fallback”— y usan su propio namespace de anti-replay, operator-management. El cuerpo de aprovisionamiento es ProvisionCommunityRequest:

{
  "host": "equipo-b.example",
  "initial_owner_pubkey": "0123...64hex",
  "create_only": true
}

create_only: true crea host y owner de forma atómica y rechaza un host existente en vez de converger o rotar la propiedad; sin ese flag la petición es idempotente y la respuesta trae status: "created" o status: "existed". El host del cuerpo pasa por normalize_candidate_host, que rechaza cadena vacía, hosts de más de MAX_HOST_LEN bytes, caracteres de control o whitespace, y cualquier cosa que contenga /, ?, # o @: el campo debe ser una autoridad desnuda sin esquema, ruta, query ni userinfo. Recién después aplica la misma normalize_host de buzz-core. transfer_community toma community_id, new_owner_pubkey y expected_owner_pubkey; el último es el guardia de concurrencia optimista para que dos transferencias en carrera no se pisen.

Aparte de todo esto, BUZZ_ADMIN_HOST define una autoridad exacta y separada: si el Host entrante coincide con ella, el relay corta antes de la ruta pública —no sirve el bundle web, ni NIP-11, ni hace upgrade a WebSocket— y expone solo rutas de lectura (/reports, /feedback) validando además el header Origin. Sin esa variable configurada, el API de admin devuelve 404 completo.

NIP-11: la excepción que confirma la regla

El documento de información del relay en / es no autenticado y no tiene tenant por construcción. El modelo formal lo trata como un canal aparte, la clase C2.4, cerrado por una cerca de tipos: la función que lo construye consume solo configuración estática del relay, sin handle de base de datos, contexto de tenant ni servicio de auditoría.

Operativamente hay un matiz que importa: NIP-11 falla abierto, no cerrado. Un host no mapeado igual recibe el documento, simplemente sin los campos scopeados por host como el icon del workspace. El comentario en router.rs explica la razón: “so the doc cannot leak which hosts are mapped”. Si NIP-11 fallara cerrado, la respuesta misma sería un enumerador de comunidades.

La regla que el documento formal impone hacia el futuro: supported_nips está bien, un total_events sería una fuga cross-tenant. Cualquier endpoint no autenticado nuevo —métricas de NIP-66, sondas de salud que expongan contadores— cae bajo la misma clase por defecto.

Conformance: cómo se valida el aislamiento

Hay tres capas de validación, y conviene no confundirlas:

flowchart TD
    A["docs/multi-tenant-conformance.md"] --> B["Checklist superficie por superficie: fuente del tenant, scope de DB, efectos de auth y fan-out, compatibilidad N igual 1"]
    C["docs/spec/MultiTenantRelay.tla"] --> D["TLC: no interferencia como invariante de flujo de etiquetas"]
    E["docs/spec/MultiTenantAuth.spthy"] --> F["Tamarin: solidez de autorizacion bajo adversario Dolev-Yao"]
    G["crate buzz-conformance"] --> H["Replay de trazas reales del relay contra el modelo"]
    I["Lints de migracion en buzz-db"] --> J["community_id NOT NULL, uniques compuestos, inmutabilidad de canal"]

La capa 1: el checklist

docs/multi-tenant-conformance.md es la tabla fuente-contra-modelo. Cada fila es una superficie —NIP-11, tokens de API, membresía, usuarios y NIP-05, eventos sin canal y DMs, canales, workflows, búsqueda, pub/sub, medios, git, mesh y agentes, auditoría— con siete columnas: comportamiento observable de hoy, fuente del tenant, community-global contra operator-global, scope requerido en DB e índices, efectos en auth/fan-out/búsqueda, compatibilidad con una sola comunidad, y la decisión o test abierto. Cierra con cinco gates de migración que deben estar automatizados antes de admitir el modo multi-tenant:

  1. Toda tabla scopeada tiene community_id, política RLS y ninguna restricción unique o FK observable entre tenants, salvo admisión explícita como operator-global.
  2. Todo lookup directo por id de evento, hash de token, id de workflow, token de aprobación, puntero de repo, metadatos de medios, perfil por pubkey o id de canal lleva contexto de comunidad, o primero resuelve el objeto bajo la comunidad.
  3. Toda clave de caché, búsqueda, pub/sub u object storage que afecte observaciones de tenant incluye contexto de comunidad, salvo el almacenamiento CAS deliberadamente compartido.
  4. Todo handler alcanzable desde fuera obtiene su TenantContext desde el binding de host antes de leer datos del cuerpo que puedan causar efectos de tenant.
  5. Los tests con N = 1 prueban que los clientes existentes no necesitan tags, rutas, campos de evento, flags de CLI ni mensajes de protocolo nuevos.

Sobre el punto 1 conviene una advertencia honesta: las migraciones incluidas en el repositorio no crean políticas de row-level security. RLS aparece en el documento formal como axioma A-RLS-1..5, una obligación admitida por despliegue mediante aserciones de arranque o CI, no algo que la migración 0001 te deja hecho. En el esquema hoy están las claves compuestas, los índices que encabezan con community_id, el trigger de inmutabilidad y los lints. RLS es el respaldo para que un predicado de aplicación olvidado devuelva cero filas en vez de todas; si lo necesitas, es trabajo del operador.

La capa 2: los modelos mecanizados

docs/spec/MultiTenantRelay.tla codifica el aislamiento como no interferencia, no como un WHERE community_id = $1. La diferencia importa: un invariante de igualdad de filas no dice nada sobre timing, errores, colisiones de unicidad, reconstrucción de proyecciones o la compuerta de auth. La codificación es un invariante de flujo de etiquetas: cada elemento de estado lleva la etiqueta de la comunidad de la que se originó, y la propiedad es “ningún valor de etiqueta alta fluye hacia una observación baja”.

En el harness finito comprometido, TLC completa exhaustivamente: 472.530.528 estados generados, 16.226.016 distintos, cero en cola, profundidad 13. Trece mutaciones (M1 a M13) están confirmadas en rojo: el lookup directo por id sin scope, la etiqueta de error ensanchada, la clave de conflicto global sin community_id, el host no mapeado cayendo a un tenant por defecto, la ruta del duplicado sin cerca, la admisión re-clavada a “admitido en cualquier comunidad”, y las de AUTH abierto, creación de canal y las dos lecturas sin #h.

docs/spec/MultiTenantAuth.spthy prueba la solidez de autorización bajo adversario Dolev-Yao: 32 lemas en verde con Tamarin 1.12.0 y Maude 3.5.1 en unos 12 segundos. Un token sellado para A nunca autoriza en B; un token filtrado sí autoriza dentro de su propia comunidad —el radio de explosión no es cero y el documento no finge lo contrario— pero nunca en otra; la clave de firma de B nunca admite una pubkey en A por NIP-43; y una autoregistración en comunidad abierta se sella con la comunidad resuelta por host.

La capa 3: el crate buzz-conformance

Aquí está la parte que un operador puede ejecutar, gobernada por una frase: “Don’t ask ‘did the model pass.’ Ask ‘did the running code emit a trace the model accepts.’”

crates/buzz-conformance define un esquema de trazas y un checker de replay independiente. La regla de independencia está escrita en el propio Cargo.toml y es tajante: el crate no depende de ningún crate de producción de Buzz —ni buzz-db, ni buzz-relay, ni buzz-pubsub, ni buzz-auth, ni buzz-search, ni buzz-audit— y lleva su propio newtype opaco CommunityLabel en vez de reusar buzz_core::CommunityId: así no perfora la cerca de “sin Serde, sin From<Uuid>” de producción, y un bug en los tipos de producción no se convierte mecánicamente en un bug del checker. El checker reimplementa la relación de transición del spec desde cero.

El relay emite un TraceStep por decisión en el borde de ingesta/auth/lectura:

{
  "schema_version": 1,
  "action": { "type": "write_insert", "msg_id": "d34db33fcafef00d", "channel": "cafe0000-0000-0000-0000-000000000010", "claimed_community": "aaaa0000-0000-0000-0000-000000000001" },
  "state_after": { "resolved_community": "aaaa0000-0000-0000-0000-000000000001", "bound_host": "a.example.test", "actor": "0123456789abcdef" }
}

El estado es proyectado, no crudo: lleva la comunidad resuelta por el servidor, la etiqueta opaca del host y los primeros 16 hex de la pubkey autenticada, pero no el h tag del cliente, el id de evento, el payload, bytes del Host, claves, tokens ni firmas. La regla clave: claimed_community se registra separado de resolved_community —si discrepan gana la resuelta— y la traza muestra ambas para que la mutación “autorizar según lo declarado” pueda morder.

Las acciones de TraceAction cubren el seam de escritura (write_insert, write_insert_global, write_duplicate), el de lectura (auth_check, read_message_rows, read_by_id_rows, read_host_feed_rows), el de error (sanitized_error, con alfabeto cerrado restricted / invalid / server_error) y el testigo de cobertura impl_bug. row_communities es un Vec sin deduplicar: el checker tiene que ver cada etiqueta filtrada, no el conjunto.

El checker devuelve tres clases de fallo: IllegalTransition cuando la acción trazada no está permitida desde el estado del modelo —lo que muerde cuando un auth_check con veredicto allow lleva una comunidad declarada ajena—; NonInterference cuando row_communities incluye una comunidad distinta de la resuelta, que es la fuga de filas ajenas; y CoverageBreach ante una traza vacía, una acción crítica requerida que nunca apareció, o un impl_bug porque un seam crítico salió sin emitir nada. La cobertura es load-bearing: sin ella, esto sería logging decorativo.

El módulo emisor del lado del relay vive en crates/buzz-relay/src/conformance/ (mod.rs con el EmitGuard y tracers.rs con los tracers), y el consumidor independiente en el crate buzz-conformance (src/lib.rs, src/checker.rs, src/transitions.rs).

Hay cuatro fixtures JSONL en crates/buzz-conformance/tests/fixtures/: good.jsonl que pasa, bad_host_channel_mismatch.jsonl que produce IllegalTransition, bad_coverage_breach.jsonl que produce CoverageBreach, y bad_foreign_row_leak.jsonl con una fila bbbb... bajo un tenant resuelto aaaa.... El test reconstruye cada fixture desde Rust tipado, verifica que el archivo coincida byte a byte y recién entonces lo reproduce: un cambio de esquema obliga a actualizarlos.

cargo test -p buzz-conformance --lib                    # 9 tests de esquema y checker
cargo test -p buzz-conformance --test replay_fixtures   # 6 tests de fixtures
cargo test -p buzz-relay --lib conformance::            # tests del EmitGuard y los tracers

# Refrescar fixtures a proposito tras un cambio de esquema
BUZZ_CONFORMANCE_UPDATE=1 cargo test -p buzz-conformance --test replay_fixtures

# En el Justfile, enganchado al job de tests unitarios junto al lint de scoping de tenant
cargo nextest run -p buzz-conformance
cargo nextest run -p buzz-db --lib

El comentario del Justfile es directo: sin ese último gate, “un archivo perdido en migrations/ o un lint roto se publica en verde”.

Lo que el gate NO atrapa

crates/buzz-conformance/LIMITS.md existe para que nadie lea de más en una corrida verde. Empieza con “The runtime conformance harness is not a proof” y declara fuera de alcance:

  • Fugas de la capa de DB que la proyección no lee. El seam de lectura está pendiente: el documento dice literalmente que “the read-seam half of the gate is not yet armed”, a la espera de la decisión de diseño sobre cómo calcular la etiqueta por fila.
  • Fugas cross-pod. El harness traza un proceso; una fuga multi-pod aparece solo en el pod que la observa. Propiedades acotadas por tiempo: el spec es sin tiempo y el gate también.
  • Fan-out de pub/sub. No es una acción del spec; una fuga ahí aparece en la traza de ingesta o lectura del receptor, no en el emit del publicador.
  • Cerca de tipos y bugs del spec. Que CommunityId no tenga From<Uuid> lo garantiza el compilador, no el gate. Y el checker reimplementa el spec: si el spec está mal, ambos pasan.

Y el detalle operativo que más importa: en producción el tracer por defecto es NoopTracer. El gate es solo observación, no realimenta la decisión; apagarlo no cambia el comportamiento del relay, solo pierdes la señal. Además, la brecha de cobertura solo puede dispararse en rutas donde el harness fue armado: un endpoint nuevo que toque el borde de tenant y no arme su EmitGuard deja el gate ciego, y eso lo enforcea la revisión de código, no el harness.

Riesgos de aislamiento que quedan declarados

El documento formal enumera lo que no promete, y esa lista es tan operativa como la de garantías.

Clase C1: canales físicos acotados por ancho de banda. Buffer cache, autovacuum, estadísticas del planner, throughput del borde derecho de las particiones y latencia de cola del pool de conexiones son compartidos, y un co-tenant los puede medir como timing. El documento es explícito: “We do not claim timing non-interference.”

Clase C2: canales lógicos, cada uno cerrado. El oráculo de existencia de ids de evento, cerrado por la unicidad compuesta (community_id, ..., id): una escritura de B contra un id que A ya tiene obtiene una clave fresca, no un conflicto. La superficie de errores de constraint, cerrada por un alfabeto sanitizado de nueve prefijos alcanzables desde NIP-01: auth-required, restricted, invalid, duplicate, pow, rate-limited, blocked, error, frame-too-large. La ruta de reconstrucción de proyecciones, que toca eventos de todas las comunidades pero solo escribe tablas del lado servidor y nunca sirve filas. Y NIP-11, cerrada por la cerca de tipos.

Clase C3: escrituras históricas tras revocación. La compuerta de admisión gobierna la capacidad actual. Revocar a un miembro se la quita, pero no re-etiqueta ni borra las filas que escribió mientras estaba admitido: “We do not claim historical writes are revoked when a member is revoked.” La redacción retroactiva es una superficie de ciclo de vida de datos del operador, fuera del modelo de aislamiento.

Fuga por encima de la interfaz. El límite de la prueba es la interfaz observacional del relay. Si un cliente multi-tenant, un nevent NIP-19 compartido o un log filtrado expone ids de eventos de la comunidad A a un usuario que también es miembro de B, ese usuario tiene un id de A fuera de banda y puede sondear el oráculo de existencia desde una conexión de B. El índice compuesto hace que el sondeo no revele nada, pero cerrarlo con un índice más débil es obligación del cliente.

Alta disponibilidad: NIP-98 y el seen-set compartido

La frescura del mint NIP-98 —que viste en Identidad y autenticación— se apoya en dos chequeos: la ventana de ±60 segundos (TIMESTAMP_TOLERANCE_SECS = 60 en crates/buzz-auth/src/nip98.rs:32, aplicada en :81-83) y un seen-set por id de evento invocado desde crates/buzz-relay/src/api/bridge.rs::check_nip98_replay (bridge.rs:136-156).

Aquí es donde conviene desconfiar de la documentación de diseño: el documento formal describe el seen-set in-process como una obligación pendiente del operador en HA, pero el código ya no usa uno. AppState instancia un guardia respaldado por Redis y no hay otra implementación cableada (crates/buzz-relay/src/state.rs:803-804):

let nip98_replay: Arc<dyn Nip98ReplayGuard> =
    Arc::new(RedisNip98ReplayGuard::new(redis_pool.clone()));

crates/buzz-pubsub/src/nip98_replay.rs lo describe sin ambigüedad: cada try_mark emite un solo SET buzz:{community}:nip98:{event_id_hex} 1 NX EX <ttl>. El NX es la inserción atómica “set-if-absent” y el OK de Redis en la primera reclamación es la prueba de frescura; una reclamación posterior dentro del TTL devuelve nil, que el llamador traduce a rechazo por replay. El comentario del propio check_nip98_replay cierra el punto: “The correctness boundary is the shared, community-scoped Redis seen-set on AppState, not process-local memory.”

Cuatro consecuencias operativas concretas:

  • La clave lleva la comunidad resuelta por el servidor, no el host que declara el cliente (nip98_replay_key en crates/buzz-auth/src/nip98_replay.rs:114-119). El mismo id de evento reclamado en dos comunidades usa dos seen-sets distintos: eso es deliberado y hay un test que lo fija.
  • El TTL tiene piso y techo. DEFAULT_REPLAY_TTL_SECS = 120 (dos veces la ventana) es el piso; MAX_REPLAY_TTL_SECS = 3600 el techo. Un valor por debajo del piso se eleva y uno por encima del techo se recorta, así que un llamador con un bug no puede dejar la ventana descubierta ni fijar una entrada por tiempo implausible.
  • Falla cerrado. Si el pool de Redis no da conexión, try_mark devuelve AuthError::Internal y la petición se rechaza: sin la prueba compartida, un worker sin estado no puede admitir la petición NIP-98 con seguridad.
  • Es cross-pod por construcción. El repositorio trae dos tests #[ignore = "requires Redis"] en bridge.rs que lo prueban: uno reclama el id desde el “pod A” y verifica que el “pod B” lo rechace en la misma comunidad y lo acepte en otra; el segundo es la regresión de mismo pod, que existe justamente porque el caché moka en proceso fue reemplazado por este seen-set.

Por eso los ejemplos de HA del repositorio con replicaCount: 3 (deploy/charts/buzz/examples/argocd-app.yaml:32 y deploy/charts/buzz/examples/flux-helmrelease.yaml:35) ya no arrastran ese hueco. Lo que sí impone la multi-réplica es la dependencia dura de Redis: templates/_validate.tpl:16-17 hace fallar el render si subes réplicas sin redis.enabled=true, sin externalRedis.url y sin un secrets.existingSecret que traiga REDIS_URL. Redis deja de ser solo el bus de fan-out y pasa a ser también la frontera de corrección del anti-replay: si se cae, no hay “modo degradado sin límites”, hay rechazo.

Métricas por comunidad y su costo

Con muchos tenants, la observabilidad es un problema de presupuesto antes que de datos. BUZZ_USAGE_METRICS_PER_COMMUNITY acepta all (default), que emite series de gauge por comunidad para cada comunidad, u off, que las suprime y deja solo los totales de flota.

El comentario del código explica el porqué con números: con unas 25 combinaciones de etiquetas de gauge por comunidad, un relay con miles de comunidades incurriría en costos mensuales de cinco cifras si cada una recibiera siempre el juego completo de series. Los totales de flota (buzz_total_*) se emiten siempre. Hay un modo top:<k> planificado como fast-follow; un valor desconocido produce un warning y cae a all. Los detalles de scraping y alertas los cubre Observabilidad: métricas, logs y salud del relay.

Checklist operativo

Si vas a servir más de una comunidad desde una infraestructura, en orden:

  • Verifica que el proxy inverso preserve el header Host intacto hacia el upstream.
  • Confirma que RELAY_URL derive la misma autoridad que usan los clientes, con puerto no-default incluido si corresponde.
  • Define RELAY_OPERATOR_PUBKEYS y RELAY_OPERATOR_API_ORIGIN juntos; el segundo es obligatorio si el primero no está vacío.
  • Aprovisiona cada host con POST /operator/communities firmado con NIP-98, usando create_only: true cuando un host ya existente deba ser un error, y consulta antes GET /operator/communities/availability.
  • Si corres más de una réplica, provee Redis de verdad: el seen-set NIP-98 y el rate limiter compartido viven ahí, y el chart falla el render si subes réplicas sin redis.enabled=true, externalRedis.url o un REDIS_URL dentro de secrets.existingSecret.
  • Mantén cargo nextest run -p buzz-db --lib y cargo nextest run -p buzz-conformance en verde en cada PR que toque migrations/ o el borde de tenant.
  • Decide el modo de BUZZ_USAGE_METRICS_PER_COMMUNITY antes de que la factura te lo decida.
  • Si tu perfil de riesgo lo exige, implementa las políticas RLS que el documento formal admite como axioma: el esquema incluido no las trae, y la revocación de un miembro no borra sus escrituras históricas.

Resumen

  • Una comunidad es el workspace que un usuario alcanza por URL. Un relay por defecto sirve una; un operador hospedado sirve muchas detrás de muchos dominios, y la regla no cambia.
  • La comunidad se deriva del host con resolve_host. bind_community se llama en router.rs:300 antes del upgrade a WebSocket; un host no mapeado o un fallo de lookup devuelven el mismo 404 genérico. Nunca hay tenant por defecto.
  • normalize_host es la única regla de normalización: lowercase, sin punto final, sin :80 ni :443, puertos no-default conservados. El relay lee el header Host, no X-Forwarded-Host: un proxy que lo reescriba rompe todo el binding.
  • Queda scopeado por community_id: events con PK (community_id, created_at, id), canales con PK (community_id, id) y trigger de inmutabilidad, usuarios y NIP-05, audit_log con una cadena hash independiente por comunidad, la búsqueda FTS, las claves de Redis buzz:{community}:... y los punteros git repos/{community}/{owner}/{repo}/pointer. Lo deliberadamente operator-global vive en la tabla _operator_global_tables, no en una lista del linter.
  • El h tag es entrada adversaria: pista de ruteo, nunca el punto de commit del tenant. El host gana siempre, incluso sobre el sello de un token NIP-98, y la cerca cubre también la ruta del duplicado para que no sea un oráculo de existencia.
  • El plano de operador es aparte: RELAY_OPERATOR_PUBKEYS vacío desactiva el aprovisionamiento por completo y RELAY_OPERATOR_API_ORIGIN fija el origen contra el que se verifica cada u de NIP-98 de operador. NIP-11, en cambio, falla abierto a propósito para no enumerar hosts.
  • La validación tiene tres capas: el checklist de docs/multi-tenant-conformance.md con sus cinco gates, los modelos mecanizados en TLA+ y Tamarin, y el crate buzz-conformance, que reproduce trazas reales contra una reimplementación independiente del spec y falla por IllegalTransition, NonInterference o CoverageBreach. Su LIMITS.md importa tanto como sus tests: el seam de lectura aún no está armado, no ve fugas cross-pod ni de fan-out, y en producción el tracer por defecto es NoopTracer.
  • Riesgos declarados fuera de la garantía: timing y recursos físicos compartidos, escrituras históricas tras una revocación, y fugas por encima de la interfaz.
  • El anti-replay NIP-98 ya es compartido: AppState monta RedisNip98ReplayGuard, que hace SET buzz:{community}:nip98:{id} NX EX con piso de 120 s y techo de 3600 s, falla cerrado ante un Redis inalcanzable y está probado cross-pod. El documento formal, que aún describe un seen-set en proceso, va por detrás del código en este punto.

Siguiente: Observabilidad: métricas, logs y salud del relay