UDT-010: Infraestructura de Auditoría y Trazabilidad — Closes #6 #14
Reference in New Issue
Block a user
Delete Branch "feature/UDT-010"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Resumen
UDT-010 (Fase 0.5 — transversal) implementa la infra de auditoría y trazabilidad para SIG-CM 2.0. Cierra el follow-up #6 (admin creador en alta de usuarios) y prepara la base para que toda UDT futura nazca con trazabilidad completa.
Source of truth del diseño:
Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md(re-escrito en esta UDT — arquitectura escalable, automatizada y auto-documentada para el modelo).Closes #6
Arquitectura (2 niveles)
dbo.Usuario_History,dbo.Rol_History,dbo.Permiso_History,dbo.RolPermiso_HistoryAuditEventdbo.AuditEventparticionada mensual, retención 10 añosSecurityEventdbo.SecurityEventparticionada mensual, retención 5 añosBatches implementados (14 commits)
2d1d1871c79dfac95bc7f68f96b908d66220b4af4c300badda3d621426efb74a3f01bcb619c052bb9011GET /api/v1/audit/events+/health/audit9eac044b526df2/admin/audit— DataTable + filtros + testsSeguridad y convenciones
IAuditLoggerlanzaAuditContextMissingExceptionsi faltaActorUserId— mejor romper el comando que perder trazabilidad.TransactionScopedel comando. Si el insert enAuditEventfalla → rollback completo.JsonSanitizerstrippea keys blacklisted (password, token, cvv, etc.) antes de persistir metadata. Configurable víaAudit:SanitizedKeys.administracion:auditoria:ver(ya existía en V005/V006 asignado a admin — no se crea permiso nuevo).ActorUserId → Usuario.Id: sin CASCADE. La auditoría sobrevive al borrado de usuario (requisito legal).Follow-up #6 cerrado
Antes:
CreateUsuarioCommandHandler.cs:40tenía// TODO: audit — record which admin created this user. Hoy: el handler emiteusuario.createconActorUserId = admin.Iddel JWT sub en cada creación. Verificable vía:Test plan
dotnet testdebe pasar 528/528 (381 Application + 147 Api)cd src/web && npm testdebe pasar 161/161GET /api/v1/audit/events?targetType=Usuariodevuelve el eventoGET /health/auditdevuelve 200 Healthy/admin/audit, verificar que el DataTable lista eventos con filtros operativosDocs actualizadas (Obsidian — gitignored, local)
Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md— re-escrito completo (source of truth de la arquitectura)Obsidian/INSTRUCCIONES_IA.md— regla "🔍 REGLA DE AUDITORÍA Y TRAZABILIDAD" agregadaObsidian/STATUS.md— UDT-010 marcado[x]sig-cm2/audit-architecture+sdd/udt-010-auditoria-trazabilidad/{explore,proposal,spec,design,tasks,apply-progress}persistidosMigración V010
Aplicada en dev (
SIGCM2+SIGCM2_Test). Para prod futuro, verdatabase/README.md— requiere ventana de mantenimiento corta (ALTER con SYSTEM_VERSIONING toma Sch-M lock brevemente) y backup previo. Rollback script incluido (V010_ROLLBACK.sql) — advertencia: pierde toda la historia auditada.OUT of scope (diferido a ADM-004)
Validates design decision #D-1 (TransactionScope ambient over IUnitOfWork): TransactionScope with TransactionScopeAsyncFlowOption.Enabled does NOT escalate to MSDTC when multiple SqlConnections share the same connection string. Test passes (DistributedIdentifier == Guid.Empty). Unblocks UDT-010 batches B1-B14. Refs: sdd/udt-010-auditoria-trazabilidad/{design,tasks}Applied to SIGCM2 (dev) and SIGCM2_Test. V010__audit_infrastructure.sql (idempotent, ~280 LoC): - Filegroups AUDIT_HOT + AUDIT_COLD with physical files (per-DB logical names via DB_NAME() prefix to avoid collision in dev/test). - pf/ps_AuditEvent_Monthly + pf/ps_SecurityEvent_Monthly (RANGE RIGHT, DATETIME2(3), 14 boundaries 2026-01..2027-02 → 15 partitions). Job extends forward monthly in B11. - dbo.AuditEvent (partitioned, clustered PK on OccurredAt+Id) + 4 indexes (Actor/Target/Action/Correlation) with PAGE compression. - dbo.SecurityEvent (partitioned) + 3 indexes (Actor/Action_Result/Ip_Failure). - CHECK constraints: Action LIKE '%.%', ISJSON(Metadata), Result IN (success|failure). - SYSTEM_VERSIONING ON in Usuario/Rol/Permiso/RolPermiso with 10 YEARS retention + PAGE compression in history tables. - No hard FK on ActorUserId → Usuario.Id (soft FK — audit must survive user deletion). V010_ROLLBACK.sql: emergency reversal (WARNING: destroys all audit history). database/README.md: migration order + V010 prod-apply notes. tests/SIGCM2.TestSupport/SqlTestFixture.cs: - EnsureV010SchemaAsync() validates audit infra is applied (fails fast with clear message if not — migration itself requires ALTER DATABASE privileges and is applied manually via sqlcmd). - Respawn TablesToIgnore extended with *_History (engine rejects direct DELETE on system-versioned history tables). tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs — 5 smoke tests: - AuditEvent insert+roundtrip with CorrelationId. - CK_AuditEvent_Action rejects Action without '.'. - CK_AuditEvent_Metadata rejects non-JSON. - CK_SecurityEvent_Result rejects invalid Result. - Usuario SYSTEM_VERSIONING: temporal query FOR SYSTEM_TIME AS OF returns pre-update state + Usuario_History populated. Suite: 130/130 passing (previous 124 + spike B0 + 5 new B1). No regressions. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-1,2, #REQ-SEC-1, design#D-4, tasks}Follow-up of B1 (V010 migration). Issues found when running the full suite cross-assembly: 1. Respawn 'Cannot delete rows from a temporal history table' error: 4 per-class Respawner configs in SIGCM2.Application.Tests did not include the newly-created *_History tables introduced by V010 (Usuario_History / Rol_History / Permiso_History / RolPermiso_History). The engine rejects direct DELETE on system-versioned history tables. Extended TablesToIgnore in all 4 configs. 2. FK_RefreshToken_Usuario violation in RolRepositoryTests.InitializeAsync: Manual 'DELETE FROM Usuario' failed when residual RefreshTokens from prior suites existed. Added 'DELETE FROM RefreshToken' before the Usuario cleanup to respect FK order. Latent bug surfaced by a new test-run ordering — not UDT-010 specific, but fixed in scope. 3. UQ_Usuario_Username duplicate admin race: TransactionScopeSpikeTests (B0) and V010MigrationTests (B1) were missing [Collection("ApiIntegration")], causing them to run in parallel with the rest of SIGCM2.Api.Tests and race on SeedAdmin. Serialized by adding the Collection attribute. Suite now passes cross-assembly: 130/130 Api.Tests + 336/336 Application.Tests. Refs: sdd/udt-010-auditoria-trazabilidad/apply-progress (B1 follow-up)Introduces the contract layer for audit logging per design #D-8: SIGCM2.Application/Audit/: - IAuditContext — request-scoped accessor for ActorUserId/ActorRoleId/ Ip/UserAgent/CorrelationId. Populated by CorrelationIdMiddleware + AuditActorMiddleware (B4). - IAuditLogger.LogAsync(action, targetType, targetId, metadata?, ct) — domain-level audit emitter. Enlists in ambient TransactionScope (fail-closed per #REQ-AUD-4). - ISecurityEventLogger.LogAsync(action, result, actorUserId?, attemptedUsername?, sessionId?, failureReason?, metadata?, ct) — security-events emitter separate from IAuditLogger (different retention, no transaction scope, captures login/logout/refresh/permission.denied). - AuditOptions — bindable POCO with SanitizedKeys[] defaults (used by JsonSanitizer in B3). - AuditEventDto — read projection for GET /api/v1/audit/events (B10). - AuditEventFilter — query filter record with default Limit=50. SIGCM2.Domain/Exceptions/: - AuditContextMissingException : DomainException — fail-closed sentinel thrown when IAuditLogger is called without ActorUserId in a user-scoped command (#REQ-AUD-4). Tests (Strict TDD — shape contract, RED → GREEN): - tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs: 11 tests covering nullability, signatures, default options, record equality. Suite: 336/336 Application.Tests (prev 325 + 11 new). 130/130 Api.Tests. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-3/4/5, design#D-8, tasks#B2}Adds the metadata sanitization layer per #REQ-AUD-5: SIGCM2.Infrastructure/Audit/JsonSanitizer.cs (static class): - Sanitize(object?, IReadOnlyCollection<string>) -> string? - Serializes via System.Text.Json + JsonNode recursive traversal. - Strips blacklisted keys at every nesting level (objects + arrays). - Case-insensitive match (ToLowerInvariant on both sides). - Null input -> null output (never throws). - Output is always valid JSON (ISJSON=1 compatible — satisfies AuditEvent CHECK). SIGCM2.Application/Audit/AuditOptions.cs: - Documented the IConfiguration array-binding quirk: config is ADDITIVE (append at higher indices), not REPLACE. Intentional for security — defaults like 'password'/'token'/'cvv' must not be silently dropped. SIGCM2.Infrastructure/DependencyInjection.cs: - services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName)) wired in AddInfrastructure(). Tests (Strict TDD, RED -> GREEN): - JsonSanitizerTests (10): null/empty-blacklist/flat/nested/arrays/case-insensitive/ primitives/round-trip-valid-json/string-as-value/default-keys-effective. - AuditOptionsBindingTests (2): defaults when section absent + additive override. One test needed adjustment during GREEN: 'AlreadySerializedJsonString' originally asserted against an encoding-specific literal; rewrote to use JsonDocument round-trip (validates behavior without coupling to encoder quirks). Suite: 348/348 Application.Tests + 130/130 Api.Tests = 478/478 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-5, design#D-5, tasks#B3}Wires the request-scoped audit context per design #D-2: Middleware pipeline in Program.cs: app.UseCors() app.UseMiddleware<CorrelationIdMiddleware>() // PRE-AUTH app.UseAuthentication() app.UseMiddleware<AuditActorMiddleware>() // POST-AUTH app.UseAuthorization() app.MapControllers() SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs: - Preserves client-sent X-Correlation-Id header when a valid GUID, otherwise generates Guid.NewGuid(). Stores in HttpContext.Items (audit:correlationId). - Captures Ip (Connection.RemoteIpAddress) + UserAgent header into Items. - Echoes the correlation id back via response header (OnStarting + immediate set — immediate set makes unit testing against DefaultHttpContext reliable). SIGCM2.Api/Middleware/AuditActorMiddleware.cs: - Reads JWT 'sub' claim from authenticated HttpContext.User, parses to int, stores as audit:actorUserId. Anonymous / non-numeric sub leaves it unset. SIGCM2.Infrastructure/Audit/AuditContext.cs (IAuditContext scoped impl): - Reads Items entries via IHttpContextAccessor. Returns null / Guid.Empty when no HttpContext is available (jobs, tests without middleware). - ActorRoleId intentionally null for now — rol code → id resolution is deferred; the logger may resolve it at persist time in a later batch. DI registration (Infrastructure/DependencyInjection.cs): - services.AddScoped<IAuditContext, AuditContext>() Tests (Strict TDD): - CorrelationIdMiddlewareTests (6): generates/preserves/handles-malformed correlation id, sets response header, captures ip/ua, calls next. - AuditActorMiddlewareTests (5): authenticated/anonymous/no-sub/non-numeric/ calls-next. - AuditContextTests (7): reads from Items, null-http-context defaults, ActorRoleId currently null. Suite: 355/355 Application.Tests + 141/141 Api.Tests = 496/496 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-3/9, design#D-2, tasks#B4}Introduces persistence layer for audit and security events per design #D-6: SIGCM2.Application/Audit/: - IAuditEventRepository: InsertAsync + QueryAsync with cursor pagination - ISecurityEventRepository: InsertAsync only (no query — SecurityEvent is queried only from an admin dashboard deferred to ADM-004) - AuditEventQueryResult: (Items, NextCursor) record SIGCM2.Infrastructure/Audit/: - AuditEventCursor (public): base64(OccurredAt:O|Id) opaque cursor for DESC pagination. TryDecode is fail-open — malformed cursor returns null and the query starts from the top. - AuditEventRepository: Dapper INSERT via OUTPUT INSERTED.Id + dynamic WHERE composition with parameterized filters (zero SQL injection risk). LEFT JOIN to dbo.Usuario to populate ActorUsername in AuditEventDto. Pagination fetches Limit+1 rows to detect "more pages"; emits cursor from the Nth row when overflow observed. - SecurityEventRepository: straight INSERT for login/logout/refresh/ permission.denied events. DI: AddScoped for both repos in AddInfrastructure. Integration tests (Strict TDD): 13 total, all against SIGCM2_Test. - AuditEventRepositoryTests (10): insert-roundtrip, filter-by-actor, filter-by-target, filter-by-date-range, cursor pagination across 3 pages (no overlap/no gap), malformed-cursor fail-open, LEFT JOIN Usuario populates username, cursor encode/decode roundtrip, cursor malformed variants. - SecurityEventRepositoryTests (3): insert success, insert failure with null ActorUserId + AttemptedUsername, CK_SecurityEvent_Result rejection. Suite: 368/368 Application.Tests + 141/141 Api.Tests = 509/509 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-2,7 #REQ-SEC-1, design#D-6, tasks#B5}Composes the audit emission layer per design #D-8: SIGCM2.Infrastructure/Audit/AuditLogger.cs (IAuditLogger): - Enriches from IAuditContext (ActorUserId/ActorRoleId/Ip/UserAgent/CorrelationId). - Sanitizes metadata via JsonSanitizer + AuditOptions.SanitizedKeys. - Persists via IAuditEventRepository.InsertAsync. - Fail-closed: throws AuditContextMissingException when ActorUserId is null. - Translates Guid.Empty correlation id to null (DB column is nullable; Empty indicates 'no middleware ran'). - Uses System.DateTime.UtcNow for occurredAt. SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs (ISecurityEventLogger): - NOT fail-closed: null ActorUserId is valid (login failures, anonymous permission.denied events). - Ip/UserAgent pulled from IAuditContext; metadata sanitized the same way. - Persists via ISecurityEventRepository. DI: AddScoped for both loggers in AddInfrastructure. Tests (Strict TDD, mocks for IAuditContext/IAuditEventRepository/ ISecurityEventRepository): - AuditLoggerTests (6): happy path with full context, fail-closed null actor, metadata sanitization, null metadata pass-through, repo-throws-bubbles-up (critical for TransactionScope rollback), custom SanitizedKeys from options. - SecurityEventLoggerTests (4): login.success with context, login.failure with null actor + attemptedUsername, metadata sanitization, permission.denied with both actor and attemptedUsername null. Two initial failures were fixed by replacing 'null' literal arguments in NSubstitute Received(...) assertions with Arg.Is<T?>(x => x == null) — NSubstitute does not always match null literals when mixed with Arg.Any<T>(). Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-4 #REQ-SEC-2/3, design#D-8, tasks#B6}7 command handlers del módulo Usuarios ahora auditan via IAuditLogger: | Handler | Action | |-----------------------------------------|-------------------------| | CreateUsuarioCommandHandler | usuario.create | | UpdateUsuarioCommandHandler | usuario.update | | DeactivateUsuarioCommandHandler | usuario.deactivate | | ReactivateUsuarioCommandHandler | usuario.reactivate | | ChangeMyPasswordCommandHandler | usuario.password_change | | ResetUsuarioPasswordCommandHandler | usuario.password_reset | | UpdateUsuarioPermisosOverridesHandler | usuario.permisos_update | Patrón por handler (per design #D-1): using (var tx = new TransactionScope(Required, ReadCommitted, AsyncFlowEnabled)) { await repo.UpdateAsync(...); await audit.LogAsync(...); tx.Complete(); } // post-commit reads OUTSIDE the using block var updated = await repo.GetDetailAsync(...); Metadata captured: - usuario.create: after={username, nombre, apellido, email, rol} — NO password. - usuario.update: {before, after} diff of editable fields. - usuario.password_reset: {targetId} only — tempPassword is NEVER persisted to audit (returned to caller once, never stored). - usuario.permisos_update: {before, after} of grant/deny override lists. Key fix during implementation: initially used 'using var tx = ...' (bare declaration). This kept the TransactionScope active for the rest of the method, causing 'The current TransactionScope is already complete' when post-commit reads (GetDetailAsync) tried to enlist. Solution: explicit 'using (var tx = ...) { ... }' block that disposes the scope before post-commit reads. AuditContextMissingException surfaces from AuditLogger when IAuditContext lacks ActorUserId — fail-closed per #REQ-AUD-4. In integration tests, the middleware populates ActorUserId from the JWT sub of the authenticated admin. Test updates: 6 existing unit test classes now inject IAuditLogger mock: - CreateUsuarioCommandHandlerTests - UpdateUsuarioCommandHandlerTests - DeactivateUsuarioCommandHandlerTests - ReactivateUsuarioCommandHandlerTests - ChangeMyPasswordCommandHandlerTests - ResetUsuarioPasswordCommandHandlerTests Follow-up #6 ([Auditoría] Registrar admin creador en alta de usuarios) is closed: CreateUsuarioCommandHandler now records ActorUserId = admin JWT sub on every user creation. TODO comment removed. Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing. Closes #6 Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-UM-AUD, design, tasks#B7}4 command handlers del módulo Roles + Permisos ahora auditan: | Handler | Action | |--------------------------------------|------------------------| | CreateRolCommandHandler | rol.create | | UpdateRolCommandHandler | rol.update | | DeactivateRolCommandHandler | rol.deactivate | | AssignPermisosToRolCommandHandler | rol.permisos_update | Mismo patrón que B7 (using block + post-commit reads outside scope). Metadata: - rol.create: after={Codigo, Nombre, Descripcion} - rol.update: {before, after} diff - rol.permisos_update: {before, after} con arrays de codigos ordenados AssignPermisosToRolCommandHandler captura 'before' leyendo GetByRolCodigoAsync antes del TransactionScope para poder emitir el diff. 4 test classes actualizados con mock de IAuditLogger. Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-RM-AUD, design, tasks#B8}Instruments auth pipeline with ISecurityEventLogger per #REQ-AUTH-SEC: LoginCommandHandler: - login success → action=login result=success actorUserId=user.Id - login failure disaggregated internally (client still sees 401 unified): user_not_found / user_inactive / invalid_password — attempts captured with attemptedUsername + FailureReason LogoutCommandHandler: - action=logout result=success actorUserId=cmd.UsuarioId RefreshCommandHandler: - refresh.issue success on successful rotation - refresh.reuse_detected failure when revoked token is presented (chain revoke already happens; we add the security event with metadata.familyId) - refresh.issue failure for: token_expired / sub_mismatch / user_not_found / user_inactive PermissionAuthorizationHandler: - permission.denied failure on require-permission rejection, with metadata { permissionRequired, endpoint, method }. ActorUserId from JWT sub. DI: ISecurityEventLogger was already registered by B6 (AddInfrastructure). Test updates: 4 test classes now inject ISecurityEventLogger mock: - LoginCommandHandlerTests, LogoutCommandHandlerTests, RefreshCommandHandlerTests - PermissionAuthorizationHandlerTests (Api.Tests) Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-SEC-2/3/4/5 #REQ-AUTH-SEC, design, tasks#B9}AuditController: - GET /api/v1/audit/events?actorUserId&targetType&targetId&from&to&cursor&limit - Protected by [RequirePermission("administracion:auditoria:ver")] — reuses the existing permission (V005/V006 seed assigns it to admin). - 400 on limit out of [1,100] or from > to. - Cursor-based DESC pagination via AuditEventRepository.QueryAsync. AuditHealthCheck (IHealthCheck): - Validates SYSTEM_VERSIONING ON on Usuario/Rol/Permiso/RolPermiso. - Validates partition boundaries exist for next 3 months (both AuditEvent and SecurityEvent functions). - Reports last audit event age (lenient 24h to accommodate dev/test quiet envs). - Validates HISTORY_RETENTION_PERIOD == 10 YEARS on all 4 tables. Key fix during impl: sys.tables.history_retention_period is stored in UNITS (1=INFINITE, 3=DAY, 4=WEEK, 5=MONTH, 6=YEAR), NOT seconds. Assertion: period=10 AND unit=6 (10 YEARS). - Mapped at /health/audit via app.MapHealthChecks with tag 'audit'. Tests (Strict TDD, integration against SIGCM2_Test): - AuditControllerTests (5): without-auth 401, without-permission 403 (cajero), admin with filter returns events, invalid limit 400, from>to 400. - AuditHealthCheckTests (1): returns Healthy with V010 applied. Suite: 378/378 Application.Tests + 147/147 Api.Tests = 525/525 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-7/8, design, tasks#B10}Read-only audit timeline per design #D-9. Delegated to sub-agent, completed before rate limit cutoff; verified with vitest 161/161 passing. New files: - src/web/src/api/audit.ts — axios client: listAuditEvents(filter) - src/web/src/features/admin/audit/useAuditEvents.ts — TanStack Query hook - src/web/src/pages/admin/audit/AuditPage.tsx — DataTable + 4 filters + cursor pagination 'Cargar más' button. Columns: OccurredAt (local time formatted), ActorUsername, Action (badge), TargetType + TargetId, IpAddress, CorrelationId (copy button with toast). - src/web/src/pages/admin/audit/AuditFilters.tsx — 4 filters form. - src/web/src/tests/features/admin/audit/useAuditEvents.test.ts — hook unit. - src/web/src/tests/features/admin/audit/AuditPage.test.tsx — component test with MSW handler mock. Modified: - src/web/src/router.tsx — /admin/audit route, protected by auth + permission 'administracion:auditoria:ver'. - src/web/src/components/layout/AppSidebar.tsx — sidebar entry (icon, visible only with the required permission, uses existing permission-filtering pattern). OUT of scope (deferred to ADM-004): - Row drilldown modal with full metadata JSON formatted. - CSV export. - Timeline-per-entity visualization. Design System v2.4 conventions respected: DataTable component from @/components/ui/data-table (no raw <table>), tokens only (no hex inline), density compact, Radix tooltip for copy button, sonner toast on copy. Vitest run: 29 test files / 161 tests passing. No regressions in existing frontend tests. Refs: sdd/udt-010-auditoria-trazabilidad/{spec, design#D-9, tasks#B12}Agrega Quartz.Extensions.Hosting 3.13.1 al catálogo central. SIGCM2.Infrastructure/Audit/Jobs/: - AuditPartitionManagerJob — mensual (cron '0 0 2 1 * ?', UTC). Extiende pf_AuditEvent_Monthly y pf_SecurityEvent_Monthly con SPLIT RANGE para el mes+2 (mantiene +1 de buffer). Idempotente: verifica existencia antes. - AuditRetentionEnforcerJob — anual (cron '0 0 3 1 1 ?', UTC). DELETE rows > 10 años en AuditEvent y > 5 años en SecurityEvent. Temporal history se purga solo vía HISTORY_RETENTION_PERIOD del engine. - AuditIntegrityCheckJob — semanal domingos (cron '0 0 1 ? * SUN', UTC). Valida SYSTEM_VERSIONING=ON + partitions próximos 3 meses. Emite SecurityEvent 'system.integrity_alert' failure via ISecurityEventLogger cuando detecta inconsistencias. AuditMaintenanceRegistration.cs: - services.AddAuditMaintenance(configuration) wraps AddQuartz + AddQuartzHostedService con los 3 triggers crónicos. Program.cs: - builder.Services.AddAuditMaintenance(configuration) wired ONLY en entornos productivos — skipeado en 'Testing' para que los integration tests no disparen los triggers cron durante el ciclo de vida del TestWebAppFactory. Row-based DELETE en RetentionEnforcerJob es la opción conservadora para la primera generación — cuando los volúmenes lo justifiquen (>200M filas), se upgradea a SWITCH OUT + DROP para partition-level drop. Documentado en comentario de la clase. Tests (Strict TDD, integration): - AuditJobsTests (3): PartitionManager crea target boundary + idempotencia, RetentionEnforcer purga > threshold (10y audit, 5y security), IntegrityCheck all-OK no emite alert. Suite: 381/381 Application.Tests + 147/147 Api.Tests = 528/528 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-6 #REQ-SEC-5, design, tasks#B11}