diff --git a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs
new file mode 100644
index 0000000..2936e55
--- /dev/null
+++ b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs
@@ -0,0 +1,522 @@
+using System.Data;
+using System.Data.Common;
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Interfaces.Services;
+using ScadaLink.Commons.Types.Enums;
+
+namespace ScadaLink.SiteRuntime.Scripts;
+
+///
+/// Audit Log #23 — M4 Bundle A: decorator that emits
+/// exactly one DbOutbound/DbWrite audit event per execution.
+///
+///
+///
+/// Vocabulary lock (M4 plan): both writes (Execute / ExecuteScalar) and
+/// reads (ExecuteReader) emit on the
+/// channel. The Extra JSON column
+/// distinguishes them — {"op":"write","rowsAffected":N} for writes,
+/// {"op":"read","rowsReturned":N} for reads.
+///
+///
+/// Best-effort emission (alog.md §7): mirrors
+/// 's 3-layer fail-safe.
+/// The original ADO.NET result (or original exception) flows back to the
+/// script untouched; audit-build, audit-write, and audit-continuation faults
+/// are all logged + swallowed. A faulted never
+/// aborts the SQL call.
+///
+///
+internal sealed class AuditingDbCommand : DbCommand
+{
+ private readonly DbCommand _inner;
+ private readonly IAuditWriter _auditWriter;
+ private readonly string _connectionName;
+ private readonly string _siteId;
+ private readonly string _instanceName;
+ private readonly string? _sourceScript;
+ private readonly ILogger _logger;
+ private DbConnection? _wrappingConnection;
+
+ public AuditingDbCommand(
+ DbCommand inner,
+ IAuditWriter auditWriter,
+ string connectionName,
+ string siteId,
+ string instanceName,
+ string? sourceScript,
+ ILogger logger)
+ {
+ _inner = inner ?? throw new ArgumentNullException(nameof(inner));
+ _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
+ _connectionName = connectionName ?? throw new ArgumentNullException(nameof(connectionName));
+ _siteId = siteId ?? string.Empty;
+ _instanceName = instanceName ?? string.Empty;
+ _sourceScript = sourceScript;
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ // -- Forwarded surface ------------------------------------------------
+
+#pragma warning disable CS8765 // ADO.NET base members carry pre-NRT signatures with permissive nullability
+ public override string CommandText
+ {
+ get => _inner.CommandText;
+ set => _inner.CommandText = value;
+ }
+#pragma warning restore CS8765
+
+ public override int CommandTimeout
+ {
+ get => _inner.CommandTimeout;
+ set => _inner.CommandTimeout = value;
+ }
+
+ public override CommandType CommandType
+ {
+ get => _inner.CommandType;
+ set => _inner.CommandType = value;
+ }
+
+ public override bool DesignTimeVisible
+ {
+ get => _inner.DesignTimeVisible;
+ set => _inner.DesignTimeVisible = value;
+ }
+
+ public override UpdateRowSource UpdatedRowSource
+ {
+ get => _inner.UpdatedRowSource;
+ set => _inner.UpdatedRowSource = value;
+ }
+
+ protected override DbConnection? DbConnection
+ {
+ // When the script has wrapped the connection (the normal path through
+ // ScriptRuntimeContext.DatabaseHelper.Connection) we keep returning
+ // the wrapper, but writes from the user go through to the inner
+ // command so the underlying provider keeps its wiring intact.
+ get => _wrappingConnection ?? _inner.Connection;
+ set
+ {
+ _wrappingConnection = value;
+ _inner.Connection = value switch
+ {
+ AuditingDbConnection auditing => auditing.GetType()
+ .GetField("_inner", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
+ !.GetValue(auditing) as DbConnection,
+ _ => value
+ };
+ }
+ }
+
+ protected override DbParameterCollection DbParameterCollection => _inner.Parameters;
+
+ protected override DbTransaction? DbTransaction
+ {
+ get => _inner.Transaction;
+ set => _inner.Transaction = value;
+ }
+
+ public override void Cancel() => _inner.Cancel();
+
+ public override void Prepare() => _inner.Prepare();
+
+ protected override DbParameter CreateDbParameter() => _inner.CreateParameter();
+
+ // -- Audited execution surface ---------------------------------------
+
+ public override int ExecuteNonQuery()
+ {
+ var occurredAtUtc = DateTime.UtcNow;
+ var startTicks = Stopwatch.GetTimestamp();
+ int rows = 0;
+ Exception? thrown = null;
+ try
+ {
+ rows = _inner.ExecuteNonQuery();
+ return rows;
+ }
+ catch (Exception ex)
+ {
+ thrown = ex;
+ throw;
+ }
+ finally
+ {
+ EmitAudit(
+ occurredAtUtc,
+ ElapsedMs(startTicks),
+ op: "write",
+ rowsAffected: thrown == null ? rows : (int?)null,
+ rowsReturned: null,
+ thrown);
+ }
+ }
+
+ public override object? ExecuteScalar()
+ {
+ var occurredAtUtc = DateTime.UtcNow;
+ var startTicks = Stopwatch.GetTimestamp();
+ object? scalar = null;
+ Exception? thrown = null;
+ try
+ {
+ scalar = _inner.ExecuteScalar();
+ return scalar;
+ }
+ catch (Exception ex)
+ {
+ thrown = ex;
+ throw;
+ }
+ finally
+ {
+ // ExecuteScalar is classified as "write" per the M4 vocabulary
+ // lock — it's a single-value execution; rowsAffected mirrors the
+ // inner command's value if exposed (DbCommand has no RecordsAffected
+ // property, so we report -1 when the provider didn't surface it).
+ EmitAudit(
+ occurredAtUtc,
+ ElapsedMs(startTicks),
+ op: "write",
+ rowsAffected: thrown == null ? -1 : (int?)null,
+ rowsReturned: null,
+ thrown);
+ }
+ }
+
+ public override async Task ExecuteNonQueryAsync(CancellationToken cancellationToken)
+ {
+ var occurredAtUtc = DateTime.UtcNow;
+ var startTicks = Stopwatch.GetTimestamp();
+ int rows = 0;
+ Exception? thrown = null;
+ try
+ {
+ rows = await _inner.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ return rows;
+ }
+ catch (Exception ex)
+ {
+ thrown = ex;
+ throw;
+ }
+ finally
+ {
+ EmitAudit(
+ occurredAtUtc,
+ ElapsedMs(startTicks),
+ op: "write",
+ rowsAffected: thrown == null ? rows : (int?)null,
+ rowsReturned: null,
+ thrown);
+ }
+ }
+
+ public override async Task