fix(opcua): DeleteRawModified with an unspecified bound is BadInvalidArgument; HistoryUpdate node-manager tests

- A DateTime.MinValue start or end on DeleteRawModified is refused
  BadInvalidArgument before the backend.
- Tests: anonymous without the HistoryUpdate role denied (with it allowed),
  UpdateTagAttributes enabling historization sets HistoryWrite, a throwing
  updater is BadInternalError, the MinValue bounds case; corrected the
  test helper's comment on how the SDK base seeds unadmitted slots.
- GatewayHistorianValueWriter remarks describe the recorder's real status
  capture (and that it does not persist on the gateway).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 14:27:33 -04:00
parent c6d4134795
commit f8ad74dc19
3 changed files with 129 additions and 8 deletions
@@ -28,12 +28,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
/// <b>Quality is a documented-dead input</b> on the <c>WriteLiveValues</c> SQL path
/// (HistorianGateway 0.2.0 Contracts C-002): <c>HistorianLiveValue.Quality</c> /
/// <c>QualityDetail</c> are server-managed columns — any value sent is accepted on the wire and
/// then silently discarded. We populate <c>Quality</c> anyway (see the mapping below): proto3
/// transmits <c>0</c> when a field is left unset, and <c>0</c> is "Bad" in OPC-DA terms, so
/// <em>not</em> setting it would send a <em>wrong</em> quality to any future gateway version that
/// starts honoring the field — whereas the recorder's <c>192</c> ("Good") is the correct value.
/// <b>Do NOT extend the recorder to capture real node quality expecting it to persist</b> — it
/// will not, until the gateway honors the field.
/// then silently discarded. We populate <c>Quality</c> anyway: proto3 transmits <c>0</c> when a
/// field is left unset, and <c>0</c> is "Bad" in OPC-DA terms, so <em>not</em> setting it would send a
/// <em>wrong</em> quality to any future gateway version that starts honoring the field. The recorder
/// now carries each sample's real OPC UA status, which <see cref="Mapping.GatewayQualityMapper.ToDaQuality"/>
/// maps to the DA byte (Good ⇒ <c>192</c>, as before). <b>That status does not persist on this
/// backend</b>: the gateway discards the field, so AVEVA still stores every sample Good — use the
/// SqlServer backend where the stored status must be the real one.
/// </para>
/// <para>
/// <b>Numeric only.</b> Values whose kind has no double form (String, DateTime, Empty) are skipped,
/// never thrown; a batch with none left is acked without calling the gateway. The recorder already drops
/// those kinds at capture for this backend, so the skip is a backstop.
/// </para>
/// </remarks>
public sealed class GatewayHistorianValueWriter : IHistorianValueWriter, IAsyncDisposable
@@ -5295,6 +5295,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (details.IsDeleteModified) return null;
var start = details.StartTime;
var end = details.EndTime;
// Part 11 requires both bounds of a raw delete; DateTime.MinValue is the wire's "unspecified". Refuse
// it here rather than let a backend read MinValue as "from the beginning of time".
if (start == DateTime.MinValue || end == DateTime.MinValue)
return (_, _, _) => Task.FromResult(new HistoryUpdateResult { StatusCode = StatusCodes.BadInvalidArgument });
return async (updater, tagname, ct) =>
{
var code = await updater.DeleteRawAsync(tagname, start, end, ct).ConfigureAwait(false);