Files
lmxopcua/docs/v2/plan.md
Joseph Doherty 592fa79e3c Add Phase 0 + Phase 1 detailed implementation plans under docs/v2/implementation/ with a phase-gate model so the work can be verified for compliance to the v2 design as it lands. Three-gate structure per phase (entry / mid / exit) with explicit compliance-check categories: schema compliance (live DB introspected against config-db-schema.md DDL via xUnit), decision compliance (every decision number cited in the phase doc must have at least one code/test citation in the codebase, verified via git grep), visual compliance (Admin UI screenshots reviewed side-by-side against ScadaLink CentralUI's equivalent screens), behavioral compliance (per-phase end-to-end smoke test that always passes at exit, never "known broken fix later"), stability compliance (cross-cutting protections from driver-stability.md wired up and regression-tested for Tier C drivers), and documentation compliance (any deviation from v2 design docs reflected back as decision-log updates with explicit "supersedes" notes). Exit gate requires two-reviewer signoff and an exit-gate-{phase}.md record; silent deviation is the failure mode the gates exist to make impossible to ship. Phase 0 doc covers the mechanical LmxOpcUa → OtOpcUa rename with 9 tasks, 7 compliance checks, and a completion checklist that gates on baseline test count parity. Phase 1 doc covers the largest greenfield phase — 5 work streams (Core.Abstractions, Configuration project with EF Core schema + stored procs + LiteDB cache + generation-diff applier, Core with GenericDriverNodeManager rename + IAddressSpaceBuilder + driver isolation, Server with Microsoft.Extensions.Hosting replacing TopShelf + credential-bound bootstrap, Admin Blazor Server app mirroring ScadaLink CentralUI verbatim with LDAP cookie auth + draft/diff/publish workflow + UNS structure management + equipment CRUD + release-reservation and merge-equipment operator flows) — with task-level acceptance criteria, a 14-step end-to-end smoke test, and decision citation requirements for #1-125. New decisions #126-127 capture the gate model and per-phase doc structure. Cross-references added to plan.md Reference Documents section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:25:09 -04:00

90 KiB
Raw Blame History

Next Phase Plan — OtOpcUa v2: Multi-Driver Architecture

Status: DRAFT — brainstorming in progress, do NOT execute until explicitly approved.

Branch: v2 Created: 2026-04-16

Vision

Rename from LmxOpcUa to OtOpcUa and evolve from a single-protocol OPC UA server (Galaxy/MXAccess only) into a multi-driver OPC UA server where:

  • The common core owns the OPC UA server, address space management, session/security/subscription machinery, and client-facing concerns.
  • Driver modules are pluggable backends that each know how to connect to a specific data source, discover its tags/hierarchy, and shuttle live data back through the core to OPC UA clients.
  • Drivers implement composable capability interfaces — a driver only implements what it supports (e.g. subscriptions, alarms, history).
  • The existing Galaxy/MXAccess integration becomes the first driver module, proving the abstraction works against real production use.

Target Drivers

Driver Protocol Capability Profile Notes
Galaxy MXAccess COM + Galaxy DB Read, Write, Subscribe, Alarms, HDA Existing v1 logic, out-of-process (.NET 4.8 x86)
Modbus TCP MB-TCP Read, Write, Subscribe (polled) Flat register model, config-driven tag map. Also covers DL205 via AddressFormat=DL205 (octal translation)
AB CIP EtherNet/IP CIP Read, Write, Subscribe (polled) ControlLogix/CompactLogix, symbolic tag addressing
AB Legacy EtherNet/IP PCCC Read, Write, Subscribe (polled) SLC 500/MicroLogix, file-based addressing
Siemens S7 S7comm (ISO-on-TCP) Read, Write, Subscribe (polled) S7-300/400/1200/1500, DB/M/I/Q addressing
TwinCAT ADS (Beckhoff) Read, Write, Subscribe (native) Symbol-based, native ADS notifications
FOCAS FOCAS2 (FANUC CNC) Read, Write, Subscribe (polled) CNC data model (axes, spindle, PMC, macros)
OPC UA Client OPC UA Read, Write, Subscribe, Alarms, HDA Gateway/aggregation — proxy a remote server

Driver Characteristics That Shape the Interface

Concern Galaxy Modbus TCP AB CIP AB Legacy S7 TwinCAT FOCAS OPC UA Client
Tag discovery DB query Config DB Config DB Config DB Config DB Symbol upload CNC query + Config DB Browse remote
Hierarchy Rich tree Flat (user groups) Flat or program-scoped Flat (file-based) Flat (DB/area) Symbol tree Functional (axes/spindle/PMC) Mirror remote
Data types mx_data_type Raw registers (user-typed) CIP typed File-typed (N=INT16, F=FLOAT) S7 typed IEC 61131-3 Scaled integers + structs Full OPC UA
Native subscriptions Yes (MXAccess) No (polled) No (polled) No (polled) No (polled) Yes (ADS notifications) No (polled) Yes (OPC UA)
Alarms Yes No No No No Possible (ADS state) Yes (CNC alarms) Yes (A&C)
History Yes (Historian) No No No No No No Yes (HistoryRead)

Note: AutomationDirect DL205 PLCs are supported by the Modbus TCP driver via AddressFormat=DL205 (octal V/X/Y/C/T/CT address translation over H2-ECOM100 module, port 502). No separate driver needed.


Architecture — Key Decisions & Open Questions

1. Common Core Boundary

Core owns:

  • OPC UA server lifecycle (startup, shutdown, session management)
  • Security (transport profiles, authentication, authorization)
  • Address space tree management (add/remove/update nodes)
  • Subscription engine (create, publish, transfer)
  • Status dashboard / health reporting
  • Redundancy
  • Configuration framework
  • Namespace allocation per driver

Driver owns:

  • Data source connection management
  • Tag/hierarchy discovery
  • Data type mapping (driver types → OPC UA types)
  • Read/write translation
  • Alarm sourcing (if supported)
  • Historical data access (if supported)

Decided:

  • Each driver instance manages its own polling internally — the core does not provide a shared poll scheduler.
  • Multiple instances of the same driver type are supported (e.g. two Modbus TCP drivers for different device groups).
  • One namespace index per driver instance (each instance gets its own NamespaceUri).

Decided:

  • Drivers register nodes via a builder/context API (IAddressSpaceBuilder) provided by the core. Core owns the tree; driver streams AddFolder / AddVariable calls as it discovers nodes. Supports incremental/large address spaces without forcing the driver to buffer the whole tree.

2. Driver Capability Interfaces

Composable — a driver implements only what it supports:

IDriver                    — required: lifecycle, metadata, health
├── ITagDiscovery          — discover tags/hierarchy from the backend
├── IReadable              — on-demand read
├── IWritable              — on-demand write
├── ISubscribable          — data change subscriptions (native or driver-managed polling)
├── IAlarmSource           — alarm events and acknowledgment
└── IHistoryProvider       — historical data reads

Note: ISubscribable covers both native subscriptions (Galaxy MXAccess advisory, OPC UA monitored items) and driver-internal polled subscriptions (Modbus, AB CIP). The driver owns its polling loop — the core just sees OnDataChange callbacks regardless of mechanism.

Capability matrix:

Interface Galaxy Modbus TCP AB CIP AB Legacy S7 TwinCAT FOCAS OPC UA Client
IDriver Y Y Y Y Y Y Y Y
ITagDiscovery Y Y (config DB) Y (config DB) Y (config DB) Y (config DB) Y (symbol upload) Y (built-in + config DB) Y (browse)
IReadable Y Y Y Y Y Y Y Y
IWritable Y Y Y Y Y Y Y (limited) Y
ISubscribable Y (native) Y (polled) Y (polled) Y (polled) Y (polled) Y (native ADS) Y (polled) Y (native)
IAlarmSource Y Y (CNC alarms) Y
IHistoryProvider Y Y

Decided:

  • Data change callback uses shared data models (DataValue with value, StatusCode quality, timestamp). Every driver maps to the same OPC UA StatusCode space — drivers define which quality codes they can produce but the model is universal.
  • Driver isolation: each driver instance runs independently. A crash or disconnect in one driver sets Bad quality on its own nodes only — no impact on other driver instances. The core must catch and contain driver failures.

Resilience — Polly

Decided: Use Polly v8+ (Microsoft.Extensions.Resilience) as the resilience layer across all drivers and the configuration subsystem.

Polly provides composable resilience pipelines rather than hand-rolled retry/circuit-breaker logic. Each driver instance (and each device within a driver) gets its own pipeline so failures are isolated at the finest practical level.

Where Polly applies:

Component Pipeline Strategies Purpose
Driver device connection Per device Retry (exp. backoff) + CircuitBreaker + Timeout Reconnect to offline PLC/device, stop hammering after N failures, bound connection attempts
Driver read ops Per device Timeout + Retry Reads are idempotent — retry transient failures freely
Driver write ops Per device Timeout only by default Writes are NOT auto-retried — a timeout may fire after the device already accepted the command; replaying non-idempotent field actions (pulses, acks, recipe steps, counter increments) can cause duplicate operations
Driver poll loop Per device CircuitBreaker When a device is consistently unreachable, open circuit and probe periodically instead of polling at full rate
Galaxy IPC (Proxy → Host) Per proxy Retry (backoff) + CircuitBreaker Reconnect when Galaxy Host service restarts, stop retrying if Host is down for extended period
Config DB polling Singleton Retry (backoff) + Fallback (use cache) Central DB unreachable → fall back to LiteDB cache, keep retrying in background
Config DB startup Singleton Retry (backoff) + Fallback (use cache) If DB is briefly unavailable at startup, retry before falling back to cache

How it integrates:

IHostedService (per driver instance)
  ├── Per-device ReadPipeline
  │     ├── Timeout          — bound how long a read can take
  │     ├── Retry            — transient failure recovery with jitter (SAFE: reads are idempotent)
  │     └── CircuitBreaker   — stop polling dead devices, probe periodically
  │                            on break: set device tags to Bad quality
  │                            on reset: resume normal polling, restore quality
  │
  └── Per-device WritePipeline
        ├── Timeout          — bound how long a write can take
        └── (NO retry by default)  — opt-in per tag via TagConfig.WriteIdempotent = true
                                     OR via a CAS (compare-and-set) wrapper that verifies
                                     the device state before each retry attempt

ConfigurationService
  └── ResiliencePipeline
        ├── Retry            — transient DB connectivity issues
        └── Fallback         — serve from LiteDB cache on sustained outage

Write-retry policy (per the adversarial review, finding #1):

  • Default: no automatic retry on writes. A timeout bubbles up as a write failure; the OPC UA client decides whether to re-issue.
  • Opt-in per tag via TagConfig.WriteIdempotent = true — explicit assertion by the configurer that replaying the same write has no side effect (e.g. setpoint overwrite, steady-state mode selection).
  • Opt-in via CAS (compare-and-set): before retrying, read the current value; retry only if the device still holds the pre-write value. Drivers whose protocol supports atomic read-modify-write (e.g. Modbus mask-write, OPC UA writes with expected-value) can plug this in.
  • Documented never-retry cases: edge-triggered acks, pulse outputs, monotonic counters, recipe-step advances, alarm acknowledgments, any "fire-and-forget" command register.

Polly integration points:

  • Microsoft.Extensions.Resilience for DI-friendly pipeline registration
  • TelemetryListener feeds circuit-breaker state changes into the status dashboard (operators see which devices are in open/half-open/closed state)
  • Per-driver/per-device pipeline configuration from the central config DB (retry counts, backoff intervals, circuit breaker thresholds can be tuned per device)

Decided:

  • Capability discovery uses interface checks via is (e.g. if (driver is IAlarmSource a) ...). The interface is the capability — no redundant flag enum to keep in sync.
  • ITagDiscovery is discovery-only. Drivers with a change signal (Galaxy deploy time, OPC UA server change notifications) additionally implement an optional IRediscoverable sub-interface; the core subscribes and rebuilds the affected subtree. Static drivers (Modbus, S7, etc. whose tags only change via a published config generation) don't implement it.

3. Runtime & Target Framework

Decided: .NET 10, C#, x64 for everything — except where explicitly required.

Component Target Reason
Core, Core.Abstractions .NET 10 x64 Default
Server .NET 10 x64 Default
Configuration .NET 10 x64 Default
Admin .NET 10 x64 Blazor Server
Driver.ModbusTcp .NET 10 x64 Default
Driver.AbCip .NET 10 x64 Default
Driver.OpcUaClient .NET 10 x64 Default
Client.CLI .NET 10 x64 Default
Client.UI .NET 10 x64 Avalonia
Driver.Galaxy .NET Framework 4.8 x86 MXAccess COM interop requires 32-bit

Critical implication: The Galaxy driver cannot load in-process with a .NET 10 x64 server. It must run as an out-of-process driver — a separate .NET 4.8 x86 process that the core communicates with over IPC.

Decided: Named pipes with MessagePack serialization for IPC.

  • Galaxy Host always runs on the same machine (MXAccess needs local ArchestrA Platform)
  • Named pipes are fast, no port allocation, built into both .NET 4.8 (System.IO.Pipes) and .NET 10
  • Galaxy.Shared defines request/response message types serialized with MessagePack over length-prefixed frames
  • MessagePack-CSharp (MessagePack NuGet) supports .NET Framework 4.6.1+ and .NET Standard 2.0+ — works on both sides
  • Compact binary format, faster than JSON, good fit for high-frequency data change callbacks
  • Simpler than gRPC on .NET 4.8 (which needs legacy Grpc.Core native library)

Decided: Galaxy Host is a separate Windows service.

  • Independent lifecycle from the OtOpcUa Server
  • Can be restarted without affecting the main server or other drivers
  • Galaxy.Proxy detects connection loss, sets Bad quality on Galaxy nodes, reconnects when Host comes back
  • Installed/managed via standard Windows service tooling
┌──────────────────────────────────┐  named pipe  ┌───────────────────────────┐
│  OtOpcUa Server (.NET 10 x64)   │◄────────────►│  Galaxy Host Service      │
│  Windows Service                 │              │  Windows Service           │
│  (Microsoft.Extensions.Hosting)  │              │  (.NET 4.8 x86)           │
│                                  │              │                           │
│  Core                           │              │  MxAccessBridge           │
│    ├── Driver.ModbusTcp (in-proc)│              │  GalaxyRepository         │
│    ├── Driver.AbCip    (in-proc) │              │  GalaxyDriverService      │
│    └── GalaxyProxy    (in-proc)──┼──────────────┼──AlarmTracking            │
│                                  │              │  HDA Plugin               │
└──────────────────────────────────┘              └───────────────────────────┘

Notes for future work:

  • The Proxy/Host/Shared split is a general pattern — any future driver with process-isolation requirements (bitness mismatch, unstable native dependency, license boundary) can reuse the same three-project layout.
  • Reusability of LmxNodeManager as a "generic driver node manager" will be assessed during Phase 2 interface extraction.

4. Galaxy/MXAccess as Out-of-Process Driver

Current tightly-coupled pieces to refactor:

  • LmxNodeManager — mixes OPC UA node management with MXAccess-specific logic
  • MxAccessBridge — COM thread, subscriptions, reconnect
  • GalaxyRepository — SQL queries for hierarchy/attributes
  • Alarm tracking tied to MXAccess subscription model
  • HDA via Wonderware Historian plugin

All of these stay in the Galaxy Host process (.NET 4.8 x86). The GalaxyProxy in the main server implements the standard driver interfaces and forwards over IPC.

Decided:

  • Refactor is incremental: extract IDriver / ISubscribable / ITagDiscovery etc. against the existing LmxNodeManager first (still in-process on v2 branch), validate the system still runs, then move the implementation behind the IPC boundary into Galaxy.Host. Keeps the system runnable at each step and de-risks the out-of-process move.
  • Parity test: run the existing v1 IntegrationTests suite against the v2 Galaxy driver (same Galaxy, same expectations) plus a scripted Client.CLI walkthrough (connect / browse / read / write / subscribe / history / alarms) on a dev Galaxy. Automated regression + human-observable behavior.

4. Configuration Model — Centralized MSSQL + Local Cache

Deployment topology — server clusters:

Sites deploy OtOpcUa as 2-node clusters to provide non-transparent OPC UA redundancy (per v1 — RedundancySupport.Warm / Hot, no VIP/load-balancer involvement; clients see both endpoints in ServerUriArray and pick by ServiceLevel). Single-node deployments are the same model with NodeCount = 1. The config schema treats this uniformly: every server is a member of a ServerCluster with 1 or 2 ClusterNode members.

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-3bldg-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:

┌─────────────────────────────────┐
│  Central Config DB (MSSQL)      │
│                                 │
│  - Server clusters (1 or 2 nodes)│
│  - Cluster nodes (physical srvs)│
│  - Driver assignments (per cluster)│
│  - Tag definitions (per cluster)│
│  - Device configs (per cluster) │
│  - Per-node overrides (minimal) │
│  - Schemaless driver config     │
│    (JSON; cluster-level + node  │
│     override JSON)              │
└──────────┬──────────────────────┘
           │  poll / change detection
           ▼
       ┌─── Cluster LINE3-OPCUA ────────────────────┐
       │                                            │
┌──────┴──────────────────┐    ┌──────────────────┴──┐
│  Node LINE3-OPCUA-A     │    │  Node LINE3-OPCUA-B │
│  RedundancyRole=Primary │    │  RedundancyRole=Secondary │
│                         │    │                     │
│  appsettings.json:      │    │  appsettings.json:  │
│    - MSSQL conn string  │    │    - MSSQL conn str │
│    - ClusterId          │    │    - ClusterId      │
│    - NodeId             │    │    - NodeId         │
│    - Local cache path   │    │    - Local cache path│
│                         │    │                     │
│  Local cache (LiteDB)   │    │  Local cache (LiteDB)│
└─────────────────────────┘    └─────────────────────┘

How it works:

  1. Each OtOpcUa node has a minimal appsettings.json with just: MSSQL connection string, its ClusterId and NodeId, a local machine-bound client certificate (or gMSA credential), and local cache file path. OPC UA port and ApplicationUri come from the central DB (ClusterNode.OpcUaPort / ClusterNode.ApplicationUri), not from local config — they're cluster topology, not local concerns.
  2. On startup, the node authenticates to the central DB using a credential bound to its NodeId — a client cert or SQL login per node, NOT a shared DB login. The DB-side authorization layer enforces that the authenticated principal may only read config for its NodeId's ClusterId. A self-asserted NodeId with the wrong credential is rejected. A node may not read another cluster's config, even if both clusters belong to the same admin team.
  3. The node requests its current config generation from the central DB: "give me the latest published generation for cluster X." Generations are cluster-scoped — one generation = one cluster's full configuration snapshot.
  4. The node receives the cluster-level config (drivers, devices, tags, poll groups) plus its own ClusterNode row (physical attributes + override JSON). It merges node overrides onto cluster-level driver configs at apply time.
  5. Config is cached locally in a LiteDB file keyed by generation number — if the central DB is unreachable at startup, the node boots from the latest cached generation.
  6. The node polls the central DB for a new published generation. When a new generation is published, the node downloads it, diffs it against its current one, and applies only the affected drivers/devices/tags (surgical application against an atomic snapshot).
  7. Both nodes of a cluster apply the same generation, but apply timing can differ slightly (network jitter, polling phase). During the apply window, one node may be on generation N and the other on N+1; this is acceptable because OPC UA non-transparent redundancy already accommodates per-endpoint state divergence and ServiceLevel will dip on the node that's mid-apply.
  8. If generation application fails mid-flight, the node rolls back to the previous generation and surfaces the failure in the status dashboard; admins can publish a corrective generation or explicitly roll back the cluster.
  9. The central DB is the single source of truth for fleet management — all tag definitions, device configs, driver assignments, and cluster topology live there, versioned by generation.

Central DB schema (conceptual):

ServerCluster                   ← top-level deployment unit (1 or 2 nodes)
  - ClusterId (PK)
  - Name                         ← human-readable e.g. "LINE3-OPCUA"
  - 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
  - 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

ClusterNode                     ← physical OPC UA server within a cluster
  - NodeId (PK)                  ← stable per physical machine, e.g. "LINE3-OPCUA-A"
  - ClusterId (FK)
  - RedundancyRole (Primary | Secondary | Standalone)
  - Host                         ← machine hostname / IP
  - OpcUaPort                    ← typically 4840 on each machine
  - DashboardPort                ← typically 8081
  - ApplicationUri               ← MUST be unique per node per OPC UA spec.
                                   Convention: urn:{Host}:OtOpcUa (hostname-embedded).
                                   Unique index enforced fleet-wide, not just per-cluster
                                   — two clusters sharing an ApplicationUri would confuse
                                   any client that browses both.
                                   Stored explicitly, NOT derived from Host at runtime —
                                   OPC UA clients pin trust to ApplicationUri (part of
                                   the cert validation chain), so silent rewrites would
                                   break client trust.
  - ServiceLevelBase             ← Primary 200, Secondary 150 by default
  - DriverConfigOverridesJson    ← per-node overrides keyed by DriverInstanceId,
                                   merged onto cluster-level DriverConfig at apply.
                                   Minimal by intent — only settings that genuinely
                                   differ per node (e.g. MxAccess.ClientName).
  - Enabled
  - LastSeenAt

ClusterNodeCredential           ← 1:1 or 1:N with ClusterNode
  - CredentialId (PK)
  - NodeId (FK)                  ← bound to the physical node, NOT the cluster
  - Kind (SqlLogin | ClientCertThumbprint | ADPrincipal | gMSA)
  - Value                        ← login name, thumbprint, SID, etc.
  - Enabled
  - RotatedAt

ConfigGeneration                ← atomic, immutable snapshot of one cluster's config
  - GenerationId (PK)            ← monotonically increasing
  - ClusterId (FK)               ← cluster-scoped — every generation belongs to one cluster
  - PublishedAt
  - PublishedBy
  - Status (Draft | Published | Superseded | RolledBack)
  - ParentGenerationId (FK)      ← rollback target
  - Notes

DriverInstance                  ← rows reference GenerationId; new generations = new rows
  - DriverInstanceRowId (PK)
  - GenerationId (FK)
  - 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 | …)
  - Enabled
  - DriverConfig (JSON)          ← schemaless, driver-type-specific settings.
                                   Per-node overrides applied via
                                   ClusterNode.DriverConfigOverridesJson at apply time.

Device (for multi-device drivers like Modbus, CIP)
  - DeviceRowId (PK)
  - GenerationId (FK)
  - DeviceId                     ← stable logical ID
  - DriverInstanceId (FK)
  - 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
  - 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)
  - TagConfig (JSON)             ← register address, poll group, scaling, etc.

PollGroup
  - PollGroupRowId (PK)
  - GenerationId (FK)
  - PollGroupId                  ← stable logical ID
  - DriverInstanceId (FK)
  - Name
  - IntervalMs

ClusterNodeGenerationState      ← tracks which generation each NODE has applied
  - NodeId (PK, FK)              ← per-node, not per-cluster — both nodes of a
                                   2-node cluster track independently
  - CurrentGenerationId (FK)
  - 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):

  • All config reads go through stored procedures that take the authenticated principal from SESSION_CONTEXT / SUSER_SNAME() / CURRENT_USER and cross-check it against ClusterNodeCredential.Value for the requesting NodeId. A principal asking for config of a ClusterId that does not contain its NodeId gets rejected, not just filtered.
  • Cross-cluster reads are forbidden even within the same site or admin scope — every config read carries the requesting NodeId and is checked.
  • Admin UI connects with a separate elevated principal that has read/write on all clusters and generations.
  • Publishing a generation is a stored procedure that validates the draft, computes the diff vs. the previous generation, and flips Status to Published atomically within a transaction. The publish is cluster-scoped — publishing a new generation for one cluster does not affect any other cluster.

appsettings.json stays minimal:

{
  "Cluster": {
    "ClusterId": "LINE3-OPCUA",
    "NodeId": "LINE3-OPCUA-A"
    // OPC UA port, ApplicationUri, redundancy role all come from central DB
  },
  "ConfigDatabase": {
    // The connection string MUST authenticate as a principal bound to this NodeId.
    // Options (pick one per deployment):
    //   - Integrated Security + gMSA (preferred on AD-joined hosts)
    //   - Client certificate (Authentication=ActiveDirectoryMsi or cert-auth)
    //   - SQL login scoped via ClusterNodeCredential table (rotate regularly)
    // A shared DB login across nodes is NOT supported — the server-side
    // authorization layer will reject cross-cluster config reads.
    "ConnectionString": "Server=configsrv;Database=OtOpcUaConfig;Authentication=...;...",
    "GenerationPollIntervalSeconds": 30,
    "LocalCachePath": "config_cache.db"
  },
  "Security": { /* transport/auth settings  still local */ }
}

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, 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 intentClusterNode.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.
  • Each device in a driver instance appears as a folder node in the address space.

Decided (rollout model):

  • Config is versioned as immutable, cluster-scoped generations. Admin authors a draft for a cluster, then publishes it in a single transaction. Nodes only ever observe a fully-published generation — never a half-edited mix of rows.
  • One generation = one cluster's full configuration snapshot. Publishing a generation for one cluster does not affect any other cluster.
  • Each node polls for the latest generation for its cluster, diffs it against its current applied generation, and surgically applies only the affected drivers/devices/tags. Surgical application is safe because the source snapshot is atomic.
  • Both nodes of a cluster apply the same generation independently — the apply timing can differ slightly. During the apply window, one node may be on generation N while the other is on N+1; this is acceptable because non-transparent redundancy already accommodates per-endpoint state divergence and ServiceLevel will dip on the node that's mid-apply.
  • Rollback: publishing a new generation never deletes old ones. Admins can roll back a cluster to any previous generation; nodes apply the target generation the same way as a forward publish.
  • Applied-state per node is tracked in ClusterNodeGenerationState so Admin can see which nodes have picked up a new publish and detect stragglers or a 2-node cluster that's diverged.
  • If neither the central DB nor a local cache is available, the node fails to start. This is acceptable — there's no meaningful "run with zero config" mode.

Decided:

  • Transport security config (certs, LDAP settings, transport profiles) stays local in appsettings.json per instance. Avoids a bootstrap chicken-and-egg where DB connection credentials would depend on config retrieved from the DB. Matches current v1 deployment model.
  • Generation retention: keep all generations forever. Rollback target is always available; audit trail is complete. Config rows are small and publish cadence is low (days/weeks), so storage cost is negligible versus the utility of a complete history.

Deferred:

  • Event-driven generation notification (SignalR / Service Broker) as an optimisation over poll interval — deferred until polling proves insufficient.

5. Project Structure

All projects target .NET 10 x64 unless noted.

src/
  # ── Configuration layer ──
  ZB.MOM.WW.OtOpcUa.Configuration/       # Central DB schema (EF), change detection,
                                          #   local LiteDB cache, config models (.NET 10)
  ZB.MOM.WW.OtOpcUa.Admin/               # Blazor Server admin UI + API for managing the
                                          #   central config DB (.NET 10)

  # ── Core + Server ──
  ZB.MOM.WW.OtOpcUa.Core/                # OPC UA server, address space, subscriptions,
                                          #   driver hosting (.NET 10)
  ZB.MOM.WW.OtOpcUa.Core.Abstractions/   # IDriver, IReadable, ISubscribable, etc.
                                          #   thin contract (.NET 10)
  ZB.MOM.WW.OtOpcUa.Server/              # Host (Microsoft.Extensions.Hosting),
                                          #   Windows Service, config bootstrap (.NET 10)

  # ── In-process drivers (.NET 10 x64) ──
  ZB.MOM.WW.OtOpcUa.Driver.ModbusTcp/    # Modbus TCP driver (NModbus)
  ZB.MOM.WW.OtOpcUa.Driver.AbCip/        # Allen-Bradley CIP driver (libplctag)
  ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/     # Allen-Bradley SLC/MicroLogix driver (libplctag)
  ZB.MOM.WW.OtOpcUa.Driver.S7/           # Siemens S7 driver (S7netplus)
  ZB.MOM.WW.OtOpcUa.Driver.TwinCat/      # Beckhoff TwinCAT ADS driver (Beckhoff.TwinCAT.Ads)
  ZB.MOM.WW.OtOpcUa.Driver.Focas/        # FANUC FOCAS CNC driver (Fwlib64.dll P/Invoke)
  ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/  # OPC UA client gateway driver

  # ── Out-of-process Galaxy driver ──
  ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ # In-process proxy that implements IDriver interfaces
                                          #   and forwards over IPC (.NET 10)
  ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/  # Separate process: MXAccess COM, Galaxy DB,
                                          #   alarms, HDA. Hosts IPC server (.NET 4.8 x86)
  ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ # Shared IPC message contracts between Proxy
                                          #   and Host (.NET Standard 2.0)

  # ── Client tooling (.NET 10 x64) ──
  ZB.MOM.WW.OtOpcUa.Client.CLI/          # client CLI
  ZB.MOM.WW.OtOpcUa.Client.UI/           # Avalonia client

tests/
  ZB.MOM.WW.OtOpcUa.Configuration.Tests/
  ZB.MOM.WW.OtOpcUa.Core.Tests/
  ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/
  ZB.MOM.WW.OtOpcUa.Driver.ModbusTcp.Tests/
  ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/
  ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/
  ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/
  ZB.MOM.WW.OtOpcUa.Driver.TwinCat.Tests/
  ZB.MOM.WW.OtOpcUa.Driver.Focas.Tests/
  ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/
  ZB.MOM.WW.OtOpcUa.IntegrationTests/

Deployment units:

Unit Description Target Deploys to
OtOpcUa Server Windows Service (M.E.Hosting) — OPC UA server + in-process drivers .NET 10 x64 Each site node
Galaxy Host Windows Service — out-of-process MXAccess driver .NET 4.8 x86 Same machine as Server (when Galaxy driver is used)
OtOpcUa Admin Blazor Server config management UI .NET 10 x64 Same server or central management host
OtOpcUa Client CLI Operator CLI tool .NET 10 x64 Any workstation
OtOpcUa Client UI Avalonia desktop client .NET 10 x64 Any workstation

Dependency graph:

Admin ──→ Configuration
Server ──→ Core ──→ Core.Abstractions
              │          ↑
              │     Driver.ModbusTcp, Driver.AbCip, Driver.AbLegacy,
              │     Driver.S7, Driver.TwinCat, Driver.Focas,
              │     Driver.OpcUaClient (in-process)
              │     Driver.Galaxy.Proxy (in-process, forwards over IPC)
              ↓
         Configuration

Galaxy.Proxy ──→ Galaxy.Shared ←── Galaxy.Host
                                   (.NET 4.8 x86, separate process)
  • Core.Abstractions — no dependencies, referenced by Core and all drivers (including Galaxy.Proxy)
  • Configuration — owns central DB access + local cache, referenced by Server and Admin
  • Admin — Blazor Server app, depends on Configuration, can deploy on same server
  • In-process drivers depend on Core.Abstractions only
  • Galaxy.Shared — .NET Standard 2.0 IPC contracts, referenced by both Proxy (.NET 10) and Host (.NET 4.8)
  • Galaxy.Host — standalone .NET 4.8 x86 process, does NOT reference Core or Core.Abstractions
  • Galaxy.Proxy — implements IDriver etc., depends on Core.Abstractions + Galaxy.Shared

Decided:

  • Mono-repo (Decision #31 above).
  • Core.Abstractions is internal-only for now — no standalone NuGet. Keep the contract mutable while the first 8 drivers are being built; revisit publishing after Phase 5 when the shape has stabilized. Design the contract as if it will eventually be public (no leaky types, stable names) to minimize churn later.

5a. LmxNodeManager Reusability Analysis

Investigated 2026-04-17. The existing LmxNodeManager (2923 lines) is the foundation for the new generic node manager — not a rewrite candidate. Categorized inventory:

Bucket Lines % What's here
Already generic ~1310 45% OPC UA plumbing: CreateAddressSpace + topological sort + _nodeMap, Read/Write dispatch, HistoryRead + continuation points, subscription delivery + _pendingDataChanges queue, dispatch thread lifecycle, runtime-status node mechanism, status-code mapping
Generic pattern, Galaxy-coded today ~1170 40% Bad-quality fan-out when a host drops, alarm auto-subscribe (InAlarm+Priority+Description pattern), background-subscribe tracking with shutdown-safe WaitAll, value normalization for arrays, connection-health probe machinery — each is a pattern every driver will need, currently wired to Galaxy types
Truly MXAccess-specific ~290 10% IMxAccessClient calls, MxDataTypeMapper, SecurityClassificationMapper, GalaxyRuntimeProbeManager construction/lifecycle, Historian literal, alarm auto-subscribe trigger
Metadata / comments ~153 5%

Interleaving assessment: concerns are cleanly separated at method boundaries. Read/Write handlers do generic resolution → generic host-status check → isolated _mxAccessClient call. The dispatch loop is fully generic. The only meaningful interleaving is in BuildAddressSpace() where GalaxyAttributeInfo leaks into node creation — fixable by introducing a driver-agnostic DriverAttributeInfo DTO.

Refactor plan:

  1. Rename LmxNodeManagerGenericDriverNodeManager : CustomNodeManager2 and lift the generic blocks unchanged. Swap IMxAccessClient for IDriver (composing IReadable / IWritable / ISubscribable). Swap GalaxyAttributeInfo for a driver-agnostic DriverAttributeInfo { FullName, DriverDataType, IsArray, ArrayDim, SecurityClass, IsHistorized }. Promote GalaxyRuntimeProbeManager to an IHostConnectivityProbe capability interface.
  2. Derive GalaxyNodeManager : GenericDriverNodeManager — driver-specific builder that maps GalaxyAttributeInfo → DriverAttributeInfo, registers MxDataTypeMapper / SecurityClassificationMapper, injects the probe manager.
  3. New drivers (Modbus, S7, etc.) extend GenericDriverNodeManager and implement the capability interfaces. No forking of the OPC UA machinery.

Ordering within Phase 2 (fits the "incremental extraction" approach in Decision #55):

  • (a) Introduce capability interfaces + DriverAttributeInfo in Core.Abstractions.
  • (b) Rename to GenericDriverNodeManager with Galaxy still in-process as the only driver; validate parity against v1 integration tests + CLI walkthrough.
  • (c) Only then move Galaxy behind the IPC boundary into Galaxy.Host.

Each step leaves the system runnable. The generic extraction is effectively free — the class is already mostly generic, just named and typed for Galaxy.


6. Migration Strategy

Decided approach:

Phase 0 — Rename + .NET 10 migration

  1. Rename to OtOpcUa — mechanical rename of namespaces, assemblies, config, and docs
  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, 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. 7. Scaffold Admin — Blazor Server app with: instance + credential management, draft/publish/rollback generation workflow (diff viewer, "publish to fleet", per-instance override), and core CRUD for drivers/devices/tags. Driver-specific config screens deferred to later phases.

Phase 2 — Galaxy driver (prove the refactor) 8. Build Galaxy.Shared — .NET Standard 2.0 IPC message contracts 9. Build Galaxy.Host — .NET 4.8 x86 process hosting MxAccessBridge, GalaxyRepository, alarms, HDA with IPC server 10. Build Galaxy.Proxy — .NET 10 in-process proxy implementing IDriver interfaces, forwarding over IPC 11. Validate parity — v2 Galaxy driver must pass the same integration tests as v1

Phase 3 — Modbus TCP driver (prove the abstraction) 12. Build Driver.ModbusTcp — NModbus, config-driven tags from central DB, internal poll loop, device-as-folder hierarchy 13. Add Modbus config screens to Admin (first driver-specific config UI)

Phase 4 — PLC drivers 14. Build Driver.AbCip — libplctag, ControlLogix/CompactLogix symbolic tags + Admin config screens 15. Build Driver.AbLegacy — libplctag, SLC 500/MicroLogix file-based addressing + Admin config screens 16. Build Driver.S7 — S7netplus, Siemens S7-300/400/1200/1500 + Admin config screens 17. Build Driver.TwinCat — Beckhoff.TwinCAT.Ads v6, native ADS notifications, symbol upload + Admin config screens

Phase 5 — Specialty drivers 18. Build Driver.Focas — FANUC FOCAS2 P/Invoke, pre-defined CNC tag set, PMC/macro config + Admin config screens 19. Build Driver.OpcUaClient — OPC UA client gateway/aggregation, namespace remapping, subscription proxying + Admin config screens

Decided:

  • Parity test for Galaxy: existing v1 IntegrationTests suite + scripted Client.CLI walkthrough (see Section 4 above).
  • Timeline: no hard deadline. Each phase ships when it's right — tests passing, Galaxy parity bar met. Quality cadence over calendar cadence.
  • FOCAS SDK: license already secured. Phase 5 can proceed as scheduled; Fwlib64.dll available for P/Invoke.

Decision Log

# Decision Rationale Date
1 Work on v2 branch Keep master stable for production 2026-04-16
2 OPC UA core + pluggable driver modules Enable multi-protocol support without forking the server 2026-04-16
3 Rename to OtOpcUa Product is no longer LMX-specific 2026-04-16
4 Composable capability interfaces Drivers vary widely in what they support; flat IDriver would force stubs 2026-04-16
5 Target drivers: Galaxy, Modbus TCP, AB CIP, AB Legacy, S7, TwinCAT, FOCAS, OPC UA Client Full PLC/CNC/SCADA/aggregation coverage 2026-04-16
6 Polling is driver-internal, not core-managed Each driver owns its poll loop; core just sees data change callbacks 2026-04-16
7 Multiple instances of same driver type supported Need e.g. separate Modbus drivers for different device groups 2026-04-16
8 Namespace index per driver instance Each instance gets its own NamespaceUri for clean isolation 2026-04-16
9 Rename to OtOpcUa as step 1 Clean mechanical change before any refactoring 2026-04-16
10 Modbus TCP as second driver Simplest protocol, validates abstraction with flat/polled/config-driven model 2026-04-16
11 Library selections per driver NModbus (Modbus), libplctag (AB CIP + AB Legacy), S7netplus (S7), Beckhoff.TwinCAT.Ads v6 (TwinCAT), Fwlib64.dll P/Invoke (FOCAS), OPC Foundation SDK (OPC UA Client) 2026-04-16
12 Driver isolation — failure contained per instance One driver crash/disconnect must not affect other drivers' nodes or quality 2026-04-16
13 Shared OPC UA StatusCode model for quality Drivers map to the same StatusCode space; each defines which codes it produces 2026-04-16
14 Central MSSQL config database Single source of truth for fleet-wide config — instances, drivers, tags, devices 2026-04-16
15 LiteDB local cache per instance Offline startup resilience — instance boots from cache if central DB is unreachable 2026-04-16
16 JSON columns for driver-specific config Schemaless per driver type, avoids table-per-driver-type explosion 2026-04-16
17 Device-as-folder in address space Multi-device drivers expose Device/Tag hierarchy for intuitive browsing 2026-04-16
18 Minimal appsettings.json (ClusterId + NodeId + DB conn) All real config lives in central DB, not local files. OPC UA port and ApplicationUri come from ClusterNode row, not local config 2026-04-16 / 2026-04-17
19 Blazor Server admin app for config management Separate deployable, manages central MSSQL config DB 2026-04-16
20 Surgical config change detection Instance detects which drivers/devices/tags changed, applies incremental updates 2026-04-16
21 Fail-to-start without DB or cache No meaningful zero-config mode — requires at least cached config 2026-04-16
22 Configuration project owns DB + cache layer Clean separation: Server and Admin both depend on it 2026-04-16
23 .NET 10 x64 default, .NET 4.8 x86 only for Galaxy Host Modern runtime for everything; COM constraint isolated to Galaxy 2026-04-16
24 Galaxy driver is out-of-process .NET 4.8 x86 process can't load into .NET 10 x64; IPC bridge required 2026-04-16
25 Galaxy.Shared (.NET Standard 2.0) for IPC contracts Must be consumable by both .NET 10 Proxy and .NET 4.8 Host 2026-04-16
26 Admin deploys on same server (co-hosted) Simplifies deployment; can also run on separate management host 2026-04-16
27 Admin scaffold early, driver-specific screens deferred Core CRUD for instances/drivers first; per-driver config UI added with each driver 2026-04-16
28 Named pipes for Galaxy IPC Fast, no port conflicts, native to both .NET 4.8 and .NET 10 2026-04-16
29 Galaxy Host is a separate Windows service Independent lifecycle, can restart without affecting main server or other drivers 2026-04-16
30 Drop TopShelf, use Microsoft.Extensions.Hosting Built-in Windows Service support in .NET 10, no third-party dependency 2026-04-16
31 Mono-repo for all drivers Simpler dependency management, single CI pipeline, shared abstractions 2026-04-16
32 MessagePack serialization for Galaxy IPC Binary, fast, works on .NET 4.8+ and .NET 10 via MessagePack-CSharp NuGet 2026-04-16
33 EF Core for Configuration DB Migrations, LINQ queries, standard .NET 10 ORM 2026-04-16
34 Polly v8+ for resilience Retry, circuit breaker, timeout per device/driver — replaces hand-rolled supervision 2026-04-16
35 Per-device resilience pipelines Circuit breaker on Drive1 doesn't affect Drive2, even in same driver instance 2026-04-16
36 Polly for config DB access Retry + fallback to LiteDB cache on sustained DB outage 2026-04-16
37 FOCAS driver uses pre-defined tag set CNC data is functional (axes, spindle, PMC), not user-defined tags — driver exposes fixed node hierarchy populated by specific FOCAS2 API calls 2026-04-16
38 FOCAS PMC + macro variables are user-configured PMC addresses (R, D, G, F, etc.) and macro variable ranges configured in central DB; not auto-discovered 2026-04-16
39 TwinCAT uses native ADS notifications One of 3 drivers with native subscriptions (Galaxy, TwinCAT, OPC UA Client); no polling needed for subscribed tags 2026-04-16
40 TwinCAT no runtime required on server Beckhoff.TwinCAT.Ads v6 supports in-process ADS router; only needs AMS route on target device 2026-04-16
41 AB Legacy (SLC/MicroLogix) as separate driver from AB CIP Different protocol (PCCC vs CIP), different addressing (file-based vs symbolic), severe connection limits (4-8) 2026-04-16
42 S7 driver notes: PUT/GET must be enabled on S7-1200/1500 Disabled by default in TIA Portal; document as prerequisite 2026-04-16
43 DL205 (AutomationDirect) handled by Modbus TCP driver DL205 supports Modbus TCP via H2-ECOM100; no separate driver needed — AddressFormat=DL205 adds octal address translation 2026-04-16
44 No automatic retry on writes by default Write retries are unsafe for non-idempotent field actions — a timeout can fire after the device already accepted the command, and replay duplicates pulses/acks/counters/recipe steps (adversarial review finding #1) 2026-04-16
45 Opt-in write retry via TagConfig.WriteIdempotent or CAS wrapper Retries must be explicit per tag; CAS (compare-and-set) verifies device state before retry where the protocol supports it 2026-04-16
46 Instance identity is credential-bound, not self-asserted Each instance authenticates to the central DB with a credential (cert/gMSA/SQL login) bound to its InstanceId; the DB rejects cross-instance config reads server-side (adversarial review finding #2) 2026-04-16
47 InstanceCredential table + authorization stored procs Credentials and the InstanceId they are authorized for live in the DB; all config reads go through procs that enforce the mapping rather than trusting the client 2026-04-16
48 Config is versioned as immutable generations with atomic publish Admin publishes a whole generation in one transaction; instances only ever observe fully-published generations, never partial multi-row edits (adversarial review finding #3) 2026-04-16
49 Surgical reload applies a generation diff, not raw row deltas The source snapshot is atomic (generation), but applying it to a running instance is still incremental — only affected drivers/devices/tags reload 2026-04-16
50 Explicit rollback via re-publishing a prior generation Generations are never deleted; rollback is just publishing an older generation as the new current, so instances apply it the same way as a forward publish 2026-04-16
51 InstanceGenerationState tracks applied generation per instance Admin can see which instances have picked up a new publish and detect stragglers or failed applies 2026-04-16
52 Address space registration via builder/context API Core owns the tree; driver streams AddFolder/AddVariable on an IAddressSpaceBuilder, avoids buffering the whole tree and supports incremental discovery 2026-04-17
53 Capability discovery via interface checks (is IAlarmSource) The interface is the capability — no redundant flag enum to keep in sync with the implementation 2026-04-17
54 Optional IRediscoverable sub-interface for change-detection Drivers with a native change signal (Galaxy deploy time, OPC UA change notifications) opt in; static drivers skip it 2026-04-17
55 Galaxy refactor is incremental — extract interfaces in place first Refactor LmxNodeManager against new abstractions while still in-process, validate, then move behind IPC. Keeps system runnable at each step 2026-04-17
56 Galaxy parity test = v1 integration suite + scripted CLI walkthrough Automated regression plus human-observable behavior on a dev Galaxy 2026-04-17
57 Transport security config stays local in appsettings.json Avoids bootstrap chicken-and-egg (DB-connection credentials can't depend on config fetched from the DB); matches v1 deployment 2026-04-17
58 Generation retention: keep all generations forever Rollback target always available; audit trail complete; storage cost negligible at publish cadence of days/weeks 2026-04-17
59 Core.Abstractions internal-only for now, no NuGet Keep the contract mutable through the first 8 drivers; design as if public, revisit after Phase 5 2026-04-17
60 No hard deadline — phases deliver when they're right Quality cadence over calendar cadence; Galaxy parity bar must be met before moving on 2026-04-17
61 FOCAS SDK license already secured Phase 5 can proceed; Fwlib64.dll available for P/Invoke with no procurement blocker 2026-04-17
62 LmxNodeManager is the foundation for GenericDriverNodeManager, not a rewrite ~85% of the 2923 lines are generic or generic-in-spirit; only ~10% (~290 lines) are truly MXAccess-specific. Concerns are cleanly separated at method boundaries — refactor is rename + DTO swap, not restructuring 2026-04-17
63 Driver stability tier model (A/B/C) Drivers vary in failure profile (pure managed vs wrapped native vs black-box DLL); tier dictates hosting and protection level. See driver-stability.md 2026-04-17
64 FOCAS is Tier C — out-of-process Windows service from day one Fwlib64.dll is a black-box vendor DLL; an AccessViolationException is uncatchable in modern .NET and would tear down the OPC UA server. Same Proxy/Host/Shared pattern as Galaxy 2026-04-17
65 Cross-cutting stability protections mandatory in all tiers SafeHandle for every native resource, memory watchdog, bounded operation queues, scheduled recycle, crash-loop circuit breaker, post-mortem log — apply to every driver process whether in-proc or isolated 2026-04-17
66 Out-of-process driver pattern is reusable across Tier C drivers Galaxy.Proxy/Host/Shared template generalizes; FOCAS is the second user; future Tier B → Tier C escalations reuse the same three-project template 2026-04-17
67 Tier B drivers may escalate to Tier C on production evidence libplctag (AB CIP/Legacy), S7netplus, TwinCAT.Ads start in-process; promote to isolated host if leaks or crashes appear in field 2026-04-17
68 Crash-loop circuit breaker — 3 crashes/5 min stops respawn Prevents host respawn thrashing when the underlying device or DLL is in a state respawning won't fix; surfaces operator-actionable alert; manual reset via Admin UI 2026-04-17
69 Post-mortem log via memory-mapped file Ring buffer of last-N operations + driver-specific state; survives hard process death including native AV; supervisor reads MMF after corpse is gone — only viable post-mortem path for native crashes 2026-04-17
70 Watchdog thresholds = hybrid multiplier + absolute floor + hard ceiling Pure multipliers misfire on tiny baselines; pure absolute MB doesn't scale across deployment sizes. max(N× baseline, baseline + floor MB) for warn/recycle plus an absolute hard ceiling. Slope detection stays orthogonal 2026-04-17
71 Crash-loop reset = escalating cooldown (1 h → 4 h → 24 h manual) with sticky alerts Manual-only is too rigid for unattended plants; pure auto-reset silently retries forever. Escalating cooldown auto-recovers transient problems but forces human attention on persistent ones; sticky alerts preserve the trail regardless of reset path 2026-04-17
72 Heartbeat cadence = 2 s with 3-miss tolerance (6 s detection) 5 s × 3 = 15 s is too slow against 1 s OPC UA publish intervals; 1 s × 3 = 3 s false-positives on GC pauses and pipe jitter. 2 s × 3 = 6 s is the sweet spot 2026-04-17
73 Process-level protections (RSS watchdog, scheduled recycle) apply ONLY to Tier C isolated host processes Process recycle in the shared server would kill every other in-proc driver, every session, and the OPC UA endpoint — directly contradicts the per-driver isolation invariant. Tier A/B drivers get per-instance allocation tracking + cache flush + no-process-kill instead (adversarial review finding #1) 2026-04-17
74 A Tier A/B driver that needs process-level recycle MUST be promoted to Tier C The only safe way to apply process recycle to a single driver is to give it its own process. If allocation tracking + cache flush can't bound a leak, the answer is isolation, not killing the server 2026-04-17
75 Wedged native calls in Tier C drivers escalate to hard process exit, never handle-free-during-call Calling release functions on a handle with an active native call is undefined behavior — exactly the AV path Tier C is designed to prevent. After grace window, leave the handle Abandoned and Environment.Exit(2). The OS reclaims fds/sockets on exit; the device's connection-timeout reclaims its end (adversarial review finding #2) 2026-04-17
76 Tier C IPC has mandatory pipe ACL + caller SID verification + per-process shared secret Default named-pipe ACL allows any local user to bypass OPC UA auth and issue reads/writes/acks directly against the host. Pipe ACL restricts to server service SID, host verifies caller token on connect, supervisor-generated per-process secret as defense-in-depth (adversarial review finding #3) 2026-04-17
77 FOCAS stability test coverage = TCP stub (functional) + FaultShim native DLL (host-side faults) A TCP stub cannot make Fwlib leak handles or AV — those live inside the P/Invoke boundary. Two artifacts cover the two layers honestly: TCP stub for ~80% of failures (network/protocol), FaultShim for the remaining ~20% (native crashes/leaks). Real-CNC validation remains the only path for vendor-specific Fwlib quirks (adversarial review finding #5) 2026-04-17
78 Per-driver stability treatment is proportional to driver risk Galaxy and FOCAS get full Tier C deep dives in driver-stability.md (different concerns: COM/STA pump vs Fwlib handle pool); TwinCAT, AB CIP, AB Legacy get short Operational Stability Notes in driver-specs.md for their tier-promotion triggers and protocol-specific failure modes; pure-managed Tier A drivers get one paragraph each. Avoids duplicating the cross-cutting protections doc seven times 2026-04-17
79 Top-level deployment unit is ServerCluster with 1 or 2 ClusterNode members Sites deploy 2-node clusters for OPC UA non-transparent redundancy (per v1 — Warm/Hot, no VIP). Single-node deployments are clusters of one. Uniform schema avoids forking the config model 2026-04-17
80 Driver / device / tag / poll-group config attaches to ClusterId, not to individual nodes Both nodes of a cluster serve identical address spaces; defining tags twice would invite drift. One generation = one cluster's complete config 2026-04-17
81 Per-node overrides minimal — ClusterNode.DriverConfigOverridesJson only Some driver settings legitimately differ per node (e.g. MxAccess.ClientName so Galaxy distinguishes them) but the surface is small. Single JSON column merged onto cluster-level DriverConfig at apply time. Tags and devices have no per-node override path 2026-04-17
82 ConfigGeneration is cluster-scoped, not fleet-scoped Publishing a generation for one cluster does not affect any other cluster. Simpler rollout (one cluster at a time), simpler rollback, simpler auth boundary. Fleet-wide synchronized rollouts (if ever needed) become a separate concern — orchestrate per-cluster publishes from Admin 2026-04-17
83 Each node authenticates with its own ClusterNodeCredential bound to NodeId Cluster-scoped auth would be too coarse — both nodes sharing a credential makes credential rotation harder and obscures which node read what. Per-node binding also enforces that Node A cannot impersonate Node B in audit logs 2026-04-17
84 Both nodes apply the same generation independently; brief divergence acceptable OPC UA non-transparent redundancy already handles per-endpoint state divergence; ServiceLevel dips on the node mid-apply and clients fail over. Forcing two-phase commit across nodes would be a complex distributed-system problem with no real upside 2026-04-17
85 OPC UA RedundancySupport.Transparent not adopted in v2 True transparent redundancy needs a VIP/load-balancer in front of the cluster. v1 ships non-transparent (Warm/Hot) with ServerUriArray and client-driven failover; v2 inherits the same model. Revisit only if a customer requirement demands LB-fronted transparency 2026-04-17
86 ApplicationUri auto-suggested as urn:{Host}:OtOpcUa but never auto-rewritten OPC UA clients pin trust to ApplicationUri — it's part of the cert validation chain. Auto-rewriting it when an operator changes Host would silently invalidate every client trust relationship. Admin UI prefills on node creation, warns on Host change, requires explicit opt-in to change. Fleet-wide unique index enforces no two nodes share an ApplicationUri 2026-04-17
87 Concrete schema and stored-proc design lives in config-db-schema.md The plan §4 sketches the conceptual model; the schema doc carries the actual DDL, indexes, stored procs, JSON conventions, and authorization model implementations. Keeps the plan readable while making the schema concrete enough to start implementing 2026-04-17
88 Admin UI is Blazor Server with LDAP-mapped admin roles (FleetAdmin / ConfigEditor / ReadOnly) Blazor Server gives real-time SignalR for live cluster status without a separate SPA build pipeline. LDAP reuses the OPC UA auth provider (no parallel user table). Three roles cover the common ops split; cluster-scoped editor grants deferred to v2.1 2026-04-17
89 Edit path is draft → diff → publish; no in-place edits, no auto-publish Generations are atomic snapshots — every change goes through an explicit publish boundary so operators see what they're committing. The diff viewer is required reading before the publish dialog enables. Bulk operations always preview before commit 2026-04-17
90 Per-node overrides are NOT generation-versioned Overrides are operationally bound to a specific physical machine, not to the cluster's logical config evolution. Editing a node override doesn't create a new generation — it updates ClusterNode.DriverConfigOverridesJson directly and takes effect on next apply. Replacement-node scenarios copy the override via deployment tooling, not by replaying generation history 2026-04-17
91 JSON content validation runs in the Admin app, not via SQL CLR CLR is disabled by default on hardened SQL Server instances; many DBAs refuse to enable it. Admin validates against per-driver JSON schemas before invoking sp_PublishGeneration; the proc enforces structural integrity (FKs, uniqueness, ISJSON) only. Direct proc invocation is already prevented by the GRANT model 2026-04-17
92 Dotted-path syntax for DriverConfigOverridesJson keys (e.g. MxAccess.ClientName) More readable than JSON Pointer in operator UI and CSV exports. Reserved-char escaping documented (\., \\); array indexing uses Items[0].Name 2026-04-17
93 sp_PurgeGenerationsBefore deferred to v2.1; signature pre-specified Initial release keeps all generations forever (decision #58). Purge proc shape locked in now: requires @ConfirmToken UI-shown random hex to prevent script-based mass deletion, CASCADE-deletes via WHERE GenerationId IN (...), audit-log entry with row counts. Surface only when a customer compliance ask demands it 2026-04-17
94 Admin UI component library = MudBlazor SUPERSEDED by #102 (See #102 — switched to Bootstrap 5 for ScadaLink parity) 2026-04-17
95 CSV import dialect = strict CSV (RFC 4180) UTF-8, BOM accepted Excel "Save as CSV (UTF-8)" produces RFC 4180 output and is the documented primary input format. TSV not initially supported 2026-04-17
96 Push-from-DB notification deferred to v2.1; polling is the v2.0 model Tightening apply latency from ~30 s → ~1 s would need SignalR backplane or SQL Service Broker — infrastructure not earning its keep at v2.0 scale. Publish dialog reserves a disabled "Push now" button labeled "Available in v2.1" so the future UX is anchored 2026-04-17
97 Draft auto-save (debounced 500 ms) with explicit Discard; Publish is the only commit Eliminates "lost work" complaints; matches Google Docs / Notion mental model. Auto-save writes to draft rows only — never to Published. Discard requires confirmation dialog 2026-04-17
98 Admin UI ships both light and dark themes SUPERSEDED by #103 (See #103 — light-only to match ScadaLink) 2026-04-17
99 CI tiering: PR-CI uses only in-process simulators; nightly/integration CI runs on dedicated Docker + Hyper-V host Keeps PR builds fast and runnable on minimal build agents; the dedicated integration host runs the heavy simulators (oitc/modbus-server, TwinCAT XAR VM, Snap7 Server, libplctag ab_server). Operational dependency: stand up the dedicated host before Phase 3 2026-04-17
100 Studio 5000 Logix Emulate: pre-release validation tier only, no phase-gate If an org license can be earmarked, designate a golden box for quarterly UDT/Program-scope passes. If not, AB CIP ships validated against ab_server only with documented UAT-time fidelity gap. Don't block Phase 4 on procurement 2026-04-17
101 FOCAS Wireshark capture is a Phase 5 prerequisite identified during Phase 4 Target capture (production CNC, CNC Guide seat, or customer site visit) identified by Phase 4 mid-point; if no target by then, escalate to procurement (CNC Guide license or dev-rig CNC) as a Phase 5 dependency 2026-04-17
102 Admin UI styling = Bootstrap 5 vendored (parity with ScadaLink CentralUI) Operators using both ScadaLink and OtOpcUa Admin see the same login screen, same sidebar, same component vocabulary. ScadaLink ships Bootstrap 5 with a custom dark-sidebar + light-main aesthetic; mirroring it directly outweighs MudBlazor's Blazor-component conveniences. Supersedes #94 2026-04-17
103 Admin UI ships single light theme matching ScadaLink (no dark mode in v2.0) ScadaLink is light-only; cross-app aesthetic consistency outweighs the ergonomic argument for dark mode. Revisit only if ScadaLink adds dark mode. Supersedes #98 2026-04-17
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
126 Three-gate model (entry / mid / exit) for every implementation phase, with explicit compliance-check categories Specified in implementation/overview.md. Categories: schema compliance (DB matches the doc), decision compliance (every decision number has a code/test citation), visual compliance (Admin UI parity with ScadaLink), behavioral compliance (per-phase smoke test), stability compliance (cross-cutting protections wired up for Tier C drivers), documentation compliance (any deviation reflected back in v2 docs). Exit gate requires two-reviewer signoff; silent deviation is the failure mode the gates exist to prevent 2026-04-17
127 Per-phase implementation docs live under docs/v2/implementation/ with structured task / acceptance / compliance / completion sections Each phase doc enumerates: scope (in / out), entry gate checklist, task breakdown with per-task acceptance criteria, compliance checks (script-runnable), behavioral smoke test, completion checklist. Phase 0 + Phase 1 docs are committed; Phases 28 land as their predecessors clear exit gates 2026-04-17

Reference Documents

  • Driver Implementation Specifications — per-driver details: connection settings, addressing, data types, libraries, API mappings, error handling, implementation notes
  • Test Data Sources — per-driver simulator/emulator/stub for development and integration testing
  • Driver Stability & Isolation — stability tier model (A/B/C), per-driver hosting decisions, cross-cutting protections, FOCAS and Galaxy deep dives
  • Central Config DB Schema — concrete table definitions, indexes, stored procedures, authorization model, JSON conventions, EF Core migrations approach
  • Admin Web UI — Blazor Server admin app: information architecture, page-by-page workflows, per-driver config screen extensibility, real-time updates, UX rules
  • Implementation Plan Overview — phase gate structure (entry / mid / exit), compliance check categories (schema / decision / visual / behavioral / stability / documentation), deliverable conventions, "what counts as following the plan"
  • Phase 0 — Rename + .NET 10 cleanup — mechanical LmxOpcUa → OtOpcUa rename with full task breakdown, compliance checks, completion checklist
  • Phase 1 — Configuration + Core.Abstractions + Admin scaffold — central MSSQL schema, EF Core migrations, stored procs, LDAP-authenticated Blazor Server admin app with ScadaLink visual parity, LiteDB local cache, generation-diff applier; 5 work streams (AE), full task breakdown, compliance checks, 14-step end-to-end smoke test

Out of Scope / Deferred