# Galaxy as a Standard Equipment Driver — Design **Date:** 2026-06-12 **Status:** Approved (brainstorming) — ready for implementation planning **Scope:** One design, three phases (A → B → C). Each phase is an independently mergeable, live-verifiable branch. ## Goal Make `GalaxyMxGateway` **just another standard driver**: bound under **Equipment**-kind namespaces like Modbus/S7, authored through the normal `TagModal`, materialized through the single equipment-tag path, with **native alarms** and **server-side historian / `HistoryRead`** working in that model. Retire the `SystemPlatform` namespace split, the SystemPlatform mirror, and the alias-tag/relay machinery. ## Background — why Galaxy is "treated differently" today The `GalaxyMxGateway` driver already implements the full Tier-A capability set through the same `IDriver`/`IDriverFactory` path as Modbus (`IReadable`/`IWritable`/`ISubscribable`/`ITagDiscovery`), plus more (`IAlarmSource` native alarms, `IRediscoverable` deploy-watch). It is **not** the odd one out at the driver-contract level. The difference lives **above the socket**, in four seams: 1. **Namespace inversion (root cause).** `NamespaceKind` (`Equipment` vs `SystemPlatform`) plus `DriverTypeRegistry.NamespaceKindCompatibility` and `DraftValidator.ValidateDriverNamespaceCompatibility` hard-code that `GalaxyMxGateway` may live **only** in a `SystemPlatform`-kind namespace and every other driver **only** in `Equipment`-kind. That single asymmetry forces everything below. 2. **The alias-tag / relay workaround.** Because Galaxy can't live under Equipment directly, its objects are bridged into the UNS via *alias tags* (`Tag{EquipmentId, DriverInstanceId=GalaxyMxGateway, TagConfig={"FullName":…}}`) and the relay→alias converter. A whole machinery (`CreateAliasTagAsync`, `CheckAliasDriverGuardAsync`, `AliasTagModal`, `/uns/convert-relays`, `IsAlias`/`Source` row decoration) exists **only** to compensate for the namespace split. (Shipped 2026-06-12.) 3. **A separate address-space pass.** `Phase7Applier.MaterialiseGalaxyTags` runs distinct from `MaterialiseEquipmentTags`, with byte-parity exception clauses (`|| di.DriverType == "GalaxyMxGateway"`) in `Phase7Composer` and `DeploymentArtifact`. 4. **Historian gap.** Galaxy implements `IAlarmSource` (native alarms, verified working end-to-end 2026-05-31) but **not** `IHistoryProvider`, and the server has **no OPC UA `HistoryRead` dispatcher at all** today. ### The decisive finding: two materialization paths The server has two ways nodes reach the address space, and Galaxy and Modbus use *different* ones: - **Authored-equipment-tag path** — `Phase7Composer` → `DeploymentArtifact` → `OtOpcUaNodeManager.MaterialiseEquipmentTags`. Materializes explicitly-authored `Tag` entities. Wires live read/write/subscribe **and scripted alarms** (`MaterialiseAlarmCondition`, keyed by `ScriptedAlarmId`). Alias tags and Modbus tags live here. - **Driver-discovery path** — `ITagDiscovery.DiscoverAsync` → `GenericDriverNodeManager` → builder. Walks the driver's whole tree and is the **only** path that wires **native `IAlarmSource` alarms**: `GenericDriverNodeManager` (`src/Core/.../OpcUa/GenericDriverNodeManager.cs:52‑83`) registers an alarm-condition sink per `MarkAsAlarmCondition` variable keyed by `DriverAttributeInfo.FullName`, subscribes `IAlarmSource.OnAlarmEvent`, and routes each transition to the sink whose key equals `AlarmEventArgs.SourceNodeId`. **This path is the SystemPlatform mirror.** Consequence: Galaxy **live values already work on equipment tags** (alias tags prove it — direct driver subscription by `FullName`), but Galaxy **native alarms only work on the mirror**. **Killing the mirror means the equipment-tag path must gain native-alarm wiring.** That is the single largest real piece of work in this design (Phase B). ## Locked decisions (from brainstorming) | Decision | Choice | |---|---| | Binding model | **Authored equipment tags** — a Galaxy point is an ordinary `Tag` bound to `GalaxyMxGateway` with `TagConfig.FullName`. No alias concept. | | Historian | **Server-side, driver-agnostic** — an `IsHistorized` tag's `HistoryRead` is served by the server's configured historian backend (the existing Wonderware sidecar reader), mapping the tag → historian tagname. No mxaccessgw history RPC. | | Migration | **Clean break** — remove `SystemPlatform`; no converter; reseed docker-dev rigs by hand. Schema/EF change to drop the split is in scope. | | Alias machinery | **Delete it** — Galaxy authors through the standard `TagModal`; `AliasTagModal`, alias guards, `/uns/convert-relays`, `IsAlias`/`Source` decoration all retire. | | Doc/plan shape | **One design, three phases.** | ### Approaches considered and rejected - **Discovery-into-equipment** (bind a `GalaxyMxGateway` instance to an Equipment node; `DiscoverAsync` auto-materializes the subtree). Rejected: reworks how Equipment materialization and authored-vs-discovered tags coexist; larger and changes the data model. - **Keep the SystemPlatform mirror as browse-only.** Rejected: leaves two materialization paths — only partially meets the goal. - **Galaxy driver implements `IHistoryProvider` via mxaccessgw.** Rejected: requires new history RPCs in the sibling gateway repo (cross-repo, out of this repo's control) and is not driver-agnostic. - **One-time migration converter** / **keep SystemPlatform deprecated.** Rejected in favor of the clean break — this is pre-release with only dev rigs. ## Architecture (target end-state) `GalaxyMxGateway` is an ordinary **Equipment-kind** driver. A Galaxy point is an authored `Tag{EquipmentId set, DriverInstanceId=GalaxyMxGateway, TagConfig={"FullName":"DelmiaReceiver_001.DownloadPath"}}` — identical to today's alias tag with the "alias" concept, guards, and namespace restriction deleted. The `NamespaceKind` split, the SystemPlatform mirror, `MaterialiseGalaxyTags`, and the byte-parity `|| di.DriverType == "GalaxyMxGateway"` clauses retire. History for **any** `IsHistorized` tag (not just Galaxy) is served by a new server-side history backend wrapping the existing Wonderware Historian client. ## Components / workstreams ### Workstream 1 — Namespace de-split (foundation, Phase A) - **`NamespaceKind`** (`src/Core/.../Configuration/Enums/NamespaceKind.cs`): remove `SystemPlatform` (keep `Equipment`; `Simulated` stays reserved). - **EF migration** (`src/Core/.../Configuration/Migrations/`): forward-only. Delete orphaned `SystemPlatform` namespace rows, then relax / rebuild the `UX_Namespace_Cluster_Kind` unique constraint (now effectively single-valued). `Namespace.Kind` may remain a column (only `Equipment`) to minimize churn, or be dropped — implementer's call during planning, but the migration is required. - **`DriverTypeRegistry`** (`src/Core/.../Core.Abstractions/DriverTypeRegistry.cs`): `GalaxyMxGateway` `NamespaceKindCompatibility` → `Equipment`. - **`DraftValidator.ValidateDriverNamespaceCompatibility`** (~`:225‑245`): drop the Galaxy/SystemPlatform branches. - **`Phase7Composer`** + **`DeploymentArtifact.BuildEquipmentTagPlans`**: remove the `|| di.DriverType == "GalaxyMxGateway"` exception clauses — the equipment-tag filter now admits Galaxy naturally. **Must stay byte-parity with each other.** - **Retire `Phase7Applier.MaterialiseGalaxyTags`** and the Galaxy use of the `DiscoverAsync`/`GenericDriverNodeManager` mirror. `ITagDiscovery.DiscoverAsync` **stays** for the address-picker live browse only (which already uses `IBrowserSessionService` and is unaffected). ### Workstream 2 — Native alarms on the equipment-tag path (the hard part, Phase B) - Teach `MaterialiseEquipmentTags` / `OtOpcUaNodeManager` that for an equipment tag whose `DriverAttributeInfo.IsAlarm` is true: register an alarm-condition sink keyed by the tag's `FullName`, subscribe the owning driver's `IAlarmSource`, and route `OnAlarmEvent.SourceNodeId == FullName` → sink. This ports `GenericDriverNodeManager`'s alarm-forwarder logic into the curated path (consider extracting the forwarder so both paths share it rather than duplicating). - **Reuse** the existing Part 9 `AlarmConditionState` materialization (`OtOpcUaNodeManager.MaterialiseAlarmCondition`) and the `alarm-commands` / ack plumbing — those are condition-node mechanics and are agnostic to whether the alarm is scripted or native. - Teardown / rediscovery parity: mirror `GenericDriverNodeManager`'s unsubscribe-before-rewalk so a Galaxy redeploy (`IRediscoverable`) does not double-deliver. ### Workstream 3 — Server-side historian backend (additive, Phase C) - New OPC UA **`HistoryRead` service override** in `OtOpcUaNodeManager` (none exists today) → a **router** that, for an `IsHistorized` node, resolves a historian tagname and calls the registered server-side `IHistorianDataSource` (the Wonderware backend hardened in the TCP-transport work). - **Tagname mapping:** default to the tag's driver `FullName`, with an optional explicit override on the tag. (System Platform logs attributes to AVEVA Historian under the same reference, so the default is usually correct.) - Apply `DriverAttributeInfo.IsHistorized` → set the UA `Historizing` attribute + `AccessLevel.HistoryRead` bit at materialization (`OtOpcUaNodeManager` `CreateVariable`, currently hardcoded `Historizing = false`). - **Config:** a server `Historian` section selecting the backend (Null default; Wonderware when configured) — mirrors the existing `AlarmHistorian` pattern. - Driver-agnostic by construction: works for any `IsHistorized` tag, not only Galaxy. ### Workstream 4 — AdminUI fold-in (Phase A) - Galaxy tag authored through the **standard `TagModal`** + the Galaxy live-browse picker (`GalaxyAddressPickerBody`) as that driver's address picker — same convention as Modbus/S7 pickers. - **Retire** `AliasTagModal`, the alias guards (`CheckAliasDriverGuardAsync`, `LoadGalaxyGatewaysForEquipmentAsync` special path), `IsAlias`/`Source` row decoration, and the `/uns/convert-relays` page + `RelayConversion` machinery. - **Keep** `GalaxyDriverPage` (typed driver-config editor) — that is the convention other drivers are moving toward, not a special-case. - Add a driver-agnostic **`IsHistorized`** (+ optional historian-tagname) control to the tag editor (Phase C surfaces it; the control can land in Phase A inert). ## Data flow (a Galaxy tag, after) ``` Author Tag{Equipment, GalaxyMxGateway, FullName} via TagModal + Galaxy picker → DraftValidator (no Galaxy special branch) → Phase7Composer / DeploymentArtifact (byte-parity, no exception clause) → OtOpcUaNodeManager.MaterialiseEquipmentTags • live value : driver ISubscribable/IReadable by FullName (works today) • native alarm : IsAlarm → register sink + IAlarmSource.OnAlarmEvent (NEW, ws-2) • historized : IsHistorized → Historizing + HistoryRead bit (NEW, ws-3) OPC UA client HistoryRead → OtOpcUaNodeManager.HistoryRead override (NEW, ws-3) → server historian backend (IHistorianDataSource = Wonderware sidecar) → AVEVA Historian ``` ## Error handling / edge cases - **Byte-parity invariant:** `Phase7Composer` and `DeploymentArtifact` must produce identical equipment-tag plans for the same input after the exception clauses are removed. Covered by a parity round-trip test. - **Unknown alarm `SourceNodeId`:** preserve `GenericDriverNodeManager`'s silently-drop behavior (an id may belong to another driver or an unflagged variable). - **Historian not configured:** `Historian` backend defaults to Null → `HistoryRead` returns the standard "history unsupported / no data" status, not an error spike. - **Redeploy double-delivery:** unsubscribe the native-alarm forwarder before re-walking on `IRediscoverable.OnRediscoveryNeeded`. - **Migration on a non-empty DB:** the forward-only EF migration must tolerate existing `SystemPlatform` rows (delete them) before the relaxed constraint applies. ## Migration (clean break) No converter. Drop `SystemPlatform`; existing SystemPlatform namespaces / mirror tags are abandoned and the docker-dev rigs are **reseeded by hand** with Galaxy tags authored under Equipment. The EF migration is forward-only and includes a data step to delete orphaned `SystemPlatform` namespace rows so the relaxed constraint applies cleanly. ## Testing (no bUnit) **xUnit + Shouldly (offline):** - `DraftValidator`: `GalaxyMxGateway` now valid under Equipment; old SystemPlatform branch gone. - `Phase7Composer` ↔ `DeploymentArtifact` byte-parity with Galaxy equipment tags (no exception clause). - **Native-alarm sink routing on the equipment path** — fake `IAlarmSource` fires `OnAlarmEvent`; assert the equipment tag's condition transitions (Phase B). - **`HistoryRead` router** — fake `IHistorianDataSource`; assert Raw / Processed / AtTime mapping + tagname resolution (default `FullName` and override) (Phase C). - `IsHistorized` → UA `Historizing` + `AccessLevel.HistoryRead` at materialization (Phase C). **Live docker-dev `/run` (user-driven; the agent does NOT sign in)** — the gate, given this codebase's documented live-only Razor binding history: - Author a Galaxy equipment tag via `TagModal` → live value updates. - Trip a Galaxy alarm → Part 9 condition appears under the equipment + ack round-trips (Phase B). - `HistoryRead` a historized Galaxy tag via Client.CLI `historyread` → samples return (Phase C). ## Phasing 1. **Phase A — de-split + AdminUI fold-in** (ws 1 + 4). Galaxy live values + scripted alarms work under Equipment; alias machinery deleted. Shippable alone. 2. **Phase B — native alarms on the equipment path** (ws 2). Restores native Galaxy alarms in the new model. 3. **Phase C — server-side historian** (ws 3). The additive, driver-agnostic `HistoryRead` capability. Each phase is a mergeable feature branch off master with its own live-verify. Phase C is the most isolated and largest; if it grows it can split into its own plan. ## Hard rules (carried into implementation) - Stage by path; never `git add .`. Never stage `sql_login.txt`, `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`, or `pending.md`. - Never echo the gateway API key or the historian `SharedSecret` into a tracked file. - No force-push, no `--no-verify`. - The `NamespaceKind` removal **does** require a Configuration/EF migration — that is in scope for this design (a deliberate exception to the usual "no migration" rule). - Build each phase on a feature branch off master. ## Authoritative touched-code list (for planning) - `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NamespaceKind.cs` - `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs` - `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs` (constraint) - `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/*` (new migration) - `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs` - `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs` - `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` - `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` (retire MaterialiseGalaxyTags) - `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (native-alarm wiring, IsHistorized attrs, HistoryRead override) - `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` - `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` (extract shared forwarder) - `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs` (server-side history router seam) - `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware*` (register as server historian backend) - AdminUI: `Uns/` (`UnsTreeService`, `IUnsTreeService`, alias removal), `Uns/TagEditors/*`, `Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor` (keep), retire `AliasTagModal`, `RelayConversion*`, `/uns/convert-relays` page. - Docs: `docs/Uns.md`, `docs/security.md`/driver docs as touched, a new `docs/drivers/Galaxy.md` note + `CLAUDE.md` "Alias tags" section rewrite. ```