41 KiB
Design + Implementation Plan — R2-06: ServerHistorian fail-fast validation + 0.2.0 DataType provisioning assessment
- Source report:
archreview/06-gateway-integrations.md(2026-07-12 re-review) - Review commit / plan verified against tree at:
f6eaa267(master; the archreview*.mdworking-tree updates from the 2026-07-12 re-review were read as-is) - OVERALL action item: #6 — "ServerHistorian fail-fast options validation (misconfig → clear startup error, not crash-loop); assess 0.2.0 DataType provisioning delta + run the live EnsureTags leg" (
archreview/00-OVERALL.md, prioritized action list) - Findings covered: 06/S-11 (Medium — enabled-but-misconfigured
ServerHistoriancrash-loops the Host with a rawUriFormatException; theAlarmHistorian-enabled/ServerHistorian-disabled corner gets zero warning), 06/U-7 (Medium — the 0.1.0→0.2.0 client bump's proto3-optionalHistorianTagDefinition.DataTypechange silently altered Boolean tag provisioning; live EnsureTags never run on 0.2.0), 06/C-7 (Low — the live-value writer populates a documented-deadQualityfield) - Scope:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/(options),src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs,src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs+Host/Configuration/,src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/(adapter, provisioner, recorder writer), the matching test projects,docs/Historian.md, CLAUDE.md.
Verification summary
Every cited file:line was opened against the working tree at f6eaa267. All three findings CONFIRMED — none stale.
- S-11 —
HistorianGatewayClientAdapter.Createeagerly doesnew Uri(options.Endpoint)(HistorianGatewayClientAdapter.cs:45) inside an otherwise lazy-channel factory ("no network I/O" is documented at :19-23; the eagerUrictor is the one exception).ServerHistorianOptions.Validate()early-returns on!Enabled(ServerHistorianOptions.cs:77) and produces only warnings for an emptyEndpoint/ApiKey(:78-81); every consumer of those warnings just logs them (Runtime/ServiceCollectionExtensions.cs:135-136,Host/Program.cs:123-124) and registration proceeds (Runtime/ServiceCollectionExtensions.cs:139). The sharper corner is confirmed exactly as reported:AddAlarmHistoriangates only onAlarmHistorian:Enabled(Runtime/ServiceCollectionExtensions.cs:86-87) while the Host sources its connection from theServerHistoriansection (Program.cs:125-127passes the outer-boundserverHistorianOptionsintoGatewayHistorian.CreateAlarmWriter) — soAlarmHistorian:Enabled=true+ServerHistorian:Enabled=falseproduces zero warning and thenCreateAlarmWriter→HistorianGatewayClientAdapter.Create→ unhandledUriFormatExceptionwhen the sink singleton is first resolved (which happens at ActorSystem start viaWithOtOpcUaRuntimeActors,Runtime/ServiceCollectionExtensions.cs:214— i.e. a startup crash-loop, not a lazy runtime fault). Continuous historization is co-gated onserverHistorianOptions.Enabled && continuousHistorizationOptions.Enabled(Program.cs:160), so only a malformed non-empty Endpoint reaches its writer (Program.cs:185-189) — also covered by the same fix. - S-11 house-style discovery — the repo already has the fail-fast idiom this plan needs:
AddValidatedOptions<TOptions, TValidator>(sharedZB.MOM.WW.Configuration,ServiceCollectionExtensions.cs:28-41: bind +IValidateOptions+ValidateOnStart) overOptionsValidatorBase<TOptions>/ValidationBuilder, used twice inProgram.cs—LdapOptions(unconditional,:102) andOpcUaApplicationHostOptions(inside thehasDriverblock,:254).ValidateOnStartfailures surface as anOptionsValidationExceptionaggregating all failure messages at host start — the exact "deliberate, named failure" shape the report asks for. No new pattern is needed; this also directly answers C-4's "give the warnings style a fail-fast tier". - U-7 —
GatewayTagProvisioneralways setsDataTypeexplicitly (GatewayTagProvisioner.cs:57) andBoolean → HistorianDataType.Int1(HistorianTypeMapper.cs:28), where Int1 is wire value 0. The 0.2.0 contracts XML (local NuGet cache,zb.mom.ww.historiangateway.contracts/0.2.0/.../ZB.MOM.WW.HistorianGateway.Contracts.xml) confirms the semantics flip verbatim: "DataType uses proto3 explicit presence (optional) … ABSENT ⇒ server mapper defaults to the SDK default Float; PRESENT (including wire 0) ⇒ the requested type, so EnsureTags CAN request an Int1 tag. (Contracts review M-2; field number 4 unchanged, wire-compatible.)" Existing tests pin the enum mapping (HistorianTypeMapperTests.cs:11,GatewayTagProvisionerTests.cs:29) but nothing pins the presence semantics (HasDataType), and the live suite (4 tests,Live/GatewayLiveIntegrationTests.cs) has no Boolean leg — its write round-trip EnsureTags a Float sandbox tag (:104) — and has never run against a 0.2.0 gateway. - U-7 EnsureTags semantics — DETERMINED from the client contract: the 0.2.0 client XML documents
HistorianGatewayClient.EnsureTagsAsyncas "Creates or updates historian tags according to the given definitions (upsert). This RPC is not idempotent and is therefore not routed through the idempotent retry pipeline." Gateway-side it maps per-tag onto AVEVA'sHistorianClient.EnsureTagAsync(definition) -> bool(scopehistorian:tags:write). So the contract is create-or-update, not skip-if-exists — a pre-bump Boolean tag existing as Float will be asked to become Int1 on the next deploy's provisioning dispatch. What the AVEVA Historian does with that in-place analog retype is the one thing the contract does not pin — see the migration note in §2. - C-7 —
GatewayHistorianValueWriterpopulatesHistorianLiveValue.Quality(GatewayHistorianValueWriter.cs:63); the 0.2.0 contracts XML documentsQuality/QualityDetailas dead inputs on theWriteLiveValuesSQL path: "any value sent here is accepted on the wire and then silently discarded — quality is managed server-side. (Also note proto3 sends 0 when these are left unset; there is no implicit 192 default on the wire.) … (Contracts C-002.)" Harmless today — the recorder hardcodesGoodQuality = 192for every captured value (ContinuousHistorizationRecorder.cs:45,256, round-tripped through the outbox at:419).
1. S-11 (Medium) — enabled-but-misconfigured ServerHistorian crash-loops the Host; the AlarmHistorian corner gets zero warning
Restatement: An operator who enables the historian (or just the alarm historian) with an empty/malformed ServerHistorian:Endpoint gets, at best, a correct warning immediately followed by an opaque UriFormatException crash-loop out of a DI factory — and in the AlarmHistorian:Enabled=true / ServerHistorian:Enabled=false corner, no warning at all before the same crash. Proven live: the #12 rig session's docker-dev bring-up (fixed config-side only, 7233e2ba).
Verification: Confirmed (see summary). The failure fires at startup, not first-use: the IAlarmHistorianSink singleton is resolved by WithOtOpcUaRuntimeActors at ActorSystem start (Runtime/ServiceCollectionExtensions.cs:214), and the read-path IHistorianDataSource by the node manager at server start — so under a service manager / docker restart policy this is a crash-loop with a raw stack trace each cycle.
Root cause: Two stacked gaps. (1) ServerHistorianOptions.Validate() is warnings-only by design (C-4's divergent idiom) and never checks that Endpoint parses; registration proceeds regardless, and the first eager new Uri(...) deep inside HistorianGatewayClientAdapter.Create becomes the de-facto validator — throwing the wrong exception type, at the wrong layer, with no config-key context. (2) The validation gate keys on the wrong predicate: it asks "is this section enabled?" when the load-bearing question is "is this section's connection consumed by anything enabled?" — and AlarmHistorian consumes it independently of ServerHistorian:Enabled.
Proposed design. Fail fast at host startup with a named, aggregated config error, using the established house pattern:
- New
ServerHistorianOptionsValidator : OptionsValidatorBase<ServerHistorianOptions>insrc/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/(sibling ofLdapOptionsValidator/OpcUaApplicationHostOptionsValidator). Ctor-injectsIConfiguration(singleton-safe per theAddValidatedOptionsremark) so it can read the consumer gate:- Compute
consumed = options.Enabled || configuration.GetValue<bool>("AlarmHistorian:Enabled"). (Continuous historization is already co-gated onServerHistorian:EnabledinProgram.cs:160, sooptions.Enabledcovers it.) - When
consumed: FAIL unlessEndpointis non-empty ANDUri.TryCreate(Endpoint, UriKind.Absolute, out var uri)withuri.Schemehttp/https. Failure messages name the exact keys, e.g."ServerHistorian:Endpoint is empty/not an absolute http(s) URI ('<value>') — required because ServerHistorian:Enabled=true"and, for the corner,"… required because AlarmHistorian:Enabled=true sources its gateway connection (endpoint/key/TLS) from the ServerHistorian section". (Endpoint values are not secrets; never echoApiKey.) - When not consumed: no failures (a disabled section may legitimately be empty — the docker-dev default after
7233e2ba). - Fail tier = provably-crashing configs only (the report's recommendation): empty
ApiKeyandMaxTieClusterOverfetch <= 0do not crash (the gateway rejects calls / the node manager degrades a read) — they stay operator warnings inValidate(), unchanged.
- Compute
- Register in
Program.csinside thehasDriverblock (immediately before the existing imperative bind at:120-124):builder.Services.AddValidatedOptions<ServerHistorianOptions, ServerHistorianOptionsValidator>(builder.Configuration, ServerHistorianOptions.SectionName);. Placement precedent:OpcUaApplicationHostOptionsValidator(:254) is also driver-block-only — admin-only nodes never build the historian singletons, so they must not be failed by a section they never consume.ValidateOnStartmakes host start throwOptionsValidationExceptioncarrying all aggregated messages — a readable, named config error in the log before anything serves (same shape a misconfigured LDAP section already produces), instead of aUriFormatExceptionstack out of a DI factory. - Defense in depth in the adapter: replace the bare
new Uri(options.Endpoint)atHistorianGatewayClientAdapter.cs:45withUri.TryCreate(...)+throw new InvalidOperationException("ServerHistorian:Endpoint must be an absolute http(s) URI (e.g. https://host:5222); got '<value>'. …"). This covers every non-Host caller (tests, the live fixture, any future wiring that bypassesValidateOnStart) with a named error instead ofUriFormatException. It also gives the plan its failing-first repro test (below) without needing a full host. - The residual "unusual but valid" corner (
AlarmHistorian:Enabled=true,ServerHistorian:Enabled=false, connection fields valid — an alarm-history-only deployment): allowed, but log one startupLog.WarninginProgram.csnext to the existing warning loop noting that the alarm sink is sourcing its connection from a disabledServerHistoriansection (reads stayGoodNoData). Health-surface note is documentation, not a newIHealthCheck:docs/Historian.mdgains a short paragraph stating that in this mode the observable symptom of a wrong endpoint/key is SQLite store-and-forward retry/dead-letter growth (the sink never throws by contract), and pointing at the drain-worker WARN lines. (A dedicated health check for the alarm sink was considered and rejected for scope: the sink is deliberately never-throwing store-and-forward; wiring its depth intoAddOtOpcUaHealthis a real feature that belongs with the S-6 health-surface work, not this validation fix.) - Warning parity in
ServerHistorianOptions.Validate(): add one warning for a non-emptyEndpointthat failsUri.TryCreate/scheme check, so the Runtime-side registrations (AddServerHistorian/AddAlarmHistorianlogging paths) describe malformed endpoints too, keeping the two surfaces consistent for any non-Host embedding.
Alternatives considered + rejected:
- Promote
Validate()to a two-tier warnings+errors result andthrowinProgram.cs— invents a third options-validation idiom when C-4 explicitly asks to converge on fewer; theAddValidatedOptionspattern already exists, aggregates, and is proven in this exact Program.cs. Rejected. - Force-disable the historian (keep Null defaults) on invalid config instead of failing — silently ignores explicit operator intent; HistoryRead quietly returns empty and alarm history quietly vanishes — precisely the "optimistic silent failure" theme (OVERALL theme #3) this review keeps flagging. A deployment that asked for a historian should not boot without one. Rejected.
- Throw from
AddServerHistorian/AddAlarmHistorianin Runtime — moves Host startup-failure policy into Runtime (which deliberately owns only gating) and still fires as an un-aggregated exception mid-ConfigureServices; the Host validators are where this policy lives (LDAP precedent). Rejected. - Validate only in the adapter (skip the startup validator) — turns a config error into a first-resolution crash again, just with a nicer message; startup validation is strictly better and nearly free. Rejected as the only fix; kept as layer 3.
Implementation steps:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs— add the failing-first repro (Task 1 below).src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs:45— TryCreate + namedInvalidOperationException; extend theCreatexmldoc (<exception>tag).src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs— new validator per design (1).src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs—AddValidatedOptionsregistration + the corner warning log (design 2 + 4).src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs— malformed-endpoint warning inValidate()(design 5); update the class xmldoc to note the Host layers a fail-fast validator on top.docs/Historian.md— fail-fast behavior + the alarm-only-mode note (design 4).
Tests:
- Failing-first repro (Task 1, must FAIL on current code):
Create_with_empty_endpoint_throws_named_config_error—HistorianGatewayClientAdapter.Create(new ServerHistorianOptions { Enabled = true, Endpoint = "" }, NullLoggerFactory.Instance)must throwInvalidOperationExceptionwhose message containsServerHistorian:Endpoint. Current code throwsUriFormatException→ RED. Companion case:Endpoint = "not a uri". - Validator unit tests (new
ServerHistorianOptionsValidatorTestsintests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/, mirroringLdapOptionsValidatorTests— plain unit tests, no Docker fixture needed): enabled+empty fails; enabled+relative-URI fails; enabled+ftp://scheme fails; enabled+validhttps://h:5222succeeds; disabled+empty succeeds; disabled+AlarmHistorian:Enabled=true+empty fails with a message naming both sections; disabled+alarm-enabled+valid succeeds. (Validator takesIConfiguration— build viaConfigurationBuilder().AddInMemoryCollection(...).) - Wiring test (same file or a sibling, mirroring
LdapOptionsBindingTests):ServiceCollection+AddValidatedOptions<ServerHistorianOptions, ServerHistorianOptionsValidator>+ in-memory config withEnabled=true/empty endpoint → resolvingIOptions<ServerHistorianOptions>.ValuethrowsOptionsValidationExceptioncontainingServerHistorian:Endpoint(validators run on options materialization, so this proves the registration without booting a host;ValidateOnStartreaching host-start is already proven by the LDAP precedent). - Warning parity (existing
ServerHistorianOptionsTests, Runtime.Tests):Enabled=true,Endpoint="not a uri"→Validate()contains an Endpoint warning.
Effort: S. Risk / blast radius: Low-Medium. The only behavior change is that provably-crashing configs now fail earlier and readably — no working deployment can regress (any config the validator fails was already crash-looping in Create). The one semantic addition: AlarmHistorian:Enabled=true with an empty ServerHistorian:Endpoint previously crashed with zero warning; it now fails startup with both section names. docker-dev is already on the fixed config shape (7233e2ba, disabled by default) and is unaffected.
2. U-7 (Medium) — the 0.2.0 bump silently changed Boolean tag provisioning; assessment, pin, and live EnsureTags leg
Restatement: The 0.1.0→0.2.0 client/contracts bump (f6eaa267, one line in Directory.Packages.props) made HistorianTagDefinition.DataType proto3-optional. Under 0.1.0, the provisioner's explicit Int1 (wire 0) was indistinguishable from unset and the gateway provisioned Boolean tags as Float; under 0.2.0 presence is explicit and the same call provisions Int1 — the intended behavior, arriving as a silent semantic change with zero code diff on our side. The bump commit's impact analysis covered only the opc_quality change.
Verification: Confirmed (see summary — contracts XML quote, GatewayTagProvisioner.cs:57, HistorianTypeMapper.cs:28). No test pins the presence semantics; the live suite has no Boolean leg and has never run on 0.2.0.
Root cause: Wire-compatible + compile-clean contract-semantics change flowing straight through the deliberately proto-typed seam (C-3's documented flip side); the bump gate was dotnet test, which cannot see presence-semantics changes (OVERALL theme: "green builds cannot gate contract-semantics changes").
Proposed design — three deliverables:
(a) Written migration/assessment note (lands in docs/Historian.md § "Tag auto-provisioning (EnsureTags)" + a pointer from CLAUDE.md's Historian section):
0.2.0 DataType provisioning delta.
EnsureTagsis contract-documented as an upsert ("creates or updates historian tags according to the given definitions"; not idempotent, not retried) that maps per-tag onto AVEVA'sHistorianClient.EnsureTagAsync(definition) -> bool. It is not skip-if-exists. Consequences for Boolean tags provisioned under 0.1.0 (which exist historian-side as Float):
- On the next deploy that dispatches provisioning for such a tag, the gateway is asked to update it to Int1. The AVEVA-side outcome of an in-place analog retype is the one thing the contract does not pin; the three candidate behaviors are (i) accepted with tag versioning (AVEVA Historian versions a tag on definition change — history preserved under the old version, new writes under Int1), (ii) accepted as a plain retype, or (iii) rejected per-tag →
TagOperationResult.Success=false→ the provisioner counts it infailed=Nin the dispatch tally log (AddressSpaceApplierprovisioning tally). Only a live run distinguishes these — that is the live leg below.- The failure mode is bounded and observable either way: provisioning is fire-and-forget and never blocks/fails a deploy; a per-tag rejection surfaces only as the tally's
failedcount, so operators watching the tally see it. Values written to a still-Float tag by the recorder remain readable (Float supersets Int1's 0/1 range); nothing is lost pending the reconcile.- New Boolean tags provision correctly as Int1 under 0.2.0 (this was the pre-existing intent of
HistorianTypeMapper); no code change is needed or wanted in the mapper.
(b) Unit test pinning the 0.2.0 Boolean→Int1 presence semantics. Extend GatewayTagProvisionerTests with Boolean_definition_carries_explicit_Int1_presence: build definitions via the provisioner for a DriverDataType.Boolean request and assert both defs[i].DataType == HistorianDataType.Int1 and defs[i].HasDataType is true. The HasDataType member exists only under the 0.2.0 proto3-optional contract, so this pins the semantics at two levels: a package downgrade breaks the compile, and a future contract change that stops sending explicit presence breaks the assert. (This is a pin, not a fix — it passes on current code by design; no RED phase, stated explicitly in the task.)
(c) Env-gated live EnsureTags verification leg (extends the existing Category=LiveIntegration recipe; skips cleanly offline):
- New env var
HISTGW_BOOL_SANDBOX_TAG(e.g.HistGW.LiveTest.BoolSandbox) added toGatewayLiveFixture(constant +Trimmedread + property + xmldoc list entry), keeping the per-test-skip convention. - New test
EnsureTags_boolean_sandbox_provisions_as_Int1inGatewayLiveIntegrationTests: through the realGatewayTagProvisionerover_fx.CreateClient(),EnsureTagsAsyncaDriverDataType.Booleanrequest for the sandbox tag; assertEnsured == 1 && Failed == 0; thenGatewayHistorianValueWriter.WriteLiveValuesAsynca 1.0 and pollReadRawback (mirror the existing Float round-trip's wide-window poll — the timezone-offset caveat atGatewayLiveIntegrationTests.cs:120-126applies identically). This retires "the live EnsureTags leg has never run on 0.2.0" for the Boolean path. - New test
EnsureTags_type_change_outcome_is_observable(the retype probe, answering the assessment note's open question): against the same dedicated Boolean sandbox tag only (never the sharedHISTGW_WRITE_SANDBOX_TAG), issue EnsureTags first asFloat32then asBoolean, and assert the second call's per-tag outcome is deterministic and observable — eitherSuccess=true(retype/version accepted) orSuccess=falsewith a non-emptyError(mapped to the provisioner'sfailedtally). Emit the observed behavior viaTestContext.Current.SendDiagnosticMessageso the run documents which of (i)/(ii)/(iii) the deployed historian exhibits; the assertion deliberately accepts both outcomes because the contract guarantees only the observability, not the policy. Follow up the doc note in (a) with the observed answer after the first run. - Both tests
Assert.Skipwhen the fixture is unconfigured orHISTGW_BOOL_SANDBOX_TAGis absent (offlinedotnet test --filter "Category=LiveIntegration"stays green-all-skipped). - Recipe update: CLAUDE.md KNOWN LIMITATION 1's env-var block gains
export HISTGW_BOOL_SANDBOX_TAG=<writable-bool-tag> # 0.2.0 Boolean EnsureTags leg + retype probe;docs/Historian.md"Live /run gate" section updated to match. The operator gate is: VPN towonder-sql-vd03, gateway on 0.2.0 contracts withRuntimeDb:Enabled=trueand an API key carryinghistorian:read+historian:write+historian:tags:write, thendotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests --filter "Category=LiveIntegration"and record the run (this also finally discharges U-2's "full documented run" for the historian suite).
Alternatives considered + rejected:
- Proactively reconcile pre-bump Float Booleans in code (a startup "re-EnsureTags all historized tags" pass) — provisioning already re-fires for added historized tags per deploy, and a bulk retype sweep against an upsert whose retype policy is unverified could churn tag versions fleet-wide. Assess first (the live probe), then decide whether reconciliation is needed at all; likely a one-time operator action, not code. Rejected for this pass.
- Pin presence by asserting the serialized wire bytes — brittle and duplicated;
HasDataTypeis the generated, supported presence witness. Rejected. - Skip the retype probe and only test fresh-Boolean provisioning — leaves the finding's consequence (1) permanently "unverified", which is the half the report flags. The probe is confined to a dedicated sandbox tag and is cheap. Rejected.
Effort: S (unit + fixture + docs) + the operator-gated live run. Risk: Low — all code is test/docs; the live tests touch only dedicated sandbox tags.
3. C-7 (Low) — the live-value writer populates a documented-dead Quality field
Restatement: GatewayHistorianValueWriter.cs:63 maps HistorizationValue.Quality onto HistorianLiveValue.Quality, which the 0.2.0 contract documents as silently discarded on the WriteLiveValues SQL path (Contracts C-002). Harmless today (the recorder hardcodes GoodQuality = 192); the hazard is forward-looking — extending the recorder to capture real node quality would appear to work and silently persist nothing.
Verification: Confirmed (see summary; contracts XML quoted).
Root cause: Contract-semantics documentation landed in 0.2.0 after the mapping was written; the mapping site carries no note.
Decision: KEEP populating the field; document at both ends. Rationale: the contract note itself says "proto3 sends 0 when these are left unset; there is no implicit 192 default on the wire" — so removing the assignment does not make the dead input visible on the wire (0 is transmitted either way) and would send a wrong quality (0 = Bad in OPC-DA terms) if a future gateway version ever starts honoring the field, whereas the recorder's 192 is correct. The "stop populating so the dead input is visible" alternative from the report is therefore rejected: it buys no wire-level clarity and plants a future bug. Visibility is achieved with comments instead:
GatewayHistorianValueWriter.cs:63— inline comment +<remarks>addition citing the 0.2.0 contract: "Quality is a documented-dead input on the WriteLiveValues SQL path (0.2.0 Contracts C-002 — server-managed column, silently discarded). Populated anyway because proto3 would send 0 (Bad) when unset, and 192 is what a quality-honoring gateway path should receive. Do NOT extend the recorder to capture real node quality expecting it to persist — it will not, until the gateway honors the field."ContinuousHistorizationRecorder.cs:45(GoodQuality) — one-line guard note pointing at the writer's remark (the natural place a "capture real quality" extension would start).docs/Historian.mdcontinuous-historization section — one sentence stating persisted quality is server-stamped by the gateway SQL path regardless of what the recorder sends.
Effort: XS (comments/docs only). Risk: none — no behavior change.
Task breakdown
House TDD convention: each task is ≤ ~5 min of implementation, RED→GREEN where a behavior changes, pins stated as pins. Filters are exact. Commits are grouped per finding (3 commits total). Static-analysis constraint on the planning session means the FAIL→PASS runs happen at execution time.
Task 1 — RED: misconfig repro test (empty Endpoint → named error, not UriFormatException)
Classification: test (failing-first repro — MUST fail on current code)
Estimated implement time: 4 min
Parallelizable with: none (this is the gate task; everything S-11 hangs off it)
Files: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs
Steps:
- Add
Create_with_empty_endpoint_throws_named_config_error:HistorianGatewayClientAdapter.Create(new ServerHistorianOptions { Enabled = true, Endpoint = "", ApiKey = "histgw_x_y" }, NullLoggerFactory.Instance)→Should.Throw<InvalidOperationException>().Message.ShouldContain("ServerHistorian:Endpoint"). Add the malformed twin (Endpoint = "not a uri"). dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests --filter "FullyQualifiedName~HistorianGatewayClientAdapterTests.Create_with_empty_endpoint"→ FAIL (current code throwsUriFormatException). Same for the malformed twin.
Task 2 — GREEN: adapter guard (defense in depth)
Classification: logic (small)
Estimated implement time: 4 min
Parallelizable with: Task 3 (different files) once Task 1 is red
Files: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs
Steps:
- In
Create, replaceEndpoint = new Uri(options.Endpoint)withUri.TryCreate(options.Endpoint, UriKind.Absolute, out var endpointUri)guard → on failurethrow new InvalidOperationException($"ServerHistorian:Endpoint must be an absolute http(s) URI (e.g. https://host:5222); got '{options.Endpoint}'."); useendpointUri. Add the<exception>xmldoc. - Re-run the Task 1 filter → PASS. Run the whole adapter suite:
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests --filter "FullyQualifiedName~HistorianGatewayClientAdapterTests"→ green (the offline "bogus endpoint" seam tests use well-formed URIs — verify none regress).
Task 3 — RED: ServerHistorianOptionsValidator unit tests
Classification: test
Estimated implement time: 5 min
Parallelizable with: Task 2
Files: tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs (new; fixture-free unit tests, LdapOptionsValidatorTests style)
Steps:
- Seven cases from §1 Tests (enabled+empty / enabled+relative / enabled+bad-scheme fail; enabled+valid ok; disabled+empty ok; disabled +
AlarmHistorian:Enabled=true+ empty fails naming both sections; disabled+alarm-enabled+valid ok). Build the ctor'sIConfigurationfromAddInMemoryCollection. - RED: does not compile (validator absent) — that is the failing state for a net-new type; note it in the commit message.
Task 4 — GREEN: implement ServerHistorianOptionsValidator
Classification: logic
Estimated implement time: 5 min
Parallelizable with: none (unblocks 5)
Files: src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs (new)
Steps:
OptionsValidatorBase<ServerHistorianOptions>; ctorIConfiguration;consumed = options.Enabled || _configuration.GetValue<bool>("AlarmHistorian:Enabled");builder.RequireThat(...)per §1 design (empty check +Uri.TryCreate+ http/https scheme), messages naming the driving section(s). Xmldoc mirroringLdapOptionsValidator's (fail tier = provably-crashing configs; warnings stay inValidate()).dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~ServerHistorianOptionsValidatorTests"→ PASS (7/7).
Task 5 — wiring: register AddValidatedOptions in Program.cs + wiring test + corner warning
Classification: wiring (the "register-AND-consume" trap class — needs its own assertion)
Estimated implement time: 5 min
Parallelizable with: Task 6
Files: src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs, tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs (wiring case)
Steps:
- RED: wiring test —
ServiceCollection+AddValidatedOptions<ServerHistorianOptions, ServerHistorianOptionsValidator>(config, ServerHistorianOptions.SectionName)with enabled+empty in-memory config; resolvingIOptions<ServerHistorianOptions>().Value→Should.Throw<OptionsValidationException>message containsServerHistorian:Endpoint. (This test passes as soon as it's written — it proves the pattern; the Program.cs registration line is the un-unit-testable top-level statement, mitigated by the LDAP/OpcUa precedent lines directly above it.) Program.cshasDriverblock, before the imperative bind at:120: add theAddValidatedOptionsline with a comment mirroring the:251-255style (naming S-11 and the AlarmHistorian corner).- Same block: after the existing warning loop (
:123-124), add the residual-cornerLog.WarningwhenAlarmHistorian:Enabled=true(read viabuilder.Configuration.GetValue<bool>) and!serverHistorianOptions.Enabled— one line noting the connection sourcing (fires only on validly-configured alarm-only deployments; invalid ones now fail startup). - Run the validator test filter (green) +
dotnet build ZB.MOM.WW.OtOpcUa.slnx.
Task 6 — warning parity: malformed-endpoint warning in ServerHistorianOptions.Validate()
Classification: logic (small) + test
Estimated implement time: 4 min
Parallelizable with: Task 5
Files: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs, tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs
Steps:
- RED:
Enabled_with_malformed_endpoint_warns—Endpoint = "not a uri"→Validate()contains an"Endpoint"warning.dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~ServerHistorianOptionsTests"→ FAIL. - GREEN: in
Validate(), after the empty check, add the non-empty-but-unparseable warning (Uri.TryCreateabsolute + http/https). Update the class xmldoc: the Host layersServerHistorianOptionsValidator(fail-fast) on top of these warnings. Re-run filter → PASS. - Commit 1 (S-11): Tasks 1-6 —
fix(historian): fail fast on misconfigured ServerHistorian (named startup error, not UriFormatException crash-loop) — archreview 06/S-11.
Task 7 — U-7 pin: Boolean→Int1 explicit-presence test
Classification: test-only guard (pin — passes on current code by design; no RED phase)
Estimated implement time: 4 min
Parallelizable with: Tasks 1-6, 8, 10
Files: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs
Steps:
- Add
Boolean_definition_carries_explicit_Int1_presence: provision aDriverDataType.Booleanrequest through the fake-client provisioner; assertdefs[0].DataType == HistorianDataType.Int1anddefs[0].HasDataType(the 0.2.0 proto3-optional presence witness — compile-pins the package floor, assert-pins the always-explicit-presence contract). Xml-comment the 0.1.0-vs-0.2.0 semantics (wire-0 ⇒ Float under 0.1.0; explicit Int1 under 0.2.0). dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests --filter "FullyQualifiedName~GatewayTagProvisionerTests"→ PASS (pin verified in place).
Task 8 — U-7 live leg: HISTGW_BOOL_SANDBOX_TAG fixture + Boolean EnsureTags test + retype probe
Classification: test (env-gated live; skip-clean offline)
Estimated implement time: 5 min (fixture) + 5 min (tests) — split if needed
Parallelizable with: Task 7
Files: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveFixture.cs, Live/GatewayLiveIntegrationTests.cs
Steps:
- Fixture:
EnvBoolSandboxTag = "HISTGW_BOOL_SANDBOX_TAG"const +BoolSandboxTagproperty + xmldoc env list entry. EnsureTags_boolean_sandbox_provisions_as_Int1per §2(c): realGatewayTagProvisioner→ assertEnsured==1, Failed==0→WriteLiveValuesAsync(1.0)→ wide-windowReadRawpoll (mirror the Float round-trip).[Trait("Category","LiveIntegration")]+ the two-stageAssert.Skip.EnsureTags_type_change_outcome_is_observableper §2(c): Float32-then-Boolean on the same dedicated sandbox tag; assert the second outcome isSuccess=trueOR (Success=falseAND non-emptyError); diagnostic-message the observed retype policy.- Offline verification (this IS runnable offline):
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests --filter "Category=LiveIntegration"→ all Skipped, none failed (skip-clean guard).
Task 9 — U-7 docs: migration/assessment note + recipe update
Classification: doc
Estimated implement time: 5 min
Parallelizable with: Task 8 (after its test names are settled)
Files: docs/Historian.md (§ Tag auto-provisioning + § Live /run gate), CLAUDE.md (KNOWN LIMITATION 1 env-var block + one-line 0.2.0 note in the provisioning paragraph)
Steps:
- Insert the §2(a) migration/assessment note verbatim (upsert semantics, the three retype hypotheses, the
failed=Ntally observability, "new Booleans provision correctly"). - Add
HISTGW_BOOL_SANDBOX_TAGto both recipe blocks; note the run also discharges U-2's "full documented run". - Commit 2 (U-7): Tasks 7-9 —
test(historian): pin 0.2.0 Boolean→Int1 explicit-presence provisioning + live EnsureTags/retype leg + migration note — archreview 06/U-7.
Task 10 — C-7: dead-Quality doc comments
Classification: doc (comments only — no behavior change)
Estimated implement time: 4 min
Parallelizable with: everything
Files: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs (mapping site :63 + <remarks>), src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs (GoodQuality guard note), docs/Historian.md (one sentence)
Steps:
- Apply the §3 comments (keep-populating decision + rationale, citing 0.2.0 Contracts C-002 and the proto3-sends-0 note).
dotnet build ZB.MOM.WW.OtOpcUa.slnx(comment-only sanity).- Commit 3 (C-7):
docs(historian): note WriteLiveValues Quality is a documented-dead input (0.2.0 C-002); keep 192 populated — archreview 06/C-7.
Task 11 — bookkeeping: STATUS.md + report cross-links
Classification: doc
Estimated implement time: 3 min
Parallelizable with: none (last)
Files: archreview/plans/STATUS.md, archreview/06-gateway-integrations.md (prior-finding table rows for S-11/U-7/C-7 when the work lands)
Steps:
- Record the branch/commits against re-review rank #7/#8 (S-11/U-7) + C-7; mark the live leg as operator-gated pending until Task 12 runs.
Task 12 — OPERATOR GATE: run the live suite on the VPN (0.2.0 gateway)
Classification: live verification (cannot run from this dev Mac offline; needs VPN + gateway per CLAUDE.md KNOWN LIMITATION 1) Estimated implement time: n/a (operator session) Parallelizable with: none — after Commit 2 Files: none (run + record) Steps:
export HISTGW_GATEWAY_ENDPOINT=https://wonder-sql-vd03:5222; export HISTGW_GATEWAY_APIKEY=histgw_<id>_<secret>(scopeshistorian:read+historian:write+historian:tags:write),HISTGW_TEST_TAG,HISTGW_WRITE_SANDBOX_TAG,HISTGW_ALARM_SOURCE,HISTGW_BOOL_SANDBOX_TAG.dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests --filter "Category=LiveIntegration"— 6 tests, 0 skipped, 0 failed expected.- Record the retype-probe diagnostic (which of accepted-versioned / accepted-retyped / rejected the historian exhibits) back into the
docs/Historian.mdnote (replaces the three-hypothesis list with the observed answer) and into STATUS.md. This run also closes 06/U-2's "full documented run" gap.
Effort + risk summary
| Finding | Code effort | Risk | Notes |
|---|---|---|---|
| S-11 | S (Tasks 1-6, ~30 min) | Low-Medium | Only provably-crashing configs change behavior — they fail earlier, readably; validator is driver-block-only so admin-only nodes unaffected |
| U-7 | S (Tasks 7-9, ~15 min) + operator live gate (Task 12) | Low | Test/docs only; live tests confined to dedicated sandbox tags |
| C-7 | XS (Task 10, ~5 min) | None | Comments/docs; deliberate keep-populating decision recorded |
Total: ~1 hour of implementation across 3 commits, plus one operator VPN session for the live gate. Suggested sequencing: Commit 1 (S-11) → Commit 2 (U-7) → Commit 3 (C-7) → Task 11 bookkeeping → Task 12 operator gate.
Execution deviations (R2-06)
Executed 2026-07-13 on branch r2/06-serverhistorian-failfast (off master 1676c8f4).
- Commit granularity — per-task, not the plan's 3 grouped commits. The plan text grouped the work into 3 commits (one per finding). The executor's operating method mandated one pathspec-scoped commit per task (with the task's
tasks.jsonstatus flip in the same commit) for traceability/bisectability. Followed the per-task rule → 11 task commits (T1–T11) instead of 3. No content difference; strictly finer history. Reversible (squashable at merge if 3 commits are still wanted). - Validator + wiring tests live in
Host.IntegrationTestsbut are pure unit tests. Per the plan (they mirrorLdapOptionsValidatorTests, which lives there). They were verified green filtered (--filter FullyQualifiedName~ServerHistorianOptionsValidatorTests, 8/8) once during T4 — before a mid-run controller constraint arrived forbidding any run of*.IntegrationTestsprojects (a ~16 GB/run memory-leak in the heavy integration tests) and whole-solutiondotnet test. After that constraint they were not re-run; the earlier filtered pass + a cleandotnet build ZB.MOM.WW.OtOpcUa.slnx(0 errors) are the standing evidence. The Historian.Gateway driver unit suite (a plain.Testsproject) and the Runtime.Tests suite were run freely. - T12 (live VPN gate) →
deferred-live. Genuinely external infra (VPN towonder-sql-vd03+ a 0.2.0 gateway withRuntimeDb:Enabled=trueand an API key carryinghistorian:read+write+tags:write). The code (T8: fixture env var + Boolean EnsureTags leg + retype probe) is complete and offline skip-clean (6 LiveIntegration tests, all skipped, 0 failed). The operator runs the suite on the VPN and feeds the observed retype policy back intodocs/Historian.md+ STATUS.md.