Files
lmxopcua/archreview/plans/R2-07-surgical-pure-adds-plan.md
T
Joseph Doherty 1891f5d6a7 docs(archreview): 2026-07-12 re-review at f6eaa267 + 12 round-2 remediation plans
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.
2026-07-12 23:52:23 -04:00

372 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.md` prioritized 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 under `tests/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.IsEmpty` short-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`/`EnsureVariable` early-return on an existing NodeId; `MaterialiseAlarmCondition` drops-and-recreates per id (idempotent but **not** subscription-preserving for that one condition node — see Design, native-alarm note). `MaterialiseHierarchy`/`MaterialiseEquipmentTags`/`MaterialiseEquipmentVirtualTags`/`MaterialiseScriptedAlarms` therefore 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_sink` iterates `ForwardingInterfaces = { IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink }` via `GetMethods()` — a member added to `ISurgicalAddressSpaceSink` in Phase 2 is picked up with **zero test edits** and fails the guard if `DeferredAddressSpaceSink` does not forward it.
## Root cause
The applier's rebuild predicate conflates three delta classes with very different minimal-mutation requirements:
1. **In-place changes** (attribute edits, renames) — already handled surgically (F10b).
2. **Pure additions** — need *no teardown at all*: the idempotent Materialise passes add exactly the new nodes; `RaiseNodesAddedModelChange` exists to announce them. The rebuild is pure collateral damage.
3. **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:
```csharp
/// <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`:
1. `Apply` does **not** call `SafeRebuild()`. It still applies any coincident folder renames + surgical tag deltas via the existing `ISurgicalAddressSpaceSink` path (any `false`/throw there → `SafeRebuild()` fallback, exactly as today).
2. The four Materialise passes in `OpcUaPublishActor.HandleRebuild` then 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 is `MaterialiseScriptedAlarms`/native-alarm materialisation: `MaterialiseAlarmCondition` is 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: make `MaterialiseAlarmCondition` **skip-if-present-and-same-kind** on the node-manager side — add an idempotent early-return when `_alarmConditions` already holds the id with the same `isNative` flag (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.
3. **Announcement:** after the Materialise passes complete, `OpcUaPublishActor` calls a new `applier.AnnounceAddedNodes(plan)` (Phase-1 applier method, no new sink member — it uses the existing, guard-covered `RaiseNodesAddedModelChange`). It computes a **deduplicated set of affected parent ids**: for each added tag/vtag → its materialise parent (`EquipmentId` or `EquipmentNodeIds.SubFolder(EquipmentId, FolderPath)`); for each added scripted alarm → its `EquipmentId`; for each added equipment → its `UnsLineId` when present, else the equipment's own id (the root has no announceable id; announcing the new folder itself is valid Part 3 — verb `NodeAdded`, `Affected` = the added node). One `RaiseNodesAddedModelChange` per distinct id. Ordering is correct by construction: `Apply` returns 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).
4. **Existing-subscription behavior on add: none affected** — no existing `NodeState` is touched; per OPC UA Part 4 monitored items are unaffected by address-space growth; model-aware clients get the Part 3 `GeneralModelChangeEvent` and 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):
```csharp
/// <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`:
1. Partition removals: removed equipment "own" their child tags/vtags/alarms — a `RemovedEquipmentTags`/`RemovedEquipmentVirtualTags`/`RemovedAlarms` entry whose `EquipmentId` is in `RemovedEquipment` is **subsumed** by the subtree removal and skipped individually.
2. Pre-teardown terminal writes (extends the existing `:83-98` pattern): removed value variables get `WriteValue(nodeId, null, Bad, ts)`; removed condition nodes (scripted alarms AND alarm-bearing tags — the latter is a today-gap: `RemovedEquipmentTags` with `Alarm != null` currently get no condition write) get `SafeWriteAlarmCondition(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 get `BadNodeIdUnknown` from the SDK because the node is no longer in the predefined set.
3. Teardown order children-first: individual variable/condition removals (for surviving equipment), then `RemoveEquipmentSubtree` per removed equipment. Empty leftover `FolderPath` sub-folders on surviving equipment are **deliberately kept** (browse-visible but harmless; the next full rebuild collects them — documented v1 cut, mirrors `EnsureFolder`'s create-only semantics).
4. Any `false`/throw from a remove ⇒ `SafeRebuild()` (one-way ratchet per apply: once rebuilt, skip remaining surgical work — the rebuild already reconciled everything).
5. **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 (`NamespaceIndex` on `OtOpcUaNodeManager`); 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 for `UpdateTagAttributes` at `:177`). Any new derivation in the remove path MUST go through `EquipmentNodeIds` — never hand-rolled strings.
- Same-NodeId collision on add (two tags materialising to one folder-scoped id) keeps today's first-wins `EnsureVariable` semantics — 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 of `Apply` — 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 `SafeMaterialiseAlarmCondition` in `MaterialiseEquipmentTags` — additive on the pure-add path once the skip-if-present guard (Phase 1 step 2) lands. Removal of an alarm-bearing tag routes to `RemoveAlarmConditionNode` (its node is a condition, not a value variable). `EnsureFolderIsEventNotifier` promotion 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.PushDesiredSubscriptions` independent of this path — adds get subscribed, removes get unsubscribed and un-routed, no change needed.
- **`AddressSpaceApplyOutcome`:** gains nothing structurally in Phase 1 (the existing `RebuildCalled=false` + `AddedNodes` counts express the pure-add outcome); the apply log line gains the classifier verdict (`kind={Kind}`) so live verification can grep for `kind=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 `MonitoredItem` to a new `NodeState`; rejected (round-1 plan concurs).
- **Generic diff-and-patch of every delta class** (make `ChangedEquipment`/`ChangedAlarms` surgical too) — larger and riskier; changed-alarm re-materialisation and equipment renames can ride a later increment (equipment DisplayName rename is `UpdateFolderDisplayName`-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: `Compute` is 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-node `bool` false-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 (F10b `UpdateTagAttributes:false`) is resync-via-rebuild. Documented in the member xmldoc.
- **Announce adds from inside `Apply`** — impossible: `Apply` runs before the Materialise passes create the nodes; the announce must be a post-passes step in `OpcUaPublishActor` (verified ordering `OpcUaPublishActor.cs:335-354`).
---
## Implementation steps (exact paths)
### Phase 1 — pure-adds
1. **`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs`** (new): `AddressSpaceChangeKind` enum + `Classify(AddressSpacePlan)` per the table; move `TagDeltaIsSurgicalEligible` + `VtagDeltaIsNodeIrrelevant` here as `internal static` (from `AddressSpaceApplier.cs:630-663`).
2. **`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs`**: replace the `structuralRebuild` boolean (`:134-140`) with `var kind = AddressSpaceChangeClassifier.Classify(plan)`; `Rebuild`/`AddRemoveMix`/`PureRemove` (until their phases ship) ⇒ `SafeRebuild()`; `PureAdd`/`AttributeOnly` ⇒ no rebuild + existing surgical renames/tag-deltas (with the existing `false`⇒rebuild fallback). Add `kind` to the apply log line. Add `public IReadOnlyList<string> ComputeAddAnnouncements(AddressSpacePlan plan)` (pure, dedup parent-id set per the design) and `public void AnnounceAddedNodes(AddressSpacePlan plan)` calling `_sink.RaiseNodesAddedModelChange` per id (Safe-wrapped). While here, refresh the stale F14-era class doc (`:7-26`, 01/C-7).
3. **`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`**: `MaterialiseAlarmCondition` (`:586-604`) — idempotent early-return when `_alarmConditions` holds the id AND the stored native flag equals `isNative` (keeps drop-and-recreate only for kind-swaps; requires remembering the flag — `_nativeAlarmNodeIds` already encodes it).
4. **`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs`**: in `HandleRebuild` after the fourth Materialise pass (`:354`), `if (!outcome.RebuildCalled) _applier.AnnounceAddedNodes(plan);`.
5. Live-`/run` gate (see Tests) + docs: `docs/v2/`-appropriate note + `archreview/plans/STATUS.md` row.
### Phase 2 — pure-removes
6. **`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 — implement `DeferredAddressSpaceSink` forwarding in the same commit or the guard fails the build's test leg (that failure IS the negative control; observe it once deliberately).
7. **`src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs`**: capability-sniffing forwards for all three (pattern of `:58-70`).
8. **`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`**: implement `RemoveVariableNode` / `RemoveAlarmConditionNode` / `RemoveEquipmentSubtree` (teardown under `Lock` mirroring the per-node loops inside `RebuildAddressSpace` `:1694-1734`, scoped to the id/subtree; clean `_variables`/`_historizedTagnames`/`_alarmConditions`/`_nativeAlarmNodeIds`/`_folders`/`_notifierFolders`(+`RemoveRootNotifier`)/`_eventNotifierSources`); `internal GeneralModelChangeEventState BuildNodesRemovedModelChange(string affectedNodeId)` mirroring `BuildNodesAddedModelChange` (`:1627-1654`) with `Verb = NodeDeleted`; report outside `Lock`.
9. **`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs`**: three one-line forwards.
10. **`AddressSpaceApplier.cs`**: `PureRemove` arm — terminal writes (Bad value / `RemovedConditionState`, extending `:83-98` incl. the alarm-bearing-removed-tag gap), subsumption partition, children-first removal calls, `false`/throw ⇒ `SafeRebuild()` ratchet.
11. Live-`/run` remove gate + docs.
### Phase 3 — mixed batches
12. **`AddressSpaceChangeClassifier.cs` + `AddressSpaceApplier.cs`**: `AddRemoveMix` routes to the Phase-2 remove pass then falls through to the pure-add (no-rebuild + announce) behavior; remove-then-recreate same-NodeId test.
13. 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 — `DeferredSinkForwardingReflectionTests` auto-covers new `ISurgicalAddressSpaceSink` members. The TDD step is a **deliberate negative control**: add the interface members with a non-forwarding `DeferredAddressSpaceSink` stub (`=> false`), run `dotnet test --filter "FullyQualifiedName~DeferredSinkForwardingReflectionTests"` and observe the member-named FAIL, then implement the forward and observe PASS. Also extend the hand-written `DeferredAddressSpaceSinkTests` (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` (the `RecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink` at `:1761` already records `RebuildCalls` + `ModelChangeQueue`): pure-add plan ⇒ `RebuildCalls == 0`, `outcome.RebuildCalled == false`; `AnnounceAddedNodes` ⇒ one `RaiseNodesAddedModelChange` per distinct parent (dedup + subfolder-parent + added-equipment cases); pure-add **with** coincident surgical tag delta ⇒ no rebuild + `UpdateTagAttributes` called; surgical-`false` during 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 new `NodeManagerAlarmIdempotentMaterialiseTests`): re-`MaterialiseAlarmCondition` same id+kind keeps the same `AlarmConditionState` instance (`FindAlarmCondition` reference-equal); kind-swap still recreates.
- **Node manager (Phase 2):** new `NodeManagerSurgicalRemoveTests`: ensure→remove variable ⇒ `FindVariable` null, `VariableCount` decremented, `TryGetHistorianTagname` false, unknown id ⇒ false; alarm-condition remove ⇒ `FindAlarmCondition` null + native flag cleared; equipment-subtree remove ⇒ descendants gone, notifier demoted (no orphaned root-notifier ref), other equipment untouched; `BuildNodesRemovedModelChange` populates `Verb=NodeDeleted` + `Affected` (mirrors `NodeManagerModelChangeOnAddTests`).
- **Publish actor (Phase 1):** extend `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs`: pure-add artifact apply ⇒ sink records NO `RebuildAddressSpace` call, records `RaiseNodesAddedModelChange` AFTER `EnsureVariable` for the added tag (ordering assertion via a sequenced recording sink).
- **Subscription-survival integration (Phase 1, the headline):** new `SubscriptionSurvivalTests` in `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/` (reuse `DualEndpointTests`' in-process server + `DefaultSessionFactory` session 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 subsequent `WriteValue` (no `BadNodeIdUnknown`/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-`/run` gate (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** (`:9200` round-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 500` and 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 shows `kind=PureAdd, rebuild=False`, (3) `browse`/`read` finds 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 reads `BadNodeIdUnknown` after). 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 (reuse `AddressSpacePlannerTests`' 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`) as `internal 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 + `UpdateTagAttributes` recorded; 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 → replace `structuralRebuild` with 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 `RaiseNodesAddedModelChange` sink 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 with `UnsLineId` ⇒ 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 from `NodeManagerModelChangeOnAddTests`)
- TDD: tests — re-materialise same id+kind ⇒ `FindAlarmCondition` returns 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 full `NodeManager*` + `AlarmCommandRouter` suites (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`, and `RaiseNodesAddedModelChange` recorded AFTER the added tag's `EnsureVariable` (sequence list); a rebuild-kind artifact records NO announce; `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~OpcUaPublishActorRebuildTests"` FAIL → add `if (!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; reuse `DualEndpointTests`' 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 `subscribe` on an existing tag (keep running); `POST :9200/api/deployments` adding ONE tag to a different equipment; assert: subscribe stream uninterrupted; central log `kind=PureAdd, rebuild=False`; `browse`/`read` shows 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 `DeferredAddressSpaceSink` stubs `=> 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 (pattern `DeferredAddressSpaceSink.cs:58-70`) → PASS. Add arg-fidelity cases to the hand-written deferred tests. NOTE: `SdkAddressSpaceSink` + `OtOpcUaNodeManager` must compile — add node-manager methods returning `false` (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 ⇒ `FindVariable` null + `VariableCount` down + `TryGetHistorianTagname` false; unknown id ⇒ false; `BuildNodesRemovedModelChange` carries `Verb=NodeDeleted` + `Affected` (internal seam, mirrors `NodeManagerModelChangeOnAddTests`); `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerSurgicalRemoveTests"` FAIL → implement (detach mirrors `RebuildAddressSpace:1694-1701` scoped to one id; build event under `Lock`, 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 ⇒ `FindAlarmCondition` null + native flag cleared + `NodeDeleted` built; unknown ⇒ false; filter as T8 FAIL→PASS (teardown mirrors `:1703-1711` scoped).
- 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, `RemoveRootNotifier` invoked (no orphaned Server↔folder ref — assert via `_notifierFolders`-driven observable or the notifier accessor), `_eventNotifierSources` entry dropped, sibling equipment fully intact; unknown ⇒ false; filter FAIL→PASS. Wire the three `SdkAddressSpaceSink` forwards 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 `WriteValue` then `RemoveVariableNode`, NO rebuild; removed alarm-bearing tag ⇒ `RemovedConditionState` write + `RemoveAlarmConditionNode` (closes the today-gap); removed equipment ⇒ `RemoveEquipmentSubtree` and 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; filter `FullyQualifiedName~AddressSpaceApplierTests` FAIL→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 (`BadNodeIdUnknown` on read). Then the live-`/run` remove recipe (both centrals; subscribe survivor; deploy 1 tag; log shows `kind=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`/`docs` note if the deploy-behavior description warrants it, `archreview/03-server-runtime.md` P1 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 ──**