Draft v2 multi-driver planning docs (docs/v2/) so Phase 0–5 work has a complete reference: rename to OtOpcUa, migrate to .NET 10 x64 (Galaxy stays .NET 4.8 x86 out-of-process), add seven new drivers behind composable capability interfaces (Modbus TCP / DL205, AB CIP, AB Legacy, S7, TwinCAT, FOCAS, OPC UA Client), introduce a central MSSQL config DB with cluster-scoped immutable generations and per-node credential binding, deploy as two-node site clusters with non-transparent redundancy and minimal per-node overrides, classify drivers by stability tier (A pure-managed / B wrapped-native / C out-of-process Windows service) with Tier C deep dives for both Galaxy and FOCAS, define per-driver test data sources (libplctag ab_server, Snap7, NModbus in-proc, TwinCAT XAR VM, FOCAS TCP stub plus native FaultShim) plus a 6-axis cross-driver test matrix, and ship a Blazor Server admin UI mirroring ScadaLink CentralUI's Bootstrap 5 / LDAP cookie auth / dark-sidebar look-and-feel — 106 numbered decisions across six docs (plan.md, driver-specs.md, driver-stability.md, test-data-sources.md, config-db-schema.md, admin-ui.md), DRAFT only and intentionally not yet wired to code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-17 09:17:49 -04:00
parent bc282b6788
commit a1e79cdb06
6 changed files with 3676 additions and 0 deletions

522
docs/v2/config-db-schema.md Normal file
View File

@@ -0,0 +1,522 @@
# 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` / `TagConfig``driver-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`
```sql
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`
```sql
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:
```jsonc
{
"<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`
```sql
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`
```sql
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`
```sql
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`
```sql
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`
```sql
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`
```sql
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`
```sql
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`
```sql
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)
```sql
-- @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)
```sql
-- 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`:
```jsonc
// 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.