19 KiB
Galaxy Phase C — Server-side HistoryRead — Implementation Plan
For Claude: REQUIRED SUB-SKILL: use superpowers-extended-cc:subagent-driven-development (or executing-plans) to implement this plan task-by-task.
Goal: Answer OPC UA HistoryRead (Raw / Processed / AtTime / Events) for historized equipment
tags by dispatching to the existing IHistorianDataSource (Wonderware TCP client). Authoring via a
"isHistorized" flag in the existing TagConfig blob — no UI, no EF migration.
Architecture: Carry isHistorized/historianTagname through the Phase-B alarm-object seam
(TagConfig → EquipmentTagPlan → byte-parity artifact → EnsureVariable), set Historizing +
AccessLevels.HistoryRead, register a NodeId→tagname map, and override CustomNodeManager2's
HistoryRead surface to block-bridge onto the async data source. Config-gated DI mirrors
AddAlarmHistorian; the Host supplies the Wonderware client.
Tech Stack: .NET 10, OPC Foundation Opc.Ua.Server (CustomNodeManager2), Akka-free node
manager, xUnit + Shouldly.
Design: docs/plans/2026-06-14-galaxy-phase-c-historian-design.md (read for the locked decisions).
Execution notes (hard rules — every task)
- Branch:
feat/galaxy-phase-c-historian(already created off masterc9a0f627). Do NOT work on master. - Stage by path; never
git add .. Never stagesql_login.txt,src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/,pending.md,current.md, ordocker-dev/docker-compose.yml. Never echo the gateway API key or historianSharedSecretinto a tracked file. No force-push, no--no-verify. - NO EF/Configuration entity or migration change. The flag rides in
TagConfigJSON. - NO bUnit — there is no UI in Phase C; everything here is offline-unit-testable. The live
/runis deferred (Task 7). - Production projects are
TreatWarningsAsErrors— keep the build at 0 warnings insrc/. - Each task: write the failing test, see it fail, implement minimally, see it pass, then commit by path.
Task 0: Feature branch (already created)
Classification: trivial · Estimated implement time: ~0 min · Parallelizable with: none
Precondition met — feat/galaxy-phase-c-historian exists off master c9a0f627, design committed
5e42c1bc. No action.
Task 1: Carry isHistorized + historianTagname (composer + artifact, byte-parity)
Classification: standard · Estimated implement time: ~5 min · Parallelizable with: none
Mirror the Phase B alarm-object carrier exactly.
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.csEquipmentTagPlanrecord (:82-91): addbool IsHistorizedandstring? HistorianTagnameas the last two positional fields (update the doc-comment to mention they parse identically on the artifact side, likeAlarm).- Add
internal static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig)next toExtractTagAlarm(:461): parseisHistorized(bool; absent/non-bool ⇒ false) andhistorianTagname(string; absent/blank/non-string ⇒ null). Never throws (catchJsonException⇒(false, null)). Carry the byte-parity comment namingDeploymentArtifact.ExtractTagHistorize. - In the
equipmentTags.Select(...)(:331-340): setIsHistorized/HistorianTagnamefromExtractTagHistorize(t.TagConfig).
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs- Add a byte-parity
private static (bool, string?) ExtractTagHistorize(string? tagConfig)next toExtractTagAlarm(:658), same parse, carrying the byte-parity comment naming the composer. - In
BuildEquipmentTagPlans(:440-449): thread the two new fields onto thenew EquipmentTagPlan(...).
- Add a byte-parity
- Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagHistorizeTests.cs(new; model onExtractTagAlarmTests.cs). - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs(new; model onDeploymentArtifactAliasParityTests.cs).
Steps:
- Failing test
ExtractTagHistorizeTests:{"FullName":"T.A","isHistorized":true}⇒(true,null);+"historianTagname":"WW.Tag"⇒(true,"WW.Tag"); absent ⇒(false,null);"isHistorized":false⇒(false,null); malformed JSON / blank-tagname ⇒(false,null)/(true,null); never throws. - Run → fail (method missing). Implement
ExtractTagHistorize+ record fields + Select wiring. Run → pass. - Failing test
DeploymentArtifactHistorizeParityTests: build a snapshot with a historized tag (and one with an override tagname), run it throughConfigComposer→artifact→DeploymentArtifactdecode, and assert the decodedEquipmentTagPlan.IsHistorized/HistorianTagnameequal the composer's for the same input (parity). Run → fail. Implement the artifact side. Run → pass. dotnet buildclean (0 warn);dotnet testthe two projects green. Commit by path (Phase7Composer.cs,DeploymentArtifact.cs, the two new test files).
Task 2: Materialize historized — Historizing + HistoryRead bit + NodeId→tagname registry
Classification: standard · Estimated implement time: ~5 min · Parallelizable with: none
Files:
- Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs—EnsureVariablesignature gains a trailingstring? historianTagnameparam (doc: null ⇒ not historized). Update theNullOpcUaAddressSpaceSinkno-op impl in the same file. - Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs— pass the new param through. - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs— pass-through. - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs—SafeEnsureVariable(:294) gains the param;MaterialiseEquipmentTags(:200) computesstring? historianTagname = tag.IsHistorized ? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname) : null;and passes it. - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs- Add
private readonly ConcurrentDictionary<string,string> _historizedTagnames = new(StringComparer.Ordinal); EnsureVariable(:820): takestring? historianTagname; when non-null setHistorizing = true, ORAccessLevels.HistoryReadinto bothAccessLevel+UserAccessLevel(keep the writable ReadWrite composite), and_historizedTagnames[variableNodeId] = historianTagname. When null, behavior is exactly as today (Historizing=false).RebuildAddressSpace(:889):_historizedTagnames.Clear();alongside the other clears.
- Add
- Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs(extend) — or a newNodeManagerHistorizeTests.csif cleaner. Reuse the node-manager harness inSdkAddressSpaceSinkTests/EquipmentWriteGateTests.
Steps:
- Failing test: materialize a variable with
historianTagname:"WW.Tag"⇒ the node'sHistorizingis true and(AccessLevel & AccessLevels.HistoryRead) != 0; a null tagname ⇒Historizingfalse, no History bit. (Expose a read path: the existing tests already reachBaseDataVariableStatevia the node manager — assert on that, or add a smallTryGetVariabletest accessor if none exists.) - Run → fail. Thread the param through all five seam files + node manager. Run → pass.
- Failing test (applier): a historized
EquipmentTagPlanwith no override ⇒ the sink receiveshistorianTagname == FullName; with an override ⇒ receives the override. (Use a recording fakeIOpcUaAddressSpaceSink.) Run → fail → implement the applier resolve → pass. - Build clean; the
OpcUaServer.Testssuite green (existingEnsureVariablecallers updated for the new param). Commit by path.
Task 3: HistoryRead override — Raw / Processed / AtTime + NullHistorianDataSource + wiring prop
Classification: high-risk · Estimated implement time: ~6 min · Parallelizable with: none
This is the load-bearing OPC UA service-level task.
Files:
- Create:
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs— singletonInstance; all reads return emptyHistoryReadResult/HistoricalEventsResult(empty list, null continuation);GetHealthSnapshot()returns a disabled snapshot;Dispose()no-op. - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs- Add
private volatile IHistorianDataSource _historianDataSource = NullHistorianDataSource.Instance;public IHistorianDataSource HistorianDataSource { get => _historianDataSource; set => _historianDataSource = value ?? NullHistorianDataSource.Instance; }(doc mirrorsNodeWriteGateway).
- Confirm the override surface of the installed
OPCFoundation.NetStandard.Opc.Ua.Serverfirst: query the DeepWiki MCP (OPCFoundation/UA-.NETStandard, per CLAUDE.md) AND inspect the actual base members (e.g.grep/decompile the referencedOpc.Ua.Server.CustomNodeManager2forHistoryRead). RECOMMENDED: override the per-details protected virtuals (HistoryReadRawModified,HistoryReadProcessed,HistoryReadAtTime); FALLBACK: override the single publicHistoryRead(...)and switch ondetails. Whatever the chosen surface, do not invoke base for nodes we own (_historizedTagnameshit); markProcessed/fillresults[i]. - Per node: resolve
NodeId.Identifier(string) in_historizedTagnames; miss ⇒BadHistoryOperationUnsupported. On hit, dispatch to_historianDataSource(block with.GetAwaiter().GetResult()intry/catch→ per-nodeBadHistoryOperationUnsupported/BadUnexpectedError+ log; never fault the batch). MapDataValueSnapshot→Opc.Ua.DataValue; wrap inHistoryData;results[i].HistoryData = new ExtensionObject(data);StatusCode = samples.Count == 0 ? GoodNoData : Good;ContinuationPoint = null. HandlereleaseContinuationPoints == true⇒Goodno-op. MapAggregate(NodeId)helper →HistoryAggregateType; unknown ⇒BadAggregateNotSupported.DataValueSnapshot→DataValuemapping helper (value Variant, StatusCode, source+server ts).
- Add
- Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs(new) — a recording/fakeIHistorianDataSource. - Test:
tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/NullHistorianDataSourceTests.cs(new).
Steps:
- Confirm the override surface (DeepWiki + assembly). Write
NullHistorianDataSource+ its test (empty results). Run → pass. - Failing test (Raw): materialize a historized node; set a fake source returning 2 samples; invoke
the HistoryRead override with a
ReadRawModifiedDetails+ oneHistoryReadValueIdfor the node; assert the fake got(tagname == FullName, start, end, numValues), the result decodes to aHistoryDataof 2DataValues with mapped value/status/timestamps,StatusCode == Good. - Run → fail. Add the prop + override + Raw path + mappers. Run → pass.
- Add tests + impl for: Processed (aggregate mapping,
BadAggregateNotSupportedon unknown), AtTime (request times passed through), default-vs-override tagname, empty ⇒GoodNoData, non-historized node ⇒BadHistoryOperationUnsupported, backend-throw ⇒ per-node bad + batch survives. - Build clean;
OpcUaServer.Tests+Core.Abstractions.Testsgreen. Commit by path.
Task 4: HistoryRead override — Events over equipment-folder notifiers
Classification: high-risk · Estimated implement time: ~6 min · Parallelizable with: Task 5
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs- Add
private readonly ConcurrentDictionary<string,string> _eventNotifierSources = new(StringComparer.Ordinal); EnsureFolderIsEventNotifier(:749): when_historianDataSource is not NullHistorianDataSource, OREventNotifiers.HistoryReadintofolder.EventNotifier(keepSubscribeToEvents); register_eventNotifierSources[folder.NodeId.Identifier?.ToString()] = folder.NodeId.Identifier?.ToString()(the equipment id =sourceName). (Reads the data-source field — fine; it's set before materialization in the Host. If a deployment materializes before wiring, the bit is simply absent until the next rebuild — acceptable; document it.)RebuildAddressSpace:_eventNotifierSources.Clear();.- Events dispatch (the
ReadEventDetailsarm of the chosen override surface): resolve the target folder NodeId in_eventNotifierSources(miss ⇒BadHistoryOperationUnsupported); call_historianDataSource.ReadEventsAsync(sourceName, start, end, maxEvents, ct)(block + guard); project eachHistoricalEvent→HistoryEventFieldListperReadEventDetails.Filter.SelectClauses— supportBaseEventTypeoperandsEventId,SourceName,Time,Message,Severity; an unsupported operand ⇒nullfield; wrap inHistoryEvent { Events = … };StatusCode = Good(orGoodNoDatawhen empty). ProjectEventField(HistoricalEvent, SimpleAttributeOperand)helper.
- Add
- Test: extend
NodeManagerHistoryReadTests.cs— events arm.
Steps:
- Failing test: promote a folder to a notifier with a wired (fake non-Null) source; invoke the
override with a
ReadEventDetails(a select clause for EventId/SourceName/Time/Message/Severity) on the folder; assert the fake got(sourceName == equipmentId, …)and each returnedHistoryEventFieldListcarries the projected fields in select-clause order; an unsupported operand ⇒ null field; empty ⇒GoodNoData; a non-notifier node ⇒BadHistoryOperationUnsupported. - Run → fail. Implement the
_eventNotifierSourcesmap + folder-promotion bit + the events arm + projector. Run → pass. - Build clean;
OpcUaServer.Testsgreen. Commit by path.
Task 5: DI + config + Host wiring (AddServerHistorian, SetHistorianDataSource)
Classification: standard · Estimated implement time: ~5 min · Parallelizable with: Task 4
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs—SectionName = "ServerHistorian";Enabled,Host,Port,UseTls,ServerCertThumbprint,SharedSecret;Validate()warnings mirroringAlarmHistorianOptions(empty SharedSecret, etc.). - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.csAddOtOpcUaRuntime:services.TryAddSingleton<IHistorianDataSource>(NullHistorianDataSource.Instance);- Add
AddServerHistorian(this IServiceCollection, IConfiguration, Func<ServerHistorianOptions, IServiceProvider, IHistorianDataSource> factory)— bind the section;Enabled != true⇒ no-op (Null stays); else logValidate()warnings +AddSingleton<IHistorianDataSource>(sp => factory(opts, sp)). MirrorAddAlarmHistorian.
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs— addpublic bool SetHistorianDataSource(IHistorianDataSource? source)mirroringSetNodeWriteGateway. - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs— afterAddAlarmHistorian(:94), addAddServerHistorian(config, (opts,sp) => new WonderwareHistorianClient(new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret){ UseTls=opts.UseTls, ServerCertThumbprint=opts.ServerCertThumbprint }, sp.GetService<ILogger<WonderwareHistorianClient>>())). - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs— afterSetNodeWriteGateway(:147), resolveIHistorianDataSourcefrom DI and_server.SetHistorianDataSource(source); inStopAsync(:166)_server?.SetHistorianDataSource(null). (The hosted service must be able to resolveIHistorianDataSource— inject it or pull from the provider exactly as the other sinks are obtained.) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json— add a disabledServerHistorianblock (documented defaults). Do not put a realSharedSecretin a tracked file (leave""). - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddServerHistorianTests.cs(new; model on the existingAddAlarmHistorianDI test) — disabled ⇒ resolvesNullHistorianDataSource; enabled ⇒ resolves the factory's source.
Steps:
- Failing DI test:
ServerHistorian:Enabled=false⇒IHistorianDataSourceis the Null instance;Enabled=true⇒ the factory's fake. Run → fail. - Implement
ServerHistorianOptions+AddServerHistorian+ theTryAddSingletondefault + theSetHistorianDataSourcesetter. Run → pass. - Wire the Host (
Program.cs+ hosted service + appsettings). Build the Host project to prove the Wonderware factory + DI compile (no unit test for the Host wiring — it's covered by the live gate). Build clean. - Commit by path (do NOT stage appsettings.json secrets — the block is disabled with empty secret).
Task 6: Docs + bookkeeping
Classification: small · Estimated implement time: ~4 min · Parallelizable with: none
Files:
- Create:
docs/Historian.md— the historized-tagTagConfigschema (isHistorized,historianTagnamedefault=FullName), theServerHistorianconfig section, HistoryRead behavior (the four variants, graceful degrade toGoodNoData, the single-shot/no-continuation limitation, events-on-equipment-folders), and aClient.CLI historyreadexample. - Modify:
CLAUDE.md— a short pointer under a Historian/HistoryRead note (one paragraph + the doc link). - Modify (disk-only, do NOT commit):
pending.md— mark #2 Phase C done with the merge SHA + the deferred live gate. - Update: the plan's
.tasks.jsonstatuses.
Steps: Write the docs; commit docs/Historian.md + CLAUDE.md by path. Update pending.md on
disk only (never staged).
Task 7: Live docker-dev /run verification — DEFERRED (user-driven)
Classification: n/a (live) · Parallelizable with: none · Depends on: Task 5
Deferred — the agent does NOT sign in and the local docker-dev rig has no Wonderware sidecar. The
historian backend is the WW Historian VM (10.100.0.48) + AVEVA, so this gate is operator-driven.
When run: author a historized Galaxy equipment tag (TagConfig "isHistorized":true), set
ServerHistorian:Enabled=true pointed at the sidecar (Host/Port/SharedSecret/TLS), deploy on
MAIN-galaxy-eq (POST http://localhost:9200/api/deployments, X-Api-Key: docker-dev-deploy-key),
then Client.CLI historyread -u opc.tcp://localhost:4840 -n "ns=2;s=<equip>/<tag>" --start … --end …
→ samples return; a non-historized tag ⇒ BadHistoryOperationUnsupported.
Dependency graph
0 → 1 → 2 → 3 → (4 ∥ 5) → 6 → 7(deferred). Tasks 4 and 5 touch disjoint files
(node-manager events arm vs. DI/Host/SdkServer) and may be dispatched concurrently after Task 3.
Done
Build clean (0 warnings in src/) + dotnet test green (new + existing suites) for Tasks 1–6. The
live /run (Task 7) is the deferred user-driven gate.