---
issue: 0003
title: Descentralización / alta disponibilidad — cluster NATS + JetStream replicado + control plane sin SPOF
status: spec
created: 2026-06-07
domain: infra
scope: unibus (pkg/embeddednats, pkg/membership, pkg/blobstore, pkg/client, cmd/membershipd) + despliegue multi-nodo
depends_on: 0001 (la auth de cluster y de clientes va junto con el endurecimiento)
---

# Objetivo

Que la caída de un servidor **no deje el bus sin servicio**. Hoy unibus es un único
`membershipd` (con NATS embebido + SQLite local): si ese host muere, no hay bus.
Este issue lleva unibus a un modelo **descentralizado / alta disponibilidad** usando
las capacidades nativas de NATS: cluster multi-nodo, JetStream replicado (RAFT), y
el estado del control plane fuera de la SQLite local. **No es federación**
(multi-operador con dominios distintos); es eliminar el punto único de fallo dentro
de un único dominio administrativo controlado por nosotros.

# Requisito clave de quorum (decisión de infraestructura)

JetStream replica con RAFT, que necesita **mayoría (quorum)** para confirmar
escrituras. Las consecuencias son duras y hay que asumirlas desde el diseño:

| Nodos | Réplica | Tolera caída de | Nota |
|---|---|---|---|
| 1 | R1 | 0 | situación actual (SPOF) |
| 2 | R2 | **0** | si cae uno se pierde quorum: las escrituras se bloquean. NO sirve para HA |
| **3** | **R3** | **1** | mínimo real para "si un server cae, seguimos" |
| 5 | R5 | 2 | mayor tolerancia |

**Por tanto el objetivo del usuario ("si mi server falla, no nos quedamos sin
servicio") exige 3 nodos JetStream.** Servers disponibles hoy: **magnus** y
**homer** (ambos VPS OVH). El tercero está pendiente de conseguir.

| Nodo | IP pública | Estado | Notas |
|---|---|---|---|
| magnus | (en pass: `MAGNUS_ovh_ssh_ROOT`) | disponible, **cargado** | corre coolify, minio, postgres, authentik, portainer, dagu — revisar recursos antes |
| homer | `141.94.69.66` | disponible, vivo | creds en pass (`vps_ovhcloud_SSH_SERVER_HOMER_-_root`, `vps_SSH_SERVER_HOMER_dataherrero`); tenía coolify |
| nodo 3 | — | **pendiente** | conseguir un tercer VPS siempre-on, o reusar om/datardos si se liberan |

Preparación previa al deploy de cada nodo: alta del alias SSH + clave, integración en
la WireGuard, y revisar/aligerar la carga existente (coolify, etc.).

## Rollout R1 → R3: funcionar con 2 nodos hoy, HA con 3 mañana

No se "desactiva el quorum"; se controla el **número de réplicas** de cada stream/KV:

| Réplicas | Quorum | Tolera | Sirve con |
|---|---|---|---|
| R1 | ninguno (1 copia) | 0 caídas | 1-2 nodos, sin bloqueo |
| R3 | 2 de 3 | 1 caída | 3 nodos |

- **Fase actual (magnus + homer):** desplegar con streams/KV en **R1** (flag
  `decentralized: off`). El bus funciona al 100% para operar, sin tolerancia a fallo
  todavía. Opción: streams en **R2** para duplicar los datos en ambos nodos
  (durabilidad/backup vivo), asumiendo que la escritura necesita los dos hasta el 3er
  nodo.
- **Cuando entre el nodo 3:** escalar en caliente `nats stream update --replicas 3`
  (idem KV/Object Store) + añadir el nodo al cluster + flag `decentralized: on`. **HA
  real, sin downtime, sin reescritura, sin migrar datos.**
- **Aviso de 2 nodos:** NO montar el meta-group de JetStream con 2 nodos como si
  fuera HA — su quorum es 2, y la caída de uno bloquea la gestión de streams. Con 2
  servers, modelo recomendado: **magnus principal (R1) + homer 2º nodo/réplica**, y
  escalar a R3 al tener el tercero.

Mientras solo haya 2 nodos: el **data plane efímero** (core-NATS, rooms `ModeNATS`)
sí tolera la caída de uno (los clientes reconectan al otro), pero las **rooms
persistentes y el control plane** (que necesitan quorum) no. El issue se despliega
de verdad cuando haya 3 nodos.

# Contexto — por qué hoy es un SPOF

- `pkg/embeddednats` arranca un NATS **standalone** (sin cluster).
- `pkg/membership` guarda rooms/members/room_keys/users en una **SQLite local** al
  proceso.
- `pkg/blobstore` guarda los blobs en el **disco local** del proceso.
- El cliente (`pkg/client`) conecta a **una** URL de NATS y **una** de control plane.

Todo vive en un host. Ese host es el punto único de fallo.

# Diseño

## Pieza 1 — Cluster NATS (data plane replicado)

`pkg/embeddednats` gana opciones de cluster: `server.Options.Cluster` (nombre +
host/puerto de routes) y `Routes` (los otros nodos). Cada `membershipd` arranca su
NATS embebido en cluster con los demás. JetStream se habilita con `Replicas: 3` en
streams y KV. Auth entre nodos (routes) con credenciales propias (no las de
clientes), y TLS también en las routes (reusa la CA del issue 0001).

## Pieza 2 — Control plane sin estado local (SQLite → JetStream KV)

Es el corazón del issue. Hoy `pkg/membership.Store` es SQLite. Se introduce, por
**branch-by-abstraction**, una interfaz `Store` con dos implementaciones:

- `sqliteStore` — la actual (sigue siendo el default mientras el flag está off; útil
  para un solo nodo / desarrollo).
- `jetstreamStore` — nueva: rooms, members, room_keys y users (la tabla del issue
  0001) viven en **JetStream KV** (buckets replicados R3). Cualquier nodo lee/escribe
  el mismo estado; RAFT garantiza consistencia. El HTTP control plane pasa a ser
  efectivamente **stateless**: cualquier `membershipd` sirve cualquier request
  porque el estado está en el KV replicado.

Flag `decentralized` (off → on). Migración inicial de datos SQLite → KV con un
comando `membershipd migrate-to-kv` (idempotente). Las claves de room siguen
selladas igual; solo cambia **dónde se guardan**, no el cifrado.

## Pieza 3 — Blobs replicados (object store → NATS Object Store)

`pkg/blobstore` gana una implementación sobre **NATS Object Store** (encima de
JetStream, replicado R3) además de la de disco local. Los blobs (ya ciphertext, E2E)
quedan disponibles desde cualquier nodo. Encaja con el GC del issue 0002.

## Pieza 4 — Cliente con failover

`pkg/client`: aceptar **lista** de seeds de NATS y **lista** de URLs de control
plane. `nats.go` ya hace reconnect/failover entre servidores del cluster nativamente
(`nats.Servers([...])`, `nats.MaxReconnects(-1)`). El control plane HTTP se prueba en
orden con reintento. Así, si un nodo cae, el cliente reconecta a otro de forma
transparente.

## Pieza 5 — Despliegue multi-nodo

3 nodos `membershipd`, cada uno con su NATS embebido en cluster, JetStream R3, mismo
`ca.crt`/credenciales de routes. systemd en cada VPS. Los clientes reciben la lista
de los 3 endpoints. Health/observabilidad por nodo (`/healthz` + métricas de
JetStream: líder RAFT, lag de réplica).

# Decisiones técnicas

| Decisión | Elegido | Alternativa | Razón |
|---|---|---|---|
| Nº de nodos de quorum | 3 (R3) | 2 (R2) | 2 no tolera caída de uno; 3 es el mínimo real de HA |
| Estado del control plane | JetStream KV replicado | SQLite replicada a mano / Postgres externo | KV ya viene con NATS, mismo RAFT que JetStream, cero infra extra |
| Migración del store | branch-by-abstraction (interfaz `Store`, dos impls, flag) | reescritura directa | master nunca se rompe; sqlite sigue para 1 nodo/dev |
| Blobs | NATS Object Store | disco compartido / S3 | replicado nativamente, sin dependencia externa |
| Failover de cliente | lista de seeds + reconnect nativo nats.go | balanceador externo | menos infra, nats.go ya lo hace |
| Federación multi-operador | **fuera de alcance** | — | no es el objetivo; es otra liga (trust entre dominios) |

# Fases (TBD, ramas `issue/0003x-*`)

1. **0003a — cluster NATS** — opciones de cluster/routes + TLS de routes en
   `pkg/embeddednats`; arrancar 2-3 nodos locales en tests e2e y verificar que un
   subject publicado en uno llega a un suscriptor en otro.
2. **0003b — interfaz Store + jetstreamStore (KV)** — abstraer `pkg/membership.Store`;
   implementar rooms/members/room_keys/users sobre JetStream KV R3; tests de
   consistencia. Flag `decentralized: off`.
3. **0003c — migrate-to-kv** — comando idempotente SQLite → KV + test de paridad
   (mismo estado antes/después).
4. **0003d — blobs en Object Store** — impl `pkg/blobstore` sobre NATS Object Store
   replicado.
5. **0003e — cliente failover** — lista de seeds + lista de ctrl-urls + reconnect;
   test que mata el nodo al que está conectado y verifica que sigue operando.
6. **0003f — despliegue 3 nodos** (humano) — 3 VPS en cluster, JetStream R3, flag
   `decentralized: on`. Chaos test real: matar un nodo en producción y comprobar que
   el servicio sigue.

# Definition of Done (evidencia ejecutable)

- **Golden:** 3 nodos en cluster; un cliente publica en un nodo y otro cliente
  suscrito a otro nodo lo recibe; crear room + invitar funciona desde cualquier nodo.
- **Edge:** un cliente conectado al nodo A; se **mata el nodo A**; el cliente
  reconecta a B automáticamente y sigue publicando/recibiendo sin perder la sesión.
- **Error path (chaos):** matar 1 de 3 nodos → el control plane sigue aceptando
  escrituras (quorum 2/3); matar 2 de 3 → las escrituras se bloquean (quorum perdido,
  comportamiento esperado y documentado, no corrupción).
- `CGO_ENABLED=0 go test ./...` verde, incluido un test e2e multi-nodo en proceso.

# Riesgos y mitigaciones

| Riesgo | Mitigación |
|---|---|
| Solo 2 nodos disponibles → sin quorum real | prerequisito explícito de 3 nodos antes de 0003f; hasta entonces, despliegue queda en standalone |
| Latencia inter-VPS afecta RAFT | nodos en la misma región o con buena red; medir; R3 tolera latencias moderadas |
| Migración SQLite→KV pierde datos | comando idempotente + test de paridad + backup de la SQLite antes |
| Partición de red (split-brain) | RAFT lo previene: el lado sin quorum se bloquea para escritura, no diverge |
| Complejidad operativa de 3 nodos | observabilidad de JetStream (líder, lag) + `/healthz` por nodo + runbook en deploy/ |

# Orden recomendado respecto a otros issues

1. **0001 (seguridad)** primero: la auth de clientes (nkey) y la CA/TLS se reutilizan
   para las routes del cluster. Desplegar descentralizado sin auth sería abrir varios
   puntos públicos sin protección.
2. **0003 (este)** después: una vez el bus es seguro, replicarlo en 3 nodos.
3. **0002 (media v2)** es ortogonal; su object store encaja con la pieza 3 (blobs
   replicados) cuando ambos estén.

# Fuera de alcance

- Federación entre operadores/dominios distintos (otra liga; requiere protocolo de
  trust entre dominios).
- Multi-tenant / accounts de NATS por organización.
- Auto-escalado dinámico de nodos.
