# 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.
```
