# 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).