# Report 0012 — Cierre de los gaps del despliegue del cluster unibus (report 0011)

- **Fecha:** 07/06/2026
- **Autor:** agente (Claude)
- **Ámbito:** `projects/message_bus/apps/unibus/` (sub-repo `dataforge/unibus`) — cierre de los gaps que el despliegue del cluster de 3 nodos (report 0011) dejó abiertos. Rama `quick/0011-deploy-gaps` (worktree `/tmp/unibus_gaps`, basada en master).
- **Estado:** done — GAP A y GAP B cerrados con evidencia ejecutable contra el cluster VIVO (magnus + homer + datardos, R3); GAP C (runbook) corregido. Pendiente menor: rollout del binario nuevo a magnus+homer (documentado, sin urgencia — la capability está probada y desplegada en datardos).

## Resumen

El report 0011 dejó tres gaps. Este trabajo los cierra: (A) `membershipd user add|list|revoke --store kv` añade usuarios al KV replicado con el cluster en marcha vía la conexión interna privilegiada — sin parar-sembrar-rearrancar; (B) `cmd/clientcheck` es la verificación end-to-end real del plano de datos (cliente autenticado nkey+TLS, room E2E, publish/subscribe, incluido failover con un nodo caído) que el chaos test del 0011 nunca ejecutó; (C) el runbook `deploy/cluster/README.md` queda corregido (orden de arranque, R1 inservible, nueva vía de alta). Todo verificado contra los 3 VPS reales con la posture enforce+ACL+TLS+R3 intacta. `app.md` sube 0.8.0 → 0.9.0.

## Cambios

| Archivo | Qué | Por qué |
|---|---|---|
| `pkg/client/identity.go` | `LoadIdentity` (load-only) extraída de `LoadOrCreateIdentity` | La CLI `--store kv` y el daemon cargan la identidad de servicio persistida; se preserva el guard "archivo corrupto = error, no se regenera". |
| `cmd/membershipd/main.go` | flag `--internal-id-file` | Persiste la identidad interna privilegiada (load-or-create, 0600) en vez de efímera, para que la misma nkey esté disponible fuera de proceso. Vacío = efímero (default dev/single-node sin cambios). |
| `cmd/membershipd/users_kv.go` | `connectKVStore` + `reportKVReplication` (nuevo) | Conexión privilegiada al NATS del cluster con la identidad interna; abre el store KV y escribe. Rechaza remoto sin `--ca`. Imprime `followers_current` tras escribir. |
| `cmd/membershipd/users_cli.go` | `--store kv` en `add`/`list`/`revoke` + idempotencia explícita | Alta/baja contra el KV vivo. Re-alta de la misma clave = `ErrUserExists` (sin sobrescribir ni elevar rol). |
| `cmd/membershipd/kv_useradd_test.go` (nuevo) | tests de integración GAP A | Golden bajo enforce, idempotencia, endpoint muerto, remoto-sin-CA. |
| `cmd/clientcheck/` (nuevo) | comando de verificación E2E reutilizable | GAP B: cliente real, room E2E, golden + loop (failover). |
| `deploy/cluster/README.md` | runbook corregido | GAP C: arranque por quórum, R1 SPOF→R3, vía de alta live, topología real. |
| `deploy/cluster/membershipd-cluster.service`, `deploy-cluster.sh` | `--internal-id-file ${INTERNAL_ID_FILE}` + `INTERNAL_ID_FILE` en cluster.env | Para que un deploy fresco habilite la vía de alta live en todos los nodos. |
| `app.md` | 0.8.0 → 0.9.0 + growth log | Nueva capability. |

### Mecanismo de la conexión privilegiada (GAP A) — diseño y porqué

Bajo `enforce` la ACL por-subject confina a cada usuario del bus a la JetStream API de SUS rooms; ninguna identidad de usuario normal puede tocar los buckets `KV_UNIBUS_*`. La única identidad a la que el autenticador concede permisos plenos de JetStream es la **identidad de servicio interna** de `membershipd` (la que ya rompe el ciclo bootstrap del nonce/KV). Esa identidad era **efímera por proceso**, así que ningún proceso externo podía presentarla.

Solución elegida (la más simple y segura de las opciones del prompt — "creds del propio servicio" + "ejecución por loopback en un nodo"): el daemon **persiste** su identidad de servicio en `--internal-id-file` (cada nodo genera/carga la suya, `0600`, junto a las claves TLS del nodo). La CLI `user add --store kv`, ejecutada **por loopback en el nodo** (el SAN del cert data-plane cubre `127.0.0.1`), carga ese mismo archivo y presenta la nkey que el autenticador del nodo reconoce con permisos plenos → abre `OpenJetStream` y escribe el bucket `KV_UNIBUS_users` replicado.

Seguridad (no baja la posture): leer el archivo de identidad exige **root en el nodo**, lo que ya implica control total de ese nodo (la clave TLS del servidor y el `cluster.pass` ya están ahí). Co-ubicarlo no añade exposición práctica. La posture publicada en `/healthz` (enforce+acl+tls+cluster+store=kv) es idéntica con o sin el flag. Cambio respecto a 0011: la identidad interna pasa de efímera a durable (rotable borrando el archivo + reinicio); documentado.

## Verificación

> Secretos redactados: clave privada del operador, su `endpoint` derivado, el hex del operador (`48bc…`) y los de usuarios de prueba se muestran truncados. Nota de build: el worktree vive en `/tmp`, donde el `replace fn-registry => ../../../../` de `go.mod` no resuelve; se usó un `go.work` fuera del árbol (`GOWORK=/tmp/unibus_gaps.work`, NO commiteado) solo para compilar. El checkout canónico bajo `~/fn_registry/.../unibus` no lo necesita.

### Mecánica — vet + build + test (CGO_ENABLED=0)

```
$ CGO_ENABLED=0 go vet ./...            -> rc=0
$ CGO_ENABLED=0 go build ./...          -> rc=0
$ CGO_ENABLED=0 go test ./pkg/membership/ ./cmd/...
ok   github.com/enmanuel/unibus/pkg/membership   8.455s
?    github.com/enmanuel/unibus/cmd/chat         [no test files]
?    github.com/enmanuel/unibus/cmd/clientcheck  [no test files]
ok   github.com/enmanuel/unibus/cmd/membershipd  0.413s
?    github.com/enmanuel/unibus/cmd/worker       [no test files]

$ CGO_ENABLED=0 go test ./...   (suite completa)
ok   github.com/enmanuel/unibus/cmd/membershipd  0.370s
ok   github.com/enmanuel/unibus/pkg/blobstore    0.091s
ok   github.com/enmanuel/unibus/pkg/busauth      0.007s
ok   github.com/enmanuel/unibus/pkg/client       6.168s
ok   github.com/enmanuel/unibus/pkg/embeddednats 5.894s
ok   github.com/enmanuel/unibus/pkg/frame        0.002s
ok   github.com/enmanuel/unibus/pkg/membership   8.383s
```

Tests de integración GAP A (nodo enforce embebido, identidad de archivo):
```
$ go test ./cmd/membershipd/ -run TestUserAddStoreKV -count=1
ok   github.com/enmanuel/unibus/cmd/membershipd   0.050s
  TestUserAddStoreKV_GoldenAndIdempotent      (escribe bajo enforce + ErrUserExists sin sobrescribir)
  TestUserAddStoreKV_RequiresInternalIdentity (sin id file / id ausente -> error)
  TestUserAddStoreKV_UnreachableKV            (endpoint muerto -> error envuelto)
  TestUserAddStoreKV_RemoteWithoutCARefused   (remoto sin --ca -> rechazado)
```

### GAP A — `user add --store kv` contra el cluster VIVO

Despliegue de verificación: binario nuevo + `--internal-id-file` a **datardos** (nodo no-crítico) con backups reversibles; arranca con identidad persistida, posture intacta, reincorporado a R3:
```
$ ssh dd ... (restart con binario nuevo)
internal service identity: persisted (/opt/unibus/secrets/internal.id)   # 0600 root
healthz: {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
```

**Golden** (alta nueva, replicada R3):
```
$ ssh dd 'sudo /opt/unibus/membershipd user add --store kv --handle gapcheck_user --role member --sign-pub a58b…5622'
added user "gapcheck_user" (a58b…5622) role=member
KV_UNIBUS_users: leader=homer followers_current=2/2 msgs=2     <-- replicado a los 2 followers
```

**Lectura replicada** (`user list --store kv` en datardos lee el KV replicado):
```
HANDLE         ROLE    STATUS  SIGN_PUB     CREATED
gapcheck_user  member  active  a58b…5622    2026-06-07T17:33:00Z
operator       admin   active  48bc…<op>    2026-06-07T16:48:12Z
```

**Edge 1 — idempotencia** (re-alta de la misma clave, y misma clave con otro rol):
```
$ ssh dd 'sudo .../membershipd user add --store kv --handle gapcheck_user --role member --sign-pub a58b…5622'
membershipd user add: user a58b…5622 already registered (unchanged); revoke it first to replace   (exit 1)
$ ssh dd 'sudo .../membershipd user add --store kv --handle impostor --role admin --sign-pub a58b…5622'
membershipd user add: user a58b…5622 already registered (unchanged); revoke it first to replace   (exit 1)
  -> NO se sobrescribe ni se eleva a admin (sin escalado de privilegios vía re-alta).
```

**Edge 2 — alta con un nodo caído (quórum 2/3)**. Se para homer (líder del stream); el stream re-elige líder a datardos y el alta commitea:
```
>>> STOP homer (líder de KV_UNIBUS_users) — quedan datardos+magnus = quórum 2/3
homer is-active=inactive
$ ssh dd 'sudo .../membershipd user add --store kv --handle gapcheck_user2 --role member --sign-pub e560…4bf4'
added user "gapcheck_user2" (e560…4bf4) role=member
KV_UNIBUS_users: leader=datardos followers_current=1/2 msgs=3   <-- líder failover homer->datardos; commit con quórum
>>> START homer (rejoin) -> is-active=active ; healthz 3/3 ; user list muestra los 3 usuarios
```

**Error 1 — KV inalcanzable** (puerto muerto):
```
$ ssh dd 'sudo .../membershipd user add --store kv --nats-url nats://127.0.0.1:4999 --handle x --sign-pub a58b…5622'
membershipd user add: --store kv: connect cluster NATS "nats://127.0.0.1:4999": nats: no servers available for connection   (exit 1)
```

**Limpieza** (revoke de los 2 usuarios de prueba — `revoke` es flip de status, no hay hard-delete en KV):
```
$ ssh dd 'sudo .../membershipd user revoke --store kv a58b…5622'  -> revoked user a58b…5622
$ ssh dd 'sudo .../membershipd user revoke --store kv e560…4bf4'  -> revoked user e560…4bf4
$ user list --store kv:
  gapcheck_user   member  revoked   a58b…5622
  gapcheck_user2  member  revoked   e560…4bf4
  operator        admin   active    48bc…<op>
```

### GAP B — verificación cliente END-TO-END real

**Golden E2E** (identidad operator de `pass`, CA por path, nkey+TLS+https a los 3 nodos, room E2E efímera, 5 msgs):
```
$ clientcheck --ca ca.crt --identity-file <operator.id 0600> \
    --nats-seeds nats://magnus,homer,datardos:4250 --ctrl-seeds https://...:8470 --messages 5
connected: endpoint=<redacted> nats=nats://51.91.100.142:4250
created E2E room: id=01KTHHXA… subject=test.gapcheck.968e58c37a2d3d81 (encrypt=true sign=true persist=false)
published 5 messages; waiting for decrypted echoes...
GOLDEN OK: all 5 messages received and decrypted end-to-end
```

**Edge E2E — failover con cliente conectado**. Loop publish/subscribe 1/s mientras se para el nodo atado (datardos):
```
loop: publishing every 1s for 1m0s — stop a node now to test failover
  t= 7s sent=7  recv=6  up=true node=nats://51.91.100.142:4250 publish=ok    <-- atado a datardos
>>> 19:29:07 STOP datardos (nodo atado)
  t= 8s sent=8  recv=7  up=true node=nats://135.125.201.30:4250 publish=ok   <-- FAILOVER a magnus, sin perder mensaje
  …
>>> 19:29:29 START datardos (rejoin)
  …
loop done: sent=56 received=56                <-- CERO pérdida a través del corte
  attached to nats://135.125.201.30:4250 for 49 ticks
  attached to nats://51.91.100.142:4250 for 7 ticks
FAILOVER OBSERVED: client was attached to 2 distinct nodes across the run
LOOP OK: client kept receiving across the run (received=56)
```
Cluster sano 3/3 tras el corte (datardos reincorporado a R3).

### GAP C — runbook corregido

`deploy/cluster/README.md`:
- **Arranque** (corregido): "arrancar magnus solo y verificar healthz" deadlockea — un nodo solo de un cluster de 3 no tiene quórum del meta-group RAFT y JetStream nunca queda current, así que `--store kv` no crea los buckets y `/healthz` no devuelve ok hasta que se une un segundo nodo. Se documenta el arranque que forma quórum (los 3 cerca, el orden da igual) o escalonado apoyado en el retry loop de 120s; un nodo solo NUNCA se auto-sirve, así que no se debe condicionar el arranque del siguiente al healthz del anterior. Se nombran los 3 fixes de cold-start del 0011.
- **R1 inservible** (corregido): a R1 los 6 buckets viven en un solo nodo (SPOF de autenticación); el cold start solo converge con los 3 fixes; ir directo a R3 una vez formado el cluster. R1 es artefacto transitorio, no hito.
- **Vía de alta con cluster vivo**: nueva sección documentando `user add --store kv` (mecanismo, idempotencia, HA, sin hard-delete) que sustituye parar-sembrar-rearrancar.
- **Topología real**: IPs reales, `ROUTE_NETWORK=public` (no hay mesh WireGuard), magnus = host crítico convivido.

## Gaps / pendientes

- **Rollout del binario nuevo a magnus + homer**: la verificación live de GAP A desplegó el binario `0.9.0` + `--internal-id-file` solo en **datardos** (nodo no-crítico). magnus y homer siguen con el binario 0011. La capability de alta está probada y disponible desde datardos; la escritura replica R3 a los otros dos. Estado mixto seguro a nivel de protocolo (cambios aditivos/node-local, posture idéntica). Para uniformidad + redundancia de la capability, rodar el binario a homer y magnus (comandos exactos en el README, con backups y healthz entre nodos). Se dejó como decisión del operador por tocar el host crítico magnus y por haber dos agentes vivos en el sub-repo; no es urgente.
- **Sin hard-delete de usuarios en KV**: `revoke` deja la fila en estado `revoked` (denegada en ambos planos, auditable); el KV no borra filas, igual que el store SQLite. Los dos `gapcheck_user*` de prueba quedan revocados (inertes; sus claves privadas eran aleatorias y descartadas). No se inventó un delete a medias (regla del prompt).
- **Sin borrado de la room de prueba (GAP B)**: el control plane no expone borrado de rooms (no hay endpoint ni `DeleteRoom` en el store). Las rooms `test.gapcheck.<rand>` creadas (efímeras, E2E, solo el operator como miembro, subject aleatorio) quedan como filas en `KV_UNIBUS_rooms`/members (inocuas, sin tráfico). Documentado; no se inventó un delete.
- **kill-2/3 (pérdida de quórum)**: fuera de scope (esperado fail-closed); el runbook lo deja como paso manual.
- **Push a Gitea del 0011**: sigue pendiente del operador (gap heredado del 0011, ajeno a este trabajo). Esta rama `quick/0011-deploy-gaps` se pushea a `origin` para revisión; NO se mergea a master (lo revisa el operador).

## Commits (rama `quick/0011-deploy-gaps`)

```
e1a7402 chore: bump unibus to 0.9.0 (live user-add + clientcheck)
ce72131 docs(cluster): correct runbook + wire --internal-id-file into deploy
3aa5a2c feat(clientcheck): end-to-end client verification (E2E room + failover)
02c2004 feat(membershipd): user add/list/revoke --store kv against a live cluster
```
