# Report 0006 — unibus: descentralización / alta disponibilidad (issue 0003, fases 0003a–0003e)

- **Fecha:** 07/06/2026
- **Autor:** agente (Claude Opus 4.8)
- **Ámbito:** `projects/message_bus/apps/unibus` (sub-repo `dataforge/unibus`). Paquetes `embeddednats`, `busauth`, `membership`, `blobstore`, `client`; `cmd/membershipd`; `dev/feature_flags.json`.
- **Estado:** done — entrega 0003a–0003e en `master` del sub-repo. El despliegue multi-nodo real (0003f) lo ejecuta el humano; al final hay un runbook.
- **Origen:** issue `dev/issues/0003-decentralization-ha.md`. Cierra además el residual H4 (ACL por subject) que el hardening (report 0005) difirió a este issue, y el agujero de anti-replay multi-nodo que la auditoría (report 0004) señaló como bloqueante antes del cluster.

## Resumen

unibus deja de ser un SPOF: el servidor NATS embebido puede formar cluster (data plane replicado), el control plane y los blobs pueden vivir en JetStream KV / Object Store replicados, el cliente hace failover entre nodos, y el anti-replay pasa a un store de nonces compartido. Todo está construido por **branch-by-abstraction** detrás del flag `decentralized` (off): el comportamiento de un solo nodo (SQLite + disco) no cambia y `master` sigue desplegable en cada paso.

El issue marca el rollout R1→R3: con 2 nodos (magnus + homer) se despliega en R1 (réplicas=1, sin tolerancia a fallo todavía); cuando entre el tercer nodo se escala a R3 (`nats stream/kv update --replicas 3`) para HA real (quorum 2/3). El código soporta réplicas configurables; el despliegue elige el número.

Historia (vista `git log --first-parent master`):

```
da42051 Merge issue/0003e-client-failover: client failover + replicated nonce store + subject ACL (H4)
649dc9e Merge issue/0003d-objectstore: replicated blobs on NATS Object Store
94e7ced Merge issue/0003c-migrate-kv: idempotent SQLite->KV migration + backup
b8c9b2b Merge issue/0003b-jetstream-store: Store interface + JetStream KV backend (fail-closed)
3230b31 Merge issue/0003a-cluster: NATS cluster routes (auth + mutual TLS)
```

Verificación global (sin caché, en `master` tras los 5 merges):

```
$ CGO_ENABLED=0 go build ./...   # OK
$ CGO_ENABLED=0 go vet ./...     # limpio
$ CGO_ENABLED=0 go test -count=1 ./...
  ok  cmd/membershipd    0.004s
  ok  pkg/blobstore      0.096s
  ok  pkg/busauth        0.007s
  ok  pkg/client         5.857s
  ok  pkg/embeddednats   6.559s
  ok  pkg/frame          0.002s
  ok  pkg/membership     3.059s
```

---

## Fase 0003a — Cluster NATS (routes con auth + TLS mutuo) ✅

**Qué.** `pkg/embeddednats.ServerConfig` gana `ServerName` (único por nodo, lo exige el RAFT de JetStream) y un `*ClusterConfig` opcional: nombre de cluster, host/puerto del listener de routes, URLs de los otros nodos, secreto de routes (`Username`/`Password` → `Cluster.Authorization`) y un `*tls.Config` de TLS mutuo. `Cluster` nil mantiene el servidor standalone (comportamiento legacy).

**Seguridad de routes (clave, del report de auditoría 0004).** Las routes son una frontera de confianza **server-to-server**, distinta del plano de clientes: autentican NODOS, no usuarios del bus. NO se reutiliza el authenticator nkey de clientes. `busauth.RouteTLSConfig(cert, key, ca)` construye el TLS mutuo: el nodo presenta su certificado firmado por la CA **y** verifica el del nodo entrante contra la misma CA (`RequireAndVerifyClientCert`), reusando la CA del issue 0001. Un nodo sin el secreto de cluster y sin un certificado firmado por la CA no puede unirse ni inyectar mensajes.

`cmd/membershipd` gana las flags de cluster (`--cluster-name/--server-name/--cluster-port/--routes/--cluster-user/--cluster-pass/--route-tls-cert/-key/-ca`). `validateClusterConfig` rechaza un cluster en bind público sin secreto de routes y sin TLS mutuo completo, y rechaza flags de route-TLS parciales (all-or-nothing).

**Evidencia (DoD: golden + 2 edge + 2 error path):**

```
$ CGO_ENABLED=0 go test ./pkg/embeddednats/ -run TestCluster -v
  --- PASS: TestClusterForwardsAcrossNodes   (2 nodos: subject publicado en n1 llega a un suscriptor en n0)
  --- PASS: TestClusterThreeNodesForward     (3 nodos, forma HA: publish en n2 llega a n0)
  --- PASS: TestClusterMutualTLSForwards     (forwarding sobre routes con TLS mutuo)
  --- PASS: TestClusterRejectsBadRouteAuth   (password de cluster incorrecto -> 0 routes; el cluster legítimo no cambia)
  --- PASS: TestClusterRejectsUnsignedNode   (cert no firmado por la CA -> 0 routes)
$ CGO_ENABLED=0 go test ./cmd/membershipd/ -run 'TestClusterConfigPolicy|TestSplitRoutes'   # PASS
```

**Gaps.** Los tests son in-process (varios `nats-server` embebidos en un proceso). El forwarding entre `membershipd`s reales en 3 VPS + escalado a R3 es 0003f (humano). `NumRoutes()` cuenta conexiones del pool de routes, no peers; los tests de rechazo comparan contra un baseline estabilizado en vez de un número fijo.

---

## Fase 0003b — Interfaz Store + jetstreamStore (KV) ✅

**Qué.** `membership.Store` pasa a interfaz (branch-by-abstraction). La implementación SQLite se renombra `sqliteStore` y sigue siendo el default (`Open(path)` la devuelve). `ErrNotFound` es un sentinel agnóstico del backend (la SQLite mapea `sql.ErrNoRows` a él; el control plane ya no importa `database/sql`). `jetstreamStore` (nuevo) implementa `Store` sobre cinco buckets KV replicados — `rooms`, `members`, `rooms_by_member` (índice inverso para `ListRoomsForEndpoint`), `room_keys`, `users` — con réplicas configurables (R1..R5). **Fail-closed**: cada lectura está acotada por `OpTimeout` e `IsAuthorized`/`HasAdmin` devuelven `false` ante cualquier error de backend (una pérdida de quorum del KV deniega, nunca admite), exactamente lo que la auditoría exigía.

Flag `decentralized` (off) añadido a `dev/feature_flags.json`.

**Evidencia (DoD: golden + edges + error path):**

```
$ CGO_ENABLED=0 go test ./pkg/membership/ -run TestJetStreamStore -v
  --- PASS: TestJetStreamStoreRoomsCRUD              (room cifrada + owner + invitado; latest-epoch + rekey)
  --- PASS: TestJetStreamStoreUsers                  (add/get/authorize/list/revoke + gate admin, normalización case-insensitive, duplicado rechazado)
  --- PASS: TestJetStreamStoreNotFound               (mapeo ErrNotFound en misses)
  --- PASS: TestJetStreamStoreIsAuthorizedFailClosed (NATS apagado -> IsAuthorized y HasAdmin DENIEGAN dentro del timeout)
```

**Gaps.** KV no tiene transacción multi-key: `CreateRoom`/`AddMember` son secuencias de Puts idempotentes con orden recoverable; documentado en el código. El **wiring de arranque** de `membershipd`-en-KV (selección del store según flag) se difiere a 0003e/0003f: tiene un ciclo bootstrap (la conexión interna de `membershipd` al NATS embebido debería autenticarse contra el mismo store que aún no existe), que pertenece al rediseño de sesión/deploy. El `jetstreamStore` queda completo, testeado y consumido inmediatamente por `migrate-to-kv` (0003c).

---

## Fase 0003c — migrate-to-kv (idempotente, con backup) ✅

**Qué.** `Snapshot`/`SealedKeyRecord` (volcado agnóstico del control plane: rooms con su epoch real, members, todas las filas de sealed-key por epoch, users con status). `ExportSnapshot` en ambos backends; `importSnapshot` del KV escribe con Puts crudos (conserva epoch/status, no resetea a defaults), así que la migración es fiel e idempotente (cada escritura es un overwrite → re-ejecutar converge). `MigrateSQLiteToKV` orquesta export→import; `BackupSQLite` hace una copia consistente con `VACUUM INTO` antes de migrar. Comando `membershipd migrate-to-kv --db <path> --nats-url <url> [--replicas N] [--ca <cert>] [--no-backup]`.

**Evidencia (DoD: paridad + idempotencia + backup) + smoke del binario:**

```
$ CGO_ENABLED=0 go test ./pkg/membership/ -run 'TestMigrate|TestBackup' -v
  --- PASS: TestMigrateSQLiteToKVParity            (2 rooms una rekeyed a epoch 2 + user revocado; KV == SQLite tras migrar)
  --- PASS: TestMigrateSQLiteToKVIdempotent        (migrar dos veces -> mismo estado KV)
  --- PASS: TestBackupSQLiteCreatesConsistentCopy  (el backup reabre con los mismos datos)

$ # smoke del binario (seed user -> server -> migrate -> re-run)
$ membershipd user add --handle alice --sign-pub <64hex> --role admin --db unibus.db
$ membershipd --bind 127.0.0.1 --http-port 18888 --nats-port 14888 --db unibus.db ... &
$ membershipd migrate-to-kv --db unibus.db --nats-url nats://127.0.0.1:14888 --replicas 1
  backed up unibus.db -> unibus.db.bak.<ts>
  migrated to KV (replicas=1): 0 rooms, 0 members, 0 keys, 1 users
$ membershipd migrate-to-kv ... --no-backup     # re-run idempotente: 1 users, igual
```

**Gaps.** El comando conecta al NATS con `--nats-url` (+ `--ca` opcional para TLS); la autenticación de la conexión interna con nkey de nodo en un cluster `enforce` se afina en el deploy 0003f.

---

## Fase 0003d — Blobs replicados (NATS Object Store) ✅

**Qué.** `blobstore.Store` pasa a interfaz. El backend de disco se renombra `diskStore` (default). `objectStore` (nuevo) implementa `Store` sobre un bucket NATS Object Store con réplicas configurables (R1..R5). El direccionamiento por contenido (sha256-hex) es idéntico, así que el contrato de wire no cambia y un `BlobRef` es portable entre backends. `membership.Server.blobs`/`NewServer` toman la interfaz.

**Evidencia (DoD: golden + edge + contrato):**

```
$ CGO_ENABLED=0 go test ./pkg/blobstore/ -v
  --- PASS: TestObjectStoreRoundTrip           (put/get/has + dedup content-addressed)
  --- PASS: TestObjectStoreMissing             (hash desconocido ausente e ilegible)
  --- PASS: TestObjectStoreAddressMatchesDisk  (Object Store y disco direccionan los mismos bytes al MISMO hash)
```

**Gaps.** Como el KV store, la selección del Object Store en `membershipd` se difiere al boot path decentralized; disco sigue default. Sin cuota/GC (eso es el issue 0002 / H9).

---

## Fase 0003e — Cliente failover + nonce replicado + ACL por subject (H4) ✅

Tres componentes, tres commits atómicos en la rama.

### 1. Failover de cliente

`Options.NatsServers` (seeds extra) y `Options.CtrlURLs` (control planes extra). El cliente conecta a la lista unida con `MaxReconnects(-1)` + `RetryOnFailedConnect`, así nats.go hace failover a un nodo vivo cuando cae el que tenía. `doJSON`/`putBlob`/`getBlob` prueban cada control plane en orden (failover de transporte; cada intento firma con nonce fresco, nunca es replay). `New/Connect/NewWithOptions` mantienen su firma (worker/chat/mobile/playground intactos). `ConnectedServer()`/`IsConnected()` para observabilidad.

```
$ CGO_ENABLED=0 go test ./pkg/client/ -run TestClientFailoverAcrossNodes -v
  --- PASS  (A suscrito en n0 recibe; se MATA el nodo de A; A reconecta al superviviente y SIGUE recibiendo — sesión intacta)
```

### 2. Anti-replay replicado (nonce store en KV)

El `nonceCache` por-proceso rompe el anti-replay en multi-nodo: un request capturado y reenviado a OTRO nodo (cuyo cache nunca vio el nonce) se aceptaría. `nonceStore` pasa a interfaz; `memNonceCache` (default) + `kvNonceStore` nuevo que reclama cada nonce con un `Create` atómico en un bucket compartido (primer uso gana; cualquier uso posterior en cualquier nodo rechaza). Error de backend → **fail closed** (rechaza). TTL del bucket = `nonceTTL` (2·clockSkew). `Server.UseReplicatedNonces(js, replicas)` lo activa por nodo.

```
$ CGO_ENABLED=0 go test ./pkg/membership/ -run TestReplicatedNonceRejectsCrossNodeReplay -v
  --- PASS  (request aceptada (200) en nodo A; mismo ts+nonce reenviado a nodo B -> 401 replayed; replay a A de nuevo -> 401)
```

### 3. ACL por subject derivada de pertenencia (residual H4)

`busauth.NewNkeyAuthenticatorACL` + `PermissionsFunc`: el authenticator, tras autorizar, deriva y `RegisterUser()`a permisos por subject; error de derivación → deniega (fail closed). `membership.SubjectACLFor(store)` mapea una identidad a los subjects de sus rooms + la infraestructura de cliente (`_INBOX.>`, `$JS.API.>`). `client.RefreshSession()` reconecta el data plane para que el authenticator re-derive permisos tras un cambio de membresía (NATS congela permisos al conectar).

```
$ CGO_ENABLED=0 go test ./pkg/membership/ -run 'TestSubjectACLIsolation|TestRefreshSessionGainsNewRoom' -v
  --- PASS: TestSubjectACLIsolation        (alice miembro de room.A: sub/pub room.A OK; sub y pub room.B -> permissions violation;
                                            alice nunca lee el tráfico de bob en room.B; bob nunca recibe el publish cruzado de alice)
  --- PASS: TestRefreshSessionGainsNewRoom (alice sin permiso para room B hasta que se la añade y llama RefreshSession; el reconnect
                                            le concede el subject y entonces recibe el tráfico de room B)
```

**Estado del residual H4.** El aislamiento por subject derivado de pertenencia está **implementado y demostrado** a nivel de data plane: un peer registrado ya no puede sub/pub en subjects arbitrarios, queda confinado a las rooms a las que pertenece, y el contenido de las demás (ya E2E desde el hardening) le es invisible incluso como metadata de subject. El authenticator ACL es **opt-in** (`NewServer`/`membershipd` mantienen el authenticator abierto por defecto) y se cablea con el boot path decentralized. Lo que queda para 0003f/futuro: el **refresco automático y transparente** de permisos en cada cambio de membresía — hoy `RefreshSession` es una llamada explícita que el peer hace tras unirse a una room (porque NATS congela permisos al conectar, el flujo "connect → create → publish en la misma conexión" requiere un refresh entre el create y el publish). Esto es deliberado para no romper el flujo legacy ni los tests existentes; se activa con el rediseño de sesión del deploy.

---

## Cobertura de tests (DoD)

| Fase | Tests nuevos | Tipo |
|---|---|---|
| 0003a | `TestCluster*` (5) + `TestClusterConfigPolicy` + `TestSplitRoutes` | golden + 2 edge + 2 error path |
| 0003b | `TestJetStreamStore*` (4) | golden + edges + fail-closed |
| 0003c | `TestMigrate*` (2) + `TestBackup*` + smoke binario | paridad + idempotencia + backup |
| 0003d | `TestObjectStore*` (3) | golden + edge + contrato |
| 0003e | `TestClientFailoverAcrossNodes`, `TestReplicatedNonceRejectsCrossNodeReplay`, `TestSubjectACLIsolation`, `TestRefreshSessionGainsNewRoom` | failover + replay + aislamiento + refresh |

Todo el suite previo sigue verde: el comportamiento de un solo nodo (SQLite + disco, authenticator abierto, nonce en memoria) no cambia.

---

## Resumen para 0003f (despliegue multi-nodo — lo ejecuta el humano)

Pre-requisitos del issue: alta del alias SSH + clave de cada nodo, integración en la WireGuard, y revisar/aligerar la carga existente (magnus corre coolify/minio/postgres/etc.). Nodos hoy: **magnus** y **homer** (`141.94.69.66`); falta el **tercero** para quorum real.

### Despliegue R1 (2 nodos, magnus + homer — funciona, sin tolerancia a fallo todavía)

1. **CA y certificados de nodo.** Generar (o reusar) la CA del 0001 (`deploy/tls/ca.crt` + `ca.key` custodiada **fuera** de los hosts de aplicación, en `pass`). Emitir un certificado de **route** por nodo cuyo SAN cubra la IP/host de su puerto de cluster, firmado por esa CA (sirve para server y client auth en el handshake mutuo de routes).
2. **Secreto de cluster.** Un `--cluster-user`/`--cluster-pass` compartido por los dos nodos (en `pass`).
3. **Arrancar cada `membershipd`** con bind público + `--bus-auth enforce` + TLS del data plane (0001) + cluster:
   ```
   membershipd --bind <ip> --bus-auth enforce \
     --tls-cert deploy/tls/server.crt --tls-key deploy/tls/server.key \
     --cluster-name unibus --server-name <magnus|homer> --cluster-port 6250 \
     --cluster-user "$CL_USER" --cluster-pass "$CL_PASS" \
     --route-tls-cert deploy/tls/<node>.crt --route-tls-key deploy/tls/<node>.key --route-tls-ca deploy/tls/ca.crt \
     --routes nats://$CL_USER:$CL_PASS@<otro-nodo>:6250
   ```
   `validateClusterConfig` exige el secreto + TLS mutuo completo en bind público (rechaza arrancar inseguro).
4. **systemd `Restart=always`** en cada nodo (un SIGTERM limpio es exit 0; `on-failure` no reiniciaría — gotcha conocido). Reusar `deploy/unibus-membershipd.service` + `deploy/install.sh`.
5. **Streams/KV/Object Store en R1** mientras solo haya 2 nodos (réplicas=1): el data plane efímero (core-NATS) ya tolera la caída de uno (los clientes reconectan al otro); el control plane KV y las rooms persistentes NO toleran caída hasta R3.
6. **Clientes:** pasarles la lista de los 2 endpoints — `Options{NatsServers: [n2], CtrlURLs: [ctrl2]}` además del primario — para que el failover de cliente (0003e) funcione.

> Nota sobre el flag `decentralized`: en R1 con 2 nodos se puede operar el control plane aún en SQLite por-nodo (cada `membershipd` su BD) si no se quiere KV todavía; para que cualquier nodo sirva cualquier request hay que activar el control plane KV (`decentralized: on`) — eso requiere completar el wiring de arranque KV (selección del store + bootstrap del authenticator interno) que esta entrega dejó documentado como el paso de integración de 0003f. La migración de los datos existentes la hace `membershipd migrate-to-kv` (probado) **antes** de activar el flag.

### Escalado a R3 (cuando entre el tercer nodo — HA real)

1. Preparar el tercer nodo (SSH + WireGuard + cert de route firmado por la CA).
2. Añadirlo al cluster (su `--routes` apunta a magnus+homer y viceversa; mismo `--cluster-name` y secreto).
3. Escalar en caliente, sin downtime ni migración de datos:
   ```
   nats stream update <stream> --replicas 3
   nats kv update <bucket> --replicas 3      # rooms/members/rooms_by_member/room_keys/users/nonces
   nats obj update UNIBUS_blobs --replicas 3
   ```
   o crear los buckets con `Replicas: 3` desde el inicio en el tercer nodo (el código ya lo soporta vía `JetStreamConfig.Replicas` / `ObjectStoreConfig.Replicas`).
4. Activar `decentralized: on` en `dev/feature_flags.json` (con el wiring de arranque KV completo).
5. **Chaos test real (DoD del issue):** 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).

### No olvidar en 0003f

- **Nonce replicado obligatorio antes de exponer el cluster:** llamar `Server.UseReplicatedNonces(js, replicas)` en cada nodo. Sin esto el anti-replay es nulo en multi-nodo (un replay a otro nodo se acepta). Probado en `TestReplicatedNonceRejectsCrossNodeReplay`.
- **ACL por subject:** si se quiere el aislamiento de data plane en producción, usar `busauth.NewNkeyAuthenticatorACL(store.IsAuthorized, perms)` con `perms` derivado de `membership.SubjectACLFor(store)`, y que los clientes llamen `RefreshSession()` tras unirse a una room. Si no se activa, el residual sigue siendo: un peer registrado observa metadata de tráfico (no contenido) de subjects que conozca y puede spamear bytes descartados — irrelevante en un despliegue solo-WireGuard.

---

## Gaps / pendientes honestos de este trabajo

- **No hay despliegue real ni chaos test sobre 3 VPS** — todo se valida in-process (varios `nats-server` embebidos en un proceso). El chaos test real (matar 1/3, matar 2/3) es 0003f.
- **El wiring de arranque `membershipd`-en-decentralized no se cableó** (selección del store/blobstore KV + bootstrap del authenticator interno). El `jetstreamStore`, el `objectStore`, el `kvNonceStore` y el authenticator ACL están completos y testeados, pero el binario solo los expone como API; conectarlos en `main.go` (con el flag) pertenece a la integración de 0003f por el ciclo bootstrap descrito. Es branch-by-abstraction correcto (flag off, master verde), pero significa que `--decentralized` no es todavía un interruptor del binario.
- **KV no es transaccional:** `CreateRoom`/`AddMember` son secuencias de Puts idempotentes; una caída a mitad deja un estado parcial recoverable (no corrupto), no atómico. Aceptable para el control plane (baja frecuencia, owner-serializado).
- **El refresco de ACL no es transparente:** `RefreshSession` es una llamada explícita; el auto-refresh en cada cambio de membresía (rehaciendo suscripciones) queda para el rediseño de sesión.
- **No se ejecutó `govulncheck`** sobre nats-server/nats.go/modernc (paso de CI aparte, ya anotado en el report 0005).
