diff --git a/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs b/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs
new file mode 100644
index 0000000..f83f3ab
--- /dev/null
+++ b/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs
@@ -0,0 +1,60 @@
+using ScadaLink.Commons.Types;
+
+namespace ScadaLink.Commons.Entities.Audit;
+
+///
+/// Central operational state row for a cached call (Site Call Audit #22, Audit Log #23 M3).
+/// One row per in the SiteCalls table — append-once
+/// then monotonic status update. Status transitions are forward-only
+/// (Submitted → Forwarded → Attempted → Delivered|Failed|Parked|Discarded); an
+/// out-of-order or duplicate upsert is a silent no-op so duplicate gRPC packets and
+/// reconciliation pulls can both feed the same writer without rolling state back.
+///
+///
+/// Sites remain the source of truth — this row is the eventually-consistent mirror the
+/// Central UI's Site Calls page reads. Unlike the partitioned AuditLog table this
+/// entity backs operational (mutable) state on a standard non-partitioned table on
+/// [PRIMARY]; no DB-role restriction applies.
+///
+public sealed record SiteCall
+{
+ /// Strong-typed idempotency key shared with every audit row for the operation.
+ public required TrackedOperationId TrackedOperationId { get; init; }
+
+ /// Trust-boundary channel — "ApiOutbound" or "DbOutbound".
+ public required string Channel { get; init; }
+
+ /// Human-readable target (e.g. "ERP.GetOrder").
+ public required string Target { get; init; }
+
+ /// Site id that submitted the cached call.
+ public required string SourceSite { get; init; }
+
+ ///
+ /// Lifecycle status — string form of
+ /// . Monotonic: later rank
+ /// wins, earlier rank is a no-op.
+ ///
+ public required string Status { get; init; }
+
+ /// Number of dispatch attempts so far; 0 prior to first attempt.
+ public required int RetryCount { get; init; }
+
+ /// Most recent error message; null when no failures have occurred.
+ public string? LastError { get; init; }
+
+ /// Most recent HTTP status code (API calls only); null otherwise.
+ public int? HttpStatus { get; init; }
+
+ /// UTC timestamp the cached call was first submitted at the site.
+ public required DateTime CreatedAtUtc { get; init; }
+
+ /// UTC timestamp of the latest status mutation at the site.
+ public required DateTime UpdatedAtUtc { get; init; }
+
+ /// UTC timestamp the row reached a terminal status; null while still active.
+ public DateTime? TerminalAtUtc { get; init; }
+
+ /// UTC timestamp central ingested (or last refreshed) this row.
+ public required DateTime IngestedAtUtc { get; init; }
+}
diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs
new file mode 100644
index 0000000..78b7ccc
--- /dev/null
+++ b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs
@@ -0,0 +1,75 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Types;
+
+namespace ScadaLink.ConfigurationDatabase.Configurations;
+
+///
+/// Maps the record to the central SiteCalls table
+/// (Site Call Audit #22, Audit Log #23 M3 Bundle B). Operational state — NOT audit —
+/// so the table is non-partitioned, standard [PRIMARY] filegroup, no DB-role
+/// restriction. Two named indexes back the Central UI's "from this site" and
+/// "in this status" queries.
+///
+public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("SiteCalls");
+
+ // PK is the strong-typed TrackedOperationId. Stored as varchar(36) by converting
+ // through the canonical "D"-format GUID string. Going through the string surface
+ // (rather than uniqueidentifier) keeps the column shape identical to how the id
+ // is serialised on the wire (gRPC strings, SQLite TEXT on the site) — one
+ // consistent format everywhere makes operational debugging far easier than
+ // mixing a uniqueidentifier central column with TEXT site columns.
+ builder.HasKey(s => s.TrackedOperationId);
+
+ builder.Property(s => s.TrackedOperationId)
+ .HasConversion(
+ id => id.Value.ToString("D"),
+ s => new TrackedOperationId(Guid.Parse(s)))
+ .HasMaxLength(36)
+ .IsUnicode(false)
+ .IsRequired();
+
+ // Enum-as-string columns: bounded varchar, ASCII.
+ builder.Property(s => s.Channel)
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .IsRequired();
+
+ builder.Property(s => s.Status)
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .IsRequired();
+
+ builder.Property(s => s.SourceSite)
+ .HasMaxLength(64)
+ .IsUnicode(false)
+ .IsRequired();
+
+ builder.Property(s => s.Target)
+ .HasMaxLength(256)
+ .IsUnicode(false)
+ .IsRequired();
+
+ // Bounded unicode message column.
+ builder.Property(s => s.LastError)
+ .HasMaxLength(1024);
+
+ // Indexes — names locked for reconciliation/migration discoverability.
+ // Source_Created backs "calls from this site" (Central UI Site Calls page,
+ // filter by SourceSite, newest first).
+ builder.HasIndex(s => new { s.SourceSite, s.CreatedAtUtc })
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_SiteCalls_Source_Created");
+
+ // Status_Updated backs "calls in this status" (e.g. parked rows awaiting
+ // operator action, newest UpdatedAtUtc first).
+ builder.HasIndex(s => new { s.Status, s.UpdatedAtUtc })
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_SiteCalls_Status_Updated");
+ }
+}
diff --git a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs
index f25118f..4e35590 100644
--- a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs
+++ b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs
@@ -85,6 +85,7 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
// Audit
public DbSet AuditLogEntries => Set();
public DbSet AuditLogs => Set();
+ public DbSet SiteCalls => Set();
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
public DbSet DataProtectionKeys => Set();
diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/SiteCallEntityTypeConfigurationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/SiteCallEntityTypeConfigurationTests.cs
new file mode 100644
index 0000000..ed0720b
--- /dev/null
+++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/SiteCallEntityTypeConfigurationTests.cs
@@ -0,0 +1,109 @@
+using Microsoft.EntityFrameworkCore;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.ConfigurationDatabase;
+using ScadaLink.ConfigurationDatabase.Configurations;
+
+namespace ScadaLink.ConfigurationDatabase.Tests.Configurations;
+
+///
+/// Schema-level tests for (#22 / #23 M3 Bundle B).
+/// Verifies the record maps to the SiteCalls table with the
+/// expected primary key, value conversion on TrackedOperationId, and the two named
+/// indexes that back the "calls from this site" and "calls in this status" Central UI queries.
+/// Mirrors the AuditLog Bundle B test pattern — inspects EF model metadata via the existing
+/// in-memory SQLite test context, no database round-trips required.
+///
+public class SiteCallEntityTypeConfigurationTests : IDisposable
+{
+ private readonly ScadaLinkDbContext _context;
+
+ public SiteCallEntityTypeConfigurationTests()
+ {
+ _context = SqliteTestHelper.CreateInMemoryContext();
+ }
+
+ public void Dispose()
+ {
+ _context.Database.CloseConnection();
+ _context.Dispose();
+ }
+
+ [Fact]
+ public void Configure_MapsToSiteCallsTable()
+ {
+ var entity = _context.Model.FindEntityType(typeof(SiteCall));
+
+ Assert.NotNull(entity);
+ Assert.Equal("SiteCalls", entity!.GetTableName());
+ }
+
+ [Fact]
+ public void Configure_PrimaryKey_TrackedOperationId()
+ {
+ var entity = _context.Model.FindEntityType(typeof(SiteCall));
+ Assert.NotNull(entity);
+
+ var pk = entity!.FindPrimaryKey();
+ Assert.NotNull(pk);
+
+ var pkPropertyNames = pk!.Properties.Select(p => p.Name).ToArray();
+ Assert.Equal(new[] { nameof(SiteCall.TrackedOperationId) }, pkPropertyNames);
+ }
+
+ [Fact]
+ public void Configure_HasIndexes_NamedAndOrdered()
+ {
+ var entity = _context.Model.FindEntityType(typeof(SiteCall));
+ Assert.NotNull(entity);
+
+ var indexes = entity!.GetIndexes().ToList();
+
+ // IX_SiteCalls_Source_Created: (SourceSite ASC, CreatedAtUtc DESC).
+ var sourceCreated = indexes.SingleOrDefault(i => i.GetDatabaseName() == "IX_SiteCalls_Source_Created");
+ Assert.NotNull(sourceCreated);
+ var sourceCreatedProps = sourceCreated!.Properties.Select(p => p.Name).ToArray();
+ Assert.Equal(new[] { nameof(SiteCall.SourceSite), nameof(SiteCall.CreatedAtUtc) }, sourceCreatedProps);
+
+ // IX_SiteCalls_Status_Updated: (Status ASC, UpdatedAtUtc DESC).
+ var statusUpdated = indexes.SingleOrDefault(i => i.GetDatabaseName() == "IX_SiteCalls_Status_Updated");
+ Assert.NotNull(statusUpdated);
+ var statusUpdatedProps = statusUpdated!.Properties.Select(p => p.Name).ToArray();
+ Assert.Equal(new[] { nameof(SiteCall.Status), nameof(SiteCall.UpdatedAtUtc) }, statusUpdatedProps);
+ }
+
+ [Fact]
+ public void Configure_TrackedOperationId_ConvertedToString_Length36()
+ {
+ var entity = _context.Model.FindEntityType(typeof(SiteCall));
+ Assert.NotNull(entity);
+
+ var property = entity!.FindProperty(nameof(SiteCall.TrackedOperationId));
+ Assert.NotNull(property);
+
+ // Stored as varchar(36) (TrackedOperationId.ToString("D") is always 36 chars).
+ // The value-conversion target type is exposed via GetProviderClrType when set, or
+ // discovered indirectly through the configured converter; either way the on-wire
+ // CLR type is string.
+ var providerClrType = property!.GetProviderClrType() ?? property.GetValueConverter()?.ProviderClrType;
+ Assert.Equal(typeof(string), providerClrType);
+ Assert.Equal(36, property.GetMaxLength());
+ Assert.False(property.IsUnicode() ?? true);
+ }
+
+ [Theory]
+ [InlineData(nameof(SiteCall.Channel), 32)]
+ [InlineData(nameof(SiteCall.SourceSite), 64)]
+ [InlineData(nameof(SiteCall.Status), 32)]
+ [InlineData(nameof(SiteCall.Target), 256)]
+ public void Configure_AsciiBoundedColumns(string propertyName, int expectedMaxLength)
+ {
+ var entity = _context.Model.FindEntityType(typeof(SiteCall));
+ Assert.NotNull(entity);
+
+ var property = entity!.FindProperty(propertyName);
+ Assert.NotNull(property);
+
+ Assert.Equal(expectedMaxLength, property!.GetMaxLength());
+ Assert.False(property.IsUnicode() ?? true);
+ }
+}