diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstance.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstance.cs
index 52dac9e..f2168a3 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstance.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstance.cs
@@ -27,6 +27,24 @@ public sealed class DriverInstance
/// Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91).
public required string DriverConfig { get; set; }
+ ///
+ /// Optional per-instance overrides for the Phase 6.1 shared Polly resilience pipeline.
+ /// Null = use the driver's tier defaults (decision #143). When populated, expected shape:
+ ///
+ /// {
+ /// "bulkheadMaxConcurrent": 16,
+ /// "bulkheadMaxQueue": 64,
+ /// "capabilityPolicies": {
+ /// "Read": { "timeoutSeconds": 5, "retryCount": 5, "breakerFailureThreshold": 3 },
+ /// "Write": { "timeoutSeconds": 5, "retryCount": 0, "breakerFailureThreshold": 5 }
+ /// }
+ /// }
+ ///
+ /// Parsed at startup by DriverResilienceOptionsParser; every key is optional +
+ /// unrecognised keys are ignored so future shapes land without a migration.
+ ///
+ public string? ResilienceConfig { get; set; }
+
public ConfigGeneration? Generation { get; set; }
public ServerCluster? Cluster { get; set; }
}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.Designer.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.Designer.cs
new file mode 100644
index 0000000..ea8ef07
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.Designer.cs
@@ -0,0 +1,1347 @@
+//
+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("20260419161932_AddDriverInstanceResilienceConfig")]
+ partial class AddDriverInstanceResilienceConfig
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
+ {
+ b.Property("NodeId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ 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("Host")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("OpcUaPort")
+ .HasColumnType("int");
+
+ b.Property("RedundancyRole")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.Property("ServiceLevelBase")
+ .HasColumnType("tinyint");
+
+ b.HasKey("NodeId");
+
+ b.HasIndex("ApplicationUri")
+ .IsUnique()
+ .HasDatabaseName("UX_ClusterNode_ApplicationUri");
+
+ b.HasIndex("ClusterId")
+ .IsUnique()
+ .HasDatabaseName("UX_ClusterNode_Primary_Per_Cluster")
+ .HasFilter("[RedundancyRole] = 'Primary'");
+
+ 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.ClusterNodeGenerationState", b =>
+ {
+ b.Property("NodeId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("CurrentGenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("LastAppliedAt")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("LastAppliedError")
+ .HasMaxLength(2048)
+ .HasColumnType("nvarchar(2048)");
+
+ b.Property("LastAppliedStatus")
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("datetime2(3)");
+
+ b.HasKey("NodeId");
+
+ b.HasIndex("CurrentGenerationId")
+ .HasDatabaseName("IX_ClusterNodeGenerationState_Generation");
+
+ b.ToTable("ClusterNodeGenerationState", (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("DetailsJson")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("NodeId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Principal")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Timestamp")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.HasKey("AuditId");
+
+ 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.ConfigGeneration", b =>
+ {
+ b.Property("GenerationId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("GenerationId"));
+
+ 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("Notes")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("ParentGenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("PublishedAt")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("PublishedBy")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.HasKey("GenerationId");
+
+ b.HasIndex("ClusterId")
+ .IsUnique()
+ .HasDatabaseName("UX_ConfigGeneration_Draft_Per_Cluster")
+ .HasFilter("[Status] = 'Draft'");
+
+ b.HasIndex("ParentGenerationId");
+
+ b.HasIndex("ClusterId", "Status", "GenerationId")
+ .IsDescending(false, false, true)
+ .HasDatabaseName("IX_ConfigGeneration_Cluster_Published");
+
+ SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("ClusterId", "Status", "GenerationId"), new[] { "PublishedAt" });
+
+ b.ToTable("ConfigGeneration", (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("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.HasKey("DeviceRowId");
+
+ b.HasIndex("GenerationId", "DeviceId")
+ .IsUnique()
+ .HasDatabaseName("UX_Device_Generation_LogicalId")
+ .HasFilter("[DeviceId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "DriverInstanceId")
+ .HasDatabaseName("IX_Device_Generation_Driver");
+
+ b.ToTable("Device", null, t =>
+ {
+ t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
+ });
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b =>
+ {
+ b.Property("NodeId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("DriverInstanceId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("HostName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("Detail")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("LastSeenUtc")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("State")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.Property("StateChangedUtc")
+ .HasColumnType("datetime2(3)");
+
+ b.HasKey("NodeId", "DriverInstanceId", "HostName");
+
+ b.HasIndex("LastSeenUtc")
+ .HasDatabaseName("IX_DriverHostStatus_LastSeen");
+
+ b.HasIndex("NodeId")
+ .HasDatabaseName("IX_DriverHostStatus_Node");
+
+ b.ToTable("DriverHostStatus", (string)null);
+ });
+
+ 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("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("NamespaceId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("ResilienceConfig")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("DriverInstanceRowId");
+
+ b.HasIndex("ClusterId");
+
+ b.HasIndex("GenerationId", "ClusterId")
+ .HasDatabaseName("IX_DriverInstance_Generation_Cluster");
+
+ b.HasIndex("GenerationId", "DriverInstanceId")
+ .IsUnique()
+ .HasDatabaseName("UX_DriverInstance_Generation_LogicalId")
+ .HasFilter("[DriverInstanceId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "NamespaceId")
+ .HasDatabaseName("IX_DriverInstance_Generation_Namespace");
+
+ 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.DriverInstanceResilienceStatus", b =>
+ {
+ b.Property("DriverInstanceId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("HostName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("BaselineFootprintBytes")
+ .HasColumnType("bigint");
+
+ b.Property("ConsecutiveFailures")
+ .HasColumnType("int");
+
+ b.Property("CurrentBulkheadDepth")
+ .HasColumnType("int");
+
+ b.Property("CurrentFootprintBytes")
+ .HasColumnType("bigint");
+
+ b.Property("LastCircuitBreakerOpenUtc")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("LastRecycleUtc")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("LastSampledUtc")
+ .HasColumnType("datetime2(3)");
+
+ b.HasKey("DriverInstanceId", "HostName");
+
+ b.HasIndex("LastSampledUtc")
+ .HasDatabaseName("IX_DriverResilience_LastSampled");
+
+ b.ToTable("DriverInstanceResilienceStatus", (string)null);
+ });
+
+ 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("DeviceId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("DeviceManualUri")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("DriverInstanceId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ 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("GenerationId")
+ .HasColumnType("bigint");
+
+ 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("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("GenerationId", "DriverInstanceId")
+ .HasDatabaseName("IX_Equipment_Generation_Driver");
+
+ b.HasIndex("GenerationId", "EquipmentId")
+ .IsUnique()
+ .HasDatabaseName("UX_Equipment_Generation_LogicalId")
+ .HasFilter("[EquipmentId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "EquipmentUuid")
+ .IsUnique()
+ .HasDatabaseName("UX_Equipment_Generation_Uuid");
+
+ b.HasIndex("GenerationId", "MachineCode")
+ .HasDatabaseName("IX_Equipment_Generation_MachineCode");
+
+ b.HasIndex("GenerationId", "SAPID")
+ .HasDatabaseName("IX_Equipment_Generation_SAPID")
+ .HasFilter("[SAPID] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "UnsLineId")
+ .HasDatabaseName("IX_Equipment_Generation_Line");
+
+ b.HasIndex("GenerationId", "ZTag")
+ .HasDatabaseName("IX_Equipment_Generation_ZTag")
+ .HasFilter("[ZTag] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "UnsLineId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_Equipment_Generation_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.Namespace", b =>
+ {
+ b.Property("NamespaceRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("ClusterId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("NamespaceId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("NamespaceUri")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("Notes")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.HasKey("NamespaceRowId");
+
+ b.HasIndex("ClusterId");
+
+ b.HasIndex("GenerationId", "ClusterId")
+ .HasDatabaseName("IX_Namespace_Generation_Cluster");
+
+ b.HasIndex("GenerationId", "NamespaceId")
+ .IsUnique()
+ .HasDatabaseName("UX_Namespace_Generation_LogicalId")
+ .HasFilter("[NamespaceId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "NamespaceUri")
+ .IsUnique()
+ .HasDatabaseName("UX_Namespace_Generation_NamespaceUri");
+
+ b.HasIndex("GenerationId", "ClusterId", "Kind")
+ .IsUnique()
+ .HasDatabaseName("UX_Namespace_Generation_Cluster_Kind");
+
+ b.HasIndex("GenerationId", "NamespaceId", "ClusterId")
+ .IsUnique()
+ .HasDatabaseName("UX_Namespace_Generation_LogicalId_Cluster")
+ .HasFilter("[NamespaceId] IS NOT NULL");
+
+ b.ToTable("Namespace", (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("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("LdapGroup")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NodeAclId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Notes")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("PermissionFlags")
+ .HasColumnType("int");
+
+ b.Property("ScopeId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("ScopeKind")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.HasKey("NodeAclRowId");
+
+ b.HasIndex("GenerationId", "ClusterId")
+ .HasDatabaseName("IX_NodeAcl_Generation_Cluster");
+
+ b.HasIndex("GenerationId", "LdapGroup")
+ .HasDatabaseName("IX_NodeAcl_Generation_Group");
+
+ b.HasIndex("GenerationId", "NodeAclId")
+ .IsUnique()
+ .HasDatabaseName("UX_NodeAcl_Generation_LogicalId")
+ .HasFilter("[NodeAclId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "ScopeKind", "ScopeId")
+ .HasDatabaseName("IX_NodeAcl_Generation_Scope")
+ .HasFilter("[ScopeId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "ClusterId", "LdapGroup", "ScopeKind", "ScopeId")
+ .IsUnique()
+ .HasDatabaseName("UX_NodeAcl_Generation_GroupScope")
+ .HasFilter("[ScopeId] IS NOT NULL");
+
+ b.ToTable("NodeAcl", (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("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("IntervalMs")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("PollGroupId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("PollGroupRowId");
+
+ b.HasIndex("GenerationId", "DriverInstanceId")
+ .HasDatabaseName("IX_PollGroup_Generation_Driver");
+
+ b.HasIndex("GenerationId", "PollGroupId")
+ .IsUnique()
+ .HasDatabaseName("UX_PollGroup_Generation_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.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")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("DriverInstanceId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("EquipmentId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("FolderPath")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("PollGroupId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("TagConfig")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TagId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("WriteIdempotent")
+ .HasColumnType("bit");
+
+ b.HasKey("TagRowId");
+
+ b.HasIndex("GenerationId", "EquipmentId")
+ .HasDatabaseName("IX_Tag_Generation_Equipment")
+ .HasFilter("[EquipmentId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "TagId")
+ .IsUnique()
+ .HasDatabaseName("UX_Tag_Generation_LogicalId")
+ .HasFilter("[TagId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "DriverInstanceId", "DeviceId")
+ .HasDatabaseName("IX_Tag_Generation_Driver_Device");
+
+ b.HasIndex("GenerationId", "EquipmentId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_Tag_Generation_EquipmentPath")
+ .HasFilter("[EquipmentId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "DriverInstanceId", "FolderPath", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_Tag_Generation_FolderPath")
+ .HasFilter("[EquipmentId] IS NULL");
+
+ b.ToTable("Tag", null, t =>
+ {
+ t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1");
+ });
+ });
+
+ 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("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("Notes")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("UnsAreaId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("UnsAreaRowId");
+
+ b.HasIndex("ClusterId");
+
+ b.HasIndex("GenerationId", "ClusterId")
+ .HasDatabaseName("IX_UnsArea_Generation_Cluster");
+
+ b.HasIndex("GenerationId", "UnsAreaId")
+ .IsUnique()
+ .HasDatabaseName("UX_UnsArea_Generation_LogicalId")
+ .HasFilter("[UnsAreaId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "ClusterId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_UnsArea_Generation_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("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("Notes")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("UnsAreaId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("UnsLineId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("UnsLineRowId");
+
+ b.HasIndex("GenerationId", "UnsAreaId")
+ .HasDatabaseName("IX_UnsLine_Generation_Area");
+
+ b.HasIndex("GenerationId", "UnsLineId")
+ .IsUnique()
+ .HasDatabaseName("UX_UnsLine_Generation_LogicalId")
+ .HasFilter("[UnsLineId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "UnsAreaId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_UnsLine_Generation_AreaName");
+
+ b.ToTable("UnsLine", (string)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.ClusterNodeGenerationState", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "CurrentGeneration")
+ .WithMany()
+ .HasForeignKey("CurrentGenerationId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node")
+ .WithOne("GenerationState")
+ .HasForeignKey("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", "NodeId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("CurrentGeneration");
+
+ b.Navigation("Node");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
+ .WithMany("Generations")
+ .HasForeignKey("ClusterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Parent")
+ .WithMany()
+ .HasForeignKey("ParentGenerationId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.Navigation("Cluster");
+
+ b.Navigation("Parent");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Generation");
+ });
+
+ 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.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Cluster");
+
+ b.Navigation("Generation");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Generation");
+ });
+
+ 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.Namespace", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
+ .WithMany("Namespaces")
+ .HasForeignKey("ClusterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Cluster");
+
+ b.Navigation("Generation");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Generation");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Generation");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Generation");
+ });
+
+ 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.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Cluster");
+
+ b.Navigation("Generation");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Generation");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
+ {
+ b.Navigation("Credentials");
+
+ b.Navigation("GenerationState");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b =>
+ {
+ b.Navigation("Generations");
+
+ b.Navigation("Namespaces");
+
+ b.Navigation("Nodes");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.cs
new file mode 100644
index 0000000..568944f
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.cs
@@ -0,0 +1,37 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
+{
+ ///
+ public partial class AddDriverInstanceResilienceConfig : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "ResilienceConfig",
+ table: "DriverInstance",
+ type: "nvarchar(max)",
+ nullable: true);
+
+ migrationBuilder.AddCheckConstraint(
+ name: "CK_DriverInstance_ResilienceConfig_IsJson",
+ table: "DriverInstance",
+ sql: "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropCheckConstraint(
+ name: "CK_DriverInstance_ResilienceConfig_IsJson",
+ table: "DriverInstance");
+
+ migrationBuilder.DropColumn(
+ name: "ResilienceConfig",
+ table: "DriverInstance");
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs
index 0b413d1..b1dd394 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs
@@ -413,6 +413,9 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
+ b.Property("ResilienceConfig")
+ .HasColumnType("nvarchar(max)");
+
b.HasKey("DriverInstanceRowId");
b.HasIndex("ClusterId");
@@ -431,6 +434,8 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
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");
});
});
diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs
index 3c5f360..0a4ac35 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs
@@ -251,6 +251,8 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.DriverInstanceRowId);
e.Property(x => x.DriverInstanceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
@@ -260,6 +262,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.Name).HasMaxLength(128);
e.Property(x => x.DriverType).HasMaxLength(32);
e.Property(x => x.DriverConfig).HasColumnType("nvarchar(max)");
+ e.Property(x => x.ResilienceConfig).HasColumnType("nvarchar(max)");
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs
new file mode 100644
index 0000000..b05b54a
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs
@@ -0,0 +1,116 @@
+using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
+
+///
+/// Parses the DriverInstance.ResilienceConfig JSON column into a
+/// instance layered on top of the tier defaults.
+/// Every key in the JSON is optional; missing keys fall back to the tier defaults from
+/// .
+///
+///
+/// Example JSON shape per Phase 6.1 Stream A.2:
+///
+/// {
+/// "bulkheadMaxConcurrent": 16,
+/// "bulkheadMaxQueue": 64,
+/// "capabilityPolicies": {
+/// "Read": { "timeoutSeconds": 5, "retryCount": 5, "breakerFailureThreshold": 3 },
+/// "Write": { "timeoutSeconds": 5, "retryCount": 0, "breakerFailureThreshold": 5 }
+/// }
+/// }
+///
+///
+/// Unrecognised keys + values are ignored so future shapes land without a migration.
+/// Per-capability overrides are layered on top of tier defaults — a partial policy (only
+/// some of TimeoutSeconds/RetryCount/BreakerFailureThreshold) fills in the other fields
+/// from the tier default for that capability.
+///
+/// Parser failures (malformed JSON, type mismatches) fall back to pure tier defaults
+/// + surface through an out-parameter diagnostic. Callers may log the diagnostic but should
+/// NOT fail driver startup — a misconfigured ResilienceConfig should never brick a
+/// working driver.
+///
+public static class DriverResilienceOptionsParser
+{
+ private static readonly JsonSerializerOptions JsonOpts = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ AllowTrailingCommas = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ };
+
+ ///
+ /// Parse the JSON payload layered on 's defaults. Returns the
+ /// effective options; is null on success, or a
+ /// human-readable error message when the JSON was malformed (options still returned
+ /// = tier defaults).
+ ///
+ public static DriverResilienceOptions ParseOrDefaults(
+ DriverTier tier,
+ string? resilienceConfigJson,
+ out string? parseDiagnostic)
+ {
+ parseDiagnostic = null;
+ var baseDefaults = DriverResilienceOptions.GetTierDefaults(tier);
+ var baseOptions = new DriverResilienceOptions { Tier = tier, CapabilityPolicies = baseDefaults };
+
+ if (string.IsNullOrWhiteSpace(resilienceConfigJson))
+ return baseOptions;
+
+ ResilienceConfigShape? shape;
+ try
+ {
+ shape = JsonSerializer.Deserialize(resilienceConfigJson, JsonOpts);
+ }
+ catch (JsonException ex)
+ {
+ parseDiagnostic = $"ResilienceConfig JSON malformed; falling back to tier {tier} defaults. Detail: {ex.Message}";
+ return baseOptions;
+ }
+
+ if (shape is null) return baseOptions;
+
+ var merged = new Dictionary(baseDefaults);
+ if (shape.CapabilityPolicies is not null)
+ {
+ foreach (var (capName, overridePolicy) in shape.CapabilityPolicies)
+ {
+ if (!Enum.TryParse(capName, ignoreCase: true, out var capability))
+ {
+ parseDiagnostic ??= $"Unknown capability '{capName}' in ResilienceConfig; skipped.";
+ continue;
+ }
+
+ var basePolicy = merged[capability];
+ merged[capability] = new CapabilityPolicy(
+ TimeoutSeconds: overridePolicy.TimeoutSeconds ?? basePolicy.TimeoutSeconds,
+ RetryCount: overridePolicy.RetryCount ?? basePolicy.RetryCount,
+ BreakerFailureThreshold: overridePolicy.BreakerFailureThreshold ?? basePolicy.BreakerFailureThreshold);
+ }
+ }
+
+ return new DriverResilienceOptions
+ {
+ Tier = tier,
+ CapabilityPolicies = merged,
+ BulkheadMaxConcurrent = shape.BulkheadMaxConcurrent ?? baseOptions.BulkheadMaxConcurrent,
+ BulkheadMaxQueue = shape.BulkheadMaxQueue ?? baseOptions.BulkheadMaxQueue,
+ };
+ }
+
+ private sealed class ResilienceConfigShape
+ {
+ public int? BulkheadMaxConcurrent { get; set; }
+ public int? BulkheadMaxQueue { get; set; }
+ public Dictionary? CapabilityPolicies { get; set; }
+ }
+
+ private sealed class CapabilityPolicyShape
+ {
+ public int? TimeoutSeconds { get; set; }
+ public int? RetryCount { get; set; }
+ public int? BreakerFailureThreshold { get; set; }
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs
index 1e49386..cba0b49 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs
@@ -27,6 +27,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
private readonly AuthorizationGate? _authzGate;
private readonly NodeScopeResolver? _scopeResolver;
private readonly StaleConfigFlag? _staleConfigFlag;
+ private readonly Func? _tierLookup;
+ private readonly Func? _resilienceConfigLookup;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger _logger;
private ApplicationInstance? _application;
@@ -39,7 +41,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
DriverResiliencePipelineBuilder? pipelineBuilder = null,
AuthorizationGate? authzGate = null,
NodeScopeResolver? scopeResolver = null,
- StaleConfigFlag? staleConfigFlag = null)
+ StaleConfigFlag? staleConfigFlag = null,
+ Func? tierLookup = null,
+ Func? resilienceConfigLookup = null)
{
_options = options;
_driverHost = driverHost;
@@ -48,6 +52,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
_authzGate = authzGate;
_scopeResolver = scopeResolver;
_staleConfigFlag = staleConfigFlag;
+ _tierLookup = tierLookup;
+ _resilienceConfigLookup = resilienceConfigLookup;
_loggerFactory = loggerFactory;
_logger = logger;
}
@@ -75,7 +81,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
$"OPC UA application certificate could not be validated or created in {_options.PkiStoreRoot}");
_server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory,
- authzGate: _authzGate, scopeResolver: _scopeResolver);
+ authzGate: _authzGate, scopeResolver: _scopeResolver,
+ tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup);
await _application.Start(_server).ConfigureAwait(false);
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs
index f0cbc04..d3c25de 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs
@@ -23,6 +23,8 @@ public sealed class OtOpcUaServer : StandardServer
private readonly DriverResiliencePipelineBuilder _pipelineBuilder;
private readonly AuthorizationGate? _authzGate;
private readonly NodeScopeResolver? _scopeResolver;
+ private readonly Func? _tierLookup;
+ private readonly Func? _resilienceConfigLookup;
private readonly ILoggerFactory _loggerFactory;
private readonly List _driverNodeManagers = new();
@@ -32,13 +34,17 @@ public sealed class OtOpcUaServer : StandardServer
DriverResiliencePipelineBuilder pipelineBuilder,
ILoggerFactory loggerFactory,
AuthorizationGate? authzGate = null,
- NodeScopeResolver? scopeResolver = null)
+ NodeScopeResolver? scopeResolver = null,
+ Func? tierLookup = null,
+ Func? resilienceConfigLookup = null)
{
_driverHost = driverHost;
_authenticator = authenticator;
_pipelineBuilder = pipelineBuilder;
_authzGate = authzGate;
_scopeResolver = scopeResolver;
+ _tierLookup = tierLookup;
+ _resilienceConfigLookup = resilienceConfigLookup;
_loggerFactory = loggerFactory;
}
@@ -59,10 +65,16 @@ public sealed class OtOpcUaServer : StandardServer
if (driver is null) continue;
var logger = _loggerFactory.CreateLogger();
- // Per-driver resilience options: default Tier A pending Stream B.1 which wires
- // per-type tiers into DriverTypeRegistry. Read ResilienceConfig JSON from the
- // DriverInstance row in a follow-up PR; for now every driver gets Tier A defaults.
- var options = new DriverResilienceOptions { Tier = DriverTier.A };
+ // Per-driver resilience options: tier comes from lookup (Phase 6.1 Stream B.1
+ // DriverTypeRegistry in the prod wire-up) or falls back to Tier A. ResilienceConfig
+ // JSON comes from the DriverInstance row via the optional lookup Func; parser
+ // layers JSON overrides on top of tier defaults (Phase 6.1 Stream A.2).
+ var tier = _tierLookup?.Invoke(driver.DriverType) ?? DriverTier.A;
+ var resilienceJson = _resilienceConfigLookup?.Invoke(driver.DriverInstanceId);
+ var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, resilienceJson, out var diag);
+ if (diag is not null)
+ logger.LogWarning("ResilienceConfig parse diagnostic for driver {DriverId}: {Diag}", driver.DriverInstanceId, diag);
+
var invoker = new CapabilityInvoker(_pipelineBuilder, driver.DriverInstanceId, () => options, driver.DriverType);
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
authzGate: _authzGate, scopeResolver: _scopeResolver);
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs
index cc8ae95..dae0bf5 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs
@@ -78,6 +78,7 @@ WHERE i.is_unique = 1 AND i.has_filter = 1;",
"CK_ServerCluster_RedundancyMode_NodeCount",
"CK_Device_DeviceConfig_IsJson",
"CK_DriverInstance_DriverConfig_IsJson",
+ "CK_DriverInstance_ResilienceConfig_IsJson",
"CK_PollGroup_IntervalMs_Min",
"CK_Tag_TagConfig_IsJson",
"CK_ConfigAuditLog_DetailsJson_IsJson",
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs
new file mode 100644
index 0000000..3e97611
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs
@@ -0,0 +1,166 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Resilience;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
+
+[Trait("Category", "Unit")]
+public sealed class DriverResilienceOptionsParserTests
+{
+ [Fact]
+ public void NullJson_ReturnsPureTierDefaults()
+ {
+ var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, null, out var diag);
+
+ diag.ShouldBeNull();
+ options.Tier.ShouldBe(DriverTier.A);
+ options.Resolve(DriverCapability.Read).ShouldBe(
+ DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
+ }
+
+ [Fact]
+ public void WhitespaceJson_ReturnsDefaults()
+ {
+ DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, " ", out var diag);
+ diag.ShouldBeNull();
+ }
+
+ [Fact]
+ public void MalformedJson_FallsBack_WithDiagnostic()
+ {
+ var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, "{not json", out var diag);
+
+ diag.ShouldNotBeNull();
+ diag.ShouldContain("malformed");
+ options.Tier.ShouldBe(DriverTier.A);
+ options.Resolve(DriverCapability.Read).ShouldBe(
+ DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
+ }
+
+ [Fact]
+ public void EmptyObject_ReturnsDefaults()
+ {
+ var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, "{}", out var diag);
+
+ diag.ShouldBeNull();
+ options.Resolve(DriverCapability.Write).ShouldBe(
+ DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
+ }
+
+ [Fact]
+ public void ReadOverride_MergedIntoTierDefaults()
+ {
+ var json = """
+ {
+ "capabilityPolicies": {
+ "Read": { "timeoutSeconds": 5, "retryCount": 7, "breakerFailureThreshold": 2 }
+ }
+ }
+ """;
+
+ var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
+
+ diag.ShouldBeNull();
+ var read = options.Resolve(DriverCapability.Read);
+ read.TimeoutSeconds.ShouldBe(5);
+ read.RetryCount.ShouldBe(7);
+ read.BreakerFailureThreshold.ShouldBe(2);
+
+ // Other capabilities untouched
+ options.Resolve(DriverCapability.Write).ShouldBe(
+ DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
+ }
+
+ [Fact]
+ public void PartialPolicy_FillsMissingFieldsFromTierDefault()
+ {
+ var json = """
+ {
+ "capabilityPolicies": {
+ "Read": { "retryCount": 10 }
+ }
+ }
+ """;
+
+ var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out _);
+
+ var read = options.Resolve(DriverCapability.Read);
+ var tierDefault = DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read];
+ read.RetryCount.ShouldBe(10);
+ read.TimeoutSeconds.ShouldBe(tierDefault.TimeoutSeconds, "partial override; timeout falls back to tier default");
+ read.BreakerFailureThreshold.ShouldBe(tierDefault.BreakerFailureThreshold);
+ }
+
+ [Fact]
+ public void BulkheadOverrides_AreHonored()
+ {
+ var json = """
+ { "bulkheadMaxConcurrent": 100, "bulkheadMaxQueue": 500 }
+ """;
+
+ var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, json, out _);
+
+ options.BulkheadMaxConcurrent.ShouldBe(100);
+ options.BulkheadMaxQueue.ShouldBe(500);
+ }
+
+ [Fact]
+ public void UnknownCapability_Surfaces_InDiagnostic_ButDoesNotFail()
+ {
+ var json = """
+ {
+ "capabilityPolicies": {
+ "InventedCapability": { "timeoutSeconds": 99 }
+ }
+ }
+ """;
+
+ var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
+
+ diag.ShouldNotBeNull();
+ diag.ShouldContain("InventedCapability");
+ // Known capabilities untouched.
+ options.Resolve(DriverCapability.Read).ShouldBe(
+ DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
+ }
+
+ [Fact]
+ public void PropertyNames_AreCaseInsensitive()
+ {
+ var json = """
+ { "BULKHEADMAXCONCURRENT": 42 }
+ """;
+
+ var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out _);
+
+ options.BulkheadMaxConcurrent.ShouldBe(42);
+ }
+
+ [Fact]
+ public void CapabilityName_IsCaseInsensitive()
+ {
+ var json = """
+ { "capabilityPolicies": { "read": { "retryCount": 99 } } }
+ """;
+
+ var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
+
+ diag.ShouldBeNull();
+ options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(99);
+ }
+
+ [Theory]
+ [InlineData(DriverTier.A)]
+ [InlineData(DriverTier.B)]
+ [InlineData(DriverTier.C)]
+ public void EveryTier_WithEmptyJson_RoundTrips_Its_Defaults(DriverTier tier)
+ {
+ var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, "{}", out var diag);
+
+ diag.ShouldBeNull();
+ options.Tier.ShouldBe(tier);
+ foreach (var cap in Enum.GetValues())
+ options.Resolve(cap).ShouldBe(DriverResilienceOptions.GetTierDefaults(tier)[cap]);
+ }
+}