namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// How must mutate the address space for a given /// . A pure policy classification OVER the planner's diff (the planner stays /// a pure diff; the kind is the applier's routing policy over that diff — see R2-07 03/P1). public enum AddressSpaceChangeKind { /// — nothing to do. Empty, /// Only surgical-eligible changed equipment/raw tags, surgical UNS-reference deltas, /// node-irrelevant vtag changes, folder renames, and/or node-inert driver deltas — the existing F10b /// in-place path. No adds, no removes. AttributeOnly, /// Only additions (any mix of Equipment/Alarms/Tags/VirtualTags) on top of at-most-AttributeOnly /// changes — no removals, no node-affecting changes. Phase 1: the idempotent Materialise passes create /// exactly the added nodes with no teardown, so every client subscription survives. PureAdd, /// Only removals on top of at-most-AttributeOnly changes — no additions, no node-affecting /// changes. Phase 2: scoped in-place teardown of only the removed subtree. PureRemove, /// Additions AND removals, still no node-affecting changes. Phase 3 applies removes-then-adds /// surgically; until Phase 3 it maps to a full rebuild. AddRemoveMix, /// Anything with a node-affecting change (ChangedEquipment, ChangedAlarms, ChangedRawContainers, /// non-surgical ChangedEquipmentTags, alarm-bearing/identity-changing ChangedRawTags, node-relevant /// ChangedEquipmentVirtualTags) — the unclassifiable-safe default. Full rebuild. Rebuild, } /// /// Pure static classifier over an — names the delta class the applier /// routes each plan through, so a pure-add deploy no longer tears down and recreates every node on the /// server (03/P1). First-match-wins per the plan's classification table; the default-closed /// catches every node-affecting change (including any future /// plan field that makes a changed record unequal), so the worst outcome of any misclassification is /// today's full-rebuild behaviour. /// public static class AddressSpaceChangeClassifier { /// Classify into the delta class the applier routes it through. /// The plan to classify. /// The for the plan. public static AddressSpaceChangeKind Classify(AddressSpacePlan plan) { ArgumentNullException.ThrowIfNull(plan); // 1 — nothing to do. if (plan.IsEmpty) return AddressSpaceChangeKind.Empty; // 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment / // ChangedAlarms / ChangedRawContainers are always node-affecting; a changed equipment tag is // node-affecting UNLESS it is surgical-eligible, a changed raw tag UNLESS it is // RawTagDeltaIsSurgicalEligible, and a changed vtag UNLESS it is node-irrelevant. Evaluated BEFORE the // add/remove split so a non-surgical change alongside pure adds still rebuilds. // // WP4 BINDING GUARD (Wave C review M3), as narrowed by the 2026-08-15 surgical-deploys work. The // guard's subject is NATIVE-ALARM NOTIFIER WIRING: a native alarm's set of referencing equipment lives // in RawTagPlan.ReferencingEquipmentPaths (part of its record equality), and only a rebuild clears + // re-wires those notifiers, so an ALARM-BEARING raw tag (Alarm != null on EITHER side) still routes to // Rebuild. What changed is the blast radius: ReferencingEquipmentPaths is node-INERT for a plain VALUE // tag, yet adding/dropping ONE UnsTagReference flips its backing raw tag to Changed — so the blanket // "ChangedRawTags ⇒ Rebuild" tore down every node on the server (killing every client subscription) for // an edit that creates a single UNS variable. The new contract: // • alarm-bearing raw-tag delta ⇒ Rebuild (notifier wiring is owned by the rebuild — unchanged); // • non-alarm raw-tag delta over the in-place-updatable attributes and/or ReferencingEquipmentPaths // ⇒ surgical (the applier's ISurgicalAddressSpaceSink.UpdateTagAttributes path); // • any other raw-tag field difference ⇒ Rebuild, via RawTagDeltaIsSurgicalEligible's // with-equality default-closed whitelist (which also closes over any FUTURE field); // • ChangedUnsReferenceVariables ⇒ surgical remove-previous + recreate-current, applier-owned (a UNS // reference variable is a mirror node binding no driver, so it carries no notifier wiring of its // own — the alarm fan-out it participates in is re-derived from the raw tag's referencing set). // No rebuild is forced for a UNS-reference delta in EITHER direction, and that is load-bearing for // the repair path below. // UNS-REFERENCE STALENESS, and why nothing here gates it (2026-08-15 review). Making non-alarm raw-tag // deltas surgical meant a raw-side IsHistorized / HistorianTagname / IsArray / ArrayLength edit updated // the raw node in place while every referencing UNS MIRROR node kept the old historian tagname + shape // (EnsureVariable early-returns on an existing node) — wrong history series through the UNS NodeId, and // a scalar→array flip left the mirror scalar AND writable. The fix is in the RECORD, not the classifier: // UnsReferenceVariable now carries the backing tag's Backing{IsHistorized,HistorianTagname,IsArray, // ArrayLength}, so the same edit makes the reference record unequal and the planner emits a COINCIDENT // UnsReferenceDelta alongside the RawTagDelta. Both are non-rebuild kinds, so the plan stays // AttributeOnly and the applier repairs the mirror surgically (remove-previous + materialise-recreate). // Adding a new inherited field to UnsReferenceVariable therefore needs no change here — but OMITTING one // silently reopens the staleness. // Anything a future surgical raw-tag path lets through that DOES touch alarm notifiers must still // re-wire/unwire them (WireAlarmNotifiers is a reconcile — pass the tag's COMPLETE current referencing // set — plus an explicit unwire on removal), else a de-referenced equipment keeps receiving the events. if (plan.ChangedEquipment.Count > 0 || plan.ChangedAlarms.Count > 0 || plan.ChangedRawContainers.Count > 0 || plan.ChangedRawTags.Any(d => !RawTagDeltaIsSurgicalEligible(d)) || plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) || plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d))) { return AddressSpaceChangeKind.Rebuild; } // Driver deltas (Added/Removed/ChangedDrivers) are node-INERT — they never touch the address-space // topology (they route to DriverHostActor's spawn plan in Runtime), so they are excluded from the // add/remove split below and leave a driver-only plan classified AttributeOnly. var hasAdds = plan.AddedEquipment.Count + plan.AddedAlarms.Count + plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count + plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count > 0; var hasRemoves = plan.RemovedEquipment.Count + plan.RemovedAlarms.Count + plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count + plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count > 0; // 3 — additions AND removals. if (hasAdds && hasRemoves) return AddressSpaceChangeKind.AddRemoveMix; // 4 — additions only. if (hasAdds) return AddressSpaceChangeKind.PureAdd; // 5 — removals only. if (hasRemoves) return AddressSpaceChangeKind.PureRemove; // 6 — only surgical tag deltas / node-irrelevant vtag deltas / folder renames / driver deltas. return AddressSpaceChangeKind.AttributeOnly; } // A VirtualTag's materialised OPC UA node (MaterialiseEquipmentVirtualTags) is derived ONLY from // {EquipmentId, FolderPath, Name, DataType}. Expression/DependencyRefs/Historize are engine/write-side // only and are adopted by VirtualTagHostActor's INDEPENDENT respawn (DriverHostActor → ApplyVirtualTags), // so a delta changing ONLY those three leaves a byte-identical node and needs no address-space rebuild. // Whitelist-of-may-differ via `with` + the record's custom Equals: any OTHER field difference (current // or future) makes the override unequal → falls back to a full rebuild (safe default). internal static bool VtagDeltaIsNodeIrrelevant(AddressSpacePlan.EquipmentVirtualTagDelta d) => (d.Previous with { Expression = d.Current.Expression, DependencyRefs = d.Current.DependencyRefs, Historize = d.Current.Historize, }).Equals(d.Current); // F10b: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized / // HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no alarm condition node) // can be updated IN PLACE on the existing node via ISurgicalAddressSpaceSink.UpdateTagAttributes, // avoiding a full rebuild (preserving subscriptions). The presentation-shape fields (DataType / IsArray / // ArrayLength) join the whitelist now that the surgical sink swaps DataType + ValueRank + ArrayDimensions // in place (and raises a GeneralModelChangeEvent). FullName / DriverInstanceId / Name / identity / alarm // differences still fall through to a rebuild — FullName/DriverInstanceId re-route the node to a different // driver point, Name re-derives the NodeId, and an alarm flip turns the node into a Part 9 condition. The // override-unequal default also covers any future field. // REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable // top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array // edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…) // TagConfigIntent.Parse falls back to the RAW TagConfig blob as FullName, so a DataType/ // array edit also mutates that blob ⇒ FullName differs ⇒ this returns false ⇒ full rebuild. That is the // correct safe default (a protocol driver's subscription needs re-spawning for a new shape anyway). internal static bool TagDeltaIsSurgicalEligible(AddressSpacePlan.EquipmentTagDelta d) => d.Previous.Alarm is null && d.Current.Alarm is null && (d.Previous with { Writable = d.Current.Writable, IsHistorized = d.Current.IsHistorized, HistorianTagname = d.Current.HistorianTagname, DataType = d.Current.DataType, IsArray = d.Current.IsArray, ArrayLength = d.Current.ArrayLength, }).Equals(d.Current); // v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable // attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength) and/or its // ReferencingEquipmentPaths. The referencing set is node-inert for a VALUE tag — it only drives native-alarm // notifier wiring (the WP4 guard), which is why Alarm != null on EITHER side disqualifies. Identity fields // (NodeId is the diff key; TagId / DriverInstanceId / Name / ParentNodeId / Realm) still force a rebuild via // the with-equality default-closed pattern. internal static bool RawTagDeltaIsSurgicalEligible(AddressSpacePlan.RawTagDelta d) => d.Previous.Alarm is null && d.Current.Alarm is null && (d.Previous with { Writable = d.Current.Writable, IsHistorized = d.Current.IsHistorized, HistorianTagname = d.Current.HistorianTagname, DataType = d.Current.DataType, IsArray = d.Current.IsArray, ArrayLength = d.Current.ArrayLength, ReferencingEquipmentPaths = d.Current.ReferencingEquipmentPaths, }).Equals(d.Current); }