# 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":"<galaxyRef>"}`). 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<string>` (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`, `<TagModal …> :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("<ref>").Value;`
// (whitespace-insensitive). Captures <ref> 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);

/// <summary>Recognise an exact relay body and capture its single GetTag reference.</summary>
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<string, string>(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<ValidationError> 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;

/// <summary>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.</summary>
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:<FullName>"`, and a normal driver tag with `IsAlias == false`.

**Step 4: Implement.**
- Interface additions (`IUnsTreeService.cs`):
```csharp
Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverConfig)>>
    LoadGalaxyGatewaysForEquipmentAsync(string equipmentId, CancellationToken ct = default);
Task<UnsMutationResult> CreateAliasTagAsync(string equipmentId, AliasTagInput input, CancellationToken ct = default);   // impl in Task 5
Task<UnsMutationResult> 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":"<input.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<UnsMutationResult?> 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<string, string> { ["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 `<select>` (auto-selected when exactly one).
- The Galaxy picker: host `<DriverTagPicker>` wrapping `<GalaxyAddressPickerBody CurrentAddress="_form.FullName" CurrentAddressChanged="v => _form.FullName = v" GetConfigJson="() => _selectedGatewayConfig" />` (mirror `GalaxyDriverPage.razor:60`). `_selectedGatewayConfig` = the selected gateway's `DriverConfig`.
- `Name` (InputText), `DataType` (`<select>` over the same OPC type list `TagModal` uses — reuse that list source), `AccessLevel` (`<select>` over `TagAccessLevel`, default `ReadOnly`).
- Save → `Svc.CreateAliasTagAsync` / `Svc.UpdateAliasTagAsync`; surface `result.Error`; on `Ok` raise `OnSaved`.

**Step 2: Wire the Tags tab** in `EquipmentPage.razor`:
- Beside *Add tag* (`:142`) add: `<button class="btn btn-outline-primary btn-sm" @onclick="OpenAddAlias" disabled="@(_gateways.Count == 0)">Add alias (browse Galaxy)</button>` with a hint span when `_gateways.Count == 0` ("no Galaxy gateway in this cluster").
- In the tags table, render the alias **badge** + **Source** column: when `t.IsAlias`, show a `<span class="badge">alias</span>` and `@t.Source`; Edit routes alias rows to `OpenEditAlias`, native rows to the existing `OpenEditTag`.
- Add `<AliasTagModal Visible=… EquipmentId=@EquipmentId Gateways=_gateways OnSaved=OnAliasSaved OnCancel=… />` next to `<TagModal …>`.
- Handlers: load `_gateways` via `Svc.LoadGalaxyGatewaysForEquipmentAsync(EquipmentId!)` inside `ReloadTagsAsync` (`:339`); `OpenAddAlias`/`OpenEditAlias`/`OnAliasSaved` mirror the tag handlers (`ReloadTagsAsync` after save/delete).

**Step 3:** `dotnet build` the AdminUI project → 0 errors. (Behaviour verified live in Task 10.)

**Step 4: Commit**
```
git commit -m "feat(adminui): AliasTagModal + Tags-tab Add-alias (Galaxy picker)"
```

---

### Task 7: Converter service method

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (serialize after Task 5; needs Task 1)

Atomic config mutation (deletes VirtualTag/Script rows, inserts Tag rows). Correctness-critical.

**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RelayConversion.cs` (request + result DTOs)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceRelayConverterTests.cs` (new)

**Step 1: DTOs** (`RelayConversion.cs`):
```csharp
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;

/// <summary>One relay VirtualTag that will/would become an alias Tag.</summary>
public sealed record RelayConversionItem(string EquipmentId, string VirtualTagName, string FullName, string DataType);
/// <summary>One relay VirtualTag that cannot be converted, with the reason.</summary>
public sealed record RelayConversionSkip(string EquipmentId, string VirtualTagName, string Reason);
/// <summary>Outcome of a (dry-run or applied) conversion pass.</summary>
public sealed record RelayConversionResult(
    IReadOnlyList<RelayConversionItem> Converted,
    IReadOnlyList<RelayConversionSkip> Skipped,
    bool Applied);
```

**Step 2: Interface** (`IUnsTreeService.cs`):
```csharp
/// <summary>Convert pure-relay VirtualTags to Galaxy alias Tags. equipmentId = null → fleet-wide.
/// dryRun = true previews without mutating. FleetAdmin-gated at the call site.</summary>
Task<RelayConversionResult> ConvertRelayVirtualTagsToAliasesAsync(
    string? equipmentId, bool dryRun, CancellationToken ct = default);
```

**Step 3: Write failing tests** (`UnsTreeServiceRelayConverterTests.cs`, use `UnsTreeTestDb`). Cases:
1. **Exact relay converted** — vtag `speed-rpm` bound to script `return ctx.GetTag("TestMachine_020.TestChangingInt").Value;` on an equipment whose cluster has the gateway → apply: a `Tag` exists (`Name=speed-rpm`, `TagConfig={"FullName":"TestMachine_020.TestChangingInt"}`, `AccessLevel=ReadOnly`, DataType carried), and the VirtualTag + its orphan Script are gone. Result lists it under `Converted`.
2. **Non-relay untouched** — vtag whose script does arithmetic → `Skipped` (reason "not a pure relay"); vtag + script remain.
3. **Shared script kept then deleted** — one Script referenced by two relay vtags: converting the first keeps the Script (still referenced); converting both deletes it.
4. **Historize=true** — relay vtag with `Historize=true` → `Skipped` (reason mentions historize); unchanged.
5. **No gateway** — equipment whose cluster has no `GalaxyMxGateway` → `Skipped` (reason "no Galaxy gateway"); unchanged.
6. **Dry-run no mutation** — case 1 with `dryRun=true` → same `Converted` list but the VirtualTag/Script still present and no Tag added.
7. **Fleet vs per-equipment scope** — `equipmentId=null` sweeps all; a specific id limits to that equipment.

**Step 4:** Run `--filter RelayConverter` → FAIL.

**Step 5: Implement** `ConvertRelayVirtualTagsToAliasesAsync`:
- One `db` context; gather candidate VirtualTags (`Where(EquipmentId == id)` or all), join their `Script.SourceCode`.
- Per vtag: `EquipmentScriptPaths.TryParseRelayBody(source, out var rawRef)` → non-relay ⇒ skip ("not a pure relay").
- Resolve the equipment's cluster gateway (reuse `LoadGalaxyGatewaysForEquipmentAsync` logic / `ResolveEquipmentClusterAsync`); none ⇒ skip ("no Galaxy gateway in cluster").
- `Historize == true` ⇒ skip ("historized — Tag has no historize column; convert manually").
- **`{{equip}}` expansion:** if `EquipmentScriptPaths.ContainsEquipToken(rawRef)`, derive the equipment's base from its existing **non-relay** Tag FullNames (`DeriveEquipmentBase` over `db.Tags.Where(EquipmentId==eq && driver==GalaxyMxGateway).Select(TagConfig.FullName)` plus any Equipment-kind driver tag FullNames); if base is null ⇒ skip ("equipment-relative token with no derivable base"); else `fullName = SubstituteEquipmentToken("ctx.GetTag(\"" + rawRef + "\")", base)`-extracted concrete ref (or apply `SubstituteEquipmentToken` to `rawRef` directly via a small wrapper). Otherwise `fullName = rawRef`.
- If `dryRun`: add to `Converted`, mutate nothing.
- Else: `db.Tags.Add(BuildAliasTag(eq, new AliasTagInput(NewTagId(), vtag.Name, gatewayId, vtag.DataType, TagAccessLevel.ReadOnly, fullName)))` (reuse Task 5's `BuildAliasTag`); `db.VirtualTags.Remove(vtag)`; decrement the Script's live reference count (count of VirtualTags + ScriptedAlarms still referencing `ScriptId` *after* this batch's removals) and `db.Scripts.Remove(script)` only when it reaches zero.
- One `await db.SaveChangesAsync(ct)` at the end for atomicity (all-or-nothing). Return `RelayConversionResult(converted, skipped, Applied: !dryRun)`.
- **Name-collision safety:** the delete-vtag + add-tag for the same `(EquipmentId, Name)` happen in one SaveChanges, so the equipment signal-name collision check is satisfied (no generation where both exist).

**Step 6:** Run `--filter RelayConverter` → PASS; full `UnsTreeService*` → green.

**Step 7: Commit** (stage the four files).
```
git commit -m "feat(adminui): ConvertRelayVirtualTagsToAliasesAsync (relay VTag -> alias Tag)"
```

---

### Task 8: Converter UI (per-equipment + fleet-wide)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (serialize after Task 6; needs Task 7)

No bUnit — proven live in Task 10.

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/RelayAliasConvert.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor` (nav entry)

**Step 1: Per-equipment action** — on the Tags tab add a *Convert relay virtual-tags to aliases…* button (shown when the equipment has any virtual tags). It calls `ConvertRelayVirtualTagsToAliasesAsync(EquipmentId, dryRun:true)`, shows a preview panel (Converted list + Skipped-with-reason list), and a *Apply* button that re-calls with `dryRun:false` then `ReloadTagsAsync` + `ReloadVirtualTagsAsync`.

**Step 2: Fleet-wide page** `RelayAliasConvert.razor`:
```razor
@page "/uns/convert-relays"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "FleetAdmin")]
@rendermode RenderMode.InteractiveServer
@inject IUnsTreeService Svc
```
A *Preview* button → `ConvertRelayVirtualTagsToAliasesAsync(null, dryRun:true)` → table of Converted (Equipment, Name, FullName) + Skipped (Equipment, Name, Reason) + counts; an *Apply conversion* button (confirmation) → `dryRun:false` → re-preview to show the now-empty Converted set. Reuse the panel markup from Step 1 (a small shared `<RelayConversionPreview>` partial is optional; inline is fine).

**Step 3: Nav** — add a `RelayAliasConvert` link under the existing UNS/maintenance nav group in `MainLayout.razor` (FleetAdmin-gated link, mirror how `RoleGrants`/maintenance links render).

**Step 4:** `dotnet build` AdminUI → 0 errors.

**Step 5: Commit**
```
git commit -m "feat(adminui): relay->alias converter UI (per-equipment + fleet-wide page)"
```

---

### Task 9: Docs

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** any task (docs-only)

**Files:**
- Modify: `docs/Uns.md`
- Modify: `CLAUDE.md`

**Step 1:** `docs/Uns.md` — add an "Alias tags" subsection under the equipment Tags tab: what an alias is (a Galaxy attribute surfaced under a UNS name via a direct driver subscription, no script), how to add one (Tags tab → *Add alias (browse Galaxy)*), AccessLevel/write-through note, and the converter (per-equipment action + `/uns/convert-relays` fleet sweep, dry-run then apply; skip reasons).

**Step 2:** `CLAUDE.md` — a short note near the "Contained Name vs Tag Name" / Galaxy section: alias tags = `GalaxyMxGateway`-backed equipment `Tag`s carrying `TagConfig.FullName`; they replace pure-relay virtual tags; converter at `/uns/convert-relays`.

**Step 3: Commit**
```
git add docs/Uns.md CLAUDE.md
git commit -m "docs: alias tags + relay converter (Uns.md, CLAUDE.md)"
```

---

### Task 10: Full-suite gate + live /run verify + finish

**Classification:** verification
**Estimated implement time:** ~6 min (+ user-driven live)
**Parallelizable with:** none (after all)

**Step 1:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 errors. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` → green (call out any pre-existing unrelated failures).

**Step 2: Live docker-dev `/run`** (user drives; agent does **not** sign in). Rebuild: `docker compose -f docker-dev/docker-compose.yml up -d --build central-1 central-2` (no `-v`; migrator no-ops — no schema change), UI at http://localhost:9200. Checklist:
1. On `blender-20` (`/uns/equipment/EQ-89e6583eeb9c`) Tags tab → *Add alias* → Galaxy picker browses the gateway → pick an attribute → save → alias row shows the badge + `galaxy:<FullName>`.
2. Publish/deploy → read the alias live via Client.CLI (`browse`/`read`) — value + quality + timestamp present.
3. Set an alias `AccessLevel` writable → write via Client.CLI → the Galaxy attribute changes; a read-only alias rejects the write.
4. Per-equipment converter dry-run on `blender-20` → preview lists the 24 relays → Apply → the 24 virtual tags become alias Tags (Tags tab), Virtual Tags tab emptied of them, values still live, no new script-log entries for them.
5. Fleet-wide `/uns/convert-relays` → preview → (optionally) apply.

**Step 3: Finish** — use **superpowers-extended-cc:finishing-a-development-branch** (verify tests, present the 4 options; carry the session's standing intent toward merge-to-master + push, but confirm given the user-driven live step). Do **not** stage `pending.md`, `sql_login.txt`, or `pki/`.

**Done gate:** build clean + `dotnet test` green + live `/run` pass.
