Merge #597/#599: real UnshelveTime, commented re-ack reports, native ack refused off-Primary, /alerts ack-path test; surgically-added raw nodes announced, equipment-with-references removal keeps references severed; Backing* parity guard

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:12:59 -04:00
16 changed files with 873 additions and 89 deletions
+66 -20
View File
@@ -292,8 +292,9 @@ branches on native-ness and routes through a dedicated path — separate
from the scripted `AlarmCommandRouter`:
1. **`OtOpcUaNodeManager.HandleNativeAlarmAck`** — gates on the caller's
`AlarmAck` role (fails closed: no role → `BadUserAccessDenied`), then
dispatches a `NativeAlarmAck(ConditionNodeId, Comment, OperatorUser)`
`AlarmAck` role (fails closed: no role → `BadUserAccessDenied`), then on the
node's redundancy role (not the Primary → `BadInvalidState`, see
§"Acknowledge on a non-Primary node" below), then dispatches a `NativeAlarmAck(ConditionNodeId, Comment, OperatorUser)`
to the `NativeAlarmAckRouter` seam (fire-and-forget, non-blocking under
the node-manager Lock). `OperatorUser` carries the authenticated
session principal's display name.
@@ -331,20 +332,38 @@ projection's Retain rule, `Active || !Acked`) after the SDK handler, before the
one ack event. ScadaBridge maps `ConfirmedState` into its alarm state, so this
is client-visible.
> **Secondary-node ack flap.** On a node that is not the redundancy Primary,
> an OPC UA Acknowledge is applied to the local condition and reported (one
> event, Acked), but `DriverHostActor`'s Primary gate drops the route upstream.
> The driver never acknowledges, so its next transition for that alarm is
> projected with `Acked = false` and the node reverts — with its own event. A
> client acking against the Secondary sees acked → unacked. Ack against the
> Primary (the higher ServiceLevel) for the ack to stick.
> **Acknowledge on a non-Primary node is refused** (Gitea #597). On a node
> that is not the redundancy Primary, an OPC UA Acknowledge of a **native**
> condition returns **`BadInvalidState`** ("not the redundancy Primary;
> acknowledge on the Primary") — nothing is routed, applied or reported. The
> verdict is the same data-plane one the device-write gate and
> `DriverHostActor`'s ack route use (`PrimaryGatePolicy.ShouldServiceAsPrimary`,
> published to `IDriverPrimaryRoleSource` on every redundancy snapshot and
> wired onto the node manager by `OtOpcUaServerHostedService`). It is checked
> after authorization, so a caller who may not ack learns nothing about the
> node's role.
>
> This replaces a documented flap: the Secondary used to apply and report the
> ack locally while `DriverHostActor` dropped the route upstream, so the driver
> never acknowledged, its next transition projected `Acked = false`, and the
> client saw acked → unacked. An honest refusal is client-visible — a client
> (ScadaBridge included) that acks against the Secondary now gets a Bad result
> instead of a Good that later reverts — so ack against the Primary (the higher
> ServiceLevel). Scripted conditions are **not** gated: their engine runs on
> both nodes, so an ack on the Secondary is real. Residual: the published
> verdict changes only on a redundancy snapshot, so in the boot window of a
> multi-node cluster (no snapshot yet) the node still accepts, while
> `DriverHostActor`'s live check may drop the route — the old flap, limited to
> that window.
> **Best-effort during reconnect.** If a native ack arrives while the owning
> driver is `Connecting`/`Reconnecting` (or on a non-Primary node), it is
> dropped with a debug log and **not** retried upstream — the OPC UA Part 9
> `AlarmConditionState` was already committed locally at step 1, so the rare
> outcome is "acked locally but not propagated to AVEVA." Re-issue the ack once
> the driver is reconnected if upstream propagation is required.
> driver is `Connecting`/`Reconnecting` (or, in the boot window above, on a
> node whose role is not yet known), it is dropped with a debug log and **not**
> retried upstream — the OPC UA Part 9 `AlarmConditionState` was already
> committed locally at step 1, so the rare outcome is "acked locally but not
> propagated to AVEVA." Re-issue the ack once the driver is reconnected if
> upstream propagation is required — a re-ack of a node already showing Acked
> is still routed to the driver for exactly this reason.
### Legacy sub-attribute path
@@ -412,8 +431,9 @@ reports nothing.
`AreEventsMonitored` bookkeeping, which is imprecise for conditions
materialised under an already-subscribed folder.
- **The SDK's shelve methods never apply shelving state** (they only call
`OnShelve`), so the wrapper applies it — with the same
`(shelved, oneShot, 0)` mapping the projection uses — before reporting.
`OnShelve`), so the wrapper applies it — with the same `(shelved, oneShot)`
mapping the projection uses, and a TimedShelve with its requested duration —
before reporting.
- **The re-projection does not duplicate it.** The engine's (or driver's)
answer to the routed command reaches `WriteAlarmCondition`'s delta-gate,
which compares against the node's current state — already the new state —
@@ -425,7 +445,16 @@ reports nothing.
nothing. The SDK accepts acknowledging an acknowledged condition and sets
`Confirmed = false` again, so a re-ack also restores the pre-call Confirmed
and Retain — silent, and a scripted node never drifts from its engine.
AddComment always reports (the comment is the change).
AddComment always reports (the comment is the change), and so does a re-ack
**carrying a comment** (Gitea #597): the SDK writes that comment to the node,
so it is reported once, with Acked/Confirmed unchanged. Before #597 the
comment landed on the node but no subscriber ever saw it.
- **A native re-ack is still routed to the driver** (deliberate, #597). The
route is fire-and-forget, so a node showing Acked does not prove the
upstream system acknowledged — an ack dropped during a reconnect leaves
exactly that state — and re-acking is the documented retry (§"Best-effort
during reconnect"). The cost is a duplicate ack upstream for an alarm that
was already acknowledged there.
- **Native conditions: Acknowledge and AddComment only** — see §"Native alarm
acknowledge → AVEVA" above. The other methods are refused
(`BadNotSupported`, no event), and a native ack keeps the condition
@@ -433,9 +462,26 @@ reports nothing.
- **TimedShelve works (scripted).** `Create()` left `MaxTimeShelved` at 0, and
the SDK rejects any shelving time above it, so every client TimedShelve
failed with `BadShelvingTimeOutOfRange`. It is now `int.MaxValue` ms (≈24.8
days). The node applies the shelve with no SDK timer of its own; the engine
owns the expiry and its Unshelved re-projection reports the one expiry
event.
days). The engine owns the expiry and its Unshelved re-projection reports
the one expiry event.
- **`UnshelveTime` is the real remaining time** (Gitea #597). Both the method
path and the projection used to apply a timed shelve with `shelvingTime 0`,
which the SDK reads as `MaxTimeShelved` — so every timed shelve advertised
≈24.8 days remaining. The method now applies the requested duration, and the
engine's projection carries its expiry (`AlarmConditionSnapshot.UnshelveAtUtc`),
so `ShelvingState/UnshelveTime` counts down to the engine's expiry whichever
path shelved it (the AdminUI `/alerts` timed shelve included). This arms the
SDK's own unshelve timer at the real instant; it changes no node state and
only routes an `Unshelve` (user `system`) back to the engine — a no-op if the
engine's 5 s expiry sweep got there first, otherwise it simply lands the
expiry on time.
- **Accepted, not fixed — the SDK's `EventsMonitored` unit for late conditions**
(Gitea #597 item 6, from the #586 review). The SDK's `AreEventsMonitored`
counter never reaches a condition materialised under a folder that already had
an event subscriber. Our own reporting does not depend on it (above); the one
thing it still gates is the SDK's method **audit** event, and only while the
server is auditing — which no configuration in this repo enables. Closed as
accepted.
- Method **audit** events (`AuditConditionAcknowledgeEventType`, …) are still
the SDK's, delivered only while the server is auditing over an encrypted
channel.
+12 -3
View File
@@ -105,9 +105,18 @@ mirror **in place** (its MonitoredItems survive); a re-point or effective-name c
removes and recreates **that one mirror node**. See [`Uns.md`](Uns.md) and
[`plans/2026-08-15-surgical-deploys.md`](plans/2026-08-15-surgical-deploys.md).
⚠️ A surgically added raw container/tag is materialised but **not** announced —
`AnnounceAddedNodes` is UNS-only, so model-aware clients see new raw nodes on their next
browse. This mattered less when every add forced a rebuild (and therefore a re-browse).
A surgically added raw container/tag is **announced** (Gitea #599): `AnnounceAddedNodes`
raises a `GeneralModelChangeEvent(NodeAdded)` in the **Raw** realm for each distinct
existing parent of the added nodes (a parent that is itself new in the same deploy is
skipped — its own parent is announced, and a re-browse there finds the whole new subtree;
a new top-level folder announces itself). UNS-side adds announce in the Uns realm as
before. Until #599 raw adds were materialised but never announced, so model-aware clients
only saw them on their next browse — invisible while every add still forced a rebuild.
Removing an equipment that holds UNS references is also surgical: the references are
removed (terminal Bad, then `RemoveVariableNode`, which severs the backing raw node's
inverse `Organizes` edge) **before** the equipment subtree, and the subtree removal severs
the same edge for anything it reaches itself (archreview 03/P5 + 03/S20).
## The `Calculation` driver
+1 -1
View File
@@ -358,7 +358,7 @@ On allow, the handler publishes a `Commons.OpcUa.AlarmCommand` (containing comma
### Delta-gate de-duplication
`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. For an OPC UA method call (Acknowledge, Confirm, AddComment, the shelve methods, Enable/Disable) the SDK applies the new state to the node, and the node manager then reports **the one** condition event for it from the method path (Gitea #587 — the SDK reports none of its own, since `AutoReportStateChanges` stays false; the shelve methods' state is applied by the node manager, because the SDK's never applies it; a call that changed nothing — e.g. a re-ack — reports nothing). Native (driver-fed) conditions support only Acknowledge and AddComment; their Confirm, shelve and Enable/Disable methods return `BadNotSupported`. The engine's later re-projection of the same transition finds the node already in that state and is suppressed, so each accepted call yields exactly one event. See `docs/AlarmTracking.md` §"One condition event per method call".
`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. For an OPC UA method call (Acknowledge, Confirm, AddComment, the shelve methods, Enable/Disable) the SDK applies the new state to the node, and the node manager then reports **the one** condition event for it from the method path (Gitea #587 — the SDK reports none of its own, since `AutoReportStateChanges` stays false; the shelve methods' state is applied by the node manager, because the SDK's never applies it; a call that changed nothing — e.g. an uncommented re-ack — reports nothing; a re-ack carrying a comment reports that comment once, Gitea #597). Native (driver-fed) conditions support only Acknowledge and AddComment; their Confirm, shelve and Enable/Disable methods return `BadNotSupported`, and their Acknowledge is refused with `BadInvalidState` on a node that is not the redundancy Primary (Gitea #597). A timed shelve's `UnshelveTime` counts down to the engine's expiry (the projection carries `UnshelveAtUtc`). The engine's later re-projection of the same transition finds the node already in that state and is suppressed, so each accepted call yields exactly one event. See `docs/AlarmTracking.md` §"One condition event per method call".
### AdminUI path