Files
lmxopcua/docs/v2/config-db-schema.md

28 KiB

Central Config DB Schema — OtOpcUa v2

Status: DRAFT — companion to plan.md §4. Concrete schema, indexes, stored procedures, and authorization model for the central MSSQL configuration database.

Branch: v2 Created: 2026-04-17

Scope

This document defines the central MSSQL database that stores all OtOpcUa fleet configuration: clusters, nodes, drivers, devices, tags, poll groups, credentials, and config generations. It is the single source of truth for fleet management — every running OtOpcUa node reads its config from here, and every operator change goes through here.

Out of scope here (covered elsewhere):

  • The Admin web UI that edits this DB → admin-ui.md
  • The local LiteDB cache on each node → covered briefly at the end of this doc; full schema is small and tracks only what's needed for offline boot
  • Driver-specific JSON shapes inside DriverConfig / DeviceConfig / TagConfigdriver-specs.md per driver
  • The cluster topology and rollout model → plan.md §4

Design Goals

  1. Atomic publish, surgical apply — operators publish a whole generation in one transaction; nodes apply only the diff
  2. Cluster-scoped isolation — one cluster's config changes never affect another cluster
  3. Per-node credential binding — each physical node has its own auth principal; the DB rejects cross-cluster reads server-side
  4. Schemaless driver config — driver-type-specific settings live in JSON columns so adding a new driver type doesn't require a schema migration
  5. Append-only generations — old generations are never deleted; rollback is just publishing an older generation as new
  6. Auditable — every publish, rollback, and apply event is recorded with the principal that did it

Schema Overview

ServerCluster (1)──(1..2) ClusterNode (1)──(1..N) ClusterNodeCredential
       │
       └──(1)──(N) ConfigGeneration ──(N)── DriverInstance ──(N)── Device ──(N)── Tag
                                                       │                     │
                                                       │                     └──(N)── PollGroup
                                                       │
                                                       └──(N)── PollGroup (driver-scoped)

ClusterNodeGenerationState (1:1 ClusterNode) — tracks applied generation per node
ConfigAuditLog                              — append-only event log

Table Definitions

All Json columns use nvarchar(max) with a CHECK (ISJSON(col) = 1) constraint. Timestamps are datetime2(3) UTC. PKs use uniqueidentifier (sequential GUIDs) unless noted; logical IDs (ClusterId, NodeId, DriverInstanceId, TagId) are nvarchar(64) for human readability.

ServerCluster

CREATE TABLE dbo.ServerCluster (
    ClusterId           nvarchar(64)    NOT NULL PRIMARY KEY,
    Name                nvarchar(128)   NOT NULL,
    Site                nvarchar(64)    NULL,           -- grouping for fleet management
    NodeCount           tinyint         NOT NULL CHECK (NodeCount IN (1, 2)),
    RedundancyMode      nvarchar(16)    NOT NULL CHECK (RedundancyMode IN ('None', 'Warm', 'Hot')),
    NamespaceUri        nvarchar(256)   NOT NULL,       -- shared by both nodes
    Enabled             bit             NOT NULL DEFAULT 1,
    Notes               nvarchar(1024)  NULL,
    CreatedAt           datetime2(3)    NOT NULL DEFAULT SYSUTCDATETIME(),
    CreatedBy           nvarchar(128)   NOT NULL,
    ModifiedAt          datetime2(3)    NULL,
    ModifiedBy          nvarchar(128)   NULL,
    CONSTRAINT CK_ServerCluster_RedundancyMode_NodeCount
        CHECK ((NodeCount = 1 AND RedundancyMode = 'None')
            OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))
);

CREATE UNIQUE INDEX UX_ServerCluster_Name ON dbo.ServerCluster (Name);
CREATE INDEX IX_ServerCluster_Site ON dbo.ServerCluster (Site) WHERE Site IS NOT NULL;

ClusterNode

CREATE TABLE dbo.ClusterNode (
    NodeId                          nvarchar(64)    NOT NULL PRIMARY KEY,
    ClusterId                       nvarchar(64)    NOT NULL FOREIGN KEY REFERENCES dbo.ServerCluster(ClusterId),
    RedundancyRole                  nvarchar(16)    NOT NULL CHECK (RedundancyRole IN ('Primary', 'Secondary', 'Standalone')),
    Host                            nvarchar(255)   NOT NULL,
    OpcUaPort                       int             NOT NULL DEFAULT 4840,
    DashboardPort                   int             NOT NULL DEFAULT 8081,
    ApplicationUri                  nvarchar(256)   NOT NULL,
    ServiceLevelBase                tinyint         NOT NULL DEFAULT 200,
    DriverConfigOverridesJson       nvarchar(max)   NULL CHECK (DriverConfigOverridesJson IS NULL OR ISJSON(DriverConfigOverridesJson) = 1),
    Enabled                         bit             NOT NULL DEFAULT 1,
    LastSeenAt                      datetime2(3)    NULL,
    CreatedAt                       datetime2(3)    NOT NULL DEFAULT SYSUTCDATETIME(),
    CreatedBy                       nvarchar(128)   NOT NULL
);

-- ApplicationUri uniqueness is FLEET-WIDE, not per-cluster (per plan.md decision #86)
CREATE UNIQUE INDEX UX_ClusterNode_ApplicationUri ON dbo.ClusterNode (ApplicationUri);
CREATE INDEX IX_ClusterNode_ClusterId ON dbo.ClusterNode (ClusterId);

-- Each cluster has at most one Primary
CREATE UNIQUE INDEX UX_ClusterNode_Primary_Per_Cluster
    ON dbo.ClusterNode (ClusterId)
    WHERE RedundancyRole = 'Primary';

DriverConfigOverridesJson shape:

{
  "<DriverInstanceId>": {
    "<JSON path within DriverConfig>": "<override value>"
  },
  // Example:
  "GalaxyMain": {
    "MxAccess.ClientName": "OtOpcUa-NodeB"
  }
}

The merge happens at apply time on the node — cluster-level DriverConfig is read, then this node's overrides are layered on top using JSON-pointer or simple key-path semantics. Tags and devices have no per-node override path.

ClusterNodeCredential

CREATE TABLE dbo.ClusterNodeCredential (
    CredentialId        uniqueidentifier    NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
    NodeId              nvarchar(64)        NOT NULL FOREIGN KEY REFERENCES dbo.ClusterNode(NodeId),
    Kind                nvarchar(32)        NOT NULL CHECK (Kind IN ('SqlLogin', 'ClientCertThumbprint', 'ADPrincipal', 'gMSA')),
    Value               nvarchar(512)       NOT NULL,   -- login name, cert thumbprint, SID, etc.
    Enabled             bit                 NOT NULL DEFAULT 1,
    RotatedAt           datetime2(3)        NULL,
    CreatedAt           datetime2(3)        NOT NULL DEFAULT SYSUTCDATETIME(),
    CreatedBy           nvarchar(128)       NOT NULL
);

CREATE INDEX IX_ClusterNodeCredential_NodeId ON dbo.ClusterNodeCredential (NodeId, Enabled);
CREATE UNIQUE INDEX UX_ClusterNodeCredential_Value ON dbo.ClusterNodeCredential (Kind, Value) WHERE Enabled = 1;

A node may have multiple enabled credentials simultaneously (e.g. during cert rotation: old + new both valid for a window). Disabled rows are kept for audit.

ConfigGeneration

CREATE TABLE dbo.ConfigGeneration (
    GenerationId            bigint              NOT NULL PRIMARY KEY IDENTITY(1, 1),
    ClusterId               nvarchar(64)        NOT NULL FOREIGN KEY REFERENCES dbo.ServerCluster(ClusterId),
    Status                  nvarchar(16)        NOT NULL CHECK (Status IN ('Draft', 'Published', 'Superseded', 'RolledBack')),
    ParentGenerationId      bigint              NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId),
    PublishedAt             datetime2(3)        NULL,
    PublishedBy             nvarchar(128)       NULL,
    Notes                   nvarchar(1024)      NULL,
    CreatedAt               datetime2(3)        NOT NULL DEFAULT SYSUTCDATETIME(),
    CreatedBy               nvarchar(128)       NOT NULL
);

-- Fast lookup of "latest published generation for cluster X" (the per-node poll path)
CREATE INDEX IX_ConfigGeneration_Cluster_Published
    ON dbo.ConfigGeneration (ClusterId, Status, GenerationId DESC)
    INCLUDE (PublishedAt);

-- One Draft per cluster at a time (prevents accidental concurrent edits)
CREATE UNIQUE INDEX UX_ConfigGeneration_Draft_Per_Cluster
    ON dbo.ConfigGeneration (ClusterId)
    WHERE Status = 'Draft';

Status transitions: Draft → Published → Superseded (when a newer generation is published) or Draft → Published → RolledBack (when explicitly rolled back). No transition skips Published.

DriverInstance

CREATE TABLE dbo.DriverInstance (
    DriverInstanceRowId     uniqueidentifier    NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
    GenerationId            bigint              NOT NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId),
    DriverInstanceId        nvarchar(64)        NOT NULL,   -- stable logical ID across generations
    ClusterId               nvarchar(64)        NOT NULL FOREIGN KEY REFERENCES dbo.ServerCluster(ClusterId),
    Name                    nvarchar(128)       NOT NULL,
    DriverType              nvarchar(32)        NOT NULL,   -- Galaxy | ModbusTcp | AbCip | AbLegacy | S7 | TwinCat | Focas | OpcUaClient
    NamespaceUri            nvarchar(256)       NOT NULL,   -- per-driver namespace within the cluster's URI scope
    Enabled                 bit                 NOT NULL DEFAULT 1,
    DriverConfig            nvarchar(max)       NOT NULL CHECK (ISJSON(DriverConfig) = 1)
);

CREATE INDEX IX_DriverInstance_Generation_Cluster
    ON dbo.DriverInstance (GenerationId, ClusterId);
CREATE UNIQUE INDEX UX_DriverInstance_Generation_LogicalId
    ON dbo.DriverInstance (GenerationId, DriverInstanceId);
CREATE UNIQUE INDEX UX_DriverInstance_Generation_NamespaceUri
    ON dbo.DriverInstance (GenerationId, NamespaceUri);

Device

CREATE TABLE dbo.Device (
    DeviceRowId         uniqueidentifier    NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
    GenerationId        bigint              NOT NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId),
    DeviceId            nvarchar(64)        NOT NULL,
    DriverInstanceId    nvarchar(64)        NOT NULL,
    Name                nvarchar(128)       NOT NULL,
    Enabled             bit                 NOT NULL DEFAULT 1,
    DeviceConfig        nvarchar(max)       NOT NULL CHECK (ISJSON(DeviceConfig) = 1)
);

CREATE INDEX IX_Device_Generation_Driver
    ON dbo.Device (GenerationId, DriverInstanceId);
CREATE UNIQUE INDEX UX_Device_Generation_LogicalId
    ON dbo.Device (GenerationId, DeviceId);

The FK to DriverInstance is logical (matched by GenerationId + DriverInstanceId in app code), not declared as a SQL FK — declaring it would require composite FKs that are awkward when generations are immutable. The publish stored procedure validates referential integrity before flipping Status.

Tag

CREATE TABLE dbo.Tag (
    TagRowId            uniqueidentifier    NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
    GenerationId        bigint              NOT NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId),
    TagId               nvarchar(64)        NOT NULL,
    DriverInstanceId    nvarchar(64)        NOT NULL,
    DeviceId            nvarchar(64)        NULL,           -- null for driver-scoped tags (no device layer)
    Name                nvarchar(128)       NOT NULL,
    FolderPath          nvarchar(512)       NOT NULL,       -- address space hierarchy
    DataType            nvarchar(32)        NOT NULL,       -- OPC UA built-in type name (Boolean, Int32, Float, etc.)
    AccessLevel         nvarchar(16)        NOT NULL CHECK (AccessLevel IN ('Read', 'ReadWrite')),
    WriteIdempotent     bit                 NOT NULL DEFAULT 0,
    PollGroupId         nvarchar(64)        NULL,
    TagConfig           nvarchar(max)       NOT NULL CHECK (ISJSON(TagConfig) = 1)
);

CREATE INDEX IX_Tag_Generation_Driver_Device
    ON dbo.Tag (GenerationId, DriverInstanceId, DeviceId);
CREATE UNIQUE INDEX UX_Tag_Generation_LogicalId
    ON dbo.Tag (GenerationId, TagId);
CREATE UNIQUE INDEX UX_Tag_Generation_Path
    ON dbo.Tag (GenerationId, DriverInstanceId, FolderPath, Name);

PollGroup

CREATE TABLE dbo.PollGroup (
    PollGroupRowId      uniqueidentifier    NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
    GenerationId        bigint              NOT NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId),
    PollGroupId         nvarchar(64)        NOT NULL,
    DriverInstanceId    nvarchar(64)        NOT NULL,
    Name                nvarchar(128)       NOT NULL,
    IntervalMs          int                 NOT NULL CHECK (IntervalMs >= 50)
);

CREATE INDEX IX_PollGroup_Generation_Driver
    ON dbo.PollGroup (GenerationId, DriverInstanceId);
CREATE UNIQUE INDEX UX_PollGroup_Generation_LogicalId
    ON dbo.PollGroup (GenerationId, PollGroupId);

ClusterNodeGenerationState

CREATE TABLE dbo.ClusterNodeGenerationState (
    NodeId                  nvarchar(64)    NOT NULL PRIMARY KEY FOREIGN KEY REFERENCES dbo.ClusterNode(NodeId),
    CurrentGenerationId     bigint          NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId),
    LastAppliedAt           datetime2(3)    NULL,
    LastAppliedStatus       nvarchar(16)    NULL CHECK (LastAppliedStatus IN ('Applied', 'RolledBack', 'Failed', 'InProgress')),
    LastAppliedError        nvarchar(2048)  NULL,
    LastSeenAt              datetime2(3)    NULL            -- updated on every poll, for liveness
);

CREATE INDEX IX_ClusterNodeGenerationState_Generation
    ON dbo.ClusterNodeGenerationState (CurrentGenerationId);

A 2-node cluster with both nodes on the same CurrentGenerationId is "converged"; nodes on different generations are "applying" or "diverged" — Admin surfaces this directly.

ConfigAuditLog

CREATE TABLE dbo.ConfigAuditLog (
    AuditId             bigint              NOT NULL PRIMARY KEY IDENTITY(1, 1),
    Timestamp           datetime2(3)        NOT NULL DEFAULT SYSUTCDATETIME(),
    Principal           nvarchar(128)       NOT NULL,       -- DB principal that performed the action
    EventType           nvarchar(64)        NOT NULL,       -- DraftCreated, DraftEdited, Published, RolledBack, NodeApplied, CredentialAdded, CredentialDisabled, ClusterCreated, NodeAdded, etc.
    ClusterId           nvarchar(64)        NULL,
    NodeId              nvarchar(64)        NULL,
    GenerationId        bigint              NULL,
    DetailsJson         nvarchar(max)       NULL CHECK (DetailsJson IS NULL OR ISJSON(DetailsJson) = 1)
);

CREATE INDEX IX_ConfigAuditLog_Cluster_Time
    ON dbo.ConfigAuditLog (ClusterId, Timestamp DESC);
CREATE INDEX IX_ConfigAuditLog_Generation
    ON dbo.ConfigAuditLog (GenerationId) WHERE GenerationId IS NOT NULL;

Append-only by convention (no UPDATE/DELETE permissions granted to any principal); enforced by GRANT model below.

Stored Procedures

All non-trivial DB access goes through stored procedures. Direct table SELECT/INSERT/UPDATE/DELETE is not granted to node or admin principals — only the procs are callable. This is the enforcement point for the authorization model.

sp_GetCurrentGenerationForCluster (called by node)

-- @NodeId: passed by the calling node; verified against authenticated principal
-- @ClusterId: passed by the calling node; verified to match @NodeId's cluster
-- Returns: latest Published generation for the cluster, or NULL if none
CREATE PROCEDURE dbo.sp_GetCurrentGenerationForCluster
    @NodeId     nvarchar(64),
    @ClusterId  nvarchar(64)
AS
BEGIN
    SET NOCOUNT ON;

    -- 1. Authenticate: verify the calling principal is bound to @NodeId
    DECLARE @CallerPrincipal nvarchar(128) = SUSER_SNAME();
    IF NOT EXISTS (
        SELECT 1 FROM dbo.ClusterNodeCredential
        WHERE NodeId = @NodeId
          AND Value = @CallerPrincipal
          AND Enabled = 1
    )
    BEGIN
        RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @CallerPrincipal, @NodeId);
        RETURN;
    END

    -- 2. Authorize: verify @NodeId belongs to @ClusterId
    IF NOT EXISTS (
        SELECT 1 FROM dbo.ClusterNode
        WHERE NodeId = @NodeId AND ClusterId = @ClusterId AND Enabled = 1
    )
    BEGIN
        RAISERROR('Forbidden: NodeId %s does not belong to ClusterId %s', 16, 1, @NodeId, @ClusterId);
        RETURN;
    END

    -- 3. Return latest Published generation
    SELECT TOP 1 GenerationId, PublishedAt, PublishedBy, Notes
    FROM dbo.ConfigGeneration
    WHERE ClusterId = @ClusterId AND Status = 'Published'
    ORDER BY GenerationId DESC;
END

Companion procs: sp_GetGenerationContent (returns full generation rows for a given GenerationId, with the same auth checks) and sp_RegisterNodeGenerationApplied (node reports back which generation it has now applied + status).

sp_PublishGeneration (called by Admin)

-- Atomic: validates the draft, computes diff vs. previous Published, flips Status
CREATE PROCEDURE dbo.sp_PublishGeneration
    @ClusterId      nvarchar(64),
    @DraftGenerationId bigint,
    @Notes          nvarchar(1024) = NULL
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;
    BEGIN TRANSACTION;

    -- 1. Verify caller is an admin (separate authz check vs. node auth)
    -- 2. Validate Draft: foreign keys resolve, no orphan tags, JSON columns parse, etc.
    --    EXEC sp_ValidateDraft @DraftGenerationId; — raises on failure
    -- 3. Mark previous Published as Superseded
    UPDATE dbo.ConfigGeneration
    SET Status = 'Superseded'
    WHERE ClusterId = @ClusterId AND Status = 'Published';
    -- 4. Promote Draft to Published
    UPDATE dbo.ConfigGeneration
    SET Status = 'Published',
        PublishedAt = SYSUTCDATETIME(),
        PublishedBy = SUSER_SNAME(),
        Notes = ISNULL(@Notes, Notes)
    WHERE GenerationId = @DraftGenerationId AND ClusterId = @ClusterId;
    -- 5. Audit log
    INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
    VALUES (SUSER_SNAME(), 'Published', @ClusterId, @DraftGenerationId);

    COMMIT;
END

sp_RollbackToGeneration (called by Admin)

Creates a new Published generation by cloning rows from the target generation. The target stays in Superseded state; the new clone becomes Published. This way every state visible to nodes is an actual published generation, never a "rolled back to" pointer that's hard to reason about.

sp_ValidateDraft (called inside publish, also exposed for Admin preview)

Checks: every Tag.DriverInstanceId resolves; every Tag.DeviceId resolves to a Device whose DriverInstanceId matches the tag's; every Tag.PollGroupId resolves; every Device.DriverInstanceId resolves; no duplicate (GenerationId, DriverInstanceId, FolderPath, Name) collisions; every JSON column parses; every DriverConfig matches its DriverType's schema (validated against a registered JSON schema per driver type — see "JSON column conventions" below).

sp_ComputeGenerationDiff

Returns the rows that differ between two generations: added, removed, modified per table. Used by the Admin UI's diff viewer and by the node's apply logic to decide what to surgically update without bouncing the whole driver instance.

Authorization Model

SQL principals

Two principal classes:

  1. Node principals — one per ClusterNode (SQL login, gMSA, or cert-mapped user). Granted EXECUTE on sp_GetCurrentGenerationForCluster, sp_GetGenerationContent, sp_RegisterNodeGenerationApplied only. No table SELECT.
  2. Admin principals — granted to operator accounts. EXECUTE on all sp_* procs. No direct table access either, except read-only views for reporting (vw_ClusterFleetStatus, vw_GenerationHistory).

The dbo schema is owned by no application principal; only db_owner (DBA-managed) can change schema.

Per-node binding enforcement

sp_GetCurrentGenerationForCluster uses SUSER_SNAME() to identify the calling principal and cross-checks against ClusterNodeCredential.Value. A principal asking for another node's cluster gets RAISERROR with HTTP-403-equivalent semantics (16/1).

For Authentication=ActiveDirectoryMsi or cert-auth scenarios where SUSER_SNAME() returns the AD principal name or cert thumbprint, ClusterNodeCredential.Value stores the matching value. Multiple Kind values are supported so a single deployment can mix gMSA and cert auth across different nodes.

Defense-in-depth: SESSION_CONTEXT

After authentication, the caller-side connection wrapper sets SESSION_CONTEXT with NodeId and ClusterId to make audit logging trivial. The procs ignore client-asserted SESSION_CONTEXT values — they recompute from SUSER_SNAME() — but the audit log captures both, so any attempt to spoof shows up in the audit trail.

Admin authn separation

Admin UI authenticates operators via the LDAP layer described in Security.md (existing v1 LDAP authentication, reused). Successful LDAP bind maps to a SQL principal that has admin DB grants. Operators do not get direct DB credentials.

JSON Column Conventions

DriverConfig, DeviceConfig, TagConfig, and DriverConfigOverridesJson are schemaless to the DB but strictly schemaed by the application. Each driver type registers a JSON schema in Core.Abstractions.DriverTypeRegistry describing valid keys for its DriverConfig, DeviceConfig, and TagConfig. sp_ValidateDraft calls into managed code (CLR-hosted validator or external EF/.NET pre-publish step) to validate before the Status flip.

Examples of the per-driver shapes — full specs in driver-specs.md:

// DriverConfig for DriverType=Galaxy
{
  "MxAccess": { "ClientName": "OtOpcUa-Cluster1", "RequestTimeoutSeconds": 30 },
  "Database": { "ConnectionString": "Server=...;Database=ZB;...", "PollIntervalSeconds": 60 },
  "Historian": { "Enabled": false }
}

// DeviceConfig for DriverType=ModbusTcp
{
  "Host": "10.0.3.42",
  "Port": 502,
  "UnitId": 1,
  "ByteOrder": "BigEndianBigEndianWord",
  "AddressFormat": "Standard"   // or "DL205"
}

// TagConfig for DriverType=ModbusTcp
{
  "RegisterType": "HoldingRegister",
  "Address": 100,
  "Length": 1,
  "Scaling": { "Multiplier": 0.1, "Offset": 0 }
}

The JSON schema lives in source so it versions with the driver; the DB doesn't carry per-type DDL.

Per-Node Override Merge Semantics

At config-apply time on a node:

  1. Node fetches DriverInstance rows for the current generation and its ClusterId
  2. Node fetches its own ClusterNode.DriverConfigOverridesJson
  3. For each DriverInstance, node parses DriverConfig (cluster-level), then walks the override JSON for that DriverInstanceId, applying each leaf-key override on top
  4. Merge is shallow at the leaf level — the override key path locates the exact JSON node to replace. Arrays are replaced wholesale, not merged element-wise. If the override path doesn't exist in DriverConfig, the merge fails the apply step (loud failure beats silent drift).
  5. Resulting JSON is the effective DriverConfig for this node, passed to the driver factory

Tags and devices are never overridden per-node. If you need a tag definition to differ between nodes, you have a different cluster — split it.

Local LiteDB Cache

Each node maintains a small LiteDB file (default config_cache.db) keyed by GenerationId. On startup, if the central DB is unreachable, the node loads the most recent cached generation and starts.

Schema (LiteDB collections):

Collection Purpose
Generations Header rows (GenerationId, ClusterId, PublishedAt, Notes)
DriverInstances Cluster-level driver definitions per generation
Devices Per-driver devices
Tags Per-driver/device tags
PollGroups Per-driver poll groups
NodeConfig This node's ClusterNode row + overrides JSON

A node only ever caches its own cluster's generations. Old cached generations beyond the most recent N (default 10) are pruned to bound disk usage.

EF Core Migrations

The Configuration project (per plan.md §5) owns the schema. EF Core code-first migrations under Configuration/Migrations/. Every migration ships with:

  • The forward Up() and reverse Down() operations
  • A schema-validation test that runs the migration against a clean DB and verifies indexes, constraints, and stored procedures match the expected DDL
  • A data-fixture test that seeds a minimal cluster + node + generation and exercises sp_GetCurrentGenerationForCluster end-to-end

Stored procedures are managed via MigrationBuilder.Sql() blocks (idempotent CREATE OR ALTER style) so they version with the schema, not as separate DDL artifacts.

Indexes — Hot Paths Summary

Path Index
Node poll: "latest published generation for my cluster" IX_ConfigGeneration_Cluster_Published
Node fetch generation content Per-table (GenerationId, ...) indexes
Admin: list clusters by site IX_ServerCluster_Site
Admin: list generations per cluster IX_ConfigGeneration_Cluster_Published (covers all statuses via DESC scan)
Admin: who's on which generation IX_ClusterNodeGenerationState_Generation
Audit query: cluster history IX_ConfigAuditLog_Cluster_Time
Auth check on every node poll IX_ClusterNodeCredential_NodeId

Backup, Retention, and Operational Concerns

  • Generations are never deleted (per decision #58). Storage cost is small — even at one publish per day per cluster, a 50-cluster fleet generates ~18k generations/year with average row counts in the hundreds. Total at full v2 fleet scale: well under 10 GB/year.
  • Backup: standard SQL Server full + differential + log backups. Point-in-time restore covers operator mistake recovery (rolled back the wrong generation, etc.).
  • Audit log retention: 7 years by default, partitioned by year for cheap pruning if a customer requires shorter retention.
  • Connection pooling: each OtOpcUa node holds a pooled connection; admin UI uses standard EF DbContext pooling.

Decisions / Open Questions

Decided (captured in plan.md decision log):

  • Cluster-scoped generations (#82)
  • Per-node credential binding (#83)
  • Both nodes apply independently with brief divergence acceptable (#84)
  • ApplicationUri unique fleet-wide, never auto-rewritten (#86)
  • All new tables (#79, #80)

Resolved Defaults:

  • JSON validation: external (in Admin app), not CLR-hosted. Requiring CLR on the SQL Server is an operational tax (CLR is disabled by default on hardened DB instances and many DBAs refuse to enable it). The Admin app validates draft content against the per-driver JSON schemas before calling sp_PublishGeneration; the proc enforces structural integrity (FKs, uniqueness, JSON parseability via ISJSON) but trusts the caller for content schema. Direct proc invocation outside the Admin app is already prevented by the GRANT model — only admin principals can publish.
  • Dotted JSON path syntax for DriverConfigOverridesJson. Example: "MxAccess.ClientName" not "/MxAccess/ClientName". Dotted is more readable in operator-facing UI and CSV exports. Reserved chars: literal . in a key segment is escaped as \.; literal \ is escaped as \\. Array indexing uses bracket form: Items[0].Name. Documented inline in the override editor's help text.
  • sp_PurgeGenerationsBefore proc deferred to v2.1. Initial release ships with "keep all generations forever" (decision #58). The purge proc is shaped now so we don't have to re-think it later: signature sp_PurgeGenerationsBefore(@ClusterId, @CutoffGenerationId, @ConfirmToken) requires an Admin-supplied confirmation token (random hex shown in the UI) to prevent script-based mass deletion; deletes are CASCADEd via per-table WHERE GenerationId IN (...); audit log entry recorded with the principal, the cutoff, and the row counts deleted. Surface in v2.1 only when a customer compliance ask demands it.