diff --git a/docs/plans/2026-06-11-alias-tag-design.md b/docs/plans/2026-06-11-alias-tag-design.md new file mode 100644 index 00000000..3bea1fd1 --- /dev/null +++ b/docs/plans/2026-06-11-alias-tag-design.md @@ -0,0 +1,266 @@ +# Galaxy Alias Tag (UNS) — Design + +**Date:** 2026-06-11 +**Status:** Approved (brainstorming) — ready for implementation plan +**Author:** design session + +## Goal + +Let an Equipment node expose a Galaxy attribute under a friendly UNS name as a +**first-class driver-bound tag** — an *alias tag* — instead of a relay +VirtualTag whose script is nothing but `return ctx.GetTag("…").Value;`. The +alias is a normal `Tag` row bound to the Galaxy gateway driver, resolved by a +direct driver subscription, with **no Roslyn script engine** in the path. + +## Motivation + +The UNS Equipment tree is an Equipment-kind namespace; the Galaxy gateway +mirrors the raw ArchestrA hierarchy into a separate **SystemPlatform**-kind +namespace. A driver instance populates one namespace kind, and +`DraftValidator.ValidateDriverNamespaceCompatibility` forbids a `GalaxyMxGateway` +driver in an Equipment namespace. So the only way today to surface a Galaxy +attribute under an Equipment with a curated name (`speed-rpm` → +`TestMachine_020.TestChangingInt`) is a VirtualTag whose script relays a single +`ctx.GetTag(...)`. The `ctx.GetTag(FullName)` call is the one mechanism that +bridges the two namespace kinds. + +Inspecting one rig equipment (`EQ-89e6583eeb9c` / `blender-20`) showed **all 24** +of its tags are exactly this pass-through, with zero computation, on a +driver-less equipment. Across the rig that is ~960 scripts (24 × 40 machines), +each spinning a compiled script that recompiles/evaluates on every input change +just to copy one value — and flattening the source quality/timestamp through the +script engine in the process. + +An alias tag removes the script layer entirely: the runtime already materialises +and subscribes equipment driver tags end-to-end (shipped 2026-06-06), and an +alias is functionally identical to a Modbus/S7 equipment tag — the only thing +special is that its driver happens to live in a SystemPlatform namespace. + +## Decisions (settled in brainstorming) + +1. **Resolution model: independent driver subscription.** The alias is a real + equipment `Tag` bound to the Galaxy gateway; the runtime subscribes to its + `FullName` directly. Self-sufficient — works whether or not the raw Galaxy + SystemPlatform mirror is deployed. The Galaxy `SubscriptionRegistry` dedups by + reference, so MXAccess sees a single subscription if the mirror also exists. +2. **Write semantics: configurable per alias, default read-only.** Alias carries + an `AccessLevel` like any tag; defaults `ReadOnly`, can be set writable. + Write-through flows through the Galaxy `IGalaxyDataWriter`, gated by the + existing `SecurityClassification` (FreeAccess/Operate/Tune/Configure) **and** + the OPC UA NodeAcl on the equipment node. +3. **Convert existing relays.** Ship a converter that detects pure-relay + VirtualTags and rewrites each as an alias `Tag`, removing the script + vtag. +4. **Authoring on the Tags tab.** An alias is an equipment tag bound to Galaxy, + so it lives on the equipment page's existing **Tags** tab; an "Add alias" + action opens the existing Galaxy live-browse picker. Aliases and native tags + coexist in one list. +5. **Approach A — reuse the `Tag` entity; broaden the equipment-tag path.** No + new entity, **no EF migration**. "Alias-ness" is simply "an equipment tag + bound to a SystemPlatform (Galaxy) driver." + +## Approach (chosen) vs alternatives + +- **A — Reuse `Tag`, broaden the equipment-tag filter (CHOSEN).** Smallest + change; rides the proven equipment-tag composer/artifact/runtime/materialise + rails; honours the no-migration constraint; the converter becomes a straight + "rewrite relay VirtualTag rows as Tag rows" transform. +- **B — New `AliasTag` entity + table.** Conceptually tidy but requires a + migration and duplicates the entire equipment-tag machinery (composer, + artifact, runtime, materialise, converter) for a near-identical row. More code, + more risk, no functional gain. Rejected. +- **C — OPC UA pointer/redirect to a mirror node.** Rejected by decision #1 — + it would force the raw mirror to be deployed and is trickier in the node + manager. Listed for completeness. + +## Gate inventory (what blocks an equipment-scoped Galaxy tag today) + +Verified against the code: + +1. **`Phase7Composer.equipmentTags` filter** requires the tag's driver namespace + be `NamespaceKind.Equipment` → a Galaxy-backed equipment tag is silently + dropped. **Must broaden.** +2. **`DeploymentArtifact.BuildEquipmentTagPlans`** mirrors that filter → + changes in lockstep (an existing byte-parity test guards the pair). +3. **AdminUI** offers only Equipment-namespace drivers when adding an equipment + tag → needs the "Add alias → Galaxy picker" path. +4. **NOT blocked:** `DraftValidator.ValidateDriverNamespaceCompatibility` checks + the driver's *home* namespace (still SystemPlatform — unaffected); and + `sp_ValidateDraft` has no tag-level namespace-kind invariant. The + "`EquipmentId` required ⟺ Equipment-kind driver" line in `Tag.cs` is a doc + convention, not enforced — it is reframed (below). + +## Architecture + +### 1. Data model & the core composer/artifact change + +An alias is an ordinary `Tag` row (no new entity, no migration): + +| Column | Value | +|---|---| +| `EquipmentId` | owning equipment (e.g. `EQ-89e6583eeb9c`) | +| `DriverInstanceId` | the Galaxy gateway (`MAIN-galaxy-mxgw`) | +| `Name` | friendly UNS leaf (`speed-rpm`) | +| `DataType` | OPC UA type (`Int32`), operator-set, matches the Galaxy attribute | +| `AccessLevel` | per-alias, default `ReadOnly` | +| `FolderPath` | `null` (alias hangs directly under the equipment) | +| `TagConfig` | `{"FullName":"TestMachine_020.TestChangingInt"}` | + +`TagConfig.FullName` is exactly what `Phase7Composer.ExtractTagFullName` already +reads for equipment tags — so the wire-reference plumbing is zero-new-code. + +**Structural change (two byte-parity-locked places):** +- `Phase7Composer.cs` `equipmentTags`: predicate becomes + `ns.Kind == NamespaceKind.Equipment || di.DriverType == "GalaxyMxGateway"`. +- `DeploymentArtifact.BuildEquipmentTagPlans`: the identical broadening; the + existing parity test is extended with an alias row. + +Everything downstream is reused unchanged: `Phase7Applier` materialises the +variable under the equipment folder; the runtime subscribes by +`DriverInstanceId` + `FullName` on the Galaxy driver. The SystemPlatform mirror +filter (`EquipmentId == null`) is untouched, so the 396 raw mirror tags are +unaffected. + +**Convention reframe (doc-only):** the `Tag.cs` comment becomes "`EquipmentId` +set ⟺ the tag participates in the Equipment tree, regardless of the driver's +namespace kind; a Galaxy-gateway-bound equipment tag is an **alias**." + +### 2. Validation, authorization & write-through + +- **Validation:** add one positive `DraftValidator` check — an equipment tag + bound to `GalaxyMxGateway` must carry a non-empty `TagConfig.FullName` + (otherwise it subscribes to nothing). No gate is needed to *permit* aliases. +- **Authz layer 1 (OPC UA permission):** the alias lives in the Equipment-kind + namespace → governed by **NodeAcl** like any equipment tag + (`ReadOnly`/`WriteOperate`/`WriteTune`/`WriteConfigure` per `NodeScope`). +- **Authz layer 2 (write-through):** a write that passes layer 1 flows through + the equipment-tag write path to the Galaxy `IGalaxyDataWriter`, which routes by + the Galaxy attribute's `SecurityClassification`. A writable alias is gated by + **both** — exactly like writing the raw Galaxy tag, reached via the UNS name. +- **Default & opt-in:** `AccessLevel` defaults `ReadOnly`; writable is explicit. +- **Quality/timestamp bonus:** the value comes straight off the driver + subscription, so the alias carries the MXAccess quality + source timestamp + natively (the relay flattened those through the script engine). +- **Edge — cluster alignment:** the alias is hosted/subscribed on the Galaxy + driver's cluster; the driver-less equipment is attributed by its UNS line's + cluster. They must resolve to the same cluster (the existing same-cluster + namespace-binding SQL check keeps a driver and its namespace aligned). + Cross-cluster alias is out of scope — documented guardrail, not built. + +### 3. AdminUI authoring (Tags tab) + +- **Entry point:** the equipment page **Tags** tab gains **+ Add alias (browse + Galaxy)** beside *+ Add tag*; disabled with a hint when the equipment's cluster + has no `GalaxyMxGateway` driver. +- **Alias modal** — a focused sibling of `TagModal` (same pattern as + `ScriptedAlarmModal`; avoids overloading `TagModal`'s driver-typed editor + dispatch): + - **Galaxy driver resolution** — from the equipment's pick-context, find + `GalaxyMxGateway` driver(s) in its cluster. One → auto-bind; several → + select; none → action disabled. + - **Live-browse pick** — embeds the existing + `GalaxyAddressPickerBody`/`DriverTagPicker` pointed at that gateway; operator + drills the live Galaxy hierarchy, picks an attribute → fills `FullName` and + suggests `DataType` from the attribute's `mx_data_type`. + - **Fields** — `Name` (UNS leaf; existing equipment signal-name + uniqueness/regex applies), `DataType` (defaulted, editable), `AccessLevel` + (default `ReadOnly`). + - **Save** — assembles `TagConfig = {"FullName": …}` and writes a `Tag` row. +- **Service layer:** thin `CreateAliasTagAsync` / `UpdateAliasTagAsync` on + `IUnsTreeService` that assemble the `TagConfig` and reuse the existing + equipment-tag name-uniqueness guard; **delete reuses `DeleteTagAsync`**. + `LoadTagsForEquipmentAsync` gains a derived **Source** field. +- **Display:** an alias row shows an **"alias" badge** and a **Source** column + `galaxy: `; native driver tags render their driver/register as today. + +### 4. Relay → alias converter + +- **Detection (conservative):** convertible only when the bound `Script` body is + exactly a single relay return (modulo whitespace): + `^\s*return\s+ctx\.GetTag\(\s*"([^"]+)"\s*\)\s*\.Value\s*;\s*$`. Anything with + extra statements, conditionals, arithmetic, or multiple `GetTag` calls is left + untouched. Ref extraction reuses the Commons `EquipmentScriptPaths` GetTag + helper. +- **`{{equip}}` expansion:** if the ref contains the reserved token, expand it + per-equipment via `DeriveEquipmentBase`/`SubstituteEquipmentToken`; the shared + `Script` is deleted only after its last referencing VirtualTag is converted. +- **Safety guards (skip + report, never silently drop):** shared script still in + use → drop vtag, keep script; `Historize = true` → skip (Tag has no historize + column); no Galaxy gateway in the cluster → skip; non-relay body → skip. +- **Field carry-over:** alias `Name` = vtag `Name`; `DataType` = vtag `DataType`; + `AccessLevel` = `ReadOnly`. `ChangeTriggered`/`TimerIntervalMs` dropped (the + subscription replaces them). +- **Atomicity:** each conversion does delete-VirtualTag-(+orphan-Script)-and- + insert-alias-Tag as one unit so a same-named alias and vtag never coexist (the + equipment signal-name collision check stays satisfied). +- **Invocation:** one method `ConvertRelayVirtualTagsToAliasesAsync(scope, + dryRun)`, **FleetAdmin-gated**, operating on the **draft** generation (never + bypasses publish). Exposed two ways sharing that method: **per-equipment** + (Tags tab) and **fleet-wide** (a maintenance entry). Both run **dry-run preview + first** (convertible refs + every skip with reason), then **confirm-apply**. + Touches only existing entities (`Tag` insert, `VirtualTag`/`Script` delete) — + **no migration**. + +## Net service / type surface (no schema change) + +New on `IUnsTreeService`: `CreateAliasTagAsync`, `UpdateAliasTagAsync`, +`ConvertRelayVirtualTagsToAliasesAsync(scope, dryRun)`; `LoadTagsForEquipmentAsync` +gains a derived Source/alias field. New AdminUI types: an alias input DTO + a +converter preview/result DTO. New component: `AliasTagModal.razor` (+ reuse of +`GalaxyAddressPickerBody`/`DriverTagPicker`). One positive `DraftValidator` check. +Composer + artifact filter broadening (byte-parity pair). + +**No Configuration entity change and no EF migration** — an alias is a `Tag` +row; the converter only inserts `Tag` rows and deletes `VirtualTag`/`Script` +rows. + +## Testing + +No bUnit — service/composer logic unit-tested; Razor proven live. + +- **Composer/artifact:** a Galaxy-bound equipment tag now lands in + `equipmentTags`; a SystemPlatform mirror tag (`EquipmentId == null`) still does + not; `DeploymentArtifact` byte-parity extended with an alias row. +- **DraftValidator:** alias missing `TagConfig.FullName` → the new error; + well-formed alias → clean; `ValidateDriverNamespaceCompatibility` still passes. +- **Service (in-memory EF):** `CreateAliasTagAsync` writes the expected row; + signal-name collision rejected; `LoadTagsForEquipmentAsync` surfaces Source; + delete via `DeleteTagAsync`. +- **Converter (in-memory EF):** exact relay → converted (ref/name/DataType + carried, vtag + orphan script removed); non-relay bodies → untouched; shared + script kept while referenced, deleted on last consumer; `Historize=true` → + skipped+reported; no-gateway → skipped+reported; `{{equip}}` → expanded + per-equipment; dry-run mutates nothing; apply atomic. +- **Live docker-dev `/run`** (user drives; agent does **not** sign in): create an + alias on `blender-20` via the Galaxy picker → publish → read it live via + Client.CLI (value + quality + timestamp); flip writable → write-through changes + the Galaxy attribute, read-only rejects writes; converter dry-run on + `blender-20` previews 24 → apply → 24 relays become alias Tags → values still + live, no script-log entries for them. + +**Done gate:** solution builds clean + `dotnet test` green + live `/run` pass. + +## Error handling / edge cases + +- **Unresolvable `FullName`** (typo, object not deployed) — the Galaxy + subscription returns Bad; the alias node publishes Bad quality and recovers if + the object appears. Same degrade as the relay's `BadNodeIdUnknown`; validation + only checks non-empty (existence is a runtime concern). +- **Underlying Galaxy object renamed/deleted** — alias goes Bad at runtime; no + config-time coupling (it holds a `FullName` string). +- **Gateway down** — alias quality goes Bad/uncertain like any Galaxy tag; + recovers on reconnect. +- **Cluster alignment** — the Section-2 guardrail; cross-cluster out of scope. + +## Docs + +- `docs/Uns.md` — alias tags on the Tags tab + the converter (per-equipment and + fleet-wide). +- `CLAUDE.md` — a short note near the Galaxy / contained-name-vs-tag-name concept. +- A pointer wherever equipment tags are described. + +## Guardrails + +Feature branch off master; stage by explicit path (never `git add .`); never +stage `sql_login.txt` or `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`; never echo the +gateway API key into a new tracked file; no force-push; no `--no-verify`; **no +Configuration entity or EF migration change**.