diff --git a/docs/plans/2026-06-14-galaxy-phase-c-historian-design.md b/docs/plans/2026-06-14-galaxy-phase-c-historian-design.md new file mode 100644 index 00000000..85360adb --- /dev/null +++ b/docs/plans/2026-06-14-galaxy-phase-c-historian-design.md @@ -0,0 +1,229 @@ +# Galaxy Phase C — Server-side OPC UA HistoryRead — Design + +**Date:** 2026-06-14 +**Status:** Approved (brainstorming) — ready for implementation planning +**Branch:** `feat/galaxy-phase-c-historian` off master `c9a0f627` +**Parent design:** `docs/plans/2026-06-12-galaxy-standard-driver-design.md` (workstream 3 / Phase C). +**Follow-up:** `pending.md` open item #2 ("Phase C — server-side `HistoryRead` backend over the Wonderware reader"). + +## Goal + +Make the OtOpcUa server answer OPC UA **HistoryRead** for an authored equipment tag flagged +historized — driver-agnostically — by dispatching to the already-built, already-hardened +server-side `IHistorianDataSource` (the Wonderware TCP client). No mxaccessgw history RPC; no EF +migration. A historized tag's raw / processed / at-time samples and an equipment folder's +event-history come back to any OPC UA client (e.g. `Client.CLI historyread`). + +## What already exists (grounded in code, 2026-06-14) + +Phase C is **mostly wiring** — the read surface is built: + +- **`IHistorianDataSource`** (`src/Core/.../Core.Abstractions/Historian/IHistorianDataSource.cs`) — + the server-side read seam, with **required** `ReadRawAsync` / `ReadProcessedAsync` / + `ReadAtTimeAsync` / `ReadEventsAsync` / `GetHealthSnapshot`. Returns + `HistoryReadResult(IReadOnlyList Samples, byte[]? ContinuationPoint)` and + `HistoricalEventsResult(IReadOnlyList Events, byte[]? ContinuationPoint)` + (both in `IHistoryProvider.cs`). `DataValueSnapshot(object? Value, uint StatusCode, + DateTime? SourceTimestampUtc, DateTime ServerTimestampUtc)` mirrors `Opc.Ua.DataValue`. +- **`WonderwareHistorianClient`** (`Driver.Historian.Wonderware.Client`, .NET 10) **implements + `IHistorianDataSource`** over a hardened TCP/TLS/shared-secret channel. The **Host is the only + project that references it** (same as the alarm-historian writer). +- **`DriverAttributeInfo.IsHistorized`** already exists; Galaxy + OpcUaClient drivers populate it + (informational — the equipment-tag path does not consume `DriverAttributeInfo`, it carries + `TagConfig`; see below). +- **The Phase B `alarm`-object-in-`TagConfig` carrier** — `EquipmentTagPlan.Alarm` parsed by + `Phase7Composer.ExtractTagAlarm` and the byte-parity `DeploymentArtifact.ExtractTagAlarm`. This is + the **exact precedent** for carrying `isHistorized` / `historianTagname`. +- **The wiring pattern** — the node manager exposes settable props (`AlarmCommandRouter`, + `NodeWriteGateway`); `OtOpcUaSdkServer.Set*` methods set them; the Host's + `OtOpcUaServerHostedService.StartAsync` wires them post-start. +- **The DI pattern** — `AddAlarmHistorian(config, writerFactory)` (Null default via + `AddOtOpcUaRuntime`'s `TryAddSingleton`, config-gated real impl), Host supplies the Wonderware + client factory in `Program.cs`. +- **`EnsureFolderIsEventNotifier`** already promotes alarm-owning equipment folders to + `EventNotifiers.SubscribeToEvents` + `AddRootNotifier` (guarded by `_notifierFolders`). + +## What's missing (the Phase C work) + +1. **No `HistoryRead` override.** `OtOpcUaNodeManager : CustomNodeManager2` overrides only + `CreateAddressSpace`. `Historizing` is hardcoded `false` in `EnsureVariable` (`:846`) and + `CreateVariable` (`:977`); no `AccessLevels.HistoryRead` bit, no event-notifier `HistoryRead` bit. +2. **No carrier for `isHistorized` / `historianTagname`** through `EquipmentTagPlan` → artifact → + materialization. +3. **No `IHistorianDataSource` registration / Null default / node-manager wiring / config section.** + +## Locked decisions (this Phase C brainstorming) + +| Decision | Choice | +|---|---| +| Read variants | **All four** — Raw, Processed, AtTime over historized **variable** nodes; **Events** over historized **equipment-folder** event-notifier nodes. (User-selected.) | +| Authoring | **Raw `TagConfig` JSON** — `"isHistorized": true` (+ optional `"historianTagname"`) rides in the existing `TagConfig` blob, mirroring the Phase B `alarm` object. **No UI control, no EF migration.** Galaxy already uses the raw-JSON tag editor. (User-selected.) | +| Tagname mapping | Default = the tag's driver **`FullName`** (already on `EquipmentTagPlan`); optional explicit override via `TagConfig.historianTagname`. | +| Async bridge | The override is **synchronous** and blocks on the async data source with its own call-timeout. **No actor** — HistoryRead is read-only and is **not** invoked under the node-manager `Lock` (unlike `OnWriteValue`), so blocking is safe and won't freeze the address space. | +| Redundancy / auth | **No Primary gate** (reads are safe from any node). Auth is the standard `NodePermissions.HistoryRead` enforced by the SDK via the `AccessLevels.HistoryRead` bit we set at materialization. | +| Graceful degrade | A **`NullHistorianDataSource`** (returns empty `Samples` / `Events`) is the default. A historized node with the Null source returns **`GoodNoData`** (empty), not an error spike (parent-design invariant). A **non-historized** node returns `BadHistoryOperationUnsupported`. | +| Continuation points | **Single-shot** read honoring the client's `NumValuesPerNode`; `results[i].ContinuationPoint = null`. `releaseContinuationPoints == true` → `Good` (we never issue one). Server-managed paging is an explicit follow-up. | +| Config | New **`ServerHistorian`** appsettings section (`Host`/`Port`/`SharedSecret`/`UseTls`/`ServerCertThumbprint`/`Enabled`) + `AddServerHistorian(config, factory)`, mirroring `AlarmHistorian`. Independent of the alarm-write path (its own enable + its own client instance to the same sidecar). | + +### Rejected / out of scope + +- **Server-object fleet-wide event history.** The Server object belongs to the SDK's + `CoreNodeManager`, not ours; the `MasterNodeManager` routes HistoryRead by node ownership. We expose + event history only on **our** equipment-folder event-notifiers (per-equipment `sourceName`). Clean + ownership boundary. +- **An historian read actor** (mirroring `HistorianAdapterActor`). The write path needs an actor for + fire-and-forget + redundancy gating; reads are synchronous, ungated, and ownerless — a direct + blocking bridge is simpler and correct. +- **Continuation-point paging / `HistoryUpdate` / live UI authoring control.** Follow-ups. + +## Carrier seam (mirrors Phase B `alarm` exactly) + +``` +Tag.TagConfig JSON { "FullName":"…", "isHistorized":true, "historianTagname":"…"? , "alarm":{…}? } + → Phase7Composer.ExtractTagHistorize(tagConfig) → (bool IsHistorized, string? HistorianTagname) + → EquipmentTagPlan { …, IsHistorized, HistorianTagname } (+2 fields) + → ConfigComposer snapshot → artifact JSON + → DeploymentArtifact.ExtractTagHistorize(tagConfig) ← BYTE-PARITY copy of the composer parse + → Phase7Applier.MaterialiseEquipmentTags + historianTagname := tag.IsHistorized ? (tag.HistorianTagname ?? tag.FullName) : null + → IOpcUaAddressSpaceSink.EnsureVariable(…, string? historianTagname) (+1 nullable param) + → OtOpcUaNodeManager.EnsureVariable + if historianTagname is not null: + Historizing = true + AccessLevel |= AccessLevels.HistoryRead (Read + History; ReadWrite + History) + _historizedTagnames[nodeId] = historianTagname +``` + +**Single nullable param.** The sink seam gains exactly one `string? historianTagname` (null ⇒ not +historized) across `IOpcUaAddressSpaceSink` / `NullOpcUaAddressSpaceSink` / +`DeferredAddressSpaceSink` / `SdkAddressSpaceSink` / `Phase7Applier.SafeEnsureVariable`. The applier +resolves default-vs-override so the node manager just consumes the final tagname. + +## HistoryRead dispatch (the new override) + +`OtOpcUaNodeManager` gains a settable `public IHistorianDataSource HistorianDataSource { get; set; }` +(defaults to `NullHistorianDataSource.Instance`; volatile-backed like `NodeWriteGateway`) and a +`ConcurrentDictionary _historizedTagnames` (NodeId-string → historian tagname). + +Override the `CustomNodeManager2` HistoryRead surface. **The exact override point is version-sensitive +— the implementer confirms it** against the installed `OPCFoundation.NetStandard.Opc.Ua.Server` +(use the DeepWiki MCP `OPCFoundation/UA-.NETStandard`, per CLAUDE.md). Recommended: override the +per-details protected virtuals (`HistoryReadRawModified` / `HistoryReadProcessed` / +`HistoryReadAtTime` / `HistoryReadEvents`); fallback: override the single `HistoryRead` and switch on +`details`. Per `nodesToRead[i]`: + +- **Resolve.** NodeId.Identifier (string) → `_historizedTagnames`. Miss ⇒ + `errors[i] = BadHistoryOperationUnsupported`, `Processed = true`. (Reading `_historizedTagnames` + needs **no `Lock`** — it's a concurrent dict.) +- **Data (variable nodes):** switch on the details type → + `HistorianDataSource.ReadRawAsync(tagname, start, end, NumValuesPerNode, ct)` / + `ReadProcessedAsync(…interval, MapAggregate(aggregateId)…)` / + `ReadAtTimeAsync(tagname, reqTimes, ct)`. Block with the data source's timeout + (`.GetAwaiter().GetResult()` inside `try`). Map each `DataValueSnapshot` → + `Opc.Ua.DataValue(new Variant(Value), new StatusCode(StatusCode), SourceTs, ServerTs)`; wrap the + list in `HistoryData { DataValues = … }`; set `results[i].HistoryData = + new ExtensionObject(historyData)`, `results[i].StatusCode = samples.Count == 0 ? GoodNoData : Good`, + `ContinuationPoint = null`, `Processed = true`. +- **Events (equipment-folder notifier nodes):** `ReadEventDetails` target folder → + `_eventNotifierSources[folderNodeId]` (`sourceName`, default = the folder's string id = equipment + id, registered when `EnsureFolderIsEventNotifier` promotes it) → + `ReadEventsAsync(sourceName, start, end, maxEvents, ct)`. Project each `HistoricalEvent` into a + `HistoryEventFieldList` per the request's `ReadEventDetails.Filter` SelectClauses — support the + standard `BaseEventType` operands (`EventId`, `SourceName`, `Time`, `Message`, `Severity`); an + unsupported operand yields a `null` field (spec-conformant). Wrap in `HistoryEvent { Events = … }`. +- **Errors never escape.** A backend throw / timeout → `errors[i] = BadHistoryOperationUnsupported` + (or `BadUnexpectedError`) + log; one node's failure never faults the batch. + +`MapAggregate(NodeId aggregateId)` maps the OPC UA Part 13 aggregate NodeIds +(`ObjectIds.AggregateFunction_Average/Minimum/Maximum/Total/Count`) → `HistoryAggregateType`; an +unsupported aggregate ⇒ `BadAggregateNotSupported`. + +## Materialization: Historizing + access bits + +- **Variable nodes** — `EnsureVariable` sets `Historizing = historianTagname is not null` and ORs + `AccessLevels.HistoryRead` into both `AccessLevel` and `UserAccessLevel` when historized; registers + `_historizedTagnames`. `CreateVariable` (lazy, never historized) stays `Historizing = false`. +- **Event-notifier folders** — `EnsureFolderIsEventNotifier` ORs `EventNotifiers.HistoryRead` into the + promoted folder's `EventNotifier` **when a non-Null historian is wired**, and registers + `_eventNotifierSources[folder.NodeId-string] = folder.NodeId.Identifier` (the equipment id). On + `RebuildAddressSpace`, clear `_historizedTagnames` + `_eventNotifierSources` alongside the existing + guards. + +## Wiring + DI + +- **`NullHistorianDataSource`** (Core.Abstractions/Historian) — empty `Samples`/`Events`, disabled + health snapshot. The node-manager prop default + the DI default. +- **`ServerHistorianOptions`** (Runtime/Historian, `SectionName = "ServerHistorian"`) + + **`AddServerHistorian(this IServiceCollection, IConfiguration, Func factory)`** — `Enabled=false` ⇒ no-op (Null default from + `AddOtOpcUaRuntime`'s new `TryAddSingleton(NullHistorianDataSource.Instance)`); + `Enabled=true` ⇒ `AddSingleton` the factory result; `Validate()` warnings mirror `AlarmHistorian`. +- **`OtOpcUaSdkServer.SetHistorianDataSource(IHistorianDataSource?)`** — mirrors + `SetNodeWriteGateway` (null-coalesces to the Null default; returns false if NodeManager not up yet). +- **Host** — `Program.cs` adds `AddServerHistorian(config, (opts,sp) => new WonderwareHistorianClient( + new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret){ UseTls=…, + ServerCertThumbprint=… }, sp.GetService>()))`; + `OtOpcUaServerHostedService.StartAsync` resolves `IHistorianDataSource` from DI and calls + `_server.SetHistorianDataSource(...)` (right after `SetNodeWriteGateway`); `StopAsync` clears it. + +## Error handling / edge cases + +- **Byte-parity invariant** — `Phase7Composer.ExtractTagHistorize` and + `DeploymentArtifact.ExtractTagHistorize` must parse identically; a parity round-trip test guards it + (precedent: `DeploymentArtifactAliasParityTests`). +- **Non-historized HistoryRead** → `BadHistoryOperationUnsupported` (node not in + `_historizedTagnames`); the SDK's own `AccessLevel` check already blocks most before we see them. +- **Null / unconfigured historian** → `GoodNoData` empty (not an error). +- **Backend timeout / throw** → per-node `BadHistoryOperationUnsupported` + log; batch survives. +- **Redeploy** → `RebuildAddressSpace` clears the two new maps; re-materialization re-registers. + +## Testing (no bUnit, offline xUnit + Shouldly) + +- **Composer** `ExtractTagHistorize` — true / false / absent / override / malformed-JSON (never throws). +- **Artifact parity** — composer plan == artifact plan for a historized tag (incl. override) round-trip. +- **`EnsureVariable`** — historized ⇒ `Historizing==true` + `HistoryRead` access bit + registry entry; + non-historized ⇒ unchanged; default-vs-override tagname resolution in the applier. +- **HistoryRead override (data)** — fake `IHistorianDataSource`; assert Raw / Processed / AtTime + dispatch, tagname passed (default + override), `DataValueSnapshot→DataValue` mapping (value / status + / timestamps), `GoodNoData` on empty, `BadHistoryOperationUnsupported` on non-historized, + `BadAggregateNotSupported` on an unknown aggregate. +- **HistoryRead override (events)** — fake source; assert folder→sourceName resolution + event-field + projection for the standard select clauses + empty/Null. +- **DI** — `AddServerHistorian` leaves Null when disabled, swaps the real source when enabled; + `NullHistorianDataSource` returns empty. + +**Live docker-dev `/run` (user-driven; the agent does NOT sign in) — DEFERRED.** Requires the +Wonderware sidecar + AVEVA Historian (the WW Historian VM `10.100.0.48`), which is not part of the +local docker-dev rig, so this gate is inherently operator-driven. When run: author a historized Galaxy +equipment tag, set `ServerHistorian:Enabled=true` pointed at the sidecar, deploy, then +`Client.CLI historyread` → samples return. + +## Hard rules (carried into implementation) + +- Stage by path; never `git add .`. Never stage `sql_login.txt`, + `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`, `pending.md`, `current.md`, or + `docker-dev/docker-compose.yml`. Never echo the gateway API key or the historian `SharedSecret` into + a tracked file. No force-push, no `--no-verify`. +- **NO EF/Configuration entity or migration change** — the flag rides in the existing `TagConfig` blob + (the Phase A `NamespaceKind` migration was a one-off already shipped; Phase C adds none). +- Build on `feat/galaxy-phase-c-historian` off master `c9a0f627`. + +## Authoritative touched-code list + +- `src/Server/.../OpcUaServer/Phase7Composer.cs` — `EquipmentTagPlan` +2 fields; `ExtractTagHistorize`. +- `src/Server/.../Runtime/Drivers/DeploymentArtifact.cs` — byte-parity `ExtractTagHistorize` + thread. +- `src/Core/.../Commons/OpcUa/IOpcUaAddressSpaceSink.cs` (+ `NullOpcUaAddressSpaceSink`, + `DeferredAddressSpaceSink`) — `EnsureVariable` gains `string? historianTagname`. +- `src/Server/.../OpcUaServer/SdkAddressSpaceSink.cs` — pass-through. +- `src/Server/.../OpcUaServer/Phase7Applier.cs` — resolve + thread the tagname. +- `src/Server/.../OpcUaServer/OtOpcUaNodeManager.cs` — `HistorianDataSource` prop, `_historizedTagnames`, + `_eventNotifierSources`, `EnsureVariable`/`EnsureFolderIsEventNotifier`/`RebuildAddressSpace` edits, + the HistoryRead override + mapping helpers. +- `src/Server/.../OpcUaServer/OtOpcUaSdkServer.cs` — `SetHistorianDataSource`. +- `src/Core/.../Core.Abstractions/Historian/NullHistorianDataSource.cs` (new). +- `src/Server/.../Runtime/Historian/ServerHistorianOptions.cs` (new) + + `src/Server/.../Runtime/ServiceCollectionExtensions.cs` — `AddServerHistorian` + Null default in + `AddOtOpcUaRuntime`. +- `src/Server/.../Host/Program.cs` + `Host/OpcUa/OtOpcUaServerHostedService.cs` — wire the data source. +- Docs: new `docs/Historian.md` (or a Galaxy-driver note) + a `CLAUDE.md` pointer; `appsettings.json` + `ServerHistorian` block (disabled).