diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs
new file mode 100644
index 0000000..cf5682f
--- /dev/null
+++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs
@@ -0,0 +1,163 @@
+using Microsoft.EntityFrameworkCore;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Commons.Types.Audit;
+
+namespace ScadaLink.ConfigurationDatabase.Repositories;
+
+///
+/// EF Core implementation of . See the interface
+/// for the append-only contract; this class only adds notes on the data-access
+/// strategy used by each method.
+///
+public class AuditLogRepository : IAuditLogRepository
+{
+ private readonly ScadaLinkDbContext _context;
+
+ public AuditLogRepository(ScadaLinkDbContext context)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ ///
+ /// Issues a single IF NOT EXISTS … INSERT INTO dbo.AuditLog (…) VALUES (…)
+ /// via .
+ /// Bypasses the EF change tracker so the row never enters a tracked state and
+ /// the enum-as-string conversion is done explicitly in C# (the columns are
+ /// declared varchar(32) via HasConversion<string>() in
+ /// ).
+ ///
+ public async Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
+ {
+ if (evt is null)
+ {
+ throw new ArgumentNullException(nameof(evt));
+ }
+
+ // Enum columns are stored as varchar(32) (HasConversion()), so do
+ // the conversion in C# rather than relying on parameter type inference —
+ // SqlClient would otherwise bind enums as int by default.
+ var channel = evt.Channel.ToString();
+ var kind = evt.Kind.ToString();
+ var status = evt.Status.ToString();
+ var forwardState = evt.ForwardState?.ToString();
+
+ // FormattableString interpolation parameterises every value (no concatenation),
+ // so this is safe against injection even for the string columns.
+ await _context.Database.ExecuteSqlInterpolatedAsync(
+ $@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
+INSERT INTO dbo.AuditLog
+ (EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId,
+ SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
+ HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
+ ResponseSummary, PayloadTruncated, Extra, ForwardState)
+VALUES
+ ({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId},
+ {evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
+ {evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
+ {evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
+ ct);
+ }
+
+ ///
+ /// Builds an AsNoTracking queryable over , applies
+ /// every non-null filter predicate, and pages by keyset on
+ /// (OccurredAtUtc DESC, EventId DESC). The keyset clause is expressed
+ /// directly (occurred < after || (occurred == after && eventId.CompareTo(afterId) < 0))
+ /// — EF Core 10 translates against SQL Server's
+ /// uniqueidentifier sort order.
+ ///
+ public async Task> QueryAsync(
+ AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
+ {
+ if (filter is null)
+ {
+ throw new ArgumentNullException(nameof(filter));
+ }
+
+ if (paging is null)
+ {
+ throw new ArgumentNullException(nameof(paging));
+ }
+
+ var query = _context.Set().AsNoTracking();
+
+ if (filter.Channel is { } channel)
+ {
+ query = query.Where(e => e.Channel == channel);
+ }
+
+ if (filter.Kind is { } kind)
+ {
+ query = query.Where(e => e.Kind == kind);
+ }
+
+ if (filter.Status is { } status)
+ {
+ query = query.Where(e => e.Status == status);
+ }
+
+ if (!string.IsNullOrEmpty(filter.SourceSiteId))
+ {
+ var siteId = filter.SourceSiteId;
+ query = query.Where(e => e.SourceSiteId == siteId);
+ }
+
+ if (!string.IsNullOrEmpty(filter.Target))
+ {
+ var target = filter.Target;
+ query = query.Where(e => e.Target == target);
+ }
+
+ if (!string.IsNullOrEmpty(filter.Actor))
+ {
+ var actor = filter.Actor;
+ query = query.Where(e => e.Actor == actor);
+ }
+
+ if (filter.CorrelationId is { } correlationId)
+ {
+ query = query.Where(e => e.CorrelationId == correlationId);
+ }
+
+ if (filter.FromUtc is { } fromUtc)
+ {
+ query = query.Where(e => e.OccurredAtUtc >= fromUtc);
+ }
+
+ if (filter.ToUtc is { } toUtc)
+ {
+ query = query.Where(e => e.OccurredAtUtc <= toUtc);
+ }
+
+ // Keyset cursor on (OccurredAtUtc desc, EventId desc).
+ if (paging.AfterOccurredAtUtc is { } afterOccurred && paging.AfterEventId is { } afterEventId)
+ {
+ query = query.Where(e =>
+ e.OccurredAtUtc < afterOccurred
+ || (e.OccurredAtUtc == afterOccurred && e.EventId.CompareTo(afterEventId) < 0));
+ }
+
+ return await query
+ .OrderByDescending(e => e.OccurredAtUtc)
+ .ThenByDescending(e => e.EventId)
+ .Take(paging.PageSize)
+ .ToListAsync(ct);
+ }
+
+ ///
+ /// M1 honest contract: throws . The
+ /// UX_AuditLog_EventId unique index is non-aligned with
+ /// ps_AuditLog_Month (it lives on [PRIMARY] to keep
+ /// cheap), and SQL Server rejects
+ /// ALTER TABLE … SWITCH PARTITION when a non-aligned index is present.
+ /// The drop-and-rebuild dance that makes the switch legal ships with the M6
+ /// purge actor.
+ ///
+ public Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
+ {
+ throw new NotSupportedException(
+ "AuditLog partition switch is blocked by the non-aligned UX_AuditLog_EventId " +
+ "unique index; the drop-and-rebuild dance ships in M6 (purge actor).");
+ }
+}
diff --git a/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs b/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs
index 9eca319..b7ba6b4 100644
--- a/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs
+++ b/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs
@@ -46,6 +46,7 @@ public static class ServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
new file mode 100644
index 0000000..6bf8db4
--- /dev/null
+++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
@@ -0,0 +1,267 @@
+using Microsoft.EntityFrameworkCore;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Types.Audit;
+using ScadaLink.Commons.Types.Enums;
+using ScadaLink.ConfigurationDatabase;
+using ScadaLink.ConfigurationDatabase.Repositories;
+using ScadaLink.ConfigurationDatabase.Tests.Migrations;
+using Xunit;
+
+namespace ScadaLink.ConfigurationDatabase.Tests.Repositories;
+
+///
+/// Bundle D (#23 M1) integration tests for . Uses
+/// the same as the Bundle C migration tests so
+/// raw-SQL paths (the IF NOT EXISTS insert, partition switch) execute against a
+/// real partitioned schema. Tests scope all queries by a per-test
+/// SourceSiteId guid suffix so they neither collide with one another nor
+/// require cleanup.
+///
+public class AuditLogRepositoryTests : IClassFixture
+{
+ private readonly MsSqlMigrationFixture _fixture;
+
+ public AuditLogRepositoryTests(MsSqlMigrationFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ [SkippableFact]
+ public async Task InsertIfNotExistsAsync_FreshEvent_WritesOneRow()
+ {
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+
+ var siteId = NewSiteId();
+ await using var context = CreateContext();
+ var repo = new AuditLogRepository(context);
+
+ var evt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc));
+ await repo.InsertIfNotExistsAsync(evt);
+
+ // Re-read in a fresh context so we exercise the persisted row, not the
+ // (already-bypassed) change tracker.
+ await using var readContext = CreateContext();
+ var loaded = await readContext.Set()
+ .Where(e => e.SourceSiteId == siteId)
+ .ToListAsync();
+
+ Assert.Single(loaded);
+ Assert.Equal(evt.EventId, loaded[0].EventId);
+ }
+
+ [SkippableFact]
+ public async Task InsertIfNotExistsAsync_DuplicateEventId_IsNoOp_NoExceptionNoDuplicate()
+ {
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+
+ var siteId = NewSiteId();
+ await using var context = CreateContext();
+ var repo = new AuditLogRepository(context);
+
+ var occurredAt = new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc);
+ var first = NewEvent(siteId, occurredAtUtc: occurredAt, errorMessage: "first");
+ await repo.InsertIfNotExistsAsync(first);
+
+ // Same EventId, different payload — first-write-wins, the second call is silently a no-op.
+ var second = first with { ErrorMessage = "second-should-be-ignored" };
+ await repo.InsertIfNotExistsAsync(second);
+
+ await using var readContext = CreateContext();
+ var loaded = await readContext.Set()
+ .Where(e => e.SourceSiteId == siteId)
+ .ToListAsync();
+
+ Assert.Single(loaded);
+ Assert.Equal("first", loaded[0].ErrorMessage);
+ }
+
+ [SkippableFact]
+ public async Task QueryAsync_ReturnsRowsInOccurredDescOrder()
+ {
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+
+ var siteId = NewSiteId();
+ await using var context = CreateContext();
+ var repo = new AuditLogRepository(context);
+
+ var t0 = new DateTime(2026, 5, 1, 9, 0, 0, DateTimeKind.Utc);
+ await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0));
+ await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(10)));
+ await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(20)));
+
+ var rows = await repo.QueryAsync(
+ new AuditLogQueryFilter(SourceSiteId: siteId),
+ new AuditLogPaging(PageSize: 10));
+
+ Assert.Equal(3, rows.Count);
+ Assert.True(rows[0].OccurredAtUtc > rows[1].OccurredAtUtc);
+ Assert.True(rows[1].OccurredAtUtc > rows[2].OccurredAtUtc);
+ }
+
+ [SkippableFact]
+ public async Task QueryAsync_FilterByChannel()
+ {
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+
+ var siteId = NewSiteId();
+ await using var context = CreateContext();
+ var repo = new AuditLogRepository(context);
+
+ var t0 = new DateTime(2026, 5, 2, 9, 0, 0, DateTimeKind.Utc);
+ await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound));
+ await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification));
+ await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.Notification));
+
+ var rows = await repo.QueryAsync(
+ new AuditLogQueryFilter(Channel: AuditChannel.Notification, SourceSiteId: siteId),
+ new AuditLogPaging(PageSize: 10));
+
+ Assert.Equal(2, rows.Count);
+ Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.Channel));
+ }
+
+ [SkippableFact]
+ public async Task QueryAsync_FilterBySourceSiteId()
+ {
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+
+ var siteId = NewSiteId();
+ var otherSiteId = NewSiteId();
+ await using var context = CreateContext();
+ var repo = new AuditLogRepository(context);
+
+ var t0 = new DateTime(2026, 5, 3, 9, 0, 0, DateTimeKind.Utc);
+ await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0));
+ await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1)));
+ await repo.InsertIfNotExistsAsync(NewEvent(otherSiteId, occurredAtUtc: t0.AddMinutes(2)));
+
+ var rows = await repo.QueryAsync(
+ new AuditLogQueryFilter(SourceSiteId: siteId),
+ new AuditLogPaging(PageSize: 10));
+
+ Assert.Equal(2, rows.Count);
+ Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId));
+ }
+
+ [SkippableFact]
+ public async Task QueryAsync_FilterByTimeRange()
+ {
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+
+ var siteId = NewSiteId();
+ await using var context = CreateContext();
+ var repo = new AuditLogRepository(context);
+
+ var t0 = new DateTime(2026, 5, 4, 9, 0, 0, DateTimeKind.Utc);
+ await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0));
+ await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(30)));
+ await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddHours(2)));
+
+ var rows = await repo.QueryAsync(
+ new AuditLogQueryFilter(
+ SourceSiteId: siteId,
+ FromUtc: t0.AddMinutes(10),
+ ToUtc: t0.AddHours(1)),
+ new AuditLogPaging(PageSize: 10));
+
+ Assert.Single(rows);
+ Assert.Equal(t0.AddMinutes(30), rows[0].OccurredAtUtc);
+ }
+
+ [SkippableFact]
+ public async Task QueryAsync_Keyset_NextPageStartsAfterCursor()
+ {
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+
+ var siteId = NewSiteId();
+ await using var context = CreateContext();
+ var repo = new AuditLogRepository(context);
+
+ var t0 = new DateTime(2026, 5, 5, 9, 0, 0, DateTimeKind.Utc);
+ // Five rows at one-minute intervals. Page-size 2 → page 1 returns minutes 4,3.
+ // Cursor (minutes 3) → page 2 returns minutes 2,1. Cursor (minutes 1) → page 3 returns minute 0.
+ for (var i = 0; i < 5; i++)
+ {
+ await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(i)));
+ }
+
+ var page1 = await repo.QueryAsync(
+ new AuditLogQueryFilter(SourceSiteId: siteId),
+ new AuditLogPaging(PageSize: 2));
+
+ Assert.Equal(2, page1.Count);
+ Assert.Equal(t0.AddMinutes(4), page1[0].OccurredAtUtc);
+ Assert.Equal(t0.AddMinutes(3), page1[1].OccurredAtUtc);
+
+ var cursor = page1[^1];
+ var page2 = await repo.QueryAsync(
+ new AuditLogQueryFilter(SourceSiteId: siteId),
+ new AuditLogPaging(
+ PageSize: 2,
+ AfterOccurredAtUtc: cursor.OccurredAtUtc,
+ AfterEventId: cursor.EventId));
+
+ Assert.Equal(2, page2.Count);
+ Assert.Equal(t0.AddMinutes(2), page2[0].OccurredAtUtc);
+ Assert.Equal(t0.AddMinutes(1), page2[1].OccurredAtUtc);
+
+ var cursor2 = page2[^1];
+ var page3 = await repo.QueryAsync(
+ new AuditLogQueryFilter(SourceSiteId: siteId),
+ new AuditLogPaging(
+ PageSize: 2,
+ AfterOccurredAtUtc: cursor2.OccurredAtUtc,
+ AfterEventId: cursor2.EventId));
+
+ Assert.Single(page3);
+ Assert.Equal(t0.AddMinutes(0), page3[0].OccurredAtUtc);
+ }
+
+ [SkippableFact]
+ public async Task SwitchOutPartitionAsync_ThrowsNotSupported_ForM1()
+ {
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+
+ await using var context = CreateContext();
+ var repo = new AuditLogRepository(context);
+
+ // The partition-switch path is intentionally blocked in M1 because
+ // UX_AuditLog_EventId is non-aligned. The drop-and-rebuild dance ships
+ // with the M6 purge actor.
+ var ex = await Assert.ThrowsAsync(
+ () => repo.SwitchOutPartitionAsync(new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc)));
+
+ Assert.Contains("M6", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ // --- helpers ------------------------------------------------------------
+
+ private ScadaLinkDbContext CreateContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseSqlServer(_fixture.ConnectionString)
+ .Options;
+ return new ScadaLinkDbContext(options);
+ }
+
+ private static string NewSiteId() =>
+ "test-bundle-d-" + Guid.NewGuid().ToString("N").Substring(0, 8);
+
+ private static AuditEvent NewEvent(
+ string siteId,
+ DateTime occurredAtUtc,
+ AuditChannel channel = AuditChannel.ApiOutbound,
+ AuditKind kind = AuditKind.ApiCall,
+ AuditStatus status = AuditStatus.Delivered,
+ string? errorMessage = null) =>
+ new()
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = occurredAtUtc,
+ Channel = channel,
+ Kind = kind,
+ Status = status,
+ SourceSiteId = siteId,
+ ErrorMessage = errorMessage,
+ };
+}
diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs
index c745a86..11baf14 100644
--- a/tests/ScadaLink.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs
+++ b/tests/ScadaLink.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs
@@ -18,6 +18,7 @@ public class ServiceCollectionExtensionsTests
Assert.Contains(services, d => d.ServiceType == typeof(ITemplateEngineRepository));
Assert.Contains(services, d => d.ServiceType == typeof(IAuditService));
Assert.Contains(services, d => d.ServiceType == typeof(IInstanceLocator));
+ Assert.Contains(services, d => d.ServiceType == typeof(IAuditLogRepository));
}
// The no-arg overload is [Obsolete(error: true)], so it cannot be referenced directly