858 lines
57 KiB
Markdown
858 lines
57 KiB
Markdown
# 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)── Namespace (generation-versioned; Kind: Equipment | SystemPlatform | Simulated)
|
||
│ ↑
|
||
│ │
|
||
├──(N)── DriverInstance ──(N)── Device
|
||
│ │
|
||
│ │ Same-cluster invariant:
|
||
│ │ DriverInstance.NamespaceId → Namespace
|
||
│ │ must satisfy Namespace.ClusterId = DriverInstance.ClusterId
|
||
│ │
|
||
│ (1)──┴──(N) Equipment ──(N)── Tag (Equipment-ns)
|
||
│ │ │
|
||
│ │ │ Equipment carries:
|
||
│ │ │ - EquipmentId (system-generated 'EQ-' + uuid prefix; never operator-set)
|
||
│ │ │ - EquipmentUuid (immutable UUIDv4)
|
||
│ │ │ - MachineCode (operator colloquial; required)
|
||
│ │ │ - ZTag (ERP id; primary browse identifier; reservation-backed)
|
||
│ │ │ - SAPID (SAP PM id; reservation-backed)
|
||
│ │ │ - UnsLineId → UnsLine → UnsArea (UNS structure)
|
||
│ │ │ - Name (UNS level 5)
|
||
│ │
|
||
│ └──(N)── Tag (SystemPlatform-ns; via DriverInstance + FolderPath)
|
||
│
|
||
├──(N)── UnsArea (UNS level 3; per-cluster, generation-versioned)
|
||
│ │
|
||
│ └──(1..N) UnsLine (UNS level 4; per-area, generation-versioned)
|
||
│
|
||
└──(N)── PollGroup (driver-scoped)
|
||
|
||
ExternalIdReservation — fleet-wide ZTag/SAPID uniqueness, NOT generation-versioned;
|
||
survives rollback, disable, and re-enable
|
||
ClusterNodeGenerationState (1:1 ClusterNode) — tracks applied generation per node
|
||
ConfigAuditLog — append-only event log
|
||
```
|
||
|
||
**Key relationships for UNS / two-namespace model**:
|
||
- Each `DriverInstance` is bound to one `Namespace` (driver type restricts allowed `Namespace.Kind`).
|
||
- `UnsArea` and `UnsLine` are first-class generation-versioned entities so renaming/reorganizing the UNS structure doesn't require rewriting every equipment row — change one `UnsArea.Name` and every equipment under it picks up the new path automatically.
|
||
- `Equipment` rows exist only when their driver is in an Equipment-kind namespace; `EquipmentUuid` is immutable across all generations of the cluster. Five identifiers per equipment (EquipmentId / EquipmentUuid / MachineCode / ZTag / SAPID) serve different audiences and are all exposed as OPC UA properties.
|
||
- `Tag.EquipmentId` is required for Equipment-ns tags, NULL for SystemPlatform-ns tags. The `FolderPath` column is used only by SystemPlatform-ns tags (preserving v1 LmxOpcUa hierarchy expression).
|
||
|
||
## 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,
|
||
Enterprise nvarchar(32) NOT NULL, -- UNS level 1, e.g. "ent"
|
||
Site nvarchar(32) NOT NULL, -- UNS level 2, e.g. "warsaw-west"
|
||
NodeCount tinyint NOT NULL CHECK (NodeCount IN (1, 2)),
|
||
RedundancyMode nvarchar(16) NOT NULL CHECK (RedundancyMode IN ('None', 'Warm', 'Hot')),
|
||
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')))
|
||
-- Stricter UNS segment validation (`^[a-z0-9-]{1,32}$`) is enforced in the
|
||
-- application layer + sp_ValidateDraft. The GRANT model prevents direct table
|
||
-- inserts so application validation is the enforcement point.
|
||
);
|
||
|
||
CREATE UNIQUE INDEX UX_ServerCluster_Name ON dbo.ServerCluster (Name);
|
||
CREATE INDEX IX_ServerCluster_Site ON dbo.ServerCluster (Site);
|
||
```
|
||
|
||
`Enterprise` and `Site` are UNS levels 1–2; cluster-level (don't change per generation), feed every Equipment-namespace path. `NamespaceUri` moved out of this table — namespaces are now first-class rows in the `Namespace` table.
|
||
|
||
### `Namespace`
|
||
|
||
```sql
|
||
CREATE TABLE dbo.Namespace (
|
||
NamespaceRowId uniqueidentifier NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
|
||
GenerationId bigint NOT NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId),
|
||
NamespaceId nvarchar(64) NOT NULL, -- stable logical ID across generations, e.g. "LINE3-OPCUA-equipment"
|
||
ClusterId nvarchar(64) NOT NULL FOREIGN KEY REFERENCES dbo.ServerCluster(ClusterId),
|
||
Kind nvarchar(32) NOT NULL CHECK (Kind IN ('Equipment', 'SystemPlatform', 'Simulated')),
|
||
NamespaceUri nvarchar(256) NOT NULL,
|
||
Enabled bit NOT NULL DEFAULT 1,
|
||
Notes nvarchar(1024) NULL
|
||
);
|
||
|
||
-- Within a generation: a cluster has at most one namespace per Kind
|
||
CREATE UNIQUE INDEX UX_Namespace_Generation_Cluster_Kind ON dbo.Namespace (GenerationId, ClusterId, Kind);
|
||
-- Within a generation: NamespaceUri unique fleet-wide (clients pin to namespace URIs)
|
||
CREATE UNIQUE INDEX UX_Namespace_Generation_NamespaceUri ON dbo.Namespace (GenerationId, NamespaceUri);
|
||
-- Within a generation: logical ID unique per cluster
|
||
CREATE UNIQUE INDEX UX_Namespace_Generation_LogicalId ON dbo.Namespace (GenerationId, NamespaceId);
|
||
-- Composite key DriverInstance uses for same-cluster validation
|
||
CREATE UNIQUE INDEX UX_Namespace_Generation_LogicalId_Cluster ON dbo.Namespace (GenerationId, NamespaceId, ClusterId);
|
||
CREATE INDEX IX_Namespace_Generation_Cluster ON dbo.Namespace (GenerationId, ClusterId);
|
||
```
|
||
|
||
`Namespace` is **generation-versioned** (revised after adversarial review 2026-04-17 finding #2). Adding, disabling, or changing a namespace is a content publish, not a topology operation — these changes affect what consumers see at the OPC UA endpoint and must travel through the same draft → diff → publish → rollback flow as drivers/tags/equipment. Reasoning: a cluster-level namespace would let an admin disable a namespace that a published driver depends on, breaking the live config without a generation change and making rollback unreproducible.
|
||
|
||
**Cross-generation invariants** (enforced by `sp_ValidateDraft`):
|
||
- **Logical-ID identity stability**: once a `(NamespaceId, ClusterId)` pair is published, every subsequent generation that includes that NamespaceId must keep the same `Kind` and the same `NamespaceUri`. Renaming a NamespaceUri or changing its Kind is forbidden — create a new NamespaceId instead. This protects clients that pin trust to the URI.
|
||
- **Append-only logical-ID space**: a NamespaceId once introduced is never reused in the same cluster for a different namespace, even after disable. Disabling sets `Enabled = 0`; the logical ID stays bound to its original Kind/URI.
|
||
- **Auto-rollback safety**: rolling back to a generation that included a namespace which is currently disabled is permitted (publish reactivates it). Rolling back through a NamespaceUri rename is forbidden by the invariant above — operator must explicitly reconcile.
|
||
|
||
`Simulated` is reserved in the `Kind` enum but no driver populates it in v2.0 — adding the future replay driver is a draft → publish flow that adds a Namespace row of `Kind = 'Simulated'` and one or more drivers bound to it.
|
||
|
||
### `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),
|
||
NamespaceId nvarchar(64) NOT NULL FOREIGN KEY REFERENCES dbo.Namespace(NamespaceId),
|
||
Name nvarchar(128) NOT NULL,
|
||
DriverType nvarchar(32) NOT NULL, -- Galaxy | ModbusTcp | AbCip | AbLegacy | S7 | TwinCat | Focas | OpcUaClient
|
||
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 INDEX IX_DriverInstance_Generation_Namespace
|
||
ON dbo.DriverInstance (GenerationId, NamespaceId);
|
||
CREATE UNIQUE INDEX UX_DriverInstance_Generation_LogicalId
|
||
ON dbo.DriverInstance (GenerationId, DriverInstanceId);
|
||
```
|
||
|
||
`NamespaceId` references the generation-versioned `Namespace` row that this driver populates. Driver type → allowed namespace Kind mapping is enforced in `sp_ValidateDraft` (not in DB CHECK because it's a cross-table constraint):
|
||
|
||
| `DriverType` | Allowed `Namespace.Kind` |
|
||
|--------------|--------------------------|
|
||
| Galaxy | SystemPlatform |
|
||
| ModbusTcp / AbCip / AbLegacy / S7 / TwinCat / Focas | Equipment |
|
||
| OpcUaClient | Equipment OR SystemPlatform (per-instance config decides) |
|
||
|
||
**Same-cluster invariant** (revised after adversarial review 2026-04-17 finding #1): the `Namespace` referenced by `DriverInstance.NamespaceId` MUST belong to the same `ClusterId`. This is a cross-cluster trust boundary — without enforcement, a draft for cluster A could bind to a namespace owned by cluster B, leaking that cluster's URI into A's endpoint and breaking tenant isolation. Three layers of enforcement:
|
||
|
||
1. **`sp_ValidateDraft`**: rejects any draft where `(NamespaceId, ClusterId)` does not resolve in the `Namespace` table for the same generation. Implementation joins `DriverInstance` (NamespaceId, ClusterId) against `UX_Namespace_Generation_LogicalId_Cluster` — the unique index above is sized for exactly this lookup.
|
||
2. **API scoping**: the namespace-selection endpoint used by the Admin UI's draft editor accepts a `ClusterId` parameter and returns only namespaces for that cluster. UI filtering alone is insufficient — server-side scoping prevents bypass via crafted requests.
|
||
3. **Audit on cross-cluster attempt**: any rejected draft that attempted a cross-cluster namespace binding is logged with `EventType = 'CrossClusterNamespaceAttempt'` in `ConfigAuditLog` for review.
|
||
|
||
### `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`.
|
||
|
||
### `UnsArea`
|
||
|
||
```sql
|
||
CREATE TABLE dbo.UnsArea (
|
||
UnsAreaRowId uniqueidentifier NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
|
||
GenerationId bigint NOT NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId),
|
||
UnsAreaId nvarchar(64) NOT NULL, -- stable logical ID across generations
|
||
ClusterId nvarchar(64) NOT NULL FOREIGN KEY REFERENCES dbo.ServerCluster(ClusterId),
|
||
Name nvarchar(32) NOT NULL, -- UNS level 3, [a-z0-9-]{1,32} or "_default"
|
||
Notes nvarchar(512) NULL
|
||
);
|
||
|
||
CREATE INDEX IX_UnsArea_Generation_Cluster
|
||
ON dbo.UnsArea (GenerationId, ClusterId);
|
||
CREATE UNIQUE INDEX UX_UnsArea_Generation_LogicalId
|
||
ON dbo.UnsArea (GenerationId, UnsAreaId);
|
||
CREATE UNIQUE INDEX UX_UnsArea_Generation_ClusterName
|
||
ON dbo.UnsArea (GenerationId, ClusterId, Name);
|
||
```
|
||
|
||
### `UnsLine`
|
||
|
||
```sql
|
||
CREATE TABLE dbo.UnsLine (
|
||
UnsLineRowId uniqueidentifier NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
|
||
GenerationId bigint NOT NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId),
|
||
UnsLineId nvarchar(64) NOT NULL, -- stable logical ID across generations
|
||
UnsAreaId nvarchar(64) NOT NULL, -- FK to UnsArea (by logical id; resolved within same generation)
|
||
Name nvarchar(32) NOT NULL, -- UNS level 4, [a-z0-9-]{1,32} or "_default"
|
||
Notes nvarchar(512) NULL
|
||
);
|
||
|
||
CREATE INDEX IX_UnsLine_Generation_Area
|
||
ON dbo.UnsLine (GenerationId, UnsAreaId);
|
||
CREATE UNIQUE INDEX UX_UnsLine_Generation_LogicalId
|
||
ON dbo.UnsLine (GenerationId, UnsLineId);
|
||
CREATE UNIQUE INDEX UX_UnsLine_Generation_AreaName
|
||
ON dbo.UnsLine (GenerationId, UnsAreaId, Name);
|
||
```
|
||
|
||
`UnsArea` and `UnsLine` make the UNS structure first-class so operators can rename / move it without rewriting every equipment row. Both are **generation-versioned** (renames go through publish + diff for safety + audit). Cross-generation logical-ID stability is enforced by `sp_ValidateDraft`: a `UnsAreaId` once introduced keeps the same identity across generations, even if its `Name` changes.
|
||
|
||
### `Equipment`
|
||
|
||
```sql
|
||
CREATE TABLE dbo.Equipment (
|
||
EquipmentRowId uniqueidentifier NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
|
||
GenerationId bigint NOT NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId),
|
||
EquipmentId nvarchar(64) NOT NULL, -- system-generated stable internal logical ID. NEVER operator-supplied,
|
||
-- NEVER appears in CSV imports, NEVER editable in Admin UI. Format:
|
||
-- 'EQ-' + first 12 hex chars of EquipmentUuid. Generated by server-side
|
||
-- equipment-creation API; sp_ValidateDraft rejects any draft whose
|
||
-- Equipment.EquipmentId does not match the canonical derivation rule.
|
||
EquipmentUuid uniqueidentifier NOT NULL, -- UUIDv4, IMMUTABLE across all generations of the same EquipmentId
|
||
DriverInstanceId nvarchar(64) NOT NULL, -- which driver provides data for this equipment
|
||
DeviceId nvarchar(64) NULL, -- optional, for multi-device drivers
|
||
UnsLineId nvarchar(64) NOT NULL, -- FK to UnsLine (by logical id; resolved within same generation).
|
||
-- Determines UNS Area + Line via the UnsLine→UnsArea chain.
|
||
Name nvarchar(32) NOT NULL, -- UNS level 5, [a-z0-9-]{1,32} (the equipment segment in the path)
|
||
|
||
-- Operator-facing and external-system identifiers
|
||
MachineCode nvarchar(64) NOT NULL, -- Operator colloquial id (e.g. "machine_001"). Unique within cluster.
|
||
ZTag nvarchar(64) NULL, -- ERP equipment id. Unique fleet-wide. Primary identifier for browsing in Admin UI.
|
||
SAPID nvarchar(64) NULL, -- SAP PM equipment id. Unique fleet-wide.
|
||
|
||
EquipmentClassRef nvarchar(128) NULL, -- nullable hook for future schemas-repo template ID
|
||
Enabled bit NOT NULL DEFAULT 1
|
||
);
|
||
|
||
CREATE INDEX IX_Equipment_Generation_Driver
|
||
ON dbo.Equipment (GenerationId, DriverInstanceId);
|
||
CREATE INDEX IX_Equipment_Generation_Line
|
||
ON dbo.Equipment (GenerationId, UnsLineId);
|
||
CREATE UNIQUE INDEX UX_Equipment_Generation_LogicalId
|
||
ON dbo.Equipment (GenerationId, EquipmentId);
|
||
-- UNS path uniqueness within a generation: (UnsLineId, Name) — Area/Line names live on UnsLine
|
||
CREATE UNIQUE INDEX UX_Equipment_Generation_LinePath
|
||
ON dbo.Equipment (GenerationId, UnsLineId, Name);
|
||
-- EquipmentUuid → EquipmentId mapping is 1:1 across all generations of a cluster (cross-gen check in sp_ValidateDraft)
|
||
CREATE UNIQUE INDEX UX_Equipment_Generation_Uuid
|
||
ON dbo.Equipment (GenerationId, EquipmentUuid);
|
||
|
||
-- Operator-facing identifier indexes — primary browse identifier is ZTag
|
||
CREATE INDEX IX_Equipment_Generation_ZTag
|
||
ON dbo.Equipment (GenerationId, ZTag) WHERE ZTag IS NOT NULL;
|
||
CREATE INDEX IX_Equipment_Generation_SAPID
|
||
ON dbo.Equipment (GenerationId, SAPID) WHERE SAPID IS NOT NULL;
|
||
-- MachineCode unique within cluster — composite check in sp_ValidateDraft (needs join through DriverInstance to get cluster)
|
||
CREATE INDEX IX_Equipment_Generation_MachineCode
|
||
ON dbo.Equipment (GenerationId, MachineCode);
|
||
```
|
||
|
||
**Note on ZTag/SAPID uniqueness**: per-generation indexes above are non-unique (only `IX_*`, not `UX_*`). Fleet-wide uniqueness lives in the `ExternalIdReservation` table (see below). Per-generation indexes exist only for fast lookup; uniqueness is enforced at publish time against the reservation table, which is rollback-safe.
|
||
|
||
**Identifier responsibilities** — equipment carries five distinct identifiers, each with a different audience:
|
||
|
||
| Identifier | Audience | Mutable? | Uniqueness scope | Purpose |
|
||
|------------|----------|:--------:|------------------|---------|
|
||
| `EquipmentId` | Internal config DB | No (after publish) | Within cluster | Stable logical key for cross-generation diffs |
|
||
| `EquipmentUuid` | Downstream events / dbt / Redpanda | **No, ever** | Globally unique (UUIDv4) | Permanent join key across systems and time |
|
||
| `MachineCode` | OT operators | Yes (with publish) | Within cluster | Colloquial name in conversations and runbooks (e.g. `machine_001`) |
|
||
| `ZTag` | ERP integration | Yes (rare) | Fleet-wide | **Primary identifier for browsing in Admin UI** — list/search default sort |
|
||
| `SAPID` | SAP PM integration | Yes (rare) | Fleet-wide | Maintenance system join key |
|
||
|
||
All five are exposed as **OPC UA properties** on the equipment node so external systems can resolve equipment by whichever identifier they natively use, without needing a sidecar lookup service.
|
||
|
||
**UUID immutability**: `sp_ValidateDraft` rejects a generation if any `(EquipmentId, EquipmentUuid)` pair conflicts with the same `EquipmentId` in any prior generation of the same cluster. Once an EquipmentId is published with a UUID, that UUID is locked for the life of the cluster. Operators can rename Area/Line/Name and edit MachineCode/ZTag/SAPID freely; the UUID stays.
|
||
|
||
**UNS validation** (in `sp_ValidateDraft` and Admin UI):
|
||
- `UnsArea.Name`, `UnsLine.Name`, `Equipment.Name`: each matches `^[a-z0-9-]{1,32}$` OR equals literal `_default`
|
||
- Computed full path `{Cluster.Enterprise}/{Cluster.Site}/{UnsArea.Name}/{UnsLine.Name}/{Equipment.Name}` ≤ 200 chars
|
||
- Driver providing this Equipment must belong to a namespace with `Kind = 'Equipment'` (cross-table check)
|
||
|
||
**Identifier validation** (in `sp_ValidateDraft`):
|
||
- `MachineCode` unique within cluster (cross-table check via `DriverInstance.ClusterId`)
|
||
- `ZTag` unique fleet-wide when not null
|
||
- `SAPID` unique fleet-wide when not null
|
||
- `MachineCode` is required; `ZTag` and `SAPID` are optional (some equipment might not yet be in ERP/SAP)
|
||
|
||
`EquipmentClassRef` is a nullable string hook; v2.0 ships with no validation. When the central `schemas` repo lands, this becomes a foreign key into the schemas-repo equipment-class catalog, validated at draft-publish time.
|
||
|
||
### `ExternalIdReservation`
|
||
|
||
```sql
|
||
CREATE TABLE dbo.ExternalIdReservation (
|
||
ReservationId uniqueidentifier NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
|
||
Kind nvarchar(16) NOT NULL CHECK (Kind IN ('ZTag', 'SAPID')),
|
||
Value nvarchar(64) NOT NULL,
|
||
EquipmentUuid uniqueidentifier NOT NULL, -- which equipment owns this reservation, FOREVER
|
||
ClusterId nvarchar(64) NOT NULL, -- first cluster to publish this id
|
||
FirstPublishedAt datetime2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
|
||
FirstPublishedBy nvarchar(128) NOT NULL,
|
||
LastPublishedAt datetime2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
|
||
ReleasedAt datetime2(3) NULL, -- non-null when explicitly released by operator
|
||
ReleasedBy nvarchar(128) NULL,
|
||
ReleaseReason nvarchar(512) NULL
|
||
);
|
||
|
||
-- Active reservations (not released) MUST be unique per (Kind, Value)
|
||
CREATE UNIQUE INDEX UX_ExternalIdReservation_KindValue_Active
|
||
ON dbo.ExternalIdReservation (Kind, Value)
|
||
WHERE ReleasedAt IS NULL;
|
||
|
||
-- One Equipment can hold reservations for both ZTag and SAPID
|
||
CREATE INDEX IX_ExternalIdReservation_Equipment ON dbo.ExternalIdReservation (EquipmentUuid);
|
||
CREATE INDEX IX_ExternalIdReservation_KindValue ON dbo.ExternalIdReservation (Kind, Value);
|
||
```
|
||
|
||
`ExternalIdReservation` is **NOT generation-versioned** (revised after adversarial review 2026-04-17 finding #3). It exists outside the generation-publish flow specifically to provide rollback-safe identifier uniqueness — generation-versioned uniqueness alone fails because old generations and disabled equipment can hold the same external ID, allowing rollback or re-enable to silently reintroduce duplicates that corrupt downstream ERP/SAP joins.
|
||
|
||
**Lifecycle**:
|
||
- **Reserve on publish**: `sp_PublishGeneration` creates a reservation row for every `(Kind, Value, EquipmentUuid)` triple in the new generation that doesn't already have a reservation; updates `LastPublishedAt` for existing reservations.
|
||
- **Reject on conflict**: if a publish includes `(Kind = 'ZTag', Value = 'ABC')` for `EquipmentUuid = X` but an active reservation already binds `('ZTag', 'ABC')` to `EquipmentUuid = Y`, the publish fails with `BadDuplicateExternalIdentifier` and the offending row is named in the audit log.
|
||
- **Survive disable**: disabling an equipment (`Equipment.Enabled = 0` in a future generation) does NOT release the reservation. The ID stays bound to that EquipmentUuid until explicit operator release.
|
||
- **Survive rollback**: rollback to an old generation that includes the original `(Kind, Value, EquipmentUuid)` triple is permitted (reservation already binds it correctly). Rollback through a state where the same value was bound to a different EquipmentUuid is rejected — operator must explicitly release the conflicting reservation first.
|
||
- **Explicit release**: operator can release a reservation via Admin UI (FleetAdmin only); requires reason; audit-logged. Released reservations stay in the table (`ReleasedAt` non-null) for audit; the unique index on `WHERE ReleasedAt IS NULL` allows the same value to be re-reserved by a different EquipmentUuid afterward.
|
||
|
||
This is the only safe way to express "ZTag and SAPID are fleet-wide unique forever, including under rollback and re-enable" without a generation-versioned schema constraint that can't see other generations.
|
||
|
||
### `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
|
||
EquipmentId nvarchar(64) NULL, -- REQUIRED when driver is in Equipment-kind namespace;
|
||
-- NULL when driver is in SystemPlatform-kind namespace.
|
||
-- Cross-table constraint enforced by sp_ValidateDraft.
|
||
Name nvarchar(128) NOT NULL, -- signal name; level-6 in Equipment ns
|
||
FolderPath nvarchar(512) NULL, -- only used when EquipmentId IS NULL (SystemPlatform ns).
|
||
-- Equipment provides path otherwise.
|
||
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 INDEX IX_Tag_Generation_Equipment
|
||
ON dbo.Tag (GenerationId, EquipmentId) WHERE EquipmentId IS NOT NULL;
|
||
CREATE UNIQUE INDEX UX_Tag_Generation_LogicalId
|
||
ON dbo.Tag (GenerationId, TagId);
|
||
-- Path uniqueness: in Equipment ns the path is (EquipmentId, Name); in SystemPlatform ns it's (DriverInstanceId, FolderPath, Name)
|
||
CREATE UNIQUE INDEX UX_Tag_Generation_EquipmentPath
|
||
ON dbo.Tag (GenerationId, EquipmentId, Name) WHERE EquipmentId IS NOT NULL;
|
||
CREATE UNIQUE INDEX UX_Tag_Generation_FolderPath
|
||
ON dbo.Tag (GenerationId, DriverInstanceId, FolderPath, Name) WHERE EquipmentId IS NULL;
|
||
```
|
||
|
||
**Path resolution at apply time**:
|
||
- If `EquipmentId IS NOT NULL` (Equipment namespace tag): full path = `{Cluster.Enterprise}/{Cluster.Site}/{Equipment.Area}/{Equipment.Line}/{Equipment.Name}/{Tag.Name}`. `FolderPath` ignored.
|
||
- If `EquipmentId IS NULL` (SystemPlatform namespace tag): full path = `{FolderPath}/{Tag.Name}` exactly as v1 LmxOpcUa expressed it. No UNS rules apply.
|
||
|
||
`sp_ValidateDraft` enforces the EquipmentId-vs-namespace-kind invariant: if the tag's `DriverInstanceId` belongs to an Equipment-kind namespace, `EquipmentId` must be set; if SystemPlatform-kind, `EquipmentId` must be null. The DB CHECK can't see across tables so this check lives in the validator.
|
||
|
||
### `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, reserves external identifiers, 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: FKs resolve, no orphan tags, JSON columns parse, identity invariants,
|
||
-- same-cluster namespace bindings, ZTag/SAPID reservations pre-flight, etc.
|
||
-- EXEC sp_ValidateDraft @DraftGenerationId; — raises on failure
|
||
-- 3. RESERVE / RENEW external identifiers atomically with the publish
|
||
-- For each (Kind, Value, EquipmentUuid) triple in the draft's Equipment rows:
|
||
-- - INSERT into ExternalIdReservation if no row matches (Kind, Value, EquipmentUuid)
|
||
-- AND no active row matches (Kind, Value) — the latter would have been caught by
|
||
-- sp_ValidateDraft, but rechecked here under transaction lock to prevent race.
|
||
-- - UPDATE LastPublishedAt for any existing matching reservation.
|
||
-- Rollback the whole publish if any reservation conflict surfaces under lock.
|
||
MERGE dbo.ExternalIdReservation AS tgt
|
||
USING (
|
||
SELECT 'ZTag' AS Kind, ZTag AS Value, EquipmentUuid
|
||
FROM dbo.Equipment
|
||
WHERE GenerationId = @DraftGenerationId AND ZTag IS NOT NULL
|
||
UNION ALL
|
||
SELECT 'SAPID', SAPID, EquipmentUuid
|
||
FROM dbo.Equipment
|
||
WHERE GenerationId = @DraftGenerationId AND SAPID IS NOT NULL
|
||
) AS src
|
||
ON tgt.Kind = src.Kind AND tgt.Value = src.Value AND tgt.EquipmentUuid = src.EquipmentUuid
|
||
WHEN MATCHED THEN
|
||
UPDATE SET LastPublishedAt = SYSUTCDATETIME()
|
||
WHEN NOT MATCHED BY TARGET THEN
|
||
INSERT (Kind, Value, EquipmentUuid, ClusterId, FirstPublishedBy, LastPublishedAt)
|
||
VALUES (src.Kind, src.Value, src.EquipmentUuid, @ClusterId, SUSER_SNAME(), SYSUTCDATETIME());
|
||
-- The unique index UX_ExternalIdReservation_KindValue_Active raises a primary-key violation
|
||
-- if a different EquipmentUuid attempts to reserve the same active (Kind, Value).
|
||
-- 4. Mark previous Published as Superseded
|
||
UPDATE dbo.ConfigGeneration
|
||
SET Status = 'Superseded'
|
||
WHERE ClusterId = @ClusterId AND Status = 'Published';
|
||
-- 5. 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;
|
||
-- 6. Audit log
|
||
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
|
||
VALUES (SUSER_SNAME(), 'Published', @ClusterId, @DraftGenerationId);
|
||
|
||
COMMIT;
|
||
END
|
||
```
|
||
|
||
**`sp_ReleaseExternalIdReservation`** (FleetAdmin only): explicit operator action to release a reservation when equipment is permanently retired and its ZTag/SAPID needs to be reused by a different physical asset. Sets `ReleasedAt`, `ReleasedBy`, `ReleaseReason`. After release, the unique index `WHERE ReleasedAt IS NULL` allows the same `(Kind, Value)` to be re-reserved by a different EquipmentUuid in a future publish. Audit-logged with `EventType = 'ExternalIdReleased'`.
|
||
|
||
### `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 (existing):
|
||
- 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 for SystemPlatform-ns tags
|
||
- No duplicate `(GenerationId, EquipmentId, Name)` collisions for Equipment-ns tags
|
||
- Every JSON column parses; every `DriverConfig` matches its `DriverType`'s schema (per "JSON column conventions" below)
|
||
|
||
Checks (UNS / namespace integration):
|
||
- **Namespace exists in same generation**: `DriverInstance.NamespaceId` must resolve to a `Namespace` row in the same `GenerationId`
|
||
- **Same-cluster namespace binding** (revised after adversarial review finding #1): the resolved `Namespace.ClusterId` must equal `DriverInstance.ClusterId`. Cross-cluster bindings are rejected with `BadCrossClusterNamespaceBinding` and audit-logged as `EventType = 'CrossClusterNamespaceAttempt'`
|
||
- **Namespace identity stability across generations** (finding #2): for every `Namespace` row in the draft, if a row with the same `(NamespaceId, ClusterId)` exists in any prior generation, it must have the same `Kind` and the same `NamespaceUri`. NamespaceUri renames are forbidden — use a new NamespaceId
|
||
- **Driver type ↔ namespace kind**: every `DriverInstance.NamespaceId` must resolve to a `Namespace` whose `Kind` matches the allowed set for that `DriverType` (Galaxy → SystemPlatform; native-protocol drivers → Equipment; OpcUaClient → either)
|
||
- **Tag ↔ namespace kind**: if a tag's `DriverInstanceId` belongs to an Equipment-kind namespace, `EquipmentId` must be set; if SystemPlatform-kind, `EquipmentId` must be null
|
||
- **UnsArea / UnsLine / Equipment.Name segment validation**: each matches `^[a-z0-9-]{1,32}$` OR equals literal `_default`
|
||
- **UnsLine.UnsAreaId resolves**: must reference a `UnsArea` row in the same generation; both must belong to the same cluster (via `UnsArea.ClusterId`)
|
||
- **Equipment.UnsLineId resolves**: must reference a `UnsLine` row in the same generation, and the area chain must trace to the same cluster as the equipment's driver
|
||
- **Equipment full-path length**: `LEN(Cluster.Enterprise) + LEN(Cluster.Site) + LEN(UnsArea.Name) + LEN(UnsLine.Name) + LEN(Equipment.Name) + 4` (slashes) ≤ 200
|
||
- **UnsArea/UnsLine logical-ID stability across generations**: once introduced, an `UnsAreaId` keeps the same identity across generations even if its `Name` changes. Same for `UnsLineId`. Renaming surfaces in the diff viewer; identity reuse with a different parent is rejected.
|
||
- **EquipmentUuid immutability across generations**: for every `(EquipmentId, EquipmentUuid)` pair in the draft, no prior generation of this cluster has the same `EquipmentId` with a different `EquipmentUuid`. Once published, an EquipmentId's UUID is locked for the cluster's lifetime
|
||
- **EquipmentId belongs to the same cluster**: `Equipment.DriverInstanceId` must resolve to a `DriverInstance` whose `ClusterId` matches the draft's cluster
|
||
- **Equipment.DriverInstanceId namespace kind**: the equipment's driver must be in an Equipment-kind namespace
|
||
- **Cluster Enterprise/Site UNS segment validation**: same regex as Area/Line/Name (defense in depth — also enforced at cluster create time)
|
||
|
||
Checks (operator/external identifiers):
|
||
- **EquipmentId is system-generated** (revised after adversarial review finding #4): every `Equipment.EquipmentId` in the draft must match the canonical derivation `'EQ-' + LOWER(LEFT(REPLACE(CONVERT(nvarchar(36), EquipmentUuid), '-', ''), 12))`. Operator-supplied or modified IDs are rejected. CSV imports never carry an `EquipmentId` column — see Admin UI workflow
|
||
- **EquipmentUuid required and stable**: `Equipment.EquipmentUuid` must be non-NULL on every row; once published with a given `(EquipmentId, EquipmentUuid)`, neither value can change in any future generation of the same cluster (cross-generation invariant)
|
||
- **MachineCode required and unique within cluster**: `Equipment.MachineCode` must be non-empty; uniqueness checked across all equipment whose driver shares the same `ClusterId` in the same generation
|
||
- **ZTag/SAPID uniqueness via reservation table** (revised after adversarial review finding #3): per-generation per-cluster checks are insufficient because old generations and disabled equipment can hold the same external IDs. Fleet-wide uniqueness is enforced by `ExternalIdReservation`:
|
||
- Validator pre-flights every `(Kind, Value, EquipmentUuid)` triple in the draft against `ExternalIdReservation WHERE ReleasedAt IS NULL`
|
||
- If reservation exists for same EquipmentUuid → ok (continuation)
|
||
- If reservation exists for a different EquipmentUuid → REJECT with `BadDuplicateExternalIdentifier`; operator must release the conflicting reservation explicitly first
|
||
- If no reservation exists → ok (sp_PublishGeneration will create on commit)
|
||
- **Identifier free-text**: MachineCode/ZTag/SAPID are not subject to UNS-segment regex (they're external system identifiers, not OPC UA path segments) — only required to be non-empty (when present) and ≤64 chars
|
||
|
||
### `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` |
|
||
| Admin / driver build: list equipment for a driver | `IX_Equipment_Generation_Driver` |
|
||
| Admin / driver build: list equipment for a UNS line | `IX_Equipment_Generation_Line` |
|
||
| Admin / driver build: lookup equipment by UNS path within line | `UX_Equipment_Generation_LinePath` |
|
||
| Admin: list lines for a UNS area | `IX_UnsLine_Generation_Area` |
|
||
| Admin: list areas for a cluster | `IX_UnsArea_Generation_Cluster` |
|
||
| Admin: equipment search by ZTag (primary browse identifier) | `UX_Equipment_Generation_ZTag` |
|
||
| Admin: equipment search by SAPID | `UX_Equipment_Generation_SAPID` |
|
||
| Admin: equipment search by MachineCode (cluster-scoped) | `IX_Equipment_Generation_MachineCode` |
|
||
| Tag fetch by equipment (address-space build) | `IX_Tag_Generation_Equipment` |
|
||
| Tag fetch by driver (SystemPlatform ns address-space build) | `IX_Tag_Generation_Driver_Device` |
|
||
| Cross-generation UUID immutability check | `UX_Equipment_Generation_Uuid` (per-gen scan combined with prior-gen lookup) |
|
||
| Driver fetch by namespace | `IX_DriverInstance_Generation_Namespace` |
|
||
| Same-cluster namespace validation | `UX_Namespace_Generation_LogicalId_Cluster` |
|
||
| Namespace fetch for cluster | `IX_Namespace_Generation_Cluster` |
|
||
| External-ID reservation lookup at publish | `UX_ExternalIdReservation_KindValue_Active` |
|
||
| External-ID reservation by equipment | `IX_ExternalIdReservation_Equipment` |
|
||
| 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.
|