37 KiB
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<void, AuditError>>;
recordEvent(input: RecordAuditEventShortcutInput): Promise<Result<void, AuditError>>;
recordMutation(input: RecordAuditMutationShortcutInput): Promise<Result<void, AuditError>>;
}
export interface IAuditStore {
append(entry: AuditEntry): Promise<Result<void, AuditStoreError>>;
}
`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<string, AuditValue>>;
- 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<void, AuditStoreError>>;
}
- 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<AuditSearchResult, Error>>;
findById(
id: string,
access: AuditReadAccess,
): Promise<Result<AuditEntry | null, Error>>;
}
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
- Crear `@repo/rdx-audit` con tipos portables, `Audit`, validación y diff.
- Introducir `OperationContext`/adapter `IAuditContextProvider` según el contrato acordado.
- Crear `ISequelizeTransactionContext` e implementación con AsyncLocalStorage.
- Adaptar `SequelizeTransactionManager.complete()` conservando rollback de `Result.fail` y política de no nesting.
- Construir una única instancia de transaction context en `apps/server` y pasarla por `ModuleParams`/runtime de tests.
- Actualizar los 14 callers de `buildTransactionManager`.
- Implementar modelos, migración y `SequelizeAuditStore`.
- Configurar `Audit` una vez en `apps/server` y pasarlo a módulos.
- Instrumentar un módulo piloto con create/update/status change y validar ergonomía.
- Extender progresivamente la cobertura a payment terms, customers, proformas, issued invoices y resto.
- 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
x
x
x
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": "admin@example.es"
},
"scope": {
"type": "company",
"id": "C1",
"label": "Empresa A"
},
"action": "customer.updated",
"target": {
"type": "customer",
"id": "CU1",
"label": "ACME"
},
"changes": [
{
"path": "email",
"kind": "change",
"before": "old@acme.es",
"after": "new@acme.es"
}
]
}