Appearance
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
- Comercial: habilitar capacidades por organización.
- Aplicación: módulos colaboran mediante contratos públicos y conservan sus escrituras.
- 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
railway-and-migrator, pre-foundation-integrity, openapi-compatibility y docs-site 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 cuatro se ejecutan sin bloquear nada.
El 12 de octubre de 2026 el propietario retiró build-test (app.yml) y sql-regressions (database.yml): juntos tardaban unos 45 minutos por corrida, gastaban los minutos del plan gratuito y sus fallos aparecían recién al promover a main, cuando el trabajo ya estaba en staging. Los reemplazan el npm run verify completo y local de quien integra, que ya corre build, pruebas unitarias, integración y e2e contra PostgreSQL 18, y el npm run db:test manual sobre un clúster desechable que AGENTS.md sección 8 exige antes de fusionar una PR que toque database/ y antes de cada promoción a main.
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.
D23. Una identidad puede tener password y proveedor a la vez, y nunca se enlazan solos
identity.authentication_methods ya era el registro único de credenciales de una identidad (password, external_provider, passkey), con identity.password_credentials e identity.external_identities como tablas de detalle 1:1. Antes de esta revisión, el único camino para adquirir una credencial external_provider era OAuthSignInRepository.resolveIdentity() durante el sign-in con proveedor, que crea una identidad nueva cuando el (provider, subject) no está enlazado a nadie; no existía ningún camino para que una identidad ya autenticada (por password o por otro proveedor) adquiriera una credencial adicional sin pasar por sign-in. Esta revisión agrega dos rutas de escritura sobre el mismo modelo, sin ninguna migración de esquema: el CHECK/índice único que hacía falta ya existía.
Crear un password no reemplaza uno existente. POST /password-methods (CreatePasswordMethodUseCase, IdentityPasswordMethodRepository.claimPassword) bloquea la fila de identity.identities con FOR UPDATE, comprueba si ya existe un método password activo y, si lo hay, responde 409 · identity.password_method_exists sin tocar nada; si no, inserta authentication_methods + password_credentials en la misma transacción. Nunca revoca sesiones porque no compromete nada: a diferencia de PasswordChangeUseCase (que reemplaza un hash y por eso cierra toda otra sesión), esta ruta solo puede ejecutarse cuando no hay password que sustituir. authentication_methods_one_active_password_uq (parcial, WHERE method_type = 'password' AND revoked_at IS NULL) es la garantía de última línea si el lock se saltara.
Enlazar un proveedor nunca fusiona identidades, y nunca por email. POST /sign-in-methods/providers (LinkProviderUseCase) verifica el ID token con el mismo OAuthIdentityVerifierPort y gasta el nonce con el mismo ProviderNonceRepositoryPort que el sign-in con proveedor — mismo emisor, audiencia, pin de firma y nonce de un solo uso —, todo antes de escribir nada. OAuthSignInRepository.linkIdentity() toma el mismo advisory lock por (provider, subject) que ya usaba resolveIdentity(), comprueba si ese par ya está enlazado a cualquier identidad y, si lo está, responde el mismo 409 · identity.provider_link_refused sin distinguir si el dueño es la propia identidad que llama o una completamente distinta: la respuesta nunca revela cuál de las dos causas fue. external_identities_provider_subject_uq (única sobre (provider, subject), sin importar la identidad) es la garantía de última línea. La identidad destino la decide siempre el llamador autenticado (caller.identityId), nunca una coincidencia de provider_email: ese campo es una instantánea informativa, nunca una clave de emparejamiento, exactamente como ya documentaba el CHECK de la tabla.
El último camino de entrada ahora cuenta un proveedor enlazado como se cuenta a sí mismo, y la fórmula se evalúa igual sin importar qué se remueve. identity.sign_in_methods.WaysInSnapshot reemplaza el booleano hasActiveSelfSufficientMethod por activeSelfSufficientMethodCount, porque POST /sign-in-methods/:id/removals (lockRemovableSignInMethod) ahora también reconoce un método external_provider activo como objetivo removible (antes solo reconocía identificadores verificados y el password). hasRemainingWayIn() calcula, para el removedKind presentado, cuántos métodos self-sufficient sobrevivirían (restando uno solo cuando lo que se remueve es el propio google/apple), cuántos identificadores verificados sobrevivirían (restando uno solo cuando lo que se remueve es el propio identificador) y si el password sobrevive (false solo cuando lo que se remueve es el propio password); queda una vía de entrada cuando sobrevive al menos un método self-sufficient, o sobreviven a la vez un identificador verificado y un password activo. Una primera versión de este cambio evaluaba la rama self-sufficient con un return anticipado que nunca consultaba la combinación identificador+password: una identidad con un email verificado, un password activo y un Google enlazado no podía remover ese Google (contaba 0 métodos self-sufficient restantes y nunca llegaba a mirar que el email y el password seguían intactos), aunque el email+password siguiera siendo una vía de entrada completa. La revisión adversarial de RDD detectó esto antes del commit; last-way-in-policy.spec.ts prueba con una tabla las cinco combinaciones que separan ambas ramas. revokeSignInMethod() decide entre revocar un identity.login_identifiers o un identity.authentication_methods reutilizando isIdentifierType(kind), ya importado, en vez de una lista nueva. La consulta que localiza un método de proveedor removible bloquea la fila con FOR UPDATE OF method (no FOR UPDATE liso): un FOR UPDATE sin acotar sobre el JOIN a identity.external_identities exige privilegio UPDATE en esa tabla, que ninaku_runtime nunca tiene (solo SELECT, INSERT) porque esta ruta nunca escribe ahí; acotar el lock a authentication_methods —la única fila que revokeSignInMethod() de verdad actualiza— evita pedir un grant nuevo.
Desenlazar (o remover una contraseña) ahora conserva la sesión que llama y termina toda otra sesión. identity.authentication_methods_revoke_sessions (trigger AFTER UPDATE, database/canonical/0400_cross_domain_contracts.sql) revocaba toda sesión de una identidad, sin excepción, cuando cualquier método de autenticación pasaba a revoked_at IS NOT NULL; hasta la revisión que introdujo el desenlace de proveedor eso solo se ejercitaba al revocar un password (que sí actualiza authentication_methods), nunca al revocar un identificador (que solo actualiza identity.login_identifiers y no dispara este trigger). Al hacer removible un proveedor, esa revisión ejercitó ese trigger por primera vez con la sesión que llama, y esa sesión también terminaba revocada — el mismo defecto que ya evitaba PasswordChangeUseCase (que nunca toca revoked_at, así que nunca dispara este trigger, y revoca las demás sesiones explícitamente). Esta revisión cierra esa inconsistencia: identity.revoke_compromised_method_sessions() distingue una transición de status a compromised — termina toda sesión sin excepción, es la ruta de un método comprometido — de una transición simple de revoked_at, que ahora respeta un ajuste local a la transacción, identity.surviving_session_id, fijado con set_config(..., true) antes del UPDATE. IdentitySignInMethodRepository.revoke() fija ese ajuste con caller.sessionId antes de revocar un password o un proveedor vinculado desde POST /sign-in-methods/:id/removals, así que esa ruta ahora tiene el mismo contrato que el cambio de contraseña: la sesión que llama sobrevive, toda otra sesión de la identidad termina. Una revocación sin sesión que llama (por ejemplo, una herramienta administrativa que actualiza la fila directamente, sin fijar ese ajuste) sigue terminando toda sesión, igual que antes. Fuera de este trigger, otras dos rutas de Identity también terminan sesiones y siguen el mismo criterio — perdonar la sesión que llama solo cuando existe una: PasswordChangeUseCase, ya citado arriba, perdona caller.sessionId porque prueba la contraseña vigente desde una sesión viva; ConfirmPasswordRecoveryUseCase (IdentityPasswordRecoveryRepository.commitRecovery()) confirma un canje/challenge sin ninguna sesión que llame, así que su propia consulta explícita (REVOKE_ALL_SESSIONS, sin excepción alguna) termina toda sesión de la identidad exactamente como la rama comprometida del trigger, sin fijar ni consultar identity.surviving_session_id. sign-in-method-providers.e2e-spec.ts prueba que la identidad sigue entrando con email+password en una sesión nueva y que ese (provider, subject) desenlazado queda refusado en cualquier otra puerta — POST /sign-ins responde 401 · identity.sign_in_refused y POST /step-ups/providers responde 403 · identity.step_up_confirmation_refused — porque ambos leen eligible a partir de method.status = 'active' AND method.revoked_at IS NULL, nunca de la mera presencia de la fila en external_identities; remove-sign-in-method-sessions.integration-spec.ts prueba la supervivencia de la sesión que llama, para un password y para un proveedor removidos. Migración 0016_voluntary_method_revocation_spares_the_caller.sql.
Remover un autenticador TOTP conserva la sesión que llama y termina toda otra sesión, sin pasar por ningún trigger. identity.totp_methods vive fuera de identity.authentication_methods, así que revocar un método TOTP nunca dispara authentication_methods_revoke_sessions: antes de esta revisión, RemoveSecondFactorUseCase revocaba la fila de totp_methods y no terminaba ninguna sesión, a diferencia de cada otra ruta de esta sección. IdentitySecondFactorRepository.revokeOtherSessions() repite, dentro de la misma transacción, la misma consulta explícita que ya usa IdentityPasswordChangeRepository.commitPasswordChange() (UPDATE identity.sessions ... WHERE identity_id = $1 AND id <> $2 AND revoked_at IS NULL), nunca el ajuste transaccional identity.surviving_session_id: como esta ruta jamás actualiza authentication_methods, no hay trigger que respetar y no hace falta uno nuevo. RemoveSecondFactorUseCase.execute() la invoca solo cuando revokeVerifiedMethod() revocó de verdad una fila, con la razón second factor removed. remove-second-factor.use-case.spec.ts prueba la secuencia revoke → revoke-other-sessions → audit → commit y que un id que la revocación no tocó nunca termina ninguna sesión; remove-second-factor-sessions.integration-spec.ts prueba contra PostgreSQL real que la sesión que llama sobrevive y toda otra sesión de la identidad termina.
Un proveedor sin enlace nunca crea una segunda identidad cuando su email verificado ya pertenece a una cuenta activa. POST /sign-ins con un proveedor resolvía un (provider, subject) sin enlace creando siempre una identidad nueva, incluso cuando el token vouchaba por un email ya verificado en otra identidad — por ejemplo, tras desenlazar Google de una cuenta con email+password, volver a entrar con ese mismo Google creaba una segunda identidad en vez de negarse. OAuthSignInRepository.resolveIdentity() ahora, antes de crear una identidad nueva, busca un identity.login_identifiers activo (revoked_at IS NULL) y verificado (verified_at IS NOT NULL) cuyo normalized_value case-insensitive coincida con el email del token — solo cuando el token lo vouchó (email_verified: true; OAuthIdentityVerifierService.readVerifiedEmail() ya descarta el email si el proveedor no lo marcó verificado, así que resolved.email definido implica siempre email_verified: true). Si existe, se niega con 409 · identity.account_exists_for_provider_email sin crear ni enlazar nada — enlazar automáticamente violaría esta misma decisión ("nunca se enlazan solos") — y el detalle le dice al llamador que entre con el método existente y enlace el proveedor desde la cuenta. Un email no verificado por el proveedor, o que no coincide con ningún identificador activo, conserva el comportamiento de hoy: crea una identidad nueva. La consulta que busca ese identificador (FIND_ACTIVE_IDENTITY_BY_VERIFIED_EMAIL) nunca junta contra identity.identities ni filtra por su status: la refusa se aplica exactamente igual cuando la identidad dueña del email está active, blocked, disabled o archived, así que bloquear una identidad nunca es evadible creando una segunda por esta puerta, y el mismo 409 genérico —nunca uno distinto ni un detalle adicional— tampoco revela que la identidad encontrada está bloqueada. provider-sign-in.e2e-spec.ts y oauth-sign-in.repository.integration-spec.ts prueban las tres ramas, más el pliegue de mayúsculas (refuses when an existing identity verified its email in mixed case and the provider token already carries it lowercased) y una identidad bloqueada (refuses even when the matching identity is blocked, never creating a second identity to evade it).
Ninguna de las rutas de esta sección usa migraciones de esquema. La única migración de la revisión que introdujo el desenlace de proveedor (0014_password_method_and_provider_link_are_audited.sql) siembra los dos nuevos Audit Action Type que esas rutas graban.
D24. Caché condicional, esquemas y medición de latencia se aplican con criterio, no por defecto
Estas reglas deciden cuándo una ruta lleva ETag, qué valida su esquema, cuándo entra a la medición de latencia y qué objetivo de latencia le corresponde. Cada una existe por un costo concreto; aplicarlas donde ese costo no existe agrega código y pruebas sin beneficio.
Una lectura lleva ETag solo cuando se lee mucho y cambia poco. Califican el catálogo público de planes (GET /plans), los datos de referencia, el perfil y la estructura de una organización, y el plan y los límites vigentes de una organización. No califican los datos operativos que cambian en segundos (pedidos, inventario, sesiones) ni las listas paginadas por cursor: su ETag cambiaría en casi toda lectura y el 304 no ahorraría nada, mientras que el hash sí costaría en cada respuesta. Un controlador que califica devuelve HttpConditionalResponse; HttpEnvelopeInterceptor compara If-None-Match y responde 304 sin cuerpo.
El ETag se deriva de los datos que la respuesta devuelve, nunca del reloj ni del proceso. Sirve una versión del agregado o un hash del contenido exacto (el catálogo usa un hash del catálogo del país pedido). Cada ruta con ETag tiene una prueba que cambia el dato subyacente y comprueba que el ETag cambia y que el If-None-Match anterior ya no produce 304; un ETag que no cambia cuando el dato cambia sirve contenido viejo sin que nadie lo note.
Una respuesta que depende del tenant o del usuario nunca lleva Cache-Control: public. public permite que un CDN o proxy compartido guarde la respuesta y se la entregue a otra persona. Esas rutas usan private, no-cache: el navegador conserva la copia, revalida con If-None-Match y nunca la comparte. Solo una lectura sin sesión e idéntica para todo llamador, como el catálogo (public, max-age=60), puede ser public.
Una escritura sobre un recurso que se lee con ETag acepta If-Match. Si el ETag enviado ya no coincide con el estado actual, assertIfMatchSatisfied (foundation/http/conditional-requests/conditional-request.ts) responde 412 y no escribe nada: dos administradores que editan el mismo perfil no se pisan en silencio.
La estructura de una organización usa una sola versión para toda la organización, y ese atajo tiene costos declarados. GET /organization y GET /organization/structure toman su ETag de organization.structure_versions, que avanza con cualquier escritura de la estructura o del perfil. Por eso dos administradores que editan cosas sin relación (un sitio y una marca) pueden recibir 412 el uno del otro si ambos mandan If-Match; el ETag del perfil cambia con cada escritura de la estructura aunque el perfil no haya cambiado; y una respuesta de escritura no devuelve un ETag nuevo, así que el cliente vuelve a leer antes de la siguiente escritura condicional. A cambio, If-Match cuesta leer una fila bloqueada y no un hash del contenido. La estructura además muestra la unidad de negocio y la entidad legal vigentes hoy en la fecha del sitio, que cambian sin ninguna escritura cuando pasa la medianoche del sitio o vence un valid_until; su ETag agrega un hash de esas asignaciones vigentes, así un If-None-Match viejo deja de dar 304 cuando cambian, y If-Match sigue aceptando ese ETag mientras la versión no cambie. El ETag del perfil no depende de fechas y no lleva ese hash.
Toda petición y toda respuesta tiene un esquema Zod estricto. El esquema de entrada rechaza campos desconocidos en vez de ignorarlos, así un campo mal escrito falla con 400 en lugar de perderse. El esquema de respuesta alimenta el documento OpenAPI y es lo que el frontend genera como tipo; una respuesta sin esquema es un contrato que nadie puede verificar.
Una ruta caliente entra a perf:http; una ruta administrativa no. Caliente es una ruta que se ejecuta en cada sesión o en cada pantalla de uso diario (iniciar sesión, refrescar sesión, leer el catálogo, leer el plan vigente). perf:http la mide con un cliente y con 16 clientes concurrentes y reporta p50, p90, p95 y p99. Las rutas del panel de plataforma se usan pocas veces al día y no entran.
Una consulta sobre una tabla que crece sin límite se revisa con EXPLAIN (ANALYZE, BUFFERS) antes de entrar. Si el plan hace un Seq Scan sobre esa tabla, la misma migración agrega el índice que el filtro y el orden necesitan. Las tablas acotadas por diseño (planes, mercados, personal de plataforma) no lo necesitan.
Las rutas que calculan un hash de contraseña tienen su propio objetivo de latencia. Iniciar sesión con contraseña, registrarse, y crear, cambiar o recuperar una contraseña ejecutan Argon2id, que es lento a propósito: esa lentitud es lo que encarece un ataque de fuerza bruta. Esas rutas se miden contra p50 < 100 ms, p90 < 150 ms, p95 < 200 ms y p99 < 300 ms; el resto de la API conserva los objetivos de AGENTS.md §5. Los parámetros de Argon2id (password-hasher.service.ts: 19 MiB, timeCost 2, parallelism 1) son el mínimo de OWASP y no se bajan para cumplir un objetivo de latencia. Cambiar a bcrypt no mejora nada: OWASP lo pone detrás de Argon2id, y su resistencia a hardware dedicado es menor al mismo costo. Si una ruta de contraseña no cumple su objetivo bajo carga, la respuesta es más CPU o limitar la concurrencia de hashing, nunca un hash más débil.
Los precios de un plan no incluyen impuestos. platform.plan_offerings.amount guarda el monto antes de impuestos y ninguna columna lo contradice; el frontend lo muestra como "sin IVA". Si un país exige mostrar el precio con impuestos incluidos, ese modo se agrega en markets (el país), no en la oferta, y solo cuando ese país exista.
D25. El detail de un error es técnico y en inglés; el cliente traduce desde code
detail en Problem Details explica la causa de un fallo a quien integra la API y siempre está en inglés. La API no traduce mensajes de error ni negocia su idioma con Accept-Language. El texto que ve el usuario final lo pone el cliente, a partir de code: un identificador estable (identity.sign_in_refused, foundation.validation_failed) que forma parte del contrato y nunca cambia sin versión nueva.
Traducir en el servidor obligaría a mantener cada mensaje (hoy unos 60) en todos los idiomas soportados, y cada fallo nuevo nacería con esa deuda. Además, el cliente sabe mejor que el servidor cómo contarle un error a su usuario: un mismo code puede verse distinto en una pantalla de caja y en un formulario de registro. Separar el mensaje para el integrador del mensaje para el usuario elimina esa deuda sin perder información, porque code sigue identificando el caso exacto y meta.errors sigue señalando el campo.
La negociación de idioma de src/foundation/locales/ queda reservada para contenido de negocio que sí se sirve localizado, como los nombres de catálogo de las tablas de traducción; ver CURRENCY_AND_LANGUAGE.md.
D26. El outbox se despacha desde PostgreSQL con un worker separado, nunca con BullMQ/Redis
Los cuatro flujos de seguridad de Identity (código de registro, alta de método de acceso, recuperación de contraseña, step-up) enviaban su código de verificación con una llamada síncrona y real al proveedor (SES, Wasender) dentro de la petición HTTP, hasta 10 segundos por intento, con cuatro órdenes distintas entre "confirmar el challenge" y "enviar el código" — incluido un caso, step-up, que enviaba antes de confirmar el challenge. AGENTS.md §5.4 y MODULE_CONTRACTS.md §4.3 nombraban BullMQ como despachador, un paquete que nunca se instaló; events.* ya tenía un outbox con forma de producción (claim_outbox_batch con FOR UPDATE SKIP LOCKED, lease, reintentos, dead letters) sin ningún grant y sin ningún módulo que lo usara. El diseño completo vive en OUTBOX_USE_CASES.md; esta decisión fija por qué su forma es la que es.
La cola vive en PostgreSQL; la despacha un worker que corre como su propio servicio de Railway (OB1), nunca BullMQ/Redis. El objetivo es que la API nunca se sature, y la saturación no viene de la tecnología de cola sino de dónde corren la espera al proveedor y sus reintentos: si esa espera vive dentro del proceso que atiende peticiones HTTP, un proveedor lento satura la API sin importar qué cola exista detrás de él. Moviendo el envío a un proceso separado, la fila de outbox se inserta en la misma transacción que el cambio de negocio (challenge, alta, etc.), así que un rollback de uno es un rollback del otro y ningún mensaje se pierde por una escritura que nunca comprometió. PostgreSQL, no BullMQ, es la fuente de verdad de la cola: no hay un segundo servicio (Redis) que pagar, operar ni mantener consistente con la base de datos, y events.outbox_records ya probaba el mecanismo (FOR UPDATE SKIP LOCKED + lease) sin necesitar nada nuevo. Si el volumen alguna vez lo exige, BullMQ puede sentarse detrás de este mismo outbox como un despachador más — esta decisión es sobre la fuente de verdad de la cola, no una prohibición permanente de BullMQ en el resto del sistema (AVAILABILITY_AND_PERFORMANCE.md §10, NESTJS_STACK_GUIDE.md §14 siguen permitiéndolo para trabajo asíncrono que no sea despacho de outbox).
Los mensajes de seguridad de Identity usan un outbox propio, sin organization_id (OB2). events.* y notifications.* exigen organization_id NOT NULL y su propia política RLS por tenant; los cuatro flujos de Identity ocurren antes de que exista contexto de tenant (un registro, un step-up, una recuperación de contraseña), así que forzarlos por el outbox tenant-scoped significaría inventar un organization_id falso o debilitar el NOT NULL/la RLS que protege cada fila de negocio real. identity.security_outbox_messages (propuesta) copia casi textual la forma ya probada de events.outbox_records, sin la columna de tenant, y un mismo worker despacha ambos outbox — la separación es estructural (una tabla sin organization_id no puede confundirse con una tenant-scoped), no solo una convención de código.
El OTP viaja cifrado y se borra apenas se envía (OB3). El código de verificación existe en claro solo en memoria, mientras se genera y se hashea para el challenge; en la tabla, solo como un sobre AES-GCM bajo una clave propia (distinta de la clave TOTP), con el id del challenge como contexto AAD — así un bug que mezclara el ciphertext de dos mensajes falla cerrado (open() lanza) en vez de entregar un código equivocado. El worker descifra, envía y borra la fila de ciphertext en la misma transacción que marca el mensaje published; un ciphertext que nunca llega a enviarse se borra cuando su challenge expira, así el secreto nunca sobrevive más tiempo del que puede ser útil.
La entrega es al menos una vez, nunca exactamente una vez (OB9). Un worker que se cae después de enviar pero antes de registrar el intento deja el mensaje reclamable de nuevo; el siguiente worker lo reenvía. Este es el mismo contrato que events.outbox_records ya documenta ("Publication is at-least-once; consumers must be idempotent"), heredado sin fortalecerlo. Un código duplicado es una molestia de UX, nunca un agujero de seguridad, porque el código sigue siendo de un solo uso en la capa del challenge; la alternativa — no reintentar una entrega ambigua (INDETERMINATE) — cambia ese riesgo por uno peor: dejar a la persona sin ningún código que funcione cuando el envío sí llegó a destino.
Un rol dedicado, ninaku_worker, es el único que despacha (OB6). ninaku_runtime (la API) nunca recibe EXECUTE sobre las funciones de claim/record de ningún outbox — solo sobre la función de encolado, dentro de la misma transacción que ya escribe el cambio de negocio. ninaku_worker recibe exactamente EXECUTE sobre claim/record/purge y SELECT+DELETE sobre el ciphertext, nunca acceso a identity.password_credentials, totp_methods, recovery_codes ni a ninguna tabla de negocio. Un proceso de API comprometido que intentara despachar un mensaje se refuta con 42501 antes de tocar una fila, la misma garantía estructural que ya separa platform.provision_organization de provision_organization_for_caller.
Excepción de sign_up al supersede de reenvío (OB7, revisión 40): un mensaje sign_up nunca supersede a otro, porque confirmar un registro exige la propia contraseña del registrante y, a diferencia de add_sign_in_method/password_recovery/step_up — siempre pedidos por el propio dueño de la cuenta, o donde cualquier código sirve para ella —, superseder dejaría a un atacante que registra la dirección de una víctima bloqueando el código todavía no entregado de esa víctima, una denegación de servicio vía reenvío que los otros tres propósitos nunca sufren.
Un dead letter no se reproduce: se reemite (OX-O2, revisión 44). El ciphertext de un mensaje se borra al pasar a dead_lettered (OB3) y el challenge guarda solo un hash argon2 del código, así que el código original no se puede recuperar ni reenviar; el comando de staff con step-up genera un código nuevo para el mismo challenge todavía abierto, reemplaza de forma atómica el hash del challenge (el código viejo deja de servir) y encola un mensaje nuevo con ese código sellado, rechazando los dead letters superseded_by_resend o challenge_expired, los challenges ya cerrados y los destinos que ya tienen un mensaje más nuevo vivo. La ruta vive en Platform, junto al resto de los comandos de staff, y el caso de uso completo llama al contrato público de Identity (SecurityOutboxOperationsContract), que es quien posee el generador de códigos, el hasher y el cifrador: es la forma mínima que cumple R1–R9 sin que Platform importe nada interno de Identity y sin duplicar esas piezas.
D27. Una invitación viaja por el outbox de seguridad de Identity (propuesta, J1)
Estado: propuesta, reversible. Delegada por el dueño del producto para el recorrido J1 "Traer a mi equipo" (ORGANIZATION_USE_CASES.md, decisiones J1 a).
Una invitación se entrega como un quinto propósito, invitation, de identity.security_outbox_messages, el outbox que el worker ya despacha (D26). El token de la invitación se sella con el mismo cifrador del outbox (security-outbox-otp-cipher.service.ts, AAD messageId:purpose), se envía por correo con SES o por WhatsApp con Wasender según el identifier_type de la invitación, y se borra en la transacción que marca el mensaje published, o cuando la invitación vence o se revoca. El token es un secreto al portador, justo el tipo de contenido para el que se construyó OB3.
Por qué no el outbox de tenant ahora. events.outbox_records no tiene código que lo escriba y el worker no lo reclama. Construirlo para un solo tipo de mensaje significa un segundo ciclo de reclamo, una segunda superficie de dead letters, RLS de tenant para ninaku_worker y un segundo lugar donde guardar un secreto; el outbox de tenant queda para eventos de negocio (OUTBOX_USE_CASES.md OX-E1 a OX-E3).
Qué necesita el esquema (cambio mínimo, una migración).
security_outbox_messages.challenge_idpasa a ser nullable y una nuevainvitation_id uuidreferenciaidentity.invitations(id); un check exige exactamente una de las dos, yinvitation_idexactamente cuandopurpose = 'invitation'. Un challenge no puede hacer de invitación:identity.authentication_challenges.identity_idesNOT NULLy un invitado puede no tener identidad.security_outbox_messages_purpose_chkadmite'invitation'.security_outbox_messages_one_pending_per_purpose_destination_uqy el supersede dentro deidentity.enqueue_security_outbox_messageexcluyen'invitation', como ya excluyen'sign_up': si no, la organización B que invita a una dirección mandaría a dead letter la invitación pendiente de la organización A a la misma dirección. Un reenvío retira a su predecesor porinvitation_id.identity.claim_security_outbox_messageshace hoy un inner join conidentity.authentication_challenges, así que una fila de invitación quedaría arrendada y nunca devuelta; debe tomar el vencimiento del challenge o de la invitación.identity.purge_expired_security_outbox_ciphertextsdebe hacer lo mismo, y además borrar el ciphertext de una invitación revocada.identity.open_security_outbox_reissue_as_staffrechaza un mensaje de invitación: la reemite el dueño (O33), staff no puede acuñar un token para un tenant.- Una nueva
SECURITY DEFINERidentity.enqueue_invitation_message(p_message_id, p_invitation_id, p_ciphertext, p_nonce, p_key_version)lee el destino de la fila de la invitación (la API no puede redirigir un mensaje) y aplica los límites de D29; una nuevaidentity.withdraw_invitation_message(p_invitation_id)manda a dead letter uno pendiente coninvitation_revoked. El claim devuelve el nombre de la organización para el texto del mensaje.
Revisión (revisión de arquitectura de J1-1, verificada contra la rama local feat/invitation-delivery-schema). El caso de uso de invitar no conoce el outbox: depende de un puerto de aplicación de Identity, InvitationDeliveryPort, con schedule(context, delivery) y withdraw(context, invitationId), donde context es el TransactionContext de src/foundation/database/transaction-context.ts de la misma transacción de tenant que inserta la invitación, así que la entrega sigue siendo atómica con el insert y con la revocación. El adaptador de hoy sella el token con el cifrador del outbox y llama a identity.enqueue_invitation_message / identity.withdraw_invitation_message; mañana puede ser un adaptador de Notifications sin tocar el caso de uso. Los límites de D29 viven en la función de PostgreSQL, no en el puerto: un adaptador futuro debe cargarlos (llamar a la misma función o reproducir sus reglas con sus pruebas) o deja a Ninaku escribiendo a terceros sin techo. La revisión encontró además, y se verificó en el código de la rama, que lo escrito arriba no se cumple todavía en cuatro puntos: el claim no devuelve el nombre de la organización (ni siquiera invitation_id); withdraw_invitation_message solo retira mensajes pending, y ni el claim ni identity.record_security_outbox_attempt miran el estado de la invitación, así que un mensaje ya arrendado de una invitación revocada o vencida todavía puede entregarse; el diferimiento entre organizaciones cuenta mensajes retirados o en dead letter y no tiene tope; y identity.list_security_outbox_dead_letters_as_staff sigue con un inner join a identity.authentication_challenges, así que staff no ve los dead letters de invitaciones que el backlog sí cuenta. Se corrigen en la tajada J1-1b, antes de J1-2 (ORGANIZATION_USE_CASES.md, plan de implementación). La migración 0054 (revisión 64) las corrige: el claim devuelve organization_name e invitation_id al final de sus columnas, no arrienda el mensaje de una invitación revocada, aceptada o vencida (lo manda a dead letter con el ciphertext borrado) y record_security_outbox_attempt no lo devuelve a pending; un mensaje ya entregado al proveedor no se puede retirar, y lo garantizado es que no hay un intento nuevo ni queda ciphertext.
D28. El secreto de la invitación y su aceptación (propuesta, J1)
Estado: propuesta, reversible (decisiones J1 b).
El token son 32 bytes aleatorios codificados en base64url. Solo se guarda sha256(token), en identity.invitations.token_hash (invitations_token_hash_chk ya exige 32 bytes o más; invitations_token_hash_uq lo vuelve una clave de búsqueda); un hash rápido alcanza porque el token tiene 256 bits de entropía, a diferencia de una contraseña o un código de seis dígitos. Ninguna respuesta, línea de log ni registro de auditoría lleva jamás el token, tampoco el del invitador: una sesión robada del invitador puede revocar o reemitir, pero nunca cosechar un enlace con el que alguien pueda unirse.
El enlace pone el token en el fragmento de la URL (/join#token=...) para que nunca llegue a un servidor ni a un log de proxy. La app lo envía en un cuerpo: POST /invitation-previews (sesión opcional, no escribe nada) y POST /invitation-acceptances (sesión obligatoria). Ninguna de las dos rutas recibe un id de invitación, así que los ids no se pueden sondear. Aceptar exige el token y una sesión cuya identidad tenga verificado el identificador invitado (identity_invitation_verified_recipient): un enlace reenviado, por sí solo, no une a nadie.
Errores: un token desconocido responde 404 · identity.invitation_not_found, idéntico para todo token desconocido; uno conocido pero vencido, revocado o ya aceptado responde 409 · identity.invitation_closed sin decir cuál (solo alcanza ese punto quien tiene el token); un token válido con la identidad equivocada responde 403 · identity.invitation_recipient_mismatch, que nunca dice si otra identidad es dueña del identificador. Ambas rutas usan ANSWERS_A_CHALLENGE_RATE_LIMIT.
Aceptar corre en una nueva función SECURITY DEFINER, identity.accept_invitation(bytea), porque el invitado no tiene contexto de tenant y toda política de las tablas de invitaciones, membresías, asignaciones de rol y alcances de acceso lo exige. Toma al llamador de app.identity_id, nunca de un parámetro, y escribe ella misma el registro de auditoría de tenant con los ajustes de la solicitud cambiados a la nueva membresía, con la misma forma que platform.provision_organization_for_caller y platform.record_organization_provisioned.
Rechazado: devolverle el enlace al invitador para que lo comparta a mano (la cosecha por sesión robada de arriba); aceptar solo con el token (cualquiera a quien se reenvíe el enlace se uniría).
D29. Límites de invitaciones, asientos, privilegio, divulgación de membresía y vencimiento (propuesta, J1)
Estado: propuesta, reversible (decisiones J1 c a g).
Límites contra abuso. Una organización puede hacer que Ninaku escriba a terceros arbitrarios, y el aprovisionamiento es autoservicio, así que los límites viven en PostgreSQL y no solo en el Throttler por IP. identity.enqueue_invitation_message (D27) es el único cuello de botella por el que pasa toda invitación saliente, y aplica, bajo advisory locks por organización y por identificador:
- invitaciones abiertas por organización ≤ el límite de plan
identity.pending_invitations(nueva fila deplatform.limit_definitions; 20 en Pionero, igual que sus asientos):409 · identity.invitation_pending_limit_reached. Es un límite de plan porque escala con el tamaño de equipo del plan; - como máximo 30 invitaciones nuevas por organización por cada 24 horas móviles, una constante en la función (un techo contra abuso, no un término comercial):
429 · identity.invitation_rate_limited; - como máximo un mensaje de invitación por identificador por organización por hora, entre invitar y reemitir:
429 · identity.invitation_rate_limitedconRetry-After; - como máximo 3 mensajes de invitación por identificador por cada 24 horas móviles entre todas las organizaciones; más allá, el mensaje se encola con un
available_atposterior en vez de rechazarse, para que un rechazo nunca revele que otra organización invitó a la misma persona. Necesita un índice nuevoidentity.invitations (identifier_type, normalized_value, created_at DESC).
El Throttler agrega un techo por IP: un nuevo INVITES_A_PERSON_RATE_LIMIT (20 cada 10 minutos) en invitar y reemitir, y ANSWERS_A_CHALLENGE_RATE_LIMIT en previsualizar y aceptar.
Asientos. Una invitación no reserva asiento (identity.assert_membership_seat_available ya lo dice). El dueño puede enviar más invitaciones que asientos libres; las aceptaciones por encima del límite se rechazan en el commit por el trigger diferido memberships_seat_limit_trg, con respuesta 409 · identity.seat_limit_reached, y la invitación sigue abierta para poder aceptarse cuando se libere un asiento. GET /memberships responde meta.seats = { used, limit } con un conteo y platform.effective_limit, ambos baratos, para que la app avise antes de enviar. Rechazado: un asiento por invitación abierta, que deja que invitaciones ociosas bloqueen contrataciones reales.
Privilegio. En J1 solo quien tiene organization.account.manage en toda la organización invita, revoca o reemite (identity.effective_permission(membership, code, NULL), la comprobación que ya usa todo comando de Organization). Un rol ofrecido debe estar activo, ser global o de esta organización, y no otorgar organization.account.manage, así que organization_owner nunca se puede ofrecer y una invitación nunca crea un administrador. Se aplica en el caso de uso y otra vez en SQL: identity.validate_invitation_role() (el trigger de identity.invitation_role_assignments) gana un rechazo para un rol que otorga organization.account.manage, y identity.accept_invitation vuelve a comprobar los roles que copia, porque los permisos de un rol pueden cambiar después de enviada la invitación. Los invitadores con alcance esperan a que exista un administrador con alcance; su regla (un alcance ofrecido debe estar cubierto por uno de los alcances administrativos del invitador) queda escrita en J1.
Ya es miembro. Invitar a un identificador verificado en una identidad con una membresía vigente (ended_at IS NULL) de esta organización responde 409 · identity.invitee_already_member: el invitador es un administrador autorizado y el dato concierne a su propia organización. Cualquier otro identificador, sea de un no miembro o de nadie, responde el mismo 201.
Vencimiento. Siete días por defecto, de 1 a 30 a pedido. Reemitir revoca la fila vieja (su token deja de servir en esa transacción) e inserta una nueva con un token nuevo y un vencimiento contado desde la reemisión, enlazada por una nueva columna replaces_invitation_id; token_hash y expires_at son inmutables por identity.protect_invitation_lifecycle(), así que extender una fila no es posible y mantendría vivo un enlace posiblemente filtrado.
Revisión (contención). La regla de privilegio de arriba es el primer paso de una regla general: un invitador puede ofrecer un rol con un alcance solo si (1) cada permiso del rol lo tiene él mismo en sus permisos efectivos, con un alcance que cubre el alcance ofrecido; (2) el alcance ofrecido está cubierto por uno de sus alcances administrativos; y (3) el rol no contiene ningún permiso administrativo no delegable. En J1 la comprobación de un único código (organization.account.manage) cumple (3), y (1) se cumple porque los roles ofrecibles no tienen permisos todavía (D30). Cuando existan más permisos administrativos, el código único se reemplaza por un atributo de identity.permissions que marque un permiso como administrativo (columna nueva, no existe hoy). Además, identity.enqueue_invitation_message debe comprobar en SQL que la membresía llamadora (identity.current_membership_id()) tiene organization.account.manage en toda la organización, porque identity.invitations.invited_by_membership_id es nullable y hoy la función solo exige contexto de tenant.
Revisión (diferimiento). El tope de 3 mensajes por identificador entre organizaciones cuenta solo los mensajes pending, leased o published de otras invitaciones, y el diferimiento nunca pasa de una hora antes del expires_at de la invitación: el tope cede ante la invitación en vez de matarla en silencio. Sin esto, una organización hostil que invita y revoca a la misma persona empuja las invitaciones de otra organización más allá de su vencimiento (verificado en la rama de J1-1: la consulta no filtra por estado ni acota el resultado). Rechazar en vez de diferir sigue descartado, porque revelaría que otra organización invitó a la misma persona.
D30. Roles del sistema por vertical, ofrecidos según el contexto (propuesta, J1)
Estado: propuesta, reversible. Decidida por el dueño del producto el 2026-10-02: quien invita elige un rol de trabajo concreto (mesero, cocina, cajero, gerente), y las dos primeras verticales en salir son Restaurante y Retail.
Tres clases de rol, sin jerarquía. Un rol de identity.roles es (1) neutral: lo define Ninaku, organization_id y vertical_code nulos, sirve en cualquier negocio; (2) de vertical: lo define Ninaku, organization_id nulo, vertical_code no nulo, solo tiene sentido donde esa vertical está activa; (3) propio de una organización ("Barista"): organization_id no nulo, lo crea el dueño en un recorrido posterior de administración de roles, no en J1. Ningún rol tiene rango ni hereda de otro: lo que un rol permite es exactamente la lista de sus permisos en identity.role_permissions, y "gerente puede más que cajero" se expresa con permisos, nunca con un orden entre roles.
Una columna nueva. identity.roles.vertical_code varchar(80) NULL, con llave foránea a platform.verticals(code) (constraint nueva roles_vertical_fk); NULL significa neutral. Un check nuevo exige que un rol propio de una organización no tenga vertical en esta versión (organization_id IS NULL OR vertical_code IS NULL), para que la regla de oferta de abajo no tenga un caso que nadie diseñó. Los índices únicos de código (roles_system_code_ci_uq, roles_organization_code_ci_uq) no cambian: un código de sistema es único entre todas las verticales.
Quién los define. Ninaku, por migración, nunca la API: ninaku_runtime solo tiene SELECT sobre identity.roles y identity.role_permissions, y la política roles_insert_policy exige organization_id del tenant. Hoy no existe ninguna fila en platform.verticals en una base creada con db:prepare (solo las pruebas las insertan), así que la misma tajada siembra primero las verticales restaurant ("Restaurant") y retail ("Retail"), ambas active.
Los siete roles sembrados (organization_id nulo, status = 'active'). El esquema tiene una sola columna name (varchar(120)) y ninguna tabla de traducción de roles, así que name va en inglés y la app muestra el nombre en español a partir de code, igual que traduce un error desde su code (D25); no se agrega una tabla identity.role_translations hasta que exista un rol propio con nombre escrito por el cliente que deba traducirse.
code | vertical_code | name | description | Nombre en la app (es) |
|---|---|---|---|---|
manager | — | Manager | Runs the daily operation of the places in its scope. Not an administrator: never holds organization.account.manage. | Gerente |
cashier | — | Cashier | Takes payments and closes sales at a point of sale. | Cajero |
waiter | restaurant | Waiter | Serves tables: opens service, takes orders and sends them to the kitchen and the bar. | Mesero |
kitchen | restaurant | Kitchen | Prepares the food orders sent to the kitchen. | Cocina |
bar | restaurant | Bar | Prepares the drink orders sent to the bar. | Bar |
sales_associate | retail | Sales associate | Attends customers on the sales floor and prepares their sales. | Asesor de ventas |
stock_keeper | retail | Stock keeper | Receives goods, counts stock and moves it between storage locations. | Bodeguero |
Nacen sin permisos, y es seguro. Los permisos nacen con el módulo que los aplica (ventas, cocina, inventario no existen todavía), así que los siete roles se siembran sin filas en identity.role_permissions. identity.effective_permission(membership, code, outlet) hace inner join de role_assignments con role_permissions y permissions, así que para una membresía cuyos únicos roles no tienen permisos devuelve false para todo código y todo alcance: el rol es una etiqueta que no concede nada. Cada módulo futuro agrega sus permisos a estos roles en su propia migración (INSERT ... ON CONFLICT DO NOTHING sobre identity.role_permissions), y en la misma migración se los agrega a organization_owner, porque la regla de contención (revisión de D29) exige que quien invita tenga cada permiso que ofrece: si el dueño no los tuviera, dejaría de poder invitar a un mesero el día que el mesero gane su primer permiso.
Revisión (K1 del catálogo, migración 0075). El catálogo es el primer módulo que da permisos a estos roles, tal como dice CATALOG_USE_CASES.md §7.2: manager recibe catalog.items.manage, catalog.items.view, catalog.assortment.manage y catalog.sellables.view; cashier y waiter, solo catalog.sellables.view; sales_associate, catalog.items.view y catalog.sellables.view; stock_keeper, catalog.items.view; kitchen y bar, ninguno; y organization_owner recibe los cuatro. Desde entonces el punto (1) de la regla de contención de D29 ya no se cumple por estar vacíos los roles: hoy solo se comprueba el permiso administrativo (3), así que un administrador cuyo rol propio tiene organization.account.manage y ningún permiso del catálogo puede ofrecer manager. El dueño tiene los cuatro, así que su camino no cambia; comprobar (1) queda pendiente para Identity.
Regla de oferta. Una invitación (y, más adelante, O26) puede ofrecer un rol con un alcance solo si el rol está active, es de sistema o de esta organización, no es administrativo (D29), y además es neutral o su vertical está activa (platform.business_unit_verticals.status = 'active') en al menos una de las unidades de negocio que cubre el alcance. Las unidades cubiertas son: toda la organización → sus unidades de negocio activas; una unidad de negocio → esa; una sucursal (outlet) → su unidad de negocio efectiva hoy (organization.effective_business_unit(org, outlet, current_date)) y, con include_descendants, también las de sus descendientes; una marca, un sitio o una persona jurídica → las unidades efectivas hoy de sus sucursales. Un alcance que no cubre ninguna unidad de negocio solo puede recibir roles neutrales. Se aplica en el caso de uso y otra vez en SQL, en un trigger nuevo sobre identity.invitation_access_scopes y dentro de identity.accept_invitation, porque una vertical puede desactivarse después de enviada la invitación. Rechazo: 422 · identity.invitation_role_vertical_inactive (código nuevo).
Un rol de una vertical que la organización no usa existe en el catálogo global y la organización lo lee (la política roles_select_policy deja leer todo rol de sistema), pero nunca se le ofrece ni se le puede asignar con un alcance donde esa vertical no está activa. Desactivar una vertical (O11) no borra asignaciones ya hechas ni invitaciones abiertas: la asignación sigue siendo una etiqueta, el módulo de la vertical exige la vertical activa en cada comando (platform.assert_operation_allowed ya recibe p_vertical_code), y una invitación abierta se rechaza al aceptarla.
Retirar un rol, nunca borrarlo. Un rol sembrado no se borra jamás (role_assignments y invitation_role_assignments lo referencian y el historial lo necesita). Se retira con status: inactive (reversible) o archived (final), siempre por migración. Un rol no active no se ofrece (identity.validate_role_assignment ya lo rechaza, 422 · identity.invitation_refused) y, ojo, deja de conceder sus permisos a quien ya lo tiene, porque effective_permission exige r.status = 'active'; retirar un rol con titulares exige mover antes a esas personas a otro rol.
Rechazado:
- Una lista global de roles sin vertical: el dueño de una tienda vería "Mesero" y "Cocina" en su formulario, y nada impediría ofrecerlos donde no significan nada.
- Copias por organización creadas al activar una vertical: N copias de cada rol que cada migración de permisos tendría que alcanzar, y la desactivación tendría que decidir qué hacer con ellas.
- Jerarquía o rango entre roles: duplica lo que ya dicen los permisos y vuelve opaca la contención ("rango mayor" no dice qué permisos se delegan).
D31. Las tareas pesadas de un módulo usan una cola propia del módulo con organization_id
Estado: vigente (OD7, ASSETS_USE_CASES.md §4.2 SG7; registrada en la etapa A1a-1, migración 0083).
Un módulo cuyo trabajo pesado (procesar una imagen, generar sus renditions, purgar sus objetos) no es un evento de integración guarda ese trabajo en una tabla de cola propia, dentro de su propio esquema, con organization_id NOT NULL y la misma forma que events.outbox_records: estado (pending, leased, completed, dead_lettered), lease (lease_owner, lease_token, lease_expires_at) con su check de forma, attempt_count acotado, available_at para el backoff y dead letter al agotar los reintentos. La fila se encola en la misma transacción que el cambio de negocio, y solo ninaku_worker la reclama y registra su resultado, siempre a través de funciones SECURITY DEFINER del módulo (FOR UPDATE SKIP LOCKED más lease, como D26); ninaku_runtime nunca reclama ni despacha.
events.* queda para los eventos de integración entre módulos: un trabajo interno de un módulo no es un hecho que otros módulos consuman, y meterlo en events.outbox_records mezclaría sus reintentos, su retención y sus permisos con los de la integración. El primer usuario es assets.processing_jobs (SG7).
Rechazado: reusar events.outbox_records para el trabajo interno de un módulo (la razón de arriba), y BullMQ/Redis como fuente de verdad de la cola (D26).
D32. Un punto de venta solo puede ocultar artículos del surtido de su unidad de negocio
Estado: vigente (decidida por el dueño, CATALOG_OPTIONS_V2.md OV24 y "K16 as built"; registrada en la rebanada K16, migración 0106).
El catálogo es abstracto y se administra por unidad de negocio o por punto de venta, sin banderas por canal. La unidad de negocio sigue siendo la unidad del surtido (business_unit_catalogs, business_unit_items) y varios puntos de venta pueden compartirla. Para decir "este artículo no se vende en este punto de venta" existe catalog.outlet_hidden_items, una capa de excepciones que solo ocultan: lo que vende un punto de venta es el surtido de la unidad que lo opera hoy menos los artículos que oculta, nunca algo que la unidad no lista. Una fila por punto de venta y artículo; ocultar un artículo oculta todas sus variantes; las filas sobreviven si el punto de venta cambia de unidad y se aplican al surtido de la nueva. Las cartas siguen siendo presentación y nunca limitan lo que se vende.
La regla vive en un solo fragmento SQL (hiddenAtOutlet, una sonda por clave (organization_id, outlet_id, item_id) por artículo candidato) que comparten la lectura del punto de venta, la búsqueda, el escaneo, resolve y admit de CatalogSellableContract (con outletId opcional: si el punto de venta no existe, es de otra organización o no lo opera la unidad dada, se rechaza con outlet_not_operated_by_unit en la misma sentencia, y sin él se juzga la unidad sola) y la disponibilidad de los componentes de un combo; catalog.variant_is_listed_at_outlet es su par en SQL y no cambia ninguna firma existente.
Rechazado: un surtido propio por punto de venta (dos fuentes de verdad del surtido y un punto de venta que vende lo que su unidad no lista), y una bandera por canal en el catálogo (OV18: el canal no es un hecho del catálogo).
D33. Las franjas horarias de una carta viven en la asignación al punto de venta y las evalúa el servidor en la zona horaria del sitio
Estado: vigente (decidida por el dueño, CATALOG_OPTIONS_V2.md "S1 as built" y CATALOG_USE_CASES.md C31; registrada en la rebanada S1, migración 0107).
Un negocio muestra cartas distintas a horas distintas en un punto de venta ("Desayuno" hasta las 11:00, "Almuerzo" ciertos días). Las cartas solo organizan la pantalla de la caja y nunca limitan lo que se vende: admit las ignora. La franja pertenece a la asignación de la carta al punto de venta (catalog.outlet_menus), no a la carta: la carta sigue siendo reutilizable en toda la organización y la misma carta puede abrir de 06:00 a 11:00 en un punto de venta y de 07:00 a 12:00 en otro. Una carta asignada sin franja se muestra siempre. Una franja son días de la semana ISO (1 a 7, todos si se omiten) y una hora de inicio (inclusive) y de fin (exclusiva) en hora local del sitio del punto de venta; una franja que termina antes de lo que empieza cruza la medianoche y pertenece al día en que empieza. Hasta 14 franjas por carta (dos servicios por cada uno de los siete días) y las que se solapan en una misma carta se rechazan (422 catalog.menu_windows_overlap), no se fusionan: dos franjas que comparten un minuto casi siempre son un error de captura y fusionarlas lo ocultaría. Se guardan en catalog.outlet_menu_windows y catalog.outlet_menu_window_weekdays (dos tablas porque una carta necesita varias franjas y cada franja un conjunto repetido de días, y no caben como columnas de outlet_menus).
El servidor evalúa, en SQL, en el momento de la lectura y con la zona horaria del sitio (organization.outlet_operating_context publica ahora site_timezone, así Catalog no lee tablas de Organization). El instante lo da un Clock de Foundation que el caso de uso lee una vez y pasa como parámetro a las dos sentencias de la lectura de la caja, así que las pruebas lo inyectan. La comparación es de reloj de pared: un cambio de horario de verano mueve la franja con el reloj. La etiqueta de la caja se deriva del conjunto de cartas abiertas (una máscara de bits sobre sus posiciones, exacta, no un hash) y del próximo instante en que cambia (menusValidUntil, que viaja también en el cuerpo), de modo que cambia sin ninguna escritura cuando el reloj abre o cierra una franja y nunca contesta 304 a un conjunto viejo. Quedan fuera de S1 los feriados y los rangos de fechas.
Rechazado: una columna de horario en catalog.menus (la carta es de toda la organización y cada punto de venta tiene su horario), que el dispositivo evalúe las franjas (dos relojes y dos zonas horarias que pueden discrepar, y un 304 sin forma de saber que el conjunto cambió), un hash corto del conjunto en la etiqueta (una colisión contestaría 304 a un conjunto viejo; la máscara no colisiona), una ruta hermana /menu-windows (dos escrituras para un mismo caso de uso, con dos If-Match y dos registros de auditoría) y fusionar las franjas solapadas.
D34. Las variantes se definen por dimensiones estructuradas del catálogo y una combinación de valores no se repite entre variantes activas
Estado: vigente (decidida por el arquitecto de la rebanada K13, CATALOG_OPTIONS_V2.md sección 8 y "K13 as built"; registrada en la rebanada K13a, migración 0109).
La matriz de variantes escribía códigos y nombres concatenados (m-azul, M / Azul): no había forma de pedir "todas las variantes azules" ni de filtrar por una dimensión. Las dimensiones son datos del catálogo, no del artículo, así "Azul" es el mismo valor en una camisa y en un pantalón. Cada tabla responde las siete preguntas de API_AND_SCHEMA_DISCIPLINE.md, y en las cuatro el dueño es Catalog, el lector es la lectura de artículos, el listado de dimensiones y el filtro del listado de artículos, y el escritor es la matriz de variantes (POST /catalog/items/{itemId}/variant-matrix):
variant_dimensions: una dimensión del catálogo (Talla, Color), concodeúnico por catálogo sin distinguir mayúsculas; no cabe enitemsporque la comparten todos los artículos y el filtro cruza artículos. Se crea la primera vez que una matriz la nombra y se archiva, nunca se borra.variant_dimension_values: un valor (M, Azul) con id estable,codeúnico por dimensión; no cabe como texto de la variante porque un cambio de nombre no puede cambiar su identidad ni sus traducciones futuras. Se retira, nunca se borra.item_variant_dimensions: cuáles dimensiones, y en qué orden, definen las variantes de un artículo; como máximo tres (sort_order0 a 2 único por artículo, de modo que la base de datos rechaza una cuarta sin necesitar un disparador). Se fija con la primera matriz del artículo y no cambia.item_variant_dimension_values: el valor que una variante tiene en cada dimensión del artículo (una fila por variante y dimensión; llaves compuestas hacen que la dimensión sea una del artículo y la variante sea de ese artículo). No cabe enitem_variantsporque son hasta tres hechos con identidad propia.
Invariante: dos variantes activas de un artículo no repiten la misma combinación de valores. Lo impone la base de datos con disparadores de restricción diferidos (al insertar vínculos y al pasar una variante a active), serializados por artículo con FOR NO KEY UPDATE sobre su fila; las variantes sin vínculos (la variante por defecto y las anteriores) se toleran. Que una variante vinculada tenga un valor por cada dimensión del artículo lo comprueba la aplicación, no el disparador. Las variantes existentes ganan sus valores cuando el dueño vuelve a aplicar la matriz: sus códigos son opacos y no se analizan.
Rechazado: columnas shirt_size o shoe_color por vertical (el modelo no se amplía por rubro), atributos descriptivos como material o corte (no definen la variante; esperan un lector con nombre), analizar los códigos concatenados para rellenar los valores (adivinar identidad) y un disparador de conteo para el máximo de tres (la restricción de posición lo cubre).
D35. El límite de cada opción por oferta vive en su propia tabla
Estado: vigente (rebanada K14a, migración 0108; faltaba su justificación).
catalog.item_modifier_group_option_overrides guarda, por oferta de un grupo de modificadores en un artículo y por opción, el máximo de unidades que esa oferta permite (0 a 999; 0 significa que la opción no se ofrece en esa oferta). El dueño es Catalog; la escribe el caso de uso que fija las ofertas de un artículo (K14b) y la leen la lectura del artículo y la caja. El invariante es una fila por oferta y opción, con la opción del mismo grupo que la oferta. No cabe en la oferta ni en la opción porque una oferta necesita un límite por cada opción y una opción tiene un límite distinto por cada oferta. Las filas se reemplazan con borrar e insertar junto con los valores por defecto de la oferta, y se borran en cascada con ella.
D36. Una opción de texto y sus indicaciones de presentación son columnas de la opción
Estado: vigente (rebanada K14c, migración 0110; CATALOG_OPTIONS_V2.md OV13, OV14 y "K14c as built").
Un grabado o un mensaje de pastel es una opción cuyo valor escribe el cliente. Se agrega el tipo text a catalog.modifier_options junto con cuatro columnas que son indicaciones: text_max_length (1 a 500, solo para text), prefix (add, extra, light, no, on_the_side, substitute), prints_on_receipt e prints_on_kitchen_ticket. No se crea ninguna tabla: cada dato es un atributo de una sola opción, lo escribe el mismo caso de uso que fija las opciones de un grupo (PUT /catalog/modifier-groups/{modifierGroupId}) y lo leen las mismas lecturas que ya leen la opción. Un prefijo no cambia ninguna venta: Producción decide el efecto (add, remove, replace, scale) y Catalog solo evita las combinaciones que Producción rechazaría al vender, con restricciones CHECK (light, no, on_the_side y substitute piden cantidad máxima 1; no, on_the_side y substitute solo van en opciones instruction; una opción text tiene cantidad exactamente 1 y ningún prefijo o add). Que extra como efecto scale pida cantidad 1 (X11) no se puede imponer aquí: el efecto es de Producción (OV18) y ella lo rechaza al vender.
Catalog no guarda el texto que escribe un cliente: lo guardará Ventas (X3, X10), que debe agregar la rama text a su comprobación de tipos antes de consumir opciones de texto. Las ofertas guardadas no se vuelven a juzgar al escribir una opción (K5).
Rechazado: una tabla de presentación por opción (un dato por opción no necesita identidad propia), un tipo de grupo input en lugar de un tipo de opción (un grupo mezcla opciones de texto y de elección), guardar el prefijo como un efecto de venta (acoplaría Catalog a Producción) y banderas por canal para la impresión (OV18, OV24: solo dos indicaciones, sin canal).
D37. Las marcas regulatorias de un artículo viven en su propia tabla y el catálogo no hace cumplir ninguna
Estado: vigente (rebanada K15a, migración 0111; CATALOG_OPTIONS_V2.md sección 17 y "K15a as built").
catalog.item_regulatory_flags (item_id, flag) declara qué es el producto para una farmacia, una licorería o una ferretería: age_restricted_alcohol, age_restricted_tobacco, prescription_only, pharmacy_only o hazardous_goods. El dueño es Catalog; la escribe el caso de uso que actualiza el artículo (PATCH /catalog/items/{itemId} con regulatoryFlags, y el alta del artículo) y la leen la lectura del artículo y, más adelante, Ventas o un módulo de cumplimiento para aplicar su propia regla. El invariante es una fila por artículo y marca, con las marcas del vocabulario cerrado de la restricción. Su ciclo de vida es reemplazar el conjunto completo (borrar e insertar, por eso el rol de la API recibe SELECT, INSERT, DELETE y nunca UPDATE) y borrarse con el reemplazo. No cabe en items porque un artículo tiene varias marcas, ni como arreglo de texto porque el vocabulario cerrado y la llave compuesta del artículo (organización y catálogo) los impone la base de datos. Catalog declara y no hace cumplir: no bloquea ventas, no pide edad ni receta; la regla pertenece a quien la aplica y lee la marca a través de sus contratos (OV18).
La misma migración acota las descripciones de artículos, categorías y grupos de modificadores a 2000 caracteres (el límite que la API ya imponía, pero la API no es el único escritor posible de una base con datos: importaciones y cargas por SQL no están probadas), exige que el mínimo y el máximo de venta de una variante sean múltiplos de su paso (la API aceptaba cualquier política que cumpliera item_variants_sales_quantity_policy_chk, así que un mínimo de 0,1 con paso 0,25 pudo guardarse) y agrega el contenido neto de la etiqueta a la variante (net_content_quantity y net_content_unit_code, los dos o ninguno). La unidad del contenido neto se valida en la aplicación contra reference.units_of_measure y no tiene llave foránea (OV18: ninguna llave nueva hacia otro módulo). El precio por unidad lo calcula Pricing. Las cuatro restricciones se crean NOT VALID y un bloque DO las valida solo si una consulta de auditoría no encuentra filas que las rompan; si las encuentra, la migración no revierte el despliegue: deja la restricción sin validar, emite un WARNING con la cuenta de filas y no toca ningún dato. Toda escritura nueva se comprueba igual; una fila vieja que la rompe solo puede cambiarse dejándola válida (corregirla es una decisión del dueño de los datos, nunca de la migración). Una instalación limpia no tiene filas, así que las valida y el canónico declara las restricciones válidas.
Rechazado: una columna de arreglo de marcas en items, hacer cumplir la edad o la receta dentro de Catalog, una llave foránea de la unidad del contenido neto, y quitar catalog_items_organization_status_idx sin una prueba de que ningún plan la usa.
D38. La marca del producto es del catálogo y no es la marca de la organización
Estado: vigente (rebanada K15c, migración 0112; CATALOG_OPTIONS_V2.md sección 17.1 y "K15c as built").
organization.brands (0020_organization.sql) es la marca comercial con la que opera la organización (su nombre de cara al cliente). Un artículo del catálogo necesita otra cosa: la marca del fabricante del producto que vende (Coca-Cola, Bosch, Nike), para buscar y filtrar. Son hechos distintos con dueños distintos, y mezclarlos obligaría a una llave foránea de Catalog hacia el esquema de Organization (OV18, D18). catalog.product_brands (id, organization_id, catalog_id, name, status) responde las siete preguntas así: el dueño es Catalog; el hecho es «esta marca de fabricante existe en este catálogo»; la escriben el alta, el cambio de nombre y el archivo de una marca (POST /catalog/product-brands, PATCH y POST .../transitions); la leen la lista de marcas, la lectura del artículo (productBrand: { id, name }) y el filtro productBrandId de la lista de artículos; el invariante es un nombre por catálogo sin distinguir mayúsculas, archivadas incluidas, y que la marca de un artículo sea del mismo catálogo y organización (llave foránea compuesta items_product_brand_fk, que no se aplica cuando el artículo no tiene marca); el ciclo de vida es active o archived y nunca se borra (el rol de la API no recibe DELETE); y no cabe en items porque muchos artículos comparten una marca y el nombre se renombra en un solo lugar.
Archivar una marca que artículos activos usan está permitido y el artículo la conserva: archivar significa «no la ofrezcas para artículos nuevos», no «sácala de los existentes». El artículo sigue mostrando productBrand y se puede filtrar por ella; para quitarla se edita el artículo con productBrandId: null. Un artículo no puede recibir una marca archivada (422 catalog.product_brand_archived). El catálogo admite como máximo 1000 marcas, archivadas incluidas, para que la lista las devuelva todas.
Rechazado: reutilizar organization.brands (acopla Catalog con Organization y confunde dos hechos), guardar la marca como texto libre del artículo (sin catálogo de nombres no hay filtro fiable), una tabla puente artículo-marca (un artículo tiene a lo sumo una marca) y un nombre único solo entre las activas (un renombrado o una restauración podrían chocar sin que nadie lo vea).
D39. Los identificadores del fabricante son filas de la variante, no códigos de canal
Estado: vigente (rebanada K15d, migración 0113; CATALOG_OPTIONS_V2.md sección 17 y "K15d as built").
Una variante puede llevar identificadores propios del producto: el número de parte del fabricante (mpn), el isbn de un libro y el código del fabricante, en catalog.item_variant_identifiers (item_variant_id, scheme, value). Hay a lo sumo un valor por esquema por variante (llave primaria (item_variant_id, scheme)), el valor tiene de 1 a 100 caracteres y no es blanco (el NUL no cabe en un varchar de PostgreSQL y la API lo rechaza antes), y no es único entre variantes: el documento de diseño no lo pide, dos proveedores pueden compartir un MPN y una unicidad inventada rechazaría catálogos legítimos. Los códigos de barras siguen siendo de barcodes y los identificadores de canal (marketplaces, GTIN de un tercero) quedan fuera: pertenecen a la integración que los usa.
Se escriben dentro de la escritura de variante que ya existe (identifiers, una lista opcional que reemplaza el conjunto) y la lectura del artículo los muestra solo cuando la variante tiene alguno, de modo que ninguna respuesta existente cambia.
Rechazado: una columna por esquema en item_variants (cada esquema nuevo sería una migración de la tabla caliente), unicidad global del valor, y mezclar estos identificadores con los códigos de barras.
D40. El catálogo publica sus cambios como eventos tipados, escritos por disparadores en la transacción del cambio
Estado: vigente (rebanada K12a, migración 0114; CATALOG_OPTIONS_V2.md sección 7 y "K12a as built").
Un dispositivo sin conexión necesita saber qué cambió después de la versión del catálogo que ya tiene, sin volver a leerlo todo, y un retiro tiene que llegarle como un hecho que pueda aplicar (una lápida), no como una ausencia. Cada sentencia que mueve catalog.catalog_versions escribe además, en la misma transacción y bajo el mismo bloqueo de la fila de versión, un evento por agregado que cambió: catalog.item, variant, barcode, offer, modifier, bundle, menu, category y listing .changed, versión de contrato 1. La carga es mínima y estable: schemaVersion, catalogVersion (la versión de la organización después de esa sentencia), change (upserted, retired o removed), el id del agregado, el status cuando el evento sale de la fila del propio agregado y, en un retiro o una eliminación, retiredAt (la lápida). Nunca lleva nombres, precios, códigos de barras ni secretos: el dispositivo relee por id. El contrato ejecutable es src/modules/catalog/contracts/catalog-change-event.ts.
Por qué disparadores y no código de aplicación. El invariante es «toda sentencia que mueve la versión deja un evento». Los disparadores de versión de 31 tablas ya viven en la base y los activan todos los comandos, también los que se agreguen; repetir la escritura del evento en 126 archivos de infraestructura haría que una escritura nueva olvidada dejara de anunciarse sin que ninguna prueba fallara. Los nuevos disparadores corren después de los de versión (el nombre _change_feed_ ordena después de _catalog_version_, y un contrato lo verifica), leen la versión que la sentencia acaba de producir y escriben con events.append_tenant_event, la función que Events publica: SECURITY DEFINER, exige contexto de tenant, exige que el tipo de evento pertenezca al módulo productor y tenga una versión de contrato publicada, y crea el evento, su publicación y su fila de outbox. El propio disparador (catalog.record_change_feed) es SECURITY DEFINER con search_path fijado, de modo que quien escribe una tabla del catálogo no necesita ningún privilegio sobre events: el rol de la API no recibe USAGE sobre el esquema ni EXECUTE sobre la función, no puede reescribir, borrar ni forjar un evento, y ningún rol de prueba ni futuro que escriba el catálogo necesita una concesión nueva. Catalog no escribe tablas de otro módulo (OV18, D18).
Orden de bloqueos. Todo comando del catálogo toma primero la fila de versión; el evento toma después el contador de publicaciones de la organización. Un productor de eventos de otro módulo que tome el contador antes que la fila de versión del catálogo (por ejemplo Sales emitiendo un evento antes de llamar a admit) puede entrar en deadlock con un comando del catálogo: ese productor debe emitir sus eventos después de admit.
Fuera de un contexto de tenant. Una escritura privilegiada sin contexto de tenant (una migración, una herramienta administrativa) mueve la versión y no escribe eventos: Events exige el tenant, y exigirlo en el disparador rompería las migraciones de datos. Un dispositivo cuya versión local queda detrás de una versión sin evento debe pedir un arranque nuevo (K12b); una migración que cambie datos del catálogo lo declara.
Qué queda fuera. product_brands, variant_dimensions y variant_dimension_values mueven la versión y no escriben eventos: ninguna lectura del punto de venta los usa todavía (K12b decide si el arranque los lleva, y entonces se mapean). Las tablas de traducciones, alérgenos, etiquetas dietéticas y nutrición no tienen disparadores de versión porque ningún caso de uso las lee ni las escribe; los agrega la primera rebanada que las use (C23, C24). Un contrato falla si aparece una tabla del catálogo con disparador de versión que no escribe eventos ni está en la lista de exclusiones, o una tabla sin disparador de versión fuera de la lista de pendientes.
Dentro de la función. events.append_tenant_event aplaza (SET CONSTRAINTS ... DEFERRED) las tres comprobaciones de completitud del evento y la llave del contador: son diferibles pero una transacción que ya pidió SET CONSTRAINTS ALL IMMEDIATE las evaluaría entre la inserción del evento y la de su publicación y su outbox, y fallaría. Los disparadores diferidos del catálogo (la asignación de la primera unidad de negocio) escriben sus eventos al confirmar.
Rechazado: escribir los eventos desde los casos de uso o los repositorios (arriba), un evento por comando con la lista de cambios (el comando no conoce lo que tocan las cascadas y la cobertura por versión deja de ser exacta), una tabla propia catalog.change_log (duplicaría Events, su outbox y su orden de publicación), dar INSERT sobre events.domain_events o EXECUTE sobre la función al rol de la API (permitiría forjar eventos de cualquier módulo) y llevar nombres o precios en la carga (duplicaría hechos que ya tienen dueño y filtraría datos a un evento que se reenvía).
D41. El arranque de un dispositivo es un corte fijado a una versión, no una instantánea larga
Estado: vigente (rebanada K12b, sin migración; CATALOG_OPTIONS_V2.md sección 7 y "K12b as built").
Un dispositivo que trabaja sin conexión descarga lo que vende un outlet en muchas páginas y después aplica los eventos de D40. Para que la composición sea exacta, la descarga tiene que ser un corte del catálogo: el estado en una versión V, con todos los eventos de versión menor o igual a V ya dentro y ninguno de versión mayor. GET /catalog/outlets/{outletId}/bootstrap responde el corte (la versión del catálogo, la versión de la estructura y la unidad de negocio que opera el outlet, como un valor opaco cut) y los datos de referencia en una sola instantánea REPEATABLE READ de solo lectura, con el mismo permiso y el mismo modelo del punto de venta (catalog.sellables.view en el outlet). GET /catalog/outlets/{outletId}/bootstrap/items?cut=... entrega las páginas de artículos, sin tope de cantidad, con cursor por llaves; cada página se lee en su propia instantánea, compara primero el corte actual con el pedido y responde 409 catalog.bootstrap_cut_moved sin leer nada si cambió. Si todas las páginas de una descarga vieron el mismo corte, ninguna escritura del catálogo ni de la estructura se confirmó entre la primera y la última lectura, así que el resultado es exactamente el catálogo en V.
Por qué no una instantánea de toda la descarga. Una instantánea de PostgreSQL vive dentro de una transacción de una conexión; no puede cruzar peticiones HTTP, y mantener una transacción abierta (o una instantánea exportada) durante toda la descarga de un dispositivo retendría una conexión del pool y la limpieza de versiones de fila por minutos, por dispositivo. La alternativa de una sola respuesta no tiene tope. Por qué no «reiniciar solo las páginas que un delta tocó». Exigiría servir una página tal como estaba en V después de una escritura posterior, y el catálogo actualiza las filas en su lugar y no guarda versiones anteriores. El costo aceptado: si el catálogo cambia durante la descarga, el dispositivo la reinicia desde la apertura. Una página de 100 artículos (el máximo del contrato HTTP) cuesta unos milisegundos (PERFORMANCE_BASELINE.md sección 23), así que 3.000 artículos son 30 páginas y una ventana de pocos segundos; un reinicio solo es probable si el catálogo se edita más de una vez por descarga.
El empalme con el feed. El dispositivo aplica los eventos catalog.*.changed de la organización cuyo catalogVersion es mayor que el del corte, en el orden de publicación, y descarta los de versión menor o igual (ya están en los datos). No detecta huecos por aritmética de versiones: la versión también se mueve por tablas que no escriben eventos (product_brands, variant_dimensions, variant_dimension_values, D40) y el feed solo anuncia lo que un dispositivo lee. Una lápida (retired o removed) borra lo que su id nombra y todo lo que cuelga de ahí; un upserted obliga a releer ese agregado. El servidor de eventos y la lectura por id de un agregado son del módulo Sync, que no existe: Catalog expone el corte y las páginas y documenta lo que Sync debe agregar (un cursor de eventos «después de la versión V» filtrado a catalog.*, y la relectura por id).
Qué lleva y qué no. Los artículos que el outlet vende (la regla del punto de venta, OV24: la unidad de negocio decide el surtido y el outlet solo oculta), cada variante con su sku y sus códigos de barras activos (de ahí el dispositivo arma su índice local código → variante), las ofertas de modificadores y combos del artículo, las imágenes por referencia, el árbol de categorías y las definiciones de los grupos de modificadores. No lleva marcas de producto ni dimensiones de variante: ninguna pantalla del punto de venta las muestra, la lectura en línea del punto de venta tampoco, y como sus cambios no escriben eventos (D40) un arranque que las incluyera dejaría a los dispositivos desactualizados sin que nada se lo dijera; si una pantalla las necesita, la misma rebanada que las agregue al arranque mapea sus tablas a eventos. Tampoco lleva las cartas ni sus ventanas horarias: organizan la pantalla, dependen del reloj y siguen leyéndose en línea.
Permisos. Cada llamada vuelve a decidir catalog.sellables.view en el outlet antes de mirar nada: un permiso revocado o un alcance movido detiene la página siguiente (403, la misma negativa llana que las demás lecturas), y un outlet de otra organización o de otra unidad no se puede arrancar con el corte de otro. Lo que el dispositivo ya descargó no se borra por una revocación: invalidar el alcance local es de Sync (siguiente sesión) y de K12c.
Rechazado: un token firmado que lleve el corte dentro del cursor (el corte vencido se distingue mejor con un 409 propio que con el 400 de un cursor inválido), una instantánea exportada con pg_export_snapshot, versionar las filas del catálogo para servir cortes pasados, incluir las cartas con una fecha de validez, y poner la posición de publicación de Events en la respuesta (Catalog no la posee, y catalogVersion ya ordena el feed del catálogo sin Sync).