
Constitución vs Runtime: un monorepo, 16 SaaS verticales, sin fork
El repositorio inite-ecosystem no envía una línea ejecutable, por diseño. Esa separación permite que 16 SaaS verticales compartan infraestructura sin forkearla.
Dos repositorios, una plataforma
Un monorepo que aloja más de un producto choca con el mismo fork tarde o temprano. Una vertical necesita una columna extra en Company, otra necesita saltarse el flujo de auth común para SSO, una tercera quiere su propio handler de webhook de billing. Cada una arranca parchando el código compartido. Cada una termina con la copia vendoreada. La plataforma ahora son siete copias de la misma cosa, divergiendo.
La separación que evita esto es más vieja que la idea de monorepo. Separar contrato de implementación. El contrato es el repositorio inite-ecosystem — JSON Schemas, YAMLs de capability, un manifiesto, un validator. La implementación es inite-shared — 19 paquetes TypeScript, Postgres real, HTTP real. Las verticales (rent, estate, shop, events, health, education, club y las demás entre las 16 del registry) consumen ambos.
El manifiesto del repo-constitución es corto y estructural:
| Prohibido en el repositorio de spec | Permitido en el repositorio de spec |
|---|---|
| Lógica de dominio | JSON Schemas |
Imports @inite/* | YAMLs de capability |
prisma/schema.prisma | Manifiestos de ejemplo |
Componentes .tsx | Validators y generadores |
| Adapters a APIs externas | Fixtures de compatibilidad |
Un pull request que agrega cualquier cosa de la columna izquierda se rechaza al toque. El repo sirve a la spec; la spec sirve a las verticales; las verticales corren el negocio. Ese orden es toda la arquitectura.
Qué contrata el contrato, en concreto
Una vertical declara conformance con un único manifiesto YAML. El formato es chico:
ecosystem: "^0.1.0"
id: inite.rent
name: INITE Rent
version: 1.0.0
domain: car_rental
maturity: production
capabilities:
- { id: inbox, version: "^1.0.0" }
- { id: billing, version: "^1.0.0" }
runtime_packages:
- "@inite/auth"
- "@inite/billing"
entities:
- { id: vehicle }
- { id: reservation }
Ese manifiesto compromete a la vertical con tres cosas. Ring 1 — cinco entidades obligatorias (User, Company, UserCompanyRole, ApiKey, CompanyPermissionOverride) tienen que existir en su schema Prisma o estar declaradas como aliases. Ring 2 — cada capability listada en capabilities: obliga a la vertical a entregar el set completo de entidades que ese capability exige. Declaraste inbox, entregás Conversation y Message en la forma que espera el bundle. Sin eso, no podés listar el capability. Ring 3 — todo lo demás (vehicle, reservation, listing, appointment) es libre. La spec no limita forma, nombres ni semántica.
La herramienta de conformance recorre manifiesto más schema Prisma y produce un reporte present/partial/missing por entidad. El drift aparece como estado en el registry, no como fork silencioso.
Qué se gana operativamente
Tres propiedades aparecen recién cuando la separación está hecha de verdad.
Primero — el upgrade del runtime queda desacoplado del cambio de contrato. Un fix en @inite/auth es un patch de runtime. Llega a cada vertical que consume el paquete, al ritmo de pull de la vertical. La spec no se mueve. No hay migración en los 16 productos. No hay entrada en el CHANGELOG de la constitución.
Segundo — los cambios incompatibles tienen una sola superficie forzada. Quitar un campo de Company es un bump MAJOR de spec. La migración entra al repositorio de spec como carpeta fixtures/migrations/<from>-to-<to>/ con manifiestos antes y después por cada vertical afectada, walkthrough escrito y ventana de deprecation de al menos un MINOR antes de que el schema viejo deje de validar. Las verticales se quedan en el MINOR anterior el tiempo que necesiten — porque la regla de caret semver zero-major trata ^0.1.0 como ~0.1.0, y el manifiesto no flota solo hacia el cambio incompatible. Tienen que hacer opt-in.
Tercero — el registry queda como fuente de verdad sobre la salud de la plataforma. En la versión 0.2.0-rc.4 el registry rastrea 18 verticales en siete buckets de estado:
| Estado | Cant. | Significado |
|---|---|---|
| production | 1 | declara versión ecosystem, cumple contrato, en prod |
| pilot | 2 | calibra la spec activamente |
| migration_pending | 4 | repo existe, intención de conformar, hay gap de modelo |
| drift | 4 | consume inite-shared, todavía sin manifiesto |
| non_conforming | 2 | repo existe, sin shared, sin manifiesto |
| candidate | 3 | todavía no es vertical, en scoping |
| legacy | 2 | reemplazada por otra vertical |
Esa tabla se actualiza en cada release de spec. Una vertical que entra en drift más allá de la ventana acordada cae en el mismo review. Un capability que no llegó a producción en dos releases pasa a revisión. El registry es el dashboard.
Por qué el runtime no es dueño de nada en la spec
Los paquetes de runtime declaran un contract_version en registry/capabilities.yaml. Ocho de los diecinueve están en 1.0.0 — contrato estable, los cambios incompatibles exigen bump MAJOR del runtime. Los once restantes están en 0.1.0 — bundles que todavía se están calibrando, como @inite/accounting y @inite/inbox-ui, donde el contrato no es estructural todavía. La spec no pina versiones de runtime. Solo referencia contratos de paquete. Un capability bundle puede decir @inite/inbox >= 0.5.0, pero nunca == 1.0.3.
La dirección inversa es asimétrica a propósito. El runtime no puede enviar un cambio de contrato — eso solo la spec. El runtime puede enviar una implementación del contrato bajo la spec actual, y esa implementación se mueve en su propio eje de versión. Cuando la spec quiere quitar un campo de Company, sale el MAJOR de spec, el runtime se ajusta, las verticales migran a su ritmo dentro de la ventana de deprecation.
Esto funciona porque en el repositorio de spec no hay nada ejecutable que se pueda romper. No hay servicio que actualizar. No hay base que migrar. No hay comportamiento que testear. El repo son JSON Schemas, YAMLs de capability, ejemplos y validator. Un PATCH de spec es edición de docs. Un MINOR es un campo opcional nuevo en una entidad o un capability nuevo. Un MAJOR es el único lugar donde los cambios incompatibles se escriben — explícitamente, con ejemplos, con ventana de deprecation.
Cómo se ve esto en las verticales reales
inite.rent corre sobre Ring 1 más inbox, billing, incidents y notifications, con un Ring 3 de cerca de veinte entidades de dominio (Vehicle, Reservation, RentalContract, InsurancePolicy, MaintenanceRecord y demás). Declara maturity: production y es la única vertical en ese estado hoy.
inite.estate corre sobre el mismo Ring 1, el mismo inbox y un Ring 3 distinto — Property, Listing, Deal, Showing, Offer. El inbox se comporta igual en las dos porque implementan el mismo contrato; storefront y búsqueda divergen porque Ring 3 es libre.
inite.shop es la que tira más fuerte de storefront y billing — ahí vive la integración con agentic commerce. Ring 3 son Product, Variant, Order, Cart, Customer (aliased de User por el mapa aliases: del manifiesto). Que Customer se llame distinto localmente está registrado en el manifiesto — alcanza para que inite-conform confirme la presencia de Ring 1 incluso con el alias.
Tres verticales distintas. Una spec. Las mismas cinco entidades Ring 1 bajo nombres locales diferentes. El mismo capability inbox entregando los mismos primitivos de conversación. Tres Ring 3 distintos que la spec no ve y no necesita ver. La forma operativa de esta tesis vive en un motor, muchas pieles.
Cuándo este es el formato equivocado
La separación constitución-vs-runtime es el formato equivocado en dos casos. El primero — un producto único que nunca va a ser más que él mismo. Agregar repo de spec, registry y herramienta de conformance a una empresa de un producto es overhead sin retorno. El monorepo común alcanza. El segundo — un portfolio de productos que no comparten nada en la capa de datos. Si el producto A es un juego iOS single-player y el producto B es una plataforma B2B de compras, compartir una entidad Company no es la palanca que parece. La separación paga cuando hay un contrato real y recurrente — SaaS multitenant que comparte identidad, billing, conversaciones y notificaciones — entre productos que parecen distintos por fuera e iguales por dentro.
Para Inite, 16 productos comparten un Ring 1, los ocho Ring 2 capabilities estables y el mismo set de servicios horizontales. La separación se paga muchas veces. Sin ella, cada vertical tendría su propio fork de @inite/auth en un trimestre. Con ella, el registry rastrea drift honesto, la spec se mueve a su ritmo y el runtime envía fixes a todos los productos a la vez. La fábrica detrás de los 16 está descrita en el modelo operativo Inite.
Dos repositorios. 16 productos. Un contrato al que se le permite cambiar de forma deliberada, y un runtime al que se le permite cambiar de forma continua. Ese es el patrón completo. El caso aplicado en deploy real está en el protocolo INITE en 6 etapas.
01¿Por qué un repositorio aparte para la spec? ¿Por qué no mantenerla dentro del runtime como una carpeta?+
Dos razones, ambas estructurales. Primera — en el momento en que la spec vive en el mismo repositorio que código ejecutable, deja de ser contrato y se vuelve documentación. Una carpeta de YAML al lado de TypeScript que todo el mundo importa va a derivar: alguien necesita un campo nuevo, edita el runtime, actualiza el YAML después — y ahora el contrato describe lo que se acaba de enviar en lugar de lo que debería enviarse. Sacar la spec a su propio repositorio sin dependencias de runtime hace ese drift físicamente imposible — no podés editar la spec y el runtime en el mismo commit. Segunda — las verticales necesitan declarar a qué versión de spec se ajustan, separado de qué versión de runtime están consumiendo hoy. Con un repo de spec aparte, el manifiesto de la vertical puede decir ecosystem: ^0.1.0 y esa línea la chequea el validator contra los schemas publicados de la spec, no contra lo que el autor del runtime haya subido esa semana.
02¿Qué impide que una vertical importe internos de @inite/auth y forkee la plataforma de hecho con monkey-patch?+
Técnicamente nada — no hay sandbox. La disciplina la sostienen tres cosas trabajando juntas. Primero: los paquetes de runtime exportan una superficie pública estrecha (para auth: resolveSession, issueJwt, permissions, apiKey) y no los módulos internos. Meterse por detrás de esa superficie se ve en code review. Segundo: la herramienta de conformance (inite-conform) lee el manifiesto de la vertical y recorre el schema Prisma para confirmar que cada entidad Ring 1 declarada existe y que cada capability Ring 2 declarada tiene las entidades que la spec exige — una vertical que hace monkey-patch aparece en el reporte como drift. Tercero: el registry rastrea estado en público. Una vertical que se salió de la superficie pública gana el estado drift en registry/verticals.yaml — ese archivo se revisa en cada release de spec. Nada de esto es a prueba de balas técnicamente, pero el costo social de ser la entrada drift en el registry público no es trivial.
03¿Qué pasa cuando una vertical necesita algo que el runtime todavía no tiene?+
Árbol de decisión de tres ramas, y cada rama corresponde a la capa donde la cosa nueva tiene que vivir. Si la cosa nueva es genuinamente específica de la vertical — por ejemplo, alquiler de autos necesita modelar cobertura de seguro de una forma que ninguna otra vertical va a reusar — va en Ring 3 del schema Prisma de esa vertical, sin cambio en la spec. Si es reusable en al menos dos verticales — un motor de cupones de descuento que rent y shop van a usar — se vuelve candidata a capability bundle Ring 2. Esto significa: primero un PR en inite-ecosystem (spec, schema, ejemplos), después un nuevo paquete @inite/<cosa> en inite-shared (runtime), después las verticales hacen opt-in agregándola al manifiesto. El orden importa: spec antes que runtime, runtime antes que adopción. Si la cosa nueva es estructural — una entidad obligatoria nueva en Ring 1 — dispara un bump MAJOR de spec, con migración escrita para cada vertical y ventana de deprecation de al menos un ciclo de release.
04¿Cómo se hace un cambio incompatible en el runtime sin romper los 16 productos de golpe?+
Tres puntos de disciplina cubren la mayoría de los casos. Los paquetes de runtime declaran un contract_version en el registry (1.0.0 para los ocho estables, 0.1.0 para los bundles todavía en calibración). Un cambio incompatible dentro de un paquete 1.x.y es un bump MAJOR de ese paquete, es decir cada vertical pina el major anterior en su package.json y actualiza de forma deliberada, no transitiva. Cambios incompatibles entre paquetes — los que tocan la spec — pasan por el proceso MAJOR de spec: entrada en CHANGELOG, carpeta fixtures/migrations/<from>-to-<to>/ con manifiestos antes y después por cada vertical afectada, y ventana de deprecation de al menos un MINOR antes de que el schema viejo deje de validar. Las verticales se quedan en el MINOR anterior el tiempo que necesiten, porque el manifiesto está pineado en ecosystem: ^0.1.0, y ^0.1.0 no flota a 0.2.0 — por la regla de caret zero-major, ^0.1.0 se trata como ~0.1.0. Tienen que optar por el cambio.
05¿No es esto solo una forma elegante de decir monorepo nx más algo de YAML?+
No. Nx, pnpm workspaces, Turborepo, Bazel resuelven orquestación de build — cómo correr tests, cómo compartir código, cómo cachear. No dicen nada sobre cómo tiene que verse un producto SaaS multitenant. La separación constitución-vs-runtime hace algo que esas herramientas no se proponen: responde la pregunta '¿qué significa que un producto sea ciudadano de esta plataforma?' de una forma que es chequeada por herramienta. El contrato Ring 1 lo enforced inite-conform leyendo tu schema Prisma. Los contratos Ring 2 los enforced la verificación de que implementás las entidades que cada capability declarada exige. Los imports de runtime los enforced el registry: runtime_packages del manifiesto tiene que ser subconjunto del registry, es decir no podés empezar a consumir en silencio un paquete que la spec no bendijo. Esto se monta encima de cualquier herramienta de monorepo, incluida nx. No la reemplaza — es la capa de arriba.