Skip to content

Decisiones para el refinamiento

Estado: decisiones vigentes. La revisión 5 implementa la fundación multi-business; las separaciones físicas de verticales y el backend continúan pendientes. Fecha de revisión: 2026-09-16.

D01. Plataforma y stack

Monolito modular en NestJS 12 / TypeScript sobre Node.js 24 con PostgreSQL. Organización, identidad, acceso y capacidades constituyen la base. Catálogo, inventario, ventas y pagos son módulos de negocio reutilizables, no requisitos funcionales de cualquier tenant.

La instalación limpia de Ninaku Core requiere PostgreSQL 18. La imagen inicial de PostgreSQL 17 es histórica. El workflow ejecuta los SQL en PostgreSQL 18 nativo y registra la versión menor efectiva en el log; las pruebas históricas no sustituyen esa ejecución. No seleccionar ORM, broker, proveedor de nube o framework de CQRS en este PR.

D02. Una definición canónica por schema

Cada schema tiene un archivo fuente propietario. Tablas, restricciones, funciones y políticas deben quedar trazables a ese propietario. Infraestructura como extensiones y orquestación de instalación es una excepción técnica identificada, no un nuevo dominio.

El baseline todavía incumple la concentración por archivo en 0050, 0170, 0300 y 0400. Se conserva íntegro en esta entrega. No se eliminarán FK para simular independencia.

Procedimiento para el próximo cambio: mover primero definiciones sin dependencias posteriores, ubicar puentes en un propietario de integración explícito y calcular el orden real. Si una regla restante requiere una fase tardía, definir su fuente en el archivo del propietario y una instalación por fases con llamadas explícitas o artefactos derivados reproducibles. No introducir un runner complejo antes de probar un caso real. Nunca otorgar acceso runtime a una instalación parcial sin seguridad finalizada. Un instalador final solo orquesta; no mantiene otra copia manual de reglas.

D03. Independencia tiene tres niveles

  1. Comercial: habilitar capacidades por organización.
  2. Aplicación: módulos colaboran mediante contratos públicos y conservan sus escrituras.
  3. Instalación: un módulo no exige físicamente tablas de verticales ausentes.

El baseline implementa estructura para capacidades, pero sus FK aún fuerzan dependencias amplias. Una columna nullable o una capacidad desactivada no elimina una dependencia DDL. El objetivo inmediato es demostrar core e inventario sin cocina; no prometer todas las combinaciones de 38 schemas.

D04. Organización, propiedad y colaboración

Organization es el límite tenant. Business Unit modela una unidad de negocio administrativa/operativa y Vertical declara capacidades funcionales; no son el mismo concepto. Legal Entity identifica la entidad legal; Brand, la identidad comercial; Site, el lugar; Outlet, la unidad operativa. Las asignaciones efectivas Outlet→Business Unit y Outlet→Legal Entity conservan vigencia. Sales congela ambas dimensiones y Payments congela Legal Entity.

Un tenant puede combinar verticales. Dos organizaciones independientes no comparten acceso por pertenecer a Ninaku ni por tener el mismo propietario humano. Relaciones B2B requieren aceptación, alcance, revocación y mensajes autorizados. No exponer IDs, datos personales ni costos internos fuera del contrato.

D05. Inventario

El historial de cantidad y valoración conserva la autoridad. Los saldos son reconstruibles; el saldo usado para admitir una salida debe ser coherente dentro de la transacción o derivarse bajo el bloqueo adecuado. Una proyección asíncrona sirve para reportes, no para garantizar capacidad.

Conservar unidades/incrementos explícitos, precisión exacta, historial de primer uso, lotes, tránsito, conteos y diferencias. Separar transferencia en una entidad legal de operaciones entre entidades. Registrar correcciones por movimientos/contrapartidas según el contrato, sin reescribir significado histórico.

D06. Offline por operación

El cliente registra comandos y resultados locales durables. Sync no duplica reglas del dominio. El servidor aplica el mismo contrato usado online y distingue hechos ya ocurridos de intenciones todavía rechazables. Una orden offline_replay debe referenciar un comando Sync tipado del módulo Sales, con dispositivo, Outlet y tiempo efectivo coherentes; una marca booleana aislada no basta.

La desconexión impide garantizar disponibilidad global si varios dispositivos gastan la misma capacidad sin coordinación. Elegir reconciliación, asignación previa exclusiva u obligación de estar online por operación. La revocación de permisos no puede ser instantánea en un dispositivo aislado: definir autorización offline limitada, vigencia y tratamiento de hechos registrados durante la desconexión. No confiar solo en la hora del dispositivo.

D07. SQL y el caso de uso

SQL canónico conserva integridad, RLS y mecanismos de concurrencia. El caso de uso en Application coordina autorización, transacción y consecuencias. Los resolvers ya presentes en SQL se conservan hasta decidir explícitamente si se trasladan; no duplicar su autoridad en Application.

El ORM futuro no genera un modelo alternativo ni ejecuta DDL al arrancar. Todo adaptador participante en una operación atómica comparte conexión/transacción y contexto tenant. Persistencia de contexto con parámetros y alcance transaccional; separar autoridad de onboarding de la operación ordinaria.

D08. Validación vinculada a la fuente

Evidencia = commit + archivos/checksums + entorno + escenario + resultado. Una cuenta histórica de pruebas no valida este repositorio. Distinguir revisión estática, instalación, comportamiento, carga, recuperación y producto completo.

La revisión 5 añade pruebas nativas adversariales para la fundación multi-business, ownership de Payments, bridges Cash/Treasury, replay offline y provisión concurrente. Los escenarios aún no cubiertos continúan pendientes y no se consideran certificados por analogía.

D09. Clean Architecture por módulo

Una aplicación desplegable con límites de módulo y dependencias dirigidas hacia Domain/Application. Contratos entre módulos en proceso; no acceso a repositorios, SQL, clientes PostgreSQL ni infraestructura interna de otro módulo. El host compone y las transacciones coordinadas conservan una única unidad de trabajo cuando la regla exige atomicidad. La especificación vigente está en MODULE_CONTRACTS.md.

D10. API por caso de uso y presupuesto de pantalla

Máximo dos solicitudes de datos para carga inicial normal con sesión válida; contenido acotado, paginación y consultas adicionales bajo demanda. Compositores de lectura evitan que el frontend coordine veinte APIs. Escrituras por acciones de negocio, idempotentes donde producen efectos, errores Problem Details y contratos OpenAPI explícitos. No confundir pocas llamadas HTTP con pocas consultas SQL.

D11. Moneda e idioma independientes

Conservar moneda de documento/pago y moneda funcional por ledger, con snapshots de conversión y reglas de redondeo explícitas. Locale de interfaz, contenido traducido y documentos tienen resoluciones distintas. No inferir moneda o zona horaria del idioma. El análisis actual registra un riesgo concreto de redondeo FX pendiente de resolver con pruebas de negocio.

D12. Destino de telemetría

Grafana Cloud es el destino elegido para trazas y métricas OTLP, mediante el gateway de la región prod-sa-east-1. Staging ya lo configura con OTEL_EXPORTER_OTLP_ENDPOINT (URL base que termina en /otlp; el SDK añade la ruta de cada señal) y OTEL_EXPORTER_OTLP_HEADERS. La cabecera debe ser el par completo Authorization=Basic <base64>, nunca solo el valor base64: el parser exige la forma nombre=valor por cada entrada separada por comas y hace fallar el arranque nombrando OTEL_EXPORTER_OTLP_HEADERS si falta el =, el nombre o el valor, en vez de dejar la exportación sin autenticar con un 401 silencioso. Muestreo y retención siguen sin decidir.

D13. Compatibilidad de contrato OpenAPI

Un cambio incompatible en el OpenAPI publicado falla el CI salvo que el componente major de info.version haya subido respecto al baseline. Subir el major es la forma explícita y autodocumentada de aceptar una ruptura de contrato: no depende de una excepción manual ni de que alguien recuerde justificarla, y deja constancia del cambio en el propio documento.

D14. Severidad de logs para 404

Un 404 por ruta no reconocida se registra en info; un 404 por ruta reconocida pero recurso no encontrado permanece en warn. warn se reserva para lo que una persona puede necesitar atender: si el rastreo automático de rutas inexistentes también fuera warn, el ruido de escáneres y bots ahogaría la señal real.

D15. Checks requeridos en main y staging

build-test, sql-regressions, railway-and-migrator, pre-foundation-integrity y openapi-compatibility son los checks que deben quedar requeridos en main y staging. Un job que corre sin ser requerido no bloquea un merge roto; no es un gate. Hoy ninguno lo es: los rulesets están inertes por el plan de la organización, como explica D20, así que estos cinco se ejecutan sin bloquear nada.

D16. Flujo de entrega

El trabajo entra por pull requests independientes hacia staging, cada una acotada a 400 líneas cambiadas y fusionada en orden. Ninguna apunta a otra: ese flujo exige un reapuntado manual después de cada fusión, y un reapuntado olvidado ya dejó trabajo fusionado fuera de staging, que hubo que recuperar con una pull request adicional. staging se fusiona a main con merge commit, nunca squash, para conservar el historial de cada pull request. Con el CI de main en verde, staging avanza en fast-forward hasta el commit de fusión de main, de modo que ambas ramas quedan en el mismo punto y la siguiente tanda de trabajo parte de una base idéntica.

D17. Resolución de la versión de servicio

La versión de servicio expuesta en cada registro de log y span se resuelve como SERVICE_VERSION explícita; si falta, RAILWAY_GIT_COMMIT_SHA truncada a 12 caracteres; si tampoco existe, unknown. El orden asegura que un span pueda rastrearse hasta el build que lo produjo sin depender de que alguien la declare manualmente en cada entorno.

D18. Contratos públicos entre módulos

Cada módulo de negocio posee una superficie pública explícita bajo contracts/. Otro módulo puede consumir esa superficie, pero no puede importar repositorios, SQL, infraestructura, handlers internos ni tipos del driver PostgreSQL del módulo proveedor. Contract significa lo que el módulo ofrece; Port significa lo que el módulo necesita y permanece interno salvo decisión explícita.

La colaboración se divide en tres modos y no se unifica en un bus genérico: contrato síncrono en proceso cuando el resultado es necesario para completar el caso de uso, read contract para composiciones de lectura, y evento durable mediante outbox/inbox para consecuencias desacoplables o recuperables. No hay HTTP interno entre módulos del mismo monolito.

Cuando una invariancia exige atomicidad entre varios propietarios, los contratos participantes pueden compartir una sesión/transacción Foundation opaca y tenant-safe; esa superficie no expone pg.PoolClient. Cada módulo conserva sus propias escrituras e invariantes. Los efectos externos nunca se consideran atómicos con PostgreSQL y usan orquestación recuperable.

Los contratos usan lenguaje de negocio y tipos command/query/result explícitos. No exponen entidades ORM, filas crudas, query builders, SDKs externos ni operaciones CRUD universales. Los verticales consumen exactamente los mismos contratos y no son un atajo para escribir tablas de módulos compartidos.

npm run lint:module-boundaries bloquea en CI los imports cross-module hacia domain/, application/, infrastructure/ o presentation/ de otro módulo y permite únicamente su superficie pública documentada, desde antes de que exista el primer módulo real. La convención completa y criterios de aceptación viven en MODULE_CONTRACTS.md.

D19. Alcance del CI por lo que cambia

En las pull requests, cada workflow se ejecuta solo si la petición toca los archivos de los que depende. En los push a main y staging no hay filtro alguno: las ramas de registro se verifican siempre completas.

La decisión se tomó con medición, no por intuición. En una jornada de 29 commits el repositorio consumió unos 119 minutos de ejecución, de los cuales 79 correspondieron a database e infrastructure; en esos mismos 29 commits no hubo un solo cambio bajo database/ ni bajo .railway/. Dos tercios del gasto se fueron en verificar entradas que nadie había tocado.

Los filtros se derivan de lo que cada workflow ejecuta realmente, no de lo que su nombre sugiere. Todos los scripts db:* viven bajo database/ y iac:typecheck bajo .railway/, de modo que sus filtros son directos. Hay dos dependencias que no son evidentes y que, omitidas, producirían verdes falsos: app ejecuta db:prepare antes de integración y e2e, así que depende de database/, y su suite unitaria cubre los specs de tooling/; infrastructure construye la imagen del migrador desde database/Dockerfile, así que también depende de database/.

repository queda sin filtrar a propósito. Es el más barato de los cuatro y es el portón estructural: openapi:compat necesita ver todo cambio para detectar deriva del contrato, y un filtro lo volvería ciego justo cuando importa.

Cada workflow declara además un grupo de concurrencia que cancela ejecuciones superadas, pero solo en pull requests. Un push a main o staging nunca se cancela.

Trampa a recordar si vuelven los rulesets: un workflow con filtro de rutas que además sea check requerido nunca reporta en las pull requests que omite, y esa pull request no puede fusionarse jamás. Hoy la combinación no se da porque no hay ningún ruleset activo, como explica D20. Cuando vuelvan, hay que sacar esos checks de la lista de requeridos o agregar un job complementario con el mismo nombre y el filtro inverso que simplemente tenga éxito.

D20. Hoy no hay protección de rama

La organización está en plan free con el repositorio privado, así que GitHub responde 403 Upgrade to GitHub Pro or make this repository public tanto en la API de rulesets como en la protección clásica de rama. No hay ruleset activo, no hay check requerido, y nada rechaza un force push, un merge sin revisar ni un merge con checks en rojo o ausentes.

El flujo de entrega de D16 y de RAILWAY_OPERATIONS.md sigue siendo el correcto. Hoy se sostiene porque las personas lo siguen, no porque algo lo imponga, y conviene decirlo en vez de suponer una compuerta que no existe: quien confía en que el sistema lo va a frenar deja de frenarse solo.

Para que vuelva a ser una compuerta hacen falta tres cosas: subir el plan o hacer público el repositorio, declarar los dos rulesets que describe RAILWAY_OPERATIONS.md, y marcar como requeridos los cinco checks de D15. La trampa de los filtros de rutas frente a los checks requeridos está en D19.

D21. Una organización activa conserva administración

Un administrador válido es un principal humano activo, con membresía activa, un rol activo del tenant o global, el permiso activo organization.account.manage y alcance de toda la organización. Varias personas pueden cumplirlo a la vez; la garantía es que quede al menos una.

La garantía empieza cuando platform.organization_provisioning_requests registra la admisión como completada. Una fila de organization.organizations creada sin esa admisión no es una organización habilitada para operar, y no se infiere administración desde el nombre de un rol.

Se rechaza al confirmar la transacción cualquier camino que deje sin administrador a una organización activa y admitida: borrar la última asignación administradora, suspender o terminar su última membresía, reducir su alcance a una sucursal o a otra dimensión, bloquear al último principal, desactivar o mover su rol, quitarle el permiso administrativo, renombrar o deprecar ese permiso, y reactivar una organización suspendida que no recuperó administración. Borrar la evidencia de admisión tampoco elude el control: una fila de provisioning completada es inmutable.

Una organización suspendida o archivada conserva su historia sin administrador activo. Eso nunca la habilita para operar. Bloquear a una persona en toda la plataforma y suspender una de sus membresías siguen siendo decisiones distintas.

identity.assert_administration_preserved (database/canonical/0400_cross_domain_contracts.sql) levanta 23514 con CONSTRAINT='identity_last_administrator_required'. Nueve triggers DEFERRABLE INITIALLY DEFERRED la invocan sobre identity.identities, identity.roles, identity.permissions, identity.role_permissions, identity.memberships, identity.role_assignments, identity.access_scopes, organization.organizations y platform.organization_provisioning_requests. Que sean diferidos es lo que permite reemplazar administración dentro de una misma transacción sin pasar por un estado inválido, y un pg_advisory_xact_lock por organización es lo que impide que dos transacciones concurrentes se quiten el último administrador entre sí. database/tests/contracts/administration-contracts.ts cubre cada uno de esos caminos.

Por qué está escrita aquí. Esta garantía se aplicaba en SQL y no se podía leer en ningún documento vigente: su definición vivía en un archivo de historial que se borró. Un invariante que nadie puede leer se rompe en el primer módulo que lo desconozca, y este cruza tres esquemas.

D22. Origen de la policy de verificación y TOTP, y quién posee sus límites

AppConfigService.outOfBandCode y AppConfigService.totp exponen la configuración de entorno como valores tipados sin validar su rango de negocio: el esquema de Foundation (environment-variables.schema.ts) solo verifica forma (texto, entero, entero no negativo), nunca formato conocido, longitud, dígitos RFC 6238, algoritmo o duración de paso. Foundation no puede importar del dominio de Identity (regla R4 de MODULE_CONTRACTS.md), así que no hay una segunda copia de esos límites para mantener sincronizada.

El dominio de Identity es el único dueño de esos límites: buildOutOfBandCodePolicy (verification-code.ts) exige un formato de OUT_OF_BAND_CODE_FORMAT y una longitud entera entre OUT_OF_BAND_CODE_MINIMUM_LENGTH (6) y OUT_OF_BAND_CODE_MAXIMUM_LENGTH (12); buildTotpPolicy (totp-policy.ts) exige dígitos y algoritmo RFC 6238, un paso entero entre TOTP_STEP_SECONDS_MINIMUM (15) y TOTP_STEP_SECONDS_MAXIMUM (120), y un desfase entero entre 0 y TOTP_ALLOWED_SKEW_STEPS_MAXIMUM (2). Un paso más corto que 15 segundos rota el código más rápido de lo que una persona puede leerlo y escribirlo; uno más largo que 120 segundos deja un código capturado válido el tiempo suficiente para debilitar "posesión ahora" a "posesión en algún momento de los últimos dos minutos". Cada paso adicional de desfase ensancha linealmente el conjunto de códigos que el servidor acepta a la vez, la misma tolerancia de la que se beneficia un atacante; un paso (la propia tolerancia de RFC 6238 para el retraso de transmisión) es el default, y dos sigue siendo una excepción acotada para redes lentas o relojes visiblemente desincronizados, nunca una ventana sin límite.

VerificationPolicyService (infrastructure/verification/verification-policy.service.ts) construye ambas políticas una sola vez, en su constructor, llamando a esos dos builders con los valores crudos de AppConfigService. Como Nest instancia todos los providers de un módulo cargado de forma no perezosa al compilar el módulo, un valor fuera de esos límites lanza ahí mismo, antes de que la aplicación termine de arrancar: la validación de forma en Foundation nunca es la última palabra, y no hace falta duplicar el límite de negocio para conseguir el mismo arranque fallido. identity.module.spec.ts prueba exactamente este camino para cada límite.

VerificationPolicyPort (application/ports/verification-policy.port.ts) es la única puerta por la que un caso de uso llega a estas políticas: emitir o verificar un código por email/WhatsApp, y construir o verificar un TOTP, siempre pasan por this.verificationPolicy.outOfBandCodePolicy(), .totpPolicy() o .totpPolicyForMethod(stored), nunca por una constante propia ni por AppConfigService directamente. Cuando llegue el override por organización (tabla en PostgreSQL, junto al módulo organization), sustituye únicamente la implementación de este puerto; ningún caso de uso cambia.

OutOfBandCodeSchema (presentation/dtos/out-of-band-code.schema.ts) y los esquemas de código TOTP (second-factor-confirmation-request.schema.ts, sign-in-second-factor-request.schema.ts) validan un superconjunto de toda policy posible (6 a 12 caracteres; 6 u 8 dígitos), no la forma exacta de la policy vigente: un código emitido antes de un cambio de policy conserva esa forma, así que Presentación sigue aceptándolo hasta que expira por sí solo, y solo el hash comparado en Aplicación decide si es correcto. verification-code-policy.e2e-spec.ts prueba que un código así emitido sigue confirmando después de que la aplicación reinicia con otra policy, y que el presupuesto de intentos del challenge y el contador de bloqueo de cuenta no se reinician por ese cambio, porque viven en PostgreSQL y el cambio de policy es puramente un valor en memoria de la aplicación.

Un cambio de policy de TOTP nunca invalida un método ya inscrito. identity.totp_methods (database/canonical/0040_identity.sql, migración 0012_totp_methods_remember_their_policy.sql, baseline revisión 22) guarda digits, algorithm y period_seconds junto a cada método, NOT NULL con default igual al comportamiento de hoy (6, SHA1, 30) y con CHECK idénticos a los límites del dominio (totp_methods_digits_chk, totp_methods_algorithm_chk, totp_methods_period_seconds_chk). protect_totp_lifecycle() extiende la misma inmutabilidad que ya protegía secret_ref: ninguna de las tres columnas puede cambiar después de la inscripción, solo una re-inscripción las reemplaza. La inscripción (EnrolSecondFactorUseCase) guarda la policy global vigente en ese momento junto al secreto; la confirmación (ConfirmSecondFactorUseCase) y el segundo paso del inicio de sesión (CompleteSignInSecondFactorUseCase) verifican cada método contra totpPolicyForMethod(stored) — la policy que ese método tiene grabada, combinada con el allowedSkewSteps global vigente, porque el desfase es tolerancia del servidor, no forma del autenticador — nunca contra totpPolicy(), la policy global "para inscripciones nuevas". totp-policy.e2e-spec.ts prueba contra PostgreSQL real que una aplicación autenticadora inscrita en 6/SHA1/30 sigue iniciando sesión con su código de 6 dígitos después de que la aplicación reinicia configurada en 8/SHA256/60, y que una inscripción nueva hecha después del reinicio recibe la policy nueva.

Application Foundation in progress. Tracked in issue #13.