# Galaxy Alias Tag (UNS) Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task. **Goal:** Let an Equipment node surface a Galaxy attribute under a friendly UNS name as a first-class driver-bound `Tag` (an *alias*), resolved by a direct Galaxy-driver subscription — replacing relay VirtualTags whose script is nothing but `return ctx.GetTag("X").Value;`. **Architecture:** Approach A — an alias *is* an ordinary `Tag` row (`EquipmentId` set, `DriverInstanceId` = the Galaxy gateway, `TagConfig = {"FullName":""}`). The only structural change is broadening the equipment-tag filter in the composer + artifact decoder to admit `GalaxyMxGateway`-backed equipment tags; everything downstream (materialise, subscribe, write-through) is reused unchanged. **No Configuration entity and no EF migration.** A converter rewrites existing relay VirtualTags as alias Tags. **Tech Stack:** .NET 10, Akka.NET, Blazor Server (InteractiveServer, no bUnit), EF Core (OtOpcUaConfigDbContext, optimistic RowVersion), xUnit + Shouldly, EF InMemory for service tests. **Design:** `docs/plans/2026-06-11-alias-tag-design.md` (committed master `305023aa`). ## Grounded facts (verified against the code) - **Composer filter** — `Phase7Composer.cs:365-381` `equipmentTags` = `EquipmentId is not null` AND driver namespace `Kind == NamespaceKind.Equipment`; `FullName: ExtractTagFullName(t.TagConfig)` (reads top-level `"FullName"`). The Galaxy/SystemPlatform filter is the inverse (`EquipmentId is null`, `Kind == SystemPlatform`) — untouched. - **Artifact decoder** — `DeploymentArtifact.cs:447-526` `BuildEquipmentTagPlans` mirrors it (builds `equipmentNamespaces` + `driverToNamespace`, requires `equipmentNamespaces.Contains(nsId)`). Byte-parity with the composer. The artifact's `DriverInstances` array carries `DriverType` (`DriverInstancePlan(DriverInstanceId, DriverType, ConfigJson)`). - **Validator** — `DraftValidator.cs`; `DraftSnapshot` exposes full `Tags` (with `TagConfig`) + `DriverInstances` (with `DriverType`). `ValidateDriverNamespaceCompatibility` (line 192) checks a driver's *home* namespace and is unaffected. No tag-level namespace-kind invariant in `sp_ValidateDraft`. - **Service** — `UnsTreeService.cs`: `CreateTagAsync` (744), `UpdateTagAsync` (794), `LoadTagsForEquipmentAsync` (86), `LoadTagDriversForEquipmentAsync` (714, filters to Equipment-kind namespaces), `CheckTagDriverGuardAsync` (1192, **rejects non-Equipment-kind drivers** — aliases need a Galaxy-aware variant), `ResolveEquipmentClusterAsync` (1161). Name-uniqueness guard = `db.Tags.AnyAsync(t => t.EquipmentId == eq && t.Name == name)`. - **DTOs** — `EquipmentChildRows.cs` `EquipmentTagRow(TagId, Name, DriverInstanceId, DataType, AccessLevel)`; `TagInput.cs`. - **Modal sibling** — `Components/Shared/Uns/ScriptedAlarmModal.razor` (the `_loadedKey` guard + `Visible/IsNew/EquipmentId/Existing/OnSaved/OnCancel` param shape to copy). - **Galaxy picker** — `Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor` params: `CurrentAddress` + `CurrentAddressChanged` + `GetConfigJson : Func` (the gateway `DriverConfig`, fed to `BrowserService.OpenAsync("GalaxyMxGateway", configJson, ct)`). Hosted inside `DriverTagPicker` (see `GalaxyDriverPage.razor:60`). - **Commons** — `EquipmentScriptPaths.cs`: `GetTagRefRegex`, `DeriveEquipmentBase`, `SubstituteEquipmentToken`, `ContainsEquipToken`. - **Equipment page Tags tab** — `Components/Pages/Uns/EquipmentPage.razor`: tab markup `:139`, `OpenAddTag :344`, `ReloadTagsAsync :339`, `_tags :296`, ` :180`. - **FleetAdmin policy** — string `"FleetAdmin"` (e.g. `RoleGrants.razor`, `ScriptAnalysisEndpoints.cs`). ## Hard rules 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 / EF migration change**. Feature branch off master `305023aa`. ## Same-file contention / ordering - `Phase7Composer.cs` + `DeploymentArtifact.cs` → **T2** only. - `EquipmentScriptPaths.cs` → **T1** only. - `DraftValidator.cs` + `Tag.cs` → **T3** only. - `IUnsTreeService.cs` + `UnsTreeService.cs` → **T4 → T5 → T7** (serialize). - `EquipmentPage.razor` → **T6 → T8** (serialize). - T1, T2, T3 are mutually independent (parallel after T0). T9 (docs) is parallel with anything. --- ### Task 0: Feature branch **Classification:** trivial **Estimated implement time:** ~1 min **Parallelizable with:** none **Step 1:** From master at `305023aa`: ```bash git checkout -b feat/uns-alias-tag git log --oneline -1 # expect 305023aa design commit on the branch base ``` No code change. Do not commit anything yet. --- ### Task 1: Commons relay-body parser **Classification:** small **Estimated implement time:** ~4 min **Parallelizable with:** Task 2, Task 3 **Files:** - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs` - Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs` Add a pure helper that recognises an *exact* relay body and extracts its single `ctx.GetTag` reference. This is load-bearing for the converter (Task 7) and is fully offline-TDD-able. **Step 1: Write failing tests** (append to `EquipmentScriptPathsTests.cs`): ```csharp [Theory] [InlineData("return ctx.GetTag(\"TestMachine_020.TestChangingInt\").Value;", "TestMachine_020.TestChangingInt")] [InlineData(" return ctx . GetTag ( \"A.B\" ) . Value ; ", "A.B")] [InlineData("return ctx.GetTag(\"{{equip}}.Speed\").Value;", "{{equip}}.Speed")] public void TryParseRelayBody_accepts_exact_passthrough(string src, string expectedRef) { EquipmentScriptPaths.TryParseRelayBody(src, out var r).ShouldBeTrue(); r.ShouldBe(expectedRef); } [Theory] [InlineData("return ctx.GetTag(\"A.B\").Value * 2;")] // arithmetic [InlineData("var x = ctx.GetTag(\"A.B\").Value; return x;")] // extra statement [InlineData("return ctx.GetTag(\"A.B\").Value + ctx.GetTag(\"C.D\").Value;")] // two reads [InlineData("return ctx.GetTag(\"A.B\").Quality;")] // not .Value [InlineData("return 5;")] // no GetTag [InlineData("")] // empty public void TryParseRelayBody_rejects_non_relay(string src) { EquipmentScriptPaths.TryParseRelayBody(src, out var r).ShouldBeFalse(); r.ShouldBeNull(); } ``` **Step 2:** Run `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests --filter TryParseRelayBody` → FAIL (method missing). **Step 3: Implement** in `EquipmentScriptPaths.cs`: ```csharp // A pure pass-through virtual-tag body: exactly `return ctx.GetTag("").Value;` // (whitespace-insensitive). Captures for relay→alias conversion. Anything with extra // statements, arithmetic, a different member than .Value, or multiple GetTag calls is NOT a relay. private static readonly Regex RelayBodyRegex = new( @"^\s*return\s+ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)\s*\.\s*Value\s*;\s*$", RegexOptions.Compiled); /// Recognise an exact relay body and capture its single GetTag reference. public static bool TryParseRelayBody(string? source, out string? tagReference) { tagReference = null; if (string.IsNullOrWhiteSpace(source)) return false; var m = RelayBodyRegex.Match(source); if (!m.Success) return false; tagReference = m.Groups[1].Value; return true; } ``` **Step 4:** Run the filter again → PASS. Run the full `EquipmentScriptPaths` suite → all green. **Step 5: Commit** ```bash git add src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs \ tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs git commit -m "feat(commons): TryParseRelayBody — detect pure ctx.GetTag relay scripts" ``` --- ### Task 2: Broaden the equipment-tag filter (composer + artifact) **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** Task 1, Task 3 Data-contract composition + byte-parity pair — the two sites must change together. **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs:365-369` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs:474-505` - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerAliasTagTests.cs` (new) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs` (new) **Step 1: Write failing composer test** (`Phase7ComposerAliasTagTests.cs`). Model the arrange on `Phase7ComposerEquipTokenTests.cs` (same project — copy its builder/setup). Assert: - An equipment-scoped Tag (`EquipmentId` set) bound to a `GalaxyMxGateway` driver whose namespace is `SystemPlatform`, with `TagConfig={"FullName":"TestMachine_020.TestChangingInt"}`, now appears in the composed **EquipmentTags** with `FullName == "TestMachine_020.TestChangingInt"`. - A SystemPlatform mirror Tag (`EquipmentId == null`, same Galaxy driver) still appears in **GalaxyTags** and **not** in EquipmentTags (no regression). **Step 2:** `dotnet test …OpcUaServer.Tests --filter AliasTag` → FAIL (alias tag dropped). **Step 3: Implement composer** — `Phase7Composer.cs`, the `equipmentTags` `.Where` (line 367-369). The lambda already binds `di` and `ns`: ```csharp .Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di) && namespacesById.TryGetValue(di.NamespaceId, out var ns) && (ns.Kind == NamespaceKind.Equipment || di.DriverType == "GalaxyMxGateway")) ``` (One clause added: `|| di.DriverType == "GalaxyMxGateway"`. The Galaxy/SystemPlatform producer keeps its `EquipmentId is null` guard, so a Galaxy alias — `EquipmentId` set — never double-counts.) **Step 4: Implement artifact** — `DeploymentArtifact.BuildEquipmentTagPlans`. Add a driverType map alongside `driverToNamespace` (in the `diArr` loop, ~line 476-483): ```csharp var driverToType = new Dictionary(StringComparer.Ordinal); // … inside the existing foreach over diArr, after reading id/ns: var dtype = el.TryGetProperty("DriverType", out var dtEl) ? dtEl.GetString() : null; if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(dtype)) driverToType[id!] = dtype!; ``` Then broaden the qualifying check (replace line 504-505): ```csharp if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue; var isGalaxyAlias = driverToType.TryGetValue(di!, out var dtype2) && dtype2 == "GalaxyMxGateway"; if (!equipmentNamespaces.Contains(nsId) && !isGalaxyAlias) continue; ``` **Step 5: Write failing parity test** (`DeploymentArtifactAliasParityTests.cs`). Model on `DeploymentArtifactEquipTokenTests.cs`. Compose a draft containing one Galaxy alias tag, serialise to the artifact, decode via `DeploymentArtifact`, and assert the decoded `EquipmentTags` equals the composer's `EquipmentTags` for that tag (same `FullName`, `EquipmentId`, `DriverInstanceId`, `Name`, `DataType`) — i.e. the alias survives the round-trip byte-parity. **Step 6:** Run both new test files + the existing `DeploymentArtifact*` and `Phase7Composer*` suites → all green (no parity regression). **Step 7: Commit** ```bash git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs \ src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs \ tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerAliasTagTests.cs \ tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs git commit -m "feat(composer): admit GalaxyMxGateway-backed equipment alias tags (+byte-parity)" ``` --- ### Task 3: DraftValidator alias-reference check + doc reframe **Classification:** standard **Estimated implement time:** ~4 min **Parallelizable with:** Task 1, Task 2 **Files:** - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs` - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs:5-9,33` (doc only) - Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs` **Step 1: Write failing tests** (append to `DraftValidatorTests.cs`, follow its existing arrange helpers). A draft with a Galaxy-gateway-bound equipment Tag whose `TagConfig` lacks `FullName` (e.g. `{}`) → `Validate` returns a `AliasTagMissingReference` error keyed on the TagId. A well-formed alias (`{"FullName":"X.Y"}`) → no such error. A normal Equipment-kind driver tag with `{}` → no alias error (not a Galaxy alias). **Step 2:** `dotnet test …Configuration.Tests --filter AliasTag` → FAIL. **Step 3: Implement.** Register a new method in `Validate(...)` and add it: ```csharp private static void ValidateAliasTagFullName(DraftSnapshot draft, List errors) { var typeByDriver = draft.DriverInstances .ToDictionary(d => d.DriverInstanceId, d => d.DriverType, StringComparer.Ordinal); foreach (var t in draft.Tags) { if (t.EquipmentId is null) continue; if (!typeByDriver.TryGetValue(t.DriverInstanceId, out var dtype) || dtype != "GalaxyMxGateway") continue; if (string.IsNullOrWhiteSpace(ExtractTagConfigFullName(t.TagConfig))) errors.Add(new("AliasTagMissingReference", $"Alias tag '{t.TagId}' on equipment '{t.EquipmentId}' is missing a Galaxy reference (TagConfig.FullName)", t.TagId)); } } // Minimal local reader (mirrors Phase7Composer.ExtractTagFullName) — top-level "FullName" string. private static string? ExtractTagConfigFullName(string? tagConfig) { if (string.IsNullOrWhiteSpace(tagConfig)) return null; try { using var doc = System.Text.Json.JsonDocument.Parse(tagConfig); return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object && doc.RootElement.TryGetProperty("FullName", out var fn) && fn.ValueKind == System.Text.Json.JsonValueKind.String ? fn.GetString() : null; } catch (System.Text.Json.JsonException) { return null; } } ``` **Step 4:** Run the filter → PASS; full `DraftValidatorTests` → green. **Step 5: Doc reframe** in `Tag.cs` — replace the class-summary line + the `EquipmentId` remark (lines 5-9, 33) so they read: *`EquipmentId` set ⟺ the tag participates in the Equipment tree, regardless of the driver's namespace kind. A `GalaxyMxGateway`-bound equipment tag is an **alias** (a Galaxy attribute surfaced under a UNS name); its Galaxy reference lives in `TagConfig.FullName`. `EquipmentId` is NULL for SystemPlatform mirror tags (FolderPath-scoped).* **Step 6: Commit** ```bash git add src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs \ src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs \ tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs git commit -m "feat(validation): require TagConfig.FullName on Galaxy alias tags; reframe Tag doc" ``` --- ### Task 4: Service read-side — alias DTO, gateway lookup, Source column **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 2, Task 3 (different files); serialize before Task 5 **Files:** - Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/AliasTagInput.cs` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs` (extend `EquipmentTagRow`) - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` (`LoadTagsForEquipmentAsync` :86 + new `LoadGalaxyGatewaysForEquipmentAsync`) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs` (new) **Step 1: Add the DTO** `AliasTagInput.cs`: ```csharp namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; /// Operator-editable fields for a Galaxy alias tag (an equipment Tag bound to the Galaxy /// gateway). FullName is the picked Galaxy reference (tag_name.AttributeName); it is stored as /// TagConfig {"FullName":…}. AccessLevel defaults ReadOnly at the call site. public sealed record AliasTagInput( string TagId, string Name, string DriverInstanceId, string DataType, TagAccessLevel AccessLevel, string FullName); ``` **Step 2: Extend `EquipmentTagRow`** (defaulted trailing params keep existing constructions compiling — mirror the `UnsMutationResult.CreatedId` trick): ```csharp public sealed record EquipmentTagRow( string TagId, string Name, string DriverInstanceId, string DataType, TagAccessLevel AccessLevel, bool IsAlias = false, string? Source = null); ``` **Step 3: Write failing tests** (`UnsTreeServiceAliasTagTests.cs`; use the `UnsTreeTestDb` helper like `UnsTreeServiceTagTests.cs`). Seed a cluster with a `GalaxyMxGateway` driver in a SystemPlatform namespace + an Equipment-kind namespace, an equipment, and tags. Assert: - `LoadGalaxyGatewaysForEquipmentAsync(eq)` returns the gateway (`DriverInstanceId`, display, `DriverConfig`) and excludes non-Galaxy drivers. - `LoadTagsForEquipmentAsync` marks a Galaxy-gateway-bound tag with `IsAlias == true` and `Source == "galaxy:"`, and a normal driver tag with `IsAlias == false`. **Step 4: Implement.** - Interface additions (`IUnsTreeService.cs`): ```csharp Task> LoadGalaxyGatewaysForEquipmentAsync(string equipmentId, CancellationToken ct = default); Task CreateAliasTagAsync(string equipmentId, AliasTagInput input, CancellationToken ct = default); // impl in Task 5 Task UpdateAliasTagAsync(string tagId, AliasTagInput input, byte[] rowVersion, CancellationToken ct = default); // Task 5 ``` - `LoadGalaxyGatewaysForEquipmentAsync` (model on `LoadTagDriversForEquipmentAsync` :714 but filter `d.DriverType == "GalaxyMxGateway"` in the equipment's cluster; select `DriverInstanceId, Name, DriverConfig`). - `LoadTagsForEquipmentAsync` (:86) — to set `IsAlias`/`Source`, the projection needs each tag's driver type + `TagConfig`. Join `db.Tags` to `db.DriverInstances` on `DriverInstanceId`; in a post-query `.Select`, set `IsAlias = driverType == "GalaxyMxGateway"`, and `Source = IsAlias ? $"galaxy:{ExtractFullName(TagConfig)}" : null`. Pull the rows first (`AsNoTracking().Where(eq).Join(...)`), then map in memory so you can parse `TagConfig` with the same small `FullName` reader used in Task 3 (add a private `ExtractTagConfigFullName` to `UnsTreeService` or a shared Commons helper — DRY: prefer one shared reader). **Step 5:** Run `dotnet test …AdminUI.Tests --filter AliasTag` → PASS. Run `UnsTreeServiceEquipmentChildRowsTests` + `UnsTreeServiceTagTests` → green (defaulted params kept them compiling). **Step 6: Commit** (stage the five files by path). ``` git commit -m "feat(adminui): alias DTO + Galaxy gateway lookup + Source/IsAlias on tag rows" ``` --- ### Task 5: Service write-side — Create/Update alias + Galaxy guard **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** none (serialize after Task 4) **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs` **Step 1: Write failing tests** (append). Assert: - `CreateAliasTagAsync(eq, input)` writes a `Tag` with `DriverInstanceId` = gateway, `EquipmentId = eq`, `FolderPath = null`, `TagConfig == {"FullName":""}`, `AccessLevel == input.AccessLevel`. - Empty/whitespace `FullName` → `Ok == false` (error mentions reference). - Binding a non-Galaxy driver via the alias path → `Ok == false` ("not a Galaxy gateway"). - Cross-cluster gateway → `Ok == false` (cluster mismatch). - Duplicate `Name` on the equipment (vs an existing tag) → `Ok == false`. - `UpdateAliasTagAsync` updates `Name`/`DataType`/`AccessLevel`/`FullName` and bumps via RowVersion path. **Step 2:** Run → FAIL (methods are interface stubs from Task 4; implement now). **Step 3: Implement** a Galaxy-aware guard + the two methods (model on `CreateTagAsync` :744 / `UpdateTagAsync` :794, but assemble `TagConfig` from `FullName` and use the alias guard instead of `CheckTagDriverGuardAsync`): ```csharp private static async Task CheckAliasDriverGuardAsync( OtOpcUaConfigDbContext db, string driverInstanceId, string? equipmentCluster, CancellationToken ct) { var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); if (driver is null) return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found."); if (driver.DriverType != "GalaxyMxGateway") return new UnsMutationResult(false, $"Driver '{driverInstanceId}' is not a Galaxy gateway."); if (driver.ClusterId != equipmentCluster) return new UnsMutationResult(false, $"Galaxy gateway '{driverInstanceId}' is in cluster '{driver.ClusterId}' but the equipment is in cluster '{equipmentCluster}'."); return null; } private static string BuildAliasTagConfig(string fullName) => System.Text.Json.JsonSerializer.Serialize(new Dictionary { ["FullName"] = fullName }); ``` `CreateAliasTagAsync`: validate `FullName` non-empty (`return new UnsMutationResult(false, "Alias is missing a Galaxy reference.")`), resolve cluster, run `CheckAliasDriverGuardAsync`, reuse the equipment signal-name uniqueness check, then `db.Tags.Add(new Tag { …, FolderPath = null, AccessLevel = input.AccessLevel, TagConfig = BuildAliasTagConfig(input.FullName) })`. Factor the entity build into a private `BuildAliasTag(equipmentId, input)` — **Task 7's converter reuses it**. **Step 4:** Run → PASS; full `UnsTreeService*` suite → green. **Step 5: Commit** ``` git commit -m "feat(adminui): CreateAliasTagAsync/UpdateAliasTagAsync + Galaxy-gateway guard" ``` --- ### Task 6: AliasTagModal + Tags-tab "Add alias" wiring **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** none (serialize after Task 5; before Task 8 on `EquipmentPage.razor`) No bUnit — proven live in Task 10. Keep markup faithful to existing modals. **Files:** - Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AliasTagModal.razor` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor` **Step 1: Build `AliasTagModal.razor`** — copy the shell + `_loadedKey` guard + param block from `ScriptedAlarmModal.razor`. Params: `Visible`, `IsNew`, `EquipmentId`, `Existing` (a small `AliasTagEditDto` — add it to `IUnsTreeService` load path if editing is in scope, else reuse `TagEditDto`/`LoadTagAsync` and pre-split `TagConfig.FullName`), `Gateways` (`IReadOnlyList<(string DriverInstanceId, string Display, string DriverConfig)>` from `LoadGalaxyGatewaysForEquipmentAsync`), `OnSaved`, `OnCancel`. Body: - A gateway `` over the same OPC type list `TagModal` uses — reuse that list source), `AccessLevel` (`