Skip to content

Contratos entre Módulos (MODULE_CONTRACTS.md) ​

Estado: Contrato arquitectónico normativo para ninaku-app-api.

Este documento define las reglas estrictas de colaboración entre módulos de negocio dentro del monolito modular de Ninaku. Complementa a ARCHITECTURE.md y consolida la especificación definitiva para las fronteras entre módulos en tiempo de compilación.

El objetivo es preservar una propiedad inmutable de los datos y hechos de negocio, manteniendo la colaboración de forma explícita, fuertemente tipada, comprobable y comprensible. Ninaku es una sola aplicación desplegable por defecto; los límites entre módulos no implican llamadas HTTP internas ni una base de datos independiente por módulo.


1. Regla Principal ​

Un módulo de negocio puede exponer sus capacidades funcionales únicamente a través de contratos públicos explícitos bajo su carpeta contracts/. Ningún módulo tiene permitido acceder a repositorios internos, consultas SQL, servicios de infraestructura o manejadores de aplicación de otro módulo.

Cada hecho de negocio tiene un solo módulo propietario. La colaboración entre módulos transfiere información o solicita acciones, pero nunca transfiere la propiedad del dato.

ts
// ❌ INCORRECTO: Violación de fronteras (acceso directo a repositorio ajeno)
class ConfirmSaleUseCase {
  constructor(
    private readonly inventoryRepository: InventoryRepository,
    private readonly paymentRepository: PaymentRepository,
  ) {}
}

// ✅ PREFERIDO: Colaboración explícita mediante Contratos Públicos
class ConfirmSaleUseCase {
  constructor(
    private readonly inventory: InventoryContract,
    private readonly payments: PaymentsContract,
  ) {}
}

En este ejemplo, Sales solicita a Inventory que ejecute una capacidad de reserva. Sales nunca escribe ni muta directamente el estado de Inventory.


2. Disposición Interna de un Módulo ​

Un módulo de negocio evoluciona hacia la siguiente estructura cuando sus responsabilidades realmente existen:

text
src/modules/inventory/
├── domain/                  <-- Reglas de negocio puras, agregados y eventos de dominio
├── application/             <-- Casos de uso, orquestación y puertos internos
├── infrastructure/          <-- Adaptadores de persistencia, proveedores y servicios
├── presentation/            <-- Controladores HTTP, DTOs y catálogo de fallos públicos
├── contracts/               <-- SUPERFICIE PÚBLICA IN-PROCESS DEL MÓDULO
└── inventory.module.ts      <-- Módulo NestJS que declara providers y exports

La carpeta contracts/ representa la superficie pública que otros módulos tienen permitido consumir. Todo lo demás (domain/, application/, infrastructure/, presentation/) es privado e interno del módulo.

  • Módulos pequeños: Pueden mantener un directorio de contratos plano:
    text
    contracts/
    ├── inventory.contract.ts
    └── inventory.contract.types.ts
  • Módulos grandes: Pueden categorizar sus archivos de contrato:
    text
    contracts/
    ├── commands/
    ├── queries/
    ├── results/
    ├── events/
    └── inventory.contract.ts

Nota sobre errores HTTP: El catálogo de fallos públicos (PublicFailure) no vive en contracts/. Los códigos de error públicos son consumidos por clientes HTTP externos, no por otros módulos de NestJS. Por ende, residen en presentation/failures/.

Un contrato publica decisiones y códigos de negocio (por ejemplo OperationDecision, el valor de la negativa y el error OperationRefusedError), nunca su traducción HTTP. El módulo que consume una decisión la traduce a PublicFailure en su propia capa presentation/, en su propio límite HTTP. La traducción de referencia de las negativas de autorización de operación (operationRefusalFailure y OPERATION_REFUSAL_FAILURE: 403 authorization.scope_unavailable, 403 platform.capability_not_entitled, 422 authorization.outlet_not_operational, 422 authorization.place_not_operational, 422 authorization.vertical_inactive) vive en src/modules/platform/presentation/failures/operation-refusal-failure.ts, y hoy solo la usa el fixture de prueba test/fixtures/operation-authorization/; catalog traduce la suya en src/modules/catalog/presentation/failures/to-public-catalog-failure.ts, donde scope_unavailable responde 403 authorization.scope_unavailable, capability_not_entitled responde 403 platform.capability_not_entitled y place_not_operational responde 422 authorization.place_not_operational; cualquier otra negativa responde igual que la denegación simple catalog.action_forbidden. place_not_operational se traduce desde la tajada K6 porque el catálogo ya tiene operaciones ancladas en una unidad de negocio (platform.authorize_operation_at): fijar el modo del surtido o listar artículos en una unidad inactiva es un rechazo de estado sobre un lugar que el llamador sí puede ver, no una falta de permiso, y se responde como en el resto de la plataforma.


3. Contrato vs. Puerto ​

Los términos Contrato (Contract) y Puerto (Port) no son intercambiables en la arquitectura de Ninaku Core.

A. Contrato Público de Módulo (Contract) ​

Un Contrato describe lo que un módulo ofrece a otros módulos de Ninaku.

text
Sales Application Use Case
    ↓ (consume)
PaymentsContract (Clase Abstracta bajo src/modules/payments/contracts/)
    ↓ (implementado por)
PaymentsApplicationService
ts
// src/modules/payments/contracts/payments.contract.ts
export abstract class PaymentsContract {
  abstract authorize(input: AuthorizePaymentInput): Promise<AuthorizePaymentResult>;
}

B. Puerto de Aplicación / Dominio (Port) ​

Un Puerto describe una dependencia que el propio módulo requiere de la infraestructura técnica o de un proveedor externo.

text
Payments Application
    ↓ (requiere)
PaymentGatewayPort (Interfaz bajo src/modules/payments/application/ports/)
    ↓ (implementado por)
DatafastAdapter (Servicio bajo src/modules/payments/infrastructure/)
ts
// src/modules/payments/application/ports/payment-gateway.port.ts
export interface PaymentGatewayPort {
  authorize(input: GatewayAuthorizationInput): Promise<GatewayAuthorizationResult>;
}

Regla nemotécnica:

  • Contrato = Lo que el módulo OFRECE hacia afuera (contracts/).
  • Puerto = Lo que el módulo NECESITA desde adentro (application/ports/).

4. Modos de Colaboración Permitidos ​

Ninaku utiliza tres modos principales de colaboración en proceso. Resuelven problemas distintos y no se deben colapsar en un bus genérico de mensajería.

4.1 Contrato Síncrono en Proceso ​

Se utiliza cuando el módulo llamador necesita el resultado inmediato para completar su caso de uso.

text
Confirm Sale Use Case
    ↓
InventoryContract.reserveStock(...)
    ↓
Inventory Application
  • La ejecución se realiza dentro del mismo proceso de NestJS (sin llamadas HTTP internas).
  • Cuando la invariante de negocio exige atomicidad entre varios propietarios, la llamada puede participar en la misma transacción de PostgreSQL compartiendo un objeto opaco TransactionContext provisto por Foundation.

4.2 Contrato de Lectura (Read Contract) ​

Se utiliza cuando un módulo o un compositor de consultas de pantalla necesita información perteneciente a otro módulo.

text
POS Workspace Query Composer
   ├── CatalogReadContract
   ├── PricingReadContract
   ├── InventoryReadContract
   └── CustomerReadContract
  • Los contratos de lectura retornan DTOs de proyección orientados a tareas.
  • No exponen entidades del ORM, firmas de repositorios ni SQL builders.

4.3 Evento Durable / Outbox ​

Se utiliza para consecuencias que no necesitan completarse en forma síncrona con el caso de uso iniciador, o que cruzan un límite externo/recuperable.

text
Sales Transaction
    ├── Actualiza estado propiedad de Sales
    └── Inserta evento de integración SaleConfirmed en la tabla outbox
             ↓ COMMIT (misma transacción de PostgreSQL)
      Worker de despacho (servicio Railway separado)
      reclama el lote con FOR UPDATE SKIP LOCKED + lease
             ↓
      Procesadores de eventos interesados (Notifications, Analytics, etc.)
  • Las llamadas a redes o servicios externos nunca se ejecutan dentro de la transacción SQL de PostgreSQL, ni dentro del proceso de la API. La fila de outbox se inserta en la misma transacción que el cambio de negocio; un worker separado (no BullMQ, no Redis) la reclama de PostgreSQL con FOR UPDATE SKIP LOCKED y un lease, reintenta con backoff acotado y deja dead letters cuando agota los reintentos — diseño completo en DECISIONS.md D26 y OUTBOX_USE_CASES.md.

4.4 Función SQL de Lectura Publicada (excepción normativa) ​

Una lectura caliente de una sola sentencia no puede partirse en una llamada a un contrato sin perder su única instantánea: el ETag y el cuerpo de la lectura del punto de venta, la búsqueda, el escaneo, resolve y admit de CatalogSellableContract salen de la misma sentencia. Solo esas lecturas pueden llamar desde su SQL a una función de lectura que el módulo propietario publica para eso, nunca a sus tablas; todo lo demás usa el contrato TypeScript.

  • La función la escribe el propietario en su propia migración, es STABLE y SECURITY INVOKER (la seguridad por fila de sus tablas se aplica a quien la llama), fija search_path, recibe la organización como primer argumento y filtra por ella, se revoca de PUBLIC y se concede solo a ninaku_runtime (también en database/access/runtime_grants.sql), y tiene su script de contratos.
  • Una fila de otra organización responde igual que una desconocida, también si se la nombra con su propia organización.
  • Funciones publicadas hoy (revisión 102, migración 0092): organization.outlet_operating_context(p_organization_id uuid, p_outlet_id uuid), que responde outlet_id, outlet_status, site_local_date (la fecha del sitio: now() en su zona horaria), business_unit_id (la unidad que opera el outlet en esa fecha, o NULL), structure_version y, desde la revisión 117 (migración 0107, rebanada S1), site_timezone (la zona horaria del sitio del outlet, al final de las columnas: las anteriores no cambian de nombre, tipo ni orden), o ninguna fila; y organization.business_unit_is_operable(p_organization_id uuid, p_business_unit_id uuid), verdadera solo para una unidad active de esa organización. organization.effective_business_unit ya seguía esta regla. Prueba: database/tests/contracts/organization-published-reads-contracts.ts.
  • Desde la rebanada K10b las usa catalog: la lista y la búsqueda del punto de venta, el escaneo, resolve y admit preguntan (SELECT organization.business_unit_is_operable(...)) como subconsulta escalar (PostgreSQL la resuelve una vez por sentencia cuando sus argumentos son parámetros) y leen el outlet, la unidad que lo opera hoy, la versión de la estructura y, desde la rebanada S1, la zona horaria del sitio (con ella evalúan las ventanas horarias de las cartas del outlet sin leer tablas de organization) de organization.outlet_operating_context, sin sentencias nuevas. El bloqueo compartido de la unidad en admit pasa por OrganizationContextContract.shareBusinessUnit, y las lecturas que no son calientes usan los contratos: businessUnitStates en las puertas de lectura, el surtido y el filtro listed de la lista de artículos, y activeBusinessUnits con BusinessUnitVerticalsContract.activeVerticals en el aviso de verticales del espacio de trabajo. npm run lint:sql-ownership vigila la regla (sección 10).

4.5 Función SQL de Escritura de Eventos Publicada (excepción normativa) ​

Un módulo que debe anunciar un cambio en la misma transacción que lo hace no escribe las tablas de events: llama a la función que Events publica para eso.

  • events.append_tenant_event(p_producer_module_code, p_event_name, p_contract_version, p_idempotency_key, p_payload) (revisión 124, migración 0114) es SECURITY DEFINER con search_path fijado, exige contexto de tenant, toma la organización del contexto (nunca de un argumento), exige que el tipo de evento pertenezca al módulo productor y tenga una versión de contrato published o deprecated, y escribe el evento, su publicación y su fila de outbox en la transacción de quien llama. Se revoca de PUBLIC y no se concede a ninaku_runtime ni a ninaku_worker: la llama un disparador SECURITY DEFINER del propio productor, de modo que el rol de la API no tiene ningún privilegio sobre el esquema events.
  • El productor declara sus tipos en su propia migración (events.event_types con su owner_module_code y events.event_contract_versions), con el contrato ejecutable de la carga en su módulo.
  • La llama el código SQL del propio productor en su esquema (un disparador SECURITY DEFINER con search_path fijado, como catalog.record_change_feed()), no el TypeScript de src/: npm run lint:sql-ownership sigue vigilando el SQL de src/.
  • Orden de bloqueos: la función toma el contador de publicaciones de la organización. Un productor que ya tenga una fila de versión u otro bloqueo de dominio lo toma después de ellos (Catalog: la fila de versión, luego el contador); quien emita eventos desde otro módulo no puede tomar el contador antes de llamar a CatalogSellableContract.admit (D40).
  • Funciones publicadas hoy: events.append_tenant_event, usada por catalog.record_change_feed() para los nueve tipos catalog.*.changed.

5. Diseño de Contratos Públicos ​

Un contrato público debe:

  • Usar lenguaje e intenciones de negocio, no nombres de tablas SQL.
  • Exponer una cantidad acotada y significativa de operaciones.
  • Utilizar tipos explícitos para entradas (Input), comandos y resultados (Result).
  • Hacer explícito el contexto de tenant (organizationId, businessUnitId) cuando sea necesario.
  • Evitar la fuga de detalles de tecnología de persistencia o SDKs de terceros.
ts
// ✅ PREFERIDO
inventory.reserveStock(input)
payments.authorize(input)
pricing.resolvePrice(input)

// ❌ RECHAZADO
inventory.save(entity)
payments.execute('action', payload)
repository.findAll(table, filters)

6. Lo Que Los Contratos NUNCA Deben Exponer ​

Los siguientes elementos son detalles internos de implementación y tienen prohibido formar parte de la superficie de un contrato público:

  1. Instancias de pg.Pool o pg.PoolClient.
  2. Constructores de consultas SQL (Kysely, Knex) o fragmentos de SQL crudo.
  3. Repositorios de persistencia pertenecientes al módulo.
  4. Entidades o modelos del ORM/base de datos.
  5. Tipos de peticiones/respuestas directos de SDKs de terceros.
  6. Operaciones CRUD genéricas que permitan a otro módulo mutar libremente tablas ajenas.

7. Convención de Composición en NestJS ​

Dado que las interfaces de TypeScript no existen en tiempo de ejecución, un contrato inyectable requiere un token de runtime. Ninaku utiliza clases abstractas como tipo de contrato y token de inyección de dependencias (DI):

ts
// src/modules/inventory/contracts/inventory.contract.ts
export abstract class InventoryContract {
  abstract reserveStock(input: ReserveStockInput): Promise<ReserveStockResult>;
}

El módulo propietario vincula el contrato público con su servicio de aplicación interno:

ts
// src/modules/inventory/inventory.module.ts
@Module({
  providers: [
    InventoryApplicationService,
    {
      provide: InventoryContract,
      useExisting: InventoryApplicationService,
    },
  ],
  exports: [InventoryContract],
})
export class InventoryModule {}

Los módulos consumidores importan InventoryModule e inyectan únicamente la clase abstracta InventoryContract.


8. Regla de Transacciones Compartidas Entre Módulos ​

El caso de uso que inicia la operación es el dueño del límite de la transacción. Cuando la atomicidad exige que varios módulos participen:

text
Application Use Case (Sales)
    ↓
TenantTransactionRunnerService (Foundation)
    ├── SalesContract/Application escribe en tablas de Sales
    ├── InventoryContract/Application escribe en tablas de Inventory
    └── Outbox escribe el evento de integración
    ↓
COMMIT / ROLLBACK

Reglas obligatorias:

  • La transacción la abre y gestiona Foundation mediante TenantTransactionRunnerService.
  • Los módulos participantes reciben un contexto de transacción opaco (TransactionContext).
  • Un contrato público nunca recibe ni expone pg.PoolClient.
  • Un módulo convierte internamente el TransactionContext opaco en una sesión de consulta mediante la función guardián asDatabaseSession(context) dentro de sus propios repositorios en infrastructure/.
  • Los repositorios no crean transacciones anidadas ocultas.
  • Un contrato que responde una negativa como valor en vez de lanzar una excepción aísla la sentencia que puede fallar en un SAVEPOINT propio, vuelve a él (ROLLBACK TO SAVEPOINT y RELEASE SAVEPOINT) cuando atrapa el rechazo y lo libera cuando la sentencia termina bien. Sin el savepoint la transacción queda abortada y la siguiente sentencia del llamador falla con 25P02.
  • Atrapar un error de SQL y seguir sin savepoint no confirma nada: PostgreSQL responde al COMMIT de una transacción abortada con la etiqueta ROLLBACK. TransactionRunnerService lee esa etiqueta y, si no es COMMIT, rechaza la unidad de trabajo con TransactionAbortedError (sin reintentos; la API responde 500 foundation.internal_error, sin detalles de SQL), en lugar de devolver como confirmado un resultado cuyas escrituras se descartaron.

9. Eventos de Dominio vs. Eventos de Integración ​

  • Evento de Dominio: Mensaje interno del módulo que notifica un cambio de estado dentro del mismo límite del agregado. No se expone a otros módulos.
  • Evento de Integración: Mensaje público, durable e inmutable que se emite a través de la tabla outbox para ser consumido por otros módulos o procesos asíncronos.

Los eventos de integración deben ser minimalistas, versionados, serializables en JSON y libres de clases del dominio interno o tipos de drivers de base de datos.


10. Fronteras de Importación y Reglas de Linter R1–R9 ​

El script de verificación estática tooling/repository/check-module-boundaries.ts (ejecutado por npm run lint:module-boundaries y por npm run verify) evalúa y hace cumplir nueve reglas innegociables (R1–R9) en cada compilación:

ReglaRegla EnforzadaQué Prohíbe ConcretamentePor Qué ExisteError Concreto que Evita
R1Un archivo bajo src/modules/<a>/** solo puede importar de src/modules/<b>/** a través de src/modules/<b>/contracts/**.Importar repositorios, entidades, servicios de aplicación o controladores internos de otro módulo.Preserva el aislamiento modular dentro del monolito.Evita que refactorizaciones internas en el módulo B rompan la compilación o lógica del módulo A.
R2Los archivos bajo src/modules/*/{domain,application,contracts,presentation}/** solo pueden importar el TIPO TransactionContext desde foundation/database/transaction-context.ts; no pueden importar pg, database-session.ts, ni los nombres DatabaseSession, asDatabaseSession o TRANSACTION_CONTEXT_BRAND.Importar el driver de PostgreSQL (pg) o funciones de ejecución SQL directa en capas que no sean infrastructure/.Mantiene la abstracción transaccional opaca en el dominio y la aplicación.Evita que casos de uso o controladores ejecuten consultas SQL crudas o administren transacciones directamente.
R3Los archivos bajo src/modules/*/domain/** no pueden importar @nestjs/*, zod, pg, foundation/http/**, foundation/database/** ni foundation/transactions/**.Importar frameworks web, ORMs, librerías de validación o infraestructura dentro del Dominio.Preserva la pureza y portabilidad del Dominio en Clean Architecture / DDD.Evita que las reglas puras de negocio queden acopladas a NestJS o PostgreSQL, imposibilitando tests unitarios puros.
R4Los archivos bajo src/foundation/** no pueden importar src/modules/** ni src/verticals/**.Importar cualquier código de negocio o vertical desde la capa base de infraestructura.foundation/ provee capacidades genéricas y debe ser completamente agnóstico al dominio.Evita acoplamientos circulares donde la infraestructura base depende de módulos comerciales.
R5Un archivo bajo src/verticals/<v>/** solo puede importar de otro dueño (src/modules/<m>/** o src/verticals/<other>/**) únicamente a través de su contracts/**.Que una vertical acceda a capas internas de un módulo o de otra vertical.Garantiza que los productos verticales compongan capacidades públicas estándar.Evita mutaciones directas en tablas de módulos compartidos sin pasar por las validaciones del módulo.
R6TenantTransactionRunnerService, SystemTransactionRunnerService y SystemStatementRunnerService solo se importan desde src/modules/*/application/** y src/verticals/*/application/**.Importar los ejecutores de transacciones desde domain/, presentation/ o infrastructure/.La capa de aplicación es la única responsable de coordinar la unidad de trabajo transaccional.Evita transacciones abiertas desde controladores HTTP o repositorios administrando su propio ciclo de vida.
R7Los archivos bajo src/modules/<m>/{domain,application,contracts,presentation}/** no pueden importar src/modules/<m>/infrastructure/**.Importaciones apuntando hacia capas exteriores de infraestructura dentro del mismo módulo.Aplica el principio de Inversión de Dependencias (Inward Dependency Rule).Evita el acoplamiento rígido de casos de uso a implementaciones técnicas concretas o adaptadores de base de datos.
R8Los archivos bajo src/modules/** no pueden importar src/verticals/**, ni siquiera su contracts/**.Que un módulo de negocio compartido conozca o importe una vertical específica.Los módulos del Core son agnósticos y reutilizables por cualquier vertical.Evita contaminar el Core con condicionales por vertical (ej. if (vertical === 'restaurant')).
R9SystemTransactionRunnerService y SystemStatementRunnerService (una sola sentencia en autocommit, también sin contexto de tenant) solo se importan desde los directorios permitidos explícitamente (SYSTEM_TRANSACTION_RUNNER_ALLOWED_DIRECTORIES).Usar transacciones sin contexto de tenant en flujos ordinarios de la aplicación.Obliga al uso de TenantTransactionRunnerService para proteger el aislamiento RLS por defecto.Evita fugas de aislamiento tenant donde una operación de negocio olvida aplicar el contexto de la organización.

Excepción Permitida en R9 (SYSTEM_TRANSACTION_RUNNER_ALLOWED_DIRECTORIES) ​

SystemTransactionRunnerService y SystemStatementRunnerService ejecutan consultas sin aplicar contexto de tenant (organization_id), por lo que su uso está restringido mediante una lista blanca explícita:

  • src/modules/identity/application: Permitido porque las operaciones de registro inicial de usuario, autenticación de credenciales y búsqueda global de identidades ocurren antes de que exista o se seleccione un contexto de organización activo.
  • src/modules/platform/application: Permitido porque el catálogo público de Planes (SP14) se lee sin sesión ni organización alguna, y porque el super admin de plataforma (SP13, platform.staff_members) administra planes, precios, mercados, suscripciones y el ciclo de vida de organizaciones ajenas sin pertenecer nunca a la organización sobre la que actúa; ninguna de las dos rutas tiene un organization_id propio del que leer contexto de tenant.
  • src/modules/organization/application: Permitido porque el aprovisionamiento autoservicio de una organización (O1, POST /organizations) crea el propio tenant, por lo que ningún contexto de tenant puede existir todavía cuando se ejecuta.

Propiedad del SQL (lint:sql-ownership) ​

tooling/repository/check-sql-ownership.ts (ejecutado por npm run lint:sql-ownership y por npm run verify, centinela NINAKU_SQL_OWNERSHIP_OK) revisa el código de producción de src/modules/<m>/** (nunca *.spec.ts, *.integration-spec.ts ni *.e2e-spec.ts) y falla con archivo:línea, el módulo y el nombre cuando un texto SQL nombra una tabla, vista, función o secuencia de un schema de otro módulo. El dueño de un schema es el módulo del mismo nombre, y solo cuentan los objetos que database/canonical/*.sql crea, así que un código de permiso o de problema con forma schema.nombre no es una referencia. Un texto cuenta como SQL cuando tiene SELECT, INSERT, UPDATE, DELETE, FROM o JOIN, cuando el nombre se llama como función o cuando el texto entero es el nombre; los literales SQL entre comillas simples y el código interpolado con ${...} no se miran.

  • Permitidos para todos (tooling/repository/sql-ownership-rules.ts, cada uno con su razón): el schema compartido reference; las funciones de contexto de tenant identity.current_organization_id e identity.has_active_tenant_context; y las funciones publicadas de la sección 4.4 (organization.outlet_operating_context, organization.business_unit_is_operable). Una función nueva entra a esa lista en el mismo cambio que la publica.
  • Qué cuenta como SQL y cuándo falla en vez de pasar: un texto cuenta como SQL cuando tiene select, insert, update, delete, from o join (en cualquier caso), cuando el nombre sigue a FROM/JOIN/INTO/UPDATE/TABLE, cuando se llama como función o cuando el texto entero es el nombre; los nombres se comparan sin distinguir mayúsculas, entre comillas dobles se toman tal cual, y schema.tabla.columna cuenta por schema.tabla. Si falta database/canonical, src/modules o la línea base, o el SQL canónico no crea ningún objeto, la regla falla con NINAKU_SQL_OWNERSHIP_SETUP_INVALID (código 2) en lugar de pasar. Un código de tres partes cuya última parte es una palabra clave (por ejemplo un código de permiso) o una frase en minúsculas que menciona una tabla puede dar un falso positivo; se corrige con el nombre o con una excepción justificada en sql-ownership-rules.ts, no desactivando la regla.
  • Línea base que solo encoge (tooling/repository/sql-ownership-baseline.json, JSON sin comentarios: archivo → nombre → cuántas veces): las referencias que existían cuando nació la regla. Un nombre nuevo o una vez más falla como referencia no listada; un archivo que nombra menos de lo que la línea base permite también falla, hasta que se baja la entrada, igual que la lista de comentarios SQL.

11. Compatibilidad y Cambios Rompedores ​

Los contratos in-process residen en la misma unidad de despliegue. Si un contrato público necesita un cambio incompatible:

  1. Se refactorizan todas las llamadas en los módulos consumidores dentro de la misma Pull Request.
  2. Se ejecutan las pruebas de integración del sistema.
  3. No se dejan capas de compatibilidad intermedias ni código deprecado dentro del repositorio.

12. Colaboración en Verticales ​

Las verticales (restaurant, retail, hospitality) son consumidoras de módulos del Core compartidos y deben respetar exactamente las mismas reglas de fronteras:

text
src/verticals/restaurant/
    ├── CatalogContract    (de src/modules/catalog/contracts/)
    ├── SalesContract      (de src/modules/sales/contracts/)
    ├── PaymentsContract   (de src/modules/payments/contracts/)
    └── PrintingContract   (de src/modules/printing/contracts/)

Código específico de restaurante (ej. comandas de cocina o asignación de mesas) nunca modifica directamente las tablas SQL de sales o catalog.


13. Antipatrones Rechazados Explícitamente ​

  1. Llamadas HTTP internas: Prohibido realizar peticiones HTTP entre módulos del mismo monolito.
  2. Importar repositorios ajenos: Un módulo nunca inyecta un repositorio de otro módulo.
  3. Exponer clientes SQL: Prohibido exportar pg.PoolClient o instancias de base de datos en contratos.
  4. Bus genérico no tipado: Prohibido usar patrones como eventBus.publish('any_string', payload) para colaboración síncrona ordinaria.
  5. Condicionales por vertical en el Core: Prohibido escribir bifurcaciones de código basadas en la vertical dentro de módulos compartidos.

14. Definición de Hecho (Definition of Done) para una Colaboración entre Módulos ​

Antes de dar por completada una integración entre módulos, se debe verificar:

  1. ¿Se identifica claramente cuál módulo es el propietario único del dato?
  2. ¿La llamada síncrona utiliza un contrato abstracto bajo contracts/?
  3. ¿El contrato expone únicamente DTOs y tipos de negocio sin fugas de persistencia?
  4. Si la operación requiere atomicidad, ¿se utiliza TenantTransactionRunnerService con un TransactionContext opaco?
  5. Si la operación es asíncrona, ¿se utiliza la tabla outbox y un procesador idempotente?
  6. ¿El comando npm run verify (y en particular npm run lint:module-boundaries y npm run lint:sql-ownership) pasa en verde sin violaciones de R1–R9 y sin SQL que nombre objetos de otro módulo?

15. Índice de Contratos Publicados ​

Cada contrato real, una vez implementado, se agrega a esta tabla en el mismo cambio que lo publica.

ContratoMódulo propietarioArchivoConsumidores actuales
PlatformAuditContractauditsrc/modules/audit/contracts/platform-audit.contract.tsidentity (diez transiciones de seguridad de plataforma: cambio y recuperación de contraseña, alta/baja de método de inicio de sesión, bloqueo de cuenta, cierre de sesión y cierre de sesión en todas partes, alta/baja de segundo factor, emisión de códigos de recuperación) y platform (cada comando de staff de platform-admin, ejecutado o rechazado: planes, ofertas, mercados, suscripciones, pagos, capacidades, estado de organizaciones y reemisión del outbox de seguridad)
TenantAuditContractauditsrc/modules/audit/contracts/tenant-audit.contract.tsorganization (perfil, entidades legales, marcas, unidades de negocio, sitios, outlets y cambios de estado de la estructura), identity (invitaciones, asignaciones de roles y control de miembros) y catalog (creación, edición y ciclo de vida de los artículos, y sus negativas, desde la rebanada K2): cada comando de tenant auditado lo consume dentro de su propia transacción de tenant, de modo que el Audit Record (audit.records) es atómico con el cambio. Toma el actor, el recurso objetivo, el resultado y una lista plana de cambios (code, before, after) que se guardan como atributos before/after; un código con aspecto de secreto, hash o token se rechaza
PermissionCheckContractidentitysrc/modules/identity/contracts/permission-check.contract.tsorganization (los comandos y lecturas con permiso exigido, casos O5 al O33), catalog (desde la rebanada K2: la lectura de un artículo con catalog.items.view en toda la organización o con el ancla de una unidad de negocio, y la desactivación y el archivo de un artículo con catalog.items.manage, que nunca dependen del plan) e identity internamente (directorio de miembros, invitaciones, asignaciones de roles y control de miembros); platform reutiliza su tipo PermissionAnchor en OperationAuthorizationContract. Lo consumen dentro de su propia transacción de tenant, de modo que la verificación es atómica con el comando. isGranted responde si una membresía activa tiene un permiso activo mediante un rol activo; sin outletId ni anchor solo lo satisface un alcance de toda la organización. Desde el lote G acepta un ancla (anchor: business_unit, legal_entity, brand, site u outlet) y decide responde granted, denied o scope_unavailable en ese lugar. Un identificador mal formado o un código de permiso vacío o mayor de 120 caracteres responde false sin llegar a SQL
IdentitySessionContractidentitysrc/modules/identity/contracts/identity-session.contract.tsplatform: PlatformElevationGuard (src/modules/platform/presentation/guards/platform-elevation.guard.ts) llama verifyElevation con el token de elevación que presenta el llamador y la sesión autenticada, y responde la negativa de elevación de platform si no se confirma. PlatformModule importa IdentityModule desde src/modules/identity/contracts/identity.module.ts. Solo responde un booleano; no expone la sesión ni el token
SecurityOutboxOperationsContractidentitysrc/modules/identity/contracts/security-outbox-operations.contract.tsplatform: los casos de uso de src/modules/platform/application/security-outbox/ que sirven platform-admin/security-outbox (readBacklog para el atraso por estado, listDeadLetters paginado por keyset con el destino enmascarado y reissueDeadLetter para reemitir un mensaje muerto), cada uno con el callerIdentityId del staff dentro de la transacción del llamador, y la reemisión auditada con PlatformAuditContract. Los errores publicados (security-outbox-message-not-found.error.ts, security-outbox-reissue-refused.error.ts, security-outbox-staff-required.error.ts) viven junto al contrato; platform los traduce a sus Problem Details
OperationAuthorizationContractplatformsrc/modules/platform/contracts/operation-authorization.contract.tscatalog desde la rebanada K2: crear, editar, reactivar y restaurar un artículo exigen catalog.items.manage en toda la organización y la capacidad catalog.items, y catalog traduce la negativa en su propia capa presentation/failures/ (D15: ningún comando de organization exige una capacidad); también lo consume una ruta de prueba. Decide en una sola sentencia (platform.authorize_operation), sin lanzar error dentro de la transacción de tenant, si el miembro del contexto puede hacer una operación: tenant_context_missing, permission_missing, scope_unavailable, capability_not_entitled, outlet_not_operational, vertical_inactive o allowed, en ese orden. Con un ancla (anchor, autorización en un lugar) decide con platform.authorize_operation_at: un ancla de outlet responde igual que platform.authorize_operation; en una unidad de negocio, entidad legal, marca o sitio se omite el paso del outlet y responde place_not_operational si el lugar no está activo. src/modules/platform/contracts/operation-refusal.ts solo publica OperationRefusedError; la traducción HTTP de la negativa es del módulo consumidor, y la de referencia (operationRefusalFailure, que usa la denegación simple del consumidor para permission_missing y tenant_context_missing) vive en src/modules/platform/presentation/failures/operation-refusal-failure.ts. PlatformModule se importa desde src/modules/platform/contracts/platform.module.ts
CatalogSellableContractcatalogsrc/modules/catalog/contracts/catalog-sellable.contract.tsNinguno todavía: lo consumirán sales (admisión de las líneas de una venta) y pricing (configuración sobre lo vendible), desde la rebanada K8. Hoy solo lo consume el módulo de prueba test/fixtures/catalog-sellable/, que ve únicamente el contrato. CatalogModule se importa desde src/modules/catalog/contracts/catalog.module.ts. resolve responde, sin bloquear nada, un veredicto por variante pedida (como máximo 100, sin repetidas) en el orden pedido, con la versión del catálogo leída en la misma sentencia y una instantánea de la variante (nombres y códigos del artículo y la variante, SKU, unidad y política de cantidad, y sus ofertas efectivas con sus opciones disponibles, la unidad de venta de la variante destino de cada opción y sus opciones preseleccionadas). admit recibe líneas (como máximo 100), cada una con su variante, su cantidad y sus selecciones { optionId, quantity } (como máximo 100 por línea, una opción una sola vez, cantidad entera de 1 a 999); juzga la cantidad de la línea contra la política de venta de la variante, la cantidad de cada opción contra su máximo efectivo y el mínimo y el máximo de cada grupo sobre la suma de las unidades, igual que el esquema de ventas. resolve y admit responden las ofertas efectivas de cada variante (itemModifierGroupId, modifierGroupId, source item o variant, mínimo y máximo), resueltas con una sola regla: la oferta activa de la variante reemplaza a la del artículo, la inactiva de la variante excluye el grupo y la ausencia hereda; la venta guarda la oferta que admit devolvió. Una opción de tipo text (K14c) lleva textMaxLength y puede llevar prefix, printsOnReceipt: false y printsOnKitchenTicket: false (campos opcionales que solo aparecen cuando difieren del valor por defecto); la selección de una opción text lleva text, que admit normaliza a NFC y juzga (no vacío, sin NUL ni caracteres de control, de 1 a textMaxLength caracteres y 500 como máximo, con cantidad 1; como máximo 10 selecciones con texto por línea, contando las de los componentes de un combo) y devuelve normalizado en la selección admitida, o rechaza con text_required, text_not_allowed, text_invalid o text_too_long (y too_many_texts antes de cualquier bloqueo); Ventas debe agregar la rama text a su comprobación de tipos y una columna para el valor antes de consumir opciones de texto. La oferta retirada de la variante también hereda, y una oferta retirada del artículo no se ofrece; una oferta del artículo suspendida (inactive) que la variante hereda y que exige un mínimo deja a esa variante sin poder configurarse. La vendibilidad es por variante: un grupo exigido sin opciones disponibles, o cuyas opciones disponibles no alcanzan su mínimo en unidades (la suma de sus cantidades máximas, la misma aritmética del esquema de ventas), deja fuera solo a esa variante (required_modifier_unsatisfiable), y las demás variantes del artículo siguen vendibles. Cuando aplica la oferta de la variante, source es variant e itemModifierGroupId es el de esa oferta. El punto de venta, la búsqueda, el escaneo, resolve y admit usan el mismo fragmento SQL. Ambos métodos aceptan además un outletId opcional (K16): con él, un artículo que ese punto de venta oculta (catalog.outlet_hidden_items) se juzga igual que uno que la unidad no lista (not_listed, también para el componente de un combo), y un outletId que no existe en la organización, que está mal formado o que la unidad de negocio dada no opera hoy (en la fecha local de su sitio, organization.outlet_operating_context) se refiere en la misma sentencia con outlet_not_operated_by_unit (una unidad inexistente si está mal formado), así que nunca se juzga el surtido de la unidad a ciegas; sin outletId el contrato juzga a nivel de unidad, un modo documentado para quien aún no conoce el punto de venta, y los consumidores existentes no cambian. X13 cerrada por la migración 0090: el esquema de ventas trata la oferta inactiva de la variante como exclusión del grupo, igual que admit, así que una línea de una variante excluida no exige el mínimo del artículo. Las ofertas de variante las escribe PUT /catalog/items/{itemId}/variants/{variantId}/modifier-groups (K9g): optedOut escribe la oferta inactiva de la variante, y la ruta del artículo suspende su oferta con suspended. admit responde por línea su posición (lineIndex), la instantánea, la cantidad y cada oferta efectiva con el nombre del grupo, las selecciones que la línea le hizo (modifierOptionId, nombre, tipo, cantidad, variante destino y su unidad de venta, lo que guarda una línea de venta), las preseleccionadas disponibles que la línea quitó (removedDefaults) y las que eligió con menos cantidad (reducedDefaults). Desde la rebanada B2 una línea puede llevar además bundleSelections (opcional): por grupo de un combo { bundleGroupId, choices }, y cada elección { choiceId, quantity, selections?, bundleSelections? } con la cantidad de veces que el cliente la tomó (entero de 1 a 999, el selection_count de ventas), los modificadores de ese componente y los grupos de un combo dentro del combo (hasta 3 niveles y 100 componentes por línea). admit juzga que el grupo y la elección existan y estén activos en ese combo, el mínimo y el máximo del grupo sobre la suma de las cantidades, el tope propio de cada elección, cada componente con la misma regla de lo vendible que una línea y sus modificadores contra sus propias ofertas efectivas; un grupo que la línea omite recibe sus preseleccionadas disponibles (appliedByDefault) y uno enviado sin una preseleccionada la nombra en removedDefaults. La línea admitida responde bundleGroups (solo si el artículo tiene un grupo activo: una línea sin combo responde exactamente lo mismo que antes) con cada grupo activo, sus elecciones con su instantánea, quantity, quantityPerSelection y sus ofertas admitidas, lo que guarda sales.order_line_bundle_selections, sin precio. Cada nivel de componentes suma una sentencia, sea cual sea el número de grupos. Las ofertas nunca se borran ni cambian de identidad: editar sus reglas u orden conserva el UUID, quitar un grupo la retira (retired) y volver a ofrecerlo la revive con el mismo UUID; conservar el UUID no congela las reglas históricas, que cada consumidor guarda en su propia instantánea. Comparte primero la fila catalog.catalog_versions de la organización y luego la fila de la unidad de negocio (FOR SHARE), antes de leer: un bloqueo compartido no hace fila detrás de un FOR UPDATE en espera, así que una venta nunca espera a un comando del catálogo; al revés, un comando del catálogo de la organización espera hasta que no quede ninguna admisión abierta y, si las ventas se solapan sin pausa, falla al vencer DATABASE_STATEMENT_TIMEOUT_MS; toda actualización de la fila de esa unidad (desactivarla, renombrarla) también espera, y dos admisiones no se esperan entre sí. Por eso el consumidor llama a admit al final de su transacción y confirma de inmediato. Rechaza todo o nada con CatalogNotSellableError (contracts/catalog-sellable.errors.ts), que lleva cada negativa por línea en un dominio cerrado (unknown, business_unit_unavailable, outlet_not_operated_by_unit, item_archived, item_inactive, variant_archived, variant_inactive, assortment_none, not_listed, required_modifier_unsatisfiable, bundle_group_unsatisfiable (también como motivo de resolve: un combo con un grupo obligatorio suspendido o cuyas elecciones disponibles no alcanzan su mínimo; admit lo explica por grupo con su path), quantity_out_of_policy, option_not_offered, option_unavailable, option_quantity_above_maximum, selection_below_minimum, selection_above_maximum y, dentro de un combo, bundle_group_not_offered, bundle_group_unavailable, bundle_group_unsatisfiable, bundle_choice_not_offered, bundle_choice_unavailable, bundle_choice_above_maximum, bundle_selection_below_minimum, bundle_selection_above_maximum, bundle_too_deep, bundle_components_above_limit); una negativa dentro de un combo es un BundleAdmissionRefusal que agrega path (por ejemplo lines[0].bundleSelections[1].choices[0]), bundleGroupId, choiceId y detail, un texto que dice cómo corregirla. Una entrada vacía, excesiva, repetida, con una cantidad inválida (invalid_quantity), con demasiados componentes (too_many_components) o demasiado anidada (too_deep) lanza CatalogSellableRequestInvalidError antes de cualquier SQL. Ambos métodos corren en la transacción de tenant del llamador, con la misma regla de lo vendible que las lecturas del punto de venta, no verifican ningún permiso (el comando consumidor autoriza el suyo), no escriben nada y no registran auditoría; una variante o una unidad de otra organización responde igual que una desconocida. El consumidor traduce CatalogNotSellableError a su propio código público en su capa presentation/failures/. El consumidor no muestra la instantánea de un veredicto rechazado (artículo archivado, inactivo o no listado: nombres, SKU, opciones) a un usuario que no tenga un permiso de lectura del catálogo
OrganizationContextContractorganizationsrc/modules/organization/contracts/organization-context.contract.tscatalog desde la rebanada K10b, a través de CatalogBusinessUnits (src/modules/catalog/application/catalog-business-units.ts): businessUnitStates responde si una unidad existe en las puertas de lectura del catálogo, el estado de la unidad que entra al ETag del surtido y si la unidad del filtro listed de la lista de artículos opera; activeBusinessUnits lista las unidades activas para el aviso de verticales del espacio de trabajo (WorkspaceVerticals); shareBusinessUnit toma la fila de la unidad en admit. También lo consume el módulo de prueba test/support/published-reads-consumer.ts; los dos importan OrganizationModule desde src/modules/organization/contracts/organization.module.ts. activeBusinessUnits (desde K10b) responde id, nombre y estado de las unidades activas de la organización del contexto, ordenadas por id; businessUnitStates responde id, nombre y estado de las unidades nombradas de la organización del contexto, ordenadas por id, sin las desconocidas ni las de otra organización; outletOperatingContext responde el outlet, su estado, la fecha de su sitio, la unidad que lo opera en esa fecha (o null) y la versión de la estructura, leídos con organization.outlet_operating_context, o null; shareBusinessUnit toma la fila de la unidad con FOR SHARE hasta el fin de la transacción y responde si existe en la organización. Un id mal formado responde como uno desconocido sin llegar a SQL. Corre en la transacción de tenant del llamador, no verifica permisos, no escribe nada y no audita
BusinessUnitVerticalsContractplatformsrc/modules/platform/contracts/business-unit-verticals.contract.tscatalog desde la rebanada K10b: WorkspaceVerticals (src/modules/catalog/application/reads/workspace-verticals.ts) le pasa las unidades activas que responde OrganizationContextContract.activeBusinessUnits para decidir el aviso units_with_different_verticals del espacio de trabajo; organization desde la rebanada K10c: ChangeBusinessUnitVerticalUseCase llama activate y deactivate dentro de su transacción de tenant, junto al Audit Record, y ya no nombra tablas de platform; desde la revisión 109 (D1) también RegisterBusinessUnitUseCase llama activate por cada código de verticalCodes de POST /business-units, después de bloquear la unidad recién insertada y en la misma transacción del alta, y traduce unavailable al mismo 422 · organization.vertical_unavailable. activate y deactivate responden { outcome, previousStatus }: outcome es changed, unchanged, unavailable (vertical desconocida, no activa en el catálogo o rechazada por la base) o last_active_vertical (la última vertical activa de la organización entre sus unidades operables), y previousStatus es el estado anterior (null si no había fila); un rechazo viaja como resultado, no como excepción, y organization lo traduce a sus Problem Details. Cuando la base rechaza la escritura (la vertical se retiró o la unidad dejó de estar activa después de la lectura del catálogo), el repositorio aísla el INSERT o el UPDATE en el savepoint platform_business_unit_vertical_write y vuelve a él, así que la transacción del llamador sigue usable después de unavailable y confirma lo que escribió antes; cada activación que escribe suma dos sentencias (SAVEPOINT y RELEASE SAVEPOINT, más ROLLBACK TO SAVEPOINT cuando hay rechazo) y desactivar no suma ninguna. Precondición del llamador: activate y deactivate no serializan por sí solos dos cambios simultáneos del mismo par; el llamador tiene que sostener el lock de la fila de la unidad de negocio (en organization, BusinessUnitRepositoryPort.lock) en la misma transacción antes de llamarlos, y por eso la lectura del estado previo y la decisión de insertar o actualizar no se pisan. Toda sentencia nombra la organización del contexto de forma explícita. activeVerticals responde los pares unidad y vertical activos de las unidades nombradas de la organización del contexto, ordenados por unidad y código; una unidad sin verticales activas, desconocida o de otra organización no aparece, y un id mal formado no llega a SQL
AssetUsageContractassetssrc/modules/assets/contracts/asset-usage.contract.tscatalog desde las rebanadas A5b y A5c: SetItemImagesUseCase y SetManyItemImagesUseCase (src/modules/catalog/application/items/, a través de attachable-images.ts) lo llaman solo con los ids que la lista agrega, después de bloquear catalog_versions y el artículo (o los artículos en orden de id), y traducen AssetNotAttachableError a 422 · catalog.image_not_attachable con un meta.errors[] por artículo y asset; CatalogModule importa AssetsModule. AssetsModule se importa desde src/modules/assets/contracts/assets-contracts.module.ts. assertAttachable corre en la transacción de tenant del llamador y bloquea FOR SHARE, en orden de id y en una sola sentencia, los assets pedidos, así que un archivo concurrente (que toma el asset FOR UPDATE) y la vinculación se serializan; el consumidor lo llama después de bloquear sus propias filas (catalog_versions y el artículo). Los ids repetidos cuentan una sola vez, en su primera posición. Responde { assetId, imageRef } de cada asset vinculable en el orden de los ids pedidos (no en el orden del bloqueo) o rechaza todo con AssetNotAttachableError (contracts/asset-contract.errors.ts), que lleva una negativa por id pedido: not_found (desconocido o de otra organización, que RLS vuelve idénticos), purging, archived, not_ready, wrong_media_kind, wrong_access_class. No verifica permisos, no escribe ni audita; el consumidor traduce el error a su propio código público. No expone llaves, buckets, proveedores ni ids de variantes
AssetDeliveryContractassetssrc/modules/assets/contracts/asset-delivery.contract.tscatalog desde la rebanada A5b: ItemRepository (src/modules/catalog/infrastructure/items/item-reads.ts) describe en una sola llamada (en tandas de 2.000 ids si hiciera falta) las imágenes de los artículos que lee o bloquea, para las URLs de C3 y de cada respuesta de artículo; desde la rebanada A6, ReadWhatAnOutletSellsUseCase (src/modules/catalog/application/till/, a través de sellable-images.ts) lo llama una sola vez por respuesta de C17 y por página de C19, solo cuando algún artículo o variante listado tiene imagen principal, para el imageRef y aiGenerated del punto de venta, y suma fingerprint() a la ETag de C17 y tileUrlTemplate() a meta.media (ambas sin SQL, así que el 304 no lee assets). describe lee, en la transacción del llamador, en una sola sentencia por clave primaria y como máximo 2.000 ids (más lanza AssetDeliveryRequestTooLargeError antes de cualquier SQL), un mapa por asset encontrado con status, imageRef (<token>/p<perfil>), aiGenerated y las URLs públicas de las renditions pedidas; solo un asset ready y public tiene imageRef y URLs, los demás las tienen en null, y un asset archivado sigue resolviendo. tileUrlTemplate() da la plantilla https://<medios>/a/{imageRef}/tile.webp y fingerprint() la huella de entrega (8 hex de SHA-256 de ASSETS_PUBLIC_BASE_URL y la lista de perfiles) que el consumidor agrega a sus ETags. Sin ASSETS_PUBLIC_BASE_URL configurada, las URLs y la plantilla son null. No expone llaves, buckets, proveedores ni ids de variantes

Dos puertos técnicos transversales viven en src/foundation/ y no son contratos de módulo: SessionAuthenticator (src/foundation/auth/session-authenticator.ts) y TenantContextResolver (src/foundation/tenancy/tenant-context-resolver.ts). Los implementa identity (AuthenticateCallerUseCase y ResolveTenantContextUseCase) y los exporta desde IdentityModule; los consumen los guards SessionGuard y TenantContextGuard de foundation, que usan identity, platform y organization en lugar de definir cada uno el suyo. TenantContextResolver resuelve la membresía activa de una identidad en una organización o rechaza con TenantContextRejectedError.

16. Modelo Mental Práctico ​

text
Lo que mi módulo OFRECE a otros        → src/modules/<m>/contracts/
Lo que mi módulo NECESITA externamente  → src/modules/<m>/application/ports/
Cómo lo implemento técnicamente        → src/modules/<m>/infrastructure/
Cómo la API HTTP me alcanza            → src/modules/<m>/presentation/
Cómo protejo las reglas de negocio     → src/modules/<m>/domain/ + application/

Application Foundation in progress. Tracked in issue #13.