diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5b95a7e1..d63e819e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -35,6 +35,12 @@
+
+
@@ -82,6 +88,8 @@
+
+
diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md
index 5321aca8..eb6985e2 100644
--- a/docs/ScriptEditor.md
+++ b/docs/ScriptEditor.md
@@ -162,11 +162,54 @@ user's fault.
`ForbiddenTypeAnalyzer.Analyze(compilation)` walks the wrapped syntax tree and
resolves every referenced symbol against the sandbox deny-list (namespace
prefixes `System.IO`, `System.Net`, `System.Diagnostics`, `System.Reflection`,
-`System.Threading.Tasks`, etc., plus type-granular denials like
-`System.Environment` and `System.Threading.Thread`). Each violation is mapped
+`System.Threading` — the whole namespace, with only `CancellationToken`,
+`Interlocked` and `Volatile` allowed back in — `System.Threading.Tasks`,
+`System.Runtime.InteropServices`, `System.Runtime.Loader`, `Microsoft.Win32`;
+type-granular denials `System.Environment`, `System.AppDomain`, `System.GC`,
+`System.Activator`, `System.Runtime.CompilerServices.Unsafe` /
+`RuntimeHelpers`; and member-granular denials on `System.Type`'s by-name
+reflection entry points — `GetType(string)`, `GetMethod(s)`, `InvokeMember`,
+and the `GetProperty`/`GetField`/`GetMember`/`GetConstructor`/`GetEvent`
+families). The runtime publish gate uses the same analyzer, so a deny-list
+change needs a `DiagnoseTests` case to keep editor ⇔ publish parity. Each violation is mapped
via `tree.GetMappedLineSpan(span)` to user-source coordinates and emitted as an
error marker with code `OTSCRIPT_FORBIDDEN`.
+### Runtime safety (what happens after publish)
+
+The editor cannot catch a script that compiles cleanly but never returns or
+allocates without bound, so every runtime seam (virtual tags, Calculation tags,
+scripted-alarm predicates) runs scripts through the shared `ScriptRunGuard`:
+
+- **Timeout + one orphan.** A run that exceeds its budget is abandoned (it cannot
+ be pre-empted in-process) and the tag/alarm reads Bad. While that abandoned run
+ is still executing, every further trigger is **refused** — Bad
+ `"previous evaluation still running"`, counted on `otopcua.script.run_refused` —
+ so a `while(true)` costs one pool thread, not one per trigger.
+- **Quarantine after 3 consecutive timeouts** (`Scripting:QuarantineAfterConsecutiveTimeouts`).
+ Only a timeout is a strike: a refusal neither adds to nor resets the streak, and
+ a run that returns (value or throw) resets it. A quarantined script is never
+ dispatched again; it reads Bad `"script quarantined: …"`, logs one Error and
+ increments `otopcua_script_quarantined`.
+- **Stuck-orphan quarantine.** A never-returning script (`while(true)`) times out
+ once and is then only refused, so the timeout streak alone never reaches 3.
+ Instead, a refusal behind an orphan that has been running longer than
+ `Scripting:StuckOrphanQuarantineFactor` × the timeout (default 10 × — 20 s at the
+ 2 s default) quarantines immediately, through the same path (Error log, metric
+ `reason=stuck`, Bad). A short overrun (a young orphan) stays a plain refusal and
+ the script resumes once the orphan completes.
+- **Allocation budget.** A single run allocating more than
+ `Scripting:MaxAllocBytesPerRun` (64 MB) is quarantined. This is sampled after the
+ run returns, so a run that OOMs the process first is not stopped — that needs an
+ out-of-process runner.
+- **Lifting.** A quarantine lifts when the script source changes or on redeploy,
+ never by itself.
+- **Script-log rate limit.** The cluster-wide script-log fan-out admits 20 entries
+ per source per minute; the rest are counted and replaced by one "N suppressed"
+ summary. The summary is emitted on that source's **next** entry after the window
+ rolls, so a source that bursts and then goes quiet shows its summary only when
+ it logs again (the full stream is always in `scripts-*.log`).
+
### 3. DependencyExtractor
`DependencyExtractor.Extract(code).Rejections` enumerates every `ctx.GetTag` /
diff --git a/docs/VirtualTags.md b/docs/VirtualTags.md
index d58013ea..183fcef0 100644
--- a/docs/VirtualTags.md
+++ b/docs/VirtualTags.md
@@ -28,15 +28,15 @@ User scripts are compiled via `Microsoft.CodeAnalysis.CSharp` (regular compiler,
`ScriptSandbox.Build` constructs the compile reference set in two parts. First, four pinned OtOpcUa assemblies are always included: `Core.Abstractions` (for `DataValueSnapshot` / `DriverDataType`), `Core.Scripting` (for `ScriptContext` + `Deadband`), `Serilog` (for `ILogger`), and the concrete context type's assembly. Second, the BCL subset is enumerated from the runtime's `TRUSTED_PLATFORM_ASSEMBLIES` list, restricted to filenames starting with `System.*` plus `netstandard.dll`, `mscorlib.dll`, and `Microsoft.Win32.Registry.dll` (the last needed so `ForbiddenTypeAnalyzer` can resolve and reject registry types). Pre-imported namespaces: `System`, `System.Linq`, `ZB.MOM.WW.OtOpcUa.Core.Abstractions`, `ZB.MOM.WW.OtOpcUa.Core.Scripting`.
-`ForbiddenTypeAnalyzer.ForbiddenNamespacePrefixes` denies `System.IO`, `System.Net`, `System.Diagnostics`, `System.Reflection`, `System.Threading.Tasks`, `System.Runtime.InteropServices`, `System.Runtime.Loader`, and `Microsoft.Win32`. Matching is by prefix against the resolved symbol's containing namespace, so `System.Net` catches `System.Net.Http.HttpClient` and every subnamespace. `System.Threading.Tasks` is denied because scripts are synchronous predicates with no legitimate need to start background tasks — a `Task.Run` fan-out would outlive the per-evaluation timeout entirely (Core.Scripting-003). `System.Runtime.Loader` is denied to block `AssemblyLoadContext` / `AssemblyDependencyResolver` — arbitrary DLL loads into the host process (Core.Scripting-012).
+`ForbiddenTypeAnalyzer.ForbiddenNamespacePrefixes` denies `System.IO`, `System.Net`, `System.Diagnostics`, `System.Reflection`, `System.Threading` (the whole namespace — `ForbiddenTypeAnalyzer.AllowedTypesInForbiddenNamespaces` lets only `CancellationToken`, `Interlocked` and `Volatile` back in), `System.Threading.Tasks`, `System.Runtime.InteropServices`, `System.Runtime.Loader`, and `Microsoft.Win32`. Matching is by prefix against the resolved symbol's containing namespace, so `System.Net` catches `System.Net.Http.HttpClient` and every subnamespace. `System.Threading.Tasks` is denied because scripts are synchronous predicates with no legitimate need to start background tasks — a `Task.Run` fan-out would outlive the per-evaluation timeout entirely (Core.Scripting-003). `System.Runtime.Loader` is denied to block `AssemblyLoadContext` / `AssemblyDependencyResolver` — arbitrary DLL loads into the host process (Core.Scripting-012).
-`ForbiddenTypeAnalyzer.ForbiddenFullTypeNames` denies type-granularly: `System.Environment`, `System.AppDomain`, `System.GC`, `System.Activator`, `System.Threading.Thread`, `System.Threading.ThreadPool`, and `System.Threading.Timer`. These types require granular denial rather than namespace-prefix denial for different reasons: `Environment` / `AppDomain` / `GC` / `Activator` live directly in the `System` namespace (which is otherwise allowed for primitives), so a namespace-prefix rule cannot reach them without blocking `int` / `string` / `Math`; `Thread` / `ThreadPool` / `Timer` live in `System.Threading` (shared with allowed types like `CancellationToken` and `SemaphoreSlim`), so a prefix on `System.Threading` would block those too. `Environment.Exit` / `FailFast` terminate the host process outright (Core.Scripting-001); `Thread` and `ThreadPool` reintroduce background-fanout vectors that `System.Threading.Tasks` denial closed (Core.Scripting-010 / -012).
+`ForbiddenTypeAnalyzer.ForbiddenFullTypeNames` denies type-granularly: `System.Environment`, `System.AppDomain`, `System.GC`, `System.Activator`, `System.Runtime.CompilerServices.Unsafe`, and `System.Runtime.CompilerServices.RuntimeHelpers`. These live in namespaces that are otherwise allowed — `System` (primitives, `Math`) and `System.Runtime.CompilerServices` (compile-time attributes such as `CallerMemberNameAttribute`) — so a namespace-prefix rule cannot reach them. `Environment.Exit` / `FailFast` terminate the host process outright (Core.Scripting-001); `Unsafe.As` bypasses the type system; `RuntimeHelpers.GetUninitializedObject` builds an instance of any type without a constructor. `Thread` / `ThreadPool` / `Timer` / `SemaphoreSlim` and the rest of `System.Threading` are now caught by the namespace prefix above instead of being listed one by one — a script is a synchronous expression with no use for background work or blocking primitives (Core.Scripting-010 / -012, review R05:10). `ForbiddenTypeAnalyzer.ForbiddenMembers` additionally denies `System.Type`'s by-name reflection entry points (`GetType(string)`, `GetMethod(s)`, `InvokeMember`, and the `GetProperty`/`GetField`/`GetMember`/`GetConstructor`/`GetEvent` families), which would otherwise reach a forbidden type without naming it syntactically.
#### Known resource limits (accepted trade-offs)
-The sandbox cannot prevent a script from **allocating unbounded memory**. A script calling `new byte[int.MaxValue]` repeatedly, or accumulating a large LINQ enumeration, can drive the server process to `OutOfMemoryException` before the 250 ms timeout fires. Script authoring is gated behind the Admin permission as the primary control; the test-harness preview (Stream F.4) allows operators to exercise a script before publishing. Out-of-process script execution is a v3 concern.
+The sandbox cannot pre-empt a script. **Memory:** each run's own-thread allocation is sampled (`GC.GetAllocatedBytesForCurrentThread`) and a run over `Scripting:MaxAllocBytesPerRun` (default 64 MB) quarantines the script — but the sample is taken after the run returns, so a script that allocates fast enough (`new byte[int.MaxValue]` in a loop) can still OOM the process first. **CPU:** a tight loop keeps its pool thread after the timeout fires; `ScriptRunGuard` bounds that to one orphan per script by refusing further runs while it spins (Bad `"previous evaluation still running"`) and quarantines after three consecutive timeouts (a refusal is not a strike) — or immediately on a refusal behind an orphan that has run longer than `Scripting:StuckOrphanQuarantineFactor` (default 10) × the timeout, which is what quarantines a never-returning `while(true)`. Both are mitigations; pre-emptive CPU/memory limits need an out-of-process runner. Script authoring is gated behind the Admin permission as the primary control. See `docs/ScriptEditor.md` §"Runtime safety".
-Similarly, **`System.Threading.Tasks` is now denied** (Core.Scripting-003), which prevents `Task.Run` / `Parallel` fan-out that would spawn background work outliving the timeout. However, a tight CPU-bound loop still runs on its thread-pool thread after `WaitAsync` returns — see the `TimedScriptEvaluator` remarks for detail. The orphaned thread is reclaimed when the Roslyn runtime eventually returns; in practice the operator fixes the script once the structured timeout warning appears in `scripts-*.log`.
+`System.Threading.Tasks` is denied (Core.Scripting-003), which prevents `Task.Run` / `Parallel` fan-out that would spawn background work outliving the timeout.
### Compile cache (`CompiledScriptCache`)
diff --git a/docs/security.md b/docs/security.md
index 87ac8d22..fa8269a2 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -6,11 +6,14 @@
> auth + role-grant pages live in `OtOpcUa.AdminUI`), and Admin auth policies
> register from `OtOpcUa.Host/Program.cs` via `AddOtOpcUaAuth`
> (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`) rather
-> than in a separate Admin process. The Admin UI uses a **single Cookie
-> authentication scheme** — there is no `AddJwtBearer` pipeline. The
-> `Security:Jwt` section configures `JwtTokenService`, which mints a JWT at the
-> `/auth/token` endpoint for **external** consumers (OPC UA clients / automation
-> scripts); the cookie itself stores the `ClaimsPrincipal` directly. DataProtection
+> than in a separate Admin process. The Admin UI session is a **Cookie**
+> scheme; the cookie stores the `ClaimsPrincipal` directly. JWT is **opt-in by
+> `Security:Jwt:SigningKey`** (validated at host start, ≥32 bytes): with a key,
+> `/auth/token` mints an HS256 token for a **cookie session only** (a bearer
+> cannot renew itself), and a first-party `AddJwtBearer` scheme (`Bearer`,
+> `ValidAlgorithms = HS256`, parameters from `JwtTokenService.BuildValidationParameters`)
+> accepts it on `/api/*` and every `RequireAuthorization` endpoint through the
+> `OtOpcUa.CookieOrBearer` default policy scheme. Without a key neither exists. DataProtection
> keys persist to the shared Config DB (`PersistKeysToDbContext`)
> so cookies survive failover between admin-role nodes.
>
@@ -245,6 +248,25 @@ with two `Security:ApiKeys` knobs:
a completed lookup (a hit or an authoritative miss) is stored, and a cached hit whose `ExpiresUtc` has
since passed is re-read rather than served, so the TTL can never resurrect an expired key.
+#### Pre-authentication guards (`KnownIdSet` / `MissBurst` / `MissRefillPerSecond`)
+
+The negative cache above is keyed by **key id**, so a flood of *distinct*, well-formed `otua__x`
+credentials missed it every time and cost one indexed SQL query per attempt — under the SDK's
+`SessionManager`-wide lock, with no credential required (review 03 §3.3). `BoundedApiKeyStore` now puts two
+guards in front of the store, bound from the same `Security:ApiKeys` section
+(`ApiKeyStoreGuardOptions`, Host-owned):
+
+| Key | Default | Meaning |
+|---|---|---|
+| `KnownIdSet` | `true` | Keep an in-memory set of every `ApiKeyId` the ConfigDb holds, refreshed every `StoreCacheSeconds` (single-flight; a failed refresh keeps the previous set). An id outside it is refused in memory — `otopcua.opcua.apikey_store.bounded{outcome=unknown_id}` — and the store is never asked. A key minted less than `StoreCacheSeconds` ago is refused until the next refresh (the same window `Enabled` revocation already has). `StoreCacheSeconds = 0` disables the set with the cache. |
+| `MissBurst` | `100` | Token bucket on lookups that **reach** the store and **miss**, for when the set is unavailable (not loaded yet, or the store cannot list ids). Exhausted ⇒ unknown ids are refused without a store read (`{outcome=miss_budget}`, a Warning-free deny through the authenticator's catch-all). A hit refunds its token, so legitimate clients are never charged. `0` disables the budget. |
+| `MissRefillPerSecond` | `20` | Refill rate of the miss bucket. |
+
+The budget is **process-wide, not per remote endpoint**: the OPC UA SDK's impersonation callback (the
+`IOpcUaUserAuthenticator` seam) does not carry the client's address, so per-endpoint accounting needs that
+seam widened in the OpcUaServer project (a Wave 3 follow-up). Neither guard applies to a driver-only node:
+its `ArtifactApiKeyStore` is two in-memory dictionary probes and is never wrapped.
+
### LDAP bind flow (`OtOpcUaLdapAuthService`)
LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs`, `SectionName = "Security:Ldap"`). The app authenticator is `OtOpcUaLdapAuthService` — a thin wrapper around the shared `ZB.MOM.WW.Auth.Ldap` directory client that adds two app-only concerns the shared library deliberately does not model: the `Enabled` master switch and `DevStubMode`. Since #520 it serves the **Admin UI cookie login (`/auth/login`) only** — `LdapOpcUaUserAuthenticator` is deleted and the OPC UA UserName path authenticates API keys instead (see [API-key identity](#api-key-identity-opc-ua-data-plane)).
@@ -1116,6 +1138,31 @@ See `archreview/plans/R3-01-mesh-grpc-allowanonymous-plan.md` (R3-01) for the fu
record, and `docs/Configuration.md` §`MeshTransport` / §`ConfigSource`-`ConfigServe` and `docs/Telemetry.md`
for the affected listener ports.
+### The known-cluster gate is fail-OPEN until central's first ClusterNode load (review 03 §4.2)
+
+`CentralCommandAuthInterceptor` verifies an inbound deploy ack against the PSK of the cluster named in
+`x-otopcua-cluster`. `MeshPskProvider` consults a **known-cluster gate** first — the set of cluster ids
+central read from `ClusterNode` on its last contact refresh — and refuses anything outside it in O(1),
+touching neither the config map nor the secret store. That gate is **inactive until the first successful
+load** (`CentralCommunicationActor` publishes it from `ContactsLoaded`; an empty publication also
+deactivates it, deliberately, so "the DB told me nothing" never becomes a fleet-wide authentication
+outage). During a central boot with the ConfigDb down, therefore:
+
+- an unauthenticated caller can name *any* `x-otopcua-cluster` value and make central perform one bounded
+ (3 s) secret-store read plus one Warning line per request — the cost/oracle the gate exists to remove;
+- **keys are still required**: nothing is admitted without the matching PSK, so this is a cost exposure,
+ not an authentication bypass;
+- the window is bounded by the bootstrap retry (15 × 2 s) and the `ContactRefreshSeconds` (60 s) periodic
+ refresh, and closes on the first load that succeeds. The actor logs the condition on every failed load
+ (*"The known-cluster gate was NOT refreshed either … it is INACTIVE"*).
+
+Since review 03 §3.4 the per-cluster keys are **refreshed asynchronously** from the same handler
+(`IMeshPskProvider.RefreshAsync`: concurrent bounded reads, atomic snapshot swap, a failed read keeps the
+previous key) instead of being invalidated on every refresh, so the first ack per cluster per minute no
+longer pays a synchronous store read on a gRPC thread. Accepted as documented, bounded behaviour; the
+alternative — refusing every cluster until SQL answers — would turn a ConfigDb outage into a deploy-ack
+outage on top of it.
+
---
## OTOPCUA0001 Analyzer — Compile-Time Guard
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Deployment.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Deployment.cs
index 19d83728..d3044244 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Deployment.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Deployment.cs
@@ -26,7 +26,11 @@ public sealed class Deployment
public DateTime CreatedAtUtc { get; init; } = DateTime.UtcNow;
/// Gets or sets the serialized artifact blob containing the configuration.
- public byte[] ArtifactBlob { get; init; } = Array.Empty();
+ ///
+ /// Settable (not init-only) so the retention sweep can EMPTY it on an old terminal row. An empty blob
+ /// is "no artifact" to every reader (#485), so the audit row survives with its bytes retired.
+ ///
+ public byte[] ArtifactBlob { get; set; } = Array.Empty();
/// Gets or sets the row version for optimistic concurrency control.
public byte[] RowVersion { get; set; } = Array.Empty();
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..30873aca 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,29 @@ 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;
+ }
+ }
+
+ // Every composed table is read ORDERED BY ITS NATURAL KEY, server-side. That order IS the deployment
+ // artifact's order (ConfigComposer.Compose serialises the snapshot as given): it is exactly the order the
+ // pre-R03:3.2 composer's own queries produced — the database collation's, case-insensitive on SQL Server —
+ // so the revision hash of an unchanged configuration is byte-identical across the refactor (fix round 1;
+ // pinned by ConfigComposerSqlServerHashStabilityTests). Re-sorting in memory with any other comparer would
+ // re-hash every mixed-case configuration and make drift detection report the whole fleet drifted.
+ private static async Task ReadAsync(OtOpcUaConfigDbContext db, CancellationToken ct)
=> new DraftSnapshot
{
// GenerationId=0 and null Enterprise/Site are intentional conservative fallbacks at the
@@ -36,23 +70,26 @@ 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().OrderBy(x => x.ClusterId).ToListAsync(ct),
+ ClusterNodes = await db.ClusterNodes.AsNoTracking().OrderBy(x => x.NodeId).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).
- RawFolders = await db.RawFolders.AsNoTracking().ToListAsync(ct),
- DriverInstances = await db.DriverInstances.AsNoTracking().ToListAsync(ct),
- Devices = await db.Devices.AsNoTracking().ToListAsync(ct),
- TagGroups = await db.TagGroups.AsNoTracking().ToListAsync(ct),
- UnsAreas = await db.UnsAreas.AsNoTracking().ToListAsync(ct),
- UnsLines = await db.UnsLines.AsNoTracking().ToListAsync(ct),
- Equipment = await db.Equipment.AsNoTracking().ToListAsync(ct),
- Tags = await db.Tags.AsNoTracking().ToListAsync(ct),
- UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct),
- VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
- ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
- Scripts = await db.Scripts.AsNoTracking().ToListAsync(ct),
- PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
- NodeAcls = await db.NodeAcls.AsNoTracking().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),
+ UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct),
+ UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).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),
+ VirtualTags = await db.VirtualTags.AsNoTracking().OrderBy(x => x.VirtualTagId).ToListAsync(ct),
+ ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().OrderBy(x => x.ScriptedAlarmId).ToListAsync(ct),
+ Scripts = await db.Scripts.AsNoTracking().OrderBy(x => x.ScriptId).ToListAsync(ct),
+ PollGroups = await db.PollGroups.AsNoTracking().OrderBy(x => x.PollGroupId).ToListAsync(ct),
+ NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct),
+ ApiKeys = await db.ApiKeys.AsNoTracking().OrderBy(x => x.ApiKeyId).ToListAsync(ct),
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
ActiveReservations = await db.ExternalIdReservations
.AsNoTracking()
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
index 04e08fe9..96a25da5 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
@@ -452,6 +452,58 @@ public static class DraftValidator
"Calculation tags form a dependency cycle (members: " + string.Join(", ", cycle) +
"); a calc→calc cycle is a live oscillation loop and must be broken before deploy",
cycle.Count > 0 ? cycle[0] : null));
+
+ // Rule 3 — effective-trigger gate (R07:U5). A calc tag evaluates on a declared dependency
+ // changing (change-trigger: the literal ctx.GetTag paths) and/or on a timer. A change-triggered
+ // tag whose script has NO literal reads (`return ctx.Now.Hour;`, a dynamic-path read) and no timer
+ // never evaluates at all: it sits BadWaitingForInitialData forever while the driver reports
+ // Healthy, and nothing warned. Same for a tag that is neither change- nor timer-triggered.
+ foreach (var (t, _, scriptId) in calcTags)
+ {
+ var (changeTriggered, timerIntervalMs) = ParseTriggers(t.TagConfig);
+ if (timerIntervalMs is > 0) continue;
+ var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var s) ? s : null;
+ // An unresolved scriptId is already CalculationScriptMissing / CalculationScriptNotFound; with no
+ // source there are no reads to count, so a change-triggered tag would get a second, misleading
+ // "no literal ctx.GetTag read" error. (changeTriggered=false + no timer is source-independent.)
+ if (changeTriggered && source is null) continue;
+ var depCount = EquipmentScriptPaths.ExtractDependencyRefs(source).Count;
+ if (changeTriggered && depCount > 0) continue;
+ errors.Add(new("CalculationNoEffectiveTrigger",
+ changeTriggered
+ ? $"Calculation tag '{t.TagId}' is change-triggered but its script has no literal ctx.GetTag read, " +
+ "and it has no timerIntervalMs — nothing would ever evaluate it. Add a dependency read or a timer."
+ : $"Calculation tag '{t.TagId}' has changeTriggered=false and no timerIntervalMs — nothing would ever evaluate it. " +
+ "Enable the change trigger or add a timer.",
+ t.TagId));
+ }
+ }
+
+ /// Read the trigger fields from a calc tag's TagConfig: changeTriggered
+ /// (default true, the driver's own default) and timerIntervalMs (null when absent or not a
+ /// number). Never throws — malformed JSON yields the defaults.
+ private static (bool ChangeTriggered, int? TimerIntervalMs) ParseTriggers(string? tagConfig)
+ {
+ if (string.IsNullOrWhiteSpace(tagConfig)) return (true, null);
+ try
+ {
+ using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
+ var root = doc.RootElement;
+ if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return (true, null);
+ var changeTriggered = true;
+ if (root.TryGetProperty("changeTriggered", out var ct)
+ && ct.ValueKind is System.Text.Json.JsonValueKind.False or System.Text.Json.JsonValueKind.True)
+ changeTriggered = ct.GetBoolean();
+ int? timer = null;
+ if (root.TryGetProperty("timerIntervalMs", out var tm)
+ && tm.ValueKind == System.Text.Json.JsonValueKind.Number && tm.TryGetInt32(out var ms))
+ timer = ms;
+ return (changeTriggered, timer);
+ }
+ catch (System.Text.Json.JsonException)
+ {
+ return (true, null);
+ }
}
/// Extract the scriptId string from a calc tag's schemaless TagConfig JSON. Never
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/LocalDbStoreAndForwardSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/LocalDbStoreAndForwardSink.cs
index 8a9b9ec4..6d239f75 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/LocalDbStoreAndForwardSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/LocalDbStoreAndForwardSink.cs
@@ -92,6 +92,12 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
private bool? _lastGateDecision;
private long _queuedRowCount;
+ // Cached dead-letter depth (R05:7). GetStatus used to run a synchronous COUNT(*) table scan per
+ // call — one per /healthz probe and per AdminUI poll on the shared LocalDb file. Kept in step by
+ // every path that flips dead_lettered (drain outcomes, corrupt rows, RetryDeadLettered, retention
+ // purge) and resynced from storage on the same cadence as _queuedRowCount, so a row the pair peer
+ // replicates in is visible after the next resync rather than never.
+ private long _deadLetterRowCount;
// Probe counter — incremented every time we actually issue a real COUNT(*) for
// capacity enforcement. Public for test instrumentation only.
private long _capacityProbeCount;
@@ -145,9 +151,16 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
_clock = clock ?? (() => DateTime.UtcNow);
_drainGate = drainGate ?? (static () => true);
- _queuedRowCount = ProbeQueuedRowCount();
+ _queuedRowCount = ProbeRowCount(deadLettered: false);
+ _deadLetterRowCount = ProbeRowCount(deadLettered: true);
}
+ /// Test-only: re-sync both cached counters (queued + dead-lettered) from storage — the same
+ /// resync the enqueue cadence drives every enqueues.
+ /// Cancellation token.
+ /// A task that completes once both counters match storage.
+ public Task DebugResyncCountersAsync(CancellationToken ct) => ResyncRowCountsAsync(ct);
+
///
/// Start the background drain worker. Not started automatically so tests can
/// drive deterministically.
@@ -260,7 +273,7 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
// has a second way to fall behind that no local code path can observe.
if (enqueuesSinceResync >= ResyncEnqueueInterval)
{
- await ResyncQueuedRowCountAsync(ct).ConfigureAwait(false);
+ await ResyncRowCountsAsync(ct).ConfigureAwait(false);
cached = Interlocked.Read(ref _queuedRowCount);
Interlocked.Exchange(ref _enqueuesSinceResync, 0);
}
@@ -273,22 +286,26 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
await EnforceCapacityAsync(ct).ConfigureAwait(false);
}
- /// Synchronously query COUNT(*) of non-dead-lettered rows. Used at startup.
- private long ProbeQueuedRowCount()
+ /// Synchronously query COUNT(*) of queued or dead-lettered rows. Used at startup.
+ private long ProbeRowCount(bool deadLettered)
{
Interlocked.Increment(ref _capacityProbeCount);
using var conn = _db.CreateConnection();
using var cmd = conn.CreateCommand();
- cmd.CommandText = "SELECT COUNT(*) FROM alarm_sf_events WHERE dead_lettered = 0";
+ cmd.CommandText = deadLettered
+ ? "SELECT COUNT(*) FROM alarm_sf_events WHERE dead_lettered = 1"
+ : "SELECT COUNT(*) FROM alarm_sf_events WHERE dead_lettered = 0";
return (long)(cmd.ExecuteScalar() ?? 0L);
}
- /// Re-sync the in-memory counter from storage (async path).
- private async Task ResyncQueuedRowCountAsync(CancellationToken ct)
+ /// Re-sync both in-memory counters from storage (async path).
+ private async Task ResyncRowCountsAsync(CancellationToken ct)
{
Interlocked.Increment(ref _capacityProbeCount);
var live = await CountAsync("dead_lettered = 0", ct).ConfigureAwait(false);
Interlocked.Exchange(ref _queuedRowCount, live);
+ var dead = await CountAsync("dead_lettered = 1", ct).ConfigureAwait(false);
+ Interlocked.Exchange(ref _deadLetterRowCount, dead);
}
private async Task CountAsync(string predicate, CancellationToken ct)
@@ -356,6 +373,7 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
await corruptTx.CommitAsync(ct).ConfigureAwait(false);
}
Interlocked.Add(ref _queuedRowCount, -corruptIds.Count);
+ Interlocked.Add(ref _deadLetterRowCount, corruptIds.Count);
_logger.Warning(
"Dead-lettered {Count} historian queue row(s) with un-deserializable payload",
corruptIds.Count);
@@ -405,6 +423,7 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
}
int rowsLeavingQueue = 0;
+ int rowsDeadLettered = 0;
await using (var tx = await _db.BeginTransactionAsync(ct).ConfigureAwait(false))
{
for (var i = 0; i < outcomes.Count; i++)
@@ -423,6 +442,7 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
case HistorianWriteOutcome.PermanentFail:
await DeadLetterRowAsync(tx, id, $"permanent fail at {_clock():O}", ct).ConfigureAwait(false);
rowsLeavingQueue++;
+ rowsDeadLettered++;
break;
case HistorianWriteOutcome.RetryPlease:
// finding 002: cap retries so a perpetually-RetryPlease (poison)
@@ -434,6 +454,7 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
{
await DeadLetterRowAsync(tx, id, $"max attempts ({_maxAttempts}) exceeded", ct).ConfigureAwait(false);
rowsLeavingQueue++;
+ rowsDeadLettered++;
}
else
{
@@ -449,6 +470,8 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
// cap (finding 002) — keep the counter aligned.
if (rowsLeavingQueue > 0)
Interlocked.Add(ref _queuedRowCount, -rowsLeavingQueue);
+ if (rowsDeadLettered > 0)
+ Interlocked.Add(ref _deadLetterRowCount, rowsDeadLettered);
var acks = outcomes.Count(o => o == HistorianWriteOutcome.Ack);
lock (_statusLock)
@@ -523,13 +546,9 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
var queued = Interlocked.Read(ref _queuedRowCount);
if (queued < 0) queued = 0;
- long deadlettered;
- using (var conn = _db.CreateConnection())
- using (var cmd = conn.CreateCommand())
- {
- cmd.CommandText = "SELECT COUNT(*) FROM alarm_sf_events WHERE dead_lettered = 1";
- deadlettered = (long)(cmd.ExecuteScalar() ?? 0L);
- }
+ // Cached, never a live scan (R05:7) — see the field's remarks for the resync cadence.
+ var deadlettered = Interlocked.Read(ref _deadLetterRowCount);
+ if (deadlettered < 0) deadlettered = 0;
DateTime? lastDrain, lastSuccess;
string? lastError;
@@ -568,7 +587,11 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
var revived = cmd.ExecuteNonQuery();
// Dead-lettered rows rejoin the non-dead-lettered queue — keep the in-memory
// counter aligned.
- if (revived > 0) Interlocked.Add(ref _queuedRowCount, revived);
+ if (revived > 0)
+ {
+ Interlocked.Add(ref _queuedRowCount, revived);
+ Interlocked.Add(ref _deadLetterRowCount, -revived);
+ }
return revived;
}
@@ -646,8 +669,9 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
Interlocked.Increment(ref _capacityProbeCount);
var count = await CountAsync("dead_lettered = 0", ct).ConfigureAwait(false);
- // Resync the in-memory counter while we have a fresh number.
+ // Resync the in-memory counters while we have a fresh number.
Interlocked.Exchange(ref _queuedRowCount, count);
+ Interlocked.Exchange(ref _deadLetterRowCount, await CountAsync("dead_lettered = 1", ct).ConfigureAwait(false));
if (count < _capacity) return;
var toEvict = count - _capacity + 1;
@@ -684,7 +708,10 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
ct).ConfigureAwait(false);
if (purged > 0)
+ {
+ Interlocked.Add(ref _deadLetterRowCount, -purged);
_logger.Information("Purged {Count} dead-lettered row(s) past retention window", purged);
+ }
}
private void BumpBackoff() => _backoffIndex = Math.Min(_backoffIndex + 1, BackoffLadder.Length - 1);
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs
index 4ccc3592..17b513ac 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs
@@ -73,6 +73,14 @@ public sealed class ScriptedAlarmEngine : IDisposable
private readonly Func _clock;
private readonly TimeSpan _scriptTimeout;
+ ///
+ /// Per-alarm run quarantine (keyed by alarm id, source identity = predicate source). Refuses a
+ /// predicate run while the alarm's previous (timed-out, abandoned) run is still executing and
+ /// quarantines after three consecutive timeouts (a refusal is not a strike unless its orphan is stuck past StuckOrphanQuarantineFactor × the budget) or an allocation-budget breach — one orphan
+ /// per alarm instead of one per upstream push. Reset on every (a redeploy).
+ ///
+ private readonly ScriptRunGuard _guard;
+
// ConcurrentDictionary, not a plain Dictionary: every mutation happens under
// _evalGate, but four read paths (GetState, GetAllStates, LoadedAlarmIds,
// RunShelvingCheck) touch _alarms from arbitrary threads (Admin UI request
@@ -162,6 +170,33 @@ public sealed class ScriptedAlarmEngine : IDisposable
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.Context : null;
private readonly ConcurrentDictionary _valueCache
= new(StringComparer.Ordinal);
+
+ /// Test hook — the number of paths currently held in the value cache.
+ internal int ValueCacheCountForTest => _valueCache.Count;
+
+ /// Test hook — whether is currently held in the value cache.
+ internal bool ValueCacheContainsForTest(string path) => _valueCache.ContainsKey(path);
+
+ ///
+ /// How long waits for in-flight background work (a re-evaluation or shelving
+ /// check mid-SaveAsync) before cancelling it through and
+ /// returning. 5 s, like PollGroupEngine's drain; a hung store must not hang host shutdown.
+ ///
+ internal TimeSpan DisposeDrainTimeout { get; set; } = TimeSpan.FromSeconds(5);
+
+ /// Cancelled by once the drain bound expires — every background task
+ /// (DrainDirtyAsync, ShelvingCheckAsync) runs on its token, so a store call that honours
+ /// cancellation returns instead of outliving the engine.
+ private readonly CancellationTokenSource _shutdownCts = new();
+
+ /// 's token, captured once: background work reads this, never the CTS,
+ /// so can dispose the CTS without racing a late reader.
+ private readonly CancellationToken _shutdownToken;
+
+ private volatile bool _shutdownCtsDisposed;
+
+ /// Test hook — whether disposed .
+ internal bool ShutdownCtsDisposedForTest => _shutdownCtsDisposed;
private readonly Dictionary> _alarmsReferencing
= new(StringComparer.Ordinal); // tag path -> alarm ids
@@ -204,13 +239,15 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// The logger for engine-level diagnostics.
/// Optional function providing the current UTC time; defaults to DateTime.UtcNow.
/// Optional timeout for script execution; defaults to the evaluator's default timeout.
+ /// Run-guard budget + quarantine threshold; null ⇒ defaults.
public ScriptedAlarmEngine(
ITagUpstreamSource upstream,
IAlarmStateStore store,
ScriptLoggerFactory loggerFactory,
ILogger engineLogger,
Func? clock = null,
- TimeSpan? scriptTimeout = null)
+ TimeSpan? scriptTimeout = null,
+ ScriptingOptions? scripting = null)
{
_upstream = upstream ?? throw new ArgumentNullException(nameof(upstream));
_store = store ?? throw new ArgumentNullException(nameof(store));
@@ -218,8 +255,21 @@ public sealed class ScriptedAlarmEngine : IDisposable
_engineLogger = engineLogger ?? throw new ArgumentNullException(nameof(engineLogger));
_clock = clock ?? (() => DateTime.UtcNow);
_scriptTimeout = scriptTimeout ?? TimedScriptEvaluator.DefaultTimeout;
+ _guard = new ScriptRunGuard(scripting, _engineLogger);
+ _shutdownToken = _shutdownCts.Token;
}
+ /// The run guard every predicate evaluation goes through (diagnostics / tests).
+ public ScriptRunGuard RunGuard => _guard;
+
+ ///
+ /// OPC UA BadInternalError — the quality a condition is annotated with while its predicate
+ /// cannot be evaluated (timed out, refused because the previous run is still executing, or
+ /// quarantined). The condition's state is frozen; the quality says why it cannot be trusted. The
+ /// next completed evaluation restores the input-derived quality through the normal bucket tracking.
+ ///
+ private const uint StatusBadInternalError = 0x80020000u;
+
/// Raised for every emission the Part9StateMachine produces that the engine should publish.
public event EventHandler? OnEvent;
@@ -259,6 +309,10 @@ public sealed class ScriptedAlarmEngine : IDisposable
UnsubscribeFromUpstream();
_alarms.Clear();
_alarmsReferencing.Clear();
+ // The value cache is re-seeded below for exactly the paths this generation references. It
+ // used to survive reloads untouched, so every path a retired alarm had ever referenced stayed
+ // cached forever — a slow leak over many deploys with a churning tag set.
+ _valueCache.Clear();
// Drop the prior generation's per-alarm scratch buffers — definitions may
// have changed (different Inputs, different Logger), so any reuse would be
// unsafe.
@@ -268,6 +322,9 @@ public sealed class ScriptedAlarmEngine : IDisposable
// recompile this one. Skipping this is what made the earlier fix a
// no-op in production.
_compileCache.Clear();
+ // A (re)load is a deploy: lift every run-guard quarantine (an edited predicate already lifts
+ // its own through the guard's source identity; this covers "redeployed to retry").
+ _guard.ResetAll();
var compileFailures = new List();
foreach (var def in definitions)
@@ -284,15 +341,17 @@ public sealed class ScriptedAlarmEngine : IDisposable
// Route through CompiledScriptCache so the emitted assembly's
// collectible ALC participates in publish-replace cleanup.
+ // The compiled script is run through _guard (timeout + quarantine) at evaluation time —
+ // the per-alarm TimedScriptEvaluator this used to wrap gave the timeout but could not
+ // see that the run it abandoned was still executing.
var evaluator = _compileCache.GetOrCompile(def.PredicateScriptSource);
- var timed = new TimedScriptEvaluator(evaluator, _scriptTimeout);
var logger = _loggerFactory.Create(def.AlarmId);
var templateTokens = MessageTemplate.ExtractTokenPaths(def.MessageTemplate);
var allInputs = new HashSet(extraction.Reads, StringComparer.Ordinal);
foreach (var t in templateTokens) allInputs.Add(t);
- _alarms[def.AlarmId] = new AlarmState(def, timed, extraction.Reads, templateTokens, logger,
+ _alarms[def.AlarmId] = new AlarmState(def, evaluator, extraction.Reads, templateTokens, logger,
AlarmConditionState.Fresh(def.AlarmId, _clock()));
foreach (var path in allInputs)
@@ -546,8 +605,8 @@ public sealed class ScriptedAlarmEngine : IDisposable
///