diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AddressSpaceRealm.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AddressSpaceRealm.cs
new file mode 100644
index 00000000..2d0d6169
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AddressSpaceRealm.cs
@@ -0,0 +1,18 @@
+namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+
+///
+/// Which of the two v3 OPC UA namespaces a node lives in. Carried explicitly alongside a
+/// node's s= identifier at every address-space sink seam so the namespace is chosen at
+/// the call site, never parsed back out of the id string (explicit beats inferred).
+/// maps each realm to its namespace URI.
+///
+public enum AddressSpaceRealm
+{
+ /// The device-oriented Raw tree (Folder→Driver→Device→TagGroup→Tag); a node's
+ /// s= id is its RawPath.
+ Raw,
+
+ /// The equipment-oriented UNS tree (Area→Line→Equipment→signal); a node's s=
+ /// id is the slash-joined Area/Line/Equipment[/EffectiveName].
+ Uns,
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs
new file mode 100644
index 00000000..f6296c7a
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs
@@ -0,0 +1,110 @@
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+
+///
+/// v3 dual-namespace NodeId + namespace-URI authority. Two OPC UA namespaces expose the same
+/// underlying values under two identity schemes:
+///
+/// - Raw (): the device-oriented tree
+/// Folder→Driver→Device→TagGroup→Tag. A node's s= identifier IS its
+/// RawPath — folders/drivers/devices/groups included (each keys
+/// on its own RawPath prefix).
+/// - Uns (): the equipment-oriented tree
+/// Area→Line→Equipment→signal. A node's s= identifier is the slash-joined
+/// Area/Line/Equipment[/EffectiveName].
+///
+/// These replace the single https://zb.com/otopcua/ns namespace and the retired
+/// EquipmentNodeIds ({equipmentId}/{folderPath}/{name}) scheme. Realm travels alongside
+/// the identifier as an at every sink seam — the namespace is
+/// never parsed back out of the id string. Both schemes share
+/// and its ordinal, case-sensitive segment charset so identity is enforced in one place.
+///
+public static class V3NodeIds
+{
+ /// The Raw namespace URI (device-oriented subtree).
+ public const string RawNamespaceUri = "https://zb.com/otopcua/raw";
+
+ /// The UNS namespace URI (equipment-oriented subtree).
+ public const string UnsNamespaceUri = "https://zb.com/otopcua/uns";
+
+ /// The namespace URI for a realm.
+ /// The address-space realm.
+ /// The realm's namespace URI.
+ /// Unknown realm.
+ public static string NamespaceUri(AddressSpaceRealm realm) => realm switch
+ {
+ AddressSpaceRealm.Raw => RawNamespaceUri,
+ AddressSpaceRealm.Uns => UnsNamespaceUri,
+ _ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unknown address-space realm."),
+ };
+
+ // ----- Raw realm -----
+
+ ///
+ /// The Raw-namespace s= identifier for a raw node — identical to its
+ /// RawPath (folders, drivers, devices, groups, and tags all key
+ /// on their own RawPath). A pass-through seam so call sites read intent rather than a bare
+ /// string; the argument is expected to already be a RawPaths-built path.
+ ///
+ /// The (already-built) RawPath.
+ /// The Raw-namespace s= identifier (== ).
+ public static string Raw(string rawPath)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(rawPath);
+ return rawPath;
+ }
+
+ // ----- Uns realm -----
+
+ ///
+ /// The UNS-namespace s= identifier for a folder or variable, slash-joined from
+ /// ordered segments (Area, then Line, then Equipment, then an optional EffectiveName).
+ /// Every segment is validated via (no embedded
+ /// separator, no leading/trailing whitespace) so a UNS id round-trips like a RawPath.
+ ///
+ /// The ordered, non-empty segments (Area → leaf).
+ /// The slash-joined UNS s= identifier.
+ /// A segment is invalid, or the sequence is empty.
+ public static string Uns(params string[] segments) => UnsFromSegments(segments);
+
+ /// Build a UNS s= identifier from ordered segments. See .
+ /// The ordered, non-empty segments (Area → leaf).
+ /// The slash-joined UNS s= identifier.
+ public static string Uns(IEnumerable segments) => UnsFromSegments(segments);
+
+ ///
+ /// Append a child segment (a Line under an Area, an Equipment under a Line, an
+ /// EffectiveName under an Equipment) to an already-built UNS path.
+ ///
+ /// The parent UNS path (assumed already valid).
+ /// The child segment to append.
+ /// The combined UNS s= identifier.
+ /// The child segment is invalid, or the parent is blank.
+ public static string UnsChild(string parentUnsPath, string childSegment)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(parentUnsPath);
+ var error = RawPaths.ValidateSegment(childSegment);
+ if (error is not null)
+ throw new ArgumentException($"Invalid UNS NodeId segment ('{childSegment}'): {error}", nameof(childSegment));
+
+ return parentUnsPath + RawPaths.Separator + childSegment;
+ }
+
+ private static string UnsFromSegments(IEnumerable segments)
+ {
+ ArgumentNullException.ThrowIfNull(segments);
+ var list = segments as IReadOnlyList ?? segments.ToList();
+ if (list.Count == 0)
+ throw new ArgumentException("A UNS NodeId must have at least one segment.", nameof(segments));
+
+ for (var i = 0; i < list.Count; i++)
+ {
+ var error = RawPaths.ValidateSegment(list[i]);
+ if (error is not null)
+ throw new ArgumentException($"Invalid UNS NodeId segment at index {i} ('{list[i]}'): {error}", nameof(segments));
+ }
+
+ return string.Join(RawPaths.Separator, list);
+ }
+}