diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DriverDeviceConfigMerger.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DriverDeviceConfigMerger.cs
new file mode 100644
index 00000000..b97756cf
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DriverDeviceConfigMerger.cs
@@ -0,0 +1,99 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+///
+/// v3 endpoint→DeviceConfig merge authority. Folds a driver's DriverConfig JSON
+/// (protocol/channel-level settings) together with its per-Device DeviceConfig JSON
+/// (host/endpoint + per-device settings — the Kepware channel/device split) plus the driver's authored
+/// raw tags into the SINGLE config blob every driver factory binds from. Shared by the deploy-decode
+/// seam (DeploymentArtifact, which builds each DriverInstanceSpec.DriverConfig) and — in
+/// Batch 2 — the browse modal (which needs the same merged shape as the browser's configJson),
+/// so the merge lives in exactly one place.
+/// Contract. Output = the parsed DriverConfig object with, in order:
+///
+/// - When there is EXACTLY ONE device, its DeviceConfig keys shallow-merged up to the top
+/// level (DeviceConfig wins) — so a single-endpoint driver's endpoint (Host/Port,
+/// EndpointUrl, gateway address, …) lands where its options bind. Multiple devices do NOT
+/// merge up (their endpoints are per-device, read from the array below).
+/// - A reconstructed Devices array — one object per device = its DeviceConfig keys
+/// plus DeviceName = the device's entity Name (the RawPath device segment /
+/// routing key). Multi-device drivers read this; single-endpoint
+/// drivers ignore the unknown key.
+/// - A RawTags array = the driver's authored list, so
+/// options.RawTags binds.
+///
+/// Existing top-level protocol/channel keys survive (single-device DeviceConfig keys override on a name
+/// clash — the documented Kepware precedence). Never throws on malformed member JSON: a blank/invalid
+/// DriverConfig or DeviceConfig degrades to an empty object for that member.
+///
+public static class DriverDeviceConfigMerger
+{
+ private static readonly JsonSerializerOptions SerializeOptions = new()
+ {
+ WriteIndented = false,
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never,
+ };
+
+ /// The device rows feeding the merge: the entity Name (routing key) + schemaless DeviceConfig JSON.
+ /// The device's entity Name — the RawPath device segment and multi-device routing key.
+ /// The device's schemaless DeviceConfig JSON (endpoint + per-device settings).
+ public readonly record struct DeviceRow(string Name, string? DeviceConfig);
+
+ /// Merge a driver's config + its devices' configs + its authored raw tags into one config blob.
+ /// The driver-level DriverConfig JSON (protocol/channel settings).
+ /// The driver's device rows, in deterministic order (the sole device merges up).
+ /// The driver's authored raw tags to inject as the RawTags array.
+ /// The single merged config JSON string the driver factory binds from.
+ public static string Merge(
+ string? driverConfigJson,
+ IReadOnlyList devices,
+ IReadOnlyList rawTags)
+ {
+ ArgumentNullException.ThrowIfNull(devices);
+ ArgumentNullException.ThrowIfNull(rawTags);
+
+ var root = ParseObject(driverConfigJson);
+
+ // (1) Exactly one device ⇒ shallow-merge its DeviceConfig up to the top level (DeviceConfig wins),
+ // so a single-endpoint driver's endpoint lands where its options bind. Multiple devices are
+ // per-device only (read from the Devices array below), so no merge-up.
+ if (devices.Count == 1)
+ {
+ var only = ParseObject(devices[0].DeviceConfig);
+ foreach (var (key, value) in only)
+ root[key] = value?.DeepClone();
+ }
+
+ // (2) Reconstruct the Devices array from ALL device rows: DeviceConfig keys + DeviceName = entity Name.
+ var devicesArray = new JsonArray();
+ foreach (var device in devices)
+ {
+ var obj = ParseObject(device.DeviceConfig);
+ obj["DeviceName"] = device.Name; // entity Name is the RawPath device segment + routing key
+ devicesArray.Add(obj);
+ }
+ root["Devices"] = devicesArray;
+
+ // (3) Inject the authored raw tags so options.RawTags binds.
+ root["RawTags"] = JsonSerializer.SerializeToNode(rawTags, SerializeOptions);
+
+ return root.ToJsonString(SerializeOptions);
+ }
+
+ /// Parse JSON into a mutable ; blank / non-object / malformed ⇒ a fresh empty object.
+ private static JsonObject ParseObject(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json)) return new JsonObject();
+ try
+ {
+ return JsonNode.Parse(json) as JsonObject ?? new JsonObject();
+ }
+ catch (JsonException)
+ {
+ return new JsonObject();
+ }
+ }
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RawPathResolver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RawPathResolver.cs
new file mode 100644
index 00000000..47feedfd
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RawPathResolver.cs
@@ -0,0 +1,132 @@
+namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+///
+/// Resolves a raw Tag's v3 RawPath — the single identity string
+/// <Folder/…>/<Driver>/<Device>/<TagGroup/…>/<Tag> — from the raw
+/// topology's ancestry maps, and a driver's RawPath prefix (folders + driver name). Pure + input-shape
+/// agnostic: callers build the four id→ancestry maps from whatever they have (EF entities on the
+/// authoring/validation side, artifact JSON on the deploy-decode side) and both sides then agree
+/// byte-for-byte because the segment order + join live ONLY here (over ).
+/// Every path is built through , so segment charset (no
+/// /, no lead/trail whitespace) is enforced in one place. A broken link (missing device / driver
+/// lookup, or an invalid segment) yields — never a throw — so a malformed
+/// artifact degrades to "this tag has no RawPath" (dropped) rather than faulting a whole deploy decode.
+/// Ancestry walks are depth-bounded to survive a corrupt parent cycle.
+///
+public sealed class RawPathResolver
+{
+ private const int MaxAncestryDepth = 256;
+
+ private readonly IReadOnlyDictionary _folders;
+ private readonly IReadOnlyDictionary _drivers;
+ private readonly IReadOnlyDictionary _devices;
+ private readonly IReadOnlyDictionary _groups;
+
+ /// Initializes a new over the raw topology's ancestry maps.
+ /// RawFolderId → (ParentRawFolderId?, Name). parent = cluster root.
+ /// DriverInstanceId → (RawFolderId?, Name). folder = cluster root.
+ /// DeviceId → (DriverInstanceId, Name).
+ /// TagGroupId → (ParentTagGroupId?, Name). parent = directly under the device.
+ public RawPathResolver(
+ IReadOnlyDictionary folders,
+ IReadOnlyDictionary drivers,
+ IReadOnlyDictionary devices,
+ IReadOnlyDictionary groups)
+ {
+ ArgumentNullException.ThrowIfNull(folders);
+ ArgumentNullException.ThrowIfNull(drivers);
+ ArgumentNullException.ThrowIfNull(devices);
+ ArgumentNullException.ThrowIfNull(groups);
+ _folders = folders;
+ _drivers = drivers;
+ _devices = devices;
+ _groups = groups;
+ }
+
+ ///
+ /// Build the RawPath prefix a driver contributes to every child tag: its folder ancestry
+ /// (root → leaf) followed by the driver name. Returns when the driver id is
+ /// unknown or any segment/link is invalid.
+ ///
+ /// The driver instance id.
+ /// The driver RawPath prefix, or on a broken/unknown chain.
+ public string? TryBuildDriverPrefix(string driverInstanceId)
+ {
+ if (driverInstanceId is null || !_drivers.TryGetValue(driverInstanceId, out var driver)) return null;
+ try
+ {
+ var path = BuildFolderChain(driver.RawFolderId);
+ return RawPaths.Combine(path, driver.Name);
+ }
+ catch (ArgumentException)
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Build a raw tag's full RawPath:
+ /// <driver-prefix>/<Device>/<TagGroup ancestry root→leaf>/<Tag>.
+ /// Returns when the device / driver lookup is missing or any segment/link is
+ /// invalid.
+ ///
+ /// The tag's owning device id.
+ /// The tag's owning tag-group id, or when directly under the device.
+ /// The tag's leaf name.
+ /// The tag's RawPath, or on a broken/unknown chain.
+ public string? TryBuildTagPath(string deviceId, string? tagGroupId, string tagName)
+ {
+ if (deviceId is null || !_devices.TryGetValue(deviceId, out var device)) return null;
+ var driverPrefix = TryBuildDriverPrefix(device.DriverInstanceId);
+ if (driverPrefix is null) return null;
+ try
+ {
+ var path = RawPaths.Combine(driverPrefix, device.Name);
+ path = AppendGroupChain(path, tagGroupId);
+ return RawPaths.Combine(path, tagName);
+ }
+ catch (ArgumentException)
+ {
+ return null;
+ }
+ }
+
+ /// Build a folder ancestry path (root → leaf) for the given leaf folder id (null = cluster root ⇒ blank).
+ private string? BuildFolderChain(string? leafFolderId)
+ {
+ if (leafFolderId is null) return null;
+ var names = new List();
+ var current = leafFolderId;
+ var depth = 0;
+ while (current is not null && depth++ < MaxAncestryDepth)
+ {
+ if (!_folders.TryGetValue(current, out var folder))
+ throw new ArgumentException($"Unknown RawFolder '{current}'.");
+ names.Add(folder.Name);
+ current = folder.ParentId;
+ }
+ names.Reverse();
+ return names.Count == 0 ? null : RawPaths.Build(names);
+ }
+
+ /// Append a tag-group ancestry path (root → leaf) onto (null group ⇒ unchanged).
+ private string AppendGroupChain(string parentPath, string? leafGroupId)
+ {
+ if (leafGroupId is null) return parentPath;
+ var names = new List();
+ var current = leafGroupId;
+ var depth = 0;
+ while (current is not null && depth++ < MaxAncestryDepth)
+ {
+ if (!_groups.TryGetValue(current, out var group))
+ throw new ArgumentException($"Unknown TagGroup '{current}'.");
+ names.Add(group.Name);
+ current = group.ParentId;
+ }
+ names.Reverse();
+ var path = parentPath;
+ foreach (var name in names)
+ path = RawPaths.Combine(path, name);
+ return path;
+ }
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs
index 08f86aa3..9fa9dad7 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs
@@ -25,25 +25,36 @@ public sealed class DraftSnapshot
///
public string? Site { get; init; }
- // v3: the Namespace entity is retired (the two OPC UA namespaces are implicit). The old
- // Namespaces list + namespace-binding validation are gone. WP4 (Wave C) owns the new v3
- // rules (raw-name charset, historized-tagname length, UNS effective-leaf uniqueness).
+ // v3: the Namespace entity is retired (the two OPC UA namespaces are implicit). The Raw tree
+ // (RawFolders → DriverInstances → Devices → TagGroups → Tags) + the UNS projection
+ // (UnsTagReferences) feed the v3 validator rules (raw-name charset, historized-tagname length, UNS
+ // effective-leaf uniqueness).
+ /// Gets the list of Raw-tree folders (drive the raw-name charset rule + RawPath computation).
+ public IReadOnlyList RawFolders { get; init; } = [];
/// Gets the list of driver instances.
public IReadOnlyList DriverInstances { get; init; } = [];
/// Gets the list of devices.
public IReadOnlyList Devices { get; init; } = [];
+ /// Gets the list of tag-groups (drive the raw-name charset rule + RawPath computation).
+ public IReadOnlyList TagGroups { get; init; } = [];
/// Gets the list of UNS areas.
public IReadOnlyList UnsAreas { get; init; } = [];
/// Gets the list of UNS lines.
public IReadOnlyList UnsLines { get; init; } = [];
/// Gets the list of equipment.
public IReadOnlyList Equipment { get; init; } = [];
- /// Gets the list of tags.
+ /// Gets the list of raw tags.
public IReadOnlyList Tags { get; init; } = [];
- /// Equipment-bound VirtualTags (script-derived signals). Shares the equipment NodeId space
- /// with Tags; the collision rule checks both.
+ /// Gets the UNS tag references (raw tag → equipment projection). Drives the UNS effective-leaf
+ /// uniqueness rule (effective name = DisplayNameOverride else the backing raw tag's Name).
+ public IReadOnlyList UnsTagReferences { get; init; } = [];
+
+ /// Equipment-bound VirtualTags (script-derived signals). Part of the UNS effective-leaf
+ /// uniqueness set within an equipment (references + VirtualTags + ScriptedAlarms).
public IReadOnlyList VirtualTags { get; init; } = [];
+ /// Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set.
+ public IReadOnlyList ScriptedAlarms { get; init; } = [];
/// Gets the list of poll groups.
public IReadOnlyList PollGroups { get; init; } = [];
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs
index 7ba7e94c..2325944d 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs
@@ -36,14 +36,20 @@ public static class DraftSnapshotFactory
// EquipmentUuid checks run in separate validator passes — no rule here reads these fields.
GenerationId = 0, // generation model dropped; placeholder (no rule reads it)
ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this
- // v3: Namespace entity retired — no namespace list to materialize.
+ // v3: Namespace entity retired — no namespace list to materialize. The Raw tree
+ // (RawFolders/TagGroups) + UnsTagReferences feed the v3 rules (charset, tagname length,
+ // effective-leaf uniqueness).
+ RawFolders = await db.RawFolders.AsNoTracking().ToListAsync(ct),
DriverInstances = await db.DriverInstances.AsNoTracking().ToListAsync(ct),
Devices = await db.Devices.AsNoTracking().ToListAsync(ct),
+ TagGroups = await db.TagGroups.AsNoTracking().ToListAsync(ct),
UnsAreas = await db.UnsAreas.AsNoTracking().ToListAsync(ct),
UnsLines = await db.UnsLines.AsNoTracking().ToListAsync(ct),
Equipment = await db.Equipment.AsNoTracking().ToListAsync(ct),
Tags = await db.Tags.AsNoTracking().ToListAsync(ct),
+ UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct),
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
+ ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
ActiveReservations = await db.ExternalIdReservations
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 d49a61d9..097f56d1 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
@@ -32,33 +32,110 @@ public static class DraftValidator
ValidateEquipmentUuidImmutability(draft, errors);
ValidateReservationPreflight(draft, errors);
ValidateEquipmentIdDerivation(draft, errors);
- ValidateNoEquipmentSignalNameCollision(draft, errors);
- // v3 WP1 note: the retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
- // and ValidateGalaxyTagFullName (blob-as-identity gone; Tags are raw-only, not equipment-bound)
- // are deleted here. TODO(v3 WP4): add the new v3 rules — raw-name charset (no '/', no
- // lead/trail whitespace) at every level, historized effective-tagname ≤ 255 chars, and UNS
- // effective-leaf uniqueness across UnsTagReference/VirtualTag/ScriptedAlarm.
+ // v3 rules (WP4). The retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
+ // and ValidateGalaxyTagFullName (blob-as-identity gone) are replaced by:
+ ValidateRawNameCharset(draft, errors);
+ ValidateHistorizedTagnameLength(draft, errors);
+ ValidateUnsEffectiveLeafUniqueness(draft, errors);
return errors;
}
- private static void ValidateNoEquipmentSignalNameCollision(DraftSnapshot draft, List errors)
+ /// Builds the shared from the draft's raw topology so the
+ /// tagname-length rule computes the SAME RawPath the deploy artifact injects into drivers.
+ private static RawPathResolver BuildRawPathResolver(DraftSnapshot draft)
{
- // v3: Tags are raw-only (no EquipmentId), so the old Tag-vs-VirtualTag NodeId collision does
- // not apply here. Until WP4 wires the full UNS effective-leaf rule (across UnsTagReference,
- // VirtualTag, and ScriptedAlarm), enforce the still-valid invariant that a VirtualTag Name is
- // unique within its owning equipment — the OPC UA NodeId key is "{EquipmentId}/{Name}".
- var signals = draft.VirtualTags.Select(v => (Eq: v.EquipmentId, v.Name));
+ var folders = draft.RawFolders.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
+ var drivers = draft.DriverInstances.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
+ var devices = draft.Devices.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
+ var groups = draft.TagGroups.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
+ return new RawPathResolver(folders, drivers, devices, groups);
+ }
- foreach (var g in signals.GroupBy(s => $"{s.Eq}/{s.Name}", StringComparer.Ordinal))
+ /// v3: every raw Name (RawFolder / DriverInstance / Device / TagGroup / Tag) must be a legal
+ /// RawPath segment — no /, no leading/trailing whitespace, non-empty — because / is the
+ /// RawPath separator and would corrupt the NodeId. Uses , the same
+ /// charset authority the path builder enforces.
+ private static void ValidateRawNameCharset(DraftSnapshot draft, List errors)
+ {
+ void Check(string kind, string id, string name)
{
- var items = g.ToList();
- if (items.Count <= 1) continue;
- var f = items[0];
- errors.Add(new("EquipmentSignalNameCollision",
- $"{items.Count} signals collide on OPC UA NodeId '{g.Key}' (equipment '{f.Eq}', name '{f.Name}'); " +
- "a VirtualTag Name must be unique within an equipment",
- f.Eq));
+ var error = RawPaths.ValidateSegment(name);
+ if (error is not null)
+ errors.Add(new("RawNameInvalid", $"{kind} name '{name}' is not a legal RawPath segment: {error}", id));
+ }
+
+ foreach (var f in draft.RawFolders) Check("RawFolder", f.RawFolderId, f.Name);
+ foreach (var d in draft.DriverInstances) Check("DriverInstance", d.DriverInstanceId, d.Name);
+ foreach (var d in draft.Devices) Check("Device", d.DeviceId, d.Name);
+ foreach (var g in draft.TagGroups) Check("TagGroup", g.TagGroupId, g.Name);
+ foreach (var t in draft.Tags) Check("Tag", t.TagId, t.Name);
+ }
+
+ /// v3: a historized tag's EFFECTIVE historian tagname (the historianTagname override
+ /// else the tag's RawPath) must be ≤ 255 chars — the live-verified AVEVA historian limit (256 rejected).
+ /// A longer name is a deploy error telling the author to set a shorter historianTagname override,
+ /// never a silent truncation.
+ private static void ValidateHistorizedTagnameLength(DraftSnapshot draft, List errors)
+ {
+ const int MaxHistorianTagname = 255;
+ var resolver = BuildRawPathResolver(draft);
+ foreach (var t in draft.Tags)
+ {
+ var intent = TagConfigIntent.Parse(t.TagConfig);
+ if (!intent.IsHistorized) continue;
+ // Effective tagname = explicit override else the computed RawPath (null when the chain is broken —
+ // a separate rule/charset check flags that; nothing to length-check here).
+ var effective = intent.HistorianTagname ?? resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
+ if (effective is null || effective.Length <= MaxHistorianTagname) continue;
+ errors.Add(new("HistorianTagnameTooLong",
+ $"tag '{t.TagId}' historized effective tagname is {effective.Length} chars (max {MaxHistorianTagname}); " +
+ "set a shorter 'historianTagname' override in its TagConfig",
+ t.TagId));
+ }
+ }
+
+ /// v3: within an equipment, the EFFECTIVE leaf name must be unique across its UnsTagReferences
+ /// (DisplayNameOverride else the backing raw tag's Name), its VirtualTags (Name), and its ScriptedAlarms
+ /// (Name) — the three share the equipment's UNS NodeId space ({EquipmentId}/{EffectiveName}).
+ /// Enforced here at the deploy gate (as well as at authoring) so a raw-tag rename that induces a clash the
+ /// authoring check never saw is caught before it ships.
+ private static void ValidateUnsEffectiveLeafUniqueness(DraftSnapshot draft, List errors)
+ {
+ var tagNameById = draft.Tags.GroupBy(t => t.TagId, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
+
+ // (EquipmentId, EffectiveName) → source descriptions, so a collision names both sources.
+ var byLeaf = new Dictionary<(string Eq, string Name), List>();
+ void Add(string equipmentId, string? effectiveName, string source)
+ {
+ if (string.IsNullOrEmpty(effectiveName)) return;
+ var key = (equipmentId, effectiveName);
+ if (!byLeaf.TryGetValue(key, out var list)) byLeaf[key] = list = new List();
+ list.Add(source);
+ }
+
+ foreach (var r in draft.UnsTagReferences)
+ {
+ var effective = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
+ Add(r.EquipmentId, effective, $"reference '{r.UnsTagReferenceId}'");
+ }
+ foreach (var v in draft.VirtualTags)
+ Add(v.EquipmentId, v.Name, $"VirtualTag '{v.VirtualTagId}'");
+ foreach (var a in draft.ScriptedAlarms)
+ Add(a.EquipmentId, a.Name, $"ScriptedAlarm '{a.ScriptedAlarmId}'");
+
+ foreach (var ((eq, name), sources) in byLeaf)
+ {
+ if (sources.Count <= 1) continue;
+ errors.Add(new("UnsEffectiveNameCollision",
+ $"{sources.Count} UNS signals collide on effective name '{name}' within equipment '{eq}': " +
+ string.Join(", ", sources) + "; effective names must be unique across references, VirtualTags, and ScriptedAlarms",
+ eq));
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs
index 645ee64e..6766c520 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs
@@ -214,15 +214,21 @@ public sealed class AdminOperationsActor : ReceiveActor
// (default) these are non-blocking (logged + appended to the accepted result Message); in Error
// mode they fold into the reject list so a typo'd config cannot be re-deployed until fixed.
// Running servers are untouched either way — the gate only sees re-deploys.
+ // v3: a raw Tag's driver is resolved via its Device (Tag no longer carries EquipmentId /
+ // DriverInstanceId). Inspect EVERY raw tag's TagConfig against its device's driver type.
var driverTypeById = draft.DriverInstances
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
+ var driverByDevice = draft.Devices
+ .GroupBy(d => d.DeviceId, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.First().DriverInstanceId, StringComparer.Ordinal);
var tagConfigWarnings = new List<(string TagId, string Text)>();
- foreach (var t in draft.Tags.Where(t => t.EquipmentId is not null))
+ foreach (var t in draft.Tags)
{
- if (!driverTypeById.TryGetValue(t.DriverInstanceId, out var driverType)) continue;
+ if (!driverByDevice.TryGetValue(t.DeviceId, out var driverInstanceId)) continue;
+ if (!driverTypeById.TryGetValue(driverInstanceId, out var driverType)) continue;
foreach (var w in EquipmentTagConfigInspector.Inspect(driverType, t.TagConfig))
- tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}' (equipment '{t.EquipmentId}'): {w}"));
+ tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}': {w}"));
}
if (tagConfigWarnings.Count > 0)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs
index 9ef81752..261b2ea7 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs
@@ -29,16 +29,23 @@ public static class ConfigComposer
public static async Task SnapshotAndFlattenAsync(
OtOpcUaConfigDbContext db, CancellationToken ct = default)
{
+ // v3 greenfield snapshot: the Raw tree (RawFolders → DriverInstances(+RawFolderId) → Devices(+DeviceConfig)
+ // → TagGroups → Tags(DeviceId/TagGroupId, no EquipmentId/FolderPath/DriverInstanceId)) plus the UNS
+ // projection (UnsAreas/UnsLines/Equipment/UnsTagReferences) and the retained VirtualTags/ScriptedAlarms/
+ // PollGroups/NodeAcls/Scripts. The Namespaces snapshot is RETIRED (the two OPC UA namespaces are implicit).
+ // RevisionHash inputs follow the blob automatically — adding/removing a table shifts the SHA-256.
var snapshot = new
{
Clusters = await db.ServerClusters.AsNoTracking().OrderBy(x => x.ClusterId).ToListAsync(ct),
Nodes = await db.ClusterNodes.AsNoTracking().OrderBy(x => x.NodeId).ToListAsync(ct),
+ RawFolders = await db.RawFolders.AsNoTracking().OrderBy(x => x.RawFolderId).ToListAsync(ct),
DriverInstances = await db.DriverInstances.AsNoTracking().OrderBy(x => x.DriverInstanceId).ToListAsync(ct),
Devices = await db.Devices.AsNoTracking().OrderBy(x => x.DeviceId).ToListAsync(ct),
+ TagGroups = await db.TagGroups.AsNoTracking().OrderBy(x => x.TagGroupId).ToListAsync(ct),
Equipment = await db.Equipment.AsNoTracking().OrderBy(x => x.EquipmentId).ToListAsync(ct),
Tags = await db.Tags.AsNoTracking().OrderBy(x => x.TagId).ToListAsync(ct),
+ UnsTagReferences = await db.UnsTagReferences.AsNoTracking().OrderBy(x => x.UnsTagReferenceId).ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().OrderBy(x => x.PollGroupId).ToListAsync(ct),
- Namespaces = await db.Namespaces.AsNoTracking().OrderBy(x => x.NamespaceId).ToListAsync(ct),
UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct),
UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct),
NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct),
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
index eff3d232..1625aec6 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
@@ -297,41 +297,24 @@ public static class AddressSpaceComposer
IReadOnlyList equipment,
IReadOnlyList driverInstances,
IReadOnlyList scriptedAlarms) =>
- Compose(Array.Empty(), Array.Empty(), equipment, driverInstances, scriptedAlarms,
- Array.Empty(), Array.Empty());
-
- /// UNS-aware overload that doesn't supply tags.
- /// The UNS areas.
- /// The UNS lines.
- /// The equipment.
- /// The driver instances.
- /// The scripted alarms.
- /// The per-device rows used to resolve each equipment's DeviceHost. null = none.
- /// The composition result.
- public static AddressSpaceComposition Compose(
- IReadOnlyList unsAreas,
- IReadOnlyList unsLines,
- IReadOnlyList equipment,
- IReadOnlyList driverInstances,
- IReadOnlyList scriptedAlarms,
- IReadOnlyList? devices = null) =>
- Compose(unsAreas, unsLines, equipment, driverInstances, scriptedAlarms,
- Array.Empty(), Array.Empty(), devices: devices);
+ Compose(Array.Empty(), Array.Empty(), equipment, driverInstances, scriptedAlarms);
///
- /// Composes the address space build plan from the configuration entities.
+ /// Composes the address space build plan from the v3 configuration entities.
+ /// v3 DARK address space (Batch 1): equipment-tag variable nodes do NOT materialize —
+ /// EquipmentTags is deliberately empty (the raw + UNS variable nodes are lit up in Batch 4's
+ /// dual namespace). The composer carries the UNS folder hierarchy (Area/Line/Equipment) +
+ /// per-equipment VirtualTags + ScriptedAlarms only. Equipment no longer binds a driver/device, so
+ /// 's driver/device hooks are always null. This is the pure reference
+ /// mirror of DeploymentArtifact.ParseComposition (byte-parity target for the corpus tests).
///
/// The UNS areas.
/// The UNS lines.
/// The equipment.
/// The driver instances.
/// The scripted alarms.
- /// The tags.
- /// The namespaces.
- /// The Equipment-namespace virtual (calculated) tags. null = none.
+ /// The per-equipment virtual (calculated) tags. null = none.
/// The scripts joined to by ScriptId for the expression. null = none.
- /// The per-device rows (DeviceId + schemaless DeviceConfig JSON) used to resolve
- /// each equipment's DeviceHost from its bound DeviceId. null = none.
/// The composition result.
public static AddressSpaceComposition Compose(
IReadOnlyList unsAreas,
@@ -339,28 +322,12 @@ public static class AddressSpaceComposer
IReadOnlyList equipment,
IReadOnlyList driverInstances,
IReadOnlyList scriptedAlarms,
- IReadOnlyList tags,
- IReadOnlyList namespaces,
IReadOnlyList? virtualTags = null,
- IReadOnlyList