using System.Buffers; using System.IO.Compression; using System.Text.Json; using System.Text.Json.Nodes; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer; namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; /// /// Minimal driver-side view of the deployment artifact emitted by /// ConfigComposer.SnapshotAndFlattenAsync. The artifact JSON is the full snapshot — /// for driver spawning we only need the DriverInstances array. Reading just the /// subset keeps allocations cheap on every deploy. /// public sealed record DriverInstanceSpec( Guid DriverInstanceRowId, string DriverInstanceId, string Name, string DriverType, bool Enabled, string DriverConfig, string? ClusterId = null, string? ResilienceConfig = null); /// How a node should scope a deployment artifact to its own ClusterId. public enum ClusterFilterMode { /// Apply everything (single-cluster / legacy deployments). None, /// Filter the artifact to the node's own ClusterId. ScopeTo, /// Apply nothing (node's cluster row not found in a multi-cluster artifact). Suppress, } /// /// The data-plane authorization inputs decoded out of a deployment artifact for one node (#520). /// /// The node's cluster per its ClusterNode row, or null on a /// single-cluster / legacy artifact where no scoping applies. /// Every ApiKey the artifact carries, revoked and expired included — the /// lookup filters those, so that a revocation is visible rather than indistinguishable from a /// missing key. /// The NodeAcl rows scoped to . /// /// Where each grant's scope sits in the address space, resolved from the artifact's own topology. /// This is why the node needs no ConfigDb to build a permission trie: the artifact already carries /// every raw and UNS parent link, so the paths are derivable from the same bytes the grants came in. /// A scope that resolves to nothing is absent here, and the trie builder drops its grant with a /// diagnostic rather than planting it somewhere unreachable. /// public sealed record DataPlaneAuthorityProjection( string? ClusterId, IReadOnlyList Keys, IReadOnlyList Grants, IReadOnlyDictionary> ScopePaths) { /// The projection of an absent or unparseable artifact: no cluster, no keys, no grants. public static readonly DataPlaneAuthorityProjection Empty = new(null, [], [], new Dictionary>()); } /// Resolved scoping decision for a node against an artifact. /// /// None = apply everything (single-cluster / legacy); ScopeTo = filter to ; /// Suppress = apply nothing. /// /// The node's ClusterId when is ScopeTo; otherwise null. public readonly record struct ClusterScope(ClusterFilterMode Mode, string? ClusterId); /// /// Static, blob-in / result-out facade over . Every entry point /// here parses the blob, answers ONE question, and throws the parsed document away — so an apply that /// asks several questions of the same bytes pays for several full builds. /// Prefer when more than one answer is needed from the same artifact; these wrappers /// exist so the one-shot call sites (and every existing test) keep their exact shape and their lenient /// empty/malformed contract. /// public static class DeploymentArtifact { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, }; /// /// Parse ONCE into a reusable , so /// every consumer of a single apply reads the same instead of rebuilding /// it per question. Dispose it when the apply completes. /// /// /// /// This is the ONLY place an artifact blob is decompressed. ConfigComposer gzips the /// serialised snapshot BEFORE hashing it, so the compressed bytes are the artifact everywhere: /// Deployment.ArtifactBlob, the LocalDb cache chunks, the gRPC fetch stream, and the /// RevisionHash the fetcher verifies against are all over these exact bytes. Nothing between /// the composer and here looks inside the blob; every reader comes through this method, so a /// gzipped artifact and a pre-upgrade uncompressed one are indistinguishable to every consumer. /// /// /// The deployment artifact bytes, gzip-compressed or raw JSON (sniffed by the gzip /// magic 1F 8B, which valid JSON can never start with). Raw-JSON bytes are not copied — the returned /// artifact reads directly from this array, which must not be mutated for its lifetime (artifact blobs never /// are: they are deployment-immutable bytes read from SQL, the LocalDb cache, or a config fetch). A gzipped /// blob is decompressed into a fresh array the artifact owns. /// The parsed artifact. The caller owns it and must dispose it. /// The blob is not valid JSON, is not decompressible, or inflates past /// . Unlike the static wrappers — which own the lenient "malformed ⇒ /// empty result" contract — opening is explicit about unusable bytes. internal static ParsedDeploymentArtifact Open(byte[] blob) => new(JsonDocument.Parse(Inflate(blob, MaxDecompressedBytes))); /// /// Hard ceiling on the decompressed size of an artifact blob (512 MiB). A corrupt or hostile blob /// must not be able to OOM the node: gzip over repetitive input reaches ~1000:1, so a few-hundred-KB /// row could otherwise inflate to hundreds of megabytes. No honest artifact comes close — the real /// ones are single-digit MB before compression. /// /// /// A deliberately: it used to be a mutable static that the size-guard test /// lowered and restored. xUnit runs test CLASSES in parallel, so that window was visible to every /// sibling class parsing a >4 KB artifact concurrently — a spurious "refusing to inflate" failure /// with no relation to the test that caused it. The limit is a parameter /// instead, so the guard is provable at any ceiling without any process-wide mutation. /// internal const int MaxDecompressedBytes = 512 * 1024 * 1024; /// /// Return the artifact's JSON bytes, decompressing when the blob carries the gzip magic /// 1F 8B at offset 0. Raw-JSON blobs (everything written before compression shipped) are /// returned as-is — a JSON document can never begin with 0x1F, so the sniff is unambiguous /// and no framing of our own is needed. /// /// /// /// gzip output is NOT guaranteed byte-stable across .NET releases. That is deliberately harmless /// here: the revision hash is computed FROM the emitted bytes at compose time and is never /// re-derived from the JSON, so the worst an implementation change can cost is one spurious /// "changed" revision on the first compose after a runtime upgrade. /// /// /// Every failure — corrupt stream, truncated stream, over-limit inflation — is reported as /// so it lands in the exact catch the malformed-JSON path already /// owns: the static wrappers degrade to empty results, and the apply paths fail the apply and /// keep last-known-good (#485 "no bytes is no answer"). Unusable bytes are unusable bytes; the /// reason they are unusable does not change any caller's decision. /// /// /// The stored/wire artifact bytes. /// Ceiling on the inflated size. Production always passes /// via ; the parameter exists so the guard can be /// proved at a small ceiling without mutating process-wide state that parallel test classes share. /// The JSON bytes to parse. /// The gzip stream is corrupt or inflates past . internal static byte[] Inflate(byte[] blob, int maxBytes) { if (blob.Length < 2 || blob[0] != 0x1F || blob[1] != 0x8B) return blob; var limit = maxBytes; try { using var input = new MemoryStream(blob, writable: false); using var gzip = new GZipStream(input, CompressionMode.Decompress); using var output = new MemoryStream((int)Math.Min(blob.Length * 4L, 1 << 20)); var buffer = ArrayPool.Shared.Rent(64 * 1024); try { int read; while ((read = gzip.Read(buffer, 0, buffer.Length)) > 0) { if (output.Length + read > limit) { throw new JsonException( $"Deployment artifact decompresses to more than the {limit}-byte limit; refusing to inflate it."); } output.Write(buffer, 0, read); } } finally { ArrayPool.Shared.Return(buffer); } return output.ToArray(); } catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException or IOException) { throw new JsonException("Deployment artifact is not a readable gzip stream.", ex); } } /// /// Parse a deployment artifact blob into the list of driver-instance specs to spawn. /// Empty / malformed blobs return an empty list — callers log + treat as "no drivers". /// /// The deployment artifact blob to parse. /// The parsed driver-instance specs, or an empty list for empty/malformed blobs. public static IReadOnlyList ParseDriverInstances(ReadOnlySpan blob) { if (blob.IsEmpty) return Array.Empty(); try { using var parsed = Open(blob.ToArray()); return parsed.ParseDriverInstances(); } catch (JsonException) { return Array.Empty(); } } /// /// Resolve how a node should scope a deployment artifact to its own ClusterId. Single-cluster /// (or legacy) artifacts resolve to so every existing /// deployment applies unchanged. In a multi-cluster artifact the node's ClusterNode row /// (matched by ) selects its /// ClusterId; a missing row resolves to . Empty / /// malformed blobs resolve to (lenient, matching the /// other parsers). /// /// The deployment artifact blob to inspect. /// The node's identity (e.g. a "host:port" string) to match against Nodes. /// The resolved decision for this node. public static ClusterScope ResolveClusterScope(ReadOnlySpan blob, string nodeId) { if (blob.IsEmpty) return new ClusterScope(ClusterFilterMode.None, null); try { using var parsed = Open(blob.ToArray()); return parsed.ResolveClusterScope(nodeId); } catch (JsonException) { return new ClusterScope(ClusterFilterMode.None, null); } } /// /// Parse a deployment artifact blob into the driver-instance specs a specific node should /// spawn, scoped to its own ClusterId via . Single-cluster / /// legacy artifacts return every spec; a multi-cluster artifact returns only the matching /// cluster's specs (or none when the node's row is absent). /// /// The deployment artifact blob to parse. /// The node's identity (e.g. a "host:port" string) used to resolve cluster scope. /// The driver-instance specs this node should spawn. public static IReadOnlyList ParseDriverInstances(ReadOnlySpan blob, string nodeId) { if (blob.IsEmpty) return Array.Empty(); try { using var parsed = Open(blob.ToArray()); return parsed.ParseDriverInstances(nodeId); } catch (JsonException) { return Array.Empty(); } } /// /// Parse the data-plane authorization inputs a node needs to authenticate OPC UA sessions: /// the ApiKeys the artifact carries and the NodeAcls granting their principals /// permissions (#520, stage 4). See /// for the scoping + leniency contract. /// /// The deployment artifact blob to parse. /// The node's identity, used to resolve cluster scope for the grants. /// The node's cluster (null when single-cluster), its keys, and its cluster's grants. public static DataPlaneAuthorityProjection ParseDataPlaneAuthority(ReadOnlySpan blob, string nodeId) { if (blob.IsEmpty) return DataPlaneAuthorityProjection.Empty; try { using var parsed = Open(blob.ToArray()); return parsed.ParseDataPlaneAuthority(nodeId); } catch (JsonException) { return DataPlaneAuthorityProjection.Empty; } } /// /// Parse the artifact into the projected used by /// AddressSpacePlanner + AddressSpaceApplier. Returns an empty composition for empty/ /// malformed blobs so callers can treat parse failure as a no-op deploy. /// /// The deployment artifact blob to parse. /// The projected , or an empty composition for empty/malformed blobs. public static AddressSpaceComposition ParseComposition(ReadOnlySpan blob) { if (blob.IsEmpty) return ParsedDeploymentArtifact.Empty(); try { using var parsed = Open(blob.ToArray()); return parsed.ParseComposition(); } catch (JsonException) { return ParsedDeploymentArtifact.Empty(); } } /// Cluster-scoped overload: the address-space composition a node should materialise given /// its NodeId. See . /// The deployment artifact blob. /// This node's identity in "host:port" form. /// Optional diagnostic callback for cross-cluster orphan bindings; null disables the check. /// The filtered composition per the node's scoping decision. public static AddressSpaceComposition ParseComposition( ReadOnlySpan blob, string nodeId, Action? onInconsistency = null) { if (blob.IsEmpty) return ParsedDeploymentArtifact.Empty(); try { using var parsed = Open(blob.ToArray()); return parsed.ParseComposition(nodeId, onInconsistency); } catch (JsonException) { return ParsedDeploymentArtifact.Empty(); } } } /// /// A deployment artifact parsed ONCE — one serving every consumer of a single /// apply (driver specs, cluster scope, data-plane authority, address-space composition), instead of the /// full copy-and-reparse each static entry point on pays per call. /// /// /// /// Open it with at the top of an apply and dispose it when the /// apply completes. Everything it returns is fully materialised (records, lists, strings) and holds /// no reference back into the document, so results stay valid after disposal. /// /// /// Not thread-safe: the per-node memo is an unsynchronised /// dictionary. An apply resolves its consumers sequentially, which is the only intended use. /// /// /// Internal on purpose. Every consumer is inside this assembly (the apply paths in /// DriverHostActor and OpcUaPublishActor, plus the tests via InternalsVisibleTo), /// and the lenient static wrappers on are the cross-assembly door. /// Keeping the disposable, non-thread-safe, document-owning type unexported is what makes the /// "results survive disposal" contract enforceable rather than merely documented. /// /// internal sealed class ParsedDeploymentArtifact : IDisposable { private readonly JsonDocument _document; // Every consumer of one apply resolves the SAME node id, so this memo turns N identical scope scans // into one. Keyed ordinally: the scan itself matches node ids case-insensitively, so a differently // cased key simply misses the memo and re-scans to the same answer — never a wrong one. private readonly Dictionary _scopeByNodeId = new(StringComparer.Ordinal); private bool _disposed; /// Takes ownership of an already-parsed artifact document. Use /// ; this exists so the document is parsed in exactly one place. /// The parsed artifact document, disposed with this instance. internal ParsedDeploymentArtifact(JsonDocument document) => _document = document; /// /// The artifact's root JSON object. Valid only until — do not retain it, /// and do not hand it outside this type. Unlike everything the parse methods return (fully /// materialised records, lists and strings that hold no reference back into the document), a /// is a live view over the disposed-with-us : /// reading one after disposal throws, or worse, reads a recycled pooled buffer. Internal so that /// escape hatch cannot be taken from outside the assembly. /// internal JsonElement Root => _document.RootElement; /// Releases the underlying . Idempotent. public void Dispose() { if (_disposed) return; _disposed = true; _document.Dispose(); } /// /// The list of driver-instance specs to spawn, from the artifact's DriverInstances array. /// An artifact with no such array yields an empty list. /// /// The parsed driver-instance specs. public IReadOnlyList ParseDriverInstances() { var root = Root; if (!root.TryGetProperty("DriverInstances", out var arr) || arr.ValueKind != JsonValueKind.Array) { return Array.Empty(); } // v3: each spec's DriverConfig is the MERGED config the driver factory binds from — // DriverConfig (protocol/channel) + per-Device DeviceConfig (endpoint) + the driver's authored // raw tags (RawTags array, keyed by RawPath) — folded once here via DriverDeviceConfigMerger so // the factory (TryCreate) and the init (InitializeRequested) hand the driver the same shape. var rawTagsByDriver = BuildRawTagsByDriver(root); var deviceRowsByDriver = BuildDeviceRowsByDriver(root); var result = new List(arr.GetArrayLength()); foreach (var el in arr.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; var spec = TryReadSpec(el, rawTagsByDriver, deviceRowsByDriver); if (spec is not null) result.Add(spec); } return result; } /// Flatten the artifact's raw topology (RawFolders / DriverInstances / Devices / TagGroups / /// Tags) into each driver's authored list, keyed by DriverInstanceId. Each /// tag's RawPath is computed via the shared (the single identity authority), /// so the deploy-decode side agrees byte-for-byte with the authoring/validation side. A tag whose /// device/driver chain is broken (unknown link) is dropped. /// The artifact root element. /// DriverInstanceId → authored raw tags (RawPath + TagConfig + WriteIdempotent + DeviceName). private static Dictionary> BuildRawTagsByDriver(JsonElement root) { var byDriver = new Dictionary>(StringComparer.Ordinal); var folders = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "RawFolders")) { var id = ReadString(el, "RawFolderId"); if (string.IsNullOrWhiteSpace(id)) continue; folders[id!] = (ReadNullableString(el, "ParentRawFolderId"), ReadString(el, "Name") ?? id!); } var drivers = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "DriverInstances")) { var id = ReadString(el, "DriverInstanceId"); if (string.IsNullOrWhiteSpace(id)) continue; drivers[id!] = (ReadNullableString(el, "RawFolderId"), ReadString(el, "Name") ?? id!); } var deviceToDriver = new Dictionary(StringComparer.Ordinal); var devices = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "Devices")) { var id = ReadString(el, "DeviceId"); var di = ReadString(el, "DriverInstanceId"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue; var name = ReadString(el, "Name") ?? id!; devices[id!] = (di!, name); deviceToDriver[id!] = di!; } var groups = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "TagGroups")) { var id = ReadString(el, "TagGroupId"); if (string.IsNullOrWhiteSpace(id)) continue; groups[id!] = (ReadNullableString(el, "ParentTagGroupId"), ReadString(el, "Name") ?? id!); } var resolver = new RawPathResolver(folders, drivers, devices, groups); // scriptId → SourceCode, from the artifact's Scripts array — the same join the VirtualTag plan // builder uses. A calc tag's TagConfig carries a scriptId (not the source); the calc driver is // host-blind and can't resolve it, so we inject the resolved `scriptSource` into the delivered // TagConfig here (the deploy-side counterpart of the Calculation driver). Tags with no scriptId // are untouched. var scriptSourceById = BuildScriptSourceMap(root); foreach (var el in EnumerateArray(root, "Tags")) { var deviceId = ReadString(el, "DeviceId"); var name = ReadString(el, "Name"); if (string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name)) continue; if (!deviceToDriver.TryGetValue(deviceId!, out var driverInstanceId)) continue; var tagGroupId = ReadNullableString(el, "TagGroupId"); var rawPath = resolver.TryBuildTagPath(deviceId!, tagGroupId, name!); if (rawPath is null) continue; // broken chain — dropped (deploy gate rejects invalid names) var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String ? tcEl.GetString() ?? "{}" : "{}"; tagConfig = InjectScriptSource(tagConfig, scriptSourceById); // WriteIdempotent (bool; non-bool/absent ⇒ false — the safe R2 default: writes are non-idempotent). var writeIdempotent = el.TryGetProperty("WriteIdempotent", out var wiEl) && wiEl.ValueKind is JsonValueKind.True or JsonValueKind.False && wiEl.GetBoolean(); var deviceName = devices.TryGetValue(deviceId!, out var dev) ? dev.Name : string.Empty; if (!byDriver.TryGetValue(driverInstanceId, out var list)) byDriver[driverInstanceId] = list = new List(); list.Add(new RawTagEntry(rawPath, tagConfig, writeIdempotent, deviceName)); } // Deterministic ordering per driver (ordinal RawPath) so the injected config is byte-stable. foreach (var list in byDriver.Values) list.Sort((a, b) => string.CompareOrdinal(a.RawPath, b.RawPath)); return byDriver; } /// /// Build the per-equipment {{equip}}/<RefName> reference map — equipmentId → /// (effectiveName → backing-tag RawPath) — from the artifact's raw topology + Tags + /// UnsTagReferences via the shared + . /// The JSON-side mirror of AddressSpaceComposer.BuildEquipmentReferenceMap (entity side), so /// both compose seams resolve {{equip}} script paths to byte-identical RawPaths. Empty when /// the artifact carries no references / tags. /// /// The artifact root element. /// equipmentId → (effectiveName → RawPath). private static IReadOnlyDictionary> BuildEquipmentReferenceMap(JsonElement root) { var folders = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "RawFolders")) { var id = ReadString(el, "RawFolderId"); if (string.IsNullOrWhiteSpace(id)) continue; folders[id!] = (ReadNullableString(el, "ParentRawFolderId"), ReadString(el, "Name") ?? id!); } var drivers = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "DriverInstances")) { var id = ReadString(el, "DriverInstanceId"); if (string.IsNullOrWhiteSpace(id)) continue; drivers[id!] = (ReadNullableString(el, "RawFolderId"), ReadString(el, "Name") ?? id!); } var devices = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "Devices")) { var id = ReadString(el, "DeviceId"); var di = ReadString(el, "DriverInstanceId"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue; devices[id!] = (di!, ReadString(el, "Name") ?? id!); } var groups = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "TagGroups")) { var id = ReadString(el, "TagGroupId"); if (string.IsNullOrWhiteSpace(id)) continue; groups[id!] = (ReadNullableString(el, "ParentTagGroupId"), ReadString(el, "Name") ?? id!); } var resolver = new RawPathResolver(folders, drivers, devices, groups); var tagsById = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "Tags")) { var tagId = ReadString(el, "TagId"); var deviceId = ReadString(el, "DeviceId"); var name = ReadString(el, "Name"); if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name)) continue; tagsById[tagId!] = new EquipmentReferenceMap.TagRow(name!, deviceId!, ReadNullableString(el, "TagGroupId")); } var references = new List(); foreach (var el in EnumerateArray(root, "UnsTagReferences")) { var refId = ReadString(el, "UnsTagReferenceId"); var equipmentId = ReadString(el, "EquipmentId"); var tagId = ReadString(el, "TagId"); if (string.IsNullOrWhiteSpace(refId) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(tagId)) continue; references.Add(new EquipmentReferenceMap.ReferenceRow( refId!, equipmentId!, tagId!, ReadNullableString(el, "DisplayNameOverride"))); } if (references.Count == 0 || tagsById.Count == 0) return new Dictionary>(StringComparer.Ordinal); return EquipmentReferenceMap.Build(references, tagsById, resolver); } /// /// v3 Batch 4 — reconstruct the raw/UNS entities from the artifact JSON and drive the pure /// , returning ONLY the three dual-namespace lists /// ( / / /// ). Reusing the composer verbatim is what /// guarantees BYTE-PARITY between the live-edit compose seam and this artifact-decode seam — the same /// RawPathResolver / RawPaths / TagConfigIntent / V3NodeIds authorities /// produce every RawPath, UNS NodeId, and Organizes backing path. Enums serialize numerically in the /// artifact (no JsonStringEnumConverter), so AccessLevel is read as an int. /// /// The artifact root element. /// The Raw containers, Raw tags, and UNS reference variables. private static (IReadOnlyList Containers, IReadOnlyList Tags, IReadOnlyList UnsRefs) BuildRawAndUnsSubtrees(JsonElement root) { var rawFolders = new List(); foreach (var el in EnumerateArray(root, "RawFolders")) { var id = ReadString(el, "RawFolderId"); if (string.IsNullOrWhiteSpace(id)) continue; rawFolders.Add(new RawFolder { RawFolderId = id!, ParentRawFolderId = ReadNullableString(el, "ParentRawFolderId"), Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty, }); } var driverInstances = new List(); foreach (var el in EnumerateArray(root, "DriverInstances")) { var id = ReadString(el, "DriverInstanceId"); if (string.IsNullOrWhiteSpace(id)) continue; driverInstances.Add(new DriverInstance { DriverInstanceId = id!, RawFolderId = ReadNullableString(el, "RawFolderId"), Name = ReadString(el, "Name") ?? id!, DriverType = ReadString(el, "DriverType") ?? string.Empty, DriverConfig = ReadString(el, "DriverConfig") ?? "{}", ClusterId = ReadString(el, "ClusterId") ?? string.Empty, }); } var devices = new List(); foreach (var el in EnumerateArray(root, "Devices")) { var id = ReadString(el, "DeviceId"); var di = ReadString(el, "DriverInstanceId"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue; devices.Add(new Device { DeviceId = id!, DriverInstanceId = di!, Name = ReadString(el, "Name") ?? id!, DeviceConfig = ReadString(el, "DeviceConfig") ?? "{}", }); } var tagGroups = new List(); foreach (var el in EnumerateArray(root, "TagGroups")) { var id = ReadString(el, "TagGroupId"); if (string.IsNullOrWhiteSpace(id)) continue; tagGroups.Add(new TagGroup { TagGroupId = id!, ParentTagGroupId = ReadNullableString(el, "ParentTagGroupId"), DeviceId = ReadString(el, "DeviceId") ?? string.Empty, Name = ReadString(el, "Name") ?? id!, }); } var tags = new List(); foreach (var el in EnumerateArray(root, "Tags")) { var id = ReadString(el, "TagId"); var deviceId = ReadString(el, "DeviceId"); var name = ReadString(el, "Name"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name)) continue; var accessLevel = el.TryGetProperty("AccessLevel", out var alEl) && alEl.ValueKind == JsonValueKind.Number ? (TagAccessLevel)alEl.GetInt32() : TagAccessLevel.Read; var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String ? tcEl.GetString() ?? "{}" : "{}"; tags.Add(new Tag { TagId = id!, DeviceId = deviceId!, TagGroupId = ReadNullableString(el, "TagGroupId"), Name = name!, DataType = ReadString(el, "DataType") ?? "Float", AccessLevel = accessLevel, TagConfig = tagConfig, }); } var unsTagReferences = new List(); foreach (var el in EnumerateArray(root, "UnsTagReferences")) { var id = ReadString(el, "UnsTagReferenceId"); var equipmentId = ReadString(el, "EquipmentId"); var tagId = ReadString(el, "TagId"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(tagId)) continue; unsTagReferences.Add(new UnsTagReference { UnsTagReferenceId = id!, EquipmentId = equipmentId!, TagId = tagId!, DisplayNameOverride = ReadNullableString(el, "DisplayNameOverride"), }); } var unsAreas = new List(); foreach (var el in EnumerateArray(root, "UnsAreas")) { var id = ReadString(el, "UnsAreaId"); if (string.IsNullOrWhiteSpace(id)) continue; unsAreas.Add(new UnsArea { UnsAreaId = id!, Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty }); } var unsLines = new List(); foreach (var el in EnumerateArray(root, "UnsLines")) { var id = ReadString(el, "UnsLineId"); if (string.IsNullOrWhiteSpace(id)) continue; unsLines.Add(new UnsLine { UnsLineId = id!, UnsAreaId = ReadString(el, "UnsAreaId") ?? string.Empty, Name = ReadString(el, "Name") ?? id! }); } var equipment = new List(); foreach (var el in EnumerateArray(root, "Equipment")) { var id = ReadString(el, "EquipmentId"); if (string.IsNullOrWhiteSpace(id)) continue; equipment.Add(new Equipment { EquipmentId = id!, UnsLineId = ReadString(el, "UnsLineId") ?? string.Empty, Name = ReadString(el, "Name") ?? id!, // MachineCode is a required member but is not used by the raw/UNS compose path (it drives no // NodeId); read it when present, else a placeholder so the object initializer is satisfied. MachineCode = ReadString(el, "MachineCode") ?? id!, }); } var composition = AddressSpaceComposer.Compose( unsAreas, unsLines, equipment, driverInstances, Array.Empty(), unsTagReferences: unsTagReferences, tags: tags, rawFolders: rawFolders, devices: devices, tagGroups: tagGroups); return (composition.RawContainers, composition.RawTags, composition.UnsReferenceVariables); } /// Build the scriptId → SourceCode map from the artifact's Scripts array (mirrors /// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts. private static Dictionary BuildScriptSourceMap(JsonElement root) { var byId = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "Scripts")) { var sid = ReadString(el, "ScriptId"); if (string.IsNullOrWhiteSpace(sid)) continue; byId[sid!] = el.TryGetProperty("SourceCode", out var srcEl) && srcEl.ValueKind == JsonValueKind.String ? srcEl.GetString() ?? string.Empty : string.Empty; } return byId; } /// /// When carries a scriptId (a calc tag), inject the resolved /// scriptSource so the host-blind Calculation driver can compile + evaluate it. Identity for /// tags with no scriptId (every other driver's tags), an unresolvable id, or malformed JSON — /// so no existing tag's blob changes. The pre-existing scriptId/trigger fields are preserved. /// /// The tag's raw TagConfig JSON. /// The scriptId → SourceCode map from the artifact. /// The TagConfig with scriptSource injected, or the original when nothing to inject. private static string InjectScriptSource(string tagConfig, IReadOnlyDictionary scriptSourceById) { if (scriptSourceById.Count == 0 || string.IsNullOrWhiteSpace(tagConfig)) return tagConfig; JsonNode? node; try { node = JsonNode.Parse(tagConfig); } catch (JsonException) { return tagConfig; } if (node is not JsonObject obj) return tagConfig; var scriptId = obj.TryGetPropertyValue("scriptId", out var sidNode) && sidNode is JsonValue sv && sv.TryGetValue(out var sid) ? sid : null; if (string.IsNullOrWhiteSpace(scriptId) || !scriptSourceById.TryGetValue(scriptId!, out var source)) return tagConfig; obj["scriptSource"] = source; return obj.ToJsonString(); } /// Group the artifact's Devices into per-driver /// lists (entity Name + schemaless DeviceConfig), ordered by DeviceId for a stable merge. /// The artifact root element. /// DriverInstanceId → ordered device rows. private static Dictionary> BuildDeviceRowsByDriver(JsonElement root) { var byDriver = new Dictionary>(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "Devices")) { var id = ReadString(el, "DeviceId"); var di = ReadString(el, "DriverInstanceId"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue; var name = ReadString(el, "Name") ?? id!; var deviceConfig = ReadString(el, "DeviceConfig"); if (!byDriver.TryGetValue(di!, out var list)) byDriver[di!] = list = new List<(string, DriverDeviceConfigMerger.DeviceRow)>(); list.Add((id!, new DriverDeviceConfigMerger.DeviceRow(name, deviceConfig))); } return byDriver.ToDictionary( kv => kv.Key, kv => kv.Value.OrderBy(x => x.DeviceId, StringComparer.Ordinal).Select(x => x.Row).ToList(), StringComparer.Ordinal); } /// Enumerate an artifact array property's object elements (empty when absent/non-array). private static IEnumerable EnumerateArray(JsonElement root, string property) { if (!root.TryGetProperty(property, out var arr) || arr.ValueKind != JsonValueKind.Array) yield break; foreach (var el in arr.EnumerateArray()) if (el.ValueKind == JsonValueKind.Object) yield return el; } /// Reads a string property, coalescing a JSON null (or wrong type) to . private static string? ReadNullableString(JsonElement el, string property) => el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; /// /// Resolve how a node should scope this artifact to its own ClusterId. Single-cluster /// (or legacy) artifacts resolve to so every existing /// deployment applies unchanged. In a multi-cluster artifact the node's ClusterNode row /// (matched by ) selects its /// ClusterId; a missing row resolves to . /// Memoized per node id — every consumer of one apply asks about the same node. /// /// The node's identity (e.g. a "host:port" string) to match against Nodes. /// The resolved decision for this node. public ClusterScope ResolveClusterScope(string nodeId) { if (_scopeByNodeId.TryGetValue(nodeId, out var cached)) return cached; var resolved = ResolveClusterScopeCore(nodeId); _scopeByNodeId[nodeId] = resolved; return resolved; } private ClusterScope ResolveClusterScopeCore(string nodeId) { var root = Root; var clusterCount = root.TryGetProperty("Clusters", out var cl) && cl.ValueKind == JsonValueKind.Array ? cl.GetArrayLength() : 0; if (clusterCount <= 1) return new ClusterScope(ClusterFilterMode.None, null); string? myCluster = null; if (root.TryGetProperty("Nodes", out var nodes) && nodes.ValueKind == JsonValueKind.Array) { foreach (var el in nodes.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; var nid = el.TryGetProperty("NodeId", out var nEl) ? nEl.GetString() : null; if (!string.Equals(nid, nodeId, StringComparison.OrdinalIgnoreCase)) continue; myCluster = el.TryGetProperty("ClusterId", out var cEl) ? cEl.GetString() : null; break; } } return string.IsNullOrWhiteSpace(myCluster) ? new ClusterScope(ClusterFilterMode.Suppress, null) : new ClusterScope(ClusterFilterMode.ScopeTo, myCluster); } /// /// The driver-instance specs a specific node should spawn, scoped to its own ClusterId via /// . Single-cluster / legacy artifacts return every spec; /// a multi-cluster artifact returns only the matching cluster's specs (or none when the node's row /// is absent). /// /// The node's identity (e.g. a "host:port" string) used to resolve cluster scope. /// The driver-instance specs this node should spawn. public IReadOnlyList ParseDriverInstances(string nodeId) { var scope = ResolveClusterScope(nodeId); if (scope.Mode == ClusterFilterMode.Suppress) return Array.Empty(); var all = ParseDriverInstances(); return scope.Mode == ClusterFilterMode.ScopeTo ? all.Where(s => string.Equals(s.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase)).ToArray() : all; } /// /// The data-plane authorization inputs a node needs to authenticate OPC UA sessions: /// the ApiKeys the artifact carries and the NodeAcls granting their principals /// permissions (#520, stage 4). /// /// /// /// This is the ONLY key source a driver-only node has — per-cluster mesh Phase 4 left /// it with no ConfigDb, so without this it denies every UserName token. /// /// /// Grants are scoped to the node's own cluster (); keys /// are not, because ApiKey carries no ClusterId — a key is a fleet-wide credential and it /// is the ACL rows that bind it to a cluster's nodes. A /// scope — the node's row is absent from a multi-cluster artifact — yields NO grants, matching /// the driver-spawn path's reading that an unlisted node was not deployed to. /// /// /// Lenient like every other parser here: an artifact carrying neither section yields empty /// lists. The caller must NOT treat that as "no keys are valid" and install it — see issue /// #485; the apply paths only reach this with bytes that already passed their own guard. /// /// /// The node's identity, used to resolve cluster scope for the grants. /// The node's cluster (null when single-cluster), its keys, and its cluster's grants. public DataPlaneAuthorityProjection ParseDataPlaneAuthority(string nodeId) { var scope = ResolveClusterScope(nodeId); var root = Root; var keys = new List(); foreach (var el in EnumerateArray(root, "ApiKeys")) { var key = TryReadApiKey(el); if (key is not null) keys.Add(key); } var grants = new List(); foreach (var el in EnumerateArray(root, "NodeAcls")) { var acl = TryReadNodeAcl(el); if (acl is null) continue; if (scope.Mode == ClusterFilterMode.Suppress) continue; if (scope.Mode == ClusterFilterMode.ScopeTo && !string.Equals(acl.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase)) { continue; } grants.Add(acl); } return new DataPlaneAuthorityProjection( scope.ClusterId, keys, grants, BuildAclScopePaths(root, grants)); } /// /// Resolve every scope named by against the artifact's own raw + UNS /// topology, using the same RawPathResolver the address space is built through — so an ACL /// path and the RawPath that forms a node's NodeId cannot drift apart. /// private static IReadOnlyDictionary> BuildAclScopePaths( JsonElement root, IReadOnlyList grants) { if (grants.Count == 0) return new Dictionary>(); var folders = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "RawFolders")) { var id = ReadString(el, "RawFolderId"); if (id is null) continue; folders[id] = (ReadNullableString(el, "ParentRawFolderId"), ReadString(el, "Name") ?? id); } var drivers = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "DriverInstances")) { var id = ReadString(el, "DriverInstanceId"); if (id is null) continue; drivers[id] = (ReadNullableString(el, "RawFolderId"), ReadString(el, "Name") ?? id); } var devices = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "Devices")) { var id = ReadString(el, "DeviceId"); var driverId = ReadString(el, "DriverInstanceId"); if (id is null || driverId is null) continue; devices[id] = (driverId, ReadString(el, "Name") ?? id); } var groups = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "TagGroups")) { var id = ReadString(el, "TagGroupId"); var deviceId = ReadString(el, "DeviceId"); if (id is null || deviceId is null) continue; groups[id] = (ReadNullableString(el, "ParentTagGroupId"), deviceId, ReadString(el, "Name") ?? id); } var tags = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "Tags")) { var id = ReadString(el, "TagId"); var deviceId = ReadString(el, "DeviceId"); if (id is null || deviceId is null) continue; tags[id] = (deviceId, ReadNullableString(el, "TagGroupId"), ReadString(el, "Name") ?? id); } var areas = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "UnsAreas")) { var id = ReadString(el, "UnsAreaId"); if (id is null) continue; areas[id] = ReadString(el, "Name") ?? id; } var lines = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "UnsLines")) { var id = ReadString(el, "UnsLineId"); var areaId = ReadString(el, "UnsAreaId"); if (id is null || areaId is null) continue; lines[id] = (areaId, ReadString(el, "Name") ?? id); } var equipment = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "Equipment")) { var id = ReadString(el, "EquipmentId"); var lineId = ReadString(el, "UnsLineId"); if (id is null || lineId is null) continue; equipment[id] = (lineId, ReadString(el, "Name") ?? id); } var references = new List<(string EquipmentId, string TagId, string EffectiveName)>(); foreach (var el in EnumerateArray(root, "UnsTagReferences")) { var equipmentId = ReadString(el, "EquipmentId"); var tagId = ReadString(el, "TagId"); if (equipmentId is null || tagId is null) continue; // Effective name = the override when present, else the backing raw tag's own name. This is the // same rule the address space materializes UNS variables under; a reference whose tag is // missing from the artifact has no resolvable name and is skipped. var effective = ReadNullableString(el, "DisplayNameOverride"); if (string.IsNullOrWhiteSpace(effective)) effective = tags.TryGetValue(tagId, out var t) ? t.Name : null; if (effective is null) continue; references.Add((equipmentId, tagId, effective)); } var resolver = new AclScopePathResolver( folders, drivers, devices, groups, tags, areas, lines, equipment, references); return resolver.Resolve(grants); } /// Reads one ApiKeys element; null when a field the credential check depends on is /// missing, so a half-written row can never authenticate. private static ApiKey? TryReadApiKey(JsonElement el) { var apiKeyId = ReadString(el, "ApiKeyId"); var secretHash = ReadString(el, "SecretHash"); var principal = ReadString(el, "Principal"); if (string.IsNullOrWhiteSpace(apiKeyId) || string.IsNullOrWhiteSpace(secretHash) || string.IsNullOrWhiteSpace(principal)) { return null; } return new ApiKey { ApiKeyId = apiKeyId, Name = ReadString(el, "Name") ?? apiKeyId, SecretHash = secretHash, Principal = principal, // A row whose Enabled flag did not survive serialisation is treated as REVOKED. The other // direction would resurrect a revoked key out of a malformed artifact. Enabled = ReadBool(el, "Enabled") ?? false, CreatedUtc = ReadDateTime(el, "CreatedUtc") ?? default, ExpiresUtc = ReadDateTime(el, "ExpiresUtc"), Notes = ReadNullableString(el, "Notes"), }; } /// Reads one NodeAcls element; null when identity, cluster or scope kind is unreadable. private static NodeAcl? TryReadNodeAcl(JsonElement el) { var nodeAclId = ReadString(el, "NodeAclId"); var clusterId = ReadString(el, "ClusterId"); var principal = ReadString(el, "Principal"); if (string.IsNullOrWhiteSpace(nodeAclId) || string.IsNullOrWhiteSpace(clusterId) || string.IsNullOrWhiteSpace(principal)) { return null; } // ConfigComposer serialises enums as NUMBERS (its JsonSerializerOptions carries no // JsonStringEnumConverter), so these read as int. Keep the two sides in step — a string-enum // converter added to the composer would silently drop every grant here. var scopeKind = ReadInt32(el, "ScopeKind"); if (scopeKind is null || !Enum.IsDefined(typeof(NodeAclScopeKind), scopeKind.Value)) return null; return new NodeAcl { NodeAclId = nodeAclId, ClusterId = clusterId, Principal = principal, ScopeKind = (NodeAclScopeKind)scopeKind.Value, ScopeId = ReadNullableString(el, "ScopeId"), // An unreadable bitmask grants nothing rather than defaulting to something permissive. PermissionFlags = (NodePermissions)(ReadInt32(el, "PermissionFlags") ?? 0), Notes = ReadNullableString(el, "Notes"), }; } /// Reads a boolean property; null when absent or not a JSON boolean. private static bool? ReadBool(JsonElement el, string property) => el.TryGetProperty(property, out var p) ? p.ValueKind switch { JsonValueKind.True => true, JsonValueKind.False => false, _ => null, } : null; /// Reads an int property; null when absent, not a number, or out of int range. private static int? ReadInt32(JsonElement el, string property) => el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.Number && p.TryGetInt32(out var v) ? v : null; /// Reads a UTC DateTime property; null when absent, JSON null, or unparseable. private static DateTime? ReadDateTime(JsonElement el, string property) { if (!el.TryGetProperty(property, out var p) || p.ValueKind != JsonValueKind.String) return null; if (!p.TryGetDateTime(out var v)) return null; // Normalise to UTC, because every consumer compares against DateTime.UtcNow and an expiry that // silently shifted by the node's timezone is a key that outlives (or predeceases) its expiry. // Unspecified must be SpecifyKind, NOT ToUniversalTime: EF materialises datetime2 as Unspecified, // so the composer writes these UTC instants without an offset and ToUniversalTime would subtract // the node's local offset from a value that was already UTC. return v.Kind switch { DateTimeKind.Utc => v, DateTimeKind.Local => v.ToUniversalTime(), _ => DateTime.SpecifyKind(v, DateTimeKind.Utc), }; } /// Reads a string property, tolerating a wrong JSON type: a property that is present but /// NOT a JSON string (number / bool / object / array) yields null rather than throwing /// from GetString(). Keeps the artifact-decode lenient /// — a wrong-typed field degrades to the field default / a skipped row, matching the documented /// "malformed blobs return an empty list/composition" contract. private static string? ReadString(JsonElement el, string property) => el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; private static DriverInstanceSpec? TryReadSpec( JsonElement el, IReadOnlyDictionary> rawTagsByDriver, IReadOnlyDictionary> deviceRowsByDriver) { var rowId = el.TryGetProperty("DriverInstanceRowId", out var rowEl) && rowEl.TryGetGuid(out var rid) ? rid : Guid.Empty; var id = ReadString(el, "DriverInstanceId"); var name = ReadString(el, "Name"); var type = ReadString(el, "DriverType"); // A non-bool Enabled token degrades to the field default (true = "desired here"), never throws. var enabled = !el.TryGetProperty("Enabled", out var enEl) || enEl.ValueKind is not (JsonValueKind.True or JsonValueKind.False) || enEl.GetBoolean(); var driverConfig = ReadString(el, "DriverConfig"); var clusterId = ReadString(el, "ClusterId"); // Per-instance resilience overrides (DriverInstance.ResilienceConfig column). Optional/nullable — // absent means tier defaults; layered onto the tier by DriverResilienceOptionsParser at spawn. var resilienceConfig = ReadString(el, "ResilienceConfig"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type)) return null; // v3 endpoint→DeviceConfig + RawTags injection: fold DriverConfig + per-device DeviceConfig + the // driver's authored raw tags into the single config the factory binds from (single-endpoint drivers // get their sole DeviceConfig merged up; multi-device drivers get a reconstructed Devices[] array). var devices = deviceRowsByDriver.TryGetValue(id!, out var dr) ? dr : (IReadOnlyList)Array.Empty(); var rawTags = rawTagsByDriver.TryGetValue(id!, out var rt) ? rt : (IReadOnlyList)Array.Empty(); var mergedConfig = DriverDeviceConfigMerger.Merge(driverConfig, devices, rawTags); return new DriverInstanceSpec( DriverInstanceRowId: rowId, DriverInstanceId: id!, Name: name ?? id!, DriverType: type!, Enabled: enabled, DriverConfig: mergedConfig, ClusterId: clusterId, ResilienceConfig: resilienceConfig); } /// /// Parse the artifact into the projected used by /// AddressSpacePlanner + AddressSpaceApplier. An artifact carrying none of the /// projected arrays yields an empty composition, which callers treat as a no-op deploy. /// /// The artifact JSON is produced by ConfigComposer.SnapshotAndFlattenAsync in the /// ControlPlane — its Pascal-case property names match the EF entities. We only need a /// subset of fields per entity class to drive the address-space rebuild on driver-role /// nodes. /// /// The projected . public AddressSpaceComposition ParseComposition() { var root = Root; { var areas = ReadArray(root, "UnsAreas", ReadAreaProjection); var lines = ReadArray(root, "UnsLines", ReadLineProjection); var equipment = ReadArray(root, "Equipment", ReadEquipmentNode); var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan); var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan); // v3 DARK address space (Batch 1): NO equipment-tag variable plans materialize — the raw + UNS // variable nodes are lit up in Batch 4 (the dual namespace + UNS fan-out). EquipmentTags is // deliberately empty; {{equip}} substitution therefore has no per-equipment tag base (empty). // The retained per-equipment plans (folder hierarchy + VirtualTags + ScriptedAlarms) still exist. var equipmentTags = Array.Empty(); // Per-equipment {{equip}}/ reference map (effectiveName → RawPath), shared by the // VirtualTag + ScriptedAlarm plan builders so both substitute against the same resolved paths. var referenceMapByEquip = BuildEquipmentReferenceMap(root); var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, referenceMapByEquip); var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root, referenceMapByEquip); // v3 Batch 4 — the Raw device subtree + UNS reference variables. Built by reconstructing the raw/UNS // entities from the artifact JSON and driving the SAME pure AddressSpaceComposer the live-edit side // uses, so the artifact-decode seam is BYTE-PARITY with the composer (identical RawPaths, identical // UNS NodeIds, identical Organizes backing paths). Only the three dual-namespace lists are taken from // the composer result; the (already byte-parity) folder/vtag/alarm plans above are kept as-is. var (rawContainers, rawTags, unsReferenceVariables) = BuildRawAndUnsSubtrees(root); return new AddressSpaceComposition(areas, lines, equipment, drivers, alarms) { EquipmentTags = equipmentTags, EquipmentVirtualTags = equipmentVirtualTags, EquipmentScriptedAlarms = equipmentScriptedAlarms, RawContainers = rawContainers, RawTags = rawTags, UnsReferenceVariables = unsReferenceVariables, }; } } /// Cluster-scoped overload: the address-space composition a node should materialise given /// its NodeId. Filters every projection to the node's own ClusterId (see ). /// Equipment attribution is dual-path: driver-bound equipment (non-null DriverInstanceId) is kept when /// its driver is in-cluster; driver-less equipment (null DriverInstanceId) is kept when its UNS line's /// area is in-cluster. /// When is supplied it is invoked with a human-readable message for each /// kept driver-bound equipment whose owning UNS line is NOT in the node's cluster — a cross-cluster binding /// that violates the same-cluster invariant and would orphan the equipment folder. This is /// detection only (observability); the equipment is still returned, since the upstream draft validator /// is the authority that should prevent the binding in the first place. /// This node's identity in "host:port" form. /// Optional diagnostic callback for cross-cluster orphan bindings; null disables the check. /// The filtered composition per the node's scoping decision. public AddressSpaceComposition ParseComposition(string nodeId, Action? onInconsistency = null) { var scope = ResolveClusterScope(nodeId); if (scope.Mode == ClusterFilterMode.None) return ParseComposition(); if (scope.Mode == ClusterFilterMode.Suppress) return Empty(); var full = ParseComposition(); var sets = BuildClusterSets(Root, scope.ClusterId!); var keptLines = full.UnsLines.Where(l => sets.AreaIds.Contains(l.UnsAreaId)).ToArray(); var keptEquipment = full.EquipmentNodes.Where(e => sets.EquipmentIds.Contains(e.EquipmentId)).ToArray(); if (onInconsistency is not null) { var keptLineIds = keptLines.Select(l => l.UnsLineId).ToHashSet(StringComparer.OrdinalIgnoreCase); // This cross-cluster check only fires for DRIVER-BOUND equipment. Driver-less equipment // is attributed by its UNS line's area cluster, so by construction its line is always in // keptLines — the condition `!keptLineIds.Contains(e.UnsLineId)` is never true for it. foreach (var e in keptEquipment) { if (!string.IsNullOrEmpty(e.UnsLineId) && !keptLineIds.Contains(e.UnsLineId)) onInconsistency( $"equipment '{e.EquipmentId}' is in cluster '{scope.ClusterId}' (by its driver) but its " + $"UNS line '{e.UnsLineId}' is not — a cross-cluster binding violates the same-cluster " + "invariant (decision #122) and would orphan the equipment folder."); } } return new AddressSpaceComposition( full.UnsAreas.Where(a => sets.AreaIds.Contains(a.UnsAreaId)).ToArray(), keptLines, keptEquipment, full.DriverInstancePlans.Where(d => sets.DriverIds.Contains(d.DriverInstanceId)).ToArray(), full.ScriptedAlarmPlans.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray()) { EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(), EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(), EquipmentScriptedAlarms = full.EquipmentScriptedAlarms.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(), // v3 Batch 4: raw tags scope by their owning driver's cluster; UNS references by their equipment's // cluster. Raw CONTAINER nodes carry no driver id, so they are kept in full — an out-of-cluster // folder/driver/device/group node materialises only as an empty browse folder (harmless), while every // raw VALUE + UNS reference node is correctly cluster-scoped. RawContainers = full.RawContainers, RawTags = full.RawTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(), UnsReferenceVariables = full.UnsReferenceVariables.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(), }; } /// The in-cluster id sets used to filter a composition. /// DriverInstanceIds whose row carries the in-scope ClusterId. /// UnsAreaIds whose row carries the in-scope ClusterId. /// In-cluster EquipmentIds: driver-bound equipment is attributed by its /// driver's cluster; driver-less equipment (null DriverInstanceId) by its UNS line's area cluster. private sealed record ClusterSets(HashSet DriverIds, HashSet AreaIds, HashSet EquipmentIds); /// Build the in-cluster id sets used to filter a composition: DriverInstanceIds + UnsAreaIds /// that directly carry the ClusterId, plus in-cluster EquipmentIds — driver-bound equipment attributed /// by its driver's cluster, and driver-less equipment (null DriverInstanceId) attributed by its UNS /// line's area cluster. /// The artifact root element — the SAME document every other consumer reads, rather /// than a second parse of the same bytes (this used to re-parse the blob on every scoped compose). /// The node's ClusterId to scope to. /// The resolved in-cluster id sets. private static ClusterSets BuildClusterSets(JsonElement root, string clusterId) { var driverIds = new HashSet(StringComparer.Ordinal); var areaIds = new HashSet(StringComparer.Ordinal); var equipmentIds = new HashSet(StringComparer.Ordinal); { CollectIdsWhereCluster(root, "DriverInstances", "DriverInstanceId", clusterId, driverIds); CollectIdsWhereCluster(root, "UnsAreas", "UnsAreaId", clusterId, areaIds); // Map each UnsLine to its owning UnsArea so driver-less equipment can be attributed via // its line's area cluster (Equipment -> UnsLine.UnsAreaId -> UnsArea.ClusterId). var lineToArea = new Dictionary(StringComparer.Ordinal); if (root.TryGetProperty("UnsLines", out var lines) && lines.ValueKind == JsonValueKind.Array) { foreach (var el in lines.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; var lineId = el.TryGetProperty("UnsLineId", out var lEl) ? lEl.GetString() : null; var areaId = el.TryGetProperty("UnsAreaId", out var aEl) ? aEl.GetString() : null; if (!string.IsNullOrWhiteSpace(lineId) && !string.IsNullOrWhiteSpace(areaId)) lineToArea[lineId!] = areaId!; } } // v3: Equipment carries no ClusterId AND no driver binding — it is attributed solely by its // UNS line's area cluster (Equipment -> UnsLine.UnsAreaId -> UnsArea.ClusterId). if (root.TryGetProperty("Equipment", out var eq) && eq.ValueKind == JsonValueKind.Array) { foreach (var el in eq.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null; var lineId = el.TryGetProperty("UnsLineId", out var luEl) ? luEl.GetString() : null; if (string.IsNullOrWhiteSpace(id)) continue; if (lineId is not null && lineToArea.TryGetValue(lineId, out var areaOfLine) && areaIds.Contains(areaOfLine)) equipmentIds.Add(id!); } } } return new ClusterSets(driverIds, areaIds, equipmentIds); } /// Collect each row's value from whose /// ClusterId equals (case-insensitive, matching the codebase convention). /// The artifact root element. /// The array property name to scan (e.g. "DriverInstances"). /// The id field to collect from each in-cluster row. /// The ClusterId rows must match. /// The set to collect matching ids into. private static void CollectIdsWhereCluster( JsonElement root, string arrayName, string idField, string clusterId, HashSet into) { if (!root.TryGetProperty(arrayName, out var arr) || arr.ValueKind != JsonValueKind.Array) return; foreach (var el in arr.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; var cid = el.TryGetProperty("ClusterId", out var cEl) ? cEl.GetString() : null; if (!string.Equals(cid, clusterId, StringComparison.OrdinalIgnoreCase)) continue; var id = el.TryGetProperty(idField, out var idEl) ? idEl.GetString() : null; if (!string.IsNullOrWhiteSpace(id)) into.Add(id!); } } /// The composition an absent / empty / unparseable artifact projects to: nothing at all. /// An empty composition. internal static AddressSpaceComposition Empty() => new( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty()); /// /// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one /// per VirtualTag. The artifact-decode mirror of /// AddressSpaceComposer.Compose's VirtualTag producer — so the compose-side + artifact-decode /// plans agree. Each {{equip}}/<RefName> in the joined Script's SourceCode is /// substituted with the owning equipment's backing RawPath (via ) /// BEFORE refs are extracted, byte-parity with the composer. Expression = the substituted /// source (empty when the ScriptId is absent); DependencyRefs = the distinct /// ctx.GetTag("…") literals in that source; FolderPath is always "" (VirtualTag has /// no FolderPath today). Ordered by EquipmentId then Name to match the composer's deterministic /// ordering. /// private static IReadOnlyList BuildEquipmentVirtualTagPlans( JsonElement root, IReadOnlyDictionary> referenceMapByEquip) { if (!root.TryGetProperty("VirtualTags", out var vtArr) || vtArr.ValueKind != JsonValueKind.Array) return Array.Empty(); var emptyRefMap = (IReadOnlyDictionary) new Dictionary(StringComparer.Ordinal); // scriptId → SourceCode (the expression source the VirtualTagActor evaluates). var scriptSourceById = new Dictionary(StringComparer.Ordinal); if (root.TryGetProperty("Scripts", out var scriptsArr) && scriptsArr.ValueKind == JsonValueKind.Array) { foreach (var el in scriptsArr.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; var sid = el.TryGetProperty("ScriptId", out var sidEl) ? sidEl.GetString() : null; if (string.IsNullOrWhiteSpace(sid)) continue; var src = el.TryGetProperty("SourceCode", out var srcEl) && srcEl.ValueKind == JsonValueKind.String ? srcEl.GetString() : null; scriptSourceById[sid!] = src ?? string.Empty; } } var result = new List(vtArr.GetArrayLength()); foreach (var el in vtArr.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; var virtualTagId = el.TryGetProperty("VirtualTagId", out var vidEl) ? vidEl.GetString() : null; var equipmentId = el.TryGetProperty("EquipmentId", out var eqEl) ? eqEl.GetString() : null; var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null; var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null; var scriptId = el.TryGetProperty("ScriptId", out var sidEl) ? sidEl.GetString() : null; if (string.IsNullOrWhiteSpace(virtualTagId) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(name)) continue; var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src) ? src : string.Empty; // Historize: the artifact carries a Pascal-case "Historize" bool (ConfigComposer serialises // the whole VirtualTag entity with DefaultIgnoreCondition.Never). Robust parse — default // false; only honoured when the JSON value is an actual boolean — so absent/non-bool ⇒ false, // byte-parity with AddressSpaceComposer's entity-default-false behaviour. var historize = el.TryGetProperty("Historize", out var hEl) && (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False) && hEl.GetBoolean(); // Substitute each {{equip}}/ with the owning equipment's backing RawPath BEFORE // extracting refs, so both Expression and DependencyRefs are machine-specific — byte-parity // with AddressSpaceComposer.Compose. var expanded = EquipmentScriptPaths.SubstituteEquipmentToken( source, referenceMapByEquip.GetValueOrDefault(equipmentId!, emptyRefMap)); result.Add(new EquipmentVirtualTagPlan( VirtualTagId: virtualTagId!, EquipmentId: equipmentId!, FolderPath: string.Empty, Name: name!, DataType: dataType ?? "BaseDataType", Expression: expanded, DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded), Historize: historize)); } result.Sort((a, b) => { var byEquipment = string.CompareOrdinal(a.EquipmentId, b.EquipmentId); return byEquipment != 0 ? byEquipment : string.CompareOrdinal(a.Name, b.Name); }); return result; } /// /// Join the artifact's ScriptedAlarms array to its Scripts array (by PredicateScriptId) to emit /// one per alarm. The artifact-decode mirror of /// AddressSpaceComposer.Compose's scripted-alarm producer — so the compose-side + artifact-decode /// plans agree byte-for-byte. An alarm whose PredicateScriptId has no matching Script row is /// SKIPPED (matching the composer's skip behaviour) to preserve parity. PredicateSource = the /// joined script source ("" when missing — but such alarms are skipped above); DependencyRefs /// = the shared merge of the predicate's /// distinct ctx.GetTag("…") reads UNION the message template's {TagPath} tokens. The /// predicate source's {{equip}}/<RefName> paths are substituted with the owning equipment's /// backing RawPath (via ) BEFORE the merge — byte-parity with /// the composer. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order. /// private static IReadOnlyList BuildEquipmentScriptedAlarmPlans( JsonElement root, IReadOnlyDictionary> referenceMapByEquip) { if (!root.TryGetProperty("ScriptedAlarms", out var alarmsArr) || alarmsArr.ValueKind != JsonValueKind.Array) return Array.Empty(); // scriptId → SourceCode (the predicate source the alarm host evaluates). Same join the // VirtualTag builder uses; an alarm whose PredicateScriptId is absent here is skipped below. var scriptSourceById = new Dictionary(StringComparer.Ordinal); if (root.TryGetProperty("Scripts", out var scriptsArr) && scriptsArr.ValueKind == JsonValueKind.Array) { foreach (var el in scriptsArr.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; var sid = el.TryGetProperty("ScriptId", out var sidEl) ? sidEl.GetString() : null; if (string.IsNullOrWhiteSpace(sid)) continue; var src = el.TryGetProperty("SourceCode", out var srcEl) && srcEl.ValueKind == JsonValueKind.String ? srcEl.GetString() : null; scriptSourceById[sid!] = src ?? string.Empty; } } var emptyRefMap = (IReadOnlyDictionary) new Dictionary(StringComparer.Ordinal); var result = new List(alarmsArr.GetArrayLength()); foreach (var el in alarmsArr.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; var scriptedAlarmId = el.TryGetProperty("ScriptedAlarmId", out var idEl) ? idEl.GetString() : null; var equipmentId = el.TryGetProperty("EquipmentId", out var eqEl) ? eqEl.GetString() : null; var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null; var alarmType = el.TryGetProperty("AlarmType", out var atEl) ? atEl.GetString() : null; var severity = el.TryGetProperty("Severity", out var svEl) && svEl.TryGetInt32(out var sv) ? sv : 0; var messageTemplate = el.TryGetProperty("MessageTemplate", out var mtEl) ? mtEl.GetString() : null; var predicateScriptId = el.TryGetProperty("PredicateScriptId", out var psEl) ? psEl.GetString() : null; // Booleans default to the entity defaults (true) when absent / null / non-boolean, so a // partial blob decodes the same as the composer's view of a default-constructed // ScriptedAlarm — preserving byte-parity. GetBoolean only runs for a genuine true/false // token (a non-bool token would otherwise throw InvalidOperationException, uncaught here). bool ReadBool(string prop, bool dflt) => el.TryGetProperty(prop, out var b) && b.ValueKind is JsonValueKind.True or JsonValueKind.False ? b.GetBoolean() : dflt; var historize = ReadBool("HistorizeToAveva", true); var retain = ReadBool("Retain", true); var enabled = ReadBool("Enabled", true); if (string.IsNullOrWhiteSpace(scriptedAlarmId)) continue; // Skip alarms whose predicate script is missing — matching AddressSpaceComposer's skip behaviour // so both sides emit the same set (byte-parity). if (predicateScriptId is null || !scriptSourceById.TryGetValue(predicateScriptId, out var rawSource)) continue; // Substitute each {{equip}}/ with the owning equipment's backing RawPath BEFORE the // dependency merge — byte-parity with AddressSpaceComposer.Compose. var source = EquipmentScriptPaths.SubstituteEquipmentToken( rawSource, referenceMapByEquip.GetValueOrDefault(equipmentId ?? string.Empty, emptyRefMap)); result.Add(new EquipmentScriptedAlarmPlan( ScriptedAlarmId: scriptedAlarmId!, EquipmentId: equipmentId ?? string.Empty, Name: name ?? string.Empty, AlarmType: alarmType ?? string.Empty, Severity: severity, MessageTemplate: messageTemplate ?? string.Empty, PredicateScriptId: predicateScriptId, PredicateSource: source, DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, messageTemplate), HistorizeToAveva: historize, Retain: retain, Enabled: enabled)); } result.Sort((a, b) => { var byEquipment = string.CompareOrdinal(a.EquipmentId, b.EquipmentId); return byEquipment != 0 ? byEquipment : string.CompareOrdinal(a.ScriptedAlarmId, b.ScriptedAlarmId); }); return result; } private static IReadOnlyList ReadArray(JsonElement root, string propertyName, Func reader) where T : class { if (!root.TryGetProperty(propertyName, out var arr) || arr.ValueKind != JsonValueKind.Array) return Array.Empty(); var result = new List(arr.GetArrayLength()); foreach (var el in arr.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; var item = reader(el); if (item is not null) result.Add(item); } // Match AddressSpaceComposer's natural-key sort so plan diffs are deterministic across // artifact-decode + composer-compose passes. return result.OrderBy(IdentityOf, StringComparer.Ordinal).ToList(); } private static string IdentityOf(T item) where T : class => item switch { UnsAreaProjection a => a.UnsAreaId, UnsLineProjection l => l.UnsLineId, EquipmentNode e => e.EquipmentId, DriverInstancePlan d => d.DriverInstanceId, ScriptedAlarmPlan a => a.ScriptedAlarmId, _ => string.Empty, }; private static UnsAreaProjection? ReadAreaProjection(JsonElement el) { var id = ReadString(el, "UnsAreaId"); var name = ReadString(el, "Name"); if (string.IsNullOrWhiteSpace(id)) return null; return new UnsAreaProjection(id!, name ?? id!); } private static UnsLineProjection? ReadLineProjection(JsonElement el) { var id = ReadString(el, "UnsLineId"); var areaId = ReadString(el, "UnsAreaId"); var name = ReadString(el, "Name"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(areaId)) return null; return new UnsLineProjection(id!, areaId!, name ?? id!); } private static EquipmentNode? ReadEquipmentNode(JsonElement el) { var id = ReadString(el, "EquipmentId"); // DisplayName = the UNS Name segment (friendly browse name, matching UnsArea/UnsLine // + the live rebuild's source of truth) — NOT the colloquial MachineCode. NodeId stays the // logical EquipmentId. var displayName = ReadString(el, "Name"); var lineId = ReadString(el, "UnsLineId"); if (string.IsNullOrWhiteSpace(id)) return null; // v3: Equipment no longer binds a driver/device — it references raw tags via UnsTagReference. // The EquipmentNode's DriverInstanceId/DeviceId/DeviceHost hooks (used by the multi-device FixedTree // partition path) are therefore always null now; Batch 4 owns the UNS↔Raw fan-out that lights them up. return new EquipmentNode(id!, displayName ?? id!, lineId ?? string.Empty); } private static DriverInstancePlan? ReadDriverPlan(JsonElement el) { var id = ReadString(el, "DriverInstanceId"); var type = ReadString(el, "DriverType"); var config = ReadString(el, "DriverConfig"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type)) return null; return new DriverInstancePlan(id!, type!, config ?? "{}"); } private static ScriptedAlarmPlan? ReadAlarmPlan(JsonElement el) { var id = ReadString(el, "ScriptedAlarmId"); var equipmentId = ReadString(el, "EquipmentId"); var script = ReadString(el, "PredicateScriptId"); var template = ReadString(el, "MessageTemplate"); if (string.IsNullOrWhiteSpace(id)) return null; return new ScriptedAlarmPlan(id!, equipmentId ?? string.Empty, script ?? string.Empty, template ?? string.Empty); } }