fix(alarms): SDK timed-unshelve timer is a no-op; commented re-ack comment is durable (#597)

Review R1 M2: since #597 the SDK timer fires at real expiry, and OnTimedUnshelve routed an operator-style
Unshelve by 'system' from both pair nodes, pre-empting the engine's AutoUnshelve. The engine owns expiry,
so the callback now routes nothing. Integration test: an engine stand-in that acts on a routed Unshelve and
expires late, plus a wrapped OnTimedUnshelve (positive control: the SDK timer fired).
Review R1 m1: Part9StateMachine.ApplyAcknowledge records a commented re-ack as AddComment (persisted and
replicated) instead of a NoOp; an uncommented re-ack is still a no-op.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:47:28 -04:00
parent 8a9bf23048
commit 83e9ffd27a
7 changed files with 140 additions and 71 deletions
@@ -874,8 +874,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine; the time
// passed here is the engine's remaining time, so the node's UnshelveTime counts down to the
// real expiry (Gitea #597 — a 0 here reads as MaxTimeShelved, ~24.8 days). The SDK timer it
// arms only routes an Unshelve back to the engine (OnTimedUnshelve), a no-op once the
// engine's own expiry has run.
// arms does nothing (OnTimedUnshelve is a no-op): the engine's own expiry re-projects.
condition.SetShelvingState(
SystemContext,
shelved: state.Shelving != AlarmShelvingKind.Unshelved,
@@ -1375,25 +1374,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
: ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime));
return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt);
};
// The auto-unshelve timer callback is SDK-initiated (the TimedShelve duration expired); the SDK
// fires it with the node manager's system context — there is NO session and NO user identity.
// Routing through HandleAlarmCommand would hit the AlarmAck gate and return BadUserAccessDenied,
// leaving the alarm permanently shelved. Instead, bypass the client gate, extract the AlarmId the
// same way HandleAlarmCommand does, and route an Unshelve command so the engine clears its shelve
// state. The manual-client Unshelve path goes through OnShelve(shelving:false) and stays gated.
alarm.OnTimedUnshelve = (context, condition) =>
{
var alarmId = condition.NodeId.Identifier?.ToString() ?? string.Empty;
// User MUST be non-empty: the engine's Part9StateMachine.ApplyUnshelve rejects a
// null/whitespace user (ArgumentException "User required."), and that exception is swallowed
// downstream — an empty user would make the timed auto-unshelve silently no-op, so a
// TimedShelve would never auto-expire. There is no client principal on a system-timer
// unshelve, so label it the canonical engine-internal "system" user (matching the engine's
// own AutoUnshelve audit user and the codebase-wide "system" system-actor convention). The
// AlarmAck gate bypass is intentional and preserved — this is a session-less SDK timer.
AlarmCommandRouter?.Invoke(new AlarmCommand(alarmId, "Unshelve", "system", null, null));
return ServiceResult.Good;
};
// The SDK's auto-unshelve timer (armed by SetShelvingState for the real remaining time since Gitea
// #597) fires with a session-less system context. It is deliberately a NO-OP: the engine owns
// timed-shelve expiry (ScriptedAlarmEngine's shelving check → Part9StateMachine.ApplyShelvingCheck,
// recorded as "AutoUnshelve" by "system" with its "Timed shelving expired" comment), and its
// re-projection is what unshelves this node. Routing an operator-style "Unshelve" from here used to
// pre-empt that check on every expiry — from BOTH pair nodes, rewriting the audit trail as a manual
// unshelve — and a timer callback already queued when a re-shelve disposed it could unshelve the
// fresh shelve. The SDK's own OnTimerExpired changes no shelving state itself (it only refreshes
// UnshelveTime after this callback), so returning without routing leaves the node shelved until
// the engine's expiry lands. Native conditions are never timed-shelved (BadNotSupported above).
alarm.OnTimedUnshelve = (_, _) => ServiceResult.Good;
// H4 — inbound Part 9 Enable/Disable (the condition type's built-in Enable/Disable methods route
// here via this delegate). The engine handles Enable/Disable for SCRIPTED alarms
@@ -1596,9 +1587,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <b>Only a real change is reported.</b> The condition's gate-relevant state is read before and after
/// the SDK handler, and nothing is reported when it did not move. The SDK accepts acknowledging an
/// acknowledged condition and sets <c>Confirmed = false</c> again; a re-ack therefore also restores the
/// pre-call Confirmed and Retain, so a scripted node never drifts from its engine (which ignores a
/// re-ack) and the call is silent — unless it carried a comment, which the SDK writes and which is then
/// reported once like AddComment (Gitea #597). AddComment always reports: adding a comment is itself
/// pre-call Confirmed and Retain, so a scripted node never drifts from its engine (which ignores an
/// uncommented re-ack) and the call is silent — unless it carried a comment, which the SDK writes and
/// which is then reported once like AddComment (Gitea #597). The scripted engine records that comment
/// too (<c>Part9StateMachine.ApplyAcknowledge</c> treats a commented re-ack as AddComment), so the
/// reported comment is persisted and replicated, not node-local. AddComment always reports: adding a comment is itself
/// the change. A NATIVE re-ack is still routed to the driver: the route is fire-and-forget, so a node
/// showing Acked does not prove the upstream system acknowledged, and re-acking is the documented retry.
/// </para>
@@ -1635,7 +1628,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// The requested duration, so UnshelveTime counts down to the real expiry (Gitea #597 — 0 reads as
// MaxTimeShelved, ~24.8 days). The scripted engine still owns the expiry and re-projects Unshelved
// when it passes (with its own remaining time meanwhile — WriteAlarmCondition); the SDK timer this
// arms only routes an Unshelve back to it (OnTimedUnshelve), a no-op once the engine has expired it.
// arms does nothing (OnTimedUnshelve is a no-op — the engine's AutoUnshelve owns the expiry).
timedShelve.OnCall = (context, method, objectId, shelvingTime) => ReportAfterMethod(
() => timed(context, method, objectId, shelvingTime),
() => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: false, shelvingTime: shelvingTime));