**DOCUMENTO DE DISEÑO** Sistema transversal de auditoría **Backend ERP · Estado consolidado** Versión de diseño: 1.0 Fecha: 24 de agosto de 2026 Estado: DISEÑO CERRADO PARA IMPLEMENTACIÓN **Propósito **Definir una infraestructura de auditoría portable, append-only y fácil de integrar, capaz de registrar eventos y mutaciones con contexto de operación, preservar atomicidad con las transacciones Sequelize del ERP y soportar una futura pantalla administrativa con autorización por scopes o acceso global. # 1\. Resumen ejecutivo La auditoría se diseña como una capacidad técnica transversal, no como un módulo funcional del ERP. El núcleo portable vive en \`@repo/rdx-audit\`; el host configura una única instancia de \`Audit\` y la distribuye por \`ModuleParams\`, igual que \`database\` y \`logger\`. - \`IAudit.record()\` es la operación canónica. \`recordEvent()\` y \`recordMutation()\` son atajos tipados sin lógica alternativa. - \`@repo/rdx-audit\` no depende de Company, tenant, Express, Sequelize, Criteria, roles, permisos ni del \`OperationContext\` concreto del ERP. - El ámbito de un hecho se modela como \`scope: { type, id, label } | null\`. \`scope=null\` significa evento sin ámbito, nunca acceso global. - La autorización de lectura es responsabilidad de la aplicación host y queda separada del scope almacenado en el evento. - Las mutaciones auditadas en producción deben persistirse en la misma transacción Sequelize que la modificación funcional. - \`SequelizeTransactionContext\` se introduce por DI y solo expone la transacción activa a infraestructura transversal; repositorios y casos de uso siguen recibiendo la transacción explícitamente. - El store portable solo conoce \`append(entry)\`. \`SequelizeAuditStore\`, \`FileAuditStore\` o un store externo son adapters intercambiables. - La lectura administrativa usa un \`SequelizeAuditReader\` server-side apoyado en \`@repo/rdx-criteria/server\`, con whitelist propia de filtros y una política de acceso separada. - No se usa Event Sourcing. La auditoría tampoco sustituye a logs, tracing, historial de versiones de documentos ni BusinessVersion. | **Decisión** | **Resultado** | | ------------------------------- | -------------------------------------------------------------- | | Arquitectura | Paquete portable + adapters host-specific | | Persistencia productiva inicial | MariaDB/Sequelize, tablas \`audit_events\` y \`audit_changes\` | | Mutaciones | Atómicas con la transacción funcional | | Eventos | Pueden persistirse con o sin transacción activa según contexto | | Lectura | Reader server-side; no forma parte del core portable | | Autorización | Fuera de \`rdx-audit\`; puede ser scoped o unrestricted | | Adopción | Incremental por módulo y operación | # 2\. Objetivos y alcance Proporcionar un Audit Trail reutilizable para registrar quién o qué realizó una acción, en qué operación, dentro de qué ámbito, sobre qué recurso y qué cambió cuando exista una mutación. ## 2.1 Objetivos específicos - Integración mínima en cualquier módulo mediante \`IAudit\`. - Persistencia append-only e identidad propia de cada \`AuditEntry\`. - Modelo portable y serializable compatible con múltiples stores. - Diff determinista para mutaciones a partir de \`before\` y \`after\` controlados por el módulo propietario. - Contexto automático de source, operación, actor y scope. - Atomicidad fuerte con MariaDB/Sequelize para mutaciones funcionales. - Lectura eficiente para una futura UI administrativa con filtros, detalle y paginación. - Diseño compatible con un administrador limitado a uno o varios scopes y con un administrador global. ## 2.2 Fuera de alcance - Event Sourcing o reconstrucción del estado de negocio a partir del audit log. - BusinessVersion de presupuestos, proformas u otros documentos funcionales. - DocumentVersion para binarios o gestor documental. - Auditar automáticamente todas las lecturas o validaciones fallidas. - Sistema SIEM, alertas o severidades de seguridad. - Retención legal, archivado, particionado o purge en esta primera versión. - Búsqueda full-text sobre \`before_value\` / \`after_value\`. - Histórico granular de arrays o líneas individuales en la primera versión. # 3\. Terminología e invariantes | **Concepto** | **Semántica** | | ------------- | ---------------------------------------------------------------------------- | | AuditEntry | Unidad histórica append-only generada por Audit. | | Event | Hecho auditable sin diff de estado. | | Mutation | Hecho auditable con uno o más cambios derivados de before/after. | | Source | Sistema que produjo el registro, p.ej. \`application:uecko-erp\`. | | Actor | Quién o qué ejecutó la acción. | | Scope | Ámbito donde ocurrió; puede ser null. | | Target | Recurso o categoría sobre la que ocurrió la acción. | | Operation | Ejecución lógica local que puede producir varios AuditEntry. | | CorrelationId | Identificador que relaciona varias operaciones, incluso entre sistemas. | | Action | Identificador histórico estable de la operación, p.ej. \`customer.updated\`. | **Invariante de autorización **\`AuditEntry.scope\` describe dónde ocurrió el hecho. No expresa lo que un usuario puede consultar. \`scope=null\` nunca significa wildcard, superadmin ni acceso global. # 4\. Arquitectura apps/server │ ├── OperationContextStore ├── SequelizeTransactionContext ├── Audit │ ├── IAuditContextProvider │ └── IAuditStore │ └── SequelizeAuditStore │ └── initModules(ModuleParams) ├── database ├── logger ├── audit └── transactionContext Los módulos no declaran \`dependencies: \["audit"\]\` ni resuelven servicios \`audit:\*\`. \`audit\` es infraestructura transversal suministrada por el runtime. ## 4.1 Flujo de una mutación HTTP / job / integration │ ▼ OperationContext │ ▼ Use Case / Application Service │ ▼ TransactionManager.complete() │ ├── TransactionContext = T1 ├── repository.save(..., T1) └── audit.recordMutation(...) │ ▼ AuditEntry │ ▼ SequelizeAuditStore │ └── TransactionContext.current() = T1 │ ▼ COMMIT o ROLLBACK # 5\. Paquete portable \`@repo/rdx-audit\` ## 5.1 API pública export interface IAudit { record(input: RecordAuditInput): Promise<Result; recordEvent(input: RecordAuditEventShortcutInput): Promise<Result; recordMutation(input: RecordAuditMutationShortcutInput): Promise<Result; }
export interface IAuditStore { append(entry: AuditEntry): Promise<Result; } \`record()\` es la única operación canónica. Los shortcuts añaden \`kind\` y delegan íntegramente en \`record()\`. ## 5.2 Inputs export interface RecordAuditEventInput { readonly kind: "event"; readonly action: string; readonly target: AuditEventTarget; }
export interface RecordAuditMutationInput { readonly kind: "mutation"; readonly action: string; readonly target: AuditMutationTarget; readonly before: AuditState; readonly after: AuditState; readonly atomicPaths?: readonly string\[\]; }
export type RecordAuditInput = | RecordAuditEventInput | RecordAuditMutationInput; ## 5.3 Contratos estructurales export interface AuditSource { readonly type: string; readonly id: string; }
export interface AuditOperation { readonly id: string; readonly correlationId: string; readonly origin: string; }
export interface AuditActor { readonly type: string; readonly id: string | null; readonly label: string | null; }
export interface AuditScope { readonly type: string; readonly id: string; readonly label: string | null; }
export interface AuditEventTarget { readonly type: string; readonly id: string | null; readonly label: string | null; }
export interface AuditMutationTarget { readonly type: string; readonly id: string; readonly label: string | null; } **Decisión KISS **No se introduce una interfaz base común para source/actor/scope/target. La duplicación es mínima y las invariantes y evolución de cada concepto son distintas. # 6\. \`AuditState\` y semántica de diff export type AuditScalar = string | number | boolean | null; export type AuditObject = Readonly<{ \[key: string\]: AuditValue }>; export type AuditArray = readonly AuditValue\[\]; export type AuditValue = AuditScalar | AuditObject | AuditArray; export type AuditState = Readonly<Record; - No admite \`undefined\`, \`Date\`, \`Buffer\`, \`bigint\`, funciones, instancias de clase ni ciclos. - Los números deben ser finitos. - Las keys auditables no contienen \`.\`; el punto queda reservado para paths. - Valores exactos como Money/Percentage/Quantity se proyectan a objetos estables con strings cuando corresponda. - El módulo construye una proyección histórica controlada; Audit nunca introspecciona aggregates, DTOs o modelos Sequelize. - Secrets no se materializan. Datos personales solo se incluyen cuando exista justificación histórica. ## 6.1 Reglas de diff | **Caso** | **Regla** | | --------------------------- | --------------------------------------------------- | | Objetos | Comparación recursiva; el orden de keys no importa. | | Arrays | Atómicos y order-sensitive en v1. | | atomicPaths | Permite tratar un objeto completo como una unidad. | | set | Campo inexistente antes y presente después. | | unset | Campo presente antes y ausente después. | | change | Campo presente en ambos con valores diferentes. | | Cambio de tipo incompatible | Se registra en el path padre. | | Orden de changes | Lexicográfico por \`path\`. | | Mutation sin cambios | \`Result.ok()\` y no se crea \`AuditEntry\`. | ## 6.2 \`AuditChange\` export interface AuditChange { readonly path: string; readonly kind: "set" | "unset" | "change"; readonly before: AuditValue | null; readonly after: AuditValue | null; } # 7\. Modelo portable \`AuditEntry\` interface AuditEntryBase { readonly id: string; readonly occurredAt: string; readonly source: AuditSource; readonly operation: AuditOperation; readonly actor: AuditActor; readonly scope: AuditScope | null; readonly action: string; }
export interface AuditEventEntry extends AuditEntryBase { readonly kind: "event"; readonly target: AuditEventTarget; readonly changes: readonly \[\]; }
export interface AuditMutationEntry extends AuditEntryBase { readonly kind: "mutation"; readonly target: AuditMutationTarget; readonly changes: readonly AuditChange\[\]; }
export type AuditEntry = AuditEventEntry | AuditMutationEntry; - \`event\` exige \`changes=\[\]\`. - \`mutation\` exige \`changes.length > 0\` en runtime y target identificado. - El entry completo es serializable y portable entre MariaDB, JSONL, servicio HTTP, Kafka u otro store. - El store nunca recibe \`before\`, \`after\`, \`atomicPaths\` ni \`OperationContext\`; recibe el \`AuditEntry\` definitivo. # 8\. \`action\`, source, actor, scope y target ## 8.1 Action \`action\` es un string validado y un contrato histórico estable propiedad del módulo funcional. No existe enum global ni registry. - Convención: segmentos lowercase separados por punto, con mínimo dos segmentos. - El primer segmento suele identificar el área o recurso propietario: \`customer.updated\`, \`proforma.issued\`, \`document.version.created\`. - No se incluyen IDs ni datos variables en la acción. - No se guarda \`actionLabel\`; la UI resuelve i18n y usa el identificador como fallback. - No se persisten \`category\`, \`namespace\` ni \`severity\` en v1. - Una acción publicada en producción no se renombra por razones estéticas; si cambia la semántica, se crea otra acción. ## 8.2 Source Identifica el productor técnico del registro y se configura una sola vez al construir \`Audit\`. Ejemplo: \`{ type: "application", id: "uecko-erp" }\`. ## 8.3 Actor Genérico: \`{ type, id|null, label|null }\`. El ERP puede usar \`user\`, \`system\`, \`integration\`, \`job\`, \`anonymous\`, sin que \`rdx-audit\` los enumere. ## 8.4 Scope Genérico y opcional: company, organization, workspace, project u otro ámbito. Si existe, \`id\` es obligatorio. \`scope=null\` significa que el evento no tiene un ámbito concreto. ## 8.5 Target Identifica la categoría/recurso afectado. Un Event puede tener \`id=null\`; una Mutation requiere un target identificado. # 9\. Identificadores y contexto de operación | **Identificador** | **Responsable** | **Semántica** | | ----------------- | ------------------------ | ------------------------------------------------------- | | AuditEntry.id | Audit | Identidad global e inmutable del registro. | | Operation.id | OperationContext host | Identidad de una ejecución local. | | correlationId | Contexto host / upstream | Relaciona operaciones; puede propagarse entre sistemas. | - Los tres son strings opacos en el contrato portable. - \`AuditEntry.id\` se genera antes del store y no usa AUTO_INCREMENT como identidad lógica. - \`operation.id\` lo genera siempre el backend; no se confía en un ID de request aportado por cliente. - \`correlationId\` puede recibirse de upstream si se valida; si no existe, se genera. - \`correlationId\` es obligatorio; no se usa null. - \`occurredAt\` lo genera Audit en ISO-8601 UTC y precisión de milisegundos. - No se introduce \`causationId\` ni \`idempotencyKey\` en v1. # 10\. OperationContext e \`IAuditContextProvider\` export interface AuditContext { readonly operation: AuditOperation; readonly actor: AuditActor; readonly scope: AuditScope | null; }
export interface IAuditContextProvider { get(): AuditContext | null; } \`rdx-audit\` no conoce \`AsyncLocalStorage\` ni el \`OperationContextStore\` concreto. El host adapta su contexto al puerto mínimo anterior. - HTTP abre el contexto con actor anónimo y scope null; autenticación puede enriquecer actor y tenancy puede enriquecer scope. - \`operationId\`, \`correlationId\` y \`origin\` son inmutables dentro de una operación. - La transición de scope permitida es null -> A. A -> B está prohibida dentro de la misma operación. - Jobs, integraciones y migraciones deben abrir su propio contexto explícito. - Ausencia de contexto al auditar es fail-fast. # 11\. \`SequelizeTransactionContext\` Es infraestructura host-specific, independiente de \`OperationContext\` y fuera de \`@repo/rdx-audit\`. Se distribuye por DI y se implementa con \`AsyncLocalStorage\`. export interface ISequelizeTransactionContext { run<T>(transaction: Transaction, callback: () => T): T; current(): Transaction | null; } - Los repositorios y servicios funcionales continúan recibiendo \`Transaction\` explícitamente. - \`SequelizeTransactionManager.complete()\` abre el scope del context alrededor del callback. - Debe conservarse el comportamiento actual: un \`Result.fail\` dentro del callback provoca rollback. - Las transacciones anidadas quedan prohibidas inicialmente; no hay evidencia productiva que requiera savepoints o nesting. - El contexto se inyecta; no se utiliza singleton global. **Deuda técnica detectada **El manager actual declara protección frente a anidamiento, pero su \`complete()\` no mantiene \`\_transaction\`; esa incoherencia debe corregirse o cubrirse con la nueva política de nesting, sin mezclar una refactorización mayor con audit. # 12\. Stores y consistencia ## 12.1 Puerto portable export interface IAuditStore { append(entry: AuditEntry): Promise<Result; } - Solo append. No update, delete ni upsert. - La lectura usa otro adapter; no se mezcla en el write port. - El entry se genera completamente antes del store. - El store debe preservar el orden de \`changes\`. - Un fallo del store no genera a su vez otro AuditEntry. ## 12.2 Adapters previstos | **Adapter** | **Garantía** | | ------------------- | ---------------------------------------------------------------------- | | SequelizeAuditStore | Store productivo principal; puede compartir la transacción funcional. | | FileAuditStore | Útil para desarrollo/best-effort; no es ACID respecto al negocio. | | ExternalAuditStore | Portable, pero no puede garantizar atomicidad con MariaDB por sí solo. | | Outbox + external | Evolución futura si se necesita entrega externa fuerte sin 2PC. | **Regla de producción **Una configuración que exija consistencia fuerte no debe aceptar como store primario un FileAuditStore o un external store no transaccional. La validación de esa combinación pertenece al bootstrap del host, no a \`rdx-audit\`. # 13\. \`SequelizeAuditStore\` y esquema MariaDB La representación relacional utiliza dos tablas. No existe \`audit_change_sets\`. audit_events 1 ─────── 0..N audit_changes ## 13.1 \`audit_events\` | **Columna** | **Tipo propuesto** | **Nulabilidad** | | -------------- | ------------------ | --------------- | | id | VARCHAR(64) | NOT NULL, PK | | kind | VARCHAR(32) | NOT NULL | | occurred_at | DATETIME(3) | NOT NULL | | source_type | VARCHAR(64) | NOT NULL | | source_id | VARCHAR(128) | NOT NULL | | operation_id | VARCHAR(128) | NOT NULL | | correlation_id | VARCHAR(128) | NOT NULL | | origin | VARCHAR(64) | NOT NULL | | actor_type | VARCHAR(64) | NOT NULL | | actor_id | VARCHAR(128) | NULL | | actor_label | VARCHAR(255) | NULL | | scope_type | VARCHAR(64) | NULL | | scope_id | VARCHAR(128) | NULL | | scope_label | VARCHAR(255) | NULL | | action | VARCHAR(191) | NOT NULL | | target_type | VARCHAR(64) | NOT NULL | | target_id | VARCHAR(128) | NULL | | target_label | VARCHAR(255) | NULL | ## 13.2 \`audit_changes\` | **Columna** | **Tipo propuesto** | **Notas** | | -------------- | ------------------ | ------------------------ | | audit_event_id | VARCHAR(64) | PK/FK -> audit_events.id | | position | INT UNSIGNED | PK; preserva orden | | path | VARCHAR(255) | NOT NULL | | kind | VARCHAR(32) | set/unset/change | | before_value | JSON | NULL | | after_value | JSON | NULL | - MariaDB \`JSON\` es adecuado para presentar valores históricos; no se diseña como mecanismo principal de búsqueda. - No existen FKs a Company, User, Customer, Proforma u otros recursos funcionales. - \`audit_changes -> audit_events\` sí tiene integridad referencial interna; \`ON DELETE CASCADE\` puede reservarse para retención administrativa futura. - No se usan timestamps Sequelize automáticos \`createdAt/updatedAt\`. - No se añade \`stored_at\`, soft delete, particionado, metadata genérica ni índices JSON en v1. ## 13.3 Constraints - \`audit_events.kind IN (event, mutation)\`. - \`audit_changes.kind IN (set, unset, change)\`. - Scope: o todos sus campos son null, o al menos \`scope_type\` y \`scope_id\` están presentes. - \`kind=mutation\` implica \`target_id IS NOT NULL\`. - Las invariantes se validan también antes de persistir; las CHECK constraints son defensa adicional. ## 13.4 Índices iniciales PRIMARY KEY (id) INDEX (occurred_at, id) INDEX (scope_type, scope_id, occurred_at, id) INDEX (target_type, target_id, occurred_at, id) INDEX (actor_type, actor_id, occurred_at, id) INDEX (operation_id) INDEX (correlation_id) No se indexan inicialmente \`kind\`, \`path\`, JSON values, source ni action. Se añadirán solo si las consultas reales justifican el coste de escritura. # 14\. Lectura administrativa \`IAuditReader\` no forma parte del core portable porque el modelo actual de Criteria se exporta desde \`@repo/rdx-criteria/server\` y arrastra Sequelize. La lectura se implementa en infraestructura server-side. interface IAuditReader { search( criteria: Criteria, access: AuditReadAccess, ): Promise<Result;
findById( id: string, access: AuditReadAccess, ): Promise<Result; } ## 14.1 \`AuditReadAccess\` type AuditReadAccess = | Readonly<{ mode: "unrestricted" }> | Readonly<{ mode: "scoped"; scopes: readonly AuditScopeIdentity\[\]; includeUnscoped: boolean; }>;
interface AuditScopeIdentity { readonly type: string; readonly id: string; } - Administrador limitado: \`mode=scoped\`, uno o varios scopes autorizados. - Administrador global: \`mode=unrestricted\` y no se añade predicado de scope. - \`includeUnscoped\` es explícito; no se infiere. - El access model no utiliza \`OperationContext.scope\` como autorización automática. ## 14.2 Criteria - Se reutiliza \`@repo/rdx-criteria/server\` solo dentro del reader Sequelize. - El reader mantiene whitelist propia de campos y operadores porque el converter actual puede utilizar un field desconocido como columna Sequelize. - Los mappings lógico -> columna son explícitos para todos los fields auditables. - \`strictMode\` se activa para sorting, pero no sustituye la whitelist de filtros. - Paginación inicial: \`pageNumber/pageSize\`, zero-based, coherente con el backend actual. - Orden por defecto: \`occurredAt DESC\`; el reader añade \`id DESC\` como desempate técnico. - El máximo de \`pageSize\` se valida en el boundary porque el MAX actual no está aplicado de forma global. ## 14.3 Filtros iniciales | **Campo** | **Operadores previstos** | | -------------------------------- | ---------------------------------- | | id | EQUALS | | kind | EQUALS | | occurredAt | EQUALS, >, >=, <, <= | | sourceType/sourceId | EQUALS | | operationId/correlationId/origin | EQUALS | | actorType/actorId | EQUALS; null checks cuando aplique | | scopeType/scopeId | EQUALS, IS_NULL, IS_NOT_NULL | | action | EQUALS; búsqueda libre separada | | targetType/targetId | EQUALS; null checks cuando aplique | ## 14.4 Quick search Primera versión: quick search específico con LIKE sobre \`action\`, \`actor_label\`, \`scope_label\` y \`target_label\`. No se crean FULLTEXT indexes ni se busca dentro de JSON en v1. ## 14.5 Limitaciones actuales de Criteria - No hay \`IN\`, \`STARTS_WITH\` ni múltiples órdenes expresables en Criteria. - La autorización multi-scope se construye directamente como \`WhereOptions\`, no mediante Criteria. - El prefijo \`customer.\*\` no es requisito de v1; si aparece necesidad real puede añadirse \`STARTS_WITH\` como extensión genérica. - Existe una incoherencia \`NOT_EQUALS\` / \`NOT_EQUAL\` en \`rdx-criteria\`; se registra como deuda técnica independiente. # 15\. Futura API/UI administrativa La pantalla aún no está definida, pero el backend queda preparado para list/detail sin comprometer la autorización futura. GET /admin/audit?...criteria... GET /admin/audit/:audit_event_id Las rutas son orientativas; la ubicación definitiva se decidirá al diseñar el módulo de administración. - El listado consulta solo \`audit_events\` y devuelve un summary sin \`changes\`. - El detalle consulta \`audit_events\` con \`id + access\` en la misma query y después carga \`audit_changes\` ordenados por position. - No se realiza un \`findById\` unrestricted seguido de verificación posterior. - Los HTTP DTOs siguen separados del modelo portable \`AuditEntry\` y se validan con los patrones DTO/Zod vigentes del ERP. - La UI resuelve labels de acciones mediante i18n y usa el action estable como fallback. # 16\. Integración con \`ModuleParams\` y módulos interface ModuleParams { // contratos actuales... readonly audit: IAudit; readonly transactionContext: ISequelizeTransactionContext; } No se acomete ahora un refactor general del tipado débil de \`ModuleParams\`; audit solo añade las dependencias necesarias. - \`transactionContext\` se pasa a los 14 callers actuales de \`buildTransactionManager\` mediante DI explícita. - \`audit\` solo se inyecta en use cases/application services que auditan operaciones. - No se inyecta en repositories, mappers, domain entities ni controllers. - Cross-module services que posean una acción auditada reciben \`IAudit\` en su propio composition root; el caller no pasa audit como argumento. - Cada action tiene un único owner funcional para evitar duplicados. # 17\. Patrones normativos por operación | **Operación** | **before** | **after** | **Action** | | ----------------- | --------------- | ----------------- | --------------------------------------------- | | Create | {} | Estado persistido | \`\*.created\` | | Update ordinario | Estado anterior | Estado nuevo | \`\*.updated\` | | Update semántico | Estado anterior | Estado nuevo | Acción propia, p.ej. \`customer.deactivated\` | | Delete físico | Estado anterior | {} | \`\*.deleted\` | | Evento no mutante | — | — | \`recordEvent()\` | ## 17.1 Secuencia de mutation load aggregate \-> build BEFORE AuditState \-> apply domain mutation \-> persist \-> build AFTER AuditState \-> audit.recordMutation() \-> commit **Orden obligatorio **Audit se registra después de que la persistencia funcional haya sido aceptada, pero antes de abandonar el callback transaccional. Si el audit falla, la transacción completa debe hacer rollback. ## 17.2 Proyección por módulo - No se usa \`aggregate.toPrimitives()\` automáticamente. - Cada módulo define una proyección histórica explícita y determinista. - Referencias legibles pueden representarse como \`{ id, label }\` y marcarse en \`atomicPaths\`. - Arrays se normalizan en el módulo cuando su orden no sea semántico. - Create usa \`before={}\` para obtener un diff de campos establecidos. - Delete captura target y before antes de borrar físicamente. # 18\. Errores y atomicidad - \`recordMutation()\` y \`recordEvent()\` devuelven \`Result\`; no son fire-and-forget. - El use case propaga \`Result.fail\` sin exponer detalles SQL. - En una mutation productiva: audit failure -> use case failure -> \`TransactionManager.complete()\` detecta Result.fail -> rollback. - \`SequelizeAuditStore\` requiere transacción activa para mutations bajo política strong/required; no abre silenciosamente otra transacción. - Un Event puede reutilizar una transacción activa o realizar una escritura autónoma si la política host lo permite. - \`rdx-audit\` no contiene flags \`required/bestEffort\`; la combinación store/consistency se valida al construir el runtime. # 19\. Seguridad, privacidad y observabilidad - No se almacenan passwords, tokens, secretos, claves privadas ni credenciales. - PII se limita a valores que tengan utilidad histórica justificada; labels son snapshots auxiliares, no descripciones completas. - No existen FKs a recursos funcionales para preservar el registro histórico aunque el recurso cambie o desaparezca. - La auditoría no sustituye al logging. Reads, validation failures y errores técnicos se observan mediante logs/tracing salvo eventos explícitos de negocio/seguridad. - No se persisten roles/permisos del actor en cada entry. La futura autorización de lectura usa el estado de autorización del host. - El usuario DB de runtime debería aplicar menor privilegio cuando la infraestructura general lo permita; no se introducen triggers anti-update en v1. # 20\. Estrategia de pruebas ## 20.1 \`rdx-audit\` - Validación de AuditState y rechazo de valores no portables. - Diff recursivo de objetos, arrays atómicos, atomicPaths y orden lexicográfico. - Event siempre con changes vacío. - Mutation sin cambios devuelve éxito sin append. - Mutation con target no identificado se rechaza. - Context inexistente produce error fail-fast. - IDs y clock deterministas en tests mediante dependencias sustituibles si se introducen. ## 20.2 TransactionContext - \`current()\` es null fuera de \`complete()\`. - Dentro de \`complete()\` devuelve la misma Transaction que recibe el callback. - Restauración del scope al finalizar y también ante error. - \`Result.fail\` sigue provocando rollback. - Nesting prohibido se detecta explícitamente. ## 20.3 SequelizeAuditStore - Event inserta solo \`audit_events\`. - Mutation inserta event + changes de forma atómica. - Fallos en changes provocan rollback completo. - Mutation sin transaction activa bajo política required falla. - Mapping JSON y orden \`position\` son correctos. ## 20.4 Módulos consumidores - Verificar action, target, before y after en las operaciones instrumentadas. - Los unit tests mockean/fakean \`IAudit\`; no necesitan conocer store, OperationContext ni TransactionContext. - La atomicidad completa se prueba en infraestructura y en uno o dos flujos representativos, no repetida en cada use case. # 21\. Plan de implementación incremental 1. Crear \`@repo/rdx-audit\` con tipos portables, \`Audit\`, validación y diff. 2. Introducir \`OperationContext\`/adapter \`IAuditContextProvider\` según el contrato acordado. 3. Crear \`ISequelizeTransactionContext\` e implementación con AsyncLocalStorage. 4. Adaptar \`SequelizeTransactionManager.complete()\` conservando rollback de \`Result.fail\` y política de no nesting. 5. Construir una única instancia de transaction context en \`apps/server\` y pasarla por \`ModuleParams\`/runtime de tests. 6. Actualizar los 14 callers de \`buildTransactionManager\`. 7. Implementar modelos, migración y \`SequelizeAuditStore\`. 8. Configurar \`Audit\` una vez en \`apps/server\` y pasarlo a módulos. 9. Instrumentar un módulo piloto con create/update/status change y validar ergonomía. 10. Extender progresivamente la cobertura a payment terms, customers, proformas, issued invoices y resto. 11. Implementar \`SequelizeAuditReader\` y autorización cuando se diseñe la pantalla administrativa. # 22\. Cobertura y ownership por módulo La adopción es explícita. Un módulo solo se considera cubierto cuando se documentan sus operaciones mutables auditadas. Customers audit coverage \[x\] create \[x\] update \[x\] deactivate \[x\] reactivate \[ \] delete - no existe No se crea un registry global de acciones. El owner funcional puede usar constantes locales para evitar typos cuando haya varias acciones. # 23\. Alternativas descartadas | **Alternativa** | **Motivo de descarte** | | ------------------------------------ | ------------------------------------------------------------------------------------------------ | | \`modules/audit\` + service registry | Audit es infraestructura transversal; añadiría wiring funcional artificial. | | \`record(input, transaction)\` | Filtra semántica Sequelize al contrato portable y empeora stores alternativos. | | Transaction context singleton | Reduce 14 cambios a costa de estado global oculto y peor testabilidad. | | Audit Sequelize hooks | No conocen adecuadamente la semántica funcional y pueden duplicar/registrar cambios no deseados. | | Audit desde Domain | Acopla dominio a infraestructura transversal. | | Audit desde controllers | No posee la semántica ni atomicidad funcional completa. | | Criteria dentro de \`rdx-audit\` | El Criteria actual server-side depende de Sequelize; duplicar DSL tampoco aporta valor. | | \`scope=null\` = acceso global | Mezcla dato histórico con autorización y hace ambiguas las consultas. | | Arrays granulares v1 | Complejidad innecesaria; arrays atómicos cubren el alcance inicial. | | Event Sourcing | No es requisito y cambiaría radicalmente el modelo de persistencia funcional. | # 24\. Evoluciones futuras, no bloqueantes - \`IAuditReader\` con cursor/keyset si el volumen hace costoso el offset. - Operadores genéricos \`STARTS_WITH\`, \`IN\` y orden múltiple en \`rdx-criteria\` si aparecen casos reales. - Full-text o índices adicionales según métricas reales de la UI administrativa. - Retención, purge administrativo, exportación y archivado. - Outbox para envío fiable a store externo. - Diff granular de Collections/arrays con keys estables si aporta valor. - \`causationId\` en flujos event-driven futuros. - \`source.version\` o environment si un mismo store mezcla despliegues/sistemas y aparece necesidad operativa. - BusinessVersion y DocumentVersion como capacidades separadas, no absorbidas por Audit. # 25\. Checklist de decisiones cerradas | **Tema** | **Decisión** | | ------------------- | ------------------------------------------------------- | | Core portable | @repo/rdx-audit | | IAudit | record + recordEvent + recordMutation | | Store | append-only | | Scope | genérico; null permitido | | Autorización | host-specific y separada | | Transacción | ambient solo para infraestructura; explícita en negocio | | Transaction context | DI, no singleton | | Nesting | prohibido inicialmente | | Persistencia SQL | audit_events + audit_changes | | Reader | server-side con Criteria | | Arrays | atómicos v1 | | Create | before={} | | Delete | after={} | | No-op mutation | no AuditEntry | | Lecturas | no audit por defecto | | Adopción | incremental | # Anexo A. Ejemplo de integración return this.transactionManager.complete(async transaction => { const customerResult = await this.customerRepository.findById( customerId, transaction, );
if (customerResult.isFailure) { return customerResult; }
const customer = customerResult.value; const before = toCustomerAuditState(customer);
const updateResult = customer.update(input); if (updateResult.isFailure) { return updateResult; }
const saveResult = await this.customerRepository.save( customer, transaction, );
if (saveResult.isFailure) { return saveResult; }
const auditResult = await this.audit.recordMutation({ action: "customer.updated", target: toCustomerAuditTarget(customer), before, after: toCustomerAuditState(customer), });
if (auditResult.isFailure) { return auditResult; }
return Result.ok(customer); }); # Anexo B. Ejemplo de \`AuditEntry\` { "id": "01J...", "kind": "mutation", "occurredAt": "2026-08-24T17:20:10.123Z", "source": { "type": "application", "id": "uecko-erp" }, "operation": { "id": "OP1", "correlationId": "CORR1", "origin": "http" }, "actor": { "type": "user", "id": "U1", "label": "" }, "scope": { "type": "company", "id": "C1", "label": "Empresa A" }, "action": "customer.updated", "target": { "type": "customer", "id": "CU1", "label": "ACME" }, "changes": \[ { "path": "email", "kind": "change", "before": "", "after": "" } \] }