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:
@@ -187,13 +187,17 @@ If we discover an Admin-specific component need, add it to our Shared folder rat
|
||||
```
|
||||
/ Fleet Overview (default landing)
|
||||
/clusters Cluster list
|
||||
/clusters/{ClusterId} Cluster detail
|
||||
/clusters/{ClusterId}/nodes/{NodeId} Node detail
|
||||
/clusters/{ClusterId}/draft Draft editor (drivers/devices/tags)
|
||||
/clusters/{ClusterId}/draft/diff Draft vs current diff viewer
|
||||
/clusters/{ClusterId}/generations Generation history
|
||||
/clusters/{ClusterId}/generations/{Id} Generation detail (read-only view of any generation)
|
||||
/clusters/{ClusterId}/audit Audit log filtered to this cluster
|
||||
/clusters/{ClusterId} Cluster detail (tabs: Overview / Namespaces / UNS Structure / Drivers / Devices / Equipment / Tags / Generations / Audit)
|
||||
/clusters/{ClusterId}/nodes/{NodeId} Node detail
|
||||
/clusters/{ClusterId}/namespaces Namespace management (generation-versioned via draft → publish; same boundary as drivers/tags)
|
||||
/clusters/{ClusterId}/uns UNS structure management (areas, lines, drag-drop reorganize)
|
||||
/clusters/{ClusterId}/equipment Equipment list (default sorted by ZTag)
|
||||
/clusters/{ClusterId}/equipment/{EquipmentId} Equipment detail (5 identifiers, UNS placement, signals, audit)
|
||||
/clusters/{ClusterId}/draft Draft editor (drivers/devices/equipment/tags)
|
||||
/clusters/{ClusterId}/draft/diff Draft vs current diff viewer
|
||||
/clusters/{ClusterId}/generations Generation history
|
||||
/clusters/{ClusterId}/generations/{Id} Generation detail (read-only view of any generation)
|
||||
/clusters/{ClusterId}/audit Audit log filtered to this cluster
|
||||
/credentials Credential management (FleetAdmin only)
|
||||
/audit Fleet-wide audit log
|
||||
/admin/users Admin role assignments (FleetAdmin only)
|
||||
@@ -224,14 +228,49 @@ Tabbed view for one cluster.
|
||||
|
||||
**Tabs:**
|
||||
|
||||
1. **Overview** — cluster metadata (name, site, redundancy mode, namespace URI), node table with online/offline/role/generation/last-applied-status, current published generation summary, draft status (none / in progress / ready to publish)
|
||||
2. **Drivers** — table of `DriverInstance` rows in the *current published* generation, with per-row navigation to driver-specific config screens. "Edit in draft" button creates or opens the cluster's draft.
|
||||
3. **Devices** — table of `Device` rows (where applicable), grouped by `DriverInstance`
|
||||
4. **Tags** — paged, filterable table of all tags. Filters: driver, device, folder path, name pattern, data type. Bulk operations toolbar: export to CSV, import from CSV (validated against active draft).
|
||||
5. **Generations** — generation history list (see Generation History page)
|
||||
6. **Audit** — filtered audit log
|
||||
1. **Overview** — cluster metadata (name, Enterprise, Site, redundancy mode), namespace summary (which kinds are configured + their URIs), node table with online/offline/role/generation/last-applied-status, current published generation summary, draft status (none / in progress / ready to publish)
|
||||
2. **Namespaces** — list of `Namespace` rows for this cluster *in the current published generation* (Kind, NamespaceUri, Enabled). **Namespaces are generation-versioned** (revised after adversarial review finding #2): add / disable / re-enable a namespace by opening a draft, making the change, and publishing. The tab is read-only when no draft is open; "Edit in draft" button opens the cluster's draft scoped to the namespace section. Equipment kind is auto-included in the cluster's first generation; SystemPlatform kind is added when a Galaxy driver is configured. Simulated kind is reserved (operator can add a row of `Kind = 'Simulated'` in a draft but no driver populates it in v2.0; UI shows "Awaiting replay driver — see roadmap" placeholder).
|
||||
3. **UNS Structure** — tree view of `UnsArea` → `UnsLine` → `Equipment` for this cluster's current published generation. Operators can:
|
||||
- Add/rename/delete areas and lines (changes go into the active draft)
|
||||
- Bulk-move lines between areas (drag-and-drop in the tree, single edit propagates UNS path changes to all equipment under the moved line)
|
||||
- Bulk-move equipment between lines
|
||||
- View live UNS path preview per node (`Enterprise/Site/Area/Line/Equipment`)
|
||||
- See validation errors inline (segment regex, length cap, _default placeholder rules)
|
||||
- Counts per node: # lines per area, # equipment per line, # signals per equipment
|
||||
- Path-rename impact: when renaming an area, UI shows "X lines, Y equipment, Z signals will pick up new path" before commit
|
||||
4. **Drivers** — table of `DriverInstance` rows in the *current published* generation, with per-row namespace assignment shown. Per-row navigation to driver-specific config screens. "Edit in draft" button creates or opens the cluster's draft.
|
||||
5. **Devices** — table of `Device` rows (where applicable), grouped by `DriverInstance`
|
||||
6. **Equipment** — table of `Equipment` rows in the current published generation, scoped to drivers in Equipment-kind namespaces. **Default sort: ZTag ascending** (the primary browse identifier per decision #117). Default columns:
|
||||
- `ZTag` (primary, bold, copyable)
|
||||
- `MachineCode` (secondary, e.g. `machine_001`)
|
||||
- Full UNS path (rendered live from cluster + UnsLine→UnsArea + Equipment.Name)
|
||||
- `SAPID` (when set)
|
||||
- `EquipmentUuid` (collapsed badge, copyable on click — "show UUID" toggle to expand)
|
||||
- `EquipmentClassRef` (placeholder until schemas repo lands)
|
||||
- DriverInstance, DeviceId, Enabled
|
||||
|
||||
The Drivers/Devices/Tags tabs are **read-only views** of the published generation; editing is done in the dedicated draft editor to make the publish boundary explicit.
|
||||
Search bar supports any of the five identifiers (ZTag, MachineCode, SAPID, EquipmentId, EquipmentUuid) — operator types and the search dispatches across all five with a typeahead that disambiguates ("Found in ZTag" / "Found in MachineCode" labels on each suggestion). Per-row click opens the Equipment Detail page.
|
||||
7. **Tags** — paged, filterable table of all tags. Filters: namespace kind, equipment (by ZTag/MachineCode/SAPID), driver, device, folder path, name pattern, data type. For Equipment-ns tags the path is shown as the full UNS path; for SystemPlatform-ns tags the v1-style `FolderPath/Name` is shown. Bulk operations toolbar: export to CSV, import from CSV (validated against active draft).
|
||||
8. **Generations** — generation history list (see Generation History page)
|
||||
9. **Audit** — filtered audit log
|
||||
|
||||
The Drivers/Devices/Equipment/Tags tabs are **read-only views** of the published generation; editing is done in the dedicated draft editor to make the publish boundary explicit. The Namespaces tab and the UNS Structure tab follow the same hybrid pattern: navigation is read-only over the published generation, click-to-edit on any node opens the draft editor scoped to that node. **No table in v2.0 is edited outside the publish boundary** (revised after adversarial review finding #2).
|
||||
|
||||
### Equipment Detail (`/clusters/{ClusterId}/equipment/{EquipmentId}`)
|
||||
|
||||
Per-equipment view. Form sections:
|
||||
|
||||
- **Identifiers panel**: all five identifiers, with explicit purpose labels and copy-to-clipboard buttons
|
||||
- `ZTag` — editable; live fleet-wide uniqueness check via `ExternalIdReservation` (warns if value is currently held by another EquipmentUuid; cannot save unless reservation is released first)
|
||||
- `MachineCode` — editable; live within-cluster uniqueness check
|
||||
- `SAPID` — editable; same reservation-backed check as ZTag
|
||||
- `EquipmentId` — **read-only forever** (revised after adversarial review finding #4). System-generated as `'EQ-' + first 12 hex chars of EquipmentUuid`. Never operator-editable, never present in any input form, never accepted from CSV imports
|
||||
- `EquipmentUuid` — read-only forever (auto-generated UUIDv4 on creation, never editable; copyable badge with "downstream consumers join on this" tooltip)
|
||||
- **UNS placement panel**: UnsArea/UnsLine pickers (typeahead from existing structure); `Equipment.Name` field with live segment validation; live full-path preview with character counter
|
||||
- **Class template panel**: `EquipmentClassRef` — free text in v2.0; becomes a typeahead picker when schemas repo lands
|
||||
- **Driver source panel**: DriverInstance + DeviceId pickers (filtered to drivers in Equipment-kind namespaces of this cluster)
|
||||
- **Signals panel**: list of `Tag` rows that belong to this equipment; inline edit not supported here (use Draft Editor's Tags panel for editing); read-only with a "Edit in draft" deep link
|
||||
- **Audit panel**: filtered audit log scoped to this equipment row across generations
|
||||
|
||||
### Node Detail (`/clusters/{ClusterId}/nodes/{NodeId}`)
|
||||
|
||||
@@ -248,17 +287,40 @@ Per-node view for `ClusterNode` management.
|
||||
|
||||
### Draft Editor (`/clusters/{ClusterId}/draft`)
|
||||
|
||||
The primary edit surface. Three-panel layout: tree on the left (drivers → devices → tags), edit form on the right, validation panel at the bottom.
|
||||
The primary edit surface. Three-panel layout: tree on the left (Drivers → Devices → Equipment → Tags, with Equipment shown only for drivers in Equipment-kind namespaces), edit form on the right, validation panel at the bottom.
|
||||
|
||||
- **Drivers panel**: add/edit/remove `DriverInstance` rows in the draft. Each driver type opens a driver-specific config screen (deferred per #27). Generic fields (Name, NamespaceUri, Enabled) are always editable.
|
||||
- **Drivers panel**: add/edit/remove `DriverInstance` rows in the draft. Each driver type opens a driver-specific config screen (deferred per #27). Generic fields (Name, NamespaceId, Enabled) are always editable. The NamespaceId picker is filtered to namespace kinds that are valid for the chosen driver type (e.g. selecting `DriverType=Galaxy` restricts the picker to SystemPlatform-kind namespaces only).
|
||||
- **Devices panel**: scoped to the selected driver instance (where applicable)
|
||||
- **UNS Structure panel** (Equipment-ns drivers only): tree of UnsArea → UnsLine; CRUD on areas and lines; rename and move operations with live impact preview ("renaming bldg-3 → bldg-3a will update 12 lines, 47 equipment, 1,103 signal paths"); validator rejects identity reuse with a different parent
|
||||
- **Equipment panel** (Equipment-ns drivers only):
|
||||
- Add/edit/remove `Equipment` rows scoped to the selected driver
|
||||
- Inline form sections:
|
||||
- **Identifiers**: `MachineCode` (required, e.g. `machine_001`, validates within-cluster uniqueness live); `ZTag` (optional, ERP id, validates fleet-wide uniqueness via `ExternalIdReservation` lookup live — surfaces "currently reserved by EquipmentUuid X in cluster Y" if collision); `SAPID` (optional, SAP PM id, same reservation-backed check)
|
||||
- **UNS placement**: `UnsLineId` picker (typeahead from existing structure or "Create new line" inline); `Name` (UNS level 5, live segment validation `^[a-z0-9-]{1,32}$`)
|
||||
- **Class template**: `EquipmentClassRef` (free text in v2.0; becomes a typeahead picker when schemas repo lands)
|
||||
- **Source**: `DeviceId` (when driver has multiple devices); `Enabled`
|
||||
- **`EquipmentUuid` is auto-generated UUIDv4 on creation, displayed read-only as a copyable badge**, never editable. **`EquipmentId` is also auto-generated** (`'EQ-' + first 12 hex chars of EquipmentUuid`) and never editable in any form. Both stay constant across renames, MachineCode/ZTag/SAPID edits, and area/line moves. The validator rejects any draft that tries to change either value on a published equipment.
|
||||
- **Live UNS path preview** above the form: `{Cluster.Enterprise}/{Cluster.Site}/{UnsArea.Name}/{UnsLine.Name}/{Name}` with character count and ≤200 limit indicator
|
||||
- Bulk operations:
|
||||
- Move many equipment from one line to another (UUIDs and identifiers preserved)
|
||||
- Bulk-edit MachineCode/ZTag/SAPID via inline grid (validation per row)
|
||||
- Bulk-create equipment from CSV (one row per equipment; UUIDs auto-generated for new rows)
|
||||
- **Tags panel**:
|
||||
- Tree view by `FolderPath`
|
||||
- Tree view: by Equipment when in Equipment-ns; by `FolderPath` when in SystemPlatform-ns
|
||||
- Inline edit for individual tags (Name, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig JSON in a structured editor)
|
||||
- **Bulk operations**: select multiple tags → bulk edit (change poll group, access level, etc.)
|
||||
- **CSV import**: upload a CSV with `(DriverInstanceId, DeviceId?, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)` columns. Preview shows additions/modifications/removals against current draft, with row-level validation errors. Operator confirms or cancels.
|
||||
- **CSV export**: emit the same shape from the current published generation, useful as a starting point for bulk edits in Excel
|
||||
- **Validation panel** runs `sp_ValidateDraft` continuously (debounced) and surfaces FK errors, JSON schema errors, duplicate paths, missing references. Publish button is disabled while errors exist.
|
||||
- **CSV import** schemas (one per namespace kind):
|
||||
- Equipment-ns: `(EquipmentId, Name, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)`
|
||||
- SystemPlatform-ns: `(DriverInstanceId, DeviceId?, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)`
|
||||
- Preview shows additions/modifications/removals against current draft, with row-level validation errors. Operator confirms or cancels.
|
||||
- **CSV export**: emit the matching shape from the current published generation
|
||||
- **Equipment CSV import** (separate flow): bulk-create-or-update equipment. Columns: `(EquipmentUuid?, MachineCode, ZTag?, SAPID?, UnsAreaName, UnsLineName, Name, DriverInstanceId, DeviceId?, EquipmentClassRef?)`. **No `EquipmentId` column** (revised after adversarial review finding #4 — operator-supplied EquipmentId would mint duplicate equipment identity on typos):
|
||||
- **Row with `EquipmentUuid` set**: matches existing equipment by UUID, updates the matched row's editable fields (MachineCode/ZTag/SAPID/UnsLineId/Name/EquipmentClassRef/DeviceId/Enabled). Mismatched UUID = error, abort row.
|
||||
- **Row without `EquipmentUuid`**: creates new equipment. System generates fresh UUID and `EquipmentId = 'EQ-' + first 12 hex chars`. Cannot be used to update an existing row — operator must include UUID for updates.
|
||||
- UnsArea/UnsLine resolved by name within the cluster (auto-create if not present, with validation prompt).
|
||||
- Identifier uniqueness checks run row-by-row with errors surfaced before commit. ZTag/SAPID checked against `ExternalIdReservation` — collisions surface inline with the conflicting EquipmentUuid named.
|
||||
- Explicit "merge equipment A into B" or "rebind ZTag from A to B" operations are not in the CSV import path — see the Merge / Rebind operator flow below.
|
||||
- **Validation panel** runs `sp_ValidateDraft` continuously (debounced 500 ms) and surfaces FK errors, JSON schema errors, duplicate paths, missing references, UNS naming-rule violations, UUID-immutability violations, and driver-type-vs-namespace-kind mismatches. Publish button is disabled while errors exist.
|
||||
- **Diff link** at top: opens the diff viewer comparing the draft against the current published generation
|
||||
|
||||
### Diff Viewer (`/clusters/{ClusterId}/draft/diff`)
|
||||
@@ -325,9 +387,15 @@ The generic JSON editor uses the per-driver JSON schema from `DriverTypeRegistry
|
||||
### Add a new cluster
|
||||
|
||||
1. FleetAdmin: `/clusters` → "New cluster"
|
||||
2. Form: Name, Site, NodeCount (1 or 2), RedundancyMode (auto-set based on NodeCount), NamespaceUri (auto-suggested from name)
|
||||
3. Save → cluster row created (`Status = Enabled`, no generations yet)
|
||||
4. Redirect to Cluster Detail; prompt to add nodes
|
||||
2. Form: Name, **Enterprise** (UNS level 1, e.g. `ent`, validated `^[a-z0-9-]{1,32}$`), **Site** (UNS level 2, e.g. `warsaw-west`, same validation), NodeCount (1 or 2), RedundancyMode (auto-set based on NodeCount)
|
||||
3. Save → cluster row created (`Enabled = 1`, no generations yet)
|
||||
4. **Open initial draft** containing default namespaces:
|
||||
- Equipment-kind namespace (`NamespaceId = {ClusterName}-equipment`, `NamespaceUri = urn:{Enterprise}:{Site}:equipment`). Operator can edit URI in the draft before publish.
|
||||
- Prompt: "This cluster will host a Galaxy / System Platform driver?" → if yes, the draft also includes a SystemPlatform-kind namespace (`urn:{Enterprise}:{Site}:system-platform`). If no, skip — operator can add it later via a draft.
|
||||
5. Operator reviews the initial draft, optionally adds the first nodes' worth of drivers/equipment, then publishes generation 1. The cluster cannot serve any consumer until generation 1 is published (no namespaces exist before that).
|
||||
6. Redirect to Cluster Detail; prompt to add nodes via the Node tab (cluster topology) — node addition itself remains cluster-level since `ClusterNode` rows are physical-machine topology, not consumer-visible content.
|
||||
|
||||
(Revised after adversarial review finding #2 — namespaces must travel through the publish boundary; the cluster-create flow no longer writes namespace rows directly.)
|
||||
|
||||
### Add a node to a cluster
|
||||
|
||||
@@ -371,6 +439,33 @@ The "no new generation" choice is deliberate: overrides are operationally bound
|
||||
3. Wait for `LastAppliedAt` on the node to advance (proves the new credential is being used by the node — operator-side work to provision the new credential on the node's machine happens out-of-band)
|
||||
4. Once verified, disable the old credential → only the new one is valid
|
||||
|
||||
### Release an external-ID reservation
|
||||
|
||||
When equipment is permanently retired and its `ZTag` or `SAPID` needs to be reusable by a different physical asset (a known-rare event):
|
||||
|
||||
1. FleetAdmin: navigate to Equipment Detail of the retired equipment, or to a global "External ID Reservations" view
|
||||
2. Select the reservation (Kind + Value), click "Release"
|
||||
3. Modal requires: confirmation of the EquipmentUuid that currently holds the reservation, and a free-text **release reason** (compliance audit trail)
|
||||
4. Confirm → `sp_ReleaseExternalIdReservation` runs: sets `ReleasedAt`, `ReleasedBy`, `ReleaseReason`. Audit-logged with `EventType = 'ExternalIdReleased'`.
|
||||
5. The same `(Kind, Value)` can now be reserved by a different EquipmentUuid in a future publish. The released row stays in the table forever for audit.
|
||||
|
||||
This is the **only** path that allows ZTag/SAPID reuse — no implicit release on equipment disable, no implicit release on cluster delete. Requires explicit FleetAdmin action with a documented reason.
|
||||
|
||||
### Merge or rebind equipment (rare)
|
||||
|
||||
When operators discover that two `EquipmentRow`s in different generations actually represent the same physical asset (e.g. a typo created a duplicate) — or when an asset's identity has been incorrectly split across UUIDs — the resolution is **not** an in-place EquipmentId edit (which is now impossible per finding #4). Instead:
|
||||
|
||||
1. FleetAdmin: Equipment Detail of the row that should be retained → "Merge from another EquipmentUuid"
|
||||
2. Pick the source EquipmentUuid (the one to retire); modal shows a side-by-side diff of identifiers and signal counts
|
||||
3. Confirm → opens a **draft** that:
|
||||
- Disables the source equipment row (`Enabled = 0`) and adds an `EventType = 'EquipmentMergedAway'` audit entry naming the target UUID
|
||||
- Re-points any tags currently on the source equipment to the target equipment
|
||||
- If the source held a ZTag/SAPID reservation that should move to the target: explicit release of the source's reservation followed by re-reservation under the target UUID, both audit-logged
|
||||
4. Operator reviews the draft diff; publishes
|
||||
5. Downstream consumers see the source EquipmentUuid disappear (joins on it return historical data only) and the target EquipmentUuid gain the merged tags
|
||||
|
||||
Merge is a destructive lineage operation — the source EquipmentUuid is never reused, but its history persists in old generations + audit log. Rare by intent; UI buries the action behind two confirmation prompts.
|
||||
|
||||
## Deferred / Out of Scope
|
||||
|
||||
- **Cluster-scoped admin grants** (`ConfigEditor` for Cluster X only, not for Cluster Y) — surface in v2.1
|
||||
@@ -391,6 +486,9 @@ The "no new generation" choice is deliberate: overrides are operationally bound
|
||||
- Draft → diff → publish is the only edit path; no in-place edits
|
||||
- Sticky alerts require manual ack
|
||||
- Per-node overrides are NOT generation-versioned
|
||||
- **All content edits go through the draft → diff → publish boundary** — Namespaces, UNS Structure, Drivers, Devices, Equipment, Tags. The UNS Structure and Namespaces tabs are hybrid (read-only navigation over the published generation, click-to-edit opens the draft editor scoped to that node). No table is editable outside the publish boundary in v2.0 (revised after adversarial review finding #2 — earlier draft mistakenly treated namespaces as cluster-level)
|
||||
- **Equipment list defaults to ZTag sort** (primary browse identifier per the 3-year-plan handoff). All five identifiers (ZTag/MachineCode/SAPID/EquipmentId/EquipmentUuid) are searchable; typeahead disambiguates which field matched
|
||||
- **EquipmentUuid is read-only forever** in the UI; never editable. Auto-generated UUIDv4 on equipment creation, displayed as a copyable badge
|
||||
|
||||
**Resolved Defaults**:
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -285,6 +285,15 @@ Galaxy has a Tier C deep dive in `driver-stability.md` covering the STA pump, CO
|
||||
- **STA pump health probe** every 10 s (separate from the proxy↔host heartbeat). A wedged pump is the most likely Tier C failure mode for Galaxy.
|
||||
- **Recycle preserves cached `time_of_last_deploy` watermark** — the common case (crash unrelated to redeploy) skips full DB rediscovery for faster recovery.
|
||||
|
||||
### Namespace Assignment
|
||||
|
||||
Galaxy is the canonical **SystemPlatform-kind namespace** driver. It exposes Aveva System Platform / Galaxy objects as OPC UA — these are *processed* values with business meaning attached at Layer 3, not raw equipment signals. Per `plan.md` §4:
|
||||
|
||||
- The Galaxy driver's `DriverInstance.NamespaceId` must reference a `Namespace` row with `Kind = 'SystemPlatform'`.
|
||||
- **UNS naming rules do NOT apply** to the Galaxy hierarchy. Tags belong to `DriverInstanceId + FolderPath` (v1 LmxOpcUa pattern preserved); `Tag.EquipmentId` is NULL.
|
||||
- The Galaxy hierarchy reflects the gobject parent chain as v1 has always done — no migration to UNS path conventions in v2.
|
||||
- If a future need arises to expose raw Galaxy gobject data alongside processed (e.g. an Aveva-Wonderware Historian raw signal feed), that becomes a *separate* driver instance assigned to an Equipment-kind namespace, with its own per-equipment mapping.
|
||||
|
||||
---
|
||||
|
||||
## 2. Modbus TCP Driver
|
||||
@@ -994,19 +1003,28 @@ Tier A (pure managed, OPC Foundation reference SDK). Universal protections cover
|
||||
- **Cascading quality**: when the upstream server reports Bad on a node, fan it out locally with the **same** StatusCode (don't translate to a generic Bad) so downstream clients can distinguish "remote source down" from "local driver failure." Preserve upstream timestamps too — overwriting them with `DateTime.UtcNow` masks staleness.
|
||||
- **Browse cache memory**: `BrowseStrategy=Full` against a large remote server can cache tens of thousands of node descriptions. Per-instance budget should bound this; on breach, switch to `Lazy` strategy and let cache pressure drive eviction. No process action.
|
||||
|
||||
### Namespace Assignment
|
||||
|
||||
OPC UA Client is the only driver that supports **either** namespace kind, decided per driver instance via `DriverConfig.TargetNamespaceKind`:
|
||||
|
||||
- **Equipment**: when gatewaying a remote OPC UA server that exposes raw equipment data (e.g. another vendor's OPC UA-native PLC stack). The driver must remap remote browse paths to UNS via a config-driven mapping table — remote nodes don't conform to UNS by default. Each remote node group → an `Equipment` row with its own UNS Area/Line/Name and stable UUID.
|
||||
- **SystemPlatform**: when gatewaying a remote OPC UA server that exposes processed/derived data (e.g. another System Platform endpoint). Hierarchy is preserved via `Tag.FolderPath` mirroring the remote browse path; no UNS conversion.
|
||||
|
||||
The driver enforces the namespace choice at startup — a misconfigured remote (raw signals routed to SystemPlatform, or processed data routed to Equipment without a UNS mapping) fails draft validation, not runtime.
|
||||
|
||||
---
|
||||
|
||||
## Driver Comparison Summary
|
||||
|
||||
| Driver | Library | License | Stability Tier | .NET Target | Native Subs | Tag Discovery | Connection Limit | Required Infrastructure |
|
||||
|--------|---------|---------|----------------|-------------|-------------|---------------|-----------------|------------------------|
|
||||
| Galaxy | MXAccess COM + MessagePack IPC | Proprietary | **C — out-of-process** | .NET 4.8 x86 (Host) + .NET 10 x64 (Proxy) | **Yes (MXAccess advisory)** | Galaxy DB query + `IRediscoverable` on deploy | Per-Galaxy (one Host per machine) | ArchestrA Platform, SQL Server (ZB DB), Historian (optional) |
|
||||
| Modbus TCP | NModbus 3.x | MIT | A — in-process | .NET 10 x64 | No (polled) | Config DB | 2-8 per device | None (also covers DL205 via octal address translation) |
|
||||
| AB CIP | libplctag 1.6.x | LGPL/MIT | B — in-process with guards | .NET 10 x64 | No (polled) | Config DB | 32-128 per PLC | None |
|
||||
| AB Legacy | libplctag 1.6.x | LGPL/MIT | B — in-process with guards | .NET 10 x64 | No (polled) | Config DB | **4-8 per PLC** | Ethernet adapter for some models |
|
||||
| Siemens S7 | S7netplus | MIT | B — in-process with guards | .NET 10 x64 | No (polled) | Config DB | 3-30 per PLC | PUT/GET enabled (S7-1200/1500) |
|
||||
| TwinCAT | Beckhoff.TwinCAT.Ads 6.x | Proprietary | B — in-process with guards | .NET 10 x64 | **Yes (ADS)** | Symbol upload | 64-128 | AMS route configured |
|
||||
| FOCAS | Fwlib64.dll (P/Invoke) + MessagePack IPC | FANUC SDK license | **C — out-of-process** | .NET 10 x64 (Host + Proxy) | No (polled) | Built-in + Config DB | **5-10 per CNC** | FANUC SDK license, CNC Ethernet option, Focas.Host Windows service |
|
||||
| OPC UA Client | OPC Foundation 1.5.x | GPL/RCL | A — in-process | .NET 10 x64 | **Yes (native)** | Browse remote | Varies | Certificate trust |
|
||||
| Driver | Library | License | Stability Tier | Namespace Kind | .NET Target | Native Subs | Tag Discovery | Connection Limit | Required Infrastructure |
|
||||
|--------|---------|---------|----------------|----------------|-------------|-------------|---------------|-----------------|------------------------|
|
||||
| Galaxy | MXAccess COM + MessagePack IPC | Proprietary | **C — out-of-process** | **SystemPlatform** | .NET 4.8 x86 (Host) + .NET 10 x64 (Proxy) | **Yes (MXAccess advisory)** | Galaxy DB query + `IRediscoverable` on deploy | Per-Galaxy (one Host per machine) | ArchestrA Platform, SQL Server (ZB DB), Historian (optional) |
|
||||
| Modbus TCP | NModbus 3.x | MIT | A — in-process | Equipment | .NET 10 x64 | No (polled) | Config DB | 2-8 per device | None (also covers DL205 via octal address translation) |
|
||||
| AB CIP | libplctag 1.6.x | LGPL/MIT | B — in-process with guards | Equipment | .NET 10 x64 | No (polled) | Config DB | 32-128 per PLC | None |
|
||||
| AB Legacy | libplctag 1.6.x | LGPL/MIT | B — in-process with guards | Equipment | .NET 10 x64 | No (polled) | Config DB | **4-8 per PLC** | Ethernet adapter for some models |
|
||||
| Siemens S7 | S7netplus | MIT | B — in-process with guards | Equipment | .NET 10 x64 | No (polled) | Config DB | 3-30 per PLC | PUT/GET enabled (S7-1200/1500) |
|
||||
| TwinCAT | Beckhoff.TwinCAT.Ads 6.x | Proprietary | B — in-process with guards | Equipment | .NET 10 x64 | **Yes (ADS)** | Symbol upload | 64-128 | AMS route configured |
|
||||
| FOCAS | Fwlib64.dll (P/Invoke) + MessagePack IPC | FANUC SDK license | **C — out-of-process** | Equipment | .NET 10 x64 (Host + Proxy) | No (polled) | Built-in + Config DB | **5-10 per CNC** | FANUC SDK license, CNC Ethernet option, Focas.Host Windows service |
|
||||
| OPC UA Client | OPC Foundation 1.5.x | GPL/RCL | A — in-process | Equipment OR SystemPlatform (per-instance) | .NET 10 x64 | **Yes (native)** | Browse remote | Varies | Certificate trust |
|
||||
|
||||
Tier definitions and per-tier protections: see `driver-stability.md`.
|
||||
Tier definitions and per-tier protections: see `driver-stability.md`. Namespace model and UNS naming rules: see `plan.md` §4 and `config-db-schema.md` (Equipment table). Equipment-namespace drivers populate `Equipment` rows whose UNS path comes from `ServerCluster.Enterprise/Site` + `Equipment.Area/Line/Name`; SystemPlatform-namespace drivers (Galaxy) preserve their own hierarchy via `Tag.FolderPath` as v1 LmxOpcUa expressed it.
|
||||
|
||||
176
docs/v2/plan.md
176
docs/v2/plan.md
@@ -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"
|
||||
- Enterprise ← UNS 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user