Harden v2 design against the four findings from the 2026-04-17 Codex adversarial review of the db schema and admin UI: (1) DriverInstance.NamespaceId now enforces a same-cluster invariant in three layers (sp_ValidateDraft cross-table check using the new UX_Namespace_Generation_LogicalId_Cluster composite index, server-side namespace-selection API scoping that prevents bypass via crafted requests, and audit-log entries on cross-cluster attempts) so a draft for cluster A can no longer bind to cluster B's namespace and leak its URI into A's endpoint; (2) the Namespace table moves from cluster-level to generation-versioned with append-only logical-ID identity and locked NamespaceUri/Kind across generations so admins can no longer disable a namespace that a published driver depends on outside the publish/diff/rollback flow, the cluster-create workflow opens an initial draft containing the default namespaces instead of writing namespace rows directly, and the Admin UI Namespaces tab becomes hybrid (read-only over published, click-to-edit opens draft) like the UNS Structure tab; (3) ZTag/SAPID fleet-wide uniqueness moves from per-generation indexes (which silently allow rollback or re-enable to reintroduce duplicates) into a new ExternalIdReservation table that sits outside generation versioning, with sp_PublishGeneration reserving atomically via MERGE under transaction lock so a different EquipmentUuid attempting the same active value rolls the whole publish back, an FleetAdmin-only sp_ReleaseExternalIdReservation as the only path to free a value for reuse with audit trail, and a corresponding Release-reservation operator workflow in the Admin UI; (4) Equipment.EquipmentId is now system-generated as 'EQ-' + first 12 hex chars of EquipmentUuid, never operator-supplied or editable, removed from the Equipment CSV import schema entirely (rows match by EquipmentUuid for updates or create new equipment with auto-generated identifiers when no UUID is supplied), with a new Merge-or-Rebind-equipment operator workflow handling the rare case where two UUIDs need to be reconciled — closing the corruption path where typos and bulk-import renames were minting duplicate identities and breaking downstream UUID-keyed lineage. New decisions #122-125 with explicit "supersedes" notes for the earlier #107 (cluster-level namespace) and #116 (operator-set EquipmentId) frames they revise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-17 11:08:58 -04:00
parent 449bc454b7
commit a59ad2e0c6
4 changed files with 674 additions and 65 deletions

View File

@@ -30,16 +30,46 @@ Out of scope here (covered elsewhere):
```
ServerCluster (1)──(1..2) ClusterNode (1)──(1..N) ClusterNodeCredential
└──(1)──(N) ConfigGeneration ──(N)── DriverInstance ──(N)── Device ──(N)── Tag
└──(N)── PollGroup
└──(N)── PollGroup (driver-scoped)
└──(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.
@@ -50,10 +80,10 @@ All `Json` columns use `nvarchar(max)` with a `CHECK (ISJSON(col) = 1)` constrai
CREATE TABLE dbo.ServerCluster (
ClusterId nvarchar(64) NOT NULL PRIMARY KEY,
Name nvarchar(128) NOT NULL,
Site nvarchar(64) NULL, -- grouping for fleet management
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')),
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(),
@@ -63,12 +93,51 @@ CREATE TABLE dbo.ServerCluster (
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) WHERE Site IS NOT NULL;
CREATE INDEX IX_ServerCluster_Site ON dbo.ServerCluster (Site);
```
`Enterprise` and `Site` are UNS levels 12; 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
@@ -170,21 +239,35 @@ CREATE TABLE dbo.DriverInstance (
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
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 INDEX IX_DriverInstance_Generation_Namespace
ON dbo.DriverInstance (GenerationId, NamespaceId);
CREATE UNIQUE INDEX UX_DriverInstance_Generation_LogicalId
ON dbo.DriverInstance (GenerationId, DriverInstanceId);
CREATE UNIQUE INDEX UX_DriverInstance_Generation_NamespaceUri
ON dbo.DriverInstance (GenerationId, NamespaceUri);
```
`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
@@ -206,6 +289,165 @@ CREATE UNIQUE INDEX UX_Device_Generation_LogicalId
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
@@ -214,9 +456,13 @@ CREATE TABLE dbo.Tag (
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
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,
@@ -226,12 +472,23 @@ CREATE TABLE dbo.Tag (
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);
CREATE UNIQUE INDEX UX_Tag_Generation_Path
ON dbo.Tag (GenerationId, DriverInstanceId, FolderPath, Name);
-- 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
@@ -343,7 +600,7 @@ Companion procs: `sp_GetGenerationContent` (returns full generation rows for a g
### `sp_PublishGeneration` (called by Admin)
```sql
-- Atomic: validates the draft, computes diff vs. previous Published, flips Status
-- Atomic: validates the draft, reserves external identifiers, flips Status
CREATE PROCEDURE dbo.sp_PublishGeneration
@ClusterId nvarchar(64),
@DraftGenerationId bigint,
@@ -355,20 +612,46 @@ BEGIN
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.
-- 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. Mark previous Published as Superseded
-- 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';
-- 4. Promote Draft to 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;
-- 5. Audit log
-- 6. Audit log
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
VALUES (SUSER_SNAME(), 'Published', @ClusterId, @DraftGenerationId);
@@ -376,13 +659,49 @@ BEGIN
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: 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).
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`
@@ -495,6 +814,22 @@ Stored procedures are managed via `MigrationBuilder.Sql()` blocks (idempotent CR
| 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` |