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:
@@ -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 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
|
||||
@@ -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` |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user