From 5683d89bda3374ef354f23590a3c9235c13fd574 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 13 Nov 2026 00:43:38 -0500 Subject: [PATCH] docs(evidence): #700 HistoryRead through the deployed DEV vd03 endpoint (fea7de0a, HG ea9f21c) Temporary Calculation-driver nodes bound to existing AVEVA tags (Float ZIPSEN.WTC1, Byte SysTimeSec/SysPulse, Double missing tag) on vd03, read over OPC UA from the Mac, then removed; the config is back at its original revision fa9a2e28. Zero historian writes (gateway metrics: Probe/GetTagMetadata/ReadRaw only). Int32/Boolean bindings were not deployed because they would trigger an EnsureTags upsert of the existing AVEVA tag (#899); Float/Double/Byte never provision (#898). Includes the read-only probe used. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi --- .../gate700-vd03-fea7de0a.json.gz | Bin 0 -> 3410 bytes .../probe/Gate700Probe.csproj | 14 ++ .../probe/Program.cs | 202 ++++++++++++++++++ .../vd03-endpoint-gate.md | 130 +++++++++++ 4 files changed, 346 insertions(+) create mode 100644 docs/plans/evidence/2026-11-13-historian-700-vd03/gate700-vd03-fea7de0a.json.gz create mode 100644 docs/plans/evidence/2026-11-13-historian-700-vd03/probe/Gate700Probe.csproj create mode 100644 docs/plans/evidence/2026-11-13-historian-700-vd03/probe/Program.cs create mode 100644 docs/plans/evidence/2026-11-13-historian-700-vd03/vd03-endpoint-gate.md diff --git a/docs/plans/evidence/2026-11-13-historian-700-vd03/gate700-vd03-fea7de0a.json.gz b/docs/plans/evidence/2026-11-13-historian-700-vd03/gate700-vd03-fea7de0a.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..7ee8f40c07fd23e7d5b80e57608ed2f9e8b527c6 GIT binary patch literal 3410 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 literal 0 HcmV?d00001 diff --git a/docs/plans/evidence/2026-11-13-historian-700-vd03/probe/Gate700Probe.csproj b/docs/plans/evidence/2026-11-13-historian-700-vd03/probe/Gate700Probe.csproj new file mode 100644 index 000000000..896bd24d3 --- /dev/null +++ b/docs/plans/evidence/2026-11-13-historian-700-vd03/probe/Gate700Probe.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + false + false + + + + + diff --git a/docs/plans/evidence/2026-11-13-historian-700-vd03/probe/Program.cs b/docs/plans/evidence/2026-11-13-historian-700-vd03/probe/Program.cs new file mode 100644 index 000000000..b8122fc2e --- /dev/null +++ b/docs/plans/evidence/2026-11-13-historian-700-vd03/probe/Program.cs @@ -0,0 +1,202 @@ +// Read-only #700 probe against a deployed OtOpcUa endpoint (lmxopcua#700). +// Sends only Browse/Read/HistoryRead. Credential: env OTOPCUA_PROBE_APIKEY (otua__), never printed. +// Usage: dotnet run -- +using System.Globalization; +using System.Text.Json; +using Opc.Ua; +using Opc.Ua.Client; + +var endpointUrl = args.Length > 0 ? args[0] : "opc.tcp://wonder-app-vd03.zmr.zimmer.com:4840/OtOpcUa"; +var outPath = args.Length > 1 ? args[1] : "gate700-vd03.json"; +// "focas" mode (args[2]): anonymous session; reads ServiceLevel and every Variable under the FOCAS driver's raw subtree. +var focasMode = args.Length > 2 && args[2] == "focas"; +var key = focasMode ? null : Environment.GetEnvironmentVariable("OTOPCUA_PROBE_APIKEY") ?? throw new InvalidOperationException("OTOPCUA_PROBE_APIKEY not set"); +var ct = CancellationToken.None; +var telemetry = DefaultTelemetry.Create(_ => { }); +var pki = Path.Combine(Path.GetTempPath(), $"gate700-probe-pki-{Guid.NewGuid():N}"); +var appConfig = new ApplicationConfiguration(telemetry) +{ + ApplicationName = "OtOpcUa.Gate700Probe", + ApplicationUri = $"urn:OtOpcUa.Gate700Probe.{Guid.NewGuid():N}", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "own") }, + TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "issuer") }, + TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "trusted") }, + RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "rejected") }, + AutoAcceptUntrustedCertificates = true, + }, + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 120_000 }, +}; +await appConfig.ValidateAsync(ApplicationType.Client, ct); +var endpoint = await CoreClientUtils.SelectEndpointAsync(appConfig, endpointUrl, false, telemetry, ct); +using var session = await new DefaultSessionFactory(telemetry).CreateAsync(appConfig, + new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)), false, false, "Gate700Probe", 120_000, + key is null ? new UserIdentity() : new UserIdentity("apikey", System.Text.Encoding.UTF8.GetBytes(key)), default, ct); + +if (focasMode) +{ + var ns = (ushort)session.NamespaceUris.GetIndex("https://zb.com/otopcua/raw"); + var slv = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new[] { new ReadValueId { NodeId = VariableIds.Server_ServiceLevel, AttributeId = Attributes.Value } }, ct); + Console.WriteLine($"ServiceLevel={Fmt(Clr(slv.Results.ToList()[0].WrappedValue))}"); + var vars = new List(); var queue = new Queue(new[] { new NodeId("focas-z-34184", ns) }); + while (queue.Count > 0) + { + var b = await session.BrowseAsync(null, null, 0, new[] { new BrowseDescription { NodeId = queue.Dequeue(), BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, ResultMask = (uint)BrowseResultMask.All } }, ct); + foreach (var r in b.Results.ToList()[0].References.ToList()) + { + var id = ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris); + if (r.NodeClass == NodeClass.Variable) vars.Add(id); else if (r.NodeClass == NodeClass.Object) queue.Enqueue(id); + } + } + var rv = await session.ReadAsync(null, 0, TimestampsToReturn.Both, vars.Select(v => new ReadValueId { NodeId = v, AttributeId = Attributes.Value }).ToArray(), ct); + var res = rv.Results.ToList(); + var byStatus = res.GroupBy(r => SName(r.StatusCode)).Select(g => $"{g.Key}={g.Count()}"); + Console.WriteLine($"FOCAS raw variables={vars.Count}: {string.Join(", ", byStatus)}"); + for (var i = 0; i < vars.Count; i++) if (res[i].StatusCode.Code != 0) Console.WriteLine($" {vars[i].Identifier} {SName(res[i].StatusCode)}"); + await session.CloseAsync(ct); + return 0; +} + +var report = new Dictionary(); +var cases = new List>(); +report["endpoint"] = endpointUrl; +report["securityMode"] = endpoint.SecurityMode.ToString(); +report["runUtc"] = DateTime.UtcNow.ToString("o"); + +var rawNs = session.NamespaceUris.GetIndex("https://zb.com/otopcua/raw"); +NodeId N(string tag) => new($"gate700-hist/Device1/{tag}", (ushort)rawNs); +var wtc1 = N("wtc1-float"); var sec = N("systimesec-byte"); var pulse = N("syspulse-byte"); var missing = N("missing-double"); + +// ServiceLevel + server build +var sl = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new[] { + new ReadValueId { NodeId = VariableIds.Server_ServiceLevel, AttributeId = Attributes.Value }, + new ReadValueId { NodeId = VariableIds.Server_ServerStatus_BuildInfo_SoftwareVersion, AttributeId = Attributes.Value }, + new ReadValueId { NodeId = VariableIds.Server_ServerStatus_BuildInfo_BuildNumber, AttributeId = Attributes.Value } }, ct); +var slr = sl.Results.ToList(); +report["serviceLevel"] = Fmt(Clr(slr[0].WrappedValue)); +report["softwareVersion"] = Fmt(Clr(slr[1].WrappedValue)); +report["buildNumber"] = Fmt(Clr(slr[2].WrappedValue)); + +// Node attributes +var attrs = new Dictionary(); +foreach (var (name, node) in new[] { ("wtc1-float", wtc1), ("systimesec-byte", sec), ("syspulse-byte", pulse), ("missing-double", missing) }) +{ + var ids = new (string Name, uint Id)[] { ("Historizing", Attributes.Historizing), ("AccessLevel", Attributes.AccessLevel), + ("UserAccessLevel", Attributes.UserAccessLevel), ("DataType", Attributes.DataType) }; + var r = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, ids.Select(a => new ReadValueId { NodeId = node, AttributeId = a.Id }).ToArray(), ct); + var rr = r.Results.ToList(); + attrs[name] = ids.Select((a, i) => (a.Name, V: rr[i].StatusCode.Code == 0 ? Fmt(Clr(rr[i].WrappedValue)) : SName(rr[i].StatusCode))) + .ToDictionary(x => x.Name, x => (object?)x.V); +} +report["nodeAttributes"] = attrs; + +// Aggregates advertised +var aggBrowse = await session.BrowseAsync(null, null, 0, new[] { new BrowseDescription { + NodeId = ObjectIds.HistoryServerCapabilities_AggregateFunctions, BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.Organizes, IncludeSubtypes = true, ResultMask = (uint)BrowseResultMask.All } }, ct); +report["aggregateFunctions"] = string.Join(",", aggBrowse.Results.ToList()[0].References.ToList().Select(r => r.BrowseName.Name).OrderBy(n => n, StringComparer.Ordinal)); + +DateTime U(string s) => DateTime.Parse(s, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal); + +// ---- cases +await Raw("missing raw", missing, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:01:00Z"), 0); +await Processed("missing Average 20s", missing, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:01:00Z"), 20_000, ObjectIds.AggregateFunction_Average); +await Processed("missing TimeAverage 20s", missing, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:01:00Z"), 20_000, ObjectIds.AggregateFunction_TimeAverage); +await AtTime("missing AtTime", missing, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:00:30Z")); + +await Raw("SysTimeSec raw 10s", sec, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:00:10Z"), 0); +await AtTime("SysTimeSec AtTime stored+mid", sec, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:00:01.5Z")); +foreach (var (n, a) in new[] { ("Average", ObjectIds.AggregateFunction_Average), ("Minimum", ObjectIds.AggregateFunction_Minimum), + ("Maximum", ObjectIds.AggregateFunction_Maximum), ("Count", ObjectIds.AggregateFunction_Count), ("Start", ObjectIds.AggregateFunction_Start), + ("End", ObjectIds.AggregateFunction_End), ("TimeAverage", ObjectIds.AggregateFunction_TimeAverage), ("Total", ObjectIds.AggregateFunction_Total), + ("TimeAverage2", ObjectIds.AggregateFunction_TimeAverage2) }) + await Processed($"SysTimeSec {n} 20s", sec, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:01:00Z"), 20_000, a); +await Processed("SysTimeSec Average 20s reverse", sec, U("2026-11-11T14:01:00Z"), U("2026-11-11T14:00:00Z"), 20_000, ObjectIds.AggregateFunction_Average); +await Modified("SysTimeSec ReadModified", sec, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:01:00Z")); + +await Raw("SysPulse raw 3min", pulse, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:03:00Z"), 0); +await AtTime("SysPulse AtTime", pulse, U("2026-11-11T14:00:30Z"), U("2026-11-11T14:01:30Z")); +await Processed("SysPulse Average 60s", pulse, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:03:00Z"), 60_000, ObjectIds.AggregateFunction_Average); + +await Raw("WTC1 raw 10s", wtc1, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:00:10Z"), 0); +await AtTime("WTC1 AtTime", wtc1, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:00:01Z")); +await Processed("WTC1 Average 60s over the NULL outage", wtc1, U("2026-11-10T11:31:00Z"), U("2026-11-10T11:40:00Z"), 60_000, ObjectIds.AggregateFunction_Average); +await Processed("WTC1 TimeAverage 60s", wtc1, U("2026-11-10T11:31:00Z"), U("2026-11-10T11:40:00Z"), 60_000, ObjectIds.AggregateFunction_TimeAverage); +await Raw("WTC1 end-only latest 3 before 11:38 (stopped tag reverse), first page", wtc1, DateTime.MinValue, U("2026-11-10T11:38:00Z"), 3, maxPages: 1); +await Raw("WTC1 end-only latest 3 before 11:38, 100 pages", wtc1, DateTime.MinValue, U("2026-11-10T11:38:00Z"), 3); +await Raw("WTC1 reverse raw 11:32:40 -> 11:32:20", wtc1, U("2026-11-10T11:32:40Z"), U("2026-11-10T11:32:20Z"), 0); + +var now = DateTime.UtcNow; +await AtTime("WTC1 future AtTime +30m/+1h/+2h", wtc1, now.AddMinutes(30), now.AddHours(1), now.AddHours(2)); +await AtTime("SysTimeSec future AtTime +30m/+1h/+2h", sec, now.AddMinutes(30), now.AddHours(1), now.AddHours(2)); + +report["cases"] = cases; +await File.WriteAllTextAsync(outPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }), ct); +await session.CloseAsync(ct); +foreach (var c in cases) Console.WriteLine($"{c["case"],-58} {c["status"],-34} rows={c["rows"]} types={c["types"]} {c["ms"]}ms"); +try { Directory.Delete(pki, true); } catch { } +return 0; + +// ---------------------------------------------------------------- helpers +async Task Raw(string name, NodeId node, DateTime start, DateTime end, uint max, int maxPages = 100) +{ + var d = new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, IsReadModified = false, ReturnBounds = false }; + await Run(name, node, d, null, maxPages); +} +async Task Modified(string name, NodeId node, DateTime start, DateTime end) +{ + var d = new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = 0, IsReadModified = true }; + await Run(name, node, d); +} +async Task Processed(string name, NodeId node, DateTime start, DateTime end, double interval, NodeId agg) +{ + var d = new ReadProcessedDetails { StartTime = start, EndTime = end, ProcessingInterval = interval, + AggregateType = new List { agg }, AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true } }; + await Run(name, node, d); +} +async Task AtTime(string name, NodeId node, params DateTime[] times) +{ + var d = new ReadAtTimeDetails { ReqTimes = times.Select(t => (DateTimeUtc)t).ToList(), UseSimpleBounds = true }; + await Run(name, node, d, times); +} +async Task Run(string name, NodeId node, IEncodeable details, DateTime[]? requested = null, int maxPages = 100) +{ + var sw = System.Diagnostics.Stopwatch.StartNew(); + var rows = new List>(); + var types = new SortedSet(StringComparer.Ordinal); + ByteString cp = default; string status; var pages = 0; + do + { + var resp = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false, + new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct); + var res = resp.Results.ToList()[0]; + status = SName(res.StatusCode); cp = res.ContinuationPoint; pages++; + if (!res.HistoryData.IsNull && ExtensionObject.ToEncodeable(res.HistoryData) is HistoryData hd) + foreach (var dv in hd.DataValues.ToList()) + { + var v = Clr(dv.WrappedValue); + if (v is not null) types.Add(v.GetType().Name); + rows.Add(new() { ["v"] = Fmt(v), ["t"] = v?.GetType().Name, ["s"] = $"0x{dv.StatusCode.Code:X8}", + ["src"] = ((DateTime)dv.SourceTimestamp).ToString("o") }); + } + if (status.StartsWith("Bad", StringComparison.Ordinal)) break; + } while (!cp.IsEmpty && pages < maxPages); + sw.Stop(); + var leftOpen = !cp.IsEmpty; + if (leftOpen) // release the server-side continuation point + await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, true, + new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct); + var c = new Dictionary { ["case"] = name, ["node"] = node.ToString(), ["status"] = status, ["pages"] = pages, + ["rows"] = rows.Count, ["types"] = string.Join("|", types), ["ms"] = sw.ElapsedMilliseconds, ["continuationLeft"] = leftOpen, ["data"] = rows }; + if (requested is not null) c["requested"] = requested.Select(t => t.ToString("o")).ToList(); + cases.Add(c); +} +static object? Clr(Variant value) => value.AsBoxedObject(Variant.BoxingBehavior.Legacy) switch +{ + ByteString b => b.IsNull ? null : b.ToArray(), DateTimeUtc t => (DateTime)t, Uuid id => (Guid)id, var o => o, +}; +static string? Fmt(object? v) => v switch { null => null, DateTime d => d.ToString("o"), IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), _ => v.ToString() }; +static string SName(StatusCode code) => code.SymbolicId is { Length: > 0 } s ? s : $"0x{code.Code:X8}"; diff --git a/docs/plans/evidence/2026-11-13-historian-700-vd03/vd03-endpoint-gate.md b/docs/plans/evidence/2026-11-13-historian-700-vd03/vd03-endpoint-gate.md new file mode 100644 index 000000000..ac5078a38 --- /dev/null +++ b/docs/plans/evidence/2026-11-13-historian-700-vd03/vd03-endpoint-gate.md @@ -0,0 +1,130 @@ +# #700: HistoryRead through the deployed DEV endpoint (wonder-app-vd03, 2026-11-13) + +**Scope.** The one gap [#700](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/700) still had after the in-process gate +([`../2026-11-12-historian-694-700-746/aveva-gate.md`](../2026-11-12-historian-694-700-746/aveva-gate.md)): the same reads +through a **deployed** Host's OPC UA endpoint. Owner-approved config deploy on DEV vd03 only. Everything ran over HTTP +(AdminUI), SMB (logs, binaries) and OPC UA from the Mac; no WinRM. + +**Result.** Every check that the deployed build and the no-write rule allow passed, and the wire matched the in-process +gate. Two acceptance items could not be run here, and why is the main finding: +binding a history node to an existing AVEVA tag as **Int32 or Boolean** would make OtOpcUa call the gateway's +**`EnsureTags`**, an upsert that rewrites the AVEVA tag (see [Binding](#binding-and-the-no-write-rule)). The integer case +ran on a **Byte** node instead. Boolean was not run here; it stays covered by the in-process gate only. + +## Versions + +| | | +|---|---| +| OtOpcUa on vd03 | **`fea7de0a`** (`OtOpcUa.Host.dll` informational version `1.0.0+fea7de0aefd9…`, read over SMB). SDK server 2.0.1, HistorianGateway Client 1.0.0. This predates `53978ca6` (native TimeAverage/Total on by default), so on vd03 the pair is still opt-in and off. | +| HistorianGateway on vd03 | **`ea9f21c`** (`ZB.MOM.WW.HistorianGateway.Server.dll` `1.0.0+ea9f21c850fb…`), `ServerHistorian` → `http://localhost:5221`. | +| Historian | AVEVA 2023 R2 on wonder-sql-vd03 (via the gateway). | +| Client | [`probe/`](probe/) (SDK 2.0.1 client, `None` security, UserName `apikey` with a temporary key). Sends only Browse, Read and HistoryRead. | + +## Binding and the no-write rule + +A node is history-readable only when it is an authored **raw tag** with `isHistorized: true`; `historianTagname` overrides +the AVEVA tag name (`AddressSpaceComposer` → `AddressSpaceApplier.MaterialiseRawSubtree`). Discovered nodes and virtual +tags do not register history. + +Every **added** historized raw tag also goes to `AddressSpaceApplier.ProvisionHistorizedTags` → `GatewayTagProvisioner` → +gateway **`EnsureTags`**, which is `Tags.AddAsync` on the historian: create **or update**. Nothing turns this off except +`ServerHistorian:Enabled=false`, which also turns off the reads. vd03's gateway key (`OtOpcUa-wonder`, HG `/apikeys`) +carries `historian:tags:write`. So an `Int32` node on `SysTimeSec` would re-ensure that tag as Int4 with OtOpcUa's +description and storage rate, and a `Boolean` node on `SysPulse` would re-ensure a discrete tag as an Int2 analog. + +The applier skips a tag whose `DataType` string does not parse as `DriverDataType` (Debug log, no request). The AdminUI's +and node manager's type names are OPC UA names, so **`Float`, `Double`, `Byte` and `SByte` never provision** (filed as a +bug: [#898](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/898)). This gate used only those types. That makes the +gate zero-write by construction, and the gateway's own counters confirm it (below). + +Other write paths: `ContinuousHistorization:Enabled=false` and `AlarmHistorian:Enabled=false` in vd03's +`appsettings.json`, with no environment override (SMB read). So no recorder and no alarm sink were running. + +## Temporary configuration + +Authored in the AdminUI (`/scripts`, `/raw`, with the four tags imported from a CSV). Nothing under the FOCAS driver was touched. + +| Object | Value | +|---|---| +| Script | `SC-cdff9670531a` `gate700-const`, `return 0;` | +| Driver | `gate700-hist`, type **Calculation** (no I/O, no endpoint), auto-created device `Device1` | +| Tags (all Read, `scriptId` above, `changeTriggered:false`, `timerIntervalMs:3600000`, `isHistorized:true`) | `wtc1-float` **Float** → `ZIPSEN.WTC1`; `systimesec-byte` **Byte** → `SysTimeSec`; `syspulse-byte` **Byte** → `SysPulse`; `missing-double` **Double** → `OtOpcUa.Gate700.DoesNotExist` | + +## Deployments + +| | Deployment | Revision | | +|---|---|---|---| +| Before | `9adc5590` | `fa9a2e28c1fd…` | In sync, Applied (2026-11-12 11:06Z) | +| Temporary | `a63e262f` | `c4f8d8dfbb61…` | 05:34:04Z, Applied. Log: `applied plan (kind="PureAdd", added=7 …)`, `raw subtree materialised (… tags=4 … failed=0)`. **No** `historian provisioning completed` line, so nothing was dispatched. | +| Cleanup 1 | `246c6c45` | `e9f687b7c403…` | Tags, device, driver and script removed: `PureRemove, removed=6`. The revision still differed because **the artifact carries the `ApiKeys` table** (hashed secrets only) and the temporary key still existed. | +| After | **`dc0af731`** | **`fa9a2e28c1fd…`** | After the key was deleted: the **same revision hash as before**, so the configuration is byte-identical to the original. In sync, Applied 05:40:55Z. | + +## Results + +Probe JSON: [`gate700-vd03-fea7de0a.json.gz`](gate700-vd03-fea7de0a.json.gz) (sha256 of the JSON `172cb3b9c1dd…`), run 2026-11-13 ~05:36Z. +ServiceLevel 250 throughout. All four nodes: `Historizing=true`, `AccessLevel=5` (CurrentRead | HistoryRead). +DataType `i=10` (Float), `i=3` (Byte), `i=3`, `i=11` (Double). Advertised aggregates: Average, Count, Delta, End, Maximum, +MaximumActualTime, Minimum, MinimumActualTime, Range, Start (10, so no TimeAverage/Total on this build). + +| Case | Result | +|---|---| +| Missing tag Raw | `GoodNoData`, 0 rows | +| Missing tag Average (3 × 20 s) | 3 buckets `BadNoData \| Calculated` (`0x809B0401`), null | +| Missing tag AtTime (2 times) | 2 × `BadNoData` (`0x809B0000`) | +| `SysTimeSec` Raw 14:00:00–:10 (2026-11-11) | 10 rows 0..9, typed **Byte** (node DataType) | +| `SysTimeSec` AtTime :00 / :01.5 | 0 Good; 2 `Interpolated` (`0x00000402`), Byte | +| `SysTimeSec` Average / Min / Max / Count / Start / End, 20 s | 9.5/29.5/49.5; 0/20/40; 19/39/59; 20/20/20 (Int32); 0/20/40; 19/39/59 (End stamped at the sample). Processed values Double, as in-process. | +| `SysTimeSec` Average reverse (14:01 → 14:00) | 3 buckets newest first, stamped with the later edge | +| `SysPulse` (discrete) on a Byte node | Raw 0/1/0 each minute (Byte); AtTime :30 → 1 Interpolated (a linear midpoint on an integer node, see note 2); Average 0/1/0 | +| `ZIPSEN.WTC1` Raw / AtTime | 5 rows typed **Single**; AtTime Interpolated, Single | +| `ZIPSEN.WTC1` Average 60 s, 11:31–11:40 (2026-11-10, platform stop) | 11:31 Good 68.09; 11:32 Uncertain (`0x40A40401`); 11:33–11:37 `BadNoData \| Calculated`; 11:38 Good; 11:39 `BadNoData`. Same shape as in-process. | +| Stopped-tag reverse, end-only latest 3 before 11:38 | First page: the NULL at 11:32:36.073 (`0x80000000`), 11:32:33.620, 11:32:31.619, newest first, **96 ms**. Following the continuation points for 100 pages (300 rows) took 5.1 s. Bounded reverse 11:32:40 → 11:32:20: 8 rows newest first. | +| Future AtTime (+30 min / +1 h / +2 h) | 3 rows in request order, Uncertain (`0x40A40402`) holds of the last value, Single and Byte | +| TimeAverage / Total / TimeAverage2 | `BadAggregateNotSupported` on every node: the expected refusal on `fea7de0a` with `ServerHistorian:Gateway:EnableNativeTimeWeighted` off. The native pair is served from `53978ca6` on (#694) and is not deployed on vd03. | +| ReadModified | `BadHistoryOperationUnsupported` | + +**Not run on the deployed endpoint** (each is covered by the in-process gate only): +- **Boolean node DataType**: needs an `EnsureTags` on an existing AVEVA tag ([#899](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/899)). +- **Int32 node DataType**: same reason. Integer typing was shown on Byte instead. +- **Transport outage → `BadNoCommunication`**: needs vd03's gateway stopped. +- **Strict ceiling and aggregate cooldown**: vd03's gateway is not strict, and changing its read limits needs a restart. + +## Historian-write check + +HistorianGateway `/metrics` (`historian_gateway_grpc_requests_total`, counters since the gateway's 2026-11-12 12:04 start): + +| Snapshot | Methods seen | +|---|---| +| Before (05:27Z) | `HistorianStatus/Probe` only | +| After the temporary deploy (05:34Z) | `Probe` only, so the deploy sent no `EnsureTags` | +| After both probe runs and cleanup (05:41Z) | `Probe` 364, `HistorianTags/GetTagMetadata` 5, `HistorianRead/ReadRaw` 271. No `EnsureTags`, `AddTagExtendedProperties`, `WriteLiveValues`, `AddHistoricalValues` or `SendEvent`. | + +AtTime and the processed reads reach the gateway as `ReadRaw` (`GatewayAtTimeFromRaw`, raw-evaluated aggregates), and the +missing tag as `GetTagMetadata`. + +## Cleanup + +- Temporary tags, device, driver and script deleted; redeployed to revision `fa9a2e28c1fd…` (the original). +- Temporary OPC UA key `gate700-temp-probe` (`cef71ff646a1cf7c`) deleted. Negative control: the same token then gets + `BadIdentityTokenRejected`. The token was never printed or written to disk. +- After cleanup: `/healthz`, `/health/ready`, `/health/active` 200; `/fleet` Applied; ServiceLevel 250. FOCAS raw subtree + (anonymous read): 22 variables, 21 Good, `Program/BlockCount` `BadNoData` (an unreported program field, `26b512ad`). +- **No history probe was kept on DEV.** It caused no writes, but it would start writing as soon as either of two things + happened: someone changed a type to Int32 or Boolean, or #898 was fixed so that `Float` provisions. Either way it would + re-ensure `ZIPSEN.WTC1` / `SysTimeSec` / `SysPulse`. + +## Notes + +1. The SDK logs `The domain 'opc.tcp://wonder-app-vd03.zmr.zimmer.com:4840/OtOpcUa' is not listed in the server + certificate` for every client that uses the FQDN. This is pre-existing; the client still connects. +2. An integer node interpolates AtTime linearly, and the value is converted to the node type. On a Byte node over a + discrete tag this produces a 1 at the midpoint of a 0 → 1 step. The in-process gate's Boolean node did not do this: + it held `false`. Binding a discrete tag to an integer node is this gate's workaround, not a supported pattern. + +## Reproduce + +```bash +cd docs/plans/evidence/2026-11-13-historian-700-vd03/probe +OTOPCUA_PROBE_APIKEY= dotnet run -c Release -- opc.tcp://:4840/OtOpcUa out.json +dotnet run -c Release -- opc.tcp://:4840/OtOpcUa /dev/null focas # anonymous ServiceLevel + FOCAS raw read +```