diff --git a/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj b/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj
index 4999344..91c296c 100644
--- a/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj
+++ b/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj
@@ -8,7 +8,9 @@
+
+
diff --git a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs
new file mode 100644
index 0000000..1db6b1b
--- /dev/null
+++ b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs
@@ -0,0 +1,109 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Interfaces.Services;
+
+namespace ScadaLink.AuditLog.Site;
+
+///
+/// Site-side SQLite hot-path writer for Audit Log (#23) events. Mirrors the
+/// design โ a single
+/// owned serialised behind a write lock, fed by a
+/// bounded drained on a
+/// dedicated background writer task โ so script-thread callers never block on
+/// disk I/O.
+///
+///
+/// Bundle B (M2-T1) ships only the schema bootstrap; the channel + writer loop
+/// land in Bundle B (M2-T2).
+///
+public class SqliteAuditWriter : IAuditWriter, IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly SqliteAuditWriterOptions _options;
+ private readonly ILogger _logger;
+ private readonly object _writeLock = new();
+ private bool _disposed;
+
+ public SqliteAuditWriter(
+ IOptions options,
+ ILogger logger,
+ string? connectionStringOverride = null)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ _options = options.Value;
+ _logger = logger;
+
+ var connectionString = connectionStringOverride
+ ?? $"Data Source={_options.DatabasePath};Cache=Shared";
+ _connection = new SqliteConnection(connectionString);
+ _connection.Open();
+
+ InitializeSchema();
+ }
+
+ private void InitializeSchema()
+ {
+ // auto_vacuum must be set before any table is created for it to take
+ // effect on a fresh database. INCREMENTAL lets a future
+ // `PRAGMA incremental_vacuum` shrink the file after the 7-day retention
+ // purge โ see alog.md ยง10.
+ using (var pragmaCmd = _connection.CreateCommand())
+ {
+ pragmaCmd.CommandText = "PRAGMA auto_vacuum = INCREMENTAL";
+ pragmaCmd.ExecuteNonQuery();
+ }
+
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = """
+ CREATE TABLE IF NOT EXISTS AuditLog (
+ EventId TEXT NOT NULL,
+ OccurredAtUtc TEXT NOT NULL,
+ Channel TEXT NOT NULL,
+ Kind TEXT NOT NULL,
+ CorrelationId TEXT NULL,
+ SourceSiteId TEXT NULL,
+ SourceInstanceId TEXT NULL,
+ SourceScript TEXT NULL,
+ Actor TEXT NULL,
+ Target TEXT NULL,
+ Status TEXT NOT NULL,
+ HttpStatus INTEGER NULL,
+ DurationMs INTEGER NULL,
+ ErrorMessage TEXT NULL,
+ ErrorDetail TEXT NULL,
+ RequestSummary TEXT NULL,
+ ResponseSummary TEXT NULL,
+ PayloadTruncated INTEGER NOT NULL,
+ Extra TEXT NULL,
+ ForwardState TEXT NOT NULL,
+ PRIMARY KEY (EventId)
+ );
+ CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
+ ON AuditLog (ForwardState, OccurredAtUtc);
+ """;
+ cmd.ExecuteNonQuery();
+ }
+
+ ///
+ /// Persist an audit event. Bundle B (M2-T2) replaces this stub with a
+ /// non-blocking Channel-based enqueue draining on a background writer task.
+ ///
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
+ {
+ throw new NotImplementedException("Channel-based hot-path lands in Bundle B-T2.");
+ }
+
+ public void Dispose()
+ {
+ lock (_writeLock)
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _connection.Dispose();
+ }
+ }
+}
diff --git a/src/ScadaLink.AuditLog/Site/SqliteAuditWriterOptions.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriterOptions.cs
new file mode 100644
index 0000000..8f1fdc1
--- /dev/null
+++ b/src/ScadaLink.AuditLog/Site/SqliteAuditWriterOptions.cs
@@ -0,0 +1,27 @@
+namespace ScadaLink.AuditLog.Site;
+
+///
+/// Options for the site-side SQLite hot-path audit writer.
+/// Mirrors the ScadaLink.SiteEventLogging pattern: a single SQLite connection
+/// fed by a background writer task draining a bounded
+/// so script-thread enqueues
+/// never block on disk I/O.
+///
+public sealed class SqliteAuditWriterOptions
+{
+ /// SQLite database path (or in-memory URI for tests).
+ public string DatabasePath { get; set; } = "auditlog.db";
+
+ ///
+ /// Capacity of the bounded write queue. Set high enough that ordinary
+ /// script bursts never fill it;
+ /// applies when the writer falls behind.
+ ///
+ public int ChannelCapacity { get; set; } = 4096;
+
+ /// Max number of pending events the writer drains in one transaction.
+ public int BatchSize { get; set; } = 256;
+
+ /// Soft flush interval the writer enforces when fewer than BatchSize events are queued.
+ public int FlushIntervalMs { get; set; } = 50;
+}
diff --git a/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj b/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj
index 9a866be..539e9a5 100644
--- a/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj
+++ b/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj
@@ -10,8 +10,11 @@
+
+
+
diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs
new file mode 100644
index 0000000..b02dd63
--- /dev/null
+++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs
@@ -0,0 +1,128 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using ScadaLink.AuditLog.Site;
+
+namespace ScadaLink.AuditLog.Tests.Site;
+
+///
+/// Bundle B (M2-T1) schema-bootstrap tests for .
+/// Uses an in-memory shared-cache SQLite database so the same connection name
+/// reaches the same file-less db across both the writer and the verifier.
+///
+public class SqliteAuditWriterSchemaTests
+{
+ ///
+ /// Each test uses a unique shared-cache in-memory database. The
+ /// "Mode=Memory;Cache=Shared" syntax lets two SqliteConnections see the same
+ /// in-memory store as long as both use the same Data Source name.
+ ///
+ private static (SqliteAuditWriter writer, string dataSource) CreateWriter(string testName)
+ {
+ var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared";
+ var options = new SqliteAuditWriterOptions
+ {
+ DatabasePath = dataSource,
+ };
+ // The writer uses raw "Data Source={path}" by appending Cache=Shared. Override
+ // by passing the full connection string via the connectionStringOverride hook.
+ var writer = new SqliteAuditWriter(
+ Options.Create(options),
+ NullLogger.Instance,
+ connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
+ return (writer, dataSource);
+ }
+
+ private static SqliteConnection OpenVerifierConnection(string dataSource)
+ {
+ var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
+ connection.Open();
+ return connection;
+ }
+
+ [Fact]
+ public void Opens_Creates_AuditLog_Table_With_20Columns_And_PK_On_EventId()
+ {
+ var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_20Columns_And_PK_On_EventId));
+ using (writer)
+ {
+ using var connection = OpenVerifierConnection(dataSource);
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "PRAGMA table_info(AuditLog);";
+ using var reader = cmd.ExecuteReader();
+
+ var columns = new List<(string Name, int Pk)>();
+ while (reader.Read())
+ {
+ columns.Add((reader.GetString(1), reader.GetInt32(5)));
+ }
+
+ Assert.Equal(20, columns.Count);
+
+ var expected = new[]
+ {
+ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId",
+ "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target",
+ "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail",
+ "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra",
+ "ForwardState",
+ };
+ Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n));
+
+ // PK is EventId only.
+ var pkColumns = columns.Where(c => c.Pk > 0).Select(c => c.Name).ToList();
+ Assert.Single(pkColumns);
+ Assert.Equal("EventId", pkColumns[0]);
+ }
+ }
+
+ [Fact]
+ public void Opens_Creates_IX_ForwardState_Occurred_Index()
+ {
+ var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_IX_ForwardState_Occurred_Index));
+ using (writer)
+ {
+ using var connection = OpenVerifierConnection(dataSource);
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "PRAGMA index_list(AuditLog);";
+ using var reader = cmd.ExecuteReader();
+
+ var indexNames = new List();
+ while (reader.Read())
+ {
+ indexNames.Add(reader.GetString(1));
+ }
+
+ Assert.Contains("IX_SiteAuditLog_ForwardState_Occurred", indexNames);
+
+ // Verify the index columns are ForwardState, OccurredAtUtc in that order.
+ using var infoCmd = connection.CreateCommand();
+ infoCmd.CommandText = "PRAGMA index_info(IX_SiteAuditLog_ForwardState_Occurred);";
+ using var infoReader = infoCmd.ExecuteReader();
+
+ var indexColumns = new List();
+ while (infoReader.Read())
+ {
+ indexColumns.Add(infoReader.GetString(2));
+ }
+
+ Assert.Equal(new[] { "ForwardState", "OccurredAtUtc" }, indexColumns);
+ }
+ }
+
+ [Fact]
+ public void PRAGMA_auto_vacuum_Is_INCREMENTAL()
+ {
+ var (writer, dataSource) = CreateWriter(nameof(PRAGMA_auto_vacuum_Is_INCREMENTAL));
+ using (writer)
+ {
+ using var connection = OpenVerifierConnection(dataSource);
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "PRAGMA auto_vacuum;";
+ var value = Convert.ToInt32(cmd.ExecuteScalar());
+
+ // INCREMENTAL = 2 (0 = NONE, 1 = FULL, 2 = INCREMENTAL).
+ Assert.Equal(2, value);
+ }
+ }
+}