Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
T
Joseph Doherty 1ea1da8236 refactor(runtime): delete the dead in-place ApplyDelta seam; config change = respawn only (#665)
Since #516 a DriverConfig change is stop + respawn, so DriverSpawnPlan.ToApplyDelta
was always empty and the in-place path had no producer. #663 and #664 were both
latent bugs in it. This deletes it:
- host: ApplyChildDelta, _pendingDelta, HandleApplyResult and HandleDeltaApplied
- plan: DriverSpawnPlan.ToApplyDelta
- actor: the ApplyDelta, ApplyResult and DeltaApplied messages, the Connected
  reinit handler and the mid-connect AdoptConfigDuringInit
- tests: the actor tests that drove the seam

IDriver.ReinitializeAsync and each driver's implementation and unit tests stay.
Comments and docs that cited AdoptConfigDuringInit as the reason a driver must
release prior state at init now cite the IDriver contract and overlapping
retries, which still apply.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 11:23:16 -04:00

1617 lines
88 KiB
C#

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;
/// <summary>
/// Minimal driver-side view of the deployment artifact emitted by
/// <c>ConfigComposer.SnapshotAndFlattenAsync</c>. The artifact JSON is the full snapshot —
/// for driver spawning we only need the <c>DriverInstances</c> array. Reading just the
/// subset keeps allocations cheap on every deploy.
/// </summary>
public sealed record DriverInstanceSpec(
Guid DriverInstanceRowId,
string DriverInstanceId,
string Name,
string DriverType,
bool Enabled,
string DriverConfig,
string? ClusterId = null,
string? ResilienceConfig = null);
/// <summary>How a node should scope a deployment artifact to its own ClusterId.</summary>
public enum ClusterFilterMode
{
/// <summary>Apply everything (single-cluster / legacy deployments).</summary>
None,
/// <summary>Filter the artifact to the node's own ClusterId.</summary>
ScopeTo,
/// <summary>Apply nothing (node's cluster row not found in a multi-cluster artifact).</summary>
Suppress,
}
/// <summary>
/// The data-plane authorization inputs decoded out of a deployment artifact for one node (#520).
/// </summary>
/// <param name="ClusterId">The node's cluster per its <c>ClusterNode</c> row, or null on a
/// single-cluster / legacy artifact where no scoping applies.</param>
/// <param name="Keys">Every <c>ApiKey</c> the artifact carries, revoked and expired included — the
/// lookup filters those, so that a revocation is visible rather than indistinguishable from a
/// missing key.</param>
/// <param name="Grants">The <c>NodeAcl</c> rows scoped to <paramref name="ClusterId"/>.</param>
/// <param name="ScopePaths">
/// 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.
/// </param>
public sealed record DataPlaneAuthorityProjection(
string? ClusterId,
IReadOnlyList<ApiKey> Keys,
IReadOnlyList<NodeAcl> Grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths)
{
/// <summary>The projection of an absent or unparseable artifact: no cluster, no keys, no grants.</summary>
public static readonly DataPlaneAuthorityProjection Empty =
new(null, [], [], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
}
/// <summary>Resolved scoping decision for a node against an artifact.</summary>
/// <param name="Mode">
/// None = apply everything (single-cluster / legacy); ScopeTo = filter to <paramref name="ClusterId"/>;
/// Suppress = apply nothing.
/// </param>
/// <param name="ClusterId">The node's ClusterId when <paramref name="Mode"/> is ScopeTo; otherwise null.</param>
public readonly record struct ClusterScope(ClusterFilterMode Mode, string? ClusterId);
/// <summary>
/// Static, blob-in / result-out facade over <see cref="ParsedDeploymentArtifact"/>. 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 <see cref="JsonDocument"/> builds.
/// Prefer <see cref="Open"/> 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.
/// </summary>
public static class DeploymentArtifact
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
/// <summary>
/// Parse <paramref name="blob"/> ONCE into a reusable <see cref="ParsedDeploymentArtifact"/>, so
/// every consumer of a single apply reads the same <see cref="JsonDocument"/> instead of rebuilding
/// it per question. Dispose it when the apply completes.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is the ONLY place an artifact blob is decompressed.</b> <c>ConfigComposer</c> gzips the
/// serialised snapshot BEFORE hashing it, so the compressed bytes are the artifact everywhere:
/// <c>Deployment.ArtifactBlob</c>, the LocalDb cache chunks, the gRPC fetch stream, and the
/// <c>RevisionHash</c> 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.
/// </para>
/// </remarks>
/// <param name="blob">The deployment artifact bytes, gzip-compressed or raw JSON (sniffed by the gzip
/// magic <c>1F 8B</c>, 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.</param>
/// <returns>The parsed artifact. The caller owns it and must dispose it.</returns>
/// <exception cref="JsonException">The blob is not valid JSON, is not decompressible, or inflates past
/// <see cref="MaxDecompressedBytes"/>. Unlike the static wrappers — which own the lenient "malformed ⇒
/// empty result" contract — opening is explicit about unusable bytes.</exception>
internal static ParsedDeploymentArtifact Open(byte[] blob) =>
new(JsonDocument.Parse(Inflate(blob, MaxDecompressedBytes)));
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// A <see langword="const"/> 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 &gt;4 KB artifact concurrently — a spurious "refusing to inflate" failure
/// with no relation to the test that caused it. The limit is a <see cref="Inflate"/> parameter
/// instead, so the guard is provable at any ceiling without any process-wide mutation.
/// </remarks>
internal const int MaxDecompressedBytes = 512 * 1024 * 1024;
/// <summary>
/// Return the artifact's JSON bytes, decompressing when the blob carries the gzip magic
/// <c>1F 8B</c> at offset 0. Raw-JSON blobs (everything written before compression shipped) are
/// returned as-is — a JSON document can never begin with <c>0x1F</c>, so the sniff is unambiguous
/// and no framing of our own is needed.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Every failure — corrupt stream, truncated stream, over-limit inflation — is reported as
/// <see cref="JsonException"/> 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.
/// </para>
/// </remarks>
/// <param name="blob">The stored/wire artifact bytes.</param>
/// <param name="maxBytes">Ceiling on the inflated size. Production always passes
/// <see cref="MaxDecompressedBytes"/> via <see cref="Open"/>; the parameter exists so the guard can be
/// proved at a small ceiling without mutating process-wide state that parallel test classes share.</param>
/// <returns>The JSON bytes to parse.</returns>
/// <exception cref="JsonException">The gzip stream is corrupt or inflates past <paramref name="maxBytes"/>.</exception>
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<byte>.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<byte>.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);
}
}
/// <summary>
/// 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".
/// </summary>
/// <param name="blob">The deployment artifact blob to parse.</param>
/// <returns>The parsed driver-instance specs, or an empty list for empty/malformed blobs.</returns>
public static IReadOnlyList<DriverInstanceSpec> ParseDriverInstances(ReadOnlySpan<byte> blob)
{
if (blob.IsEmpty) return Array.Empty<DriverInstanceSpec>();
try
{
using var parsed = Open(blob.ToArray());
return parsed.ParseDriverInstances();
}
catch (JsonException)
{
return Array.Empty<DriverInstanceSpec>();
}
}
/// <summary>
/// Resolve how a node should scope a deployment artifact to its own ClusterId. Single-cluster
/// (or legacy) artifacts resolve to <see cref="ClusterFilterMode.None"/> so every existing
/// deployment applies unchanged. In a multi-cluster artifact the node's <c>ClusterNode</c> row
/// (matched by <paramref name="nodeId"/>) selects <see cref="ClusterFilterMode.ScopeTo"/> its
/// ClusterId; a missing row resolves to <see cref="ClusterFilterMode.Suppress"/>. Empty /
/// malformed blobs resolve to <see cref="ClusterFilterMode.None"/> (lenient, matching the
/// other parsers).
/// </summary>
/// <param name="blob">The deployment artifact blob to inspect.</param>
/// <param name="nodeId">The node's identity (e.g. a "host:port" string) to match against <c>Nodes</c>.</param>
/// <returns>The resolved <see cref="ClusterScope"/> decision for this node.</returns>
public static ClusterScope ResolveClusterScope(ReadOnlySpan<byte> 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);
}
}
/// <summary>
/// Parse a deployment artifact blob into the driver-instance specs a specific node should
/// spawn, scoped to its own ClusterId via <see cref="ResolveClusterScope"/>. 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).
/// </summary>
/// <param name="blob">The deployment artifact blob to parse.</param>
/// <param name="nodeId">The node's identity (e.g. a "host:port" string) used to resolve cluster scope.</param>
/// <returns>The driver-instance specs this node should spawn.</returns>
public static IReadOnlyList<DriverInstanceSpec> ParseDriverInstances(ReadOnlySpan<byte> blob, string nodeId)
{
if (blob.IsEmpty) return Array.Empty<DriverInstanceSpec>();
try
{
using var parsed = Open(blob.ToArray());
return parsed.ParseDriverInstances(nodeId);
}
catch (JsonException)
{
return Array.Empty<DriverInstanceSpec>();
}
}
/// <summary>
/// Parse the data-plane authorization inputs a node needs to authenticate OPC UA sessions:
/// the <c>ApiKeys</c> the artifact carries and the <c>NodeAcls</c> granting their principals
/// permissions (#520, stage 4). See <see cref="ParsedDeploymentArtifact.ParseDataPlaneAuthority"/>
/// for the scoping + leniency contract.
/// </summary>
/// <param name="blob">The deployment artifact blob to parse.</param>
/// <param name="nodeId">The node's identity, used to resolve cluster scope for the grants.</param>
/// <returns>The node's cluster (null when single-cluster), its keys, and its cluster's grants.</returns>
public static DataPlaneAuthorityProjection ParseDataPlaneAuthority(ReadOnlySpan<byte> 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;
}
}
/// <summary>
/// Parse the artifact into the projected <see cref="AddressSpaceComposition"/> used by
/// <c>AddressSpacePlanner</c> + <c>AddressSpaceApplier</c>. Returns an empty composition for empty/
/// malformed blobs so callers can treat parse failure as a no-op deploy.
/// </summary>
/// <param name="blob">The deployment artifact blob to parse.</param>
/// <returns>The projected <see cref="AddressSpaceComposition"/>, or an empty composition for empty/malformed blobs.</returns>
public static AddressSpaceComposition ParseComposition(ReadOnlySpan<byte> blob)
{
if (blob.IsEmpty) return ParsedDeploymentArtifact.Empty();
try
{
using var parsed = Open(blob.ToArray());
return parsed.ParseComposition();
}
catch (JsonException)
{
return ParsedDeploymentArtifact.Empty();
}
}
/// <summary>Cluster-scoped overload: the address-space composition a node should materialise given
/// its NodeId. See <see cref="ParsedDeploymentArtifact.ParseComposition(string, Action{string})"/>.</summary>
/// <param name="blob">The deployment artifact blob.</param>
/// <param name="nodeId">This node's identity in "host:port" form.</param>
/// <param name="onInconsistency">Optional diagnostic callback for cross-cluster orphan bindings; null disables the check.</param>
/// <returns>The filtered composition per the node's scoping decision.</returns>
public static AddressSpaceComposition ParseComposition(
ReadOnlySpan<byte> blob, string nodeId, Action<string>? 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();
}
}
}
/// <summary>
/// A deployment artifact parsed ONCE — one <see cref="JsonDocument"/> 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 <see cref="DeploymentArtifact"/> pays per call.
/// </summary>
/// <remarks>
/// <para>
/// Open it with <see cref="DeploymentArtifact.Open"/> 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.
/// </para>
/// <para>
/// Not thread-safe: the per-node <see cref="ResolveClusterScope(string)"/> memo is an unsynchronised
/// dictionary. An apply resolves its consumers sequentially, which is the only intended use.
/// </para>
/// <para>
/// <b>Internal on purpose.</b> Every consumer is inside this assembly (the apply paths in
/// <c>DriverHostActor</c> and <c>OpcUaPublishActor</c>, plus the tests via <c>InternalsVisibleTo</c>),
/// and the lenient static wrappers on <see cref="DeploymentArtifact"/> 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.
/// </para>
/// </remarks>
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<string, ClusterScope> _scopeByNodeId = new(StringComparer.Ordinal);
private bool _disposed;
/// <summary>Takes ownership of an already-parsed artifact document. Use
/// <see cref="DeploymentArtifact.Open"/>; this exists so the document is parsed in exactly one place.</summary>
/// <param name="document">The parsed artifact document, disposed with this instance.</param>
internal ParsedDeploymentArtifact(JsonDocument document) => _document = document;
/// <summary>
/// The artifact's root JSON object. <b>Valid only until <see cref="Dispose"/> — do not retain it,
/// and do not hand it outside this type.</b> Unlike everything the parse methods return (fully
/// materialised records, lists and strings that hold no reference back into the document), a
/// <see cref="JsonElement"/> is a live view over the disposed-with-us <see cref="JsonDocument"/>:
/// reading one after disposal throws, or worse, reads a recycled pooled buffer. Internal so that
/// escape hatch cannot be taken from outside the assembly.
/// </summary>
internal JsonElement Root => _document.RootElement;
/// <summary>Releases the underlying <see cref="JsonDocument"/>. Idempotent.</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_document.Dispose();
}
/// <summary>
/// The list of driver-instance specs to spawn, from the artifact's <c>DriverInstances</c> array.
/// An artifact with no such array yields an empty list.
/// </summary>
/// <returns>The parsed driver-instance specs.</returns>
public IReadOnlyList<DriverInstanceSpec> ParseDriverInstances()
{
var root = Root;
if (!root.TryGetProperty("DriverInstances", out var arr)
|| arr.ValueKind != JsonValueKind.Array)
{
return Array.Empty<DriverInstanceSpec>();
}
// 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<DriverInstanceSpec>(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;
}
/// <summary>Flatten the artifact's raw topology (RawFolders / DriverInstances / Devices / TagGroups /
/// Tags) into each driver's authored <see cref="RawTagEntry"/> list, keyed by DriverInstanceId. Each
/// tag's RawPath is computed via the shared <see cref="RawPathResolver"/> (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.</summary>
/// <param name="root">The artifact root element.</param>
/// <returns>DriverInstanceId → authored raw tags (RawPath + TagConfig + WriteIdempotent + DeviceName).</returns>
private static Dictionary<string, List<RawTagEntry>> BuildRawTagsByDriver(JsonElement root)
{
var byDriver = new Dictionary<string, List<RawTagEntry>>(StringComparer.Ordinal);
var folders = new Dictionary<string, (string? ParentId, string Name)>(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<string, (string? RawFolderId, string Name)>(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<string, string>(StringComparer.Ordinal);
var devices = new Dictionary<string, (string DriverInstanceId, string Name)>(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<string, (string? ParentId, string Name)>(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<RawTagEntry>();
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;
}
/// <summary>
/// Build the per-equipment <c>{{equip}}/&lt;RefName&gt;</c> reference map — <c>equipmentId →
/// (effectiveName → backing-tag RawPath)</c> — from the artifact's raw topology + <c>Tags</c> +
/// <c>UnsTagReferences</c> via the shared <see cref="EquipmentReferenceMap"/> + <see cref="RawPathResolver"/>.
/// The JSON-side mirror of <c>AddressSpaceComposer.BuildEquipmentReferenceMap</c> (entity side), so
/// both compose seams resolve <c>{{equip}}</c> script paths to byte-identical RawPaths. Empty when
/// the artifact carries no references / tags.
/// </summary>
/// <param name="root">The artifact root element.</param>
/// <returns><c>equipmentId → (effectiveName → RawPath)</c>.</returns>
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(JsonElement root)
{
var folders = new Dictionary<string, (string? ParentId, string Name)>(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<string, (string? RawFolderId, string Name)>(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<string, (string DriverInstanceId, string Name)>(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<string, (string? ParentId, string Name)>(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<string, EquipmentReferenceMap.TagRow>(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<EquipmentReferenceMap.ReferenceRow>();
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<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
return EquipmentReferenceMap.Build(references, tagsById, resolver);
}
/// <summary>
/// v3 Batch 4 — reconstruct the raw/UNS <b>entities</b> from the artifact JSON and drive the pure
/// <see cref="AddressSpaceComposer.Compose"/>, returning ONLY the three dual-namespace lists
/// (<see cref="AddressSpaceComposition.RawContainers"/> / <see cref="AddressSpaceComposition.RawTags"/> /
/// <see cref="AddressSpaceComposition.UnsReferenceVariables"/>). Reusing the composer verbatim is what
/// guarantees BYTE-PARITY between the live-edit compose seam and this artifact-decode seam — the same
/// <c>RawPathResolver</c> / <c>RawPaths</c> / <c>TagConfigIntent</c> / <c>V3NodeIds</c> authorities
/// produce every RawPath, UNS NodeId, and Organizes backing path. Enums serialize numerically in the
/// artifact (no <c>JsonStringEnumConverter</c>), so <c>AccessLevel</c> is read as an int.
/// </summary>
/// <param name="root">The artifact root element.</param>
/// <returns>The Raw containers, Raw tags, and UNS reference variables.</returns>
private static (IReadOnlyList<RawContainerNode> Containers, IReadOnlyList<RawTagPlan> Tags, IReadOnlyList<UnsReferenceVariable> UnsRefs)
BuildRawAndUnsSubtrees(JsonElement root)
{
var rawFolders = new List<RawFolder>();
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<DriverInstance>();
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<Device>();
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<TagGroup>();
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<Tag>();
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<UnsTagReference>();
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<UnsArea>();
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<UnsLine>();
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<Equipment>();
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<ScriptedAlarm>(),
unsTagReferences: unsTagReferences, tags: tags,
rawFolders: rawFolders, devices: devices, tagGroups: tagGroups);
return (composition.RawContainers, composition.RawTags, composition.UnsReferenceVariables);
}
/// <summary>Build the <c>scriptId → SourceCode</c> map from the artifact's <c>Scripts</c> array (mirrors
/// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts.</summary>
private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root)
{
var byId = new Dictionary<string, string>(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;
}
/// <summary>
/// When <paramref name="tagConfig"/> carries a <c>scriptId</c> (a calc tag), inject the resolved
/// <c>scriptSource</c> so the host-blind Calculation driver can compile + evaluate it. Identity for
/// tags with no <c>scriptId</c> (every other driver's tags), an unresolvable id, or malformed JSON —
/// so no existing tag's blob changes. The pre-existing <c>scriptId</c>/trigger fields are preserved.
/// </summary>
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON.</param>
/// <param name="scriptSourceById">The <c>scriptId → SourceCode</c> map from the artifact.</param>
/// <returns>The TagConfig with <c>scriptSource</c> injected, or the original when nothing to inject.</returns>
private static string InjectScriptSource(string tagConfig, IReadOnlyDictionary<string, string> 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<string>(out var sid) ? sid : null;
if (string.IsNullOrWhiteSpace(scriptId) || !scriptSourceById.TryGetValue(scriptId!, out var source))
return tagConfig;
obj["scriptSource"] = source;
return obj.ToJsonString();
}
/// <summary>Group the artifact's Devices into per-driver <see cref="DriverDeviceConfigMerger.DeviceRow"/>
/// lists (entity Name + schemaless DeviceConfig), ordered by DeviceId for a stable merge.</summary>
/// <param name="root">The artifact root element.</param>
/// <returns>DriverInstanceId → ordered device rows.</returns>
private static Dictionary<string, List<DriverDeviceConfigMerger.DeviceRow>> BuildDeviceRowsByDriver(JsonElement root)
{
var byDriver = new Dictionary<string, List<(string DeviceId, DriverDeviceConfigMerger.DeviceRow Row)>>(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);
}
/// <summary>Enumerate an artifact array property's object elements (empty when absent/non-array).</summary>
private static IEnumerable<JsonElement> 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;
}
/// <summary>Reads a string property, coalescing a JSON <c>null</c> (or wrong type) to <see langword="null"/>.</summary>
private static string? ReadNullableString(JsonElement el, string property) =>
el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
/// <summary>
/// Resolve how a node should scope this artifact to its own ClusterId. Single-cluster
/// (or legacy) artifacts resolve to <see cref="ClusterFilterMode.None"/> so every existing
/// deployment applies unchanged. In a multi-cluster artifact the node's <c>ClusterNode</c> row
/// (matched by <paramref name="nodeId"/>) selects <see cref="ClusterFilterMode.ScopeTo"/> its
/// ClusterId; a missing row resolves to <see cref="ClusterFilterMode.Suppress"/>.
/// Memoized per node id — every consumer of one apply asks about the same node.
/// </summary>
/// <param name="nodeId">The node's identity (e.g. a "host:port" string) to match against <c>Nodes</c>.</param>
/// <returns>The resolved <see cref="ClusterScope"/> decision for this node.</returns>
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);
}
/// <summary>
/// The driver-instance specs a specific node should spawn, scoped to its own ClusterId via
/// <see cref="ResolveClusterScope(string)"/>. 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).
/// </summary>
/// <param name="nodeId">The node's identity (e.g. a "host:port" string) used to resolve cluster scope.</param>
/// <returns>The driver-instance specs this node should spawn.</returns>
public IReadOnlyList<DriverInstanceSpec> ParseDriverInstances(string nodeId)
{
var scope = ResolveClusterScope(nodeId);
if (scope.Mode == ClusterFilterMode.Suppress) return Array.Empty<DriverInstanceSpec>();
var all = ParseDriverInstances();
return scope.Mode == ClusterFilterMode.ScopeTo
? all.Where(s => string.Equals(s.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase)).ToArray()
: all;
}
/// <summary>
/// The data-plane authorization inputs a node needs to authenticate OPC UA sessions:
/// the <c>ApiKeys</c> the artifact carries and the <c>NodeAcls</c> granting their principals
/// permissions (#520, stage 4).
/// </summary>
/// <remarks>
/// <para>
/// This is the ONLY key source a <b>driver-only</b> node has — per-cluster mesh Phase 4 left
/// it with no ConfigDb, so without this it denies every UserName token.
/// </para>
/// <para>
/// Grants are scoped to the node's own cluster (<see cref="ResolveClusterScope(string)"/>); keys
/// are not, because <c>ApiKey</c> 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 <see cref="ClusterFilterMode.Suppress"/>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <param name="nodeId">The node's identity, used to resolve cluster scope for the grants.</param>
/// <returns>The node's cluster (null when single-cluster), its keys, and its cluster's grants.</returns>
public DataPlaneAuthorityProjection ParseDataPlaneAuthority(string nodeId)
{
var scope = ResolveClusterScope(nodeId);
var root = Root;
var keys = new List<ApiKey>();
foreach (var el in EnumerateArray(root, "ApiKeys"))
{
var key = TryReadApiKey(el);
if (key is not null) keys.Add(key);
}
var grants = new List<NodeAcl>();
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));
}
/// <summary>
/// Resolve every scope named by <paramref name="grants"/> against the artifact's own raw + UNS
/// topology, using the same <c>RawPathResolver</c> the address space is built through — so an ACL
/// path and the RawPath that forms a node's NodeId cannot drift apart.
/// </summary>
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> BuildAclScopePaths(
JsonElement root,
IReadOnlyList<NodeAcl> grants)
{
if (grants.Count == 0) return new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
var folders = new Dictionary<string, (string? ParentId, string Name)>(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<string, (string? RawFolderId, string Name)>(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<string, (string DriverInstanceId, string Name)>(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<string, (string? ParentId, string DeviceId, string Name)>(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<string, (string DeviceId, string? TagGroupId, string Name)>(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<string, string>(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<string, (string UnsAreaId, string Name)>(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<string, (string UnsLineId, string Name)>(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);
}
/// <summary>Reads one <c>ApiKeys</c> element; null when a field the credential check depends on is
/// missing, so a half-written row can never authenticate.</summary>
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"),
};
}
/// <summary>Reads one <c>NodeAcls</c> element; null when identity, cluster or scope kind is unreadable.</summary>
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"),
};
}
/// <summary>Reads a boolean property; null when absent or not a JSON boolean.</summary>
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;
/// <summary>Reads an int property; null when absent, not a number, or out of int range.</summary>
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;
/// <summary>Reads a UTC DateTime property; null when absent, JSON null, or unparseable.</summary>
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),
};
}
/// <summary>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
/// <see cref="InvalidOperationException"/> from <c>GetString()</c>. 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.</summary>
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<string, List<RawTagEntry>> rawTagsByDriver,
IReadOnlyDictionary<string, List<DriverDeviceConfigMerger.DeviceRow>> 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<DriverDeviceConfigMerger.DeviceRow>)Array.Empty<DriverDeviceConfigMerger.DeviceRow>();
var rawTags = rawTagsByDriver.TryGetValue(id!, out var rt) ? rt : (IReadOnlyList<RawTagEntry>)Array.Empty<RawTagEntry>();
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);
}
/// <summary>
/// Parse the artifact into the projected <see cref="AddressSpaceComposition"/> used by
/// <c>AddressSpacePlanner</c> + <c>AddressSpaceApplier</c>. 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 <c>ConfigComposer.SnapshotAndFlattenAsync</c> 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.
/// </summary>
/// <returns>The projected <see cref="AddressSpaceComposition"/>.</returns>
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<EquipmentTagPlan>();
// Per-equipment {{equip}}/<RefName> 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,
};
}
}
/// <summary>Cluster-scoped overload: the address-space composition a node should materialise given
/// its NodeId. Filters every projection to the node's own ClusterId (see <see cref="ResolveClusterScope(string)"/>).
/// 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 <paramref name="onInconsistency"/> 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.</summary>
/// <param name="nodeId">This node's identity in "host:port" form.</param>
/// <param name="onInconsistency">Optional diagnostic callback for cross-cluster orphan bindings; null disables the check.</param>
/// <returns>The filtered composition per the node's scoping decision.</returns>
public AddressSpaceComposition ParseComposition(string nodeId, Action<string>? 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(),
};
}
/// <summary>The in-cluster id sets used to filter a composition.</summary>
/// <param name="DriverIds">DriverInstanceIds whose row carries the in-scope ClusterId.</param>
/// <param name="AreaIds">UnsAreaIds whose row carries the in-scope ClusterId.</param>
/// <param name="EquipmentIds">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.</param>
private sealed record ClusterSets(HashSet<string> DriverIds, HashSet<string> AreaIds, HashSet<string> EquipmentIds);
/// <summary>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.</summary>
/// <param name="root">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).</param>
/// <param name="clusterId">The node's ClusterId to scope to.</param>
/// <returns>The resolved in-cluster id sets.</returns>
private static ClusterSets BuildClusterSets(JsonElement root, string clusterId)
{
var driverIds = new HashSet<string>(StringComparer.Ordinal);
var areaIds = new HashSet<string>(StringComparer.Ordinal);
var equipmentIds = new HashSet<string>(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<string, string>(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);
}
/// <summary>Collect each row's <paramref name="idField"/> value from <paramref name="arrayName"/> whose
/// ClusterId equals <paramref name="clusterId"/> (case-insensitive, matching the codebase convention).</summary>
/// <param name="root">The artifact root element.</param>
/// <param name="arrayName">The array property name to scan (e.g. "DriverInstances").</param>
/// <param name="idField">The id field to collect from each in-cluster row.</param>
/// <param name="clusterId">The ClusterId rows must match.</param>
/// <param name="into">The set to collect matching ids into.</param>
private static void CollectIdsWhereCluster(
JsonElement root, string arrayName, string idField, string clusterId, HashSet<string> 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!);
}
}
/// <summary>The composition an absent / empty / unparseable artifact projects to: nothing at all.</summary>
/// <returns>An empty composition.</returns>
internal static AddressSpaceComposition Empty() => new(
Array.Empty<UnsAreaProjection>(),
Array.Empty<UnsLineProjection>(),
Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>());
/// <summary>
/// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one
/// <see cref="EquipmentVirtualTagPlan"/> per VirtualTag. The artifact-decode mirror of
/// <c>AddressSpaceComposer.Compose</c>'s VirtualTag producer — so the compose-side + artifact-decode
/// plans agree. Each <c>{{equip}}/&lt;RefName&gt;</c> in the joined Script's <c>SourceCode</c> is
/// substituted with the owning equipment's backing RawPath (via <paramref name="referenceMapByEquip"/>)
/// BEFORE refs are extracted, byte-parity with the composer. <c>Expression</c> = the substituted
/// source (empty when the ScriptId is absent); <c>DependencyRefs</c> = the distinct
/// <c>ctx.GetTag("…")</c> literals in that source; <c>FolderPath</c> is always "" (VirtualTag has
/// no FolderPath today). Ordered by EquipmentId then Name to match the composer's deterministic
/// ordering.
/// </summary>
private static IReadOnlyList<EquipmentVirtualTagPlan> BuildEquipmentVirtualTagPlans(
JsonElement root, IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> referenceMapByEquip)
{
if (!root.TryGetProperty("VirtualTags", out var vtArr) || vtArr.ValueKind != JsonValueKind.Array)
return Array.Empty<EquipmentVirtualTagPlan>();
var emptyRefMap = (IReadOnlyDictionary<string, string>)
new Dictionary<string, string>(StringComparer.Ordinal);
// scriptId → SourceCode (the expression source the VirtualTagActor evaluates).
var scriptSourceById = new Dictionary<string, string>(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<EquipmentVirtualTagPlan>(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}}/<RefName> 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;
}
/// <summary>
/// Join the artifact's ScriptedAlarms array to its Scripts array (by PredicateScriptId) to emit
/// one <see cref="EquipmentScriptedAlarmPlan"/> per alarm. The artifact-decode mirror of
/// <c>AddressSpaceComposer.Compose</c>'s scripted-alarm producer — so the compose-side + artifact-decode
/// plans agree byte-for-byte. An alarm whose <c>PredicateScriptId</c> has no matching Script row is
/// SKIPPED (matching the composer's skip behaviour) to preserve parity. <c>PredicateSource</c> = the
/// joined script source ("" when missing — but such alarms are skipped above); <c>DependencyRefs</c>
/// = the shared <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> merge of the predicate's
/// distinct <c>ctx.GetTag("…")</c> reads UNION the message template's <c>{TagPath}</c> tokens. The
/// predicate source's <c>{{equip}}/&lt;RefName&gt;</c> paths are substituted with the owning equipment's
/// backing RawPath (via <paramref name="referenceMapByEquip"/>) BEFORE the merge — byte-parity with
/// the composer. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order.
/// </summary>
private static IReadOnlyList<EquipmentScriptedAlarmPlan> BuildEquipmentScriptedAlarmPlans(
JsonElement root, IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> referenceMapByEquip)
{
if (!root.TryGetProperty("ScriptedAlarms", out var alarmsArr) || alarmsArr.ValueKind != JsonValueKind.Array)
return Array.Empty<EquipmentScriptedAlarmPlan>();
// 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<string, string>(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<string, string>)
new Dictionary<string, string>(StringComparer.Ordinal);
var result = new List<EquipmentScriptedAlarmPlan>(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}}/<RefName> 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<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
where T : class
{
if (!root.TryGetProperty(propertyName, out var arr) || arr.ValueKind != JsonValueKind.Array)
return Array.Empty<T>();
var result = new List<T>(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>(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);
}
}