Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
T
Joseph Doherty 18f8eb5d2f feat(address-space): server-side array writes gated per driver capability (#610)
Array nodes were forced read-only for every driver. A new single authority,
Core.Abstractions DriverArrayWriteSupport, says which (driver, element type)
pairs encode a whole-array write: Modbus (Coils / numeric HoldingRegisters),
S7 (Byte/Int16/UInt16/Int32/UInt32/Float32), OpcUaClient (pass-through) and
Galaxy (Boolean/Int32/Int64/Float/Double/String/DateTime). Everything else
stays read-only, so the server never advertises a write the driver refuses.

- Composer derives RawTagPlan.ArrayWritable + UnsReferenceVariable
  .BackingArrayWritable at compose time (both seams, byte-parity); the
  applier's one writability rule is Writable && (!IsArray || ArrayWritable)
  for materialise and surgical paths, raw and UNS alike.
- ArrayWritable is on the classifier's surgical raw-tag whitelist and
  mirrored on the UNS record (UnsReferenceBackingParityTests updated).
- Node manager refuses a wrong-length / non-array value on an array node
  (BadTypeMismatch) and any IndexRange write (BadWriteNotSupported) inline,
  before dispatch; WriteOperate/ACL gates and the revert are unchanged.
- Per-driver parity tests pin the table against the real Modbus write path,
  S7 EncodeArrayBlock and Galaxy MxValueEncoder (UInt32/UInt64 excluded:
  array covariance reinterprets their bits).
- Over-the-wire proof in OpcUaServer.IntegrationTests (ArrayWriteWireTests).
- ValueRank > 1 documented as out of scope by decision (docs/Uns.md).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:39:05 -04:00

1397 lines
93 KiB
C#

using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Side-effecting orchestrator over <see cref="AddressSpacePlan"/>. Drives an
/// <see cref="IOpcUaAddressSpaceSink"/> to materialise the diff between two
/// <see cref="AddressSpaceComposition"/> snapshots, routing each delta class to its MINIMAL mutation
/// (R2-07 03/P1) via <see cref="AddressSpaceChangeClassifier"/> instead of rebuilding the whole
/// address space for any topology change (which would kill every client's server-wide subscriptions):
///
/// <list type="bullet">
/// <item><b>PureAdd</b> — no rebuild. <c>OpcUaPublishActor</c>'s idempotent Materialise passes
/// create exactly the added folders/variables/conditions (no-op'ing existing nodes), then
/// <c>AnnounceAddedNodes</c> raises a Part 3 <c>NodeAdded</c> per affected parent so
/// model-aware clients re-browse. Existing MonitoredItems are untouched.</item>
/// <item><b>AttributeOnly</b> — no rebuild. Surgical-eligible changed equipment tags, changed RAW
/// tags (in the Raw realm) and UNS folder renames are applied IN PLACE via
/// <see cref="ISurgicalAddressSpaceSink"/> (F10b); a changed UNS <i>reference</i> variable is a
/// remove-previous here + recreate-current in the materialise pass. Any <c>false</c>
/// or throw from a surgical call falls back to a full rebuild (safe default).</item>
/// <item><b>PureRemove</b> — scoped in-place teardown of only the removed subtree (writes a
/// terminal Bad / <c>RemovedConditionState</c> first so in-flight MonitoredItems observe the
/// removal). Maps to a full rebuild until R2-07 Phase 2 ships.</item>
/// <item><b>AddRemoveMix</b> — removes-then-adds within one apply. Maps to a full rebuild until
/// R2-07 Phase 3 ships.</item>
/// <item><b>Rebuild</b> — the default-closed safety valve for any node-affecting change
/// (ChangedEquipment / ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant
/// ChangedEquipmentVirtualTags), so the worst outcome of any misclassification is today's
/// full-rebuild behaviour.</item>
/// </list>
///
/// Stays pure-of-SDK so production binds a real SDK sink, dev/Mac binds
/// <see cref="NullOpcUaAddressSpaceSink"/>, and tests can capture every call.
/// </summary>
public sealed class AddressSpaceApplier
{
private readonly IOpcUaAddressSpaceSink _sink;
private readonly ILogger<AddressSpaceApplier> _logger;
private readonly IHistorianProvisioning _provisioning;
private readonly IHistorizedTagSubscriptionSink _historizedSubscriptions;
/// <summary>Initializes a new instance of the AddressSpaceApplier class.</summary>
/// <param name="sink">The OPC UA address space sink to apply changes to.</param>
/// <param name="logger">The logger instance.</param>
/// <param name="provisioning">
/// Optional historian tag provisioner — when an address space is (re)built, historized added
/// tags are auto-ensured in the historian via <see cref="IHistorianProvisioning.EnsureTagsAsync"/>.
/// Defaults (a <c>null</c> argument) to the no-op <see cref="NullHistorianProvisioning"/>, so every
/// existing two-argument call site compiles and behaves unchanged. The provisioning round-trip is
/// dispatched fire-and-forget off <see cref="Apply"/> (which runs on the OPC UA publish actor's
/// pinned thread), so it can never block or break a deploy.
/// </param>
/// <param name="historizedSubscriptions">
/// Optional continuous-historization feed — when an address space is (re)applied, the add/remove
/// delta of historized tag refs (resolved EXACTLY as the provisioning hook above) is pushed to the
/// recorder so its dependency-mux interest converges to the currently-historized set. Defaults
/// (a <c>null</c> argument) to the no-op <see cref="NullHistorizedTagSubscriptionSink"/>, so every
/// existing call site compiles and behaves unchanged. The feed is a single non-blocking post off
/// <see cref="Apply"/> and is wrapped so it can never block or break a deploy.
/// </param>
public AddressSpaceApplier(
IOpcUaAddressSpaceSink sink,
ILogger<AddressSpaceApplier> logger,
IHistorianProvisioning? provisioning = null,
IHistorizedTagSubscriptionSink? historizedSubscriptions = null)
{
ArgumentNullException.ThrowIfNull(sink);
ArgumentNullException.ThrowIfNull(logger);
_sink = sink;
_logger = logger;
_provisioning = provisioning ?? NullHistorianProvisioning.Instance;
_historizedSubscriptions = historizedSubscriptions ?? NullHistorizedTagSubscriptionSink.Instance;
}
/// <summary>
/// Apply <paramref name="plan"/> to the sink. Returns a summary of what was applied so
/// callers (OpcUaPublishActor) can correlate the work back to the originating deployment.
/// </summary>
/// <param name="plan">The plan to apply.</param>
/// <returns>A AddressSpaceApplyOutcome summarizing the applied changes.</returns>
public AddressSpaceApplyOutcome Apply(AddressSpacePlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
if (plan.IsEmpty)
{
_logger.LogDebug("AddressSpaceApplier: plan is empty; skipping sink writes");
return new AddressSpaceApplyOutcome(RemovedNodes: 0, AddedNodes: 0, ChangedNodes: 0, RebuildCalled: false);
}
var ts = DateTime.UtcNow;
var removedCount = 0;
// Swallowed removal-pass condition-write failures are tallied here (not lost to a per-node Warning)
// so a degraded removal is operator-visible via AddressSpaceApplyOutcome.FailedNodes (archreview 01/S-1).
var failedNodes = 0;
foreach (var eq in plan.RemovedEquipment)
{
if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
removedCount++;
}
foreach (var alarm in plan.RemovedAlarms)
{
if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
removedCount++;
}
// Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write
// before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes
// is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. v3 Batch 4:
// removed raw containers/tags + UNS reference variables are likewise real removals.
removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count;
var changedCount =
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
plan.ChangedEquipmentTags.Count +
plan.ChangedEquipmentVirtualTags.Count +
plan.ChangedRawContainers.Count + plan.ChangedRawTags.Count + plan.ChangedUnsReferenceVariables.Count +
// A UNS Area/Line rename is an in-place change to an existing folder node.
plan.RenamedFolders.Count;
var addedCount =
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
plan.AddedEquipmentTags.Count +
plan.AddedEquipmentVirtualTags.Count +
plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count;
// R2-07 03/P1 — classify the plan and route each delta class to its MINIMAL mutation instead of
// rebuilding the world for any topology change. AddressSpaceChangeClassifier is a pure policy over
// the planner's diff (the planner stays a pure diff). The routing here:
// • PureAdd / AttributeOnly ⇒ NO rebuild. The idempotent Materialise passes in
// OpcUaPublishActor.HandleRebuild create exactly the added nodes (no-op'ing existing ones), so
// every client subscription server-wide survives; coincident surgical tag deltas + folder
// renames are applied IN PLACE via ISurgicalAddressSpaceSink below (any false/throw there falls
// back to a full rebuild — the F10b contract).
// • PureRemove ⇒ NO full rebuild. The removed nodes are torn down IN PLACE, scoped to the affected
// subtree, via the ISurgicalAddressSpaceSink remove members below (each preceded by a terminal
// Bad / RemovedConditionState write so in-flight MonitoredItems observe the removal); subscribers
// of OTHER nodes are untouched. Any false/throw from a remove ⇒ full-rebuild fallback (ratchet).
// • AddRemoveMix ⇒ NO full rebuild. Removes-then-adds within one apply: the removed nodes are torn
// down IN PLACE here (the PureRemove pass), then OpcUaPublishActor's idempotent Materialise passes
// create the added nodes and AnnounceAddedNodes announces them — natural remove-then-recreate
// order even when an id is reused across the remove + add sets (the recreated node is a fresh
// NodeState, correct — it is a different tag). Any remove false/throw ⇒ full-rebuild fallback.
// • Rebuild ⇒ the default-closed safety valve for any node-affecting change (ChangedEquipment /
// ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant ChangedEquipmentVirtualTags)
// — the classifier's rule 2, which also catches any future plan field that makes a changed
// record unequal, so the worst outcome of a misclassification is today's full rebuild.
// ChangedDrivers is node-inert (routes through DriverHostActor's spawn plan) — the classifier leaves
// a driver-only plan AttributeOnly, so it never rebuilds here.
var kind = AddressSpaceChangeClassifier.Classify(plan);
var mustRebuild = kind is AddressSpaceChangeKind.Rebuild;
// PureRemove + AddRemoveMix both run the in-place remove pass below (AddRemoveMix's adds are handled
// afterward by the publish actor's Materialise passes + announce).
var hasRemovePass = kind is AddressSpaceChangeKind.PureRemove or AddressSpaceChangeKind.AddRemoveMix;
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList();
// UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
// when no structural rebuild fires. When a rebuild DOES fire (any add/remove/structural change),
// MaterialiseHierarchy re-creates every folder with the new display names, so the renames are
// covered for free and need no separate surgical pass.
var renamedFolders = plan.RenamedFolders;
// v3 surgical raw-tag + UNS-reference deltas (2026-08-15 surgical-deploys, Task 5). When kind is not
// Rebuild the classifier has ALREADY guaranteed every ChangedRawTags entry is surgical-eligible; the
// filter is defense-in-depth so a future classifier change can only ever under-apply here (the
// non-eligible remainder is left to the rebuild the classifier would have demanded).
var surgicalRawTagDeltas = plan.ChangedRawTags.Where(AddressSpaceChangeClassifier.RawTagDeltaIsSurgicalEligible).ToList();
var unsRefDeltas = plan.ChangedUnsReferenceVariables;
var rebuilt = false;
var rebuildFailed = false;
if (mustRebuild)
{
rebuildFailed = !SafeRebuild();
rebuilt = true;
}
else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || hasRemovePass ||
surgicalRawTagDeltas.Count > 0 || unsRefDeltas.Count > 0)
{
if (_sink is ISurgicalAddressSpaceSink surgical)
{
var allApplied = true;
// Folder renames first — an in-place DisplayName swap on the existing Area/Line folder.
foreach (var rename in renamedFolders)
{
bool ok;
try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName, AddressSpaceRealm.Uns); }
catch (Exception ex)
{
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateFolderDisplayName threw for {Node}", rename.FolderNodeId);
ok = false;
}
if (!ok) { allApplied = false; break; }
}
foreach (var d in allApplied ? surgicalTagDeltas : Enumerable.Empty<AddressSpacePlan.EquipmentTagDelta>())
{
// Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags
// would so the in-place update matches what a rebuild would have produced. Array tags are
// forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays).
var nodeId = UnsEquipmentVar(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name);
var writable = d.Current.Writable && !d.Current.IsArray;
var historian = d.Current.IsHistorized
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname)
: null;
bool ok;
try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Uns); }
catch (Exception ex)
{
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId);
ok = false;
}
if (!ok) { allApplied = false; break; }
}
// v3 RAW tags — the same in-place attribute update, in the RAW realm. Adding or dropping ONE
// UnsTagReference flips its backing raw tag to Changed purely through ReferencingEquipmentPaths,
// and before this path existed that tore down every node on the server for an edit that creates
// a single UNS variable.
foreach (var d in allApplied ? surgicalRawTagDeltas : Enumerable.Empty<AddressSpacePlan.RawTagDelta>())
{
// ReferencingEquipmentPaths is node-INERT for a plain value tag (it only drives native-alarm
// notifier wiring, and an alarm-bearing delta never reaches here — the classifier routes it
// to Rebuild). So a delta whose ONLY difference is the referencing set needs NOTHING said to
// the sink: compare the two sides with the referencing set copied across, and skip on equal.
if ((d.Previous with { ReferencingEquipmentPaths = d.Current.ReferencingEquipmentPaths }).Equals(d.Current))
{
continue;
}
// Compute writable + historian EXACTLY as MaterialiseRawSubtree does so the in-place update
// matches what a rebuild would have produced: an array is writable only when its driver can
// write arrays (#610) and a blank historian override falls back to the RawPath (== NodeId).
var writable = RawNodeWritable(d.Current);
var historian = d.Current.IsHistorized
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.NodeId : d.Current.HistorianTagname)
: null;
bool ok;
try { ok = surgical.UpdateTagAttributes(d.Current.NodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Raw); }
catch (Exception ex)
{
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for raw {Node}", d.Current.NodeId);
ok = false;
}
if (!ok) { allApplied = false; break; }
}
// v3 UNS reference variables — TWO shapes, split on whether the delta moves the node's
// IDENTITY (2026-08-15 review I2).
//
// (a) IDENTITY UNCHANGED (same NodeId, same EquipmentId, same BackingRawPath): only the
// inherited Backing* / DataType / Writable attributes moved — the 875ec9fe repair shape,
// produced whenever a raw tag's historian or array shape is edited under a reference that
// was not itself touched. Nothing about the node's identity or its Organizes edge changes,
// so an in-place UpdateTagAttributes in the Uns realm says everything that needs saying
// AND the mirror's client MonitoredItems survive. Remove+recreate here would kill the
// subscriptions of every client watching a UNS signal because someone historized the raw
// tag behind it.
//
// (b) IDENTITY CHANGED — a re-point (BackingRawPath moved: the node's Organizes edge must move
// with it, and the surgical sink surface has no RemoveReference) or an effective-name edit
// (the effective name is part of the UNS NodeId, and an OPC UA NodeId is immutable). Remove
// the PREVIOUS node here (terminal Bad first, so an in-flight MonitoredItem observes the
// removal) and let the CURRENT node be created by the materialise pass after Apply returns.
// The cost is that ONE node's monitored items, against today's cost of a full rebuild
// killing every subscription server-wide.
//
// BOTH shapes leave the delta's Current in the publish actor's materialise subset, and that is
// deliberate: for (b) the subset IS the recreate (dropping it would leave the reference removed
// and never brought back), and for (a) it is a cheap idempotent no-op — EnsureVariable
// early-returns on the already-present node and AddReference is idempotent on the unchanged
// edge. Making the subset shape-aware would buy nothing and add a way for the two sides to
// disagree about which references were repaired.
foreach (var d in allApplied ? unsRefDeltas : Enumerable.Empty<AddressSpacePlan.UnsReferenceDelta>())
{
if (IsIdentityPreservingUnsRefDelta(d))
{
// Resolve writable + historian EXACTLY as MaterialiseUnsReferences does when it creates
// the node: an array is writable only when its backing driver can write arrays (#610), and
// a blank override falls back to the backing RawPath (which IS the backing raw tag's NodeId).
var writable = UnsReferenceNodeWritable(d.Current);
var historian = d.Current.BackingIsHistorized
? (string.IsNullOrWhiteSpace(d.Current.BackingHistorianTagname) ? d.Current.BackingRawPath : d.Current.BackingHistorianTagname)
: null;
bool updated;
try
{
updated = surgical.UpdateTagAttributes(
d.Current.NodeId, writable, historian, d.Current.DataType,
d.Current.BackingIsArray, d.Current.BackingArrayLength, AddressSpaceRealm.Uns);
}
catch (Exception ex)
{
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for UNS reference {Node}", d.Current.NodeId);
updated = false;
}
if (!updated) { allApplied = false; break; }
continue;
}
if (!SafeWriteValue(d.Previous.NodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns)) failedNodes++;
if (!SafeRemoveVariable(surgical, d.Previous.NodeId, AddressSpaceRealm.Uns)) { allApplied = false; break; }
}
// R2-07 Phase 2/3 — scoped remove teardown for PureRemove AND AddRemoveMix (runs after any
// coincident surgical attribute updates / renames succeeded). Terminal Bad /
// RemovedConditionState writes then in-place removes; any false/throw flips allApplied → the
// rebuild ratchet below. For AddRemoveMix the adds are materialised afterward by the publish
// actor's passes (remove-then-recreate order).
if (allApplied && hasRemovePass)
{
allApplied = ApplyPureRemove(surgical, plan, ts, ref failedNodes);
}
if (!allApplied) { rebuildFailed = !SafeRebuild(); rebuilt = true; }
}
else
{
// Sink lacks the surgical capability ⇒ rebuild (safe default).
rebuildFailed = !SafeRebuild();
rebuilt = true;
}
}
_logger.LogInformation(
"AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, surgicalRawTags={SurgicalRaw}, unsRefDeltas={UnsRefs}, renamedFolders={Renamed}, rebuild={Rebuild})",
kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count,
rebuilt ? 0 : surgicalRawTagDeltas.Count, rebuilt ? 0 : unsRefDeltas.Count,
rebuilt ? 0 : renamedFolders.Count, rebuilt);
// After the address-space work has completed, auto-provision the historian for the added
// historized tags. This is fully detached (fire-and-forget) and wrapped so it can NEVER block
// or break the deploy — Apply has already produced its outcome and returns it regardless.
ProvisionHistorizedTags(plan);
// Alongside provisioning: feed the continuous-historization recorder the add/remove delta of
// historized tag refs this plan changes, so its dependency-mux interest converges to exactly the
// currently-historized set. Same non-blocking + throw-safe discipline as the provisioning hook.
FeedHistorizedRefs(plan);
return new AddressSpaceApplyOutcome(removedCount, addedCount, changedCount, rebuilt, rebuildFailed, failedNodes);
}
/// <summary>
/// R2-07 Phase 2 — apply a PureRemove plan by tearing down ONLY the affected nodes IN PLACE (no full
/// rebuild). Removed equipment "own" their child tags/vtags/alarms, so any removed child whose
/// <c>EquipmentId</c> is itself in <see cref="AddressSpacePlan.RemovedEquipment"/> is SUBSUMED by the
/// subtree removal and skipped individually. For surviving-equipment removals: a removed value
/// variable gets a terminal Bad <c>WriteValue</c> then <see cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>;
/// a removed alarm-bearing tag (the pre-R2-07 today-gap: it got no terminal condition write) and a
/// removed scripted alarm get a terminal <see cref="RemovedConditionState"/> write then
/// <see cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>; each removed equipment gets one
/// <see cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>. The terminal write is what an
/// in-flight MonitoredItem observes (a final Bad); after the node is gone, re-subscription gets
/// <c>BadNodeIdUnknown</c>. Returns false on the FIRST remove that reports the node unknown / throws
/// (the caller's one-way rebuild ratchet takes over), leaving no further surgical work attempted.
/// </summary>
/// <param name="surgical">The surgical sink to route removes through.</param>
/// <param name="plan">The PureRemove plan.</param>
/// <param name="ts">The timestamp for the terminal writes.</param>
/// <param name="failedNodes">Accumulator for swallowed terminal-write failures (archreview 01/S-1).</param>
/// <returns>True when every removal succeeded; false on the first failure (caller rebuilds).</returns>
private bool ApplyPureRemove(ISurgicalAddressSpaceSink surgical, AddressSpacePlan plan, DateTime ts, ref int failedNodes)
{
var removedEquipmentIds = new HashSet<string>(
plan.RemovedEquipment.Select(e => e.EquipmentId), StringComparer.Ordinal);
bool NotSubsumed(string equipmentId) => !removedEquipmentIds.Contains(equipmentId);
// Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment.
foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId)))
{
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
if (tag.Alarm is not null)
{
// Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap
// closed), then remove the condition in place.
if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
if (!SafeRemoveAlarmCondition(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
}
else
{
// Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal.
SafeWriteValue(nodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
}
}
// Removed VirtualTags (always plain value variables) for surviving equipment.
foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId)))
{
var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name);
SafeWriteValue(nodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
}
// Removed scripted alarms for surviving equipment — the terminal RemovedConditionState was already
// written by the top-of-Apply removal block; here we tear the condition node down in place.
foreach (var alarm in plan.RemovedAlarms.Where(a => NotSubsumed(a.EquipmentId)))
{
if (!SafeRemoveAlarmCondition(surgical, alarm.ScriptedAlarmId, AddressSpaceRealm.Uns)) return false;
}
// v3 Batch 4 — removed UNS reference variables (Uns realm, plain value nodes): terminal Bad then
// in-place remove.
//
// WHAT REACHES THIS LOOP (the "→ rebuild" this comment used to claim is long dead — re-points are
// surgical now): only genuine DEREFERENCES, i.e. the equipment dropped the reference row, so the node
// is gone for good — INCLUDING every reference of a removed equipment, which the planner lists here
// too. A CHANGED reference never lands in RemovedUnsReferenceVariables at all — the planner reports it
// as a UnsReferenceDelta, which Apply routes above either to an in-place UpdateTagAttributes (identity
// unchanged) or to its own remove-the-previous + recreate-the-current pair (identity changed). That
// second shape does remove a node, but it removes it from Apply's delta loop, not from here.
//
// Gitea #599 (archreview 03/P5): this runs BEFORE the equipment-subtree removal below, deliberately,
// instead of skipping a removed equipment's references with the NotSubsumed filter the loops above
// use. Whether the subtree removal reaches a reference depends on its NodeId, not on its parent: the
// node manager scopes a subtree by id prefix, and a reference's NodeId is Area/Line/Equipment/Name
// while the equipment folder's is its EquipmentId — so in production the subtree does NOT reach it,
// and a filter would leak the node. Where an id does sit under the equipment id, the old order had the
// subtree take the node first, this loop then reported it unknown, and the deploy ratcheted to a full
// rebuild — severing every subscription on the server to remove one equipment. Removing references
// first is correct for both shapes: each is still present, gets its terminal Bad, and goes through
// RemoveVariableNode, which also severs its inverse Organizes edge on the surviving raw node.
foreach (var v in plan.RemovedUnsReferenceVariables)
{
SafeWriteValue(v.NodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, v.NodeId, AddressSpaceRealm.Uns)) return false;
}
// Removed equipment — one subtree teardown each (subsumes their child tags/vtags/alarms). The
// top-of-Apply block already wrote the equipment id's terminal condition state.
foreach (var eq in plan.RemovedEquipment)
{
if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId, AddressSpaceRealm.Uns)) return false;
}
// v3 Batch 4 — removed RAW tags (Raw realm): an alarm-bearing raw tag is a condition node (terminal
// RemovedConditionState + RemoveAlarmConditionNode); a plain raw value tag is a variable (terminal Bad
// + RemoveVariableNode).
foreach (var t in plan.RemovedRawTags)
{
if (t.Alarm is not null)
{
if (!SafeWriteAlarmCondition(t.NodeId, RemovedConditionState, ts, AddressSpaceRealm.Raw)) failedNodes++;
if (!SafeRemoveAlarmCondition(surgical, t.NodeId, AddressSpaceRealm.Raw)) return false;
}
else
{
SafeWriteValue(t.NodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Raw);
if (!SafeRemoveVariable(surgical, t.NodeId, AddressSpaceRealm.Raw)) return false;
}
}
// Removed raw CONTAINER nodes (Folder/Driver/Device/TagGroup) have no surgical folder-remove on the
// sink surface, so any container removal falls back to a full rebuild (the ratchet). Evaluated LAST so
// the cheap variable/condition removals above still run in place when only tags were removed.
if (plan.RemovedRawContainers.Count > 0) return false;
return true;
}
/// <summary>
/// 2026-08-15 review I2 — true when a <see cref="AddressSpacePlan.UnsReferenceDelta"/> can be applied
/// as an IN-PLACE attribute update rather than a remove+recreate: the node's identity is untouched.
/// Three fields make up that identity — the <c>NodeId</c> (an OPC UA NodeId is immutable, so a moved
/// effective name is a different node), the <c>EquipmentId</c> (the node's PARENT, which
/// <c>UpdateTagAttributes</c> cannot re-hang), and the <c>BackingRawPath</c> (the target of the node's
/// <c>Organizes</c> edge, which the surgical sink surface has no way to move — there is no
/// RemoveReference). Everything else on the record is a plain attribute the in-place update carries.
/// </summary>
/// <param name="d">The UNS reference delta to classify.</param>
/// <returns><c>true</c> when the delta is attribute-only and may be applied in place.</returns>
/// <summary>
/// Gitea #610 — the ONE writability rule for a raw value node, shared by the materialise pass and the
/// surgical in-place update so the two can never disagree: authored <c>ReadWrite</c>, and — for an array —
/// a driver that can write the array (<see cref="RawTagPlan.ArrayWritable"/>, from
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverArrayWriteSupport"/>). An array tag on a driver
/// with no array encoder stays read-only, so a client write is refused at the node (<c>BadNotWritable</c>)
/// instead of being optimistically applied and reverted.
/// </summary>
internal static bool RawNodeWritable(RawTagPlan t) => t.Writable && (!t.IsArray || t.ArrayWritable);
/// <summary>The UNS-mirror counterpart of <see cref="RawNodeWritable"/>, off the inherited <c>Backing*</c>
/// fields — a mirror is writable exactly when its raw node is.</summary>
internal static bool UnsReferenceNodeWritable(UnsReferenceVariable v) =>
v.Writable && (!v.BackingIsArray || v.BackingArrayWritable);
private static bool IsIdentityPreservingUnsRefDelta(AddressSpacePlan.UnsReferenceDelta d) =>
string.Equals(d.Previous.NodeId, d.Current.NodeId, StringComparison.Ordinal) &&
string.Equals(d.Previous.EquipmentId, d.Current.EquipmentId, StringComparison.Ordinal) &&
string.Equals(d.Previous.BackingRawPath, d.Current.BackingRawPath, StringComparison.Ordinal);
/// <summary>Remove a value-variable node in place, treating a throw as a false (⇒ rebuild fallback).</summary>
/// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns>
private bool SafeRemoveVariable(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm)
{
try { return surgical.RemoveVariableNode(nodeId, realm); }
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveVariableNode threw for {Node}", nodeId); return false; }
}
/// <summary>Remove an alarm-condition node in place, treating a throw as a false (⇒ rebuild fallback).</summary>
/// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns>
private bool SafeRemoveAlarmCondition(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm)
{
try { return surgical.RemoveAlarmConditionNode(nodeId, realm); }
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveAlarmConditionNode threw for {Node}", nodeId); return false; }
}
/// <summary>Remove an equipment subtree in place, treating a throw as a false (⇒ rebuild fallback).</summary>
/// <returns><c>true</c> when the subtree was removed; <c>false</c> when unknown or the sink threw.</returns>
private bool SafeRemoveEquipmentSubtree(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm)
{
try { return surgical.RemoveEquipmentSubtree(nodeId, realm); }
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveEquipmentSubtree threw for {Node}", nodeId); return false; }
}
/// <summary>Write a value, swallowing (and Warning-logging) any sink fault — used for the terminal Bad
/// on a removed variable so an in-flight MonitoredItem observes the removal.
/// <para>Every caller passes the generic <see cref="OpcUaStatus.Bad"/>, which is exactly what the
/// retired 3-state projection produced here. A removal could say something more specific (the node is
/// about to stop existing, so <c>BadNodeIdUnknown</c> would be truer), but #519 was about not LOSING a
/// sub-code the driver already had — inventing a new one for the deploy path is a separate call, so
/// this deliberately stays behaviour-identical.</para></summary>
/// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
private bool SafeWriteValue(string nodeId, object? value, uint statusCode, DateTime ts, AddressSpaceRealm realm)
{
try { _sink.WriteValue(nodeId, value, statusCode, ts, realm); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteValue threw for {Node}", nodeId); return false; }
}
/// <summary>
/// Compute the deduplicated, deterministically-ordered set of affected PARENT node ids to announce
/// (Part 3 <c>NodeAdded</c>) after a pure-add apply's Materialise passes have created the new nodes.
/// For each added tag/vtag the parent is its materialise folder (the equipment folder, or the
/// <c>FolderPath</c> sub-folder — the exact ids the Materialise passes place children at); for each
/// added scripted alarm the parent is its equipment folder (where the condition node parents); for
/// each added equipment the parent is its UNS line folder when set, else the equipment's own new
/// folder id (a valid Part 3 announcement of the folder itself — the root has no announceable id).
/// Pure — no sink interaction — so it is unit-testable in isolation and safe to call before the
/// nodes exist. Ordinal-sorted so the announce sequence is stable.
/// </summary>
/// <param name="plan">The plan whose added nodes' parents to announce.</param>
/// <returns>The distinct affected parent node ids, ordinal-sorted.</returns>
public IReadOnlyList<string> ComputeAddAnnouncements(AddressSpacePlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in plan.AddedEquipmentTags) ids.Add(MaterialiseParent(t.EquipmentId, t.FolderPath));
foreach (var v in plan.AddedEquipmentVirtualTags) ids.Add(MaterialiseParent(v.EquipmentId, v.FolderPath));
foreach (var a in plan.AddedAlarms) ids.Add(a.EquipmentId);
// v3 Batch 4 / review I5 — an added UNS reference variable is materialised directly under its
// equipment folder (MaterialiseUnsReferences passes EquipmentId as the parent), and the equipment
// folder is a UNS-realm node, announced in the Uns realm like every id this method returns. Added RAW
// containers/tags have Raw-realm parents and are computed separately (ComputeRawAddAnnouncements).
foreach (var v in plan.AddedUnsReferenceVariables) ids.Add(v.EquipmentId);
foreach (var eq in plan.AddedEquipment)
ids.Add(string.IsNullOrWhiteSpace(eq.UnsLineId) ? eq.EquipmentId : eq.UnsLineId);
var list = ids.ToList();
list.Sort(StringComparer.Ordinal);
return list;
}
/// <summary>
/// Gitea #599 (archreview 01/U-8 = 03/P6) — the Raw-realm counterpart of
/// <see cref="ComputeAddAnnouncements"/>: the deduplicated, ordinal-sorted RAW parent node ids to announce
/// after a non-rebuild apply materialised added raw containers (Folder/Driver/Device/TagGroup) and raw
/// tags. Each added node announces its <c>ParentNodeId</c> — the exact parent
/// <c>MaterialiseRawSubtree</c> hangs it under. A parent that is ITSELF added in the same plan is skipped:
/// its own parent is announced, and a client re-browsing there discovers the whole new subtree, so the
/// inner announcements would only be noise. A top-level container (no parent — it hangs under the shared
/// root folder, which has no realm-qualified id to announce) announces itself, the same convention an
/// added equipment with no line uses.
/// </summary>
/// <param name="plan">The plan whose added raw nodes' parents to announce.</param>
/// <returns>The distinct affected Raw-realm parent node ids, ordinal-sorted.</returns>
public IReadOnlyList<string> ComputeRawAddAnnouncements(AddressSpacePlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var added = new HashSet<string>(StringComparer.Ordinal);
foreach (var c in plan.AddedRawContainers) added.Add(c.NodeId);
foreach (var t in plan.AddedRawTags) added.Add(t.NodeId);
var ids = new HashSet<string>(StringComparer.Ordinal);
void Announce(string nodeId, string? parentNodeId)
{
if (string.IsNullOrWhiteSpace(parentNodeId)) ids.Add(nodeId);
else if (!added.Contains(parentNodeId)) ids.Add(parentNodeId);
}
foreach (var c in plan.AddedRawContainers) Announce(c.NodeId, c.ParentNodeId);
foreach (var t in plan.AddedRawTags) Announce(t.NodeId, t.ParentNodeId);
var list = ids.ToList();
list.Sort(StringComparer.Ordinal);
return list;
}
/// <summary>The materialise-parent node id a tag/vtag's variable (or native-alarm condition) hangs
/// under: the equipment folder when <paramref name="folderPath"/> is null/empty, else the FolderPath
/// sub-folder. Mirrors the parent derivation the Materialise passes use.</summary>
/// <param name="equipmentId">The owning equipment folder id.</param>
/// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
/// <returns>The materialise-parent node id.</returns>
private static string MaterialiseParent(string equipmentId, string? folderPath) =>
string.IsNullOrWhiteSpace(folderPath) ? equipmentId : UnsSubFolder(equipmentId, folderPath);
// ----- v3 UNS-equipment NodeId helpers (replace the retired EquipmentNodeIds) -----
// Equipment-owned UNS nodes (per-equipment VirtualTags, the vestigial equipment-tag path, and the
// discovered-node graft) keep their EquipmentId-anchored slash-path NodeId ({equipmentId}/{folderPath}/{name}),
// now expressed through the v3 identity authority V3NodeIds.Uns so the retired EquipmentNodeIds type can go.
// These live in the UNS realm. FolderPath may be multi-segment ("a/b") — each segment is validated
// by V3NodeIds.Uns like a RawPath. (UnsTagReference variables use the composer's Area/Line/Equipment/Name
// NodeId directly and are NOT built here.)
private static string UnsSubFolder(string equipmentId, string folderPath) =>
V3NodeIds.Uns(new[] { equipmentId }.Concat(folderPath.Split(RawPaths.Separator)));
private static string UnsEquipmentVar(string equipmentId, string? folderPath, string name) =>
string.IsNullOrWhiteSpace(folderPath)
? V3NodeIds.Uns(equipmentId, name)
: V3NodeIds.Uns(new[] { equipmentId }.Concat(folderPath.Split(RawPaths.Separator)).Append(name));
/// <summary>
/// Announce a Part 3 <c>GeneralModelChangeEvent(NodeAdded)</c> per distinct affected parent from
/// <see cref="ComputeAddAnnouncements"/> (Uns realm) and <see cref="ComputeRawAddAnnouncements"/>
/// (Raw realm), so model-aware clients re-browse the parents the new nodes were added under. Called by
/// <c>OpcUaPublishActor</c> AFTER the Materialise passes (the nodes exist by then) and ONLY on a
/// non-rebuild apply — after a full rebuild the announcement is moot (subscriptions are dead anyway).
/// Uses the existing, guard-covered, realm-aware
/// <see cref="IOpcUaAddressSpaceSink.RaiseNodesAddedModelChange"/> — no new sink member. Each call is
/// Safe-wrapped: a faulting announcement can never break a deploy (the nodes already stand).
/// <para>Gitea #599: until the raw half existed, surgically-added raw containers/tags were materialised
/// but never announced, so a connected model-aware client could not discover them until it happened to
/// re-browse. (Before surgical deploys every raw add rebuilt the address space, which hid the gap.)</para>
/// </summary>
/// <param name="plan">The plan whose added nodes to announce.</param>
public void AnnounceAddedNodes(AddressSpacePlan plan)
{
foreach (var id in ComputeAddAnnouncements(plan)) SafeAnnounce(id, AddressSpaceRealm.Uns);
foreach (var id in ComputeRawAddAnnouncements(plan)) SafeAnnounce(id, AddressSpaceRealm.Raw);
}
/// <summary>Raise one NodeAdded model change, swallowing (and Warning-logging) any sink fault.</summary>
private void SafeAnnounce(string nodeId, AddressSpaceRealm realm)
{
try { _sink.RaiseNodesAddedModelChange(nodeId, realm); }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node} ({Realm})", nodeId, realm); }
}
/// <summary>
/// Resolve the historian name EXACTLY as MaterialiseRawSubtree does: a null/blank override falls
/// back to the RawPath (== NodeId).
/// </summary>
private static string ResolveHistorianName(RawTagPlan tag) =>
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname;
/// <summary>
/// Auto-provision the historian for the added (and newly-historized / renamed-historian) raw tags. Runs on the OPC UA
/// publish actor's pinned thread, so the synchronous portion is kept to building the request
/// list only and the gateway round-trip is dispatched fire-and-forget. The whole hook is wrapped
/// in try/catch — a synchronously-throwing provisioner (or any request-building fault) is
/// swallowed so it cannot break a deploy.
/// </summary>
/// <param name="plan">The plan whose added historized tags to ensure in the historian.</param>
private void ProvisionHistorizedTags(AddressSpacePlan plan)
{
try
{
List<HistorianTagProvisionRequest>? requests = null;
// v3 Batch 4: historized value tags are RAW tags (added-raw-tag delta). Native-alarm tags
// materialise as Part 9 conditions (never historized value variables), so mirror the materialiser
// and skip them.
foreach (var tag in plan.AddedRawTags)
{
if (!tag.IsHistorized || tag.Alarm is not null) continue;
AddRequest(tag);
}
// #600.1: a historization flip (false->true) or a historian-name / data-type change on an
// EXISTING raw tag never appears in AddedRawTags, so EnsureTags never fired for it. EnsureTags is
// an upsert, so re-ensuring is safe; an unchanged pair is a no-op. Only raw deltas are used - a
// UNS mirror delta must not double-provision the same historian tag.
foreach (var d in plan.ChangedRawTags)
{
var cur = d.Current;
if (!cur.IsHistorized || cur.Alarm is not null) continue;
var prev = d.Previous;
var wasProvisioned = prev.IsHistorized && prev.Alarm is null;
if (wasProvisioned
&& ResolveHistorianName(prev) == ResolveHistorianName(cur)
&& string.Equals(prev.DataType, cur.DataType, StringComparison.OrdinalIgnoreCase))
continue;
AddRequest(cur);
}
void AddRequest(RawTagPlan tag)
{
// An unparseable data type is skipped (logged at Debug) rather than faulting the hook.
if (!Enum.TryParse<DriverDataType>(tag.DataType, ignoreCase: true, out var dataType))
{
_logger.LogDebug(
"AddressSpaceApplier: skipping historian provisioning for a historized tag whose data type '{DataType}' is not a DriverDataType",
tag.DataType);
return;
}
(requests ??= new List<HistorianTagProvisionRequest>()).Add(
new HistorianTagProvisionRequest(ResolveHistorianName(tag), dataType, EngineeringUnit: null, Description: tag.Name));
}
if (requests is null) return;
// Fire-and-forget OFF the apply path. Never await/.Wait()/.Result here — Apply must return
// its outcome without blocking on the gateway. The continuation observes the task so a
// faulted provisioning never becomes an unobserved exception, and logs the tally.
var provisionCount = requests.Count;
var dispatch = _provisioning.EnsureTagsAsync(requests, CancellationToken.None);
_ = dispatch.ContinueWith(
t =>
{
if (!t.IsCompletedSuccessfully)
{
// Faulted OR canceled — never reach t.Result (which would re-throw and
// leave this discarded continuation unobserved).
_logger.LogWarning(t.Exception?.GetBaseException(),
"AddressSpaceApplier: historian provisioning of {Count} tag(s) did not complete; deploy unaffected", provisionCount);
return;
}
// Emit a tally on EVERY successful dispatch (not only on Failed/Skipped) so a fully-
// successful provisioning is observable AND a dormant no-op is detectable: the no-op
// NullHistorianProvisioning reports Requested=0 regardless of input, so a line showing
// dispatched={N} but requested=0 unmistakably flags "provisioning is not wired" — the
// exact invisibility that let the dormant-provisioner bug ship unnoticed.
var result = t.Result;
_logger.LogInformation(
"AddressSpaceApplier: historian provisioning completed (dispatched={Dispatched}, requested={Requested}, ensured={Ensured}, skipped={Skipped}, failed={Failed})",
provisionCount, result.Requested, result.Ensured, result.Skipped, result.Failed);
},
CancellationToken.None,
TaskContinuationOptions.None,
TaskScheduler.Default);
}
catch (Exception ex)
{
// A synchronous fault (e.g. the provisioner throws before returning a task) must not break
// the deploy. Apply has already produced its outcome.
_logger.LogWarning(ex, "AddressSpaceApplier: historian provisioning hook faulted synchronously; deploy unaffected");
}
}
/// <summary>
/// Feed the continuous-historization recorder the add/remove delta of historized tag refs this
/// plan changes, so its dependency-mux interest converges to exactly the currently-historized set
/// after every deploy. The plan is a pure DIFF (an incremental/surgical apply carries a delta, not
/// the full set), so a delta feed is the only convergent design this hook can produce — the
/// recorder keeps the full set and re-registers it. Each ref is resolved EXACTLY as
/// <see cref="ProvisionHistorizedTags"/> / <c>MaterialiseEquipmentTags</c> resolve it
/// (override-or-FullName), and only non-alarm historized value variables count (native-alarm tags
/// materialise as Part 9 condition nodes, never historized value variables). Runs on the OPC UA
/// publish actor's pinned thread, so the only work here is building two small ref lists; the
/// downstream feed is a single non-blocking post behind the sink. The whole hook is wrapped so a
/// faulting feed can never block or break a deploy.
/// </summary>
/// <param name="plan">The plan whose historized-ref changes drive the recorder's interest set.</param>
private void FeedHistorizedRefs(AddressSpacePlan plan)
{
try
{
List<HistorizedTagRef>? added = null;
List<HistorizedTagRef>? removed = null;
// v3 Batch 4: historized value tags are RAW tags (keyed by RawPath). The mux HistorizedTagRef
// stays SINGLE, keyed by RawPath — a UNS reference node registers the same historian tagname in the
// node manager but does NOT add a second mux ref (no double historization). So this feed emits raw
// refs only.
// Added historized raw value tags → new interest.
foreach (var tag in plan.AddedRawTags)
{
if (HistorizedRef(tag) is { } r) (added ??= new List<HistorizedTagRef>()).Add(r);
}
// Removed historized raw value tags → drop interest.
foreach (var tag in plan.RemovedRawTags)
{
if (HistorizedRef(tag) is { } r) (removed ??= new List<HistorizedTagRef>()).Add(r);
}
// Changed raw tags: the historized ref may have flipped on/off or the historian override changed
// (a rename is remove+add, handled above). Compare previous-vs-current resolved ref PAIRS — an
// unchanged pair is a no-op.
foreach (var d in plan.ChangedRawTags)
{
var prev = HistorizedRef(d.Previous);
var cur = HistorizedRef(d.Current);
if (prev == cur) continue;
if (prev is not null) (removed ??= new List<HistorizedTagRef>()).Add(prev);
if (cur is not null) (added ??= new List<HistorizedTagRef>()).Add(cur);
}
if (added is null && removed is null) return;
_historizedSubscriptions.UpdateHistorizedRefs(
added ?? (IReadOnlyList<HistorizedTagRef>)Array.Empty<HistorizedTagRef>(),
removed ?? (IReadOnlyList<HistorizedTagRef>)Array.Empty<HistorizedTagRef>());
}
catch (Exception ex)
{
// A synchronous fault in the feed (or in building the ref lists) must not break the deploy.
// Apply has already produced its outcome.
_logger.LogWarning(ex, "AddressSpaceApplier: historized-ref subscription feed faulted; deploy unaffected");
}
}
/// <summary>
/// Resolve the historized tag ref for <paramref name="tag"/> as a
/// <see cref="HistorizedTagRef"/> carrying BOTH identifiers the recorder needs: the
/// <c>MuxRef</c> = the driver-side <c>FullName</c> the dependency mux fans values by, and the
/// <c>HistorianName</c> = the value the EnsureTags hook / materialiser write under (a non-alarm
/// historized value variable's <c>HistorianTagname</c> override, else its <c>FullName</c>). The
/// two diverge ONLY when an override is set. Returns <c>null</c> when the tag is not a historized
/// value variable (not historized, or a native-alarm condition node).
/// </summary>
/// <param name="tag">The raw tag to resolve a historized ref for.</param>
/// <returns>The resolved historized ref pair, or <c>null</c> when the tag is not a historized value variable.</returns>
private static HistorizedTagRef? HistorizedRef(RawTagPlan tag) =>
tag.IsHistorized && tag.Alarm is null
// v3: the RawPath (== NodeId) is BOTH the mux ref (the driver fans values by RawPath) and the
// default historian tagname; an override diverges only the historian name.
? new HistorizedTagRef(
tag.NodeId,
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname)
: null;
/// <summary>Rebuild the sink's address space, swallowing (and Error-logging) any fault.
/// Returns <c>true</c> on success, <c>false</c> when the sink threw — the caller threads the
/// result into <see cref="AddressSpaceApplyOutcome.RebuildFailed"/> so a broken rebuild is
/// visible instead of reported as optimistic success (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the rebuild completed; <c>false</c> when the sink threw.</returns>
private bool SafeRebuild()
{
try
{
_sink.RebuildAddressSpace();
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "AddressSpaceApplier: sink.RebuildAddressSpace threw");
return false;
}
}
/// <summary>
/// Build the UNS Area/Line/Equipment folder hierarchy in the address space from a
/// composition snapshot. Called by <c>OpcUaPublishActor</c> after a rebuild so OPC UA
/// clients browsing the server see proper folder structure instead of flat tag ids.
/// Idempotent: each <c>EnsureFolder</c> call returns the existing folder if already
/// present, so re-applies are cheap.
/// <para><b>This pass has NO subset overload, deliberately — it always runs over the FULL
/// composition</b>, unlike the five node-materialising passes below (which take an optional
/// <c>only*</c> delta subset so a non-rebuild apply touches the plan's added nodes instead of the
/// whole address space). Two reasons: it is O(areas + lines + equipment), which is a rounding error
/// next to the tag/reference counts the subsetting exists to bound; and every added node materialised
/// by the other passes needs its ANCESTOR folders to already exist, so narrowing this one to the
/// plan's delta would mean re-deriving the ancestor closure of every add — strictly more work than
/// re-ensuring the folders.</para>
/// </summary>
/// <param name="composition">The composition result containing the hierarchy to materialise.</param>
/// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
/// sums it into the degraded-apply surface (archreview 01/S-1).</returns>
public int MaterialiseHierarchy(AddressSpaceComposition composition)
{
ArgumentNullException.ThrowIfNull(composition);
var failed = 0;
foreach (var area in composition.UnsAreas)
{
if (!SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName, realm: AddressSpaceRealm.Uns)) failed++;
}
foreach (var line in composition.UnsLines)
{
if (!SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName, realm: AddressSpaceRealm.Uns)) failed++;
}
foreach (var equipment in composition.EquipmentNodes)
{
// Equipment with no UnsLineId (legacy / dev rows) hang under the root.
var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId;
if (!SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName, realm: AddressSpaceRealm.Uns)) failed++;
}
_logger.LogInformation(
"AddressSpaceApplier: hierarchy materialised (areas={Areas}, lines={Lines}, equipment={Equipment}, failed={Failed})",
composition.UnsAreas.Count, composition.UnsLines.Count, composition.EquipmentNodes.Count, failed);
return failed;
}
/// <summary>
/// v3 Batch 4 — materialise the <b>Raw</b> device-oriented subtree (<c>ns=Raw</c>): the container
/// nodes (Folder→Driver→Device→TagGroup, each an <c>Object</c>/<c>Folder</c>) parents-before-children,
/// then the tag Variable nodes keyed <c>s=&lt;RawPath&gt;</c>. A native-alarm tag (<see cref="RawTagPlan.Alarm"/>
/// non-null) materialises as a Part 9 condition node at its RawPath (ConditionId = RawPath, parent = its
/// device/group folder) instead of a value variable — the single condition instance; WP4 wires the extra
/// per-equipment notifiers. A historized value tag resolves its effective historian tagname HERE
/// (override else the RawPath). Array tags are forced read-only (the driver write path can't handle
/// arrays). Every sink call passes <see cref="AddressSpaceRealm.Raw"/> EXPLICITLY — the driver binds
/// values to these raw NodeIds, and every referencing UNS node fans out from them. Idempotent.
/// <para><b>Subset overload (2026-08-15 surgical-deploys).</b> <paramref name="onlyContainers"/> /
/// <paramref name="onlyTags"/> narrow WHAT is materialised to a plan's delta while
/// <paramref name="composition"/> stays the FULL composition for context lookups — specifically the
/// native-alarm notifier map (<see cref="BuildEquipmentIdByFolderPath"/>), which must see every
/// equipment on the node, not just the ones this deploy touched. A <c>null</c> subset means "the whole
/// composition" (the rebuild path), so every pre-existing call site is unchanged. Ordering holds in
/// both modes: the composer sorts <c>RawContainers</c> by NodeId and the planner sorts its added set by
/// the same key, so a parent's RawPath still precedes each child's.</para>
/// </summary>
/// <param name="composition">The FULL composition — the context for lookups even when a subset is passed.</param>
/// <param name="onlyContainers">Materialise only these containers; <c>null</c> ⇒ the whole composition's.</param>
/// <param name="onlyTags">Materialise only these tags; <c>null</c> ⇒ the whole composition's.</param>
/// <returns>The count of swallowed sink failures in this pass.</returns>
public int MaterialiseRawSubtree(
AddressSpaceComposition composition,
IReadOnlyList<RawContainerNode>? onlyContainers = null,
IReadOnlyList<RawTagPlan>? onlyTags = null)
{
ArgumentNullException.ThrowIfNull(composition);
var containers = onlyContainers ?? composition.RawContainers;
var tags = onlyTags ?? composition.RawTags;
if (containers.Count == 0 && tags.Count == 0) return 0;
var failed = 0;
// Containers first — the composer sorts them by NodeId (ordinal) so a parent's RawPath precedes each
// child's; EnsureFolder is idempotent, so a re-apply is cheap.
foreach (var c in containers)
{
if (!SafeEnsureFolder(c.NodeId, c.ParentNodeId, c.DisplayName, AddressSpaceRealm.Raw)) failed++;
}
// v3 Batch 4 WP4 — reverse map (equipment UNS folder PATH → EquipmentId) so a native alarm's
// ReferencingEquipmentPaths (Area/Line/Equipment name paths, from the composer) resolve to the
// EquipmentId the equipment folders were materialised under by MaterialiseHierarchy. Built lazily only
// when at least one raw tag carries an alarm (the common no-alarm deploy pays nothing).
IReadOnlyDictionary<string, string>? equipIdByFolderPath = null;
foreach (var t in tags)
{
if (t.Alarm is not null)
{
// Native alarm tag → a single Part 9 condition node at the RawPath (ConditionId = RawPath).
// Parent is its device/group folder. The single condition instance is materialised ONCE here.
if (!SafeMaterialiseAlarmCondition(t.NodeId, t.ParentNodeId ?? string.Empty, t.Name, t.Alarm.AlarmType, t.Alarm.Severity, isNative: true, AddressSpaceRealm.Raw))
{
failed++;
}
else if (t.ReferencingEquipmentPaths.Count > 0)
{
// WP4 multi-notifier fan-out: wire the SINGLE condition as an event notifier of each
// referencing equipment's UNS folder, so one ReportEvent reaches every referencing equipment
// (never re-reported per root). Resolve the folder PATHS to their EquipmentId folder NodeIds
// (the id scheme MaterialiseHierarchy created the equipment folders under).
equipIdByFolderPath ??= BuildEquipmentIdByFolderPath(composition);
var equipFolderNodeIds = t.ReferencingEquipmentPaths
.Select(p => equipIdByFolderPath!.GetValueOrDefault(p))
.Where(id => !string.IsNullOrEmpty(id))
.Select(id => id!)
.Distinct(StringComparer.Ordinal)
.ToList();
if (equipFolderNodeIds.Count == 0)
{
// Wave C review M2: the alarm HAS referencing equipment but NONE of its paths resolved
// to an EquipmentId folder — the native fan-out to those equipment is silently absent.
// Surface it (Warning + a failed-node tally so the degraded-apply meter fires) instead
// of swallowing zero operator signal. Today this only happens on a broken UNS ancestry /
// an invalid segment (the composer would have dropped the equipment too); the latent
// risk is a future editable Area/Line display-name feature breaking the path↔id coupling
// (see BuildEquipmentIdByFolderPath + AddressSpaceComposerPathParityTests).
_logger.LogWarning(
"AddressSpaceApplier: native alarm {Node} has {Count} referencing equipment path(s) but NONE resolved to an equipment folder — the alarm's multi-notifier fan-out to those equipment is absent (paths={Paths})",
t.NodeId, t.ReferencingEquipmentPaths.Count, string.Join(", ", t.ReferencingEquipmentPaths));
failed++;
}
else if (!SafeWireAlarmNotifiers(t.NodeId, AddressSpaceRealm.Raw, equipFolderNodeIds, AddressSpaceRealm.Uns))
{
failed++;
}
}
}
else
{
// A historized tag materialises Historizing + HistoryRead; the effective historian tagname is
// the override else the RawPath (== NodeId). An array is writable only when its driver can
// write arrays (#610) — see RawNodeWritable.
string? historianTagname = t.IsHistorized
? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname)
: null;
var writable = RawNodeWritable(t);
if (!SafeEnsureVariable(t.NodeId, t.ParentNodeId, t.Name, t.DataType, writable, AddressSpaceRealm.Raw, historianTagname, t.IsArray, t.ArrayLength)) failed++;
}
}
_logger.LogInformation(
"AddressSpaceApplier: raw subtree materialised (containers={Containers}, tags={Tags}, subset={Subset}, failed={Failed})",
containers.Count, tags.Count, onlyContainers is not null || onlyTags is not null, failed);
return failed;
}
/// <summary>
/// v3 Batch 4 — materialise the <b>UNS</b> reference Variable nodes (<c>ns=UNS</c>): one per
/// <see cref="UnsReferenceVariable"/>, keyed <c>s=&lt;Area&gt;/&lt;Line&gt;/&lt;Equipment&gt;/&lt;EffectiveName&gt;</c>,
/// parented under its equipment folder (the logical <see cref="UnsReferenceVariable.EquipmentId"/> node
/// <see cref="MaterialiseHierarchy"/> already created), THEN an <c>Organizes</c> reference
/// UNS→Raw to its backing raw node. The UNS node inherits DataType / writable / array shape / historian
/// tagname from its backing RAW tag, so a historized raw tag's UNS reference registers the SAME tagname
/// (both NodeIds → one tagname → HistoryRead works against either). The UNS node binds NO driver — the
/// driver publish path fans values to it (WP3 runtime binding). Every sink call passes
/// <see cref="AddressSpaceRealm.Uns"/> / the Organizes edge passes both realms EXPLICITLY. Idempotent.
/// <para><b>The inherited values are read from the <see cref="UnsReferenceVariable"/> RECORD, not
/// re-looked-up from <see cref="AddressSpaceComposition.RawTags"/> by RawPath.</b> The record is the
/// single truth: the composer copies them off the backing tag's <c>TagConfigIntent</c> at compose time
/// (both compose seams, byte-parity), and having them ON the record is what makes a raw-side historian /
/// shape edit produce the coincident <c>UnsReferenceDelta</c> that drives this node's repair
/// (remove-previous in <c>Apply</c>, recreate here). A back-lookup would re-introduce the split the
/// 2026-08-15 review found: a record that says one thing and a materialised node built from another.</para>
/// <para><b>Subset overload (2026-08-15 surgical-deploys).</b> <paramref name="only"/> narrows WHAT is
/// materialised to a plan's delta (its added references PLUS the <i>current</i> side of every changed
/// one — <c>Apply</c> removed the previous node, so omitting the currents would leave a re-pointed or
/// renamed reference removed and never recreated). <paramref name="composition"/> stays the FULL
/// composition for context; a <c>null</c> subset means "the whole composition" (the rebuild path).</para>
/// </summary>
/// <param name="composition">The FULL composition — the context even when a subset is passed.</param>
/// <param name="only">Materialise only these references; <c>null</c> ⇒ the whole composition's.</param>
/// <returns>The count of swallowed sink failures in this pass.</returns>
public int MaterialiseUnsReferences(
AddressSpaceComposition composition,
IReadOnlyList<UnsReferenceVariable>? only = null)
{
ArgumentNullException.ThrowIfNull(composition);
var references = only ?? composition.UnsReferenceVariables;
if (references.Count == 0) return 0;
var failed = 0;
foreach (var v in references)
{
var isArray = v.BackingIsArray;
uint? arrayLength = v.BackingArrayLength;
// A UNS reference to a historized raw tag registers the SAME effective historian tagname (override
// else the backing RawPath — BackingRawPath IS the backing raw tag's NodeId) so HistoryRead resolves
// against the UNS NodeId too. The mux ref stays single (keyed by RawPath) — FeedHistorizedRefs emits
// raw refs only.
string? historianTagname = v.BackingIsHistorized
? (string.IsNullOrWhiteSpace(v.BackingHistorianTagname) ? v.BackingRawPath : v.BackingHistorianTagname)
: null;
var writable = UnsReferenceNodeWritable(v);
if (!SafeEnsureVariable(v.NodeId, v.EquipmentId, v.EffectiveName, v.DataType, writable, AddressSpaceRealm.Uns, historianTagname, isArray, arrayLength))
{
failed++;
continue; // node not created — skip the Organizes edge (a missing endpoint would no-op anyway)
}
// Organizes UNS→Raw so the linkage is browsable. Both endpoints are realm-qualified; a missing
// endpoint is a logged no-op in the sink (never throws), so this can't fault a deploy.
try { _sink.AddReference(v.NodeId, AddressSpaceRealm.Uns, v.BackingRawPath, AddressSpaceRealm.Raw); }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: AddReference (Organizes UNS->Raw) threw for {Node}", v.NodeId); failed++; }
}
_logger.LogInformation(
"AddressSpaceApplier: UNS reference variables materialised (refs={Refs}, subset={Subset}, failed={Failed})",
references.Count, only is not null, failed);
return failed;
}
/// <summary>
/// Materialise Equipment-namespace tags from a composition snapshot.
/// For each <see cref="EquipmentTagPlan"/>,
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder, then
/// ensure a Variable (NodeId = <c>FullName</c>, the driver-side ref) inside it. Variables
/// start BadWaitingForInitialData; the driver fills live values in a later milestone.
/// Idempotent. This is a sink-based pass, NOT a reuse of <c>EquipmentNodeWalker</c>: no
/// sink-backed <c>IAddressSpaceBuilder</c> adapter exists, and the walker re-creates the
/// whole Area/Line/Equipment tree with browse-path NodeIds, incompatible with this path's
/// logical-Id NodeIds and the already-materialised equipment folders. This pass adds ONLY
/// variables (and any per-tag sub-folder); <see cref="MaterialiseHierarchy"/> owns the
/// equipment folders and this pass never re-creates them. The sink's <c>EnsureVariable</c>
/// takes a plain <c>string dataType</c> (not a DriverAttributeInfo).
/// <para><b>Subset overload (2026-08-15 surgical-deploys).</b> <paramref name="only"/> narrows WHAT is
/// materialised to a plan's added tags; <paramref name="composition"/> stays the FULL composition for
/// context. A <c>null</c> subset means "the whole composition" (the rebuild path).</para>
/// </summary>
/// <param name="composition">The FULL composition — the context even when a subset is passed.</param>
/// <param name="only">Materialise only these tags; <c>null</c> ⇒ the whole composition's.</param>
/// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
/// sums it into the degraded-apply surface (archreview 01/S-1).</returns>
public int MaterialiseEquipmentTags(
AddressSpaceComposition composition,
IReadOnlyList<EquipmentTagPlan>? only = null)
{
ArgumentNullException.ThrowIfNull(composition);
var equipmentTags = only ?? composition.EquipmentTags;
if (equipmentTags.Count == 0) return 0;
var failed = 0;
// Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder
// (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the
// equipment folder, which MaterialiseHierarchy already created (never re-create
// the equipment folder here).
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
foreach (var tag in equipmentTags)
{
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
var folderNodeId = UnsSubFolder(tag.EquipmentId, tag.FolderPath);
if (!foldersCreated.Add(folderNodeId)) continue;
if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath, realm: AddressSpaceRealm.Uns)) failed++;
}
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), NOT the raw FullName — a driver
// ref (e.g. a Modbus register) is not unique across identical machines, so FullName-as-NodeId
// would collide in the sink (EnsureVariable keys on NodeId) and drop all but one machine's
// signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to
// route by. Parent is the FolderPath sub-folder when set, else the equipment folder directly.
// Per-variable idempotency relies on the sink's own EnsureVariable.
foreach (var tag in equipmentTags)
{
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
? tag.EquipmentId
: UnsSubFolder(tag.EquipmentId, tag.FolderPath);
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
if (tag.Alarm is not null)
{
// Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path),
// NOT a value variable. Parent is the sub-folder when set, else the equipment folder.
if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true, realm: AddressSpaceRealm.Uns)) failed++;
}
else
{
// Phase C: a historized tag materialises Historizing + HistoryRead. Resolve the effective
// historian tagname HERE (default-vs-override): a null/blank override falls back to the
// driver-side FullName; null means the tag is not historized at all.
string? historianTagname = tag.IsHistorized
? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
: null;
// Retired equipment-tag path (structurally empty in v3): array tags stay forced read-only
// here. The live array-write capability (#610) is carried on RawTagPlan /
// UnsReferenceVariable only — see RawNodeWritable.
var writable = tag.Writable && !tag.IsArray;
if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, AddressSpaceRealm.Uns, historianTagname, tag.IsArray, tag.ArrayLength)) failed++;
}
}
_logger.LogInformation(
"AddressSpaceApplier: equipment tags materialised (tags={Tags}, equipment={Equipment}, subset={Subset}, failed={Failed})",
equipmentTags.Count,
equipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count(),
only is not null,
failed);
return failed;
}
/// <summary>
/// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag
/// analogue of <see cref="MaterialiseEquipmentTags"/>. For each <see cref="EquipmentVirtualTagPlan"/>,
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder (in
/// practice <c>FolderPath</c> is empty for VirtualTags, so this is usually a no-op), then ensure
/// a Variable inside it. Like the tag pass, the variable's NodeId is FOLDER-SCOPED
/// (<c>parent/Name</c>) — NOT the <see cref="EquipmentVirtualTagPlan.VirtualTagId"/> or
/// <see cref="EquipmentVirtualTagPlan.Expression"/> — so identically-named VirtualTags on
/// different equipments never collide in the sink (which keys on NodeId). Variables start
/// BadWaitingForInitialData; <c>VirtualTagActor</c> fills live values in a later milestone.
/// Idempotent (per-variable idempotency relies on the sink's own <c>EnsureVariable</c>).
/// <para><b>Subset overload (2026-08-15 surgical-deploys).</b> <paramref name="only"/> narrows WHAT is
/// materialised to a plan's added VirtualTags; <paramref name="composition"/> stays the FULL
/// composition for context. A <c>null</c> subset means "the whole composition" (the rebuild path).</para>
/// </summary>
/// <param name="composition">The FULL composition — the context even when a subset is passed.</param>
/// <param name="only">Materialise only these VirtualTags; <c>null</c> ⇒ the whole composition's.</param>
/// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
/// sums it into the degraded-apply surface (archreview 01/S-1).</returns>
public int MaterialiseEquipmentVirtualTags(
AddressSpaceComposition composition,
IReadOnlyList<EquipmentVirtualTagPlan>? only = null)
{
ArgumentNullException.ThrowIfNull(composition);
var virtualTags = only ?? composition.EquipmentVirtualTags;
if (virtualTags.Count == 0) return 0;
var failed = 0;
// Sub-folders first — a VirtualTag's FolderPath becomes one folder UNDER its equipment folder
// (deduped per distinct equipment+path). VirtualTags with no FolderPath hang directly under the
// equipment folder, which MaterialiseHierarchy already created (never re-create it here).
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
foreach (var v in virtualTags)
{
if (string.IsNullOrWhiteSpace(v.FolderPath)) continue;
var folderNodeId = UnsSubFolder(v.EquipmentId, v.FolderPath);
if (!foldersCreated.Add(folderNodeId)) continue;
if (!SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath, realm: AddressSpaceRealm.Uns)) failed++;
}
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), mirroring the equipment-tag pass.
// Parent is the FolderPath sub-folder when set, else the equipment folder directly.
// NOTE (H5): a VirtualTag's Historize flag is honoured on the WRITE side only — VirtualTagHostActor
// forwards historized results to IHistoryWriter. It is intentionally NOT materialised as an SDK
// Historizing/HistoryRead variable here (no server-side OPC UA HistoryRead for vtags), so these
// stay plain read-only computed-output nodes.
foreach (var v in virtualTags)
{
var parent = string.IsNullOrWhiteSpace(v.FolderPath)
? v.EquipmentId
: UnsSubFolder(v.EquipmentId, v.FolderPath);
var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name);
// VirtualTags are computed outputs — read-only nodes (no inbound write).
if (!SafeEnsureVariable(nodeId, parent, v.Name, v.DataType, writable: false, realm: AddressSpaceRealm.Uns)) failed++;
}
_logger.LogInformation(
"AddressSpaceApplier: equipment virtualtags materialised (vtags={Vtags}, equipment={Equipment}, subset={Subset}, failed={Failed})",
virtualTags.Count,
virtualTags.Select(v => v.EquipmentId).Distinct(StringComparer.Ordinal).Count(),
only is not null,
failed);
return failed;
}
/// <summary>
/// T14 — materialise real OPC UA Part 9 <c>AlarmConditionState</c> nodes from a composition
/// snapshot. For each <b>enabled</b> <see cref="EquipmentScriptedAlarmPlan"/>, register a
/// condition node (keyed by its <see cref="EquipmentScriptedAlarmPlan.ScriptedAlarmId"/>, which
/// is the same id <c>OpcUaPublishActor.AlarmStateUpdate</c> targets) under its equipment folder.
/// Disabled alarms are skipped — they expose no node. Must run AFTER
/// <see cref="MaterialiseHierarchy"/> so the equipment folders exist. Idempotent (the sink's
/// <c>MaterialiseAlarmCondition</c> re-creates cleanly on re-apply).
/// <para><b>Subset overload (2026-08-15 surgical-deploys).</b> <paramref name="only"/> narrows WHAT is
/// materialised to a plan's added alarms; <paramref name="composition"/> stays the FULL composition for
/// context. A <c>null</c> subset means "the whole composition" (the rebuild path).</para>
/// </summary>
/// <param name="composition">The FULL composition — the context even when a subset is passed.</param>
/// <param name="only">Materialise only these alarms; <c>null</c> ⇒ the whole composition's.</param>
/// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
/// sums it into the degraded-apply surface (archreview 01/S-1).</returns>
public int MaterialiseScriptedAlarms(
AddressSpaceComposition composition,
IReadOnlyList<EquipmentScriptedAlarmPlan>? only = null)
{
ArgumentNullException.ThrowIfNull(composition);
var alarms = only ?? composition.EquipmentScriptedAlarms;
if (alarms.Count == 0) return 0;
var materialised = 0;
var failed = 0;
foreach (var alarm in alarms)
{
if (!alarm.Enabled) continue;
if (!SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false, realm: AddressSpaceRealm.Uns)) failed++;
materialised++;
}
_logger.LogInformation(
"AddressSpaceApplier: scripted alarms materialised (alarms={Alarms}, equipment={Equipment}, subset={Subset}, failed={Failed})",
materialised,
alarms.Where(a => a.Enabled)
.Select(a => a.EquipmentId).Distinct(StringComparer.Ordinal).Count(),
only is not null,
failed);
return failed;
}
/// <summary>Ensure a folder node, swallowing (and Warning-logging) any sink fault. Returns <c>true</c>
/// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node
/// count so a swallowed materialise failure is operator-visible (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the folder was ensured; <c>false</c> when the sink threw.</returns>
private bool SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
{
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName, realm); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureFolder threw for {Node}", nodeId); return false; }
}
/// <summary>Ensure a variable node, swallowing (and Warning-logging) any sink fault. Returns <c>true</c>
/// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node
/// count (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the variable was ensured; <c>false</c> when the sink threw.</returns>
private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
{
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; }
}
// The tag/vtag surgical-eligibility predicates moved to AddressSpaceChangeClassifier (R2-07 T1) so the
// whole delta classification is one pure, table-testable unit; the applier references them there (and the
// classifier's Classify names the routing). The `with`-expression whitelist technique is preserved there
// byte-for-byte.
/// <summary>The "no-event" condition state written to a removed equipment / alarm node before the
/// rebuild tears it down: inactive, acked, confirmed, enabled, unshelved, severity 0, empty message.
/// Drives Retain to false so a removed condition stops replaying on ConditionRefresh.</summary>
private static readonly AlarmConditionSnapshot RemovedConditionState = new(
Active: false,
Acknowledged: true,
Confirmed: true,
Enabled: true,
Shelving: AlarmShelvingKind.Unshelved,
Severity: 0,
Message: string.Empty);
/// <summary>Write an alarm-condition snapshot, swallowing (and Warning-logging) any sink fault.
/// Returns <c>true</c> on success, <c>false</c> when the sink threw — the removal pass tallies the
/// false into <see cref="AddressSpaceApplyOutcome.FailedNodes"/> (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
private bool SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm)
{
try { _sink.WriteAlarmCondition(nodeId, state, ts, realm); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteAlarmCondition threw for {Node}", nodeId); return false; }
}
/// <summary>Materialise an alarm-condition node, swallowing (and Warning-logging) any sink fault.
/// Returns <c>true</c> on success, <c>false</c> when the sink threw — callers tally the false into
/// their pass's failed-node count (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the condition was materialised; <c>false</c> when the sink threw.</returns>
private bool SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative, AddressSpaceRealm realm)
{
try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); return false; }
}
/// <summary>Wire a native alarm condition's extra equipment-folder notifiers (WP4 multi-notifier),
/// swallowing (and Warning-logging) any sink fault. Returns <c>true</c> on success, <c>false</c> when the
/// sink threw — callers tally the false into their pass's failed-node count (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the notifiers were wired; <c>false</c> when the sink threw.</returns>
private bool SafeWireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
{
try { _sink.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WireAlarmNotifiers threw for {Node}", alarmNodeId); return false; }
}
/// <summary>
/// v3 Batch 4 WP4 — build the reverse map <c>equipment UNS folder PATH (Area/Line/Equipment) →
/// EquipmentId</c>. The composer emits a native alarm's <see cref="RawTagPlan.ReferencingEquipmentPaths"/>
/// as name paths (<c>V3NodeIds.Uns(areaName, lineName, equipName)</c>), but the equipment folders were
/// materialised under their logical <c>EquipmentId</c> by <see cref="MaterialiseHierarchy"/>, so the
/// multi-notifier wiring must translate path → id. This inverts the composer's own equipment-folder-path
/// construction EXACTLY — <b>path↔id coupling invariant:</b> the composer builds the path from the
/// Area/Line/Equipment <c>Name</c>; this inverts it via the projected <c>DisplayName</c>, which the
/// composer sets to that same <c>Name</c> at every level (verified by
/// <c>AddressSpaceComposerPathParityTests</c>). A future editable Area/Line display-name feature that
/// lets <c>DisplayName != Name</c> would silently break this inversion — that test is the tripwire.
/// An invalid segment throws in <see cref="V3NodeIds.Uns"/> and is skipped (the same drop the composer
/// applies), so an entry the composer produced always resolves here.
/// </summary>
/// <param name="composition">The composition carrying the UNS topology + equipment nodes.</param>
/// <returns>A map from each resolvable equipment folder path to its EquipmentId.</returns>
private IReadOnlyDictionary<string, string> BuildEquipmentIdByFolderPath(AddressSpaceComposition composition)
{
var areaName = composition.UnsAreas
.GroupBy(a => a.UnsAreaId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DisplayName, StringComparer.Ordinal);
var lineByid = composition.UnsLines
.GroupBy(l => l.UnsLineId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => (g.First().UnsAreaId, g.First().DisplayName), StringComparer.Ordinal);
var map = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var e in composition.EquipmentNodes)
{
if (string.IsNullOrWhiteSpace(e.UnsLineId)) continue;
if (!lineByid.TryGetValue(e.UnsLineId, out var line)) continue;
if (!areaName.TryGetValue(line.UnsAreaId, out var aName)) continue;
string path;
try { path = V3NodeIds.Uns(aName, line.DisplayName, e.DisplayName); }
catch (ArgumentException) { continue; /* invalid segment — dropped, mirroring the composer */ }
// Wave C review L3: two equipment sharing an identical Area/Line/Name collapse to one id
// (last-write-wins). UNS uniqueness prevents this, so it is defense-in-depth: warn (mirroring the
// composer's own last-write-wins spot) rather than silently pick one — a collision here would send
// the alarm's notifier to the wrong equipment folder.
if (map.TryGetValue(path, out var existing) && !string.Equals(existing, e.EquipmentId, StringComparison.Ordinal))
{
_logger.LogWarning(
"AddressSpaceApplier: two equipment share the UNS folder path {Path} ({Existing} vs {New}) — the native-alarm notifier map collapses them (last-write-wins); UNS uniqueness should prevent this",
path, existing, e.EquipmentId);
}
map[path] = e.EquipmentId;
}
return map;
}
}
/// <summary>Summary of one apply pass. Useful for tests + audit-log entries on the deploy path.
/// <para><see cref="RebuildCalled"/> means a rebuild was ATTEMPTED; <see cref="RebuildFailed"/> means the
/// attempt threw (the sink's address space is now stale/partial). <see cref="FailedNodes"/> counts
/// swallowed per-node sink failures in <see cref="AddressSpaceApplier.Apply"/>'s own passes (the removal
/// alarm-condition writes). The <c>Materialise*</c> passes report their own failed-node tallies via their
/// <c>int</c> returns — the publish actor sums both channels so a degraded apply is operator-visible
/// (Error log + <c>otopcua.opcua.apply.failed</c> meter) instead of reported as optimistic success
/// (archreview 01/S-1). New trailing fields are defaulted so every existing construction compiles.</para></summary>
public sealed record AddressSpaceApplyOutcome(
int RemovedNodes,
int AddedNodes,
int ChangedNodes,
bool RebuildCalled,
bool RebuildFailed = false,
int FailedNodes = 0);