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 b38dcc59..32a9a4cc 100644
--- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json
+++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json
@@ -8,7 +8,7 @@
{ "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": "completed", "blockedBy": ["T1", "T3"] },
{ "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "completed", "blockedBy": ["T4", "T5"] },
- { "id": "T7", "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "pending", "blockedBy": ["T5"] },
+ { "id": "T7", "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "completed", "blockedBy": ["T5"] },
{ "id": "T8", "subject": "DraftValidator swap: Configuration→Commons ProjectReference; ExtractTagConfigFullName → TagConfigIntent.ExplicitFullName (null-on-absent preserved)", "status": "pending", "blockedBy": ["T2", "T3"] },
{ "id": "T9", "subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites", "status": "pending", "blockedBy": ["T2", "T3"] },
{ "id": "T10", "subject": "Retire OpcUaServer.Tests ExtractTag*Tests (authority moved to Commons.Tests); verify zero 'MUST parse identically' hits", "status": "pending", "blockedBy": ["T3", "T5"] },
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
index 202aea59..1c132341 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
@@ -458,8 +458,10 @@ public static class DeploymentArtifact
// ordinary equipment tags now (GalaxyMxGateway is a standard Equipment-kind driver).
if (!equipmentNamespaces.Contains(nsId)) continue;
- var (isHistorized, historianTagname) = ExtractTagHistorize(tagConfig);
- var (isArray, arrayLength) = ExtractTagArray(tagConfig);
+ // Parse the schemaless TagConfig blob ONCE per tag via the shared byte-parity authority
+ // (01/C-1) — the SAME TagConfigIntent.Parse the live-compose seam (AddressSpaceComposer)
+ // consumes, so the artifact-decode plan stays byte-equal with the composer's.
+ var intent = TagConfigIntent.Parse(tagConfig);
result.Add(new EquipmentTagPlan(
TagId: tagId!,
EquipmentId: equipmentId!,
@@ -467,13 +469,13 @@ public static class DeploymentArtifact
FolderPath: folder ?? string.Empty,
Name: name!,
DataType: dataType ?? "BaseDataType",
- FullName: ExtractTagFullName(tagConfig),
+ FullName: intent.FullName,
Writable: writable,
- Alarm: ExtractTagAlarm(tagConfig),
- IsHistorized: isHistorized,
- HistorianTagname: historianTagname,
- IsArray: isArray,
- ArrayLength: arrayLength));
+ Alarm: intent.Alarm is { } a ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) : null,
+ IsHistorized: intent.IsHistorized,
+ HistorianTagname: intent.HistorianTagname,
+ IsArray: intent.IsArray,
+ ArrayLength: intent.ArrayLength));
}
result.Sort((a, b) =>
@@ -665,113 +667,6 @@ public static class DeploymentArtifact
return result;
}
- ///
- /// Extract the driver-side full reference from a tag's TagConfig JSON (top-level "FullName"
- /// field). The artifact-decode mirror of AddressSpaceComposer.ExtractTagFullName /
- /// EquipmentNodeWalker.ExtractFullName — replicated because Runtime does not reference
- /// the Core driver assembly. Falls back to the raw blob when absent or non-JSON.
- ///
- private static string ExtractTagFullName(string? tagConfig)
- {
- if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig ?? string.Empty;
- try
- {
- using var doc = JsonDocument.Parse(tagConfig);
- if (doc.RootElement.ValueKind == JsonValueKind.Object
- && doc.RootElement.TryGetProperty("FullName", out var fullName)
- && fullName.ValueKind == JsonValueKind.String)
- {
- return fullName.GetString() ?? tagConfig;
- }
- }
- catch (JsonException) { /* fall through to raw blob */ }
- return tagConfig;
- }
-
- /// Parses the optional alarm object from a tag's TagConfig JSON. Returns null
- /// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
- /// live-edit side (AddressSpaceComposer.ExtractTagAlarm) MUST parse identically (byte-parity).
- private static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig)
- {
- if (string.IsNullOrWhiteSpace(tagConfig)) return null;
- try
- {
- using var doc = JsonDocument.Parse(tagConfig);
- if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
- if (!doc.RootElement.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): byte-parity with
- // AddressSpaceComposer.ExtractTagAlarm — only an explicit false suppresses the durable AVEVA write.
- bool? historize = a.TryGetProperty("historizeToAveva", out var hEl)
- && hEl.ValueKind is JsonValueKind.True or JsonValueKind.False
- ? hEl.GetBoolean()
- : null;
- return new EquipmentTagAlarmInfo(type, sev, historize);
- }
- catch (JsonException) { return null; }
- }
-
- /// Parses the optional server-side HistoryRead intent from a tag's TagConfig JSON:
- /// the isHistorized bool (absent / not a bool / non-object root / blank / malformed ⇒
- /// false) and the optional historianTagname string override (absent / not a string /
- /// whitespace-or-empty ⇒ null, meaning the historian tagname defaults to the tag's FullName,
- /// resolved later). The raw string value is used — not trimmed — matching ExtractTagFullName /
- /// ExtractTagAlarm. Never throws. The live-edit composer side
- /// (AddressSpaceComposer.ExtractTagHistorize) MUST parse identically (byte-parity).
- private static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig)
- {
- if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
- try
- {
- using var doc = JsonDocument.Parse(tagConfig);
- if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null);
- var isHistorized = doc.RootElement.TryGetProperty("isHistorized", out var hEl)
- && (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
- && hEl.GetBoolean();
- string? tagname = null;
- if (doc.RootElement.TryGetProperty("historianTagname", out var nEl)
- && nEl.ValueKind == JsonValueKind.String)
- {
- var raw = nEl.GetString();
- if (!string.IsNullOrWhiteSpace(raw)) tagname = raw;
- }
- return (isHistorized, tagname);
- }
- catch (JsonException) { return (false, null); }
- }
-
- /// Parses the optional array intent from a tag's TagConfig JSON: the isArray
- /// bool (absent / not a bool / non-object root / blank / malformed ⇒ false) and the optional
- /// arrayLength uint (honoured ONLY when isArray is true AND the prop is a JSON number
- /// that fits uint; else null). Mirrors in structure +
- /// null/blank/non-object/malformed-JSON tolerance. Never throws. The live-edit composer side
- /// (AddressSpaceComposer.ExtractTagArray) MUST parse identically (byte-parity).
- private static (bool IsArray, uint? ArrayLength) ExtractTagArray(string? tagConfig)
- {
- if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
- try
- {
- using var doc = JsonDocument.Parse(tagConfig);
- if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null);
- var isArray = doc.RootElement.TryGetProperty("isArray", out var aEl)
- && (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False)
- && aEl.GetBoolean();
- uint? arrayLength = null;
- if (isArray
- && doc.RootElement.TryGetProperty("arrayLength", out var lEl)
- && lEl.ValueKind == JsonValueKind.Number
- && lEl.TryGetUInt32(out var len))
- {
- arrayLength = len;
- }
- return (isArray, arrayLength);
- }
- catch (JsonException) { return (false, null); }
- }
-
private static IReadOnlyList ReadArray(JsonElement root, string propertyName, Func reader)
where T : class
{