dec-0120

Vista generada de dec-0120: styx consume spire: control Bun↔daemon (session-ipc v2) y bus NATS

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0120-spire-consumido-por-styx.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0120-spire-consumido-por-styx.md. No se edita a mano: bun run docs:gen la regenera y bun run docs:check falla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.

CampoValor
EstadoLOCKED
Fecha2026-09-30
Ficherodocs/decisions/dec-0120-spire-consumido-por-styx.md

Enmienda a: r07, r18, dec-0119

Por qué importa (del frontmatter del ADR):

Fija cómo consume styx el SDK spire: el socket de control Bun<->daemon pasa a session-ipc v2 (spire unix, sobre firmado, handshake, policy generada de BUS_ROUTES), el NATS de los seis servicios va por @styx/bus sobre spire, el daemon publica sus evt con spire-zig, la ACL de NATS por servicio se genera de la misma tabla, y ningún proceso habla NATS ni el socket del daemon por otro camino (guard). Los contratos siguen siendo @styx/api-contracts (r18, dec-0116).

Nodos del roadmap que lo citan en refs: track/spire, track/docs, track/oss-extraction

Páginas de la documentación que lo citan: Puesta en marcha de un nodo (implementado), Generadores de la documentación (especificado), Guardas del repositorio (implementado), Arquitectura (implementado), Dispositivos, mando y handoff (especificado), Protocolos binarios (implementado), Comunicaciones entre procesos (implementado), Seguridad del data plane (implementado), Modelo de amenazas (implementado), Bus y contratos (implementado), zkit, spire y conduit (implementado), Instalar (especificado), Seguir una reproducción por todos los servicios (especificado)

Texto del ADR

Leído de docs/decisions/dec-0120-spire-consumido-por-styx.md, el fichero canónico.

dec-0120 — styx consume spire: control Bun↔daemon (session-ipc v2) y bus NATS

  • Fecha: 2026-09-30
  • Estado: LOCKED. Locks de waxin del 2026-09-28 (notas de entorno de la tanda 4):
    • "spire = SDK de mensajería UNIFICADO de styx (Zig+TS, bindings generados desde @styx/api-contracts) con transportes enchufables: in-process, unix socket (Bun<->daemon control IPC) y NATS JetStream … Reemplaza el código NATS/IPC hand-written de cada servicio (load-bearing) … Daemon Zig puede publicar evt a NATS vía spire-zig."
    • "conduit y spire SE PASAN A ZIG Y STYX LOS CONSUME (decisión tomada, locked) … SDK Zig de spire construido de verdad … reemplazando el IPC de control Bun<->daemon; codegen en spire pero fuente de verdad = @styx/api-contracts (r18); ADRs spire/conduit/zkit como LOCKED".
    • "spire = PUNTO ÚNICO DE SEGURIDAD de comunicaciones": dec-0119, que este ADR aplica.
  • SDK: MKS2508/spire, rama feat/zig-sdk (ADR 0001 del SDK, spec/ENVELOPE_V2.md). Este ADR decide el consumo en styx, no el diseño del SDK.
  • Numeración: spire toma dec-0120 y conduit dec-0121 (orden fijado en el índice tras la tanda 3).

Contexto (medido en 845017e, base con zkit integrado)

  • IPC de control: native/zig/media-daemon/ipc/control.zig (2 564 líneas) implementaba un protocolo JSON propio ([u32 len][0x01][JSON]) sin autenticar: cualquier proceso con acceso al socket abría sesiones o las cerraba. Parsers de campos a mano, broadcast por fd de señales de transporte y de métricas de caché, eventos que nadie consumía. En Bun, media-daemon-client.ts (850) y transport-signals-client.ts (577) repetían el framing, más el cliente de los harness (tools/soak-harness/lib/ipc.ts, 593) y el script Python del motor de medios.
  • NATS: cada servicio tenía su bus.ts (conexión), su query-server.ts o cmd-server.ts (suscripción, deserializeEnvelope, validación, respuesta) y sus clientes; 2 877 líneas en total, sin firma ni identidad de servicio, contra un nats-server sin autenticación.
  • Señales de transporte: daemon → broadcast IPC → playback-svc → republish en JetStream → realtime-svc. playback-svc era un relay sin decisión.

Qué se decide

1. Contratos: @styx/api-contracts/bus

  • Cada mensaje del bus es un contrato (subject, kind, version, maxBytes, JSON Schema de petición y respuesta) en packages/api-contracts/src/bus/: nats.ts (entre servicios, con los schemas de dominio que ya existían) y media-daemon.ts (socket de control).
  • BUS_ROUTES (routes.ts) es la tabla única de autorización: servicio que atiende, transporte, contrato, emisores admitidos y rate. De ella salen la policy de cada handler TS (busHandler la toma de la tabla, el servicio no la escribe), la policy de cada handler del daemon (generada) y la ACL de NATS (generada).
  • Criterio de la allowlist (load-bearing): nombra a quien hoy llama al handler en un production path. Un handler sin emisor de servicio sólo admite styx-ops (operación y harness, fuera del keyring de producción). Nada de consumidores "previstos".
  • bun run gen:bus genera native/zig/media-daemon/ipc/contracts.generated.zig (structs con validate() por @spire/bus/codegen + policies del socket) y deploy/nats/nats-authorization.conf; bun run check:bus falla si lo versionado difiere.

2. Socket de control: protocols/session-ipc v2

  • El daemon sirve el socket con el transporte unix de spire-zig: SO_PEERCRED (STYX_CONTROL_ALLOWED_UIDS/_GIDS), handshake mutuo firmado en 2 s, un sobre v2 firmado por mensaje, router con los contratos y policies generados, presupuesto de memoria por mensaje.
  • control.zig queda en lo del daemon: sesiones, binding de capabilities (dec-0117 I2), empaquetado (dec-0110). Subjects cmd.media.{createSession, openMoqtSession, openPackaging, closePackaging, seek, cancel, closeSession} y qry.media.ping.
  • Errores en dos capas: transporte/policy/contrato en el status del sobre; negocio en { error } del payload.
  • Se retira lo que no tenía consumidor: eventos (SessionCreated, TransportEndpoint, PipelineReady, Progress, Metrics, Closed), Subscribe*TransportSignals, SubscribeCacheMetrics, MediaEngines, MediaProbe. El stat que playback-svc hacía del asset (segunda apertura fuera del path guard) lo sustituye fileSize en la respuesta.
  • Ruptura deliberada: el byte de versión pasa a tipo de frame, 0x01 se rechaza. No hay modo de compatibilidad (styx es el único cliente; mantener v1 dejaría abierto el socket sin autenticar). Detalle: protocols/session-ipc/SESSION_PROTOCOL.md, version-history.md.

3. NATS de los servicios: @styx/bus

  • packages/bus es el único punto de los servicios que toca spire: identidad desde el entorno (SPIRE_SEED, SPIRE_KEYRING, SPIRE_NATS_NKEY_SEED; sin identidad no se arranca), connectBusNats/startServiceBus, busHandler (policy de la tabla), request/emit/ emitAcked con traceparent del span activo, mapeo de status a BUS_*, cliente del daemon (createDaemonBus) y @styx/bus/testing (router en proceso con identidades de prueba, para que los tests de cada servicio pasen por la misma tubería que producción).
  • Los seis servicios (catalog, identity, playback, realtime, sources, workers) sirven y piden por ahí; su código NATS a mano está borrado.
  • Guard: test/no-direct-bus-transport.test.ts falla si algún fichero de apps/, packages/ o tools/ importa nats, o si alguien fuera de @styx/bus importa @spire/bus/nats o @spire/bus/unix. En Zig, audit:safety S1 ya no tiene allowlist de bind/unlink para control.zig: un socket crudo en el daemon vuelve a ser un hallazgo.

4. El daemon publica sus evt con spire-zig

  • evt.media.transportSignals: el daemon sella cada muestra con su identidad y la publica en NATS core (latest-wins, no JetStream) con su NKey (ResilientPublisher: conexión y escrituras acotadas, descarta mientras NATS no está, reconecta con espera exponencial). realtime-svc la consume; su ruta sólo admite a media-daemon. playback-svc deja de ser relay.
  • Sin NATS_URL el daemon no publica (no hay a quién).

5. Identidad y ACL de NATS por servicio (deploy)

  • deploy/nats/nats.conf incluye la ACL generada: un usuario NKey por servicio que publica exactamente lo que alguna allowlist le deja, suscribe sus handlers y su inbox (_INBOX_<servicio>), y allow_responses sólo si atiende cmd/qry. Un servicio sin subjects de publish recibe deny: [">"] (en nats-server una allow: [] no restringe nada; el bug salió en el test de ACL y está arreglado en spire 3a3e1ad).
  • Streams JetStream: los servicios sólo los consultan. La ACL de un servicio tiene $JS.API.STREAM.INFO.<su stream> y nada más de la API de JetStream; connectBusNats comprueba al arrancar que sus streams existen con exactamente sus subjects y sin republish/mirror/ sources, y si no, no arranca. Los crea y los corrige la identidad de despliegue styx-deploy (BUS_PROVISIONER: $JS.API.STREAM.{CREATE,INFO,UPDATE} de CATALOG y SOURCES, nada más; no está en el keyring ni firma sobres) con bun run bus:streams, paso de despliegue entre la infra y los servicios; su semilla no la recibe ningún contenedor de servicio. Motivo (hallazgo del escéptico, reproducido): con STREAM.UPDATE, catalog-svc podía añadir qry.identity.session a su stream con un republish hacia su inbox, leer el accessToken que styx-ops manda en esa petición y contestarle con un PubAck; la ACL de subscribe no lo impedía porque el que publica es el servidor.
  • Compose pasa a cada servicio su SPIRE_SEED/SPIRE_KEYRING/SPIRE_NATS_NKEY_SEED y a nats-server las claves públicas SPIRE_NKEY_* (también la de styx-deploy); sin alguna, compose no arranca. bun run bus:keys escribe un juego nuevo en deploy/.env (fuera de git, idempotente).
  • bun run test:bus-acl levanta un nats-server efímero con esa ACL y prueba que un servicio no publica ni suscribe en subjects ajenos ni crea o cambia streams (el caso de arriba: rojo con la ACL anterior, verde con ésta). Corre en la CI (guard.yml), igual que check:bus.
  • De qué depende la garantía y qué no da. "Un servicio no publica ni suscribe en subjects ajenos" es cierto mientras ninguna identidad de servicio tenga permisos de configuración de JetStream (streams, consumers con deliver_subject, mirrors/sources) ni de cuenta de sistema: cualquiera de ellos le deja al servidor copiar mensajes a donde el servicio sí puede leer. Y los payloads del bus (por ejemplo el accessToken de qry.identity.session) viajan firmados, no cifrados: el sobre v2 da autenticidad, integridad y anti-replay, no confidencialidad; los lee en claro el broker y cualquier identidad que pueda suscribir el subject. Cifrado extremo a extremo o TLS al broker: follow-up (dec-0119 §3.1, modo operador).

6. Cumplimiento de dec-0119 en styx

dec-0119Estado en styx
§1 todo lo que cruza un proceso pasa por spireCumplido para NATS y el socket de control (guard TS + S1 en Zig). Pendiente lo que traiga la lane conduit (ver Consecuencias).
§3.1 identidad NATSNKey por servicio + ACL generada, deny por defecto, diff contra lo desplegado (check:bus, en CI). Streams: los servicios sólo INFO; crear/cambiar es de styx-deploy (§5). Payloads firmados, no cifrados (§5). No cumplido: modo operador con JWT de cuenta (nsc) y límites JetStream por cuenta; hoy los usuarios NKey van en la config del servidor. Follow-up.
§3.2 unixSO_PEERCRED + handshake mutuo en 2 s. Pendiente: directorio 0750 / socket 0660 con grupo dedicado en el despliegue.
§4 deny-by-defaultCumplido: handler sin ruta no se construye (TS), sin policy no compila (Zig, generada).
§5 sobre v2, anti-replayCumplido (spire). Caché de nonces con cuota por emisor (allowlist antes del anti-replay; cada emisor admitido reserva burst + perSec × ventana): un emisor que inunda agota la suya y no la de otro. Daemon: ventana 10 s, 100/s ráfaga 100 → 1 110 por (emisor, ruta), 12 210 en total. En memoria: un consumidor por servicio hoy; Valkey al escalar réplicas (follow-up, sin cuota por emisor todavía).
§5.2 actor JWTTodas las rutas actuales son actor: forbidden: ningún mensaje del bus actúa hoy en nombre de un usuario.
§6 validación de entrada y salidaCumplido a los dos lados (TypeBox compilado; validate() generado en Zig).
§7 límitesTamaño por contrato, rate por (emisor, subject), deadline.
§8 auditA OTel y al log (sin payloads ni tokens). Los rechazos de SCT del daemon salen por el bus como evt.security.capabilityRejected (hook on_reject de spire.cap, SEC-Z12). Pendiente: evt.security.comms.*.
§9 SCT en spire-zigCumplido. El módulo security del daemon es spire.cap (sct + authority) más request_cap, que es de styx; playback-svc firma con @spire/bus/sct. Las copias de styx están borradas (security/{sct,authority,sct_testing,sct_vectors_test}.zig, service/capability/sct.ts) y el daemon exige que los vectores de spire sean protocols/capability-token/vectors.json byte a byte. El audit:safety de styx ya no recorre sct.zig (vive en spire): follow-up de un guard equivalente en spire.
§5.4 costeMedido en el SDK (256 B): sellar 18.5/59 µs TS, 55.6/126 µs Zig; dispatch completo 89/282 µs TS, 127/260 µs Zig (p50/p99). Cabe; sin MAC de sesión.

Consecuencias

  • LOC (git diff 845017e..w4/spire, sin docs ni generados): producción TS −2 851, producción Zig −2 042 (control.zig 2 564 → 490), tests −266 (TS −636, Zig +370: el control socket se prueba ahora extremo a extremo con el router real), harness −370, infra y guards +294. @styx/bus (+813) sustituye seis bus.ts y los query/cmd servers.
  • Ronda 3 del escéptico (git diff --numstat d758d71.., sin docs, lock ni generados): Zig de producción −1 324 (SCT de styx borrado −1 377: authority 857, sct 482, sct_testing 38; control.zig +39 de rollback y ventana; security.zig +12), tests Zig +123 (rollback, OOM y flood del socket +246; sct_vectors_test.zig −123, sus vectores los comprueba spire); TS de producción −192 (sct.ts −265; ACL con provisioner, comprobación y provisión de streams +73), tests TS +1 (codec SCT a spire −56, caso de streams en la ACL +55), scripts y harness +38 (bus:streams, provisión en el gate y el web VOD). El daemon no deja nada detrás en ningún camino de error de createSession/openMoqtSession (errdefer con rollback, y onReplyFailure de spire si la respuesta no se puede sellar).
  • Ruptura v1 del socket: cualquier cliente de v1 deja de funcionar. tools/media-engine-bench/daemon-smoke.py (Python, protocolo v1, ya sin SCT desde dec-0117) queda fuera de servicio hasta portarlo al cliente spire de los harness: follow-up.
  • Lane conduit (w4/conduit, dec-0121): se escribió sobre el IPC y el NATS a mano (ingest_ipc.zig en control.zig, ingest-daemon-client.ts, ingest-event-publisher.ts, consumidor durable de evt.media.ingestCompleted en catalog, principal-resolver.ts pidiendo qry.identity.session). Al integrarse con esta rama hay que portarla: contratos cmd.media.openIngest/… en media-daemon.ts y handlers en el router del daemon; rutas en BUS_ROUTES (playback-svc → identity-svc qry.identity.session pasa a tener emisor de servicio real); publicación con emitAcked; y consumo durable con ack/nak/term, que @styx/bus todavía no ofrece (follow-up de spire: consumidor JetStream durable con la misma tubería del router).
  • Rollback: revertir la rama devuelve v1 entero (daemon, clientes y harness en el mismo cambio); no hay estado persistente nuevo salvo el stream CATALOG/SOURCES, que ya existía.

Evidencia

docs/track/spire/evidence/ (y la del SDK en MKS2508/spire docs/evidence/2026-09-29-sdk/): ACL en nats-server real (rojo con allow: [], verde con deny; rojo con STREAM.UPDATE en un servicio, verde sin él), check:bus en CI con tres mutantes en rojo, rollback del daemon con cinco mutantes en rojo, R-02 por el socket spire (5/5, controles de auth refutados), R-06 con el daemon publicando por NATS bajo ACL (verde y rojo con la NKey de otro servicio), suites Zig/TSAN/TS, guard con control negativo, e2e web VOD.

Alternativas descartadas

  • Mantener v1 del socket en paralelo: dejaría un camino sin autenticar al lado del seguro; styx es el único cliente, se migra entero.
  • Seguir republicando las señales desde playback-svc: un relay sin decisión es un proceso más que puede caerse y un salto más sin firma de origen; el dato es del daemon y lo firma el daemon.
  • Policies escritas en cada servicio: N copias que divergen; la tabla única genera las tres vistas (TS, Zig, ACL) y un test las compara.