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 46bbe08a..d952ba71 100644
--- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json
+++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json
@@ -2,7 +2,7 @@
"planPath": "archreview/plans/R2-11-tagconfig-consolidation-plan.md",
"lastUpdated": "2026-07-12",
"tasks": [
- { "id": "T1", "subject": "Golden TagConfig corpus + compose→encode→decode parity characterization test (Runtime.Tests, green on unmodified tree)", "status": "pending", "blockedBy": [] },
+ { "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": "pending", "blockedBy": [] },
{ "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "pending", "blockedBy": [] },
{ "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "pending", "blockedBy": [] },
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigCorpusParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigCorpusParityTests.cs
new file mode 100644
index 00000000..562c27a4
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigCorpusParityTests.cs
@@ -0,0 +1,111 @@
+using System.Linq;
+using System.Text.Json;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer;
+using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
+
+///
+/// The cross-seam characterization net for R2-11 (01/C-1 + 01/P-1). Composes one equipment tag per
+/// blob, serialises the SAME draft to the artifact blob shape,
+/// decodes it, and asserts the decoded list equals the composer's
+/// element-wise (positional-record value equality) over the WHOLE corpus. Because both producers parse
+/// the identical raw TagConfig string, this proves the four byte-parity parse sites agree TODAY,
+/// and is the permanent regression guard held green through every subsequent seam swap
+/// (TagConfigIntent.Parse consolidation).
+///
+public sealed class TagConfigCorpusParityTests
+{
+ [Fact]
+ public void Composer_and_artifact_agree_over_the_whole_TagConfig_corpus()
+ {
+ var ns = new Namespace
+ {
+ NamespaceId = "ns-eq",
+ ClusterId = "c1",
+ Kind = NamespaceKind.Equipment,
+ NamespaceUri = "urn:eq",
+ };
+ var driver = new DriverInstance
+ {
+ DriverInstanceId = "drv-1",
+ ClusterId = "c1",
+ NamespaceId = "ns-eq",
+ Name = "Modbus1",
+ DriverType = "Modbus",
+ DriverConfig = "{}",
+ };
+ var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
+ var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
+ var equip = new Equipment
+ {
+ EquipmentId = "eq-1",
+ DriverInstanceId = "drv-1",
+ UnsLineId = "line-1",
+ Name = "Machine_001",
+ MachineCode = "MACHINE_001",
+ };
+
+ var tags = TagConfigGoldenCorpus.Blobs
+ .Select((blob, i) => new Tag
+ {
+ TagId = $"tag-{i:D2}",
+ DriverInstanceId = "drv-1",
+ EquipmentId = "eq-1",
+ FolderPath = null,
+ Name = $"Sig{i:D2}",
+ DataType = "Float",
+ AccessLevel = TagAccessLevel.Read,
+ TagConfig = blob,
+ })
+ .ToArray();
+
+ // ---- Side 1: the live-edit composer ----
+ var composed = AddressSpaceComposer.Compose(
+ new[] { area }, new[] { line }, new[] { equip },
+ new[] { driver }, Array.Empty(), tags, new[] { ns });
+
+ // ---- Side 2: serialise the SAME draft to the artifact blob shape, then decode it ----
+ var blob = JsonSerializer.SerializeToUtf8Bytes(new
+ {
+ Namespaces = new[]
+ {
+ new { ns.NamespaceId, ns.ClusterId, Kind = (int)ns.Kind },
+ },
+ DriverInstances = new[]
+ {
+ new { driver.DriverInstanceId, driver.DriverType, driver.DriverConfig, driver.NamespaceId, driver.ClusterId },
+ },
+ Tags = tags.Select(ToSnapshot).ToArray(),
+ });
+
+ var decoded = DeploymentArtifact.ParseComposition(blob);
+
+ decoded.EquipmentTags.Count.ShouldBe(tags.Length);
+ // Full byte-parity: every field, same order (positional-record value equality). A field-by-field
+ // spell-out per blob so a divergence names the offending corpus entry.
+ for (var i = 0; i < composed.EquipmentTags.Count; i++)
+ {
+ var c = composed.EquipmentTags[i];
+ var d = decoded.EquipmentTags.Single(t => t.TagId == c.TagId);
+ d.ShouldBe(c, customMessage: $"corpus blob #{c.TagId} diverged: '{tags.Single(t => t.TagId == c.TagId).TagConfig}'");
+ }
+ decoded.EquipmentTags.SequenceEqual(composed.EquipmentTags).ShouldBeTrue();
+ }
+
+ private static object ToSnapshot(Tag t) => new
+ {
+ t.TagId,
+ t.DriverInstanceId,
+ t.EquipmentId,
+ t.Name,
+ t.FolderPath,
+ t.DataType,
+ AccessLevel = (int)t.AccessLevel,
+ t.TagConfig,
+ };
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs
new file mode 100644
index 00000000..a76bed77
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs
@@ -0,0 +1,83 @@
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
+
+///
+/// The shared golden corpus of TagConfig JSON blobs used to characterise the cross-driver
+/// platform-intent parse (FullName / alarm / isHistorized / historianTagname / isArray / arrayLength).
+/// Every entry is a NON-NULL string (the DB CK_Tag_TagConfig_IsJson constraint guarantees a
+/// non-null TagConfig for a real deploy) so the compose→encode→decode parity test
+/// () can round-trip each through both equipment-tag producers
+/// and assert byte-parity field-by-field. The null-input divergence (composer returns the raw
+/// null, artifact coalesces to "") is a documented seam handled only at the
+/// TagConfigIntent.Parse unit level, not through the parity round-trip.
+/// Covers: happy path, blank, non-object root, malformed JSON, FullName absent/non-string/whitespace,
+/// alarm object with each field absent/wrong-type, historizeToAveva true/false/non-bool,
+/// historianTagname whitespace, isArray/arrayLength combinations (incl. negative/overflow/non-number/
+/// length-without-flag/zero), unknown keys, and mixed platform+driver keys.
+///
+public static class TagConfigGoldenCorpus
+{
+ /// The corpus blobs, index-ordered. The parity test builds one equipment tag per blob.
+ public static readonly IReadOnlyList Blobs = new[]
+ {
+ // 0 happy path
+ "{\"FullName\":\"X.Y\"}",
+ // 1 blank (whitespace) — FullName falls back to the raw (blank) blob on both seams
+ " ",
+ // 2 non-object root
+ "[1,2]",
+ // 3 malformed JSON
+ "not json {",
+ // 4 FullName absent (driver keys only) — FullName = raw blob
+ "{\"region\":\"HoldingRegisters\",\"address\":5}",
+ // 5 FullName present but non-string — FullName = raw blob
+ "{\"FullName\":123}",
+ // 6 FullName whitespace value (not trimmed)
+ "{\"FullName\":\" \"}",
+ // 7 alarm empty object → defaults (AlarmCondition, 500, historize null)
+ "{\"FullName\":\"A\",\"alarm\":{}}",
+ // 8 alarm fully specified
+ "{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}",
+ // 9 alarm non-object → no alarm
+ "{\"FullName\":\"A\",\"alarm\":\"oops\"}",
+ // 10 alarm historizeToAveva true
+ "{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}",
+ // 11 alarm historizeToAveva false
+ "{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}",
+ // 12 alarm historizeToAveva non-bool → null
+ "{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}",
+ // 13 alarm severity non-number → 500
+ "{\"FullName\":\"A\",\"alarm\":{\"severity\":\"high\"}}",
+ // 14 alarm alarmType non-string → AlarmCondition
+ "{\"FullName\":\"A\",\"alarm\":{\"alarmType\":123}}",
+ // 15 isHistorized true
+ "{\"FullName\":\"A\",\"isHistorized\":true}",
+ // 16 isHistorized true + explicit tagname
+ "{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\"P.L.X\"}",
+ // 17 isHistorized false + JSON-null tagname
+ "{\"FullName\":\"A\",\"isHistorized\":false,\"historianTagname\":null}",
+ // 18 historianTagname whitespace → null
+ "{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\" \"}",
+ // 19 isHistorized non-bool → false
+ "{\"FullName\":\"A\",\"isHistorized\":\"yes\"}",
+ // 20 isArray true + length
+ "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":16}",
+ // 21 isArray true, no length
+ "{\"FullName\":\"A\",\"isArray\":true}",
+ // 22 isArray false + length → scalar
+ "{\"FullName\":\"A\",\"isArray\":false,\"arrayLength\":16}",
+ // 23 isArray true, length 0 → true, 0
+ "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":0}",
+ // 24 arrayLength non-number → true, null
+ "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":\"16\"}",
+ // 25 arrayLength negative → true, null
+ "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":-1}",
+ // 26 arrayLength float → true, null
+ "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":16.5}",
+ // 27 arrayLength overflow (uint.MaxValue + 1) → true, null
+ "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":4294967296}",
+ // 28 unknown + mixed platform/driver keys
+ "{\"FullName\":\"A.B\",\"region\":\"Coils\",\"address\":5,\"dataType\":\"Int16\",\"isHistorized\":true,\"alarm\":{\"severity\":250},\"foo\":\"bar\"}",
+ // 29 all intents combined
+ "{\"FullName\":\"C.D\",\"isArray\":true,\"arrayLength\":4,\"isHistorized\":true,\"historianTagname\":\"H\",\"alarm\":{\"alarmType\":\"DiscreteAlarm\",\"severity\":900,\"historizeToAveva\":false}}",
+ };
+}