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 19647b38..6bbe0462 100644
--- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json
+++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json
@@ -4,7 +4,7 @@
"tasks": [
{ "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": "pending", "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": "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"] },
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs
new file mode 100644
index 00000000..ba81fcf2
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs
@@ -0,0 +1,129 @@
+using System.Text.Json;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+///
+/// The cross-driver platform intents parsed from a Tag's schemaless TagConfig JSON.
+/// SINGLE SOURCE OF TRUTH for the byte-parity contract shared by the live-compose seam
+/// (AddressSpaceComposer), the artifact-decode seam (DeploymentArtifact), the draft
+/// gate (DraftValidator), and the walker (EquipmentNodeWalker). One
+/// per blob. Never throws.
+/// Two deliberate variations are carried on the record so every seam can be served from one
+/// parse:
+///
+/// - is the driver-side wire reference: the top-level
+/// FullName string when present, else the RAW blob (an equipment tag's TagConfig JSON
+/// is its wire reference for the six protocol drivers). Parse(null) ⇒
+/// FullName == "".
+/// - is the FullName property ONLY when
+/// present-and-string, else — the DraftValidator Galaxy-rule semantics
+/// (it wants the explicit reference, not the raw-blob fallback).
+///
+///
+/// Top-level FullName string, else the raw blob (wire-reference fallback).
+/// The FullName property when present-and-string, else .
+/// The optional native-alarm intent parsed from the alarm object; ⇒ plain value variable.
+/// isHistorized — true only when the value is a bool true.
+/// Non-whitespace historianTagname string override, else (not trimmed).
+/// isArray bool.
+/// arrayLength honoured ONLY when and a JSON number fitting ; else .
+public sealed record TagConfigIntent(
+ string FullName,
+ string? ExplicitFullName,
+ TagAlarmIntent? Alarm,
+ bool IsHistorized,
+ string? HistorianTagname,
+ bool IsArray,
+ uint? ArrayLength)
+{
+ ///
+ /// Parse a Tag's schemaless TagConfig JSON into the cross-driver platform intents.
+ /// Never throws: malformed JSON / non-object root / blank / null ⇒ all intents default with
+ /// = the raw blob (or "" for null). Semantics are ported verbatim from the
+ /// historical AddressSpaceComposer.Extract* statics (the canonical copy).
+ ///
+ /// The tag's raw TagConfig JSON (nullable/blank tolerated).
+ /// The parsed intents; never .
+ public static TagConfigIntent Parse(string? tagConfig)
+ {
+ if (string.IsNullOrWhiteSpace(tagConfig))
+ return new TagConfigIntent(tagConfig ?? string.Empty, null, null, false, null, false, null);
+
+ try
+ {
+ using var doc = JsonDocument.Parse(tagConfig);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ return new TagConfigIntent(tagConfig, null, null, false, null, false, null);
+
+ var explicitFullName =
+ root.TryGetProperty("FullName", out var fn) && fn.ValueKind == JsonValueKind.String
+ ? fn.GetString()
+ : null;
+ var fullName = explicitFullName ?? tagConfig;
+
+ var (isHistorized, historianTagname) = ParseHistorize(root);
+ var (isArray, arrayLength) = ParseArray(root);
+
+ return new TagConfigIntent(
+ fullName, explicitFullName, ParseAlarm(root),
+ isHistorized, historianTagname, isArray, arrayLength);
+ }
+ catch (JsonException)
+ {
+ return new TagConfigIntent(tagConfig, null, null, false, null, false, null);
+ }
+ }
+
+ private static TagAlarmIntent? ParseAlarm(JsonElement root)
+ {
+ if (!root.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
+ var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String
+ ? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition";
+ var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number
+ && sEl.TryGetInt32(out var sv) ? sv : 500;
+ // historizeToAveva (bool?, absent ⇒ null ⇒ historize): only an explicit false suppresses the
+ // durable AVEVA write at the HistorianAdapterActor gate; a non-bool node ⇒ null (default-on).
+ bool? historize = a.TryGetProperty("historizeToAveva", out var hEl)
+ && hEl.ValueKind is JsonValueKind.True or JsonValueKind.False
+ ? hEl.GetBoolean()
+ : null;
+ return new TagAlarmIntent(type, sev, historize);
+ }
+
+ private static (bool IsHistorized, string? HistorianTagname) ParseHistorize(JsonElement root)
+ {
+ var isHistorized = root.TryGetProperty("isHistorized", out var hEl)
+ && (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
+ && hEl.GetBoolean();
+ string? tagname = null;
+ if (root.TryGetProperty("historianTagname", out var nEl) && nEl.ValueKind == JsonValueKind.String)
+ {
+ var raw = nEl.GetString();
+ if (!string.IsNullOrWhiteSpace(raw)) tagname = raw; // not trimmed
+ }
+ return (isHistorized, tagname);
+ }
+
+ private static (bool IsArray, uint? ArrayLength) ParseArray(JsonElement root)
+ {
+ var isArray = root.TryGetProperty("isArray", out var aEl)
+ && (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False)
+ && aEl.GetBoolean();
+ uint? arrayLength = null;
+ if (isArray
+ && root.TryGetProperty("arrayLength", out var lEl)
+ && lEl.ValueKind == JsonValueKind.Number
+ && lEl.TryGetUInt32(out var len))
+ {
+ arrayLength = len;
+ }
+ return (isArray, arrayLength);
+ }
+}
+
+/// The optional native-alarm intent parsed from a tag's TagConfig.alarm object.
+/// OPC UA Part 9 subtype string; default "AlarmCondition".
+/// 1..1000 severity; default 500.
+/// Per-condition durable-write opt-out (bool?; absent ⇒ ⇒ historize).
+public sealed record TagAlarmIntent(string AlarmType, int Severity, bool? HistorizeToAveva);
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs
new file mode 100644
index 00000000..b19f46fb
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs
@@ -0,0 +1,128 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
+
+///
+/// The single byte-parity authority for the cross-driver TagConfig platform intents (R2-11, 01/C-1).
+/// Ports the three retired OpcUaServer.Tests/ExtractTag{Alarm,Historize,Array}Tests tables and
+/// adds the /
+/// distinction plus the never-throws property.
+///
+public sealed class TagConfigIntentTests
+{
+ // ---- FullName / ExplicitFullName ----
+
+ [Theory]
+ [InlineData("{\"FullName\":\"X.Y\"}", "X.Y", "X.Y")]
+ [InlineData("{\"FullName\":\" \"}", " ", " ")] // whitespace string returned verbatim (not trimmed)
+ [InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\"}", "A.B", "A.B")]
+ // fallbacks: FullName = raw blob, ExplicitFullName = null
+ [InlineData("{\"region\":\"HoldingRegisters\"}", "{\"region\":\"HoldingRegisters\"}", null)]
+ [InlineData("{\"FullName\":123}", "{\"FullName\":123}", null)]
+ [InlineData("[1,2]", "[1,2]", null)]
+ [InlineData("not json {", "not json {", null)]
+ [InlineData(" ", " ", null)]
+ public void FullName_and_ExplicitFullName(string cfg, string expectedFullName, string? expectedExplicit)
+ {
+ var intent = TagConfigIntent.Parse(cfg);
+ intent.FullName.ShouldBe(expectedFullName);
+ intent.ExplicitFullName.ShouldBe(expectedExplicit);
+ }
+
+ [Fact]
+ public void Parse_null_yields_empty_FullName_and_null_explicit()
+ {
+ var intent = TagConfigIntent.Parse(null);
+ intent.FullName.ShouldBe(string.Empty);
+ intent.ExplicitFullName.ShouldBeNull();
+ intent.Alarm.ShouldBeNull();
+ intent.IsHistorized.ShouldBeFalse();
+ intent.IsArray.ShouldBeFalse();
+ }
+
+ // ---- Alarm (ported from ExtractTagAlarmTests) ----
+
+ [Theory]
+ [InlineData("{\"FullName\":\"X.Y\"}", false, null, 0)]
+ [InlineData("{\"FullName\":\"X.Y\",\"alarm\":{}}", true, "AlarmCondition", 500)]
+ [InlineData("{\"FullName\":\"X.Y\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", true, "OffNormalAlarm", 700)]
+ [InlineData("not json", false, null, 0)]
+ [InlineData("{\"FullName\":\"X.Y\",\"alarm\":\"oops\"}", false, null, 0)]
+ [InlineData("{\"alarm\":{\"severity\":\"high\"}}", true, "AlarmCondition", 500)] // non-number severity → 500
+ [InlineData("{\"alarm\":{\"alarmType\":123}}", true, "AlarmCondition", 500)] // non-string type → default
+ public void Alarm_parses_or_null(string cfg, bool present, string? type, int sev)
+ {
+ var alarm = TagConfigIntent.Parse(cfg).Alarm;
+ if (!present) { alarm.ShouldBeNull(); return; }
+ alarm!.AlarmType.ShouldBe(type);
+ alarm.Severity.ShouldBe(sev);
+ }
+
+ [Theory]
+ [InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500}}", null)]
+ [InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}", true)]
+ [InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}", false)]
+ [InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}", null)]
+ public void Alarm_historizeToAveva(string cfg, bool? expected)
+ {
+ TagConfigIntent.Parse(cfg).Alarm!.HistorizeToAveva.ShouldBe(expected);
+ }
+
+ // ---- Historize ----
+
+ [Theory]
+ [InlineData("{\"FullName\":\"A\",\"isHistorized\":true}", true, null)]
+ [InlineData("{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\"P.L.X\"}", true, "P.L.X")]
+ [InlineData("{\"FullName\":\"A\",\"isHistorized\":false,\"historianTagname\":null}", false, null)]
+ [InlineData("{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\" \"}", true, null)] // whitespace → null
+ [InlineData("{\"FullName\":\"A\",\"isHistorized\":\"yes\"}", false, null)] // non-bool → false
+ [InlineData("{\"FullName\":\"A\"}", false, null)]
+ public void Historize(string cfg, bool expectedHistorized, string? expectedTagname)
+ {
+ var intent = TagConfigIntent.Parse(cfg);
+ intent.IsHistorized.ShouldBe(expectedHistorized);
+ intent.HistorianTagname.ShouldBe(expectedTagname);
+ }
+
+ // ---- Array (ported from ExtractTagArrayTests) ----
+
+ [Theory]
+ [InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":16}", true, (uint)16)]
+ [InlineData("{\"FullName\":\"T.A\",\"isArray\":true}", true, null)]
+ [InlineData("{\"FullName\":\"T.A\"}", false, null)]
+ [InlineData("{\"FullName\":\"T.A\",\"isArray\":false,\"arrayLength\":16}", false, null)]
+ [InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":0}", true, (uint)0)]
+ [InlineData(null, false, null)]
+ [InlineData("", false, null)]
+ [InlineData("not json {", false, null)]
+ [InlineData("[1,2]", false, null)]
+ [InlineData("{\"isArray\":\"yes\"}", false, null)]
+ [InlineData("{\"isArray\":true,\"arrayLength\":\"16\"}", true, null)]
+ [InlineData("{\"isArray\":true,\"arrayLength\":-1}", true, null)]
+ [InlineData("{\"isArray\":true,\"arrayLength\":16.5}", true, null)]
+ [InlineData("{\"isArray\":true,\"arrayLength\":4294967296}", true, null)]
+ public void Array_parses_or_defaults(string? cfg, bool expectedIsArray, uint? expectedLength)
+ {
+ var intent = TagConfigIntent.Parse(cfg);
+ intent.IsArray.ShouldBe(expectedIsArray);
+ intent.ArrayLength.ShouldBe(expectedLength);
+ }
+
+ // ---- never-throws property over an adversarial set ----
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("not json {")]
+ [InlineData("[1,2,3]")]
+ [InlineData("42")]
+ [InlineData("\"just a string\"")]
+ [InlineData("{\"alarm\":42,\"isArray\":\"x\",\"arrayLength\":true,\"isHistorized\":123}")]
+ public void Parse_never_throws(string? cfg)
+ {
+ Should.NotThrow(() => TagConfigIntent.Parse(cfg));
+ }
+}