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

@@ -244,6 +244,59 @@ Sites deploy OtOpcUa as **2-node clusters** to provide non-transparent OPC UA re
Within a cluster, both nodes serve **identical** address spaces — defining tags twice would invite drift — so driver definitions, device configs, tag definitions, and poll groups attach to `ClusterId`, not to individual nodes. Per-node overrides exist only for physical-machine settings that legitimately differ (host, port, `ApplicationUri`, redundancy role, machine cert) and for the rare driver setting that must differ per node (e.g. `MxAccess.ClientName` so Galaxy distinguishes them). Overrides are minimal by intent.
**Namespaces — two today, extensible to N:**
Each cluster serves **multiple OPC UA namespaces through a single endpoint**, per the 3-year-plan handoff (`handoffs/otopcua-handoff.md` §4). At v2.0 GA there are two namespace kinds:
| Kind | Source | Purpose |
|------|--------|---------|
| **Equipment** | New drivers (Modbus, AB CIP, AB Legacy, S7, TwinCAT, FOCAS, OPC UA Client when gatewaying raw data) | Raw equipment data — no deadbanding, no aggregation, no business meaning. The OT-side surface of the canonical model. |
| **SystemPlatform** | Galaxy driver (existing v1 LmxOpcUa functionality, folded in) | Processed data tap — Aveva System Platform objects exposed as OPC UA so OPC UA-native consumers read derived state through the same endpoint as raw equipment data. |
Future kinds — `Simulated` is named in the plan as a next addition (replay historical equipment data to exercise tier-1/tier-2 consumers without physical equipment). Architecturally supported, **not committed for v2.0 build**. The schema models namespace as a first-class entity (`Namespace` table) so adding a third kind is a config-DB row insert + driver wiring, not a structural refactor.
A cluster always has at most one namespace per kind (UNIQUE on `ClusterId, Kind`). Each `DriverInstance` is bound to exactly one `NamespaceId`; a driver type is restricted to the namespace kinds it can populate (Galaxy → SystemPlatform; all native-protocol drivers → Equipment; OPC UA Client → either, by config).
**UNS naming hierarchy — mandatory in the Equipment namespace:**
Per the 3-year-plan handoff §12, the Equipment namespace browse paths must conform to the canonical 5-level Unified Namespace structure:
| Level | Name | Source | Example |
|-------|------|--------|---------|
| 1 | Enterprise | `ServerCluster.Enterprise` | `ent` |
| 2 | Site | `ServerCluster.Site` | `warsaw-west` |
| 3 | Area | `UnsArea.Name` (first-class table) | `bldg-3` or `_default` |
| 4 | Line | `UnsLine.Name` (first-class table) | `line-2` or `_default` |
| 5 | Equipment | `Equipment.Name` | `cnc-mill-05` |
| 6 | Signal | `Tag.Name` | `RunState`, `ActualFeedRate` |
OPC UA browse path: `ent/warsaw-west/bldg-3/line-2/cnc-mill-05/RunState`.
**`UnsArea` and `UnsLine` are first-class generation-versioned entities** so the UNS structure is manageable on its own — operators can rename `bldg-3``bldg-3a` and every equipment under it picks up the new path automatically; bulk-move 5 lines from one building to another with a single edit; etc. Equipment references `UnsLineId` (FK), not denormalized Area/Line strings.
**Naming rules** (validated at draft-publish time and in Admin UI):
- Each segment matches `^[a-z0-9-]{1,32}$`, OR equals the reserved placeholder `_default`
- Lowercase enforced; hyphens allowed within a segment, slashes only between segments
- Total path ≤ 200 characters
**Equipment is a first-class entity** with five distinct identifiers serving different audiences:
| Identifier | Audience | Mutability | Uniqueness | Purpose |
|------------|----------|:----------:|------------|---------|
| `EquipmentUuid` | Downstream events / dbt / Redpanda | **Immutable forever** | Globally unique (UUIDv4) | Permanent join key across systems and time |
| `EquipmentId` | Internal config DB | Immutable after publish | Within cluster | Stable logical key for cross-generation diffs |
| `MachineCode` | OT operators | Mutable (with publish) | Within cluster | Colloquial name in conversations and runbooks (e.g. `machine_001`) |
| `ZTag` | ERP integration | Mutable (rare) | Fleet-wide | **Primary identifier for browsing in Admin UI** — list/search default sort |
| `SAPID` | SAP PM integration | Mutable (rare) | Fleet-wide | Maintenance system join key |
All five are exposed as **OPC UA properties** on the equipment node. External systems can resolve equipment by whichever identifier they natively use — ERP queries by ZTag, SAP PM by SAPID, OT operators by MachineCode in conversation, downstream events by EquipmentUuid for permanent lineage. The OPC UA browse path uses `Equipment.Name` as the level-5 segment; the other identifiers do not appear in the path but are properties on the node.
**SystemPlatform namespace does NOT use UNS** — Galaxy's hierarchy is preserved as v1 LmxOpcUa exposes it (Area > Object). UNS rules apply only to drivers in Equipment-kind namespaces.
**Authority for equipment-class templates** lives in a future central `schemas` repo (not yet created per the 3-year-plan). v2.0 ships an `Equipment.EquipmentClassRef` column as a hook (nullable, FK-to-future); enforcement is added when the schemas repo lands. Cheap to add now, expensive to retrofit.
**Canonical machine state vocabulary** (`Running`, `Idle`, `Faulted`, `Starved`, `Blocked`) — derivation lives at Layer 3 (System Platform / Ignition), not in OtOpcUa. Our role is delivering the raw signals cleanly so derivation is accurate. Equipment-class templates from the schemas repo will define which raw signals each class exposes.
**Architecture:**
```
@@ -296,10 +349,25 @@ Within a cluster, both nodes serve **identical** address spaces — defining tag
ServerCluster ← top-level deployment unit (1 or 2 nodes)
- ClusterId (PK)
- Name ← human-readable e.g. "LINE3-OPCUA"
- Site grouping for fleet management e.g. "PlantA"
- EnterpriseUNS level 1, e.g. "ent" (validated [a-z0-9-]{1,32})
- Site ← UNS level 2, e.g. "warsaw-west" (validated [a-z0-9-]{1,32})
- NodeCount (1 | 2)
- RedundancyMode (None | Warm | Hot) ← None when NodeCount=1
- NamespaceUri ← shared by both nodes (per v1 redundancy model)
- Enabled
- Notes
-- NOTE: NamespaceUri removed; namespaces are now first-class rows in Namespace table
Namespace ← generation-versioned (revised after adversarial review finding #2),
1+ per cluster per generation
- NamespaceRowId (PK)
- GenerationId (FK)
- NamespaceId ← stable logical ID across generations, e.g. "LINE3-OPCUA-equipment"
- ClusterId (FK)
- Kind (Equipment | SystemPlatform | Simulated) ← UNIQUE (GenerationId, ClusterId, Kind)
- NamespaceUri ← e.g. "urn:ent:warsaw-west:equipment".
UNIQUE per generation; cross-generation invariant: once a
(NamespaceId, ClusterId) pair publishes a NamespaceUri,
it cannot change in any future generation
- Enabled
- Notes
@@ -350,9 +418,13 @@ DriverInstance ← rows reference GenerationId; new generations
- DriverInstanceId ← stable logical ID across generations
- ClusterId (FK) ← driver lives at the cluster level — both nodes
instantiate it identically (modulo node overrides)
- NamespaceId (FK) ← which namespace this driver populates.
Driver type restricts allowed namespace Kind:
Galaxy → SystemPlatform
Modbus/AB CIP/AB Legacy/S7/TwinCAT/FOCAS → Equipment
OpcUaClient → either, by config
- Name
- DriverType (Galaxy | ModbusTcp | AbCip | OpcUaClient | …)
- NamespaceUri ← per-driver namespace within the cluster's URI scope
- Enabled
- DriverConfig (JSON) ← schemaless, driver-type-specific settings.
Per-node overrides applied via
@@ -366,13 +438,60 @@ Device (for multi-device drivers like Modbus, CIP)
- Name
- DeviceConfig (JSON) ← host, port, unit ID, slot, etc.
UnsArea ← UNS level 3 (first-class for rename/move)
- UnsAreaRowId (PK)
- GenerationId (FK)
- UnsAreaId ← stable logical ID across generations
- ClusterId (FK)
- Name ← UNS level 3, [a-z0-9-]{1,32} or "_default"
- Notes
UnsLine ← UNS level 4 (first-class for rename/move)
- UnsLineRowId (PK)
- GenerationId (FK)
- UnsLineId ← stable logical ID across generations
- UnsAreaId (FK)
- Name ← UNS level 4, [a-z0-9-]{1,32} or "_default"
- Notes
Equipment ← UNS level-5 entity. Only for drivers in Equipment-kind namespace.
- EquipmentRowId (PK)
- GenerationId (FK)
- EquipmentId ← SYSTEM-GENERATED ('EQ-' + first 12 hex chars of EquipmentUuid).
Never operator-supplied, never editable, never in CSV imports.
(Revised after adversarial review finding #4 — operator-set ID
is a corruption path: typos mint duplicate identities.)
- EquipmentUuid (UUIDv4) ← IMMUTABLE across all generations of the same EquipmentId.
Validated by sp_ValidateDraft. Path/MachineCode/ZTag/SAPID
can change; UUID cannot.
- DriverInstanceId (FK) ← which driver provides data for this equipment
- DeviceId (FK, nullable) ← optional, for multi-device drivers
- UnsLineId (FK) ← UNS level-3+4 source via UnsLine→UnsArea
- Name ← UNS level 5, [a-z0-9-]{1,32} (the equipment name)
-- Operator-facing and external-system identifiers (all exposed as OPC UA properties)
- MachineCode ← Operator colloquial id (e.g. "machine_001"); REQUIRED;
unique within cluster
- ZTag ← ERP equipment id; nullable; unique fleet-wide;
PRIMARY identifier for browsing in Admin UI
- SAPID ← SAP PM equipment id; nullable; unique fleet-wide
- EquipmentClassRef ← nullable; future FK to schemas-repo template (TBD authority)
- Enabled
Tag
- TagRowId (PK)
- GenerationId (FK)
- TagId ← stable logical ID
- DeviceId (FK) or DriverInstanceId (FK)
- Name
- FolderPath ← address space hierarchy
- EquipmentId (FK, nullable) ← REQUIRED when driver is in Equipment-kind namespace.
NULL when driver is in SystemPlatform-kind namespace
(Galaxy hierarchy is preserved as v1 expressed it).
- DriverInstanceId (FK) ← always present (Equipment.DriverInstanceId mirrors this
when EquipmentId is set; redundant but indexed for joins)
- DeviceId (FK, nullable)
- Name ← signal name. UNS level 6 when in Equipment namespace.
- FolderPath ← only used when EquipmentId is NULL (SystemPlatform ns);
Equipment provides path otherwise.
- DataType
- AccessLevel (Read | ReadWrite)
- WriteIdempotent (bool) ← opt-in for write retry eligibility (see Polly section)
@@ -393,6 +512,22 @@ ClusterNodeGenerationState ← tracks which generation each NODE has applie
- LastAppliedAt
- LastAppliedStatus (Applied | RolledBack | Failed)
- LastAppliedError
ExternalIdReservation ← NOT generation-versioned (revised after adversarial review finding #3).
Fleet-wide ZTag/SAPID uniqueness that survives rollback,
disable, and re-enable. Per-generation indexes can't enforce
this — old generations still hold the same external IDs.
- ReservationId (PK)
- Kind (ZTag | SAPID)
- Value ← the identifier string
- EquipmentUuid ← which equipment owns this reservation, FOREVER
- ClusterId ← first cluster to publish it
- FirstPublishedAt / LastPublishedAt
- ReleasedAt / ReleasedBy / ReleaseReason ← non-null when explicitly released by FleetAdmin
Lifecycle: sp_PublishGeneration auto-reserves on publish. Disable doesn't release.
Rollback respects the reservation table. Explicit release is the only way to free a value
for reuse by a different EquipmentUuid. UNIQUE (Kind, Value) WHERE ReleasedAt IS NULL.
```
**Authorization model (server-side, enforced in DB):**
@@ -429,10 +564,14 @@ ClusterNodeGenerationState ← tracks which generation each NODE has applie
**Decided:**
- Central MSSQL database is the single source of truth for all configuration.
- **Top-level deployment unit is `ServerCluster`** with 1 or 2 `ClusterNode` members. Single-node and 2-node deployments use the same schema; single-node is a cluster of one.
- **Driver, device, tag, and poll-group config attaches to `ClusterId`, not to individual nodes.** Both nodes of a cluster serve identical address spaces.
- **Per-node overrides are minimal by intent** — `ClusterNode.DriverConfigOverridesJson` is the only override mechanism, scoped to driver-config settings that genuinely must differ per node (e.g. `MxAccess.ClientName`). Tags and devices have no per-node override path.
- **Driver, device, tag, equipment, and poll-group config attaches to `ClusterId`, not to individual nodes.** Both nodes of a cluster serve identical address spaces.
- **Per-node overrides are minimal by intent** — `ClusterNode.DriverConfigOverridesJson` is the only override mechanism, scoped to driver-config settings that genuinely must differ per node (e.g. `MxAccess.ClientName`). Tags, equipment, and devices have no per-node override path.
- **`ApplicationUri` is auto-suggested but never auto-rewritten.** When an operator creates a new `ClusterNode` in Admin, the UI prefills `urn:{Host}:OtOpcUa`. If the operator later changes `Host`, the UI surfaces a warning that `ApplicationUri` is **not** updated automatically — OPC UA clients pin trust to it, and a silent rewrite would force every client to re-pair. Operator must explicitly opt in to changing it.
- Each node identifies itself by `NodeId` and `ClusterId` **and authenticates with a credential bound to its NodeId**; the DB enforces the mapping server-side. A self-asserted `NodeId` is not accepted, and a node may not read another cluster's config.
- **Each cluster serves multiple namespaces through one endpoint**, modeled as first-class `Namespace` rows (Kind ∈ {Equipment, SystemPlatform, Simulated}). Adding a future namespace kind is a config-DB row insert + driver wiring, not a structural refactor.
- **UNS naming hierarchy mandatory in Equipment-kind namespaces**: 5 levels (Enterprise/Site/Area/Line/Equipment) with signals as level-6 children. Each segment validated `^[a-z0-9-]{1,32}$` or `_default`; total path ≤ 200 chars. SystemPlatform namespace preserves Galaxy's existing hierarchy unchanged.
- **Equipment is a first-class entity in Equipment namespaces** with stable `EquipmentUuid` (UUIDv4) immutable across renames, moves, and generations. Path can change; UUID cannot.
- **`Equipment.EquipmentClassRef` is a hook for future schemas-repo integration** — nullable now, FK enforcement added when the central `schemas` repo lands per the 3-year-plan.
- Local LiteDB cache for offline startup resilience, keyed by generation.
- JSON columns for driver-type-specific config (schemaless per driver type, structured at the fleet level).
- Multiple instances of the same driver type supported within one cluster.
@@ -589,7 +728,7 @@ Each step leaves the system runnable. The generic extraction is effectively free
2. **Migrate to .NET 10 x64** — retarget all projects except Galaxy Host
**Phase 1 — Core extraction + Configuration layer + Admin scaffold**
3. **Build `Configuration` project** — central MSSQL schema with `ServerCluster`, `ClusterNode`, `ClusterNodeCredential`, `ConfigGeneration`, `ClusterNodeGenerationState` plus the cluster-scoped `DriverInstance` / `Device` / `Tag` / `PollGroup` tables (EF Core + migrations); server-side authorization stored procs that enforce per-node-bound-to-cluster access from authenticated principals; atomic cluster-scoped publish/rollback stored procs; LiteDB local cache keyed by generation; generation-diff application logic; per-node override merge at apply time.
3. **Build `Configuration` project** — central MSSQL schema with `ServerCluster`, `ClusterNode`, `ClusterNodeCredential`, `Namespace` (generation-versioned), `UnsArea`, `UnsLine`, `ConfigGeneration`, `ClusterNodeGenerationState`, `ExternalIdReservation` (NOT generation-versioned, fleet-wide ZTag/SAPID uniqueness) plus the cluster-scoped `DriverInstance` / `Device` / `Equipment` / `Tag` / `PollGroup` tables (EF Core + migrations); UNS naming validators (segment regex, path length, `_default` placeholder, UUIDv4 immutability across generations, system-generated EquipmentId, same-cluster namespace binding, ZTag/SAPID reservation pre-flight, within-cluster uniqueness for MachineCode); server-side authorization stored procs that enforce per-node-bound-to-cluster access from authenticated principals; atomic cluster-scoped publish/rollback stored procs (`sp_PublishGeneration` reserves external IDs atomically; `sp_ReleaseExternalIdReservation` is FleetAdmin-only); LiteDB local cache keyed by generation; generation-diff application logic; per-node override merge at apply time.
4. **Extract `Core.Abstractions`** — define `IDriver`, `ITagDiscovery`, `IReadable`, `IWritable`, `ISubscribable`, `IAlarmSource`, `IHistoryProvider`. `IWritable` contract separates idempotent vs. non-idempotent writes at the interface level.
5. **Build `Core`** — generic driver-hosting node manager that delegates to capability interfaces, driver isolation (catch/contain), address space registration, separate Polly pipelines for reads vs. writes per the write-retry policy above.
6. **Wire `Server`** — bootstrap from Configuration using an instance-bound credential (cert/gMSA/SQL login), fail fast if the credential is rejected, register drivers, start Core.
@@ -732,6 +871,25 @@ Each step leaves the system runnable. The generic extraction is effectively free
| 104 | Admin auth pattern lifted directly from ScadaLink: `LdapAuthService` + `RoleMapper` + `JwtTokenService` + cookie auth + `CookieAuthenticationStateProvider` | Same login form, same cookie scheme (30-min sliding), same claim shape (Name, DisplayName, Username, Role[], optional ClusterId[] scope), parallel `/auth/token` endpoint for API clients. Code lives in `ZB.MOM.WW.OtOpcUa.Admin.Security` (sibling of `ScadaLink.Security`); consolidate to a shared NuGet only if it later makes operational sense | 2026-04-17 |
| 105 | Cluster-scoped admin grants ship in v2.0 (lifted from v2.1 deferred list) | ScadaLink already ships the equivalent site-scoped pattern (`PermittedSiteIds` claim, `IsSystemWideDeployment` flag), so we get cluster-scoped grants free by mirroring it. `LdapGroupRoleMapping` table maps groups → role + cluster scope; users without explicit cluster claims are system-wide | 2026-04-17 |
| 106 | Shared component set copied verbatim from ScadaLink CentralUI | `DataTable`, `ConfirmDialog`, `LoadingSpinner`, `ToastNotification`, `TimestampDisplay`, `RedirectToLogin`, `NotAuthorizedView`. New Admin-specific shared components added to our folder rather than diverging from ScadaLink's set, so the shared vocabulary stays aligned | 2026-04-17 |
| 107 | Each cluster serves multiple OPC UA namespaces through one endpoint, modeled as first-class `Namespace` rows | Per 3-year-plan handoff §4: at v2.0 GA there are two namespaces (Equipment for raw signals, SystemPlatform for Galaxy-derived data); future Simulated namespace must be addable as a config-DB row + driver wiring, not a structural refactor. UNIQUE (ClusterId, Kind) | 2026-04-17 |
| 108 | UNS 5-level naming hierarchy mandatory in Equipment-kind namespaces | Per 3-year-plan handoff §12: Enterprise/Site/Area/Line/Equipment with signals as level-6 children. Each segment `^[a-z0-9-]{1,32}$` or `_default`; total path ≤ 200 chars. Validated at draft-publish and in Admin UI. SystemPlatform namespace preserves Galaxy's existing hierarchy unchanged — UNS rules don't apply there | 2026-04-17 |
| 109 | `Equipment` is a first-class entity in Equipment namespaces with stable `EquipmentUuid` (UUIDv4), immutable across renames/moves/generations | Per handoff §12: path can change (rename, move) but UUID cannot. Downstream consumers (Redpanda events, dbt) carry both UUID for joins/lineage and path for dashboards/filtering. `sp_ValidateDraft` enforces UUID-per-EquipmentId is constant across all generations of a cluster | 2026-04-17 |
| 110 | Tag belongs to Equipment in Equipment namespaces; tag belongs to Driver+FolderPath in SystemPlatform namespaces | Single `Tag` table with nullable `EquipmentId`. When set (Equipment ns), full path is computed `Enterprise/Site/Area/Line/Name/TagName`. When null (SystemPlatform ns), v1-style `DriverInstanceId + FolderPath` provides the path. Application-level constraint enforced by `sp_ValidateDraft`, not DB CHECK | 2026-04-17 |
| 111 | Driver type restricts allowed namespace Kind | Galaxy → SystemPlatform only; Modbus/AB CIP/AB Legacy/S7/TwinCAT/FOCAS → Equipment only; OpcUaClient → either, by config. Encoded in `Core.Abstractions` driver-type registry; enforced by `sp_ValidateDraft` | 2026-04-17 |
| 112 | `Equipment.EquipmentClassRef` shipped as nullable hook in v2.0 for future schemas-repo integration | Per handoff §12: equipment-class templates will live in a central `schemas` repo (not yet created). Cheap to add the column now; expensive to retrofit later. Enforcement added when schemas repo lands. v2.0 ships without template validation | 2026-04-17 |
| 113 | Canonical machine state derivation lives at Layer 3, not in OtOpcUa | Per handoff §13: `Running`/`Idle`/`Faulted`/`Starved`/`Blocked` derivation is System Platform / Ignition's job. OtOpcUa's role is delivering raw signals cleanly so derivation is accurate. Equipment-class templates (when schemas repo lands) define which raw signals each class exposes | 2026-04-17 |
| 114 | Future `Simulated` namespace architecturally supported, not v2.0 committed | Per handoff §14: `Simulated` is named as the next namespace kind for replaying historical equipment data without physical equipment. The `Namespace.Kind` enum reserves the value; no driver implementation in v2.0. Adds via config-DB row + a future replay driver | 2026-04-17 |
| 115 | UNS structure (Area, Line) modeled as first-class generation-versioned tables (`UnsArea`, `UnsLine`), not denormalized strings on Equipment | Renaming an area or moving lines between buildings is a single edit that propagates to every equipment under it; bulk-restructure operations work cleanly. Generation-versioning preserves the publish/diff/rollback safety boundary for structural changes | 2026-04-17 |
| 116 | Equipment carries five identifiers: EquipmentUuid, EquipmentId, MachineCode, ZTag, SAPID — each with a different audience | Single-identifier-per-equipment can't satisfy the diverse consumer set: downstream events need a UUID for permanent lineage, OT operators say `machine_001` (MachineCode), ERP queries by ZTag, SAP PM by SAPID, internal config diffs need a stable EquipmentId. All five exposed as OPC UA properties on the equipment node so external systems resolve by their preferred identifier without a sidecar | 2026-04-17 |
| 117 | `ZTag` is the primary browse identifier in the Admin UI | Equipment list/search defaults to ZTag column + sort. MachineCode shown alongside; SAPID searchable. The OPC UA browse path itself uses `Equipment.Name` (UNS-segment rules); ZTag/MachineCode/SAPID are properties on the node, not path components | 2026-04-17 |
| 118 | `MachineCode` required, fleet-wide uniqueness on `ZTag` and `SAPID` when set | MachineCode is the operator's colloquial name — every equipment must have one. ZTag and SAPID are external system identifiers that may not exist for newly commissioned equipment. Fleet-wide uniqueness on ERP/SAP IDs prevents the same external identifier from referencing two equipment in our config (which would silently corrupt joins) | 2026-04-17 |
| 119 | MachineCode/ZTag/SAPID free-text, not subject to UNS regex | These are external system identifiers, not OPC UA path segments. They can carry whatever conventions ERP/SAP/operator workflows use (mixed case, underscores, vendor-specific schemes). Validation is only non-empty (when present) and ≤64 chars | 2026-04-17 |
| 120 | Admin UI exposes UNS structure as a first-class management surface | Dedicated **UNS Structure tab** with tree of UnsArea → UnsLine → Equipment, drag-drop reorganize, rename with live impact preview ("X lines, Y equipment, Z signals will pick up new path"). Hybrid model: read-only navigation over the published generation, click-to-edit opens the draft editor scoped to that node. Bulk-rename and bulk-move propagate through UnsLineId FK (no per-equipment row rewrite) | 2026-04-17 |
| 121 | All five equipment identifiers exposed as OPC UA properties on the equipment node | `MachineCode`, `ZTag`, `SAPID`, `EquipmentUuid`, `EquipmentId` are properties so external systems resolve equipment by their preferred identifier without a sidecar lookup service. Browse path uses `Equipment.Name` as the level-5 segment (UNS-compliant); the other identifiers are properties, not path components | 2026-04-17 |
| 122 | Same-cluster invariant on `DriverInstance.NamespaceId` enforced in three layers (sp_ValidateDraft, API scoping, audit) | Without enforcement a draft for cluster A could bind to cluster B's namespace, leaking the URI into A's endpoint and breaking tenant isolation. UI filtering alone is insufficient — server-side scoping prevents bypass via crafted requests. Cross-cluster attempts audit-logged as `CrossClusterNamespaceAttempt`. (Closes adversarial review 2026-04-17 finding #1, critical) | 2026-04-17 |
| 123 | `Namespace` is generation-versioned (revised from earlier "cluster-level" decision) | A cluster-level namespace lets an admin disable a namespace that a published driver depends on, breaking the live config without a generation change and making rollback unreproducible. Namespaces affect what consumers see at the OPC UA endpoint — they are content, not topology — and must travel through draft → diff → publish like every other consumer-visible config. Cross-generation invariant: once a (NamespaceId, ClusterId) publishes a NamespaceUri/Kind, it cannot change. (Closes adversarial review 2026-04-17 finding #2, supersedes part of #107) | 2026-04-17 |
| 124 | ZTag/SAPID fleet-wide uniqueness backed by an `ExternalIdReservation` table, NOT generation-versioned per-generation indexes | Per-generation indexes fail under rollback and disable: old generations and disabled equipment can still hold the same external IDs, so rollback or re-enable can silently reintroduce duplicates that corrupt downstream ERP/SAP joins. The reservation table sits outside generation versioning, survives rollback, and reserves fresh values atomically at publish via `sp_PublishGeneration`. Explicit FleetAdmin release (audit-logged) is the only path that frees a value for reuse by a different EquipmentUuid. (Closes adversarial review 2026-04-17 finding #3) | 2026-04-17 |
| 125 | `Equipment.EquipmentId` is system-generated (`'EQ-' + first 12 hex chars of EquipmentUuid`), never operator-supplied or editable, never in CSV imports | Operator-supplied IDs are a real corruption path: typos and bulk-import renames mint new EquipmentIds, which then get new UUIDs even when the physical asset is the same. That permanently splits downstream joins keyed on EquipmentUuid. Removing operator authoring of EquipmentId eliminates the failure mode entirely. CSV imports match by EquipmentUuid (preferred) for updates; rows without UUID create new equipment with system-generated identifiers. Explicit Merge / Rebind operator flow handles the rare case where two UUIDs need to be reconciled. (Closes adversarial review 2026-04-17 finding #4, supersedes part of #116) | 2026-04-17 |
## Reference Documents