diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json
index 6bbe0462..42326c7b 100644
--- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json
+++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json
@@ -5,7 +5,7 @@
{ "id": "T1", "subject": "Golden TagConfig corpus + compose→encode→decode parity characterization test (Runtime.Tests, green on unmodified tree)", "status": "completed", "blockedBy": [] },
{ "id": "T2", "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "completed", "blockedBy": [] },
{ "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "completed", "blockedBy": [] },
- { "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "pending", "blockedBy": [] },
+ { "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "completed", "blockedBy": [] },
{ "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "pending", "blockedBy": ["T1", "T3"] },
{ "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "pending", "blockedBy": ["T4", "T5"] },
{ "id": "T7", "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "pending", "blockedBy": ["T5"] },
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs
new file mode 100644
index 00000000..cca42273
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs
@@ -0,0 +1,47 @@
+using System.Text.Json;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+///
+/// SINGLE SOURCE OF TRUTH for extracting + normalizing a Device's connection host from its
+/// schemaless DeviceConfig JSON. Shared by the live-compose seam (AddressSpaceComposer),
+/// the artifact-decode seam (DeploymentArtifact), and the FixedTree-partition path
+/// (DriverHostActor) so an EquipmentNode.DeviceHost and a driver-discovered device-host
+/// folder segment for the same device compare byte-equal. Ported verbatim from the historical
+/// AddressSpaceComposer.TryExtractDeviceHost / NormalizeDeviceHost.
+///
+public static class DeviceConfigIntent
+{
+ ///
+ /// Extract a Device's connection host from its schemaless DeviceConfig JSON: the
+ /// top-level "HostAddress" string (e.g. "10.201.31.5:8193"). Returns
+ /// when the config is blank, not a JSON object, has no string
+ /// HostAddress, or the value is blank/whitespace. Never throws. The returned host is
+ /// deterministically normalized via (trim + lower-case).
+ ///
+ /// The device's schemaless DeviceConfig JSON blob.
+ /// The normalized device host, or when absent/blank/unparseable.
+ public static string? TryExtractHost(string? deviceConfigJson)
+ {
+ if (string.IsNullOrWhiteSpace(deviceConfigJson)) return null;
+ try
+ {
+ using var doc = JsonDocument.Parse(deviceConfigJson);
+ if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
+ if (!doc.RootElement.TryGetProperty("HostAddress", out var hostEl)
+ || hostEl.ValueKind != JsonValueKind.String) return null;
+ var raw = hostEl.GetString();
+ if (string.IsNullOrWhiteSpace(raw)) return null;
+ return NormalizeHost(raw);
+ }
+ catch (JsonException) { return null; }
+ }
+
+ ///
+ /// The single normalization authority for a device host: trims surrounding whitespace and
+ /// lower-cases (invariant). Idempotent (an already-normalized value is unchanged).
+ ///
+ /// The raw host string (non-null; a non-empty HostAddress or folder segment).
+ /// The normalized host (trimmed + lower-cased).
+ public static string NormalizeHost(string host) => host.Trim().ToLowerInvariant();
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/DeviceConfigIntentTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/DeviceConfigIntentTests.cs
new file mode 100644
index 00000000..55e6db11
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/DeviceConfigIntentTests.cs
@@ -0,0 +1,46 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
+
+///
+/// Pins device-host extraction + normalization semantics (ported
+/// from the historical composer statics; R2-11): null on blank/non-object/non-string/whitespace,
+/// trim+lower normalization, and idempotence.
+///
+public sealed class DeviceConfigIntentTests
+{
+ [Theory]
+ [InlineData("{\"HostAddress\":\"10.0.0.5:8193\"}", "10.0.0.5:8193")]
+ [InlineData("{\"HostAddress\":\" HOST:8193 \"}", "host:8193")] // trim + lower
+ [InlineData("{\"HostAddress\":\"HOST:8193\",\"other\":1}", "host:8193")]
+ // null-yielding cases
+ [InlineData(null, null)]
+ [InlineData("", null)]
+ [InlineData(" ", null)]
+ [InlineData("not json {", null)]
+ [InlineData("[1,2]", null)] // non-object root
+ [InlineData("{\"HostAddress\":123}", null)] // non-string
+ [InlineData("{\"HostAddress\":\" \"}", null)] // whitespace value
+ [InlineData("{\"other\":\"x\"}", null)] // absent
+ public void TryExtractHost(string? cfg, string? expected)
+ {
+ DeviceConfigIntent.TryExtractHost(cfg).ShouldBe(expected);
+ }
+
+ [Theory]
+ [InlineData(" HOST:8193 ", "host:8193")]
+ [InlineData("host:8193", "host:8193")]
+ public void NormalizeHost_trims_and_lowercases(string raw, string expected)
+ {
+ DeviceConfigIntent.NormalizeHost(raw).ShouldBe(expected);
+ }
+
+ [Fact]
+ public void NormalizeHost_is_idempotent()
+ {
+ var once = DeviceConfigIntent.NormalizeHost(" HOST:8193 ");
+ DeviceConfigIntent.NormalizeHost(once).ShouldBe(once);
+ }
+}