diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs
index 889e9d7f..e6517171 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs
@@ -3,9 +3,11 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
///
-/// One canonical tag (signal) in a cluster's generation. Per decision #110:
-/// is REQUIRED when the driver is in an Equipment-kind namespace
-/// and NULL when in SystemPlatform-kind namespace (Galaxy hierarchy preserved).
+/// One canonical tag (signal) in a cluster's generation. 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, with its Galaxy reference carried in TagConfig.FullName.
+/// is NULL for SystemPlatform mirror tags (FolderPath-scoped).
///
public sealed class Tag
{
@@ -30,8 +32,8 @@ public sealed class Tag
public string? DeviceId { get; set; }
///
- /// Required when driver is in Equipment-kind namespace; NULL when in SystemPlatform-kind.
- /// Cross-table invariant enforced by sp_ValidateDraft (decision #110).
+ /// Set when the tag belongs to an Equipment (driver tag OR Galaxy alias); NULL for
+ /// SystemPlatform mirror tags.
///
public string? EquipmentId { get; set; }
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
index 16dc5f21..88db5322 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
@@ -33,10 +33,43 @@ public static class DraftValidator
ValidateEquipmentIdDerivation(draft, errors);
ValidateDriverNamespaceCompatibility(draft, errors);
ValidateNoEquipmentSignalNameCollision(draft, errors);
+ ValidateAliasTagFullName(draft, errors);
return errors;
}
+ 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 reader for the top-level "FullName" string in a tag's schemaless TagConfig JSON
+ // (mirrors Phase7Composer.ExtractTagFullName — a small local copy, consistent with this codebase
+ // where the composer keeps its own).
+ 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; }
+ }
+
private static void ValidateNoEquipmentSignalNameCollision(DraftSnapshot draft, List errors)
{
// Materialiser NodeId key: "{EquipmentId}[/{FolderPath}]/{Name}". Tag (EquipmentId != null) and
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs
index 6e98af3b..5452884b 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs
@@ -405,16 +405,21 @@ public sealed class DraftValidatorTests
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipmentSignalNameCollision");
}
- private static Tag BuildTag(string? equipmentId, string name, string? folderPath) => new()
+ private static Tag BuildTag(
+ string? equipmentId,
+ string name,
+ string? folderPath,
+ string driverInstanceId = "d",
+ string tagConfig = "{}") => new()
{
TagId = $"tag-{name}",
- DriverInstanceId = "d",
+ DriverInstanceId = driverInstanceId,
EquipmentId = equipmentId,
Name = name,
FolderPath = folderPath,
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
- TagConfig = "{}",
+ TagConfig = tagConfig,
};
private static VirtualTag BuildVirtualTag(string equipmentId, string name) => new()
@@ -426,6 +431,57 @@ public sealed class DraftValidatorTests
ScriptId = "s-1",
};
+ // ------------------------------------------------------------------------------------
+ // ValidateAliasTagFullName — Galaxy alias tags must carry TagConfig.FullName
+ // ------------------------------------------------------------------------------------
+
+ /// Verifies that an equipment-scoped Tag bound to a GalaxyMxGateway driver whose
+ /// TagConfig has no FullName (an alias with no Galaxy reference) is rejected — it would
+ /// subscribe to nothing.
+ [Fact]
+ public void AliasTag_missing_FullName_is_rejected()
+ {
+ var draft = new DraftSnapshot
+ {
+ GenerationId = 1, ClusterId = "c",
+ DriverInstances = [new DriverInstance { DriverInstanceId = "d-galaxy", ClusterId = "c", NamespaceId = "ns-1", Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}" }],
+ Tags = [BuildTag(equipmentId: "eq-1", name: "alias", folderPath: null, driverInstanceId: "d-galaxy", tagConfig: "{}")],
+ };
+
+ DraftValidator.Validate(draft).ShouldContain(e => e.Code == "AliasTagMissingReference" && e.Context == "tag-alias");
+ }
+
+ /// Verifies that an equipment-scoped Galaxy alias tag carrying a TagConfig.FullName
+ /// reference is accepted.
+ [Fact]
+ public void AliasTag_with_FullName_is_accepted()
+ {
+ var draft = new DraftSnapshot
+ {
+ GenerationId = 1, ClusterId = "c",
+ DriverInstances = [new DriverInstance { DriverInstanceId = "d-galaxy", ClusterId = "c", NamespaceId = "ns-1", Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}" }],
+ Tags = [BuildTag(equipmentId: "eq-1", name: "alias", folderPath: null, driverInstanceId: "d-galaxy", tagConfig: "{\"FullName\":\"X.Y\"}")],
+ };
+
+ DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "AliasTagMissingReference");
+ }
+
+ /// Verifies that an equipment-scoped Tag bound to a NON-Galaxy driver with an empty
+ /// TagConfig is NOT flagged as an alias missing its reference — only GalaxyMxGateway-bound
+ /// equipment tags are aliases.
+ [Fact]
+ public void AliasTag_check_skips_nonGalaxy_equipment_tag()
+ {
+ var draft = new DraftSnapshot
+ {
+ GenerationId = 1, ClusterId = "c",
+ DriverInstances = [new DriverInstance { DriverInstanceId = "d-modbus", ClusterId = "c", NamespaceId = "ns-1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}" }],
+ Tags = [BuildTag(equipmentId: "eq-1", name: "alias", folderPath: null, driverInstanceId: "d-modbus", tagConfig: "{}")],
+ };
+
+ DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "AliasTagMissingReference");
+ }
+
// ------------------------------------------------------------------------------------
// Phase 6.3 task #148 part 2 — ValidateClusterTopology
// ------------------------------------------------------------------------------------