dec-0116

Vista generada de dec-0116: Contratos contract-first en TypeBox 1.x y salto a Elysia 2 beta, Arktype fuera

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0116-contratos-typebox-fuera-arktype.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0116-contratos-typebox-fuera-arktype.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-28
Ficherodocs/decisions/dec-0116-contratos-typebox-fuera-arktype.md

Enmienda a: r18, r20, r54, r60

Por qué importa (del frontmatter del ADR):

Fija la librería de los wire contracts contract-first de @styx/api-contracts y @styx/domain (r20 §2.3) y la major de Elysia de todos los svcs HTTP. Los contratos los validan en runtime los svcs NATS (parseEnvelope + 12 sitios) y las rutas HTTP, y la librería se carga en cada contenedor (r17). Sustituye a la propuesta dec-0115.

Nodos del roadmap que lo citan en refs: track/identity/sec, track/elysia2-typebox, track/spire, track/docs

Páginas de la documentación que lo citan: Empezar a contribuir (implementado), Generadores de la documentación (especificado), Catálogo federado (implementado), Bus y contratos (implementado), Planos y servicios (implementado), Entrar con el CLI (implementado)

Texto del ADR

Leído de docs/decisions/dec-0116-contratos-typebox-fuera-arktype.md, el fichero canónico.

dec-0116 — Contratos contract-first en TypeBox 1.x y salto a Elysia 2 beta, Arktype fuera

  • Fecha: 2026-09-28
  • Estado: LOCKED. Dos decisiones de waxin del mismo día:
    1. La regla fijada antes de medir: "si TypeBox consume menos que Arktype, fuera Arktype". Los datos cumplen la condición (§Datos), así que Arktype sale sin otra pregunta.
    2. En el chat, tras ver que TypeBox 1.x sólo funciona con Elysia 2: "pasamos ya a la beta". Se adopta ya elysia@2.0.0-beta.19 con typebox 1.x (pin en §Decisión 2), sin esperar a Elysia 2 estable y sin puente por @sinclair/typebox 0.34.
  • Sustituye a: dec-0115 (PROPUESTO, misma rama), que dejaba abiertas la línea de TypeBox y el orden respecto a Elysia 2, citaba el cold start de la corrida con el host cargado y declaraba paridad 1:1 sin las divergencias semánticas que encontró la revisión.
  • Nota de redacción: una versión anterior de este ADR (commit 01c751c, nunca integrada ni publicada) fijaba 0.34 hasta Elysia 2 estable y descartaba la beta. La decisión de waxin la invierte y este texto la reemplaza. No hace falta ADR de enmienda porque la versión anterior no llegó a ninguna rama compartida.
  • Enmienda: r18 §contracts y r20 §2.3 (librería: Arktype → TypeBox 1.x, JSON Schema como forma canónica), y las menciones "contratos Arktype" de r54 y r60 D6. Contract-first se mantiene: los contratos se siguen escribiendo a mano en @styx/api-contracts y no se derivan de Elysia. r52 (Eden App-type) no cambia.
  • Evidencia:
    • Bench: spikes/typebox-vs-arktype/RESULTS.md + data/*.json (rama bench/typebox-vs-arktype, commit 036fc7c, base bb1d967). Dos revisiones independientes del mismo día: una reprodujo memoria, cold start y throughput, la otra auditó cobertura y semántica.
    • Spike de migración: rama mig/elysia2-spike (base 03ea232), commits 7acd203 (contratos a TypeBox 1.3.34), 103d7eb (catalog-svc en Elysia 2 beta.19 + @elysia/* + Eden 2) y 64c8768 (problem+json en @styx/clients). Los escépticos no lo refutaron. Auditoría de breaking changes y plan de lotes: artefactos de sesión elysia2/BREAKING.md y PLAN.md, resumidos en §Hallazgos de Elysia 2.

Datos

Versiones: arktype 2.2.5, @sinclair/typebox 0.34.52 (la línea que usa Elysia 1.4) y typebox 1.3.34 (línea vigente en npm), con Bun 1.4.3 y Node 22. Se portaron 17 schemas reales de unos 55. Throughput y cold start son de la run 2 (loadavg ~2), que es la que reprodujo la revisión independiente; la run 1 (loadavg 16) queda en data/run1-host-loaded/ sólo como referencia. Memoria, bundle y typecheck son de la run 1 (la memoria reprodujo dentro de ±2%).

eje (p50)ArktypeTypeBox 0.34 compiladoTypeBox 1.x compilado (elegido)
heap tras import + 17 schemas + 1ª validación, bun / node17.95 / 19.62 MB2.16 / 2.82 MB3.17 / 6.59 MB
de ello, sólo importar la librería, bun / node10.50 / 12.52 MB0.39 / 1.72 MB0.71 / 3.25 MB
cold start de la lib (import + build + 1ª val), bun / node, run 2~350 / ~460 ms~68 / ~100 ms~155 / ~242 ms
bundle web de la suite, min + gzip47 KB23 KB34 KB
tsgo: check / memoria151 ms / 43.2 MB60 ms / 21.4 MB68.5 ms / 29.3 MB
mensajes válidos, ops/s, bun / node4.25M / 3.69M (.allows) · 3.68M / 2.86M (schema(v), lo que usa producción)4.54M / 4.82M6.30M / 3.50M
90% válidos / 10% inválidos con errores detallados, ops/s, bun / node520k / 500k (schema(v)) · 435k / 616k (.allows)794k / 720k156k / 153k
Elysia 1.4.30sí (Standard Schema)sí (t.*)NO: 500, validator.body.Validate is not a function
Elysia 2.0.0-beta.19sí (Standard Schema)NO: "body" is not a schemasí

Cómo leer la tabla sin sobredimensionarla:

  • Memoria y cold start son las diferencias robustas: el heap reprodujo dentro de ±2% y el cold start dentro de unos 5%. Sólo con importar, Arktype cuesta 10.5–12.5 MB de heap y unos 220–325 ms, por su parser/JIT del DSL. El port cubre 17 de ~55 schemas y el coste por schema de Arktype es mayor (6.2 MB frente a 1.9 MB de 1.x en bun para los 17), así que en producción la diferencia será mayor, no menor. Esto último es una inferencia, confirmada en orden de magnitud por la medida en un svc real (§Hallazgos, RSS).
  • Mensajes válidos: TypeBox 1.x gana en bun (unas 1.5×) y empata en node (3.50M frente a 3.69M; la revisión midió 3.41M frente a 3.46M). La varianza entre procesos llega al 40–60% en algunas celdas de node. Lo que se lee son ratios, no el tercer dígito.
  • Camino de error: TypeBox 1.x pierde entre 2.8× y 4.9× frente a Arktype según runtime y corrida, peor en node. dec-0115 decía "unas 3.3×", y eso se queda corto. TypeBox 0.34 gana a Arktype en este eje, pero no es una opción viable con Elysia 2.
  • Per-schema, Arktype con .allows gana en dos sitios. Uno es MetadataPatch (records): 6.69M / 4.64M frente a 4.31M / 1.93M de 1.x en mensajes válidos. El otro es el rechazo booleano de unions grandes como PlaybackPlan: 9.60M frente a 1.68M. Producción no usa .allows y PlaybackPlan no tiene schema runtime, así que ninguno de los dos pesa en el tráfico real hoy.
  • Mensajes de error: los de Arktype son mejores. En una union discriminada, TypeBox 1.x repite la ruta una vez por rama.

Ponderación para styx. Se pondera por dónde paga styx cada eje:

  1. Coste por contenedor (r17): es el eje dominante, porque cada svc paga la librería entera al arrancar. Los 5 svcs que hoy declaran arktype y elysia (catalog, playback, realtime, sources y workers; identity cuando se integre) pagan unos 15 MB de heap y unos 200 ms de arranque (bun) más con Arktype que con TypeBox 1.x sólo por la validación. Además, Elysia 2 ya carga typebox 1.x como peer (>= 1.3.0), así que el coste marginal de la librería de contratos pasa a ser aproximadamente cero.
  2. Bus NATS: el tráfico es sobre todo de mensajes válidos. Contra lo que usa hoy producción (schema(v), 3.68M / 2.86M), TypeBox 1.x compilado gana en bun y empata en node. Un payload inválido sólo aparece si el emisor está roto o es malicioso; ahí 1.x es 2.8–4.9× más lento, y la fachada (§Decisión 3) lo saca del camino caliente.
  3. Web: apps/web no valida en runtime; sólo usa Eden para tipos (r52). Lo que paga es bundle: hoy arrastra Arktype entero (47 KB gzip) por createRequestId a través del barrel. Eso se arregla con un subpath del módulo hoja, que pesa 0 KB y no depende de esta decisión.
  4. Typecheck: unas 2.2× menos de tiempo y 1.5× menos de memoria en tsgo. Es un eje secundario, pero va en la misma dirección.

Veredicto de la regla: TypeBox 1.x compilado consume menos que Arktype en heap, cold start, bundle y typecheck, gana en CPU de mensajes válidos en bun y empata en node. No gana en todos los ejes: pierde en el camino de error (2.8–4.9×) y en calidad de mensajes. La regla de waxin es de consumo, y en consumo la condición se cumple sin ambigüedad, así que Arktype sale.

Decisión

  1. Arktype sale de @styx/api-contracts, @styx/domain y apps/catalog-svc/src/core/ports/ffprobe-types.ts, y de los package.json que lo declaran. Los contratos se escriben con el builder Type.* de typebox 1.x. Value.Check sin compilar es 10–270× más lento y queda prohibido fuera de tests.

  2. Pins exactos (sin rangos; cada bump es un cambio revisado con smoke):

    • elysia@2.0.0-beta.19 (tag next a 2026-09-24) y typebox@1.3.23. Corrección de implementación (2026-09-28, lane w3/elysia2): el spike fijó 1.3.34, pero typebox >= 1.3.24 rompe la compilación eager de beta.19 (precompile tumba listen() y un schema con error custom da 500 en todas las peticiones, también las válidas; bisección en ADOPT F1). El pin baja a 1.3.23 y lo protege un test contra Bun real en @styx/service-http (rojo con 1.3.34, verde con 1.3.23). Cada bump de typebox o de elysia repite ese test.
    • Plugins bajo el scope nuevo @elysia/*: @elysia/opentelemetry@2.0.0-beta.1, @elysia/cors@2.0.0-beta.1, @elysia/eden@2.0.0-beta.5, y @elysia/openapi@2.0.0-beta.4 donde se publique OpenAPI. Los @elysiajs/* no tienen versión compatible con Elysia 2. dec-0062 R-D2.1 ("pin de eden") se aplica ahora a @elysia/eden.
    • Peers de Elysia 2 declarados en cada paquete que la use: exact-mirror@1.2.6 y openapi-types@12.1.3. Bun enlaza si no el exact-mirror@0.2.7 que ya está en el grafo por Elysia 1.4 y sólo avisa con warn: incorrect peer dependency.
    • Nunca conviven dos majors de Elysia ni dos líneas de TypeBox en un mismo proceso.
  3. Dos formas del mismo contrato:

    • A Elysia se le pasa el schema crudo (Type.Object(...) exportado por api-contracts). Elysia lo compila y lo cachea. Un validador ya compilado con Compile(schema) no falla al registrar la ruta: da 500 en la primera petición (Compiled schema detected).
    • Las fronteras sin Elysia (NATS query/cmd servers, eventos, IPC) usan la fachada compileContract(schema) de @styx/api-contracts/src/validation.ts: compila con typebox/compile de forma perezosa, check() booleano en el camino caliente e issues() / validate() sólo tras un check() fallido. IContractIssue = { path, message } es propio de styx, así que el formato de logs no depende de la librería.
  4. JSON Schema pasa a ser la forma canónica serializada del contrato, porque un schema de TypeBox ya es JSON Schema. Eso desbloquea OpenAPI (el spike lo publica en GET /openapi/json con toOpenAPISchema(app) y el body de /scan sale igual que el contrato) y los clientes no-TS (Swift, SDK de spire) sin pasar por .toJsonSchema(), que hoy falla con el .narrow() de SourceBinding.preference.

  5. Semántica: se preserva la actual y no se endurece en silencio. La paridad del bench (17 fixtures + 16 edge cases) no cubría estas divergencias. Esta es la regla para cada una:

    construcciónArktype hoyTypeBox 1.x por defectoregla del port
    'object' (details, causedBy, ResolvePlanReply.plan, ResolvePlanPayload.client/preferences, RepresentationGeneration.metrics)acepta arrayslos rechazaType.Union([Type.Object({}), Type.Array(Type.Unknown())]). Llega por el cable (JSON transporta arrays). Endurecerlo es otra decisión
    clave opcional presente con valor undefinedrechazaaceptase acepta la divergencia: JSON.parse no puede producir undefined
    ±Infinity en numberacepta0.34 rechaza; en 1.x no medidose acepta: JSON no transporta Infinity
    array disperso en string[]rechazaaceptase acepta: JSON no produce huecos

    Además: .narrow() de SourceBinding.preference → Number({ minimum: 0, maximum: 1 }), y number.integer > 0 → exclusiveMinimum. Único endurecimiento deliberado del spike: ScanAssetCommandSchema.path lleva minLength: 1, que la ruta HTTP ya exigía; al consumir el contrato, la restricción sube a él. No se tocan las políticas globales de TypeBox, porque cambiarían también la validación t.* de Elysia en el mismo proceso. Si algún día se valida en proceso algo que no venga de JSON.parse, esta tabla se reabre.

  6. Eden (r52): no cambia de modelo. Eden 2 infiere de typeof createHttpApp también con el schema crudo del contrato como body; el spike lo comprobó con un probe @ts-expect-error (si Eden infiriera any, el probe se rompería).

  7. Elysia t.*: se deja de duplicar contratos. Las rutas HTTP que hoy declaran su body con t.Object inline pasan a usar el schema de @styx/api-contracts directamente: catalog-svc/.../scan.ts (hecho en el spike), sources-svc/.../resolve.ts y playback-svc/.../sessions.ts, que además revalida a mano. t.* se sigue usando sólo para params/query locales de una ruta que no son contrato (works.ts).

  8. spire: es otro repo, y en este hay 0 referencias. Este ADR no crea una dependencia de código con spire. Lo que comparten es el formato de intercambio (JSON Schema) y la fuente de verdad (@styx/api-contracts, r18).

  9. La imagen sirve el bundle AOT (implementación, 2026-09-28, lane w3/elysia2). Elysia 2 desde fuente arranca más lento que 1.4 (ADOPT F5), así que cada docker/<svc>-svc.Dockerfile corre bun run build (= styx-build-service de @styx/service-http): el plugin AOT de Elysia captura src/app.aot.ts (la misma createHttpApp del bootstrap con los puertos sin implementar) y el bundle dist/bootstrap.js va solo al runner, sin node_modules, con bun --no-install. Build con strip: true y compileServiceRoutes(app) antes de listen(): si la app de captura y la del bootstrap divergen, el servicio no arranca (sin eso, 500 en esas rutas con /health en 200). Medido en los 6 servicios (3 rondas intercaladas): arranque −42 a −55 %, RSS al estar listo −26 a −29 % y tras carga −11 a −21 %, req/s dentro del ruido (−5 a +9 %), y las 26 respuestas del smoke idénticas a las de fuente (docs/outcome/foundation/evidence/elysia2-aot-2026-09-28/). El manifiesto AOT es ABI de la beta: el build de la imagen lo regenera siempre.

Hallazgos de Elysia 2 que condicionan la migración

Auditoría de breaking changes de 1.4 → 2.0.0-beta.19 (codemod oficial @elysia/codemod 2.0.0-beta.1 más smokes ejecutables) y spike real sobre catalog-svc. Lo que el codemod cubre de forma mecánica: orden (path, hook, handler), lifecycle sin prefijo on, scopes 'local' | 'plugin' | 'global' y scope de plugins @elysiajs/* → @elysia/*. Lo que no cubre y se hace a mano:

  • Errores RFC 9457 por defecto: validación → 422 application/problem+json con code: 'validation'; ruta inexistente → 404 code: 'not-found'; JSON malformado → 400 code: 'parse'. Compila igual y los tests que sólo miran el status siguen verdes, pero cambia lo que ve cualquier cliente que parsee cuerpos de error. unwrapEdenResult de @styx/clients los trata como error de framework (EDEN_BAD_REQUEST / EDEN_NOT_FOUND), no como código de dominio (hecho en el spike, con tests). En desarrollo el 422 reenvía el body entero (found); con NODE_ENV=production Elysia lo omite. Los svcs con bodies sensibles (identity) no deben depender de NODE_ENV: error handler propio, en la tanda AppSec.
  • No pasar Compile() a rutas Elysia (§Decisión 3): 500 en la primera petición, no al registrar.
  • HEAD pasa a opt-in (elysia/auto-head). Una ruta función responde HEAD → 404. Los HEALTHCHECK de los 5 docker/*-svc.Dockerfile usaban wget --spider, que en GNU wget manda HEAD: los contenedores quedarían unhealthy y el compose de apps (service_healthy) no arrancaría en cadena. Ningún test lo detecta porque usan app.handle(GET). Regla: healthcheck con GET explícito (wget -q -O /dev/null http://localhost:<port>/health), que vale también para Elysia 1.4, con un test que falla si vuelve --spider.
  • Bug de @elysia/opentelemetry@2.0.0-beta.1: su .wrap() descarta el segundo argumento, así que context.server es null en todas las rutas de una app que monte el plugin OTel, incluso con listen() real. En identity eso deja el rate-limit por IP con un único bucket auth:unknown compartido (P1 de seguridad y verde falso: sus tests no montan OTel). Receta: resolver la IP desde la instancia (app.server?.requestIP(req)) o bun patch del plugin y reportarlo upstream, con un test de integración con OTel + listen() y dos IPs.
  • app.stop() hace drain, ejecuta los cleanup y devuelve promesa: siempre await. app.server es undefined sin listen (no null).
  • Hooks afterResponse/error de un plugin son local por defecto: routeSpan de @styx/observability (0 callers en producción) dejaría spans sin cerrar. En el spike sale del barrel porque arrastraba elysia 1.4 a un proceso en Elysia 2 (dos majors cargados sin error visible); su borrado espera el OK de waxin.
  • @elysia/openapi con provider: null no publica nada, y con UI carga Scalar @latest desde CDN. Se usa toOpenAPISchema(app) en una ruta propia con new Elysia({ introspect: true }).
  • realtime-svc debe pasar de app.handle(req) a app.fetch(req) en su Bun.serve propio.
  • cors({ origin: true, credentials }) refleja cualquier origen, igual que en 1.x: allowlist en la tanda AppSec, no es breaking.

Medida en un svc real (catalog-svc con Postgres, NATS y OTel reales, 5 rondas intercaladas, host compartido): RSS −26 MB por contenedor (125.3 → 99.4 MB al estar listo, −21%), con dispersión menor de 2 MB; la fila intermedia (TypeBox 1.x sobre Elysia 1.4) muestra que todo ese ahorro viene de sacar Arktype y que Elysia 2 es neutro en RSS. Arranque −209 ms (−25%) con loadavg ~7, que baja a unos 60 ms con loadavg 12: es ruidoso y no se puede repartir entre Elysia 2 y Arktype. Bundle de apps/web: −44 KB gzip.

Plan de migración

Cada lote es un commit o PR verificable, con typecheck, lint, tests, smoke real y rm -rf node_modules && bun install limpio. La verificación de cierre la hace alguien distinto de quien implementa (testing-evidence). El salto de Elysia y el de contratos son dos fases separables:

  • Fase A: Elysia 2 con los contratos intactos. Las rutas ya usan t.* y Arktype sólo vive dentro de los handlers, y Elysia 2 acepta Standard Schema, así que se puede subir la major sin tocar contratos: codemod, pins de §Decisión 2 y los fixes manuales de §Hallazgos.
  • Fase B: contratos Arktype → TypeBox 1.x. Fachada validation.ts, port de los ~55 schemas (codemod de un solo uso, con las reglas de §Decisión 5), los 13 call sites (parseEnvelope + 12 en apps) y las rutas HTTP de §Decisión 7.

Ambas pueden ir juntas, como hizo el spike (contratos primero sobre Elysia 1.4 en 7acd203, Elysia 2 en 103d7eb), y es el orden recomendado para no cargar Arktype como puente permanente. Lo que no se permite es dejar un svc a medias: nunca dos majors de Elysia ni dos líneas de TypeBox en un proceso. Orden por el grafo packages → apps → web:

  • Lote 0: integración. Integrar la tanda 2 y rebasar el spike sobre ella; w2/identity añade schemas Arktype a api-contracts y se portan con el mismo codemod. Actualizar a TypeBox/JSON Schema y al vocabulario de lifecycle 2.0 los textos vivos que dicen "contract-first Arktype" (CLAUDE.md root, apps/CLAUDE.md, packages/CLAUDE.md, .claude/rules/typescript-elysia.md, agentes builder-services, README.md, workflows de auditoría). Si la migración se retrasa, subir ya elysia a 1.4.30 (5 GHSA que el lock actual no tiene).
  • Lote 1: packages. api-contracts, domain, observability (fuera de routeSpan o port a hooks 'global'), clients (Eden 2, problem+json), subpath @styx/api-contracts/request-id para web. Guards: 0 imports de arktype en apps//packages/; un solo typebox y un solo major de elysia en bun.lock; exact-mirror fijado en cada consumidor de Elysia 2; ningún Dockerfile con wget --spider.
  • Lote 2: servicios, de menor a mayor riesgo y un commit por svc: workers, realtime (app.fetch), sources, playback (@elysia/cors, sessions.ts con el contrato, await app.stop()), identity (fix de context.server con test, compose.ts, validate() con la fachada). Gate: docker compose ... up con todos los svcs en healthy.
  • Lote 3: web. Eden 2 compila y vite build pasa en el spike; falta un smoke Playwright de las pantallas que llaman a catalog y revisar los consumidores de EdenErrorCode.
  • Lote 4: cierre. Borrar elysia@1.4 y @elysiajs/* del lock; re-medir RSS y arranque en todos los contenedores con el método del spike; re-medir el camino de error bajo carga NATS inválida (fuzz de payloads). Sin esas medidas el ADR no se da por cumplido.

Consecuencias

  • A favor: el coste fijo de validación por contenedor baja de forma medida (−26 MB RSS en catalog-svc). El repo deja de mantener dos stacks (Arktype para NATS y TypeBox t.* para HTTP). JSON Schema y OpenAPI pasan a ser nativos. Se adopta la línea viva de Elysia y TypeBox (r16).
  • En contra:
    • Framework HTTP en beta. Los plugins @elysia/{cors,opentelemetry} van en beta.1 (julio) frente al core beta.19 (septiembre) y ya salió un bug real (context.server). Upstream sigue rompiendo entre betas. Cada bump exige re-leer el CHANGELOG, repetir los smokes y mantener el pin exacto.
    • El camino de error de TypeBox 1.x es 2.8–4.9× más lento que Arktype. Se mitiga con check primero, pero se re-mide bajo carga antes de cerrar.
    • Los mensajes de error de TypeBox en unions son peores; la fachada trunca a 5 issues.
    • Los contratos quedan atados a la major de Elysia (1.x ↔ Elysia 2), cosa que Arktype no hacía porque habla Standard Schema.
    • Cambia el formato de los logs de validación y de los cuerpos de error HTTP (problem+json), sin error de compilación para los clientes.

Alternativas descartadas

  • Mantener Arktype: incumple la regla de waxin. Pierde en heap (unas 5.5× frente a 1.x en bun), cold start (unas 2.2×), bundle y typecheck. Sus ventajas reales son los mensajes de error, el camino de error y el ser agnóstico de la major de Elysia. Los "generics nativos" que citaba dec-0115 no cuentan: producción no los usa. Si otro repo sigue con Arktype, debe validar con .allows(v) en los hot paths: schema(v) es 50–100× más lento al rechazar.
  • @sinclair/typebox 0.34 como puente hasta Elysia 2 estable (lo que decía la versión anterior de este ADR): consume menos que 1.x, pero obliga a portar dos veces los contratos y a hacer después un segundo salto acoplado al de Elysia. waxin eligió no esperar.
  • TypeBox 1.x con Elysia 1.4: no es viable. Da 500 en runtime (RESULTS §g).
  • .use(autoHead()) en cada app para los healthchecks: ata el despliegue a un plugin del framework; el GET explícito no depende de él.
  • TypeBox sin compilar (Value.Check): consume menos memoria, pero es 10–270× más lento validando.