diff --git a/Directory.Packages.props b/Directory.Packages.props
index 332e9a2..d3074f0 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -15,6 +15,7 @@
+
diff --git a/src/api/SIGCM2.Api/Program.cs b/src/api/SIGCM2.Api/Program.cs
index 69fdac1..92ab9f8 100644
--- a/src/api/SIGCM2.Api/Program.cs
+++ b/src/api/SIGCM2.Api/Program.cs
@@ -6,6 +6,7 @@ using SIGCM2.Api.HealthChecks;
using SIGCM2.Api.Middleware;
using SIGCM2.Application;
using SIGCM2.Infrastructure;
+using SIGCM2.Infrastructure.Audit.Jobs;
using SIGCM2.Api.Filters;
// Bootstrap logger — before DI is built
@@ -25,6 +26,11 @@ builder.Host.UseSerilog((ctx, lc) => lc
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
+// UDT-010: Quartz.NET + 3 audit maintenance jobs (partition, retention, integrity).
+// Disabled in Testing environment to keep integration tests deterministic.
+if (!builder.Environment.IsEnvironment("Testing"))
+ builder.Services.AddAuditMaintenance(builder.Configuration);
+
// Authorization — handler lives in Api layer; DO NOT move to Infrastructure DI
builder.Services.AddAuthorization();
builder.Services.AddScoped();
diff --git a/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditIntegrityCheckJob.cs b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditIntegrityCheckJob.cs
new file mode 100644
index 0000000..f6b2c3e
--- /dev/null
+++ b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditIntegrityCheckJob.cs
@@ -0,0 +1,95 @@
+using Dapper;
+using Microsoft.Extensions.Logging;
+using Quartz;
+using SIGCM2.Application.Audit;
+using SIGCM2.Infrastructure.Persistence;
+
+namespace SIGCM2.Infrastructure.Audit.Jobs;
+
+///
+/// UDT-010 (#REQ-AUD-6) — weekly integrity verification:
+/// - Validates SYSTEM_VERSIONING is ON in all cataloged tables.
+/// - Validates partitions exist for the next 3 months on both event tables.
+/// - Emits a SecurityEvent 'system.integrity_alert' with Result=failure when any
+/// check fails; logs success otherwise.
+/// - Intended schedule: cron '0 0 1 ? * SUN' (every Sunday at 01:00 UTC).
+///
+[DisallowConcurrentExecution]
+public sealed class AuditIntegrityCheckJob : IJob
+{
+ public const string CronSchedule = "0 0 1 ? * SUN";
+
+ private readonly SqlConnectionFactory _factory;
+ private readonly ISecurityEventLogger _security;
+ private readonly ILogger _logger;
+
+ public AuditIntegrityCheckJob(
+ SqlConnectionFactory factory,
+ ISecurityEventLogger security,
+ ILogger logger)
+ {
+ _factory = factory;
+ _security = security;
+ _logger = logger;
+ }
+
+ public async Task Execute(IJobExecutionContext context)
+ {
+ var ct = context.CancellationToken;
+ var failures = new List();
+
+ await using var conn = _factory.CreateConnection();
+ await conn.OpenAsync(ct);
+
+ // 1. SYSTEM_VERSIONING still ON
+ var missing = (await conn.QueryAsync("""
+ SELECT name FROM sys.tables
+ WHERE name IN ('Usuario','Rol','Permiso','RolPermiso') AND temporal_type <> 2;
+ """)).ToList();
+ if (missing.Any())
+ failures.Add($"system_versioning_missing:{string.Join(',', missing)}");
+
+ // 2. Next 3 months have partitions in both event tables
+ var now = DateTime.UtcNow;
+ var required = new[]
+ {
+ new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(1),
+ new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(2),
+ new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(3),
+ };
+
+ foreach (var pf in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" })
+ {
+ var existingBoundaries = (await conn.QueryAsync("""
+ SELECT CAST(prv.value AS DATETIME2(3))
+ FROM sys.partition_functions pf
+ JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id
+ WHERE pf.name = @Name;
+ """, new { Name = pf })).ToHashSet();
+
+ foreach (var req in required)
+ {
+ if (!existingBoundaries.Contains(req))
+ failures.Add($"partition_missing:{pf}:{req:yyyy-MM-dd}");
+ }
+ }
+
+ if (failures.Any())
+ {
+ _logger.LogError("AuditIntegrityCheckJob detected {Count} integrity failures: {Failures}",
+ failures.Count, string.Join(" | ", failures));
+
+ await _security.LogAsync(
+ action: "system.integrity_alert",
+ result: "failure",
+ actorUserId: null,
+ failureReason: "integrity_check_failed",
+ metadata: new { failures, checkedAt = now },
+ ct: ct);
+ }
+ else
+ {
+ _logger.LogInformation("AuditIntegrityCheckJob completed — all checks passed");
+ }
+ }
+}
diff --git a/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditMaintenanceRegistration.cs b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditMaintenanceRegistration.cs
new file mode 100644
index 0000000..17c182c
--- /dev/null
+++ b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditMaintenanceRegistration.cs
@@ -0,0 +1,43 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Quartz;
+
+namespace SIGCM2.Infrastructure.Audit.Jobs;
+
+/// UDT-010 (#REQ-AUD-6) — DI extension to register Quartz + the 3 audit maintenance jobs.
+/// Call from Program.cs: builder.Services.AddAuditMaintenance(builder.Configuration).
+public static class AuditMaintenanceRegistration
+{
+ public static IServiceCollection AddAuditMaintenance(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ services.AddQuartz(q =>
+ {
+ var partitionKey = new JobKey(nameof(AuditPartitionManagerJob));
+ q.AddJob(j => j.WithIdentity(partitionKey));
+ q.AddTrigger(t => t
+ .ForJob(partitionKey)
+ .WithIdentity($"{nameof(AuditPartitionManagerJob)}-trigger")
+ .WithCronSchedule(AuditPartitionManagerJob.CronSchedule, x => x.InTimeZone(TimeZoneInfo.Utc)));
+
+ var retentionKey = new JobKey(nameof(AuditRetentionEnforcerJob));
+ q.AddJob(j => j.WithIdentity(retentionKey));
+ q.AddTrigger(t => t
+ .ForJob(retentionKey)
+ .WithIdentity($"{nameof(AuditRetentionEnforcerJob)}-trigger")
+ .WithCronSchedule(AuditRetentionEnforcerJob.CronSchedule, x => x.InTimeZone(TimeZoneInfo.Utc)));
+
+ var integrityKey = new JobKey(nameof(AuditIntegrityCheckJob));
+ q.AddJob(j => j.WithIdentity(integrityKey));
+ q.AddTrigger(t => t
+ .ForJob(integrityKey)
+ .WithIdentity($"{nameof(AuditIntegrityCheckJob)}-trigger")
+ .WithCronSchedule(AuditIntegrityCheckJob.CronSchedule, x => x.InTimeZone(TimeZoneInfo.Utc)));
+ });
+
+ services.AddQuartzHostedService(opts => opts.WaitForJobsToComplete = true);
+
+ return services;
+ }
+}
diff --git a/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditPartitionManagerJob.cs b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditPartitionManagerJob.cs
new file mode 100644
index 0000000..e980787
--- /dev/null
+++ b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditPartitionManagerJob.cs
@@ -0,0 +1,64 @@
+using Dapper;
+using Microsoft.Extensions.Logging;
+using Quartz;
+using SIGCM2.Infrastructure.Persistence;
+
+namespace SIGCM2.Infrastructure.Audit.Jobs;
+
+///
+/// UDT-010 (#REQ-AUD-6) — monthly maintenance:
+/// - Extends the forward boundary of pf_AuditEvent_Monthly and pf_SecurityEvent_Monthly
+/// so the next month always has a partition ready (SPLIT RANGE).
+/// - Intended schedule: cron '0 0 2 1 * ?' (day 1 each month at 02:00 UTC).
+/// - Idempotent: only splits if the target boundary does not yet exist.
+///
+[DisallowConcurrentExecution]
+public sealed class AuditPartitionManagerJob : IJob
+{
+ public const string CronSchedule = "0 0 2 1 * ?";
+
+ private readonly SqlConnectionFactory _factory;
+ private readonly ILogger _logger;
+
+ public AuditPartitionManagerJob(SqlConnectionFactory factory, ILogger logger)
+ {
+ _factory = factory;
+ _logger = logger;
+ }
+
+ public async Task Execute(IJobExecutionContext context)
+ {
+ var ct = context.CancellationToken;
+ await using var conn = _factory.CreateConnection();
+ await conn.OpenAsync(ct);
+
+ // Target: boundary for "next month + 1" (so the next month is always pre-created and we
+ // keep at least one boundary ahead after the rotation).
+ var now = DateTime.UtcNow;
+ var target = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(2);
+
+ var affected = 0;
+ foreach (var pf in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" })
+ {
+ var exists = await conn.ExecuteScalarAsync("""
+ SELECT COUNT(*)
+ FROM sys.partition_functions pf
+ JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id
+ WHERE pf.name = @Name AND CAST(prv.value AS DATETIME2(3)) = @Target;
+ """, new { Name = pf, Target = target });
+
+ if (exists == 0)
+ {
+ // Parameterized partition function name would require dynamic SQL; whitelisted above.
+ var sql = $"ALTER PARTITION FUNCTION {pf}() SPLIT RANGE (@Target);";
+ await conn.ExecuteAsync(sql, new { Target = target });
+ affected++;
+ _logger.LogInformation("Partition boundary {Boundary:yyyy-MM-dd} added to {Function}", target, pf);
+ }
+ }
+
+ _logger.LogInformation(
+ "AuditPartitionManagerJob completed — {Affected} partition function(s) extended (target: {Target:yyyy-MM-dd})",
+ affected, target);
+ }
+}
diff --git a/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditRetentionEnforcerJob.cs b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditRetentionEnforcerJob.cs
new file mode 100644
index 0000000..054c06a
--- /dev/null
+++ b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditRetentionEnforcerJob.cs
@@ -0,0 +1,57 @@
+using Dapper;
+using Microsoft.Extensions.Logging;
+using Quartz;
+using SIGCM2.Infrastructure.Persistence;
+
+namespace SIGCM2.Infrastructure.Audit.Jobs;
+
+///
+/// UDT-010 (#REQ-AUD-6, #REQ-SEC-5) — annual retention enforcement:
+/// - Purges rows from dbo.AuditEvent older than 10 years.
+/// - Purges rows from dbo.SecurityEvent older than 5 years.
+/// - Temporal history tables are purged automatically by the engine via
+/// HISTORY_RETENTION_PERIOD = 10 YEARS configured in V010.
+/// - Intended schedule: cron '0 0 3 1 1 ?' (Jan 1 at 03:00 UTC).
+///
+/// Row-based DELETE is the conservative choice for the first generation of this
+/// job — avoids requiring filegroup switching logic. When volumes warrant, the
+/// job can be upgraded to SWITCH OUT + DROP for partition-level drop.
+///
+[DisallowConcurrentExecution]
+public sealed class AuditRetentionEnforcerJob : IJob
+{
+ public const string CronSchedule = "0 0 3 1 1 ?";
+
+ private readonly SqlConnectionFactory _factory;
+ private readonly ILogger _logger;
+
+ public AuditRetentionEnforcerJob(SqlConnectionFactory factory, ILogger logger)
+ {
+ _factory = factory;
+ _logger = logger;
+ }
+
+ public async Task Execute(IJobExecutionContext context)
+ {
+ var ct = context.CancellationToken;
+ await using var conn = _factory.CreateConnection();
+ await conn.OpenAsync(ct);
+
+ var now = DateTime.UtcNow;
+ var auditCutoff = now.AddYears(-10);
+ var securityCutoff = now.AddYears(-5);
+
+ var auditDeleted = await conn.ExecuteAsync(
+ "DELETE FROM dbo.AuditEvent WHERE OccurredAt < @Cutoff;",
+ new { Cutoff = auditCutoff });
+
+ var securityDeleted = await conn.ExecuteAsync(
+ "DELETE FROM dbo.SecurityEvent WHERE OccurredAt < @Cutoff;",
+ new { Cutoff = securityCutoff });
+
+ _logger.LogInformation(
+ "AuditRetentionEnforcerJob completed — AuditEvent purged {AuditDeleted} rows (< {AuditCutoff:yyyy-MM-dd}), " +
+ "SecurityEvent purged {SecurityDeleted} rows (< {SecurityCutoff:yyyy-MM-dd})",
+ auditDeleted, auditCutoff, securityDeleted, securityCutoff);
+ }
+}
diff --git a/src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj b/src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj
index cf325e1..53083e9 100644
--- a/src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj
+++ b/src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj
@@ -14,6 +14,7 @@
+
diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditJobsTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditJobsTests.cs
new file mode 100644
index 0000000..1753434
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditJobsTests.cs
@@ -0,0 +1,123 @@
+using Dapper;
+using FluentAssertions;
+using Microsoft.Data.SqlClient;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using Quartz;
+using SIGCM2.Application.Audit;
+using SIGCM2.Infrastructure.Audit.Jobs;
+using SIGCM2.Infrastructure.Persistence;
+using Xunit;
+
+namespace SIGCM2.Application.Tests.Infrastructure.Audit;
+
+/// UDT-010 Batch 11 — audit maintenance jobs integration tests.
+/// Executes each IJob directly (no Quartz scheduler needed) against SIGCM2_Test.
+[Collection("Database")]
+public sealed class AuditJobsTests : IAsyncLifetime
+{
+ private const string ConnectionString =
+ "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
+
+ private SqlConnection _connection = null!;
+ private SqlConnectionFactory _factory = null!;
+
+ public async Task InitializeAsync()
+ {
+ _connection = new SqlConnection(ConnectionString);
+ await _connection.OpenAsync();
+ await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent; DELETE FROM dbo.SecurityEvent;");
+ _factory = new SqlConnectionFactory(ConnectionString);
+ }
+
+ public async Task DisposeAsync()
+ {
+ await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent; DELETE FROM dbo.SecurityEvent;");
+ await _connection.CloseAsync();
+ await _connection.DisposeAsync();
+ }
+
+ private static IJobExecutionContext MockContext()
+ {
+ var ctx = Substitute.For();
+ ctx.CancellationToken.Returns(CancellationToken.None);
+ return ctx;
+ }
+
+ [Fact]
+ public async Task PartitionManager_ExtendsFunctionForward_Idempotent()
+ {
+ var job = new AuditPartitionManagerJob(_factory, NullLogger.Instance);
+
+ // First run: ensure the target boundary exists
+ await job.Execute(MockContext());
+
+ // Compute target as the job does
+ var now = DateTime.UtcNow;
+ var target = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(2);
+
+ foreach (var pf in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" })
+ {
+ var count = await _connection.ExecuteScalarAsync("""
+ SELECT COUNT(*)
+ FROM sys.partition_functions pf
+ JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id
+ WHERE pf.name = @Name AND CAST(prv.value AS DATETIME2(3)) = @Target;
+ """, new { Name = pf, Target = target });
+ count.Should().Be(1, $"{pf} should have boundary {target:yyyy-MM-dd}");
+ }
+
+ // Second run must not throw (idempotent)
+ await job.Execute(MockContext());
+ }
+
+ [Fact]
+ public async Task RetentionEnforcer_PurgesAuditEventOlderThan10Years_AndSecurityOlderThan5Years()
+ {
+ await _connection.ExecuteAsync("""
+ INSERT INTO dbo.AuditEvent (OccurredAt, ActorUserId, Action, TargetType, TargetId)
+ VALUES
+ (@Ancient, 1, 'x.y', 'T', '1'), -- should be purged
+ (@Recent, 1, 'x.y', 'T', '2'); -- should stay
+ INSERT INTO dbo.SecurityEvent (OccurredAt, ActorUserId, Action, Result)
+ VALUES
+ (@Ancient5, 1, 'login', 'success'), -- should be purged
+ (@Recent, 1, 'login', 'success'); -- should stay
+ """, new
+ {
+ Ancient = DateTime.UtcNow.AddYears(-11),
+ Recent = DateTime.UtcNow.AddDays(-1),
+ Ancient5 = DateTime.UtcNow.AddYears(-6),
+ });
+
+ var job = new AuditRetentionEnforcerJob(_factory, NullLogger.Instance);
+ await job.Execute(MockContext());
+
+ var auditCount = await _connection.ExecuteScalarAsync("SELECT COUNT(*) FROM dbo.AuditEvent;");
+ var securityCount = await _connection.ExecuteScalarAsync("SELECT COUNT(*) FROM dbo.SecurityEvent;");
+ auditCount.Should().Be(1);
+ securityCount.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task IntegrityCheck_AllOk_DoesNotEmitSecurityEvent()
+ {
+ var security = Substitute.For();
+ var job = new AuditIntegrityCheckJob(_factory, security, NullLogger.Instance);
+
+ // Ensure partition manager has run first so next-3-months exist
+ await new AuditPartitionManagerJob(_factory, NullLogger.Instance).Execute(MockContext());
+
+ await job.Execute(MockContext());
+
+ await security.DidNotReceive().LogAsync(
+ action: "system.integrity_alert",
+ result: Arg.Any(),
+ actorUserId: Arg.Any(),
+ attemptedUsername: Arg.Any(),
+ sessionId: Arg.Any(),
+ failureReason: Arg.Any(),
+ metadata: Arg.Any