diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20261022102459_EnableSnapshotIsolation.Designer.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20261022102459_EnableSnapshotIsolation.Designer.cs
new file mode 100644
index 00000000..db54f3a9
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20261022102459_EnableSnapshotIsolation.Designer.cs
@@ -0,0 +1,1618 @@
+//
+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.OtOpcUa.Configuration;
+
+#nullable disable
+
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
+{
+ [DbContext(typeof(OtOpcUaConfigDbContext))]
+ [Migration("20261022102459_EnableSnapshotIsolation")]
+ partial class EnableSnapshotIsolation
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.7")
+ .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", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ApiKey", b =>
+ {
+ b.Property("ApiKeyRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("ApiKeyId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("ExpiresUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Notes")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("Principal")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("SecretHash")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.HasKey("ApiKeyRowId");
+
+ b.HasIndex("ApiKeyId")
+ .IsUnique()
+ .HasDatabaseName("UX_ApiKey_ApiKeyId");
+
+ b.HasIndex("Principal")
+ .HasDatabaseName("IX_ApiKey_Principal");
+
+ b.ToTable("ApiKey", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
+ {
+ b.Property("NodeId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("AkkaPort")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasDefaultValue(4053);
+
+ b.Property("ApplicationUri")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("ClusterId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("DashboardPort")
+ .HasColumnType("int");
+
+ b.Property("DriverConfigOverridesJson")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("GrpcPort")
+ .HasColumnType("int");
+
+ b.Property("Host")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("MaintenanceMode")
+ .HasColumnType("bit");
+
+ b.Property("OpcUaPort")
+ .HasColumnType("int");
+
+ b.Property("ServiceLevelBase")
+ .HasColumnType("tinyint");
+
+ b.HasKey("NodeId");
+
+ b.HasIndex("ApplicationUri")
+ .IsUnique()
+ .HasDatabaseName("UX_ClusterNode_ApplicationUri");
+
+ b.HasIndex("ClusterId")
+ .HasDatabaseName("IX_ClusterNode_ClusterId");
+
+ b.ToTable("ClusterNode", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b =>
+ {
+ b.Property("CredentialId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("NodeId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("RotatedAt")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.HasKey("CredentialId");
+
+ b.HasIndex("Kind", "Value")
+ .IsUnique()
+ .HasDatabaseName("UX_ClusterNodeCredential_Value")
+ .HasFilter("[Enabled] = 1");
+
+ b.HasIndex("NodeId", "Enabled")
+ .HasDatabaseName("IX_ClusterNodeCredential_NodeId");
+
+ b.ToTable("ClusterNodeCredential", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigAuditLog", b =>
+ {
+ b.Property("AuditId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId"));
+
+ b.Property("ClusterId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("CorrelationId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DetailsJson")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("EventId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("NodeId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Outcome")
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.Property("Principal")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Timestamp")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.HasKey("AuditId");
+
+ b.HasIndex("EventId")
+ .IsUnique()
+ .HasDatabaseName("UX_ConfigAuditLog_EventId")
+ .HasFilter("[EventId] IS NOT NULL");
+
+ b.HasIndex("GenerationId")
+ .HasDatabaseName("IX_ConfigAuditLog_Generation")
+ .HasFilter("[GenerationId] IS NOT NULL");
+
+ b.HasIndex("ClusterId", "Timestamp")
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time");
+
+ b.ToTable("ConfigAuditLog", null, t =>
+ {
+ t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1");
+ });
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigEdit", b =>
+ {
+ b.Property("EditId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("EditedAtUtc")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.Property("EditedBy")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("EntityId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("ExecutionId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("FieldsJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("SourceNode")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("EditId");
+
+ b.HasIndex("EditedAtUtc")
+ .HasDatabaseName("IX_ConfigEdit_EditedAt");
+
+ b.HasIndex("ExecutionId")
+ .HasDatabaseName("IX_ConfigEdit_Execution")
+ .HasFilter("[ExecutionId] IS NOT NULL");
+
+ b.HasIndex("EntityType", "EntityId")
+ .HasDatabaseName("IX_ConfigEdit_Entity");
+
+ b.ToTable("ConfigEdit", null, t =>
+ {
+ t.HasCheckConstraint("CK_ConfigEdit_FieldsJson_IsJson", "ISJSON(FieldsJson) = 1");
+ });
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", b =>
+ {
+ b.Property("DeploymentId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("ArtifactBlob")
+ .IsRequired()
+ .HasColumnType("varbinary(max)");
+
+ b.Property("CreatedAtUtc")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("FailureReason")
+ .HasMaxLength(2048)
+ .HasColumnType("nvarchar(2048)");
+
+ b.Property("RevisionHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("SealedAtUtc")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("Status")
+ .HasColumnType("int");
+
+ b.HasKey("DeploymentId");
+
+ b.HasIndex("CreatedAtUtc")
+ .HasDatabaseName("IX_Deployment_CreatedAt");
+
+ b.HasIndex("Status")
+ .HasDatabaseName("IX_Deployment_Status");
+
+ b.ToTable("Deployment", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b =>
+ {
+ b.Property("DeviceRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("DeviceConfig")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DeviceId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("DriverInstanceId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.HasKey("DeviceRowId");
+
+ b.HasIndex("DeviceId")
+ .IsUnique()
+ .HasDatabaseName("UX_Device_LogicalId")
+ .HasFilter("[DeviceId] IS NOT NULL");
+
+ b.HasIndex("DriverInstanceId")
+ .HasDatabaseName("IX_Device_Driver");
+
+ b.HasIndex("DriverInstanceId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_Device_Driver_Name");
+
+ b.ToTable("Device", null, t =>
+ {
+ t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
+ });
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b =>
+ {
+ b.Property("DriverInstanceRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("ClusterId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("DriverConfig")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DriverInstanceId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("DriverType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("RawFolderId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("ResilienceConfig")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.HasKey("DriverInstanceRowId");
+
+ b.HasIndex("ClusterId")
+ .HasDatabaseName("IX_DriverInstance_Cluster");
+
+ b.HasIndex("DriverInstanceId")
+ .IsUnique()
+ .HasDatabaseName("UX_DriverInstance_LogicalId")
+ .HasFilter("[DriverInstanceId] IS NOT NULL");
+
+ b.HasIndex("RawFolderId")
+ .HasDatabaseName("IX_DriverInstance_RawFolder");
+
+ b.HasIndex("ClusterId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_DriverInstance_ClusterRoot_Name")
+ .HasFilter("[RawFolderId] IS NULL");
+
+ b.HasIndex("ClusterId", "RawFolderId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_DriverInstance_Folder_Name")
+ .HasFilter("[RawFolderId] IS NOT NULL");
+
+ b.ToTable("DriverInstance", null, t =>
+ {
+ t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1");
+
+ t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson", "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1");
+ });
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b =>
+ {
+ b.Property("EquipmentRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("AssetLocation")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("DeviceManualUri")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("EquipmentClassRef")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("EquipmentId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("EquipmentUuid")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("HardwareRevision")
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("MachineCode")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Manufacturer")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("ManufacturerUri")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("Model")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("SAPID")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("SerialNumber")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("SoftwareRevision")
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("UnsLineId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("YearOfConstruction")
+ .HasColumnType("smallint");
+
+ b.Property("ZTag")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("EquipmentRowId");
+
+ b.HasIndex("EquipmentId")
+ .IsUnique()
+ .HasDatabaseName("UX_Equipment_LogicalId")
+ .HasFilter("[EquipmentId] IS NOT NULL");
+
+ b.HasIndex("EquipmentUuid")
+ .IsUnique()
+ .HasDatabaseName("UX_Equipment_Uuid");
+
+ b.HasIndex("MachineCode")
+ .HasDatabaseName("IX_Equipment_MachineCode");
+
+ b.HasIndex("SAPID")
+ .HasDatabaseName("IX_Equipment_SAPID")
+ .HasFilter("[SAPID] IS NOT NULL");
+
+ b.HasIndex("UnsLineId")
+ .HasDatabaseName("IX_Equipment_Line");
+
+ b.HasIndex("ZTag")
+ .HasDatabaseName("IX_Equipment_ZTag")
+ .HasFilter("[ZTag] IS NOT NULL");
+
+ b.HasIndex("UnsLineId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_Equipment_LinePath");
+
+ b.ToTable("Equipment", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b =>
+ {
+ b.Property("ReservationId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("ClusterId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("EquipmentUuid")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("FirstPublishedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.Property("FirstPublishedBy")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.Property("LastPublishedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.Property("ReleaseReason")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("ReleasedAt")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("ReleasedBy")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("ReservationId");
+
+ b.HasIndex("EquipmentUuid")
+ .HasDatabaseName("IX_ExternalIdReservation_Equipment");
+
+ b.HasIndex("Kind", "Value")
+ .IsUnique()
+ .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active")
+ .HasFilter("[ReleasedAt] IS NULL");
+
+ b.ToTable("ExternalIdReservation", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ClusterId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("IsSystemWide")
+ .HasColumnType("bit");
+
+ b.Property("LdapGroup")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("Notes")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClusterId");
+
+ b.HasIndex("LdapGroup")
+ .HasDatabaseName("IX_LdapGroupRoleMapping_Group");
+
+ b.HasIndex("LdapGroup", "ClusterId")
+ .IsUnique()
+ .HasDatabaseName("UX_LdapGroupRoleMapping_Group_Cluster")
+ .HasFilter("[ClusterId] IS NOT NULL");
+
+ b.ToTable("LdapGroupRoleMapping", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b =>
+ {
+ b.Property("NodeAclRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("ClusterId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("NodeAclId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Notes")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("PermissionFlags")
+ .HasColumnType("int");
+
+ b.Property("Principal")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("ScopeId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("ScopeKind")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.HasKey("NodeAclRowId");
+
+ b.HasIndex("ClusterId")
+ .HasDatabaseName("IX_NodeAcl_Cluster");
+
+ b.HasIndex("NodeAclId")
+ .IsUnique()
+ .HasDatabaseName("UX_NodeAcl_LogicalId")
+ .HasFilter("[NodeAclId] IS NOT NULL");
+
+ b.HasIndex("Principal")
+ .HasDatabaseName("IX_NodeAcl_Principal");
+
+ b.HasIndex("ScopeKind", "ScopeId")
+ .HasDatabaseName("IX_NodeAcl_Scope")
+ .HasFilter("[ScopeId] IS NOT NULL");
+
+ b.HasIndex("ClusterId", "Principal", "ScopeKind", "ScopeId")
+ .IsUnique()
+ .HasDatabaseName("UX_NodeAcl_PrincipalScope")
+ .HasFilter("[ScopeId] IS NOT NULL");
+
+ b.ToTable("NodeAcl", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b =>
+ {
+ b.Property("NodeId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("DeploymentId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("AppliedAtUtc")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("FailureReason")
+ .HasMaxLength(2048)
+ .HasColumnType("nvarchar(2048)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("StartedAtUtc")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.Property("Status")
+ .HasColumnType("int");
+
+ b.HasKey("NodeId", "DeploymentId");
+
+ b.HasIndex("DeploymentId")
+ .HasDatabaseName("IX_NodeDeploymentState_Deployment");
+
+ b.HasIndex("Status")
+ .HasDatabaseName("IX_NodeDeploymentState_Status");
+
+ b.ToTable("NodeDeploymentState", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b =>
+ {
+ b.Property("PollGroupRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("DriverInstanceId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("IntervalMs")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("PollGroupId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.HasKey("PollGroupRowId");
+
+ b.HasIndex("DriverInstanceId")
+ .HasDatabaseName("IX_PollGroup_Driver");
+
+ b.HasIndex("PollGroupId")
+ .IsUnique()
+ .HasDatabaseName("UX_PollGroup_LogicalId")
+ .HasFilter("[PollGroupId] IS NOT NULL");
+
+ b.ToTable("PollGroup", null, t =>
+ {
+ t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50");
+ });
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.RawFolder", b =>
+ {
+ b.Property("RawFolderRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("ClusterId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("ParentRawFolderId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("RawFolderId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("SortOrder")
+ .HasColumnType("int");
+
+ b.HasKey("RawFolderRowId");
+
+ b.HasIndex("ClusterId")
+ .HasDatabaseName("IX_RawFolder_Cluster");
+
+ b.HasIndex("RawFolderId")
+ .IsUnique()
+ .HasDatabaseName("UX_RawFolder_LogicalId")
+ .HasFilter("[RawFolderId] IS NOT NULL");
+
+ b.HasIndex("ClusterId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_RawFolder_ClusterRoot_Name")
+ .HasFilter("[ParentRawFolderId] IS NULL");
+
+ b.HasIndex("ClusterId", "ParentRawFolderId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_RawFolder_Parent_Name")
+ .HasFilter("[ParentRawFolderId] IS NOT NULL");
+
+ b.ToTable("RawFolder", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b =>
+ {
+ b.Property("ScriptRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("Language")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("ScriptId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("SourceCode")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("SourceHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("ScriptRowId");
+
+ b.HasIndex("ScriptId")
+ .IsUnique()
+ .HasDatabaseName("UX_Script_LogicalId")
+ .HasFilter("[ScriptId] IS NOT NULL");
+
+ b.HasIndex("SourceHash")
+ .HasDatabaseName("IX_Script_SourceHash");
+
+ b.ToTable("Script", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarm", b =>
+ {
+ b.Property("ScriptedAlarmRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("AlarmType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("EquipmentId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("HistorizeToAveva")
+ .HasColumnType("bit");
+
+ b.Property("MessageTemplate")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("PredicateScriptId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Retain")
+ .HasColumnType("bit");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("ScriptedAlarmId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Severity")
+ .HasColumnType("int");
+
+ b.HasKey("ScriptedAlarmRowId");
+
+ b.HasIndex("PredicateScriptId")
+ .HasDatabaseName("IX_ScriptedAlarm_Script");
+
+ b.HasIndex("ScriptedAlarmId")
+ .IsUnique()
+ .HasDatabaseName("UX_ScriptedAlarm_LogicalId")
+ .HasFilter("[ScriptedAlarmId] IS NOT NULL");
+
+ b.HasIndex("EquipmentId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_ScriptedAlarm_EquipmentPath");
+
+ b.ToTable("ScriptedAlarm", null, t =>
+ {
+ t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')");
+
+ t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000");
+ });
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b =>
+ {
+ b.Property("ClusterId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("Enterprise")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("ModifiedAt")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("ModifiedBy")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("NodeCount")
+ .HasColumnType("tinyint");
+
+ b.Property("Notes")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("RedundancyMode")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.Property("Site")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.HasKey("ClusterId");
+
+ b.HasIndex("Name")
+ .IsUnique()
+ .HasDatabaseName("UX_ServerCluster_Name");
+
+ b.HasIndex("Site")
+ .HasDatabaseName("IX_ServerCluster_Site");
+
+ b.ToTable("ServerCluster", null, t =>
+ {
+ t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))");
+ });
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b =>
+ {
+ b.Property("TagRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("AccessLevel")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.Property("DataType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("DeviceId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("PollGroupId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("TagConfig")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TagGroupId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("TagId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("WriteIdempotent")
+ .HasColumnType("bit");
+
+ b.HasKey("TagRowId");
+
+ b.HasIndex("TagId")
+ .IsUnique()
+ .HasDatabaseName("UX_Tag_LogicalId")
+ .HasFilter("[TagId] IS NOT NULL");
+
+ b.HasIndex("DeviceId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_Tag_Device_Name")
+ .HasFilter("[TagGroupId] IS NULL");
+
+ b.HasIndex("DeviceId", "TagGroupId")
+ .HasDatabaseName("IX_Tag_Device");
+
+ b.HasIndex("DeviceId", "TagGroupId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_Tag_Group_Name")
+ .HasFilter("[TagGroupId] IS NOT NULL");
+
+ b.ToTable("Tag", null, t =>
+ {
+ t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1");
+ });
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.TagGroup", b =>
+ {
+ b.Property("TagGroupRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("DeviceId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("ParentTagGroupId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("SortOrder")
+ .HasColumnType("int");
+
+ b.Property("TagGroupId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("TagGroupRowId");
+
+ b.HasIndex("DeviceId")
+ .HasDatabaseName("IX_TagGroup_Device");
+
+ b.HasIndex("TagGroupId")
+ .IsUnique()
+ .HasDatabaseName("UX_TagGroup_LogicalId")
+ .HasFilter("[TagGroupId] IS NOT NULL");
+
+ b.HasIndex("DeviceId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_TagGroup_DeviceRoot_Name")
+ .HasFilter("[ParentTagGroupId] IS NULL");
+
+ b.HasIndex("DeviceId", "ParentTagGroupId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_TagGroup_Parent_Name")
+ .HasFilter("[ParentTagGroupId] IS NOT NULL");
+
+ b.ToTable("TagGroup", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b =>
+ {
+ b.Property("UnsAreaRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("ClusterId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("Notes")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("UnsAreaId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("UnsAreaRowId");
+
+ b.HasIndex("ClusterId")
+ .HasDatabaseName("IX_UnsArea_Cluster");
+
+ b.HasIndex("UnsAreaId")
+ .IsUnique()
+ .HasDatabaseName("UX_UnsArea_LogicalId")
+ .HasFilter("[UnsAreaId] IS NOT NULL");
+
+ b.HasIndex("ClusterId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_UnsArea_ClusterName");
+
+ b.ToTable("UnsArea", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b =>
+ {
+ b.Property("UnsLineRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("Notes")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("UnsAreaId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("UnsLineId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("UnsLineRowId");
+
+ b.HasIndex("UnsAreaId")
+ .HasDatabaseName("IX_UnsLine_Area");
+
+ b.HasIndex("UnsLineId")
+ .IsUnique()
+ .HasDatabaseName("UX_UnsLine_LogicalId")
+ .HasFilter("[UnsLineId] IS NOT NULL");
+
+ b.HasIndex("UnsAreaId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_UnsLine_AreaName");
+
+ b.ToTable("UnsLine", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsTagReference", b =>
+ {
+ b.Property("UnsTagReferenceRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("DisplayNameOverride")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("EquipmentId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("SortOrder")
+ .HasColumnType("int");
+
+ b.Property("TagId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("UnsTagReferenceId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("UnsTagReferenceRowId");
+
+ b.HasIndex("EquipmentId")
+ .HasDatabaseName("IX_UnsTagReference_Equipment");
+
+ b.HasIndex("TagId")
+ .HasDatabaseName("IX_UnsTagReference_Tag");
+
+ b.HasIndex("UnsTagReferenceId")
+ .IsUnique()
+ .HasDatabaseName("UX_UnsTagReference_LogicalId")
+ .HasFilter("[UnsTagReferenceId] IS NOT NULL");
+
+ b.HasIndex("EquipmentId", "TagId")
+ .IsUnique()
+ .HasDatabaseName("UX_UnsTagReference_Equip_Tag");
+
+ b.ToTable("UnsTagReference", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b =>
+ {
+ b.Property("VirtualTagRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("ChangeTriggered")
+ .HasColumnType("bit");
+
+ b.Property("DataType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("EquipmentId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Historize")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("ScriptId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("TimerIntervalMs")
+ .HasColumnType("int");
+
+ b.Property("VirtualTagId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("VirtualTagRowId");
+
+ b.HasIndex("ScriptId")
+ .HasDatabaseName("IX_VirtualTag_Script");
+
+ b.HasIndex("VirtualTagId")
+ .IsUnique()
+ .HasDatabaseName("UX_VirtualTag_LogicalId")
+ .HasFilter("[VirtualTagId] IS NOT NULL");
+
+ b.HasIndex("EquipmentId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_VirtualTag_EquipmentPath");
+
+ b.ToTable("VirtualTag", null, t =>
+ {
+ t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50");
+
+ t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL");
+ });
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
+ .WithMany("Nodes")
+ .HasForeignKey("ClusterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Cluster");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node")
+ .WithMany("Credentials")
+ .HasForeignKey("NodeId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Node");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
+ .WithMany()
+ .HasForeignKey("ClusterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Cluster");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
+ .WithMany()
+ .HasForeignKey("ClusterId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.Navigation("Cluster");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", "Deployment")
+ .WithMany()
+ .HasForeignKey("DeploymentId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node")
+ .WithMany()
+ .HasForeignKey("NodeId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Deployment");
+
+ b.Navigation("Node");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.RawFolder", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", null)
+ .WithMany("RawFolders")
+ .HasForeignKey("ClusterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
+ .WithMany()
+ .HasForeignKey("ClusterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Cluster");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
+ {
+ b.Navigation("Credentials");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b =>
+ {
+ b.Navigation("Nodes");
+
+ b.Navigation("RawFolders");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20261022102459_EnableSnapshotIsolation.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20261022102459_EnableSnapshotIsolation.cs
new file mode 100644
index 00000000..58cf9a7e
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20261022102459_EnableSnapshotIsolation.cs
@@ -0,0 +1,36 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
+{
+ ///
+ /// Enables ALLOW_SNAPSHOT_ISOLATION on the config database so the deploy path can read every
+ /// table inside one SNAPSHOT transaction (DraftSnapshotFactory.FromConfigDbAsync):
+ /// the validator and the artifact composer then see one consistent state instead of two reads
+ /// an AdminUI save can land between. No schema change — the model snapshot is untouched.
+ ///
+ ///
+ /// ALTER DATABASE cannot run inside the user transaction the migrator wraps each migration in,
+ /// hence suppressTransaction: true. Turning the option ON does not need exclusive access (unlike
+ /// READ_COMMITTED_SNAPSHOT); it waits for in-flight transactions and then takes effect. Down
+ /// leaves it ON: a database that has run with it is not harmed by keeping it, and switching it OFF
+ /// would fail any deploy running at that instant.
+ ///
+ public partial class EnableSnapshotIsolation : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql(
+ "ALTER DATABASE CURRENT SET ALLOW_SNAPSHOT_ISOLATION ON;",
+ suppressTransaction: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ // Deliberately a no-op; see the class remarks.
+ }
+ }
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs
index 0c0c47d9..fd7dc35a 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs
@@ -66,6 +66,23 @@ public sealed class DraftSnapshot
/// silently dropped when the permission trie is built, so the grant has to be caught here instead.
public IReadOnlyList NodeAcls { get; init; } = [];
+ ///
+ /// Cluster rows. Not read by the row-level rules; carried so the deploy path validates the cluster
+ /// topology () and composes the artifact from
+ /// the SAME read as every other table — a second read between validate and compose is exactly how
+ /// an edit ships unvalidated.
+ ///
+ public IReadOnlyList ServerClusters { get; init; } = [];
+
+ /// Cluster-node rows; see for why they ride the snapshot.
+ public IReadOnlyList ClusterNodes { get; init; } = [];
+
+ ///
+ /// Data-plane API keys (peppered HMAC only, never a secret). Not validated; carried so the artifact
+ /// composer ships the keys a driver-only node authenticates against from the one consistent read.
+ ///
+ public IReadOnlyList ApiKeys { get; init; } = [];
+
/// Prior Equipment rows (any generation, same cluster) for stability checks.
public IReadOnlyList PriorEquipment { get; init; } = [];
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs
index 3bfd0a5c..154d664c 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs
@@ -1,3 +1,4 @@
+using System.Data;
using Microsoft.EntityFrameworkCore;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
@@ -21,6 +22,16 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
/// / are left null so the path-length rule uses its
/// conservative upper bound.
///
+///
+/// One consistent read. On a relational provider the tables are read inside a
+/// transaction, so a /uns or /raw save that
+/// lands mid-read cannot leave half the tables on the new state and half on the old. The deploy path
+/// validates AND composes its artifact from this one snapshot, which is what makes "the artifact the
+/// validator never saw" unrepresentable. SQL Server requires ALLOW_SNAPSHOT_ISOLATION ON
+/// (migration EnableSnapshotIsolation); without it the first query fails with error 3952 and
+/// the deploy is rejected rather than silently read unisolated. A non-relational provider (the
+/// InMemory test provider) and a context already inside a transaction read without one.
+///
///
public static class DraftSnapshotFactory
{
@@ -29,6 +40,23 @@ public static class DraftSnapshotFactory
/// Cancellation token.
/// A snapshot populated from the live DB, ready for .
public static async Task FromConfigDbAsync(OtOpcUaConfigDbContext db, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(db);
+
+ var ownsTransaction = db.Database.IsRelational() && db.Database.CurrentTransaction is null;
+ var tx = ownsTransaction
+ ? await db.Database.BeginTransactionAsync(IsolationLevel.Snapshot, ct).ConfigureAwait(false)
+ : null;
+ await using (tx)
+ {
+ var snapshot = await ReadAsync(db, ct).ConfigureAwait(false);
+ // Read-only: committing releases the snapshot's version-store reservation; nothing was written.
+ if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false);
+ return snapshot;
+ }
+ }
+
+ private static async Task ReadAsync(OtOpcUaConfigDbContext db, CancellationToken ct)
=> new DraftSnapshot
{
// GenerationId=0 and null Enterprise/Site are intentional conservative fallbacks at the
@@ -36,6 +64,8 @@ public static class DraftSnapshotFactory
// EquipmentUuid checks run in separate validator passes — no rule here reads these fields.
GenerationId = 0, // generation model dropped; placeholder (no rule reads it)
ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this
+ ServerClusters = await db.ServerClusters.AsNoTracking().ToListAsync(ct),
+ ClusterNodes = await db.ClusterNodes.AsNoTracking().ToListAsync(ct),
// v3: Namespace entity retired — no namespace list to materialize. The Raw tree
// (RawFolders/TagGroups) + UnsTagReferences feed the v3 rules (charset, tagname length,
// effective-leaf uniqueness).
@@ -53,6 +83,7 @@ public static class DraftSnapshotFactory
Scripts = await db.Scripts.AsNoTracking().ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
NodeAcls = await db.NodeAcls.AsNoTracking().ToListAsync(ct),
+ ApiKeys = await db.ApiKeys.AsNoTracking().ToListAsync(ct),
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
ActiveReservations = await db.ExternalIdReservations
.AsNoTracking()
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs
index d569fd08..4aabe3db 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs
@@ -223,18 +223,14 @@ public sealed class AdminOperationsActor : ReceiveActor
// pair on the row itself, but it cannot see the per-node ClusterNode.Enabled flag — an
// operator can disable a node (effective enabled-count = 1) while leaving RedundancyMode at
// Hot/Warm and the constraint stays green, which would boot the runtime into an
- // InvalidTopology band. ValidateClusterTopology catches that drift, but it isn't carried on
- // the generation-versioned DraftSnapshot (the cluster/node rows aren't versioned), so it must
- // be run separately here against the live rows. Read-only (AsNoTracking); errors fold into the
- // same reject summary alongside the snapshot rules so a deploy failing either check is
- // rejected with both sets of messages. ClusterId-ordered for a deterministic summary.
- var clusters = await db.ServerClusters
- .AsNoTracking()
- .OrderBy(c => c.ClusterId)
- .ToListAsync();
- var nodesByCluster = (await db.ClusterNodes
- .AsNoTracking()
- .ToListAsync())
+ // InvalidTopology band. ValidateClusterTopology catches that drift; it is not part of
+ // DraftValidator.Validate, so it runs separately here over the snapshot's cluster/node rows.
+ // Errors fold into the same reject summary alongside the snapshot rules so a deploy failing
+ // either check is rejected with both sets of messages. ClusterId-ordered for a deterministic summary.
+ // R03:3.2 — both come off the SAME snapshot read as every other table (one SNAPSHOT
+ // transaction), not a second query that could observe a later edit.
+ var clusters = draft.ServerClusters.OrderBy(c => c.ClusterId, StringComparer.Ordinal).ToList();
+ var nodesByCluster = draft.ClusterNodes
.GroupBy(n => n.ClusterId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
foreach (var cluster in clusters)
@@ -310,8 +306,9 @@ public sealed class AdminOperationsActor : ReceiveActor
try
{
const double PerScriptMiB = 1.66; // measured post-A0 per-script RSS (design doc 2026-06-07)
- var scriptSources = await db.Scripts.AsNoTracking().Select(s => s.SourceCode).ToListAsync();
- var compiled = scriptSources
+ // Projected from the snapshot already in hand — Script.SourceCode used to be read three times.
+ var compiled = draft.Scripts
+ .Select(s => s.SourceCode)
.Where(src => !PassthroughScript.TryMatch(src, out _))
.Distinct(StringComparer.Ordinal)
.Count();
@@ -331,7 +328,9 @@ public sealed class AdminOperationsActor : ReceiveActor
advisory = null;
}
- var artifact = await ConfigComposer.SnapshotAndFlattenAsync(db);
+ // Composed from the validated snapshot, never from a second read: an edit that lands between
+ // validation and here is simply not in this artifact (R03:3.2).
+ var artifact = ConfigComposer.Compose(draft);
var deploymentId = DeploymentId.NewId();
var revHash = RevisionHash.Parse(artifact.RevisionHash);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs
index d73dd7b3..6f332143 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs
@@ -1,9 +1,8 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
-using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
-using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
@@ -25,45 +24,65 @@ public static class ConfigComposer
};
/// Reads the current configuration and returns a deterministic snapshot blob with revision hash.
+ ///
+ /// One consistent read (, a SNAPSHOT transaction on
+ /// SQL Server) handed to . The deploy path does not call this: it validates and
+ /// composes from the one it already holds, so nothing can land between the
+ /// validator's read and the composer's. This overload serves the drift probe and tests.
+ ///
/// The configuration database context.
/// The cancellation token for the operation.
/// A deterministic containing the serialised snapshot bytes and its revision hash.
public static async Task SnapshotAndFlattenAsync(
OtOpcUaConfigDbContext db, CancellationToken ct = default)
+ => Compose(await DraftSnapshotFactory.FromConfigDbAsync(db, ct).ConfigureAwait(false));
+
+ ///
+ /// Serialises an already-read into the deterministic artifact. Pure over
+ /// its input: the same snapshot always yields the same bytes and hash, whatever the DB holds by now.
+ ///
+ /// The rows to compose — the SAME rows the validator was run over.
+ /// The compressed artifact blob and its revision hash.
+ public static ConfigArtifact Compose(DraftSnapshot snapshot)
{
+ ArgumentNullException.ThrowIfNull(snapshot);
+
// v3 greenfield snapshot: the Raw tree (RawFolders → DriverInstances(+RawFolderId) → Devices(+DeviceConfig)
// → TagGroups → Tags(DeviceId/TagGroupId, no EquipmentId/FolderPath/DriverInstanceId)) plus the UNS
// projection (UnsAreas/UnsLines/Equipment/UnsTagReferences) and the retained VirtualTags/ScriptedAlarms/
// PollGroups/NodeAcls/Scripts. The Namespaces snapshot is RETIRED (the two OPC UA namespaces are implicit).
// RevisionHash inputs follow the blob automatically — adding/removing a table shifts the SHA-256.
- var snapshot = new
+ // Every collection is sorted by its natural key with an ORDINAL comparer so the hash is independent of
+ // both EF row order and the process culture (a culture-aware sort could order "a-1"/"A-1" differently
+ // on two central nodes and make the same config hash twice).
+ var ordered = new
{
- Clusters = await db.ServerClusters.AsNoTracking().OrderBy(x => x.ClusterId).ToListAsync(ct),
- Nodes = await db.ClusterNodes.AsNoTracking().OrderBy(x => x.NodeId).ToListAsync(ct),
- RawFolders = await db.RawFolders.AsNoTracking().OrderBy(x => x.RawFolderId).ToListAsync(ct),
- DriverInstances = await db.DriverInstances.AsNoTracking().OrderBy(x => x.DriverInstanceId).ToListAsync(ct),
- Devices = await db.Devices.AsNoTracking().OrderBy(x => x.DeviceId).ToListAsync(ct),
- TagGroups = await db.TagGroups.AsNoTracking().OrderBy(x => x.TagGroupId).ToListAsync(ct),
- Equipment = await db.Equipment.AsNoTracking().OrderBy(x => x.EquipmentId).ToListAsync(ct),
- Tags = await db.Tags.AsNoTracking().OrderBy(x => x.TagId).ToListAsync(ct),
- UnsTagReferences = await db.UnsTagReferences.AsNoTracking().OrderBy(x => x.UnsTagReferenceId).ToListAsync(ct),
- PollGroups = await db.PollGroups.AsNoTracking().OrderBy(x => x.PollGroupId).ToListAsync(ct),
- UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct),
- UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct),
- NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct),
+ Clusters = snapshot.ServerClusters.OrderBy(x => x.ClusterId, StringComparer.Ordinal).ToList(),
+ Nodes = snapshot.ClusterNodes.OrderBy(x => x.NodeId, StringComparer.Ordinal).ToList(),
+ RawFolders = snapshot.RawFolders.OrderBy(x => x.RawFolderId, StringComparer.Ordinal).ToList(),
+ DriverInstances = snapshot.DriverInstances.OrderBy(x => x.DriverInstanceId, StringComparer.Ordinal).ToList(),
+ Devices = snapshot.Devices.OrderBy(x => x.DeviceId, StringComparer.Ordinal).ToList(),
+ TagGroups = snapshot.TagGroups.OrderBy(x => x.TagGroupId, StringComparer.Ordinal).ToList(),
+ Equipment = snapshot.Equipment.OrderBy(x => x.EquipmentId, StringComparer.Ordinal).ToList(),
+ Tags = snapshot.Tags.OrderBy(x => x.TagId, StringComparer.Ordinal).ToList(),
+ UnsTagReferences = snapshot.UnsTagReferences.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal).ToList(),
+ PollGroups = snapshot.PollGroups.OrderBy(x => x.PollGroupId, StringComparer.Ordinal).ToList(),
+ UnsAreas = snapshot.UnsAreas.OrderBy(x => x.UnsAreaId, StringComparer.Ordinal).ToList(),
+ UnsLines = snapshot.UnsLines.OrderBy(x => x.UnsLineId, StringComparer.Ordinal).ToList(),
+ NodeAcls = snapshot.NodeAcls.OrderBy(x => x.NodeAclId, StringComparer.Ordinal).ToList(),
// the data-plane credentials travel with the config, because a driver-only node
// has no ConfigDb to read them from (per-cluster mesh Phase 4) and would otherwise deny every
// UserName token. Only the peppered HMAC of each secret ships — never the secret — and the
// pepper is node configuration that is deliberately NOT in the artifact, so a stolen blob
// still cannot be turned into a working credential. This is also why revoking a key reaches a
// driver-only node only on the next deployment; see the ApiKey entity's remarks.
- ApiKeys = await db.ApiKeys.AsNoTracking().OrderBy(x => x.ApiKeyId).ToListAsync(ct),
- Scripts = await db.Scripts.AsNoTracking().OrderBy(x => x.ScriptId).ToListAsync(ct),
- VirtualTags = await db.VirtualTags.AsNoTracking().OrderBy(x => x.VirtualTagId).ToListAsync(ct),
- ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().OrderBy(x => x.ScriptedAlarmId).ToListAsync(ct),
+ ApiKeys = snapshot.ApiKeys.OrderBy(x => x.ApiKeyId, StringComparer.Ordinal).ToList(),
+ Scripts = snapshot.Scripts.OrderBy(x => x.ScriptId, StringComparer.Ordinal).ToList(),
+ VirtualTags = snapshot.VirtualTags.OrderBy(x => x.VirtualTagId, StringComparer.Ordinal).ToList(),
+ ScriptedAlarms = snapshot.ScriptedAlarms.OrderBy(x => x.ScriptedAlarmId, StringComparer.Ordinal).ToList(),
};
- return Pack(JsonSerializer.SerializeToUtf8Bytes(snapshot, JsonOptions));
+ return Pack(JsonSerializer.SerializeToUtf8Bytes(ordered, JsonOptions));
}
///
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftSnapshotFactorySqlServerTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftSnapshotFactorySqlServerTests.cs
new file mode 100644
index 00000000..aa16ead6
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftSnapshotFactorySqlServerTests.cs
@@ -0,0 +1,117 @@
+using Microsoft.Data.SqlClient;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
+
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
+
+///
+/// R03:3.2 on the real provider: the migrated config database allows SNAPSHOT isolation (migration
+/// EnableSnapshotIsolation) and reads under
+/// it. The InMemory tests cannot see either — the provider has no transactions — so this is the one
+/// place the deploy path's "one consistent read" is proven against SQL Server.
+///
+[Trait("Category", "SchemaCompliance")]
+[Collection(nameof(SchemaComplianceCollection))]
+public sealed class DraftSnapshotFactorySqlServerTests
+{
+ private readonly SchemaComplianceFixture _fixture;
+
+ /// Initializes a new instance of the class.
+ /// The migrated test database.
+ public DraftSnapshotFactorySqlServerTests(SchemaComplianceFixture fixture) => _fixture = fixture;
+
+ /// The migration leaves the database with snapshot isolation allowed.
+ [Fact]
+ public void Migrated_database_allows_snapshot_isolation()
+ {
+ using var conn = _fixture.OpenConnection();
+ using var cmd = new SqlCommand(
+ "SELECT snapshot_isolation_state_desc FROM sys.databases WHERE name = DB_NAME();", conn);
+ cmd.ExecuteScalar().ShouldBe("ON");
+ }
+
+ ///
+ /// A row committed by another connection while the snapshot read is in progress is NOT observed by
+ /// the tables read after it — every table reflects the instant the transaction started. Positive
+ /// control: a read started after the commit does see the row.
+ ///
+ [Fact]
+ public async Task FromConfigDb_reads_every_table_as_of_one_instant()
+ {
+ var clusterId = $"SNAP-{Guid.NewGuid():N}"[..24];
+ var scriptId = $"late-{Guid.NewGuid():N}";
+ await using (var seed = NewContext())
+ {
+ seed.ServerClusters.Add(new ServerCluster
+ {
+ ClusterId = clusterId, Name = "snapshot-read", Enterprise = "zb", Site = "test",
+ NodeCount = 1, RedundancyMode = RedundancyMode.None, CreatedBy = "test",
+ });
+ await seed.SaveChangesAsync();
+ }
+
+ try
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseSqlServer(_fixture.ConnectionString)
+ .AddInterceptors(new LateEditInterceptor(_fixture.ConnectionString, clusterId, scriptId))
+ .Options;
+
+ await using (var db = new OtOpcUaConfigDbContext(options))
+ {
+ var snapshot = await DraftSnapshotFactory.FromConfigDbAsync(db);
+ // The interceptor committed a Script while ServerClusters (the FIRST table read) was being
+ // materialised. Scripts is read later in the same transaction and must not see it.
+ snapshot.ServerClusters.ShouldContain(c => c.ClusterId == clusterId);
+ snapshot.Scripts.ShouldNotContain(s => s.ScriptId == scriptId);
+ }
+
+ await using (var verify = NewContext())
+ {
+ // Positive control: the edit really landed, and a read started afterwards sees it.
+ (await verify.Scripts.AnyAsync(s => s.ScriptId == scriptId)).ShouldBeTrue();
+ var later = await DraftSnapshotFactory.FromConfigDbAsync(verify);
+ later.Scripts.ShouldContain(s => s.ScriptId == scriptId);
+ }
+ }
+ finally
+ {
+ await using var cleanup = NewContext();
+ await cleanup.Scripts.Where(s => s.ScriptId == scriptId).ExecuteDeleteAsync();
+ await cleanup.ServerClusters.Where(c => c.ClusterId == clusterId).ExecuteDeleteAsync();
+ }
+ }
+
+ private OtOpcUaConfigDbContext NewContext() => new(
+ new DbContextOptionsBuilder().UseSqlServer(_fixture.ConnectionString).Options);
+
+ ///
+ /// Commits a Script on a SEPARATE connection the first time the snapshot read materialises the
+ /// seeded ServerCluster row — i.e. mid-read, after the first table and before Scripts.
+ ///
+ private sealed class LateEditInterceptor(string connectionString, string clusterId, string scriptId)
+ : IMaterializationInterceptor
+ {
+ private int _fired;
+
+ public object InitializedInstance(MaterializationInterceptionData materializationData, object entity)
+ {
+ if (entity is ServerCluster c && c.ClusterId == clusterId && Interlocked.Exchange(ref _fired, 1) == 0)
+ {
+ using var other = new OtOpcUaConfigDbContext(
+ new DbContextOptionsBuilder().UseSqlServer(connectionString).Options);
+ other.Scripts.Add(new Script
+ {
+ ScriptId = scriptId, Name = "late edit", SourceCode = "return 1;", SourceHash = "late-hash",
+ });
+ other.SaveChanges();
+ }
+ return entity;
+ }
+ }
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftSnapshotFactoryTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftSnapshotFactoryTests.cs
index 9ad5806a..df1fad5a 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftSnapshotFactoryTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftSnapshotFactoryTests.cs
@@ -103,6 +103,36 @@ public sealed class DraftSnapshotFactoryTests : IDisposable
snapshot.ActiveReservations[0].ReleasedAt.ShouldBeNull();
}
+ ///
+ /// R03:3.2 — the deploy path validates AND composes from ONE snapshot, so the tables the composer
+ /// needs beyond the validator's set (clusters, nodes, API keys) must ride the same read.
+ ///
+ [Fact]
+ public async Task FromConfigDb_carries_clusters_nodes_and_api_keys_for_the_composer()
+ {
+ _db.ServerClusters.Add(new ServerCluster
+ {
+ ClusterId = "SITE-A", Name = "Site A", Enterprise = "zb", Site = "site-a",
+ NodeCount = 1, RedundancyMode = RedundancyMode.None, CreatedBy = "test",
+ });
+ _db.ClusterNodes.Add(new ClusterNode
+ {
+ NodeId = "site-a-1:4053", ClusterId = "SITE-A", Host = "site-a-1",
+ ApplicationUri = "urn:OtOpcUa:site-a-1", Enabled = true, CreatedBy = "test",
+ });
+ _db.ApiKeys.Add(new ApiKey
+ {
+ ApiKeyId = "k1", Name = "scada", SecretHash = "hash", Principal = "scadabridge", Enabled = true,
+ });
+ await _db.SaveChangesAsync();
+
+ var snapshot = await DraftSnapshotFactory.FromConfigDbAsync(_db);
+
+ snapshot.ServerClusters.ShouldHaveSingleItem().ClusterId.ShouldBe("SITE-A");
+ snapshot.ClusterNodes.ShouldHaveSingleItem().NodeId.ShouldBe("site-a-1:4053");
+ snapshot.ApiKeys.ShouldHaveSingleItem().ApiKeyId.ShouldBe("k1");
+ }
+
private void SeedEquipment(string equipmentId)
{
var uuid = Guid.NewGuid();
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorSingleSnapshotTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorSingleSnapshotTests.cs
new file mode 100644
index 00000000..aa5ded29
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorSingleSnapshotTests.cs
@@ -0,0 +1,133 @@
+using System.IO.Compression;
+using System.Text;
+using Akka.Actor;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.EntityFrameworkCore.Storage;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
+using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
+using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
+
+///
+/// R03:3.2 — StartDeployment used to read the whole config DB twice: once for the validator,
+/// once for the composer. An edit landing between the two shipped an artifact the validator never saw.
+/// The deploy now validates and composes ONE snapshot, so an edit that lands mid-deploy is either
+/// validated (it was in the snapshot) or absent from the artifact (it was not) — never shipped unseen.
+///
+public sealed class AdminOperationsActorSingleSnapshotTests : ControlPlaneActorTestBase
+{
+ private const string LateEquipmentId = "EQ-late-and-NOT-canonical";
+
+ ///
+ /// An Equipment with a non-canonical id (the validator rejects those with EquipmentIdNotDerived)
+ /// is committed by another context while the snapshot read is in progress, AFTER the Equipment table
+ /// has been read. The validator cannot see it, so the deploy is Accepted — and the artifact must not
+ /// carry it either. Positive control: the row really landed in the DB, and a deploy started after it
+ /// is Rejected on exactly that rule.
+ ///
+ [Fact]
+ public void Edit_landing_between_validate_and_compose_cannot_ship_unvalidated()
+ {
+ // One shared root: an InMemory store is scoped to the internal service provider, and a context
+ // carrying an interceptor builds its own provider — without the shared root the intercepting
+ // context would read an EMPTY database and the whole test would be vacuous.
+ var dbName = Guid.NewGuid().ToString("N");
+ var root = new InMemoryDatabaseRoot();
+ var plain = new RootedDbFactory(dbName, root, interceptor: null);
+ using (var db = plain.CreateDbContext())
+ {
+ // A Tag to materialise — Tags is read AFTER Equipment by the snapshot factory, so an insert
+ // fired on the first Tag lands "between" the two tables of one read.
+ db.DriverInstances.Add(new DriverInstance
+ {
+ DriverInstanceId = "d", ClusterId = "", Name = "drv", DriverType = "Modbus", DriverConfig = "{}",
+ });
+ db.Devices.Add(new Device { DeviceId = "dev", DriverInstanceId = "d", Name = "dev", DeviceConfig = "{}" });
+ db.Tags.Add(new Tag
+ {
+ TagId = "tag-speed", DeviceId = "dev", Name = "speed", DataType = "Float",
+ AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
+ });
+ db.SaveChanges();
+ }
+
+ var injecting = new RootedDbFactory(dbName, root, new LateEquipmentInterceptor(plain));
+ var coordinator = CreateTestProbe("coord");
+ var actor = Sys.ActorOf(AdminOperationsActor.Props(injecting, coordinator.Ref, Enumerable.Empty()));
+
+ actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
+
+ var reply = ExpectMsg(PresenceBudget);
+ reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, reply.Message);
+ coordinator.ExpectMsg(PresenceBudget);
+
+ using (var verify = plain.CreateDbContext())
+ {
+ // The injection happened...
+ verify.Equipment.Any(e => e.EquipmentId == LateEquipmentId).ShouldBeTrue();
+ // ...and the shipped artifact does not contain what the validator never saw.
+ var row = verify.Deployments.Single();
+ Inflate(row.ArtifactBlob).ShouldNotContain(LateEquipmentId);
+ // The coordinator is a probe here, so seal the row by hand to let the next deploy through.
+ row.Status = DeploymentStatus.Sealed;
+ verify.SaveChanges();
+ }
+
+ // Positive control: now that the row is visible to a fresh read, the gate rejects it.
+ actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
+ var second = ExpectMsg(PresenceBudget);
+ second.Outcome.ShouldBe(StartDeploymentOutcome.Rejected);
+ second.Message.ShouldNotBeNull();
+ second.Message.ShouldContain("EquipmentIdNotDerived");
+ }
+
+ private static string Inflate(byte[] blob)
+ {
+ using var gz = new GZipStream(new MemoryStream(blob), CompressionMode.Decompress);
+ using var reader = new StreamReader(gz, Encoding.UTF8);
+ return reader.ReadToEnd();
+ }
+
+ /// On the first Tag materialised, commits a non-canonical Equipment through ANOTHER context.
+ private sealed class LateEquipmentInterceptor(IDbContextFactory other)
+ : IMaterializationInterceptor
+ {
+ private int _fired;
+
+ public object InitializedInstance(MaterializationInterceptionData materializationData, object entity)
+ {
+ if (entity is Tag && Interlocked.Exchange(ref _fired, 1) == 0)
+ {
+ using var db = other.CreateDbContext();
+ db.Equipment.Add(new Equipment
+ {
+ EquipmentUuid = Guid.NewGuid(), EquipmentId = LateEquipmentId, Name = "late",
+ UnsLineId = "line-a", MachineCode = "m",
+ });
+ db.SaveChanges();
+ }
+ return entity;
+ }
+ }
+
+ private sealed class RootedDbFactory(string dbName, InMemoryDatabaseRoot root, IInterceptor? interceptor)
+ : IDbContextFactory
+ {
+ public OtOpcUaConfigDbContext CreateDbContext()
+ {
+ var builder = new DbContextOptionsBuilder().UseInMemoryDatabase(dbName, root);
+ if (interceptor is not null) builder.AddInterceptors(interceptor);
+ return new OtOpcUaConfigDbContext(builder.Options);
+ }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigComposerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigComposerTests.cs
index 4e431151..674ececd 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigComposerTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigComposerTests.cs
@@ -222,6 +222,47 @@ public sealed class ConfigComposerTests : ControlPlaneActorTestBase
withoutOverride.ResilienceConfig.ShouldBeNull("an unset override stays null ⇒ tier defaults at spawn");
}
+ ///
+ /// R03:3.2 — the deploy path composes from the it
+ /// validated rather than re-reading the DB. over the factory's
+ /// snapshot must therefore be byte-identical to the DB overload, or the drift probe (which still
+ /// uses the DB overload) would report every deployment as drifted.
+ ///
+ [Fact]
+ public async Task Compose_over_the_factory_snapshot_is_byte_identical_to_the_db_overload()
+ {
+ var f = NewInMemoryDbFactory();
+ await using (var db = f.CreateDbContext())
+ {
+ db.ServerClusters.Add(NewCluster("cluster-b"));
+ db.ServerClusters.Add(NewCluster("cluster-a"));
+ db.ApiKeys.Add(new ApiKey
+ {
+ ApiKeyId = "k1", Name = "scada", SecretHash = "h", Principal = "scadabridge", CreatedUtc = FixedTimestamp,
+ });
+ db.Scripts.Add(new Script { ScriptId = "s-1", Name = "s", SourceCode = "return 1;", SourceHash = "x" });
+ await db.SaveChangesAsync();
+ }
+
+ var viaDb = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
+ var snapshot = await Configuration.Validation.DraftSnapshotFactory.FromConfigDbAsync(f.CreateDbContext());
+ var viaSnapshot = ConfigComposer.Compose(snapshot);
+
+ viaSnapshot.RevisionHash.ShouldBe(viaDb.RevisionHash);
+ viaSnapshot.Blob.ShouldBe(viaDb.Blob);
+ // The composed content is the snapshot's, not something re-read: the keys and clusters are in it.
+ var json = Inflate(viaSnapshot.Blob);
+ json.ShouldContain("\"ApiKeyId\":\"k1\"");
+ json.IndexOf("cluster-a", StringComparison.Ordinal).ShouldBeLessThan(json.IndexOf("cluster-b", StringComparison.Ordinal));
+ }
+
+ private static string Inflate(byte[] blob)
+ {
+ using var gz = new GZipStream(new MemoryStream(blob), CompressionMode.Decompress);
+ using var reader = new StreamReader(gz, Encoding.UTF8);
+ return reader.ReadToEnd();
+ }
+
private static readonly DateTime FixedTimestamp = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static ServerCluster NewCluster(string id) => new()