diff --git a/docs/plans/sql/AddAuditLogHashChain.sql b/docs/plans/sql/AddAuditLogHashChain.sql
new file mode 100644
index 00000000..7818ca7c
--- /dev/null
+++ b/docs/plans/sql/AddAuditLogHashChain.sql
@@ -0,0 +1,68 @@
+-- AddAuditLogHashChain (2026-11-09, ScadaBridge#41). AuditLog hash-chain tamper evidence:
+-- ChainSeq/RowHash on dbo.AuditLog, AuditLogChainHead + AuditLogChainTombstone, two partition-aligned
+-- filtered indexes, the scadabridge_audit_chainer role. Apply to the central configuration database
+-- with both central nodes stopped (old binaries cannot switch out a partition once the columns exist).
+BEGIN TRANSACTION;
+ALTER TABLE [AuditLog] ADD [ChainSeq] bigint NULL;
+
+ALTER TABLE [AuditLog] ADD [RowHash] binary(32) NULL;
+
+CREATE TABLE [AuditLogChainHead] (
+ [ChainMonth] date NOT NULL,
+ [LastSeq] bigint NOT NULL,
+ [LastHash] binary(32) NOT NULL,
+ [PurgedThroughSeq] bigint NULL,
+ [PurgedThroughHash] binary(32) NULL,
+ [UpdatedAtUtc] datetime2 NOT NULL,
+ CONSTRAINT [PK_AuditLogChainHead] PRIMARY KEY ([ChainMonth])
+);
+
+CREATE TABLE [AuditLogChainTombstone] (
+ [ChainMonth] date NOT NULL,
+ [ChainSeq] bigint NOT NULL,
+ [RowHash] binary(32) NOT NULL,
+ [EventId] uniqueidentifier NOT NULL,
+ [PurgedAtUtc] datetime2 NOT NULL,
+ CONSTRAINT [PK_AuditLogChainTombstone] PRIMARY KEY ([ChainMonth], [ChainSeq])
+);
+
+
+CREATE NONCLUSTERED INDEX IX_AuditLog_Unchained
+ON dbo.AuditLog (OccurredAtUtc, EventId)
+WHERE ChainSeq IS NULL
+ON ps_AuditLog_Month(OccurredAtUtc);
+
+
+CREATE NONCLUSTERED INDEX IX_AuditLog_ChainSeq
+ON dbo.AuditLog (ChainSeq)
+WHERE ChainSeq IS NOT NULL
+ON ps_AuditLog_Month(OccurredAtUtc);
+
+
+IF DATABASE_PRINCIPAL_ID('scadabridge_audit_chainer') IS NULL
+ EXEC sp_executesql N'CREATE ROLE scadabridge_audit_chainer';
+-- The chaining job reads unchained rows and writes ONLY the two link columns.
+GRANT SELECT ON dbo.AuditLog TO scadabridge_audit_chainer;
+GRANT UPDATE (ChainSeq, RowHash) ON dbo.AuditLog TO scadabridge_audit_chainer;
+GRANT SELECT, INSERT, UPDATE ON dbo.AuditLogChainHead TO scadabridge_audit_chainer;
+GRANT SELECT ON dbo.AuditLogChainTombstone TO scadabridge_audit_chainer;
+
+IF DATABASE_PRINCIPAL_ID('scadabridge_audit_purger') IS NULL
+ EXEC sp_executesql N'CREATE ROLE scadabridge_audit_purger';
+-- The per-channel purge writes a tombstone per chained row it removes; the partition
+-- switch moves the month's head to its checkpoint anchor and drops its tombstones.
+GRANT SELECT, UPDATE ON dbo.AuditLogChainHead TO scadabridge_audit_purger;
+GRANT SELECT, INSERT, DELETE ON dbo.AuditLogChainTombstone TO scadabridge_audit_purger;
+
+IF DATABASE_PRINCIPAL_ID('scadabridge_audit_writer') IS NULL
+ EXEC sp_executesql N'CREATE ROLE scadabridge_audit_writer';
+-- The ingest principal must never be able to forge chain state.
+DENY INSERT, UPDATE, DELETE ON dbo.AuditLogChainHead TO scadabridge_audit_writer;
+DENY INSERT, UPDATE, DELETE ON dbo.AuditLogChainTombstone TO scadabridge_audit_writer;
+
+INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+VALUES (N'20261109211317_AddAuditLogHashChain', N'10.0.13');
+
+COMMIT;
+GO
+
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogHashChainOptions.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogHashChainOptions.cs
new file mode 100644
index 00000000..db2ff85e
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogHashChainOptions.cs
@@ -0,0 +1,51 @@
+using ZB.MOM.WW.Configuration;
+
+namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
+
+///
+/// Options for the central (section
+/// AuditLog:HashChain) — hash-chain tamper evidence for the AuditLog
+/// (audit roadmap T1, alog.md §11.4, ScadaBridge#41).
+///
+///
+///
+/// Default OFF (alog.md §11.4: "Default off … to avoid operational burden"). When
+/// enabled, both central nodes run the job; the SQL application lock means only one of
+/// them chains at a time, so a dual-active pair still produces one gap-free chain per month.
+///
+///
+/// Enabling it on an existing database chains the historical backlog oldest month first,
+/// × rows per tick, so the first
+/// ticks run long. Rows chained late are only protected from the moment they are chained.
+/// Run audit backfill-source-node BEFORE enabling: a chained row's SourceNode is frozen.
+///
+///
+public sealed class AuditLogHashChainOptions
+{
+ /// Whether the chaining job runs (default false).
+ public bool Enabled { get; set; }
+
+ /// Seconds between chaining ticks (default 60).
+ public int IntervalSeconds { get; set; } = 60;
+
+ /// Rows linked per transaction (default 1000).
+ public int BatchSize { get; set; } = 1000;
+
+ /// Upper bound on batches per tick so a backlog yields between ticks (default 100).
+ public int MaxBatchesPerTick { get; set; } = 100;
+}
+
+/// Validates at host startup.
+public sealed class AuditLogHashChainOptionsValidator : OptionsValidatorBase
+{
+ ///
+ protected override void Validate(ValidationBuilder builder, AuditLogHashChainOptions options)
+ {
+ builder.RequireThat(options.IntervalSeconds > 0,
+ $"AuditLog:HashChain:{nameof(AuditLogHashChainOptions.IntervalSeconds)} ({options.IntervalSeconds}) must be > 0.");
+ builder.RequireThat(options.BatchSize is > 0 and <= 10_000,
+ $"AuditLog:HashChain:{nameof(AuditLogHashChainOptions.BatchSize)} ({options.BatchSize}) must be between 1 and 10000.");
+ builder.RequireThat(options.MaxBatchesPerTick > 0,
+ $"AuditLog:HashChain:{nameof(AuditLogHashChainOptions.MaxBatchesPerTick)} ({options.MaxBatchesPerTick}) must be > 0.");
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogHashChainService.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogHashChainService.cs
new file mode 100644
index 00000000..4a003743
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogHashChainService.cs
@@ -0,0 +1,151 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
+
+namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
+
+///
+/// Central hosted service that links newly written AuditLog rows into their month's
+/// SHA-256 hash chain (audit roadmap T1, alog.md §11.4, ScadaBridge#41). The chaining
+/// job of alog.md §11.4, run incrementally — every
+/// rather than only once a month has closed, so a row is tamper-evident within about a
+/// minute of reaching central instead of up to a month later.
+///
+///
+///
+/// Same shape as : a background loop, a
+/// fresh DI scope per tick, every failure logged and contained. Runs on BOTH central nodes;
+/// takes an exclusive SQL application
+/// lock with a zero timeout, so the peer's pass skips instead of racing (and correctness
+/// does not depend on Akka's view of which node is active).
+///
+///
+public sealed class AuditLogHashChainService : IHostedService, IDisposable
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly IOptions _options;
+ private readonly ILogger _logger;
+ private CancellationTokenSource? _cts;
+ private Task? _loop;
+
+ /// Initializes the service.
+ /// Scope factory used to resolve the scoped chain implementation per tick.
+ /// Hash-chain options.
+ /// Logger.
+ public AuditLogHashChainService(
+ IServiceScopeFactory scopeFactory,
+ IOptions options,
+ ILogger logger)
+ {
+ _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ if (!_options.Value.Enabled)
+ {
+ _logger.LogInformation("AuditLog hash chain disabled (AuditLog:HashChain:Enabled=false); no chaining job started.");
+ return Task.CompletedTask;
+ }
+
+ var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ _cts = cts;
+ var token = cts.Token;
+ _loop = Task.Run(() => RunLoopAsync(token), CancellationToken.None);
+ return Task.CompletedTask;
+ }
+
+ private async Task RunLoopAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ await TickAsync(ct).ConfigureAwait(false);
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(_options.Value.IntervalSeconds), ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ }
+ }
+
+ ///
+ /// Runs one tick: chains batches until the backlog is empty, the peer holds the lock,
+ /// or is reached.
+ ///
+ /// Cancellation token.
+ /// The number of rows chained in this tick.
+ internal async Task TickAsync(CancellationToken ct)
+ {
+ long total = 0;
+ try
+ {
+ var options = _options.Value;
+ await using var scope = _scopeFactory.CreateAsyncScope();
+ var chain = scope.ServiceProvider.GetRequiredService();
+ for (var i = 0; i < options.MaxBatchesPerTick && !ct.IsCancellationRequested; i++)
+ {
+ var result = await chain.ChainPendingAsync(options.BatchSize, ct).ConfigureAwait(false);
+ total += result.RowsChained;
+ if (result.LockBusy || result.RowsChained == 0)
+ {
+ break;
+ }
+ }
+
+ if (total > 0)
+ {
+ _logger.LogDebug("AuditLog hash chain linked {Rows} rows this tick.", total);
+ }
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ // Shutdown.
+ }
+ catch (Exception ex)
+ {
+ // Contained: a transient SQL failure must not end the loop; the next tick
+ // resumes from wherever the last committed pass left the chain.
+ _logger.LogError(ex, "AuditLog hash-chain tick failed after linking {Rows} rows.", total);
+ }
+
+ return total;
+ }
+
+ ///
+ public Task StopAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ _cts?.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ // Dispose already cancelled the loop.
+ }
+
+ return _loop ?? Task.CompletedTask;
+ }
+
+ ///
+ public void Dispose()
+ {
+ try
+ {
+ _cts?.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ // Idempotent.
+ }
+
+ _cts?.Dispose();
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs
index 39f7b107..2219173c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs
@@ -52,6 +52,9 @@ public static class ServiceCollectionExtensions
/// Configuration section bound to .
public const string PartitionMaintenanceSectionName = "AuditLog:PartitionMaintenance";
+ /// Configuration section bound to (ScadaBridge#41).
+ public const string HashChainSectionName = "AuditLog:HashChain";
+
/// Configuration section bound to .
public const string PurgeSectionName = "AuditLog:Purge";
@@ -395,6 +398,13 @@ public static class ServiceCollectionExtensions
AuditLogPartitionMaintenanceOptionsValidator>(config, PartitionMaintenanceSectionName);
services.AddHostedService();
+ // Hash-chain tamper evidence (ScadaBridge#41). Default OFF; the hosted service is
+ // always registered and returns from StartAsync without a loop when disabled.
+ // IAuditLogHashChain is registered by AddConfigurationDatabase (central only).
+ services.AddValidatedOptions(
+ config, HashChainSectionName);
+ services.AddHostedService();
+
// I1 (review): bind the two central-singleton options HERE rather than in
// AddAuditLogCentralReconciliationClient. AkkaHostedService.RegisterCentralActors
// resolves IOptions /
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogHashChain.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogHashChain.cs
new file mode 100644
index 00000000..077c738f
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogHashChain.cs
@@ -0,0 +1,46 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
+
+///
+/// Hash-chain tamper evidence for the central AuditLog table (audit roadmap T1,
+/// alog.md §11.4, ScadaBridge#41).
+///
+///
+///
+/// One chain per partition = UTC calendar month of OccurredAtUtc (the same
+/// month the ps_AuditLog_Month scheme partitions on). Each chained row carries a
+/// ChainSeq (1, 2, 3 … within its month) and
+/// RowHash = SHA-256(prev.RowHash || canonical(row)), where prev is the row
+/// at ChainSeq - 1 (32 zero bytes for the first). The canonical serialisation
+/// covers every writable column plus the month and sequence number.
+///
+///
+/// Rows are linked by a chaining job, not by the hot-path writers: two central
+/// nodes and the site-ingest path all INSERT concurrently, so a write-time chain would
+/// need a global serialisation point on every audit write. The job serialises itself
+/// with an exclusive SQL Server application lock, so even a dual-active central pair
+/// produces one gap-free chain per month.
+///
+///
+public interface IAuditLogHashChain
+{
+ ///
+ /// Links up to unchained rows (oldest
+ /// OccurredAtUtc first) onto the end of their month's chain, in one transaction.
+ /// Returns immediately with set when another
+ /// chainer holds the chain lock.
+ ///
+ /// Maximum rows to chain in this pass.
+ /// Cancellation token.
+ /// How many rows were chained, and whether the lock was busy.
+ Task ChainPendingAsync(int batchSize, CancellationToken ct = default);
+
+ ///
+ /// Verifies one month's chain end to end and reports the first break.
+ ///
+ /// The first instant of the month (UTC, day 1, midnight).
+ /// Cancellation token.
+ /// The verification result.
+ Task VerifyMonthAsync(DateTime monthStartUtc, CancellationToken ct = default);
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs
index 7812ffe6..8cfe9ea6 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs
@@ -155,6 +155,11 @@ public interface IAuditLogRepository
/// OccurredAtUtc is strictly older than , in
/// bounded batches of rows, looping until no further
/// rows match. Returns the total number of rows deleted across all batches.
+ ///
+ /// Hash chain (ScadaBridge#41): every CHAINED row the DELETE removes leaves a
+ /// dbo.AuditLogChainTombstone (month, ChainSeq, RowHash) written in the same
+ /// transaction, under the exclusive chain lock, so the month's chain still verifies.
+ ///
///
///
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditChainVerification.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditChainVerification.cs
new file mode 100644
index 00000000..1f309a28
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditChainVerification.cs
@@ -0,0 +1,100 @@
+using System.Text.Json.Serialization;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+
+///
+/// Overall outcome of verifying one month's AuditLog hash chain (audit roadmap T1,
+/// ScadaBridge#41).
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum AuditChainStatus
+{
+ /// Every chained row, tombstone and the chain head agree.
+ Intact,
+
+ /// The chain is broken; names where.
+ Broken,
+
+ /// The month has no chained rows and no chain head — nothing to verify yet.
+ NotChained,
+}
+
+/// How the first chain break manifested.
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum AuditChainBreakKind
+{
+ ///
+ /// A row's recomputed hash differs from its stored RowHash: the row's content
+ /// was edited, or rows were reordered (their ChainSeq values swapped).
+ ///
+ HashMismatch,
+
+ ///
+ /// A sequence number is absent and no purge tombstone covers it: a chained row was
+ /// deleted outside the sanctioned purge path, or its ChainSeq was cleared.
+ ///
+ MissingRow,
+
+ /// Two rows carry the same ChainSeq.
+ DuplicateSequence,
+
+ /// A chained row carries a ChainSeq but no RowHash (or vice versa).
+ IncompleteLink,
+
+ ///
+ /// The chain ends before (or after) the sequence/hash the chain head records: rows were
+ /// deleted from the tail, or the head itself was altered.
+ ///
+ HeadMismatch,
+}
+
+///
+/// The first point at which a month's chain fails verification.
+///
+/// The chain partition — the UTC calendar month, formatted YYYY-MM.
+/// The sequence number at which the break was detected.
+///
+/// The EventId of the row at (or, for ,
+/// immediately after) the break; null when no row is present there (tail truncation).
+///
+/// How the break manifested.
+/// Human-readable explanation.
+public sealed record AuditChainBreak(
+ string Partition,
+ long ChainSeq,
+ Guid? EventId,
+ AuditChainBreakKind Kind,
+ string Detail);
+
+///
+/// Result of audit verify-chain --month YYYY-MM: whether the month's AuditLog hash
+/// chain is intact, and if not, the first break.
+///
+/// The chain partition verified (YYYY-MM).
+/// Overall outcome.
+/// Chained rows whose hash was recomputed and matched before the first break (or in total when intact).
+/// Sequence numbers bridged by a sanctioned purge tombstone (per-channel retention).
+///
+/// The checkpoint anchor left by a monthly partition purge: the last sequence number the
+/// purge removed. Verification starts immediately after it. Null when the month was never purged.
+///
+/// The last sequence number the chain head records, or null when the month has no head.
+///
+/// Rows in the month the chaining job has not reached yet (written since the last tick, or
+/// written while chaining was disabled). They are not covered by the chain.
+///
+/// The first break, or null when is not .
+public sealed record AuditChainVerificationResult(
+ string Partition,
+ AuditChainStatus Status,
+ long RowsVerified,
+ long TombstonesApplied,
+ long? PurgedThroughSeq,
+ long? HeadSeq,
+ long UnchainedRows,
+ AuditChainBreak? FirstBreak);
+
+/// Outcome of one chaining pass over unchained AuditLog rows.
+/// Rows linked into a chain by this pass.
+/// True when another chainer (the peer central node) held the chain lock, so this pass did nothing.
+public sealed record AuditChainBatchResult(int RowsChained, bool LockBusy);
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs
index dc1bc4fa..f58fee97 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs
@@ -169,6 +169,17 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration e.ChainSeq);
+
+ builder.Property(e => e.RowHash)
+ .HasMaxLength(32)
+ .IsFixedLength();
+
// ── Keys + indexes ───────────────────────────────────────────────────
// Composite PK includes OccurredAtUtc — required by the monthly partition scheme
@@ -241,5 +252,46 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration new { e.Actor, e.OccurredAtUtc })
.IsDescending(false, true)
.HasDatabaseName("IX_AuditLog_Actor_Occurred");
+
+ // Hash-chain indexes (AddAuditLogHashChain migration, raw SQL ON ps_AuditLog_Month
+ // so they stay partition-aligned). Unchained: the chaining job's work queue —
+ // filtered to ChainSeq IS NULL, so it shrinks to the rows written since the last
+ // tick. ChainSeq: verification streams one month's chain in sequence order.
+ builder.HasIndex(e => new { e.OccurredAtUtc, e.EventId })
+ .HasFilter("[ChainSeq] IS NULL")
+ .HasDatabaseName("IX_AuditLog_Unchained");
+
+ builder.HasIndex(e => e.ChainSeq)
+ .HasFilter("[ChainSeq] IS NOT NULL")
+ .HasDatabaseName("IX_AuditLog_ChainSeq");
+ }
+}
+
+/// Maps to dbo.AuditLogChainHead (ScadaBridge#41).
+public class AuditLogChainHeadEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ /// Applies the EF Core type configuration for .
+ /// The entity type builder to configure.
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("AuditLogChainHead");
+ builder.HasKey(e => e.ChainMonth);
+ builder.Property(e => e.ChainMonth).HasColumnType("date");
+ builder.Property(e => e.LastHash).HasMaxLength(32).IsFixedLength().IsRequired();
+ builder.Property(e => e.PurgedThroughHash).HasMaxLength(32).IsFixedLength();
+ }
+}
+
+/// Maps to dbo.AuditLogChainTombstone (ScadaBridge#41).
+public class AuditLogChainTombstoneEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ /// Applies the EF Core type configuration for .
+ /// The entity type builder to configure.
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("AuditLogChainTombstone");
+ builder.HasKey(e => new { e.ChainMonth, e.ChainSeq });
+ builder.Property(e => e.ChainMonth).HasColumnType("date");
+ builder.Property(e => e.RowHash).HasMaxLength(32).IsFixedLength().IsRequired();
}
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogChainEntities.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogChainEntities.cs
new file mode 100644
index 00000000..a2584408
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogChainEntities.cs
@@ -0,0 +1,61 @@
+namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
+
+///
+/// One row per chain partition (UTC calendar month) of the AuditLog hash chain
+/// (ScadaBridge#41): where the chain currently ends, plus the checkpoint anchor a
+/// monthly partition purge leaves behind.
+///
+///
+/// The head is what lets verification detect tail truncation — deleting the last
+/// rows of a month leaves a chain that is internally consistent but shorter than the
+/// head says. It is written by the chaining job (/)
+/// and by the partition-switch purge (/).
+///
+public sealed class AuditLogChainHead
+{
+ /// First day of the chain's month (UTC). Primary key.
+ public DateTime ChainMonth { get; set; }
+
+ /// Sequence number of the last row linked into the chain.
+ public long LastSeq { get; set; }
+
+ /// RowHash of the last row linked into the chain (32 bytes).
+ public byte[] LastHash { get; set; } = [];
+
+ ///
+ /// Checkpoint anchor: the last sequence number removed by a monthly partition-switch
+ /// purge. Verification resumes at PurgedThroughSeq + 1 with
+ /// as the previous link. Null when never purged.
+ ///
+ public long? PurgedThroughSeq { get; set; }
+
+ /// RowHash at ; null when never purged.
+ public byte[]? PurgedThroughHash { get; set; }
+
+ /// UTC time the head last moved.
+ public DateTime UpdatedAtUtc { get; set; }
+}
+
+///
+/// The link a sanctioned per-channel retention DELETE removed from the middle of a chain
+/// (ScadaBridge#41). Written in the SAME statement batch as the DELETE (via
+/// OUTPUT … INTO) so the purge can never remove a chained row without leaving its
+/// hash behind; verification bridges the gap with it.
+///
+public sealed class AuditLogChainTombstone
+{
+ /// First day of the chain's month (UTC). Part of the primary key.
+ public DateTime ChainMonth { get; set; }
+
+ /// Sequence number of the purged row. Part of the primary key.
+ public long ChainSeq { get; set; }
+
+ /// RowHash of the purged row (32 bytes).
+ public byte[] RowHash { get; set; } = [];
+
+ /// The purged row's EventId, kept for the operator's benefit.
+ public Guid EventId { get; set; }
+
+ /// UTC time of the purge.
+ public DateTime PurgedAtUtc { get; set; }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogRow.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogRow.cs
index 200d6404..83a9ef2b 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogRow.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogRow.cs
@@ -138,4 +138,19 @@ public sealed record AuditLogRow
: null;
}
private readonly DateTime? _ingestedAtUtc;
+
+ // ── Hash-chain link (audit roadmap T1, ScadaBridge#41) ────────────────────
+
+ ///
+ /// Position of this row in its month's hash chain (1-based, gap-free per UTC calendar
+ /// month of ); null until the chaining job reaches the row.
+ /// Written ONLY by the chaining job — never by the ingest/writer path.
+ ///
+ public long? ChainSeq { get; init; }
+
+ ///
+ /// SHA-256(prev.RowHash || canonical(row)) — 32 bytes; null until chained. See
+ /// AuditChainHasher for the canonical serialisation.
+ ///
+ public byte[]? RowHash { get; init; }
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditChainHasher.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditChainHasher.cs
new file mode 100644
index 00000000..65b18efc
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditChainHasher.cs
@@ -0,0 +1,155 @@
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
+
+///
+/// The stored column values of one dbo.AuditLog row as the hash chain sees them
+/// (ScadaBridge#41). Strings are the raw column values exactly as SQL Server returns them
+/// (e.g. / are the stored enum NAMES), so
+/// the hash never depends on how a later build happens to map them to CLR enums.
+///
+/// The row's EventId.
+/// The row's OccurredAtUtc (datetime2(7) — full tick precision).
+/// Actor column.
+/// Action column.
+/// Outcome column.
+/// Category (channel) column.
+/// Target column.
+/// SourceNode column.
+/// CorrelationId column.
+/// DetailsJson column.
+/// Stored ChainSeq, or null when unchained.
+/// Stored RowHash, or null when unchained.
+public sealed record AuditChainRow(
+ Guid EventId,
+ DateTime OccurredAtUtc,
+ string? Actor,
+ string Action,
+ string Outcome,
+ string Category,
+ string? Target,
+ string? SourceNode,
+ Guid? CorrelationId,
+ string? DetailsJson,
+ long? ChainSeq = null,
+ byte[]? RowHash = null);
+
+///
+/// Canonical serialisation + SHA-256 link computation for the AuditLog hash chain
+/// (alog.md §11.4, ScadaBridge#41): RowHash = SHA-256(prev.RowHash || canonical(row)).
+///
+///
+///
+/// Canonical form (v1), all integers big-endian: the ASCII domain tag
+/// "ScadaBridge.AuditLog.Chain.v1"; the chain partition (YYYY-MM); the
+/// 64-bit ChainSeq; then the ten writable columns in table order — EventId (as its
+/// lowercase D-format string), OccurredAtUtc (64-bit ticks), Actor, Action, Outcome,
+/// Category, Target, SourceNode, CorrelationId (D-format string), DetailsJson.
+/// Every string-valued field is encoded as one presence byte (0 = SQL NULL,
+/// 1 = present) followed, when present, by a 32-bit byte length and its UTF-8 bytes —
+/// so NULL and the empty string hash differently, and no field boundary is ambiguous.
+///
+///
+/// The partition and sequence number are inside the hashed bytes so a row moved to a
+/// different position, or into another month's chain, fails verification on its own,
+/// independently of the previous-hash link. The computed columns (Kind, Status, …) are
+/// derived from DetailsJson, so hashing DetailsJson covers them.
+///
+///
+/// Changing anything here invalidates every chain already written. A new form needs a new
+/// domain tag and a migration story; never edit v1 in place.
+///
+///
+public static class AuditChainHasher
+{
+ /// Length of a link hash in bytes.
+ public const int HashLength = 32;
+
+ private static readonly byte[] DomainTag = Encoding.ASCII.GetBytes("ScadaBridge.AuditLog.Chain.v1");
+
+ /// The previous-hash value the first row of every chain links to (32 zero bytes).
+ public static ReadOnlySpan GenesisHash => new byte[HashLength];
+
+ /// Returns the first instant (UTC) of the month containing .
+ /// A UTC timestamp.
+ /// Day 1, 00:00:00 UTC of that month.
+ public static DateTime MonthOf(DateTime occurredAtUtc)
+ => new(occurredAtUtc.Year, occurredAtUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ /// Formats a chain partition as YYYY-MM.
+ /// The month's first instant.
+ /// The partition label.
+ public static string PartitionLabel(DateTime monthStartUtc)
+ => monthStartUtc.ToString("yyyy-MM", CultureInfo.InvariantCulture);
+
+ /// Computes the link hash for at .
+ /// The RowHash of the row at chainSeq - 1, or .
+ /// The chain partition the row belongs to.
+ /// The row's position in the chain.
+ /// The row's stored column values.
+ /// The 32-byte SHA-256 link hash.
+ public static byte[] ComputeRowHash(ReadOnlySpan previousHash, DateTime monthStartUtc, long chainSeq, AuditChainRow row)
+ {
+ ArgumentNullException.ThrowIfNull(row);
+ if (previousHash.Length != HashLength)
+ {
+ throw new ArgumentException($"Previous hash must be {HashLength} bytes.", nameof(previousHash));
+ }
+
+ using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
+ hash.AppendData(previousHash);
+ hash.AppendData(Canonicalize(monthStartUtc, chainSeq, row));
+ return hash.GetHashAndReset();
+ }
+
+ /// Builds the canonical v1 byte form of at its chain position.
+ /// The chain partition.
+ /// The row's position in the chain.
+ /// The row's stored column values.
+ /// The canonical bytes (excluding the previous hash).
+ public static byte[] Canonicalize(DateTime monthStartUtc, long chainSeq, AuditChainRow row)
+ {
+ ArgumentNullException.ThrowIfNull(row);
+ using var buffer = new MemoryStream(256 + (row.DetailsJson?.Length ?? 0) * 3);
+ buffer.Write(DomainTag);
+ WriteString(buffer, PartitionLabel(monthStartUtc));
+ WriteInt64(buffer, chainSeq);
+ WriteString(buffer, row.EventId.ToString("D"));
+ WriteInt64(buffer, row.OccurredAtUtc.Ticks);
+ WriteString(buffer, row.Actor);
+ WriteString(buffer, row.Action);
+ WriteString(buffer, row.Outcome);
+ WriteString(buffer, row.Category);
+ WriteString(buffer, row.Target);
+ WriteString(buffer, row.SourceNode);
+ WriteString(buffer, row.CorrelationId?.ToString("D"));
+ WriteString(buffer, row.DetailsJson);
+ return buffer.ToArray();
+ }
+
+ private static void WriteInt64(Stream stream, long value)
+ {
+ Span bytes = stackalloc byte[8];
+ BinaryPrimitives.WriteInt64BigEndian(bytes, value);
+ stream.Write(bytes);
+ }
+
+ private static void WriteString(Stream stream, string? value)
+ {
+ if (value is null)
+ {
+ stream.WriteByte(0);
+ return;
+ }
+
+ stream.WriteByte(1);
+ var bytes = Encoding.UTF8.GetBytes(value);
+ Span length = stackalloc byte[4];
+ BinaryPrimitives.WriteInt32BigEndian(length, bytes.Length);
+ stream.Write(length);
+ stream.Write(bytes);
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditChainVerifier.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditChainVerifier.cs
new file mode 100644
index 00000000..2551cecb
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditChainVerifier.cs
@@ -0,0 +1,214 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+
+namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
+
+/// A month's chain head as verification sees it (ScadaBridge#41).
+/// Last sequence number linked.
+/// RowHash at .
+/// Checkpoint anchor left by a partition-switch purge, or null.
+/// RowHash at , or null.
+public sealed record AuditChainHeadState(
+ long LastSeq,
+ byte[] LastHash,
+ long? PurgedThroughSeq,
+ byte[]? PurgedThroughHash);
+
+///
+/// Streaming verifier for one month's AuditLog hash chain (ScadaBridge#41). Feed it the
+/// month's chained rows in ChainSeq order via , then call
+/// . Storage-agnostic so the detection rules are unit-testable
+/// without SQL Server.
+///
+///
+///
+/// Verification starts at the checkpoint anchor (PurgedThroughSeq + 1, previous
+/// link PurgedThroughHash) when a partition purge left one, otherwise at sequence 1
+/// with the genesis hash. Each row's hash is recomputed from the previous link and its own
+/// stored columns; a sequence number with no row is bridged only by a purge tombstone. At
+/// the end the chain must land exactly on the head's (LastSeq, LastHash), which is
+/// what catches deletion of the newest rows.
+///
+///
+/// Detection, by tampering act: an edited row →
+/// at that row; a deleted row → at its
+/// sequence number (or if it was the tail);
+/// reordered rows (ChainSeq values swapped) →
+/// at the first moved row, because the sequence number is inside the hashed bytes.
+///
+///
+public sealed class AuditChainVerifier
+{
+ private readonly DateTime _monthStartUtc;
+ private readonly string _partition;
+ private readonly AuditChainHeadState? _head;
+ private readonly IReadOnlyDictionary _tombstones;
+ private readonly long _startAfter;
+ private byte[] _previous;
+ private long _expected;
+ private long _verified;
+ private long _tombstonesApplied;
+ private AuditChainBreak? _break;
+
+ /// Initializes a verifier for one month.
+ /// The chain partition (first instant of the month, UTC).
+ /// The month's chain head, or null when none exists.
+ /// Purge tombstones for the month, keyed by ChainSeq.
+ public AuditChainVerifier(
+ DateTime monthStartUtc,
+ AuditChainHeadState? head,
+ IReadOnlyDictionary tombstones)
+ {
+ _monthStartUtc = monthStartUtc;
+ _partition = AuditChainHasher.PartitionLabel(monthStartUtc);
+ _head = head;
+ _tombstones = tombstones ?? throw new ArgumentNullException(nameof(tombstones));
+
+ if (head?.PurgedThroughSeq is { } purgedThrough && head.PurgedThroughHash is { } anchor)
+ {
+ _startAfter = purgedThrough;
+ _previous = anchor;
+ }
+ else
+ {
+ _startAfter = 0;
+ _previous = AuditChainHasher.GenesisHash.ToArray();
+ }
+
+ _expected = _startAfter + 1;
+ }
+
+ /// The chain partition label (YYYY-MM).
+ public string Partition => _partition;
+
+ /// The checkpoint anchor this verification resumed after, or null.
+ public long? PurgedThroughSeq => _head?.PurgedThroughSeq;
+
+ /// True once a break has been found; further rows are ignored.
+ public bool IsBroken => _break is not null;
+
+ /// Feeds the next chained row (ascending ChainSeq).
+ /// The row; its must be non-null.
+ /// False once a break has been found, so the caller can stop reading.
+ public bool Accept(AuditChainRow row)
+ {
+ ArgumentNullException.ThrowIfNull(row);
+ if (_break is not null)
+ {
+ return false;
+ }
+
+ var seq = row.ChainSeq ?? throw new ArgumentException("Only chained rows can be verified.", nameof(row));
+
+ // Rows at or below a partition-purge anchor cannot exist (the switch removed the
+ // whole month's partition); ignore rather than mis-report them.
+ if (seq <= _startAfter)
+ {
+ return true;
+ }
+
+ if (seq < _expected)
+ {
+ return Fail(seq, row.EventId, AuditChainBreakKind.DuplicateSequence,
+ $"ChainSeq {seq} appears more than once.");
+ }
+
+ while (_expected < seq)
+ {
+ if (!_tombstones.TryGetValue(_expected, out var tombstone))
+ {
+ return Fail(_expected, row.EventId, AuditChainBreakKind.MissingRow,
+ $"ChainSeq {_expected} is missing and no purge tombstone covers it; "
+ + $"the next chained row is ChainSeq {seq} (EventId {row.EventId}).");
+ }
+
+ _previous = tombstone;
+ _tombstonesApplied++;
+ _expected++;
+ }
+
+ if (row.RowHash is not { Length: AuditChainHasher.HashLength } stored)
+ {
+ return Fail(seq, row.EventId, AuditChainBreakKind.IncompleteLink,
+ $"Row at ChainSeq {seq} has a sequence number but no 32-byte RowHash.");
+ }
+
+ var computed = AuditChainHasher.ComputeRowHash(_previous, _monthStartUtc, seq, row);
+ if (!computed.AsSpan().SequenceEqual(stored))
+ {
+ return Fail(seq, row.EventId, AuditChainBreakKind.HashMismatch,
+ $"Recomputed hash of the row at ChainSeq {seq} does not match its stored RowHash: "
+ + "the row was edited, moved to a different position, or an earlier link was altered.");
+ }
+
+ _previous = stored;
+ _expected++;
+ _verified++;
+ return true;
+ }
+
+ /// Finishes verification against the chain head.
+ /// Rows in the month not yet chained (reported, never a break).
+ /// The verification result.
+ public AuditChainVerificationResult Complete(long unchainedRows)
+ {
+ if (_break is null)
+ {
+ // A purge tombstone may cover the newest links.
+ while (_tombstones.TryGetValue(_expected, out var tombstone))
+ {
+ _previous = tombstone;
+ _tombstonesApplied++;
+ _expected++;
+ }
+
+ var lastSeq = _expected - 1;
+ if (_head is null)
+ {
+ if (lastSeq > _startAfter)
+ {
+ Fail(lastSeq, null, AuditChainBreakKind.HeadMismatch,
+ $"Chained rows exist through ChainSeq {lastSeq} but the month has no chain head.");
+ }
+ else
+ {
+ return Result(AuditChainStatus.NotChained, unchainedRows);
+ }
+ }
+ else if (lastSeq < _head.LastSeq)
+ {
+ Fail(lastSeq + 1, null, AuditChainBreakKind.HeadMismatch,
+ $"The chain ends at ChainSeq {lastSeq} but the head records {_head.LastSeq}: "
+ + $"ChainSeq {lastSeq + 1}..{_head.LastSeq} are missing (rows deleted from the end).");
+ }
+ else if (lastSeq > _head.LastSeq)
+ {
+ Fail(_head.LastSeq + 1, null, AuditChainBreakKind.HeadMismatch,
+ $"The chain continues to ChainSeq {lastSeq} past the head's {_head.LastSeq}.");
+ }
+ else if (!_previous.AsSpan().SequenceEqual(_head.LastHash))
+ {
+ Fail(lastSeq, null, AuditChainBreakKind.HeadMismatch,
+ $"The hash at ChainSeq {lastSeq} does not match the chain head's recorded hash.");
+ }
+ }
+
+ return Result(_break is null ? AuditChainStatus.Intact : AuditChainStatus.Broken, unchainedRows);
+ }
+
+ private AuditChainVerificationResult Result(AuditChainStatus status, long unchainedRows)
+ => new(
+ _partition,
+ status,
+ _verified,
+ _tombstonesApplied,
+ _head?.PurgedThroughSeq,
+ _head?.LastSeq,
+ unchainedRows,
+ _break);
+
+ private bool Fail(long seq, Guid? eventId, AuditChainBreakKind kind, string detail)
+ {
+ _break ??= new AuditChainBreak(_partition, seq, eventId, kind, detail);
+ return false;
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditLogHashChain.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditLogHashChain.cs
new file mode 100644
index 00000000..701e3425
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditLogHashChain.cs
@@ -0,0 +1,403 @@
+using System.Data;
+using System.Data.Common;
+using System.Text;
+using Microsoft.Data.SqlClient;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+
+namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
+
+///
+/// The SQL Server application lock that serialises every writer of AuditLog hash-chain
+/// state (ScadaBridge#41): the chaining job and both purge paths take it EXCLUSIVE;
+/// verification takes it SHARED so it reads a chain no writer is half-way through.
+///
+public static class AuditChainLock
+{
+ /// The sp_getapplock resource name.
+ public const string Resource = "ScadaBridge.AuditLogHashChain";
+}
+
+///
+/// SQL Server implementation of (ScadaBridge#41,
+/// alog.md §11.4). See the interface for the chain model.
+///
+///
+///
+/// Concurrency. Two central nodes and the site-ingest path INSERT into
+/// dbo.AuditLog concurrently; none of them touches the chain. The chaining pass runs
+/// in one transaction that first takes the exclusive with a
+/// zero timeout (the peer node's pass simply skips its tick), then reads the head row with
+/// UPDLOCK, HOLDLOCK and advances it with an optimistic LastSeq check. Every
+/// link UPDATE is guarded by ChainSeq IS NULL and its row count is checked, so a row
+/// purged or chained underneath the pass rolls the whole pass back instead of leaving a gap.
+/// A row INSERTed while the pass runs is invisible to it (READ COMMITTED) and is chained on
+/// a later pass with a later sequence number — chain order is chaining order, not
+/// OccurredAtUtc order, which is why late-arriving site rows need no special case.
+///
+///
+/// Append-only. The only AuditLog columns this class writes are ChainSeq and
+/// RowHash, and only while they are NULL. In production that UPDATE runs under the
+/// scadabridge_audit_chainer role, which holds a COLUMN-level UPDATE grant on just
+/// those two columns; the ingest writer role keeps its table-wide DENY UPDATE.
+///
+///
+public sealed class AuditLogHashChain : IAuditLogHashChain
+{
+ // 4 parameters per row; 200 rows = 800 parameters, far under SQL Server's 2,100 cap.
+ private const int UpdateChunkRows = 200;
+
+ private const string RowColumns =
+ "EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson";
+
+ private readonly ScadaBridgeDbContext _context;
+ private readonly ILogger _logger;
+
+ /// Initializes a new instance of the class.
+ /// The database context whose connection is used.
+ /// Optional logger.
+ public AuditLogHashChain(ScadaBridgeDbContext context, ILogger? logger = null)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ ///
+ /// Command timeout for verification statements, in seconds. A month can hold millions
+ /// of rows; the management Ask (long-running, 5 min) is the real deadline.
+ ///
+ public int VerifyCommandTimeoutSeconds { get; init; } = 300;
+
+ /// How long verification waits for an in-flight chaining/purge pass to release the lock.
+ public TimeSpan VerifyLockTimeout { get; init; } = TimeSpan.FromSeconds(60);
+
+ ///
+ public async Task ChainPendingAsync(int batchSize, CancellationToken ct = default)
+ {
+ if (batchSize <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be > 0.");
+ }
+
+ var conn = _context.Database.GetDbConnection();
+ var openedHere = await OpenAsync(conn, ct).ConfigureAwait(false);
+ try
+ {
+ await using var tx = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct).ConfigureAwait(false);
+
+ if (!await TryAcquireLockAsync(conn, tx, "Exclusive", TimeSpan.Zero, ct).ConfigureAwait(false))
+ {
+ await tx.RollbackAsync(ct).ConfigureAwait(false);
+ return new AuditChainBatchResult(0, LockBusy: true);
+ }
+
+ // Oldest month that still has unchained rows. One month per pass keeps the
+ // candidate read partition-eliminated and index-ordered (no cross-partition sort).
+ DateTime? oldest;
+ await using (var cmd = Command(conn, tx,
+ "SELECT TOP (1) OccurredAtUtc FROM dbo.AuditLog WHERE ChainSeq IS NULL ORDER BY OccurredAtUtc;"))
+ {
+ var scalar = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
+ oldest = scalar is DateTime dt ? DateTime.SpecifyKind(dt, DateTimeKind.Utc) : null;
+ }
+
+ if (oldest is null)
+ {
+ await tx.CommitAsync(ct).ConfigureAwait(false);
+ return new AuditChainBatchResult(0, LockBusy: false);
+ }
+
+ var month = AuditChainHasher.MonthOf(oldest.Value);
+ var candidates = new List(Math.Min(batchSize, 4096));
+ await using (var cmd = Command(conn, tx,
+ $"SELECT TOP (@batch) {RowColumns} FROM dbo.AuditLog " +
+ "WHERE ChainSeq IS NULL AND OccurredAtUtc >= @monthStart AND OccurredAtUtc < @monthEnd " +
+ "ORDER BY OccurredAtUtc, EventId;"))
+ {
+ AddInt(cmd, "@batch", batchSize);
+ AddMonthRange(cmd, month);
+ await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ candidates.Add(ReadRow(reader, withLink: false));
+ }
+ }
+
+ // Current chain end for the month, locked until commit.
+ long? headSeq = null;
+ byte[] previous = AuditChainHasher.GenesisHash.ToArray();
+ await using (var cmd = Command(conn, tx,
+ "SELECT LastSeq, LastHash FROM dbo.AuditLogChainHead WITH (UPDLOCK, HOLDLOCK) WHERE ChainMonth = @month;"))
+ {
+ AddDate(cmd, "@month", month);
+ await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
+ if (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ headSeq = reader.GetInt64(0);
+ previous = (byte[])reader.GetValue(1);
+ }
+ }
+
+ var seq = headSeq ?? 0;
+ var links = new List<(AuditChainRow Row, long Seq, byte[] Hash)>(candidates.Count);
+ foreach (var row in candidates)
+ {
+ seq++;
+ var hash = AuditChainHasher.ComputeRowHash(previous, month, seq, row);
+ links.Add((row, seq, hash));
+ previous = hash;
+ }
+
+ for (var offset = 0; offset < links.Count; offset += UpdateChunkRows)
+ {
+ var chunk = links.Skip(offset).Take(UpdateChunkRows).ToList();
+ var updated = await WriteLinksAsync(conn, tx, chunk, ct).ConfigureAwait(false);
+ if (updated != chunk.Count)
+ {
+ // A candidate was purged (or chained by a pass that bypassed the lock)
+ // between the read and the write. Linking the rest would leave a gap.
+ _logger.LogWarning(
+ "AuditLog hash chain: {Updated} of {Expected} links written for {Partition}; rolling the pass back to retry.",
+ updated, chunk.Count, AuditChainHasher.PartitionLabel(month));
+ await tx.RollbackAsync(ct).ConfigureAwait(false);
+ return new AuditChainBatchResult(0, LockBusy: false);
+ }
+ }
+
+ if (links.Count > 0 && !await AdvanceHeadAsync(conn, tx, month, headSeq, seq, previous, ct).ConfigureAwait(false))
+ {
+ _logger.LogWarning(
+ "AuditLog hash chain: head for {Partition} moved underneath the pass; rolling back to retry.",
+ AuditChainHasher.PartitionLabel(month));
+ await tx.RollbackAsync(ct).ConfigureAwait(false);
+ return new AuditChainBatchResult(0, LockBusy: false);
+ }
+
+ await tx.CommitAsync(ct).ConfigureAwait(false);
+ return new AuditChainBatchResult(links.Count, LockBusy: false);
+ }
+ finally
+ {
+ if (openedHere)
+ {
+ await conn.CloseAsync().ConfigureAwait(false);
+ }
+ }
+ }
+
+ ///
+ public async Task VerifyMonthAsync(DateTime monthStartUtc, CancellationToken ct = default)
+ {
+ var month = AuditChainHasher.MonthOf(DateTime.SpecifyKind(monthStartUtc, DateTimeKind.Utc));
+
+ var conn = _context.Database.GetDbConnection();
+ var openedHere = await OpenAsync(conn, ct).ConfigureAwait(false);
+ try
+ {
+ await using var tx = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct).ConfigureAwait(false);
+ if (!await TryAcquireLockAsync(conn, tx, "Shared", VerifyLockTimeout, ct).ConfigureAwait(false))
+ {
+ await tx.RollbackAsync(ct).ConfigureAwait(false);
+ throw new TimeoutException(
+ $"The AuditLog hash chain was busy (chaining or purge in progress) for {VerifyLockTimeout.TotalSeconds:0}s; retry.");
+ }
+
+ AuditChainHeadState? head = null;
+ await using (var cmd = Command(conn, tx,
+ "SELECT LastSeq, LastHash, PurgedThroughSeq, PurgedThroughHash FROM dbo.AuditLogChainHead WHERE ChainMonth = @month;"))
+ {
+ AddDate(cmd, "@month", month);
+ await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
+ if (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ head = new AuditChainHeadState(
+ reader.GetInt64(0),
+ (byte[])reader.GetValue(1),
+ reader.IsDBNull(2) ? null : reader.GetInt64(2),
+ reader.IsDBNull(3) ? null : (byte[])reader.GetValue(3));
+ }
+ }
+
+ var tombstones = new Dictionary();
+ await using (var cmd = Command(conn, tx,
+ "SELECT ChainSeq, RowHash FROM dbo.AuditLogChainTombstone WHERE ChainMonth = @month;"))
+ {
+ AddDate(cmd, "@month", month);
+ await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ tombstones[reader.GetInt64(0)] = (byte[])reader.GetValue(1);
+ }
+ }
+
+ long unchained;
+ await using (var cmd = Command(conn, tx,
+ "SELECT COUNT_BIG(*) FROM dbo.AuditLog WHERE ChainSeq IS NULL AND OccurredAtUtc >= @monthStart AND OccurredAtUtc < @monthEnd;"))
+ {
+ cmd.CommandTimeout = VerifyCommandTimeoutSeconds;
+ AddMonthRange(cmd, month);
+ unchained = Convert.ToInt64(await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false));
+ }
+
+ var verifier = new AuditChainVerifier(month, head, tombstones);
+ await using (var cmd = Command(conn, tx,
+ $"SELECT {RowColumns}, ChainSeq, RowHash FROM dbo.AuditLog " +
+ "WHERE ChainSeq IS NOT NULL AND OccurredAtUtc >= @monthStart AND OccurredAtUtc < @monthEnd " +
+ "ORDER BY ChainSeq;"))
+ {
+ cmd.CommandTimeout = VerifyCommandTimeoutSeconds;
+ AddMonthRange(cmd, month);
+ await using var reader = await cmd.ExecuteReaderAsync(CommandBehavior.SequentialAccess, ct).ConfigureAwait(false);
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ if (!verifier.Accept(ReadRow(reader, withLink: true)))
+ {
+ break;
+ }
+ }
+ }
+
+ await tx.CommitAsync(ct).ConfigureAwait(false);
+ return verifier.Complete(unchained);
+ }
+ finally
+ {
+ if (openedHere)
+ {
+ await conn.CloseAsync().ConfigureAwait(false);
+ }
+ }
+ }
+
+ ///
+ /// Takes the chain application lock inside (released at commit /
+ /// rollback). Returns false when the lock could not be granted within .
+ ///
+ internal static async Task TryAcquireLockAsync(
+ DbConnection conn, DbTransaction tx, string mode, TimeSpan timeout, CancellationToken ct)
+ {
+ await using var cmd = Command(conn, tx,
+ "DECLARE @result int; " +
+ "EXEC @result = sp_getapplock @Resource = @resource, @LockMode = @mode, @LockOwner = 'Transaction', @LockTimeout = @timeoutMs; " +
+ "SELECT @result;");
+ cmd.Parameters.Add(new SqlParameter("@resource", SqlDbType.NVarChar, 255) { Value = AuditChainLock.Resource });
+ cmd.Parameters.Add(new SqlParameter("@mode", SqlDbType.VarChar, 32) { Value = mode });
+ AddInt(cmd, "@timeoutMs", (int)Math.Min(int.MaxValue, timeout.TotalMilliseconds));
+ if (timeout > TimeSpan.Zero)
+ {
+ cmd.CommandTimeout = (int)Math.Ceiling(timeout.TotalSeconds) + 30;
+ }
+
+ var result = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false));
+ return result >= 0;
+ }
+
+ private static async Task WriteLinksAsync(
+ DbConnection conn, DbTransaction tx, IReadOnlyList<(AuditChainRow Row, long Seq, byte[] Hash)> chunk, CancellationToken ct)
+ {
+ var sql = new StringBuilder();
+ // The ONE sanctioned UPDATE against AuditLog outside the maintenance backfill: it
+ // writes only the two chain columns, only while they are still NULL.
+ sql.Append("UPDATE dbo.AuditLog SET ChainSeq = v.ChainSeq, RowHash = v.RowHash\n"); // AUDIT-PURGE-ALLOWED: hash-chain link (ChainSeq/RowHash only, NULL→value), chaining job
+ sql.Append("FROM dbo.AuditLog INNER JOIN (VALUES ");
+ await using var cmd = conn.CreateCommand();
+ cmd.Transaction = tx;
+ for (var i = 0; i < chunk.Count; i++)
+ {
+ if (i > 0)
+ {
+ sql.Append(", ");
+ }
+
+ sql.Append($"(@e{i}, @o{i}, @s{i}, @h{i})");
+ cmd.Parameters.Add(new SqlParameter($"@e{i}", SqlDbType.UniqueIdentifier) { Value = chunk[i].Row.EventId });
+ cmd.Parameters.Add(new SqlParameter($"@o{i}", SqlDbType.DateTime2) { Scale = 7, Value = chunk[i].Row.OccurredAtUtc });
+ cmd.Parameters.Add(new SqlParameter($"@s{i}", SqlDbType.BigInt) { Value = chunk[i].Seq });
+ cmd.Parameters.Add(new SqlParameter($"@h{i}", SqlDbType.Binary, AuditChainHasher.HashLength) { Value = chunk[i].Hash });
+ }
+
+ sql.Append(") AS v (EventId, OccurredAtUtc, ChainSeq, RowHash)\n");
+ sql.Append("ON dbo.AuditLog.EventId = v.EventId AND dbo.AuditLog.OccurredAtUtc = v.OccurredAtUtc\n");
+ sql.Append("WHERE dbo.AuditLog.ChainSeq IS NULL;");
+ cmd.CommandText = sql.ToString();
+ return await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+ }
+
+ private static async Task AdvanceHeadAsync(
+ DbConnection conn, DbTransaction tx, DateTime month, long? oldSeq, long newSeq, byte[] newHash, CancellationToken ct)
+ {
+ await using var cmd = Command(conn, tx, oldSeq is null
+ ? "INSERT INTO dbo.AuditLogChainHead (ChainMonth, LastSeq, LastHash, UpdatedAtUtc) VALUES (@month, @seq, @hash, SYSUTCDATETIME());"
+ : "UPDATE dbo.AuditLogChainHead SET LastSeq = @seq, LastHash = @hash, UpdatedAtUtc = SYSUTCDATETIME() " +
+ "WHERE ChainMonth = @month AND LastSeq = @oldSeq;");
+ AddDate(cmd, "@month", month);
+ cmd.Parameters.Add(new SqlParameter("@seq", SqlDbType.BigInt) { Value = newSeq });
+ cmd.Parameters.Add(new SqlParameter("@hash", SqlDbType.Binary, AuditChainHasher.HashLength) { Value = newHash });
+ if (oldSeq is not null)
+ {
+ cmd.Parameters.Add(new SqlParameter("@oldSeq", SqlDbType.BigInt) { Value = oldSeq.Value });
+ }
+
+ return await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false) == 1;
+ }
+
+ private static AuditChainRow ReadRow(DbDataReader reader, bool withLink)
+ {
+ // Ordinals follow RowColumns (+ ChainSeq, RowHash). Read strictly in order — the
+ // verification reader is opened with SequentialAccess.
+ var eventId = reader.GetGuid(0);
+ var occurred = DateTime.SpecifyKind(reader.GetDateTime(1), DateTimeKind.Utc);
+ var actor = reader.IsDBNull(2) ? null : reader.GetString(2);
+ var action = reader.GetString(3);
+ var outcome = reader.GetString(4);
+ var category = reader.GetString(5);
+ var target = reader.IsDBNull(6) ? null : reader.GetString(6);
+ var sourceNode = reader.IsDBNull(7) ? null : reader.GetString(7);
+ Guid? correlationId = reader.IsDBNull(8) ? null : reader.GetGuid(8);
+ var details = reader.IsDBNull(9) ? null : reader.GetString(9);
+ long? chainSeq = null;
+ byte[]? rowHash = null;
+ if (withLink)
+ {
+ chainSeq = reader.IsDBNull(10) ? null : reader.GetInt64(10);
+ rowHash = reader.IsDBNull(11) ? null : (byte[])reader.GetValue(11);
+ }
+
+ return new AuditChainRow(eventId, occurred, actor, action, outcome, category, target, sourceNode,
+ correlationId, details, chainSeq, rowHash);
+ }
+
+ private static DbCommand Command(DbConnection conn, DbTransaction tx, string sql)
+ {
+ var cmd = conn.CreateCommand();
+ cmd.Transaction = tx;
+ cmd.CommandText = sql;
+ return cmd;
+ }
+
+ private static void AddInt(DbCommand cmd, string name, int value)
+ => cmd.Parameters.Add(new SqlParameter(name, SqlDbType.Int) { Value = value });
+
+ private static void AddDate(DbCommand cmd, string name, DateTime month)
+ => cmd.Parameters.Add(new SqlParameter(name, SqlDbType.Date) { Value = month.Date });
+
+ private static void AddMonthRange(DbCommand cmd, DateTime month)
+ {
+ cmd.Parameters.Add(new SqlParameter("@monthStart", SqlDbType.DateTime2) { Scale = 7, Value = month });
+ cmd.Parameters.Add(new SqlParameter("@monthEnd", SqlDbType.DateTime2) { Scale = 7, Value = month.AddMonths(1) });
+ }
+
+ private static async Task OpenAsync(DbConnection conn, CancellationToken ct)
+ {
+ if (conn.State == ConnectionState.Open)
+ {
+ return false;
+ }
+
+ await conn.OpenAsync(ct).ConfigureAwait(false);
+ return true;
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261109211317_AddAuditLogHashChain.Designer.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261109211317_AddAuditLogHashChain.Designer.cs
new file mode 100644
index 00000000..ef68e4ca
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261109211317_AddAuditLogHashChain.Designer.cs
@@ -0,0 +1,2255 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
+
+#nullable disable
+
+namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
+{
+ [DbContext(typeof(ScadaBridgeDbContext))]
+ [Migration("20261109211317_AddAuditLogHashChain")]
+ partial class AddAuditLogHashChain
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.13")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Xml")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.ToTable("DataProtectionKeys");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditLogEntry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("AfterStateJson")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("BundleImportId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("EntityId")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("EntityName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Timestamp")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("User")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Action");
+
+ b.HasIndex("BundleImportId")
+ .HasDatabaseName("IX_AuditLogEntries_BundleImportId");
+
+ b.HasIndex("EntityId");
+
+ b.HasIndex("EntityName")
+ .HasDatabaseName("IX_AuditLogEntries_EntityName");
+
+ b.HasIndex("EntityType");
+
+ b.HasIndex("Timestamp");
+
+ b.HasIndex("User");
+
+ b.HasIndex("EntityType", "Timestamp")
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_AuditLogEntries_EntityType_Timestamp");
+
+ b.HasIndex("Timestamp", "Id")
+ .IsDescending()
+ .HasDatabaseName("IX_AuditLogEntries_Timestamp_Id");
+
+ b.HasIndex("User", "Timestamp")
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_AuditLogEntries_User_Timestamp");
+
+ b.ToTable("AuditLogEntries");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall", b =>
+ {
+ b.Property("TrackedOperationId")
+ .HasMaxLength(36)
+ .IsUnicode(false)
+ .HasColumnType("varchar(36)");
+
+ b.Property("Channel")
+ .IsRequired()
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .HasColumnType("varchar(32)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("HttpStatus")
+ .HasColumnType("int");
+
+ b.Property("IngestedAtUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("LastError")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("RetryCount")
+ .HasColumnType("int");
+
+ b.Property("SourceNode")
+ .HasMaxLength(64)
+ .IsUnicode(false)
+ .HasColumnType("varchar(64)");
+
+ b.Property("SourceSite")
+ .IsRequired()
+ .HasMaxLength(64)
+ .IsUnicode(false)
+ .HasColumnType("varchar(64)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .HasColumnType("varchar(32)");
+
+ b.Property("Target")
+ .IsRequired()
+ .HasMaxLength(256)
+ .IsUnicode(false)
+ .HasColumnType("varchar(256)");
+
+ b.Property("TerminalAtUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("datetime2");
+
+ b.HasKey("TrackedOperationId");
+
+ b.HasIndex("CreatedAtUtc")
+ .HasDatabaseName("IX_SiteCalls_NonTerminal")
+ .HasFilter("[TerminalAtUtc] IS NULL");
+
+ SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("CreatedAtUtc"), new[] { "SourceSite", "SourceNode", "Status" });
+
+ b.HasIndex("TerminalAtUtc")
+ .HasDatabaseName("IX_SiteCalls_Terminal")
+ .HasFilter("[TerminalAtUtc] IS NOT NULL");
+
+ b.HasIndex("CreatedAtUtc", "TrackedOperationId")
+ .IsDescending()
+ .HasDatabaseName("IX_SiteCalls_Created_TrackedOperationId");
+
+ b.HasIndex("SourceSite", "CreatedAtUtc")
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_SiteCalls_Source_Created");
+
+ b.HasIndex("Status", "UpdatedAtUtc")
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_SiteCalls_Status_Updated");
+
+ b.ToTable("SiteCalls", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ConfigurationJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DeployedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeploymentId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.Property("RevisionHash")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeploymentId");
+
+ b.HasIndex("InstanceId")
+ .IsUnique();
+
+ b.ToTable("DeployedConfigSnapshots");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CompletedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeployedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeployedBy")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("DeploymentId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ErrorMessage")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.Property("RevisionHash")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeployedBy")
+ .HasDatabaseName("IX_DeploymentRecords_DeployedBy");
+
+ b.HasIndex("DeploymentId")
+ .IsUnique();
+
+ b.HasIndex("InstanceId");
+
+ b.HasIndex("RevisionHash")
+ .HasDatabaseName("IX_DeploymentRecords_RevisionHash");
+
+ b.HasIndex("DeployedAt", "Id")
+ .IsDescending()
+ .HasDatabaseName("IX_DeploymentRecords_DeployedAt_Id");
+
+ b.ToTable("DeploymentRecords");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.PendingDeployment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ConfigurationJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeploymentId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ExpiresAtUtc")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.Property("RevisionHash")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeploymentId")
+ .IsUnique();
+
+ b.HasIndex("ExpiresAtUtc");
+
+ b.HasIndex("InstanceId");
+
+ b.ToTable("PendingDeployments", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ArtifactType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("DeployedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeployedBy")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("PerSiteStatus")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeployedAt");
+
+ b.ToTable("SystemArtifactDeploymentRecords");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ConnectionString")
+ .IsRequired()
+ .HasMaxLength(8000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("MaxRetries")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("RetryDelay")
+ .HasColumnType("time");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("DatabaseConnectionDefinitions");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AuthConfiguration")
+ .HasMaxLength(8000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("AuthType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("EndpointUrl")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("MaxRetries")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("RetryDelay")
+ .HasColumnType("time");
+
+ b.Property("TimeoutSeconds")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasDefaultValue(0);
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("ExternalSystemDefinitions");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ExternalSystemDefinitionId")
+ .HasColumnType("int");
+
+ b.Property("HttpMethod")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("nvarchar(10)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ParameterDefinitions")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("ReturnDefinition")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExternalSystemDefinitionId", "Name")
+ .IsUnique();
+
+ b.ToTable("ExternalSystemMethods");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiMethod", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ParameterDefinitions")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("ReturnDefinition")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("Script")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TimeoutSeconds")
+ .HasColumnType("int");
+
+ b.Property("TraceAllCalls")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("ApiMethods");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ParentAreaId")
+ .HasColumnType("int");
+
+ b.Property("SiteId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ParentAreaId");
+
+ b.HasIndex("SiteId", "ParentAreaId", "Name")
+ .IsUnique()
+ .HasFilter("[ParentAreaId] IS NOT NULL");
+
+ b.ToTable("Areas");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AreaId")
+ .HasColumnType("int");
+
+ b.Property("SiteId")
+ .HasColumnType("int");
+
+ b.Property("State")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("TemplateId")
+ .HasColumnType("int");
+
+ b.Property("UniqueName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AreaId");
+
+ b.HasIndex("SiteId");
+
+ b.HasIndex("TemplateId");
+
+ b.HasIndex("UniqueName")
+ .IsUnique();
+
+ b.ToTable("Instances");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AlarmCanonicalName")
+ .IsRequired()
+ .HasMaxLength(400)
+ .HasColumnType("nvarchar(400)");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.Property("IsMesReportableOverride")
+ .HasColumnType("bit");
+
+ b.Property("PriorityLevelOverride")
+ .HasColumnType("int");
+
+ b.Property("TriggerConfigurationOverride")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId", "AlarmCanonicalName")
+ .IsUnique();
+
+ b.ToTable("InstanceAlarmOverrides");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AttributeName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ElementDataType")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.Property("OverrideValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId", "AttributeName")
+ .IsUnique();
+
+ b.ToTable("InstanceAttributeOverrides");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AttributeName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("DataConnectionId")
+ .HasColumnType("int");
+
+ b.Property("DataSourceReferenceOverride")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DataConnectionId");
+
+ b.HasIndex("InstanceId", "AttributeName")
+ .IsUnique();
+
+ b.ToTable("InstanceConnectionBindings");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ConditionFilterOverride")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("ConnectionNameOverride")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.Property("IsMesReportableOverride")
+ .HasColumnType("bit");
+
+ b.Property("SourceCanonicalName")
+ .IsRequired()
+ .HasMaxLength(400)
+ .HasColumnType("nvarchar(400)");
+
+ b.Property("SourceReferenceOverride")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId", "SourceCanonicalName")
+ .IsUnique();
+
+ b.ToTable("InstanceNativeAlarmSourceOverrides");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiRollupHourly", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("HourStartUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("MaxValue")
+ .HasColumnType("float");
+
+ b.Property("Metric")
+ .IsRequired()
+ .HasMaxLength(64)
+ .IsUnicode(false)
+ .HasColumnType("varchar(64)");
+
+ b.Property("MinValue")
+ .HasColumnType("float");
+
+ b.Property("SampleCount")
+ .HasColumnType("int");
+
+ b.Property("Scope")
+ .IsRequired()
+ .HasMaxLength(16)
+ .IsUnicode(false)
+ .HasColumnType("varchar(16)");
+
+ b.Property("ScopeKey")
+ .HasMaxLength(64)
+ .IsUnicode(false)
+ .HasColumnType("varchar(64)");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasMaxLength(64)
+ .IsUnicode(false)
+ .HasColumnType("varchar(64)");
+
+ b.Property("Value")
+ .HasColumnType("float");
+
+ b.HasKey("Id");
+
+ b.HasIndex("HourStartUtc")
+ .HasDatabaseName("IX_KpiRollupHourly_Hour");
+
+ b.HasIndex("Source", "Metric", "Scope", "ScopeKey", "HourStartUtc")
+ .IsUnique()
+ .HasDatabaseName("IX_KpiRollupHourly_Series");
+
+ b.ToTable("KpiRollupHourly", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CapturedAtUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("Metric")
+ .IsRequired()
+ .HasMaxLength(64)
+ .IsUnicode(false)
+ .HasColumnType("varchar(64)");
+
+ b.Property("Scope")
+ .IsRequired()
+ .HasMaxLength(16)
+ .IsUnicode(false)
+ .HasColumnType("varchar(16)");
+
+ b.Property("ScopeKey")
+ .HasMaxLength(64)
+ .IsUnicode(false)
+ .HasColumnType("varchar(64)");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasMaxLength(64)
+ .IsUnicode(false)
+ .HasColumnType("varchar(64)");
+
+ b.Property("Value")
+ .HasColumnType("float");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CapturedAtUtc")
+ .HasDatabaseName("IX_KpiSample_Captured");
+
+ b.HasIndex("Source", "Metric", "Scope", "ScopeKey", "CapturedAtUtc")
+ .HasDatabaseName("IX_KpiSample_Series");
+
+ b.ToTable("KpiSample", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification", b =>
+ {
+ b.Property("NotificationId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeliveredAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("LastAttemptAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("LastError")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("ListName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("NextAttemptAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("OriginExecutionId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("OriginParentExecutionId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ResolvedTargets")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RetryCount")
+ .HasColumnType("int");
+
+ b.Property("SiteEnqueuedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("SourceInstanceId")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("SourceNode")
+ .HasMaxLength(64)
+ .IsUnicode(false)
+ .HasColumnType("varchar(64)");
+
+ b.Property("SourceScript")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("SourceSiteId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("Subject")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("TypeData")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("NotificationId");
+
+ b.HasIndex("DeliveredAt")
+ .HasDatabaseName("IX_Notifications_Delivered")
+ .HasFilter("[Status] = 'Delivered'");
+
+ b.HasIndex("CreatedAt", "NotificationId")
+ .IsDescending()
+ .HasDatabaseName("IX_Notifications_Created_NotificationId");
+
+ b.HasIndex("SourceSiteId", "CreatedAt");
+
+ b.HasIndex("Status", "NextAttemptAt");
+
+ b.ToTable("Notifications");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("NotificationLists");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("EmailAddress")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("NotificationListId")
+ .HasColumnType("int");
+
+ b.Property("PhoneNumber")
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NotificationListId");
+
+ b.ToTable("NotificationRecipients");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmsConfiguration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AccountSid")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ApiBaseUrl")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("AuthToken")
+ .HasMaxLength(8000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ConnectionTimeoutSeconds")
+ .HasColumnType("int");
+
+ b.Property("FromNumber")
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("MaxRetries")
+ .HasColumnType("int");
+
+ b.Property("MessagingServiceSid")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("RetryDelay")
+ .HasColumnType("time");
+
+ b.HasKey("Id");
+
+ b.ToTable("SmsConfigurations");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmtpConfiguration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AuthType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("ConnectionTimeoutSeconds")
+ .HasColumnType("int");
+
+ b.Property("Credentials")
+ .HasMaxLength(8000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("FromAddress")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Host")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("MaxConcurrentConnections")
+ .HasColumnType("int");
+
+ b.Property("MaxRetries")
+ .HasColumnType("int");
+
+ b.Property("OAuth2Authority")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("OAuth2Scope")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Port")
+ .HasColumnType("int");
+
+ b.Property("RetryDelay")
+ .HasColumnType("time");
+
+ b.Property("TlsMode")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("Transport")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.HasKey("Id");
+
+ b.ToTable("SmtpConfigurations");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas.SharedSchema", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("SchemaJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Scope")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("SharedSchemas");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts.SharedScript", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property