Re-ran all seven domain reviews at master f6eaa267 (reports rewritten in
place, each with a prior-finding status table): all 4 round-1 Criticals
verified closed; new top findings are the S7 connect-timeout OCE regression
(05/STAB-14), the ResilienceConfig operator-authorable brick (01/S-6), and
a batch of resilience-seam Mediums. 00-OVERALL.md carries the updated
maturity matrix + 12-item action list.
Adds R2-01..R2-12 design/implementation plans (one per action item, house
format + bite-sized TDD task breakdowns + co-located .tasks.json; 193 tasks,
~18-24 dev-days). STATUS.md updated: round-1 topology marked historical
(all merged+pushed), re-review findings table + plan pointers added.
49 KiB
Design & Implementation Plan — R2-07: Surgical pure-adds (and removals) in the address-space applier
Status (2026-07-12): NOT STARTED — this is the round-2 plan. The declared prerequisite (03/U2 reflection-exhaustive deferred-sink forwarding guard,
DeferredSinkForwardingReflectionTests) is merged to master and auto-covers every new sink capability member this plan adds, so the F10b/PR#423 inertness class is retired and the work is unblocked.
- Source reports:
archreview/03-server-runtime.md(P1 — the primary finding),archreview/01-core-composition.md(applier/planner context, S-1, C-7),archreview/00-OVERALL.mdprioritized action #7 ("Surgical pure-adds in the applier — Large effort, High impact, biggest operational perf win"). - Round-1 plan extended:
archreview/plans/03-server-runtime-plan.md§P1 (this plan supersedes its two-phase sketch with a full three-phase staged design). - Review commit:
f6eaa267· Plan verified against tree at:f6eaa267(master, clean tree; archreview/*.md working-tree updates from the 2026-07-12 re-review read in-place). - Scope:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/(AddressSpaceApplier,AddressSpacePlan/AddressSpacePlanner,OtOpcUaNodeManager,SdkAddressSpaceSink),src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/(ISurgicalAddressSpaceSink,DeferredAddressSpaceSink,IOpcUaAddressSpaceSink),src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs, plus tests undertests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/,tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/,tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/.
Verification summary
Every claim below re-verified by direct read at f6eaa267:
| # | Claim | Verified at |
|---|---|---|
| 1 | Any add/remove of Equipment / ScriptedAlarm / EquipmentTag / EquipmentVirtualTag forces structuralRebuild → SafeRebuild() |
AddressSpaceApplier.cs:134-154 — the predicate ORs every Added*/Removed* count |
| 2 | RebuildAddressSpace() detaches + removes every NodeState (variables, conditions, folders, notifier refs) under one Lock |
OtOpcUaNodeManager.cs:1690-1736 |
| 3 | Recreated nodes are new NodeState instances ⇒ existing client MonitoredItems go permanently dead |
SDK binds monitored items to NodeState instances; confirmed by the F10b design history and ISurgicalAddressSpaceSink doc (ISurgicalAddressSpaceSink.cs:9-17) |
| 4 | The surgical path today covers ONLY changed-tag attribute edits (TagDeltaIsSurgicalEligible) + folder DisplayName renames (RenamedFolders) — not adds, not removes |
AddressSpaceApplier.cs:155-199, 630-663 |
| 5 | The four Materialise passes run unconditionally after Apply returns, over the FULL composition, and are idempotent (EnsureFolder early-return OtOpcUaNodeManager.cs:1290, EnsureVariable early-return :1376, MaterialiseAlarmCondition drop-and-recreate guard :596-604) |
OpcUaPublishActor.cs:335-354 (passes at 338/343/349/354, gated on the plan.IsEmpty short-circuit at :328) |
| 6 | RaiseNodesAddedModelChange (Part 3 GeneralModelChangeEvent, verb NodeAdded, built under Lock, reported outside it) already exists, is on IOpcUaAddressSpaceSink, is forwarded by DeferredAddressSpaceSink, and is guard-covered |
OtOpcUaNodeManager.cs:1592-1654, IOpcUaAddressSpaceSink.cs:88-94, DeferredAddressSpaceSink.cs:49; NodeManagerModelChangeOnAddTests.cs exists |
| 7 | The U2 prerequisite guard is merged: DeferredSinkForwardingReflectionTests (Commons.Tests) reflects over every member of every forwarding interface of DeferredAddressSpaceSink via a DispatchProxy recorder that auto-implements future members, plus a guard-of-the-guard pinning the interface set |
tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs:29-75 |
| 8 | FeedHistorizedRefs + ProvisionHistorizedTags operate on the plan's add/remove/change deltas and fire at the end of Apply unconditionally — independent of rebuild-vs-surgical routing |
AddressSpaceApplier.cs:208-213, 226-353 |
| 9 | The driver-side routing maps (_nodeIdByDriverRef / _driverRefByNodeId / _alarmNodeIdByDriverRef) and desired-subscription sets are rebuilt every apply by DriverHostActor.PushDesiredSubscriptions, fully independent of the node-manager rebuild |
DriverHostActor.cs:107-158 (doc: "Rebuilt every apply") |
| 10 | Variable NodeIds are folder-scoped strings derived deterministically from composition (EquipmentNodeIds.Variable/SubFolder), all in the one server namespace (NamespaceIndex) — adds cannot shift existing NodeIds |
EquipmentNodeIds.cs, OtOpcUaNodeManager.cs:1299, 1391 |
| 11 | The applier already writes a terminal condition state (RemovedConditionState) to removed equipment/alarm condition nodes before the teardown |
AddressSpaceApplier.cs:83-98, 665-681 |
| 12 | 03/P1 is STILL OPEN at f6eaa267 ("guard merged, work not started") |
archreview/03-server-runtime.md prior-finding table row P1 + §2 P1 |
Conclusion: the finding fully reproduces. Adding one tag to one equipment still tears down and recreates every node on the server, killing every client subscription server-wide. The striking verification result (claims 5+6) is that Phase 1 (pure-adds) requires no new sink capability at all — the idempotent Materialise passes already create exactly the new nodes when the rebuild is skipped; the only missing pieces are (a) a classifier that routes a pure-add plan away from SafeRebuild() and (b) a post-materialise NodeAdded model-change announcement. Removals (Phase 2) are where new sink members are needed.
Restatement
AddressSpaceApplier.Apply treats every topology delta identically: any added or removed equipment, scripted alarm, equipment tag, or virtual tag sets structuralRebuild = true, which calls sink.RebuildAddressSpace() → OtOpcUaNodeManager.RebuildAddressSpace() clears all five node maps and detaches every NodeState. The subsequent Materialise passes recreate the world as new NodeState instances. OPC UA monitored items are bound to the old instances, so every client subscription on the server goes dead — for a deploy that added a single tag. On a production SCADA server with dozens of clients (including ScadaBridge's Data Connection Layer), every authoring action that touches topology is a server-wide subscription outage. The F10b surgical path (ISurgicalAddressSpaceSink.UpdateTagAttributes / UpdateFolderDisplayName) proves in-place mutation preserves subscriptions, but covers only in-place attribute edits and UNS folder renames.
Verification
Confirmed — see the table above. Key mechanics re-checked at f6eaa267:
- The rebuild predicate (
AddressSpaceApplier.cs:134-140):AddedEquipment.Count > 0 || RemovedEquipment.Count > 0 || ChangedEquipment.Count > 0 || AddedAlarms/RemovedAlarms/ChangedAlarms || AddedEquipmentTags/RemovedEquipmentTags || ChangedEquipmentTags.Any(!TagDeltaIsSurgicalEligible) || AddedEquipmentVirtualTags/RemovedEquipmentVirtualTags || ChangedEquipmentVirtualTags.Any(!VtagDeltaIsNodeIrrelevant). Pure adds land in this branch. - The apply pipeline (
OpcUaPublishActor.HandleRebuild,OpcUaPublishActor.cs:290-364): load artifact →ParseComposition→AddressSpacePlanner.Compute(_lastApplied, composition)→plan.IsEmptyshort-circuit (:328 — the known IsEmpty-gate: the Materialise passes do NOT run on an empty plan, so anything the plan is blind to never materialises) →_applier.Apply(plan)→ the four Materialise passes over the full composition (:338-354). A pure-add plan is non-empty, so the passes always run for it. - Idempotency of the passes:
EnsureFolder/EnsureVariableearly-return on an existing NodeId;MaterialiseAlarmConditiondrops-and-recreates per id (idempotent but not subscription-preserving for that one condition node — see Design, native-alarm note).MaterialiseHierarchy/MaterialiseEquipmentTags/MaterialiseEquipmentVirtualTags/MaterialiseScriptedAlarmstherefore add exactly the new nodes and no-op existing ones when no rebuild cleared the maps. - Folder-scoped NodeIds (the second known gotcha): variables materialise at
EquipmentNodeIds.Variable(equipmentId, folderPath, name)— NOT the driver FullName — so add-only deploys cannot collide with or re-key existing nodes; the driver value router (_nodeIdByDriverRef) is rebuilt per apply from the same composition and needs no change. - The guard:
DeferredSinkForwardingReflectionTests.Every_forwarding_interface_method_reaches_the_inner_sinkiteratesForwardingInterfaces = { IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink }viaGetMethods()— a member added toISurgicalAddressSpaceSinkin Phase 2 is picked up with zero test edits and fails the guard ifDeferredAddressSpaceSinkdoes not forward it.
Root cause
The applier's rebuild predicate conflates three delta classes with very different minimal-mutation requirements:
- In-place changes (attribute edits, renames) — already handled surgically (F10b).
- Pure additions — need no teardown at all: the idempotent Materialise passes add exactly the new nodes;
RaiseNodesAddedModelChangeexists to announce them. The rebuild is pure collateral damage. - Removals / node-affecting mutations — genuinely need teardown, but only of the affected subtree, not the world.
The predicate collapses all three into one boolean. The fix is a classifier that names the classes and routes each to its minimal mutation, with full rebuild as the safety valve for anything else.
Proposed design
The delta classifier
A new pure static AddressSpaceChangeClassifier in ZB.MOM.WW.OtOpcUa.OpcUaServer (same project as the planner — it is a pure function over AddressSpacePlan, unit-testable without any sink), returning:
/// <summary>How AddressSpaceApplier must mutate the address space for a given plan.</summary>
public enum AddressSpaceChangeKind
{
/// <summary>Plan.IsEmpty — nothing to do.</summary>
Empty,
/// <summary>Only surgical-eligible changed tags, node-irrelevant vtag changes, and/or folder
/// renames — the existing F10b in-place path. No adds, no removes.</summary>
AttributeOnly,
/// <summary>Only additions (any mix of Equipment/Alarms/Tags/VirtualTags/Drivers) on top of
/// at-most-AttributeOnly changes — no removals, no node-affecting changes. Phase 1.</summary>
PureAdd,
/// <summary>Only removals on top of at-most-AttributeOnly changes — no additions, no
/// node-affecting changes. Phase 2.</summary>
PureRemove,
/// <summary>Additions AND removals, still no node-affecting changes. Phase 3 applies
/// removes-then-adds surgically; until Phase 3 it maps to Rebuild.</summary>
AddRemoveMix,
/// <summary>Anything with a node-affecting change (ChangedEquipment, ChangedAlarms,
/// non-surgical ChangedEquipmentTags, node-relevant ChangedEquipmentVirtualTags) — the
/// unclassifiable-safe default. Full rebuild.</summary>
Rebuild,
}
public static class AddressSpaceChangeClassifier
{
public static AddressSpaceChangeKind Classify(AddressSpacePlan plan);
}
Classification algorithm (evaluated top-down; first match wins):
| Order | Condition | Kind |
|---|---|---|
| 1 | plan.IsEmpty |
Empty |
| 2 | ChangedEquipment.Count > 0 or ChangedAlarms.Count > 0 or ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) or ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d)) |
Rebuild |
| 3 | hasAdds && hasRemoves |
AddRemoveMix |
| 4 | hasAdds |
PureAdd |
| 5 | hasRemoves |
PureRemove |
| 6 | otherwise (only surgical tag deltas / node-irrelevant vtag deltas / folder renames / driver deltas) | AttributeOnly |
where hasAdds = AddedEquipment.Count + AddedAlarms.Count + AddedEquipmentTags.Count + AddedEquipmentVirtualTags.Count > 0 and hasRemoves = RemovedEquipment.Count + RemovedAlarms.Count + RemovedEquipmentTags.Count + RemovedEquipmentVirtualTags.Count > 0. Driver deltas (Added/Removed/ChangedDrivers) are node-inert — they never touch the address space today (excluded from the current predicate, routed to DriverSpawnPlanner in Runtime) and stay excluded from hasAdds/hasRemoves. Note a PureAdd/PureRemove/AddRemoveMix plan may also carry surgical tag deltas and folder renames — the applier applies those via the existing F10b path in the same pass (they are orthogonal in-place mutations).
The two eligibility predicates (TagDeltaIsSurgicalEligible, VtagDeltaIsNodeIrrelevant, currently private statics at AddressSpaceApplier.cs:630-663) move to the classifier (as internal static, applier keeps thin delegating calls or references them directly) so the whole classification is one pure, table-testable unit. Their whitelist-via-with-expression technique is preserved byte-for-byte (01 report calls it out as a positive).
Phase staging
Phase 1 — pure-adds (the ~90% win, no new sink members). When Classify == PureAdd:
Applydoes not callSafeRebuild(). It still applies any coincident folder renames + surgical tag deltas via the existingISurgicalAddressSpaceSinkpath (anyfalse/throw there →SafeRebuild()fallback, exactly as today).- The four Materialise passes in
OpcUaPublishActor.HandleRebuildthen run as they always do and create exactly the added nodes (folders/variables/conditions), idempotently no-op'ing everything that already exists. No applier change needed for the creation itself — this is the load-bearing simplification. The only pass with a wrinkle isMaterialiseScriptedAlarms/native-alarm materialisation:MaterialiseAlarmConditionis drop-and-recreate per id, so on a pure-add apply it would drop-and-recreate every existing enabled condition node (killing monitored items on those condition nodes only). Fix: makeMaterialiseAlarmConditionskip-if-present-and-same-kind on the node-manager side — add an idempotent early-return when_alarmConditionsalready holds the id with the sameisNativeflag (drop-and-recreate stays for the kind-swap/re-severity case, which only reaches this path via a rebuild where the map was just cleared). This keeps existing alarm subscriptions alive across a pure-add deploy and is behavior-identical on the rebuild path. - Announcement: after the Materialise passes complete,
OpcUaPublishActorcalls a newapplier.AnnounceAddedNodes(plan)(Phase-1 applier method, no new sink member — it uses the existing, guard-coveredRaiseNodesAddedModelChange). It computes a deduplicated set of affected parent ids: for each added tag/vtag → its materialise parent (EquipmentIdorEquipmentNodeIds.SubFolder(EquipmentId, FolderPath)); for each added scripted alarm → itsEquipmentId; for each added equipment → itsUnsLineIdwhen present, else the equipment's own id (the root has no announceable id; announcing the new folder itself is valid Part 3 — verbNodeAdded,Affected= the added node). OneRaiseNodesAddedModelChangeper distinct id. Ordering is correct by construction:Applyreturns before the passes run, and the announce runs after them, so the nodes exist when clients re-browse. The announce is gated on!outcome.RebuildCalled(after a full rebuild the announcement is moot — subscriptions are dead anyway, unchanged behavior). - Existing-subscription behavior on add: none affected — no existing
NodeStateis touched; per OPC UA Part 4 monitored items are unaffected by address-space growth; model-aware clients get the Part 3GeneralModelChangeEventand re-browse.
Phase 2 — pure-removes (new sink capability). When Classify == PureRemove:
New members on ISurgicalAddressSpaceSink (existing interface — the guard's ForwardingInterfaces set needs no change; the DispatchProxy recorder auto-covers new members):
/// <summary>Remove ONE existing value-variable node in place (detach from parent, drop from the
/// predefined-node set, drop the historized-tagname registration), then raise a Part 3
/// GeneralModelChangeEvent (verb=NodeDeleted) outside the lock. The caller has already written a
/// final Bad quality to the node so in-flight MonitoredItems observe the removal. Returns false
/// when the node id is unknown (caller falls back to a full rebuild — map-drift safety valve).</summary>
bool RemoveVariableNode(string variableNodeId);
/// <summary>Remove ONE existing Part 9 alarm-condition node in place (detach, drop from the
/// predefined-node set, drop the native-alarm flag), then raise NodeDeleted outside the lock. The
/// caller has already written the terminal RemovedConditionState (Retain=false). Returns false
/// when the condition id is unknown (caller rebuilds).</summary>
bool RemoveAlarmConditionNode(string alarmNodeId);
/// <summary>Remove an equipment folder AND its entire descendant subtree (sub-folders, variables,
/// condition nodes) in place: per-descendant map/registration cleanup (variables, historized
/// tagnames, conditions, native flags), notifier demotion (RemoveRootNotifier + notifier-folder +
/// event-notifier-source registrations for the equipment id), then one NodeDeleted for the
/// equipment folder outside the lock. Returns false when the folder id is unknown (caller
/// rebuilds).</summary>
bool RemoveEquipmentSubtree(string equipmentNodeId);
Implemented on OtOpcUaNodeManager (all mutation under Lock; the NodeDeleted model-change event built under Lock and reported after release, mirroring RaiseNodesAddedModelChange / ReportNodeShapeChangedEvent), forwarded by SdkAddressSpaceSink, capability-sniff-forwarded by DeferredAddressSpaceSink (_inner is ISurgicalAddressSpaceSink s && s.X(...) — false pre-swap ⇒ rebuild fallback, same as UpdateTagAttributes), and no-op'd nowhere else (NullOpcUaAddressSpaceSink doesn't implement the surgical interface — unchanged).
Applier routing for PureRemove:
- Partition removals: removed equipment "own" their child tags/vtags/alarms — a
RemovedEquipmentTags/RemovedEquipmentVirtualTags/RemovedAlarmsentry whoseEquipmentIdis inRemovedEquipmentis subsumed by the subtree removal and skipped individually. - Pre-teardown terminal writes (extends the existing
:83-98pattern): removed value variables getWriteValue(nodeId, null, Bad, ts); removed condition nodes (scripted alarms AND alarm-bearing tags — the latter is a today-gap:RemovedEquipmentTagswithAlarm != nullcurrently get no condition write) getSafeWriteAlarmCondition(nodeId, RemovedConditionState, ts). This is what an in-flight MonitoredItem observes (final Bad notification), which is the spec-friendly signal; after the node is gone, re-subscription attempts getBadNodeIdUnknownfrom the SDK because the node is no longer in the predefined set. - Teardown order children-first: individual variable/condition removals (for surviving equipment), then
RemoveEquipmentSubtreeper removed equipment. Empty leftoverFolderPathsub-folders on surviving equipment are deliberately kept (browse-visible but harmless; the next full rebuild collects them — documented v1 cut, mirrorsEnsureFolder's create-only semantics). - Any
false/throw from a remove ⇒SafeRebuild()(one-way ratchet per apply: once rebuilt, skip remaining surgical work — the rebuild already reconciled everything). - Subscription behavior on remove (per spec): subscribers of other nodes are untouched; subscribers of the removed node see a final Bad value (step 2), the node disappears from browse, and model-aware clients get
NodeDeleted. This matches OPC UA Part 3/4 expectations for node deletion.
Phase 3 — mixed add+remove batches. When Classify == AddRemoveMix: apply the Phase-2 remove pass, then let the Materialise passes + Phase-1 announce handle the adds — removes-then-adds within one apply. The one ordering hazard is an id reused across the remove and add sets in the same deploy (e.g. tag removed from equipment E folder F name N while another tag materialises at the identical folder-scoped NodeId): removes run in Apply (before the passes), adds run in the passes (after), so remove-then-recreate is the natural order and the recreated node is a fresh NodeState (correct — it is a different tag). No special-casing needed; covered by a targeted test. Until Phase 3 ships, AddRemoveMix maps to Rebuild (today's behavior), which is why each phase is independently shippable.
NodeId / namespace-index stability constraints
- All materialised nodes live in the single server namespace (
NamespaceIndexonOtOpcUaNodeManager); the index is assigned at server start and is untouched by deploys. No constraint is added or relaxed by this work. - Folder/variable/condition NodeIds are deterministic pure functions of composition (
UnsAreaId/UnsLineId/EquipmentId,EquipmentNodeIds.SubFolder/Variable,ScriptedAlarmId) — an add cannot re-key an existing node, and the surgical paths reuse the same derivations the Materialise passes use (the applier already does this forUpdateTagAttributesat:177). Any new derivation in the remove path MUST go throughEquipmentNodeIds— never hand-rolled strings. - Same-NodeId collision on add (two tags materialising to one folder-scoped id) keeps today's first-wins
EnsureVariablesemantics — no regression, explicitly out of scope.
Interaction with the historian/provisioning/alarm seams (all verified no-change or additive)
- Historized-ref feed (
FeedHistorizedRefs) and historian provisioning (ProvisionHistorizedTags) already consume the plan's add/remove/change deltas and fire unconditionally at the end ofApply— routing a plan surgically instead of via rebuild changes nothing for them. (They are the design template for this work: per-deploy delta consumption, non-blocking, deploy-safe.) - Native alarm condition nodes: added alarm-bearing tags materialise via
SafeMaterialiseAlarmConditioninMaterialiseEquipmentTags— additive on the pure-add path once the skip-if-present guard (Phase 1 step 2) lands. Removal of an alarm-bearing tag routes toRemoveAlarmConditionNode(its node is a condition, not a value variable).EnsureFolderIsEventNotifierpromotion for a first-alarm-on-existing-equipment add is already runtime-safe (the rebuild path performs it on a live server today). - Driver subscriptions / value routing: rebuilt every apply by
DriverHostActor.PushDesiredSubscriptionsindependent of this path — adds get subscribed, removes get unsubscribed and un-routed, no change needed. AddressSpaceApplyOutcome: gains nothing structurally in Phase 1 (the existingRebuildCalled=false+AddedNodescounts express the pure-add outcome); the apply log line gains the classifier verdict (kind={Kind}) so live verification can grep forkind=PureAdd, rebuild=False.
Fallback-to-rebuild safety valve
Single principle, enforced by the classifier's shape: anything not positively classified as safe falls into Rebuild (order rule 2 catches every node-affecting change, including any future plan field that makes a changed record unequal — the with-expression whitelists are default-closed). At apply time, every surgical sink call keeps the F10b contract: false or throw ⇒ SafeRebuild() and mark rebuilt. Net: the worst possible outcome of any bug in this work is today's behavior (a full rebuild).
Alternatives considered (and rejected)
- Re-home monitored items onto recreated nodes after a rebuild — the SDK offers no supported API to re-bind a
MonitoredItemto a newNodeState; rejected (round-1 plan concurs). - Generic diff-and-patch of every delta class (make
ChangedEquipment/ChangedAlarmssurgical too) — larger and riskier; changed-alarm re-materialisation and equipment renames can ride a later increment (equipment DisplayName rename isUpdateFolderDisplayName-shaped — noted as follow-up, not in scope). The add/remove split follows the plan's existing typed delta sets. - Classify in
AddressSpacePlanner(attach the kind to the plan record) — rejected:Computeis a pure diff and the kind is a policy over the diff that the applier owns (and the F10b eligibility predicates it needs already live applier-side). A separate pure classifier keeps the planner untouched and the policy table-testable. - Batch remove member (
RemoveNodes(IReadOnlyList<…>)) instead of three typed members — rejected: the per-nodeboolfalse-means-rebuild contract (the established F10b posture) gets murky on a batch; three members mirror the existing surgical-member shape and the guard covers each independently. - Return
true(idempotent success) from a remove of an unknown node — rejected: an unknown id on remove means the node-manager maps drifted from what the planner believes; the house posture (F10bUpdateTagAttributes:false) is resync-via-rebuild. Documented in the member xmldoc. - Announce adds from inside
Apply— impossible:Applyruns before the Materialise passes create the nodes; the announce must be a post-passes step inOpcUaPublishActor(verified orderingOpcUaPublishActor.cs:335-354).
Implementation steps (exact paths)
Phase 1 — pure-adds
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs(new):AddressSpaceChangeKindenum +Classify(AddressSpacePlan)per the table; moveTagDeltaIsSurgicalEligible+VtagDeltaIsNodeIrrelevanthere asinternal static(fromAddressSpaceApplier.cs:630-663).src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs: replace thestructuralRebuildboolean (:134-140) withvar kind = AddressSpaceChangeClassifier.Classify(plan);Rebuild/AddRemoveMix/PureRemove(until their phases ship) ⇒SafeRebuild();PureAdd/AttributeOnly⇒ no rebuild + existing surgical renames/tag-deltas (with the existingfalse⇒rebuild fallback). Addkindto the apply log line. Addpublic IReadOnlyList<string> ComputeAddAnnouncements(AddressSpacePlan plan)(pure, dedup parent-id set per the design) andpublic void AnnounceAddedNodes(AddressSpacePlan plan)calling_sink.RaiseNodesAddedModelChangeper id (Safe-wrapped). While here, refresh the stale F14-era class doc (:7-26, 01/C-7).src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs:MaterialiseAlarmCondition(:586-604) — idempotent early-return when_alarmConditionsholds the id AND the stored native flag equalsisNative(keeps drop-and-recreate only for kind-swaps; requires remembering the flag —_nativeAlarmNodeIdsalready encodes it).src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs: inHandleRebuildafter the fourth Materialise pass (:354),if (!outcome.RebuildCalled) _applier.AnnounceAddedNodes(plan);.- Live-
/rungate (see Tests) + docs:docs/v2/-appropriate note +archreview/plans/STATUS.mdrow.
Phase 2 — pure-removes
src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs: add the three members (signatures above). Guard-first discipline: the reflection guard auto-covers them the moment they exist — implementDeferredAddressSpaceSinkforwarding in the same commit or the guard fails the build's test leg (that failure IS the negative control; observe it once deliberately).src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs: capability-sniffing forwards for all three (pattern of:58-70).src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs: implementRemoveVariableNode/RemoveAlarmConditionNode/RemoveEquipmentSubtree(teardown underLockmirroring the per-node loops insideRebuildAddressSpace:1694-1734, scoped to the id/subtree; clean_variables/_historizedTagnames/_alarmConditions/_nativeAlarmNodeIds/_folders/_notifierFolders(+RemoveRootNotifier)/_eventNotifierSources);internal GeneralModelChangeEventState BuildNodesRemovedModelChange(string affectedNodeId)mirroringBuildNodesAddedModelChange(:1627-1654) withVerb = NodeDeleted; report outsideLock.src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs: three one-line forwards.AddressSpaceApplier.cs:PureRemovearm — terminal writes (Bad value /RemovedConditionState, extending:83-98incl. the alarm-bearing-removed-tag gap), subsumption partition, children-first removal calls,false/throw ⇒SafeRebuild()ratchet.- Live-
/runremove gate + docs.
Phase 3 — mixed batches
AddressSpaceChangeClassifier.cs+AddressSpaceApplier.cs:AddRemoveMixroutes to the Phase-2 remove pass then falls through to the pure-add (no-rebuild + announce) behavior; remove-then-recreate same-NodeId test.- Final docs + STATUS.md close-out.
Tests
Ordering rule: guard-set/TDD first in every task (the F10b precedent — 258 unit tests + 3 reviews missed prod-inertness; the guard + live gate are the two nets that catch it).
- Guard extension (Phase 2, FIRST): no test-code change needed —
DeferredSinkForwardingReflectionTestsauto-covers newISurgicalAddressSpaceSinkmembers. The TDD step is a deliberate negative control: add the interface members with a non-forwardingDeferredAddressSpaceSinkstub (=> false), rundotnet test --filter "FullyQualifiedName~DeferredSinkForwardingReflectionTests"and observe the member-named FAIL, then implement the forward and observe PASS. Also extend the hand-writtenDeferredAddressSpaceSinkTests(Commons + OpcUaServer.Tests variants) with arg-fidelity cases for the three members. - Classifier (Phase 1): new
AddressSpaceChangeClassifierTests(OpcUaServer.Tests) — table-driven ([Theory]/[MemberData]) over the full matrix: empty; each single add class ⇒ PureAdd; each single remove class ⇒ PureRemove; add+remove ⇒ AddRemoveMix; each changed class ⇒ Rebuild; surgical-eligible-changed-only ⇒ AttributeOnly; renames-only ⇒ AttributeOnly; adds+surgical-changes ⇒ PureAdd; adds+non-surgical-change ⇒ Rebuild; driver-only deltas ⇒ AttributeOnly (node-inert). - Applier (Phase 1): extend
AddressSpaceApplierTests(theRecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSinkat:1761already recordsRebuildCalls+ModelChangeQueue): pure-add plan ⇒RebuildCalls == 0,outcome.RebuildCalled == false;AnnounceAddedNodes⇒ oneRaiseNodesAddedModelChangeper distinct parent (dedup + subfolder-parent + added-equipment cases); pure-add with coincident surgical tag delta ⇒ no rebuild +UpdateTagAttributescalled; surgical-falseduring a pure-add apply ⇒ rebuild fallback; remove-only and add+remove plans still rebuild (phase-boundary regression pins, updated in Phases 2/3). - Node manager (Phase 1): extend
NodeManagerSurgicalShapeUpdateTests-style suite (or newNodeManagerAlarmIdempotentMaterialiseTests): re-MaterialiseAlarmConditionsame id+kind keeps the sameAlarmConditionStateinstance (FindAlarmConditionreference-equal); kind-swap still recreates. - Node manager (Phase 2): new
NodeManagerSurgicalRemoveTests: ensure→remove variable ⇒FindVariablenull,VariableCountdecremented,TryGetHistorianTagnamefalse, unknown id ⇒ false; alarm-condition remove ⇒FindAlarmConditionnull + native flag cleared; equipment-subtree remove ⇒ descendants gone, notifier demoted (no orphaned root-notifier ref), other equipment untouched;BuildNodesRemovedModelChangepopulatesVerb=NodeDeleted+Affected(mirrorsNodeManagerModelChangeOnAddTests). - Publish actor (Phase 1): extend
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs: pure-add artifact apply ⇒ sink records NORebuildAddressSpacecall, recordsRaiseNodesAddedModelChangeAFTEREnsureVariablefor the added tag (ordering assertion via a sequenced recording sink). - Subscription-survival integration (Phase 1, the headline): new
SubscriptionSurvivalTestsintests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/(reuseDualEndpointTests' in-process server +DefaultSessionFactorysession bootstrap): materialise tag A, create a real subscription + monitored item on A, drive a pure-add apply (applier + Materialise passes) adding tag B, then (a) A's monitored item still delivers on a subsequentWriteValue(noBadNodeIdUnknown/dead item), (b) B is browsable + readable. Phase 2 sibling: subscribe A + B, surgical-remove B ⇒ A alive, B's item observes the final Bad then the node is gone from browse; Phase 3 sibling for the mixed case. This also chips at 03/U6's "OpcUaServer.IntegrationTests has one test". - Live-
/rungate (MANDATORY per phase — the F10b lesson: an optimization path that isn't live-driven can ship inert despite full unit green): on docker-dev, rebuild BOTH central-1 and central-2 (:9200round-robins them);dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=<existing tag>" -i 500and keep it running;POST http://localhost:9200/api/deployments(X-Api-Key) a config adding one tag to a different equipment; assert (1) the running subscribe keeps delivering (no dead/Bad transition on the existing node), (2) the central log showskind=PureAdd, rebuild=False, (3)browse/readfinds the new node, (4) the new tag goes Good once the driver publishes. Phase 2: same recipe with a one-tag remove (subscription on a different tag survives; the removed node readsBadNodeIdUnknownafter). Phase 3: one deploy carrying +1/−1.
Effort + Risk (per phase)
| Phase | Effort | Risk / blast radius | Mitigations |
|---|---|---|---|
| 1 — pure-adds | M (~1.5-2 days incl. integration + live gate) | High-leverage, moderate risk: touches the apply routing core, but adds no sink members and no node-manager teardown; the worst regression is "should have rebuilt but didn't" (stale space) — bounded by the default-closed classifier + F10b fallback | table-driven classifier tests; recording-sink applier tests; subscription-survival integration test; mandatory live-/run; classifier verdict in the apply log |
| 2 — pure-removes | M-L (~2-3 days) | Highest risk slice: new node-manager teardown under Lock (leaked notifier refs / map drift / lock-order inversion on the event report) + the deferred-forwarding trap |
guard auto-coverage + deliberate negative control; teardown mirrors the proven RebuildAddressSpace loops; report-outside-Lock mirrors three existing patterns; false⇒rebuild ratchet; live-/run remove gate |
| 3 — mixed | S-M (~0.5-1 day) | Low: composition of two shipped paths; the only new surface is remove-then-recreate ordering | targeted same-NodeId reuse test; live-/run mixed gate |
Overall: Large (matches OVERALL #7), ~4-6 days. Each phase lands independently; between phases, unhandled kinds keep today's rebuild behavior.
Task breakdown
Tasks are bite-sized TDD increments. Classification: high-risk = touches the sink seam / node-manager mutation under Lock / apply routing (review + live-verify weight); low-risk = pure functions, tests, docs. Every task: run its FAIL→PASS filter, then the touched project's full suite before committing. Phase boundaries are hard sequence points (marked). Baseline branch suggestion: feat/r2-07-surgical-pure-adds off master.
── PHASE 1 — pure-adds ──
T1 — Classifier skeleton + table-driven tests (RED→GREEN)
- Classification: low-risk (pure function)
- Estimated implement time: 5 min (tests) + 5 min (impl)
- Parallelizable with: T5 (different files); everything else in Phase 1 depends on it
- Files:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs(new),tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs(new) - TDD: write the full
[Theory]matrix from the Tests section (reuseAddressSpacePlannerTests' plan-construction helpers);dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~AddressSpaceChangeClassifierTests"→ FAIL (type missing) → implement enum +Classify(first-match-wins table) with the two eligibility predicates moved from the applier (AddressSpaceApplier.cs:630-663) asinternal static; applier temporarily references them at their new home (compile-only change to the applier) → PASS. Full project suite green. - Commit:
feat(opcua): add AddressSpaceChangeClassifier — typed delta classification for the applier (R2-07 T1)
T2 — Route Apply through the classifier (behavior-preserving except PureAdd)
- Classification: high-risk (apply routing core)
- Estimated implement time: 5 min (tests) + 10 min (impl)
- Parallelizable with: — (blocks on T1)
- Files:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs,tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs - TDD: add applier tests — pure-add-only plan ⇒
RebuildCalls == 0+RebuildCalled == false; pure-add + surgical tag delta ⇒ no rebuild +UpdateTagAttributesrecorded; pure-add + surgical-returns-false ⇒ rebuild fallback; remove-only / add+remove / changed-non-surgical plans ⇒ still rebuild (phase pins);dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~AddressSpaceApplierTests"→ new tests FAIL → replacestructuralRebuildwith the classifier switch (Rebuild/AddRemoveMix/PureRemove ⇒SafeRebuild(); PureAdd/AttributeOnly ⇒ surgical path) +kind={Kind}in the apply log → PASS, every pre-existing applier test untouched-green (the behavior-preservation proof). - Commit:
feat(opcua): route AddressSpaceApplier through the classifier — pure-add deploys no longer rebuild (R2-07 T2)
T3 — ComputeAddAnnouncements + AnnounceAddedNodes on the applier
- Classification: low-risk (uses the existing guard-covered
RaiseNodesAddedModelChangesink member) - Estimated implement time: 5 min (tests) + 5 min (impl)
- Parallelizable with: T4a (blocks T4b)
- Files:
AddressSpaceApplier.cs,AddressSpaceApplierTests.cs - TDD: tests — two tags added under the same equipment ⇒ ONE announce for the equipment id; tag with FolderPath ⇒ announce
EquipmentNodeIds.SubFolder(...); added scripted alarm ⇒ equipment id; added equipment withUnsLineId⇒ line id, without ⇒ its own id; empty-add plan ⇒ zero announces; filter as T2 → FAIL → implement (pure compute + Safe-wrapped sink loop) → PASS. - Commit:
feat(opcua): announce pure-add nodes via Part 3 NodeAdded model-change (R2-07 T3)
T4a — Idempotent MaterialiseAlarmCondition (skip-if-present-and-same-kind)
- Classification: high-risk (node-manager mutation under
Lock) - Estimated implement time: 5 min (tests) + 5 min (impl)
- Parallelizable with: T3
- Files:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs,tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmIdempotentMaterialiseTests.cs(new; crib the server-bootstrap fixture fromNodeManagerModelChangeOnAddTests) - TDD: tests — re-materialise same id+kind ⇒
FindAlarmConditionreturns the same instance (subscription-preserving); kind-swap (isNative flip) ⇒ recreated; post-rebuild re-materialise ⇒ fresh (map was cleared);dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerAlarmIdempotentMaterialise"FAIL → early-return guard → PASS. Run the fullNodeManager*+AlarmCommandRoutersuites (condition-handler regressions). - Commit:
feat(opcua): make MaterialiseAlarmCondition idempotent for same-kind re-applies (R2-07 T4a)
T4b — Publish-actor post-materialise announce hook
- Classification: high-risk (deploy pipeline)
- Estimated implement time: 5 min (tests) + 5 min (impl)
- Parallelizable with: — (blocks on T2+T3+T4a)
- Files:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs,tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs - TDD: extend the rebuild tests with a pure-add artifact case — recording sink shows NO
RebuildAddressSpace, andRaiseNodesAddedModelChangerecorded AFTER the added tag'sEnsureVariable(sequence list); a rebuild-kind artifact records NO announce;dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~OpcUaPublishActorRebuildTests"FAIL → addif (!outcome.RebuildCalled) _applier.AnnounceAddedNodes(plan);after the fourth pass → PASS. - Commit:
feat(runtime): announce added nodes after the materialise passes on non-rebuild applies (R2-07 T4b)
T5 — Subscription-survival integration test (over-the-wire)
- Classification: high-risk to write, low-risk to land (test-only)
- Estimated implement time: 15 min
- Parallelizable with: T1-T4 (can be authored against the design; goes green only after T4b)
- Files:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs(new; reuseDualEndpointTests' server + session bootstrap) - TDD: subscribe tag A (real monitored item, 250 ms), pure-add apply adds tag B,
WriteValue(A)⇒ notification arrives (item alive), browse finds B. Written FIRST it FAILS on master behavior (rebuild kills the item); goes PASS once T2/T4b land.dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests --filter "FullyQualifiedName~SubscriptionSurvivalTests". - Commit:
test(opcua): over-the-wire subscription-survival on pure-add deploy (R2-07 T5)
T6 — Phase 1 live-/run gate (docker-dev) — MANDATORY before merging Phase 1
- Classification: high-risk verification (the F10b inertness net)
- Estimated implement time: 20 min
- Parallelizable with: —
- Files: none (evidence into the PR/STATUS notes)
- Steps: rebuild both central-1 + central-2 images; Client.CLI
subscribeon an existing tag (keep running);POST :9200/api/deploymentsadding ONE tag to a different equipment; assert: subscribe stream uninterrupted; central logkind=PureAdd, rebuild=False;browse/readshows the new node; new tag turns Good on first driver publish. Capture the log lines. - Commit:
docs(archreview): record R2-07 Phase 1 live-verify evidence in STATUS.md - ── PHASE 1 SHIPPABLE BOUNDARY — merge/PR here ──
── PHASE 2 — pure-removes ──
T7 — Sink members + deferred forwarding, guard-first (negative control observed)
- Classification: high-risk (THE deferred-sink trap; guard-set coverage FIRST)
- Estimated implement time: 5 min (interface+stub, observe guard FAIL) + 5 min (forward, PASS)
- Parallelizable with: — (Phase 2 root)
- Files:
src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs,src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs,tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs - TDD (the guard IS the test): add the three members with
DeferredAddressSpaceSinkstubs=> false(non-forwarding);dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests --filter "FullyQualifiedName~DeferredSinkForwardingReflectionTests"→ FAIL with the member-named diagnostic (negative control — record it) → implement capability-sniffing forwards (patternDeferredAddressSpaceSink.cs:58-70) → PASS. Add arg-fidelity cases to the hand-written deferred tests. NOTE:SdkAddressSpaceSink+OtOpcUaNodeManagermust compile — add node-manager methods returningfalse(rebuild-fallback semantics ⇒ tree stays shippable) with xmldoc marking T8-T10 as the real implementation. - Commit:
feat(commons): add surgical remove members to ISurgicalAddressSpaceSink + deferred forwarding (guard-verified) (R2-07 T7)
T8 — RemoveVariableNode on the node manager
- Classification: high-risk (teardown under
Lock) - Estimated implement time: 5 min (tests) + 10 min (impl)
- Parallelizable with: T9 (disjoint methods, same file — coordinate or sequence)
- Files:
OtOpcUaNodeManager.cs,tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalRemoveTests.cs(new) - TDD: ensure→remove ⇒
FindVariablenull +VariableCountdown +TryGetHistorianTagnamefalse; unknown id ⇒ false;BuildNodesRemovedModelChangecarriesVerb=NodeDeleted+Affected(internal seam, mirrorsNodeManagerModelChangeOnAddTests);dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerSurgicalRemoveTests"FAIL → implement (detach mirrorsRebuildAddressSpace:1694-1701scoped to one id; build event underLock, report outside) → PASS. - Commit:
feat(opcua): surgical RemoveVariableNode with NodeDeleted model-change (R2-07 T8)
T9 — RemoveAlarmConditionNode
- Classification: high-risk
- Estimated implement time: 5 + 5 min
- Parallelizable with: T8 (coordinate on the shared test file)
- Files:
OtOpcUaNodeManager.cs,NodeManagerSurgicalRemoveTests.cs - TDD: materialise (native + scripted)→remove ⇒
FindAlarmConditionnull + native flag cleared +NodeDeletedbuilt; unknown ⇒ false; filter as T8 FAIL→PASS (teardown mirrors:1703-1711scoped). - Commit:
feat(opcua): surgical RemoveAlarmConditionNode (R2-07 T9)
T10 — RemoveEquipmentSubtree (descendants + notifier demotion)
- Classification: high-risk (the notifier/root-ref leak hazard)
- Estimated implement time: 10 min (tests) + 15 min (impl)
- Parallelizable with: — (blocks on T8+T9 for the per-node primitives it reuses)
- Files:
OtOpcUaNodeManager.cs,SdkAddressSpaceSink.cs(the three forwards),NodeManagerSurgicalRemoveTests.cs - TDD: equipment with subfolder+variables+condition → remove ⇒ all descendants gone from every map,
RemoveRootNotifierinvoked (no orphaned Server↔folder ref — assert via_notifierFolders-driven observable or the notifier accessor),_eventNotifierSourcesentry dropped, sibling equipment fully intact; unknown ⇒ false; filter FAIL→PASS. Wire the threeSdkAddressSpaceSinkforwards here (replacing T7's false-stubs at the manager) and re-run the Commons guard. - Commit:
feat(opcua): surgical RemoveEquipmentSubtree with notifier demotion (R2-07 T10)
T11 — Applier PureRemove arm (terminal writes + subsumption + fallback ratchet)
- Classification: high-risk (apply routing)
- Estimated implement time: 10 min (tests) + 10 min (impl)
- Parallelizable with: —
- Files:
AddressSpaceApplier.cs,AddressSpaceApplierTests.cs - TDD: remove-one-tag plan ⇒ Bad
WriteValuethenRemoveVariableNode, NO rebuild; removed alarm-bearing tag ⇒RemovedConditionStatewrite +RemoveAlarmConditionNode(closes the today-gap); removed equipment ⇒RemoveEquipmentSubtreeand NO individual child removals (subsumption); any remove returns false ⇒SafeRebuild()+ no further surgical calls (ratchet); flip the T2 phase-pin ("remove-only ⇒ rebuild") to the new expectation; filterFullyQualifiedName~AddressSpaceApplierTestsFAIL→PASS. - Commit:
feat(opcua): surgical pure-remove deploys — scoped teardown, no full rebuild (R2-07 T11)
T12 — Phase 2 integration + live gate
- Classification: high-risk verification
- Estimated implement time: 15 min (test) + 20 min (live)
- Parallelizable with: —
- Files:
SubscriptionSurvivalTests.cs - TDD: subscribe A+B → surgical-remove B ⇒ A's item alive; B's item observes final Bad; B unbrowsable (
BadNodeIdUnknownon read). Then the live-/runremove recipe (both centrals; subscribe survivor; deploy −1 tag; log showskind=PureRemove, rebuild=False). Record evidence in STATUS.md. - Commit:
test(opcua): subscription-survival on pure-remove + live-verify evidence (R2-07 T12) - ── PHASE 2 SHIPPABLE BOUNDARY ──
── PHASE 3 — mixed batches ──
T13 — Classifier + applier AddRemoveMix routing (removes-then-adds)
- Classification: high-risk (apply routing)
- Estimated implement time: 5 min (tests) + 5 min (impl)
- Parallelizable with: —
- Files:
AddressSpaceApplier.cs,AddressSpaceApplierTests.cs,AddressSpaceChangeClassifierTests.cs - TDD: add+remove plan ⇒ removes applied surgically, NO rebuild,
RebuildCalled=false(flip the T2 phase pin); remove-then-recreate same folder-scoped NodeId in one plan ⇒ remove recorded before the passes would re-ensure (sequence assertion); any remove failure ⇒ rebuild ratchet still holds; filters as before FAIL→PASS. - Commit:
feat(opcua): surgical mixed add+remove deploys (R2-07 T13)
T14 — Phase 3 integration + live gate + docs close-out
- Classification: high-risk verification + low-risk docs
- Estimated implement time: 15 min (test) + 20 min (live) + 10 min (docs)
- Parallelizable with: —
- Files:
SubscriptionSurvivalTests.cs,archreview/plans/STATUS.md,CLAUDE.md/docsnote if the deploy-behavior description warrants it,archreview/03-server-runtime.mdP1 row (mark remediated) - TDD: mixed-deploy integration case (subscribe A; deploy −B/+C; A alive, B gone, C browsable). Live: one deploy carrying +1/−1 on docker-dev (both centrals), log
kind=AddRemoveMix, rebuild=False, survivor subscription uninterrupted. Update STATUS.md + the P1 finding row. - Commit:
test+docs: mixed-deploy survival, live evidence, STATUS/P1 close-out (R2-07 T14) - ── PHASE 3 / PLAN COMPLETE ──