Files
lmxopcua/archreview/plans/R2-06-serverhistorian-failfast-plan.md
T
Joseph Doherty d59e6b5934
v2-ci / build (pull_request) Successful in 3m55s
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (pull_request) Failing after 3m51s
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (pull_request) Failing after 1m43s
v2-ci / unit-tests (pull_request) Failing after 8m36s
docs(r2-06): mark T12 live gate deferred-live + record execution deviations (06/U-7)
2026-07-13 10:07:37 -04:00

41 KiB
Raw Blame History

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 *.md working-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 ServerHistorian crash-loops the Host with a raw UriFormatException; the AlarmHistorian-enabled/ServerHistorian-disabled corner gets zero warning), 06/U-7 (Medium — the 0.1.0→0.2.0 client bump's proto3-optional HistorianTagDefinition.DataType change silently altered Boolean tag provisioning; live EnsureTags never run on 0.2.0), 06/C-7 (Low — the live-value writer populates a documented-dead Quality field)
  • 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-11HistorianGatewayClientAdapter.Create eagerly does new Uri(options.Endpoint) (HistorianGatewayClientAdapter.cs:45) inside an otherwise lazy-channel factory ("no network I/O" is documented at :19-23; the eager Uri ctor is the one exception). ServerHistorianOptions.Validate() early-returns on !Enabled (ServerHistorianOptions.cs:77) and produces only warnings for an empty Endpoint/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: AddAlarmHistorian gates only on AlarmHistorian:Enabled (Runtime/ServiceCollectionExtensions.cs:86-87) while the Host sources its connection from the ServerHistorian section (Program.cs:125-127 passes the outer-bound serverHistorianOptions into GatewayHistorian.CreateAlarmWriter) — so AlarmHistorian:Enabled=true + ServerHistorian:Enabled=false produces zero warning and then CreateAlarmWriterHistorianGatewayClientAdapter.Create → unhandled UriFormatException when the sink singleton is first resolved (which happens at ActorSystem start via WithOtOpcUaRuntimeActors, Runtime/ServiceCollectionExtensions.cs:214 — i.e. a startup crash-loop, not a lazy runtime fault). Continuous historization is co-gated on serverHistorianOptions.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> (shared ZB.MOM.WW.Configuration, ServiceCollectionExtensions.cs:28-41: bind + IValidateOptions + ValidateOnStart) over OptionsValidatorBase<TOptions>/ValidationBuilder, used twice in Program.csLdapOptions (unconditional, :102) and OpcUaApplicationHostOptions (inside the hasDriver block, :254). ValidateOnStart failures surface as an OptionsValidationException aggregating 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-7GatewayTagProvisioner always sets DataType explicitly (GatewayTagProvisioner.cs:57) and Boolean → 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.EnsureTagsAsync as "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's HistorianClient.EnsureTagAsync(definition) -> bool (scope historian: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-7GatewayHistorianValueWriter populates HistorianLiveValue.Quality (GatewayHistorianValueWriter.cs:63); the 0.2.0 contracts XML documents Quality/QualityDetail as dead inputs on the WriteLiveValues SQL 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 hardcodes GoodQuality = 192 for 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:

  1. New ServerHistorianOptionsValidator : OptionsValidatorBase<ServerHistorianOptions> in src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ (sibling of LdapOptionsValidator / OpcUaApplicationHostOptionsValidator). Ctor-injects IConfiguration (singleton-safe per the AddValidatedOptions remark) so it can read the consumer gate:
    • Compute consumed = options.Enabled || configuration.GetValue<bool>("AlarmHistorian:Enabled"). (Continuous historization is already co-gated on ServerHistorian:Enabled in Program.cs:160, so options.Enabled covers it.)
    • When consumed: FAIL unless Endpoint is non-empty AND Uri.TryCreate(Endpoint, UriKind.Absolute, out var uri) with uri.Scheme http/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 echo ApiKey.)
    • 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 ApiKey and MaxTieClusterOverfetch <= 0 do not crash (the gateway rejects calls / the node manager degrades a read) — they stay operator warnings in Validate(), unchanged.
  2. Register in Program.cs inside the hasDriver block (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. ValidateOnStart makes host start throw OptionsValidationException carrying all aggregated messages — a readable, named config error in the log before anything serves (same shape a misconfigured LDAP section already produces), instead of a UriFormatException stack out of a DI factory.
  3. Defense in depth in the adapter: replace the bare new Uri(options.Endpoint) at HistorianGatewayClientAdapter.cs:45 with Uri.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 bypasses ValidateOnStart) with a named error instead of UriFormatException. It also gives the plan its failing-first repro test (below) without needing a full host.
  4. The residual "unusual but valid" corner (AlarmHistorian:Enabled=true, ServerHistorian:Enabled=false, connection fields valid — an alarm-history-only deployment): allowed, but log one startup Log.Warning in Program.cs next to the existing warning loop noting that the alarm sink is sourcing its connection from a disabled ServerHistorian section (reads stay GoodNoData). Health-surface note is documentation, not a new IHealthCheck: docs/Historian.md gains 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 into AddOtOpcUaHealth is a real feature that belongs with the S-6 health-surface work, not this validation fix.)
  5. Warning parity in ServerHistorianOptions.Validate(): add one warning for a non-empty Endpoint that fails Uri.TryCreate/scheme check, so the Runtime-side registrations (AddServerHistorian/AddAlarmHistorian logging 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 and throw in Program.cs — invents a third options-validation idiom when C-4 explicitly asks to converge on fewer; the AddValidatedOptions pattern 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/AddAlarmHistorian in 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:

  1. tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs — add the failing-first repro (Task 1 below).
  2. src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs:45 — TryCreate + named InvalidOperationException; extend the Create xmldoc (<exception> tag).
  3. src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs — new validator per design (1).
  4. src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.csAddValidatedOptions registration + the corner warning log (design 2 + 4).
  5. src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs — malformed-endpoint warning in Validate() (design 5); update the class xmldoc to note the Host layers a fail-fast validator on top.
  6. 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_errorHistorianGatewayClientAdapter.Create(new ServerHistorianOptions { Enabled = true, Endpoint = "" }, NullLoggerFactory.Instance) must throw InvalidOperationException whose message contains ServerHistorian:Endpoint. Current code throws UriFormatException → RED. Companion case: Endpoint = "not a uri".
  • Validator unit tests (new ServerHistorianOptionsValidatorTests in tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/, mirroring LdapOptionsValidatorTests — plain unit tests, no Docker fixture needed): enabled+empty fails; enabled+relative-URI fails; enabled+ftp:// scheme fails; enabled+valid https://h:5222 succeeds; disabled+empty succeeds; disabled+AlarmHistorian:Enabled=true+empty fails with a message naming both sections; disabled+alarm-enabled+valid succeeds. (Validator takes IConfiguration — build via ConfigurationBuilder().AddInMemoryCollection(...).)
  • Wiring test (same file or a sibling, mirroring LdapOptionsBindingTests): ServiceCollection + AddValidatedOptions<ServerHistorianOptions, ServerHistorianOptionsValidator> + in-memory config with Enabled=true/empty endpoint → resolving IOptions<ServerHistorianOptions>.Value throws OptionsValidationException containing ServerHistorian:Endpoint (validators run on options materialization, so this proves the registration without booting a host; ValidateOnStart reaching 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. EnsureTags is 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's HistorianClient.EnsureTagAsync(definition) -> bool. It is not skip-if-exists. Consequences for Boolean tags provisioned under 0.1.0 (which exist historian-side as Float):

  1. 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-tagTagOperationResult.Success=false → the provisioner counts it in failed=N in the dispatch tally log (AddressSpaceApplier provisioning tally). Only a live run distinguishes these — that is the live leg below.
  2. 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 failed count, 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.
  3. 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 to GatewayLiveFixture (constant + Trimmed read + property + xmldoc list entry), keeping the per-test-skip convention.
  • New test EnsureTags_boolean_sandbox_provisions_as_Int1 in GatewayLiveIntegrationTests: through the real GatewayTagProvisioner over _fx.CreateClient(), EnsureTagsAsync a DriverDataType.Boolean request for the sandbox tag; assert Ensured == 1 && Failed == 0; then GatewayHistorianValueWriter.WriteLiveValuesAsync a 1.0 and poll ReadRaw back (mirror the existing Float round-trip's wide-window poll — the timezone-offset caveat at GatewayLiveIntegrationTests.cs:120-126 applies 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 shared HISTGW_WRITE_SANDBOX_TAG), issue EnsureTags first as Float32 then as Boolean, and assert the second call's per-tag outcome is deterministic and observable — either Success=true (retype/version accepted) or Success=false with a non-empty Error (mapped to the provisioner's failed tally). Emit the observed behavior via TestContext.Current.SendDiagnosticMessage so 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.Skip when the fixture is unconfigured or HISTGW_BOOL_SANDBOX_TAG is absent (offline dotnet 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 to wonder-sql-vd03, gateway on 0.2.0 contracts with RuntimeDb:Enabled=true and an API key carrying historian:read+historian:write+historian:tags:write, then dotnet 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; HasDataType is 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:

  1. 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."
  2. ContinuousHistorizationRecorder.cs:45 (GoodQuality) — one-line guard note pointing at the writer's remark (the natural place a "capture real quality" extension would start).
  3. docs/Historian.md continuous-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:

  1. 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").
  2. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests --filter "FullyQualifiedName~HistorianGatewayClientAdapterTests.Create_with_empty_endpoint"FAIL (current code throws UriFormatException). 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:

  1. In Create, replace Endpoint = new Uri(options.Endpoint) with Uri.TryCreate(options.Endpoint, UriKind.Absolute, out var endpointUri) guard → on failure throw new InvalidOperationException($"ServerHistorian:Endpoint must be an absolute http(s) URI (e.g. https://host:5222); got '{options.Endpoint}'."); use endpointUri. Add the <exception> xmldoc.
  2. 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:

  1. 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's IConfiguration from AddInMemoryCollection.
  2. 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:

  1. OptionsValidatorBase<ServerHistorianOptions>; ctor IConfiguration; 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 mirroring LdapOptionsValidator's (fail tier = provably-crashing configs; warnings stay in Validate()).
  2. 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:

  1. RED: wiring test — ServiceCollection + AddValidatedOptions<ServerHistorianOptions, ServerHistorianOptionsValidator>(config, ServerHistorianOptions.SectionName) with enabled+empty in-memory config; resolving IOptions<ServerHistorianOptions>().ValueShould.Throw<OptionsValidationException> message contains ServerHistorian: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.)
  2. Program.cs hasDriver block, before the imperative bind at :120: add the AddValidatedOptions line with a comment mirroring the :251-255 style (naming S-11 and the AlarmHistorian corner).
  3. Same block: after the existing warning loop (:123-124), add the residual-corner Log.Warning when AlarmHistorian:Enabled=true (read via builder.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).
  4. 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:

  1. RED: Enabled_with_malformed_endpoint_warnsEndpoint = "not a uri"Validate() contains an "Endpoint" warning. dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~ServerHistorianOptionsTests" → FAIL.
  2. GREEN: in Validate(), after the empty check, add the non-empty-but-unparseable warning (Uri.TryCreate absolute + http/https). Update the class xmldoc: the Host layers ServerHistorianOptionsValidator (fail-fast) on top of these warnings. Re-run filter → PASS.
  3. 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:

  1. Add Boolean_definition_carries_explicit_Int1_presence: provision a DriverDataType.Boolean request through the fake-client provisioner; assert defs[0].DataType == HistorianDataType.Int1 and defs[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).
  2. 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:

  1. Fixture: EnvBoolSandboxTag = "HISTGW_BOOL_SANDBOX_TAG" const + BoolSandboxTag property + xmldoc env list entry.
  2. EnsureTags_boolean_sandbox_provisions_as_Int1 per §2(c): real GatewayTagProvisioner → assert Ensured==1, Failed==0WriteLiveValuesAsync(1.0) → wide-window ReadRaw poll (mirror the Float round-trip). [Trait("Category","LiveIntegration")] + the two-stage Assert.Skip.
  3. EnsureTags_type_change_outcome_is_observable per §2(c): Float32-then-Boolean on the same dedicated sandbox tag; assert the second outcome is Success=true OR (Success=false AND non-empty Error); diagnostic-message the observed retype policy.
  4. 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:

  1. Insert the §2(a) migration/assessment note verbatim (upsert semantics, the three retype hypotheses, the failed=N tally observability, "new Booleans provision correctly").
  2. Add HISTGW_BOOL_SANDBOX_TAG to both recipe blocks; note the run also discharges U-2's "full documented run".
  3. 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:

  1. Apply the §3 comments (keep-populating decision + rationale, citing 0.2.0 Contracts C-002 and the proto3-sends-0 note).
  2. dotnet build ZB.MOM.WW.OtOpcUa.slnx (comment-only sanity).
  3. 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.

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:

  1. 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:

  1. export HISTGW_GATEWAY_ENDPOINT=https://wonder-sql-vd03:5222; export HISTGW_GATEWAY_APIKEY=histgw_<id>_<secret> (scopes historian:read+historian:write+historian:tags:write), HISTGW_TEST_TAG, HISTGW_WRITE_SANDBOX_TAG, HISTGW_ALARM_SOURCE, HISTGW_BOOL_SANDBOX_TAG.
  2. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests --filter "Category=LiveIntegration" — 6 tests, 0 skipped, 0 failed expected.
  3. Record the retype-probe diagnostic (which of accepted-versioned / accepted-retyped / rejected the historian exhibits) back into the docs/Historian.md note (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.json status flip in the same commit) for traceability/bisectability. Followed the per-task rule → 11 task commits (T1T11) 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.IntegrationTests but are pure unit tests. Per the plan (they mirror LdapOptionsValidatorTests, 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 *.IntegrationTests projects (a ~16 GB/run memory-leak in the heavy integration tests) and whole-solution dotnet test. After that constraint they were not re-run; the earlier filtered pass + a clean dotnet build ZB.MOM.WW.OtOpcUa.slnx (0 errors) are the standing evidence. The Historian.Gateway driver unit suite (a plain .Tests project) and the Runtime.Tests suite were run freely.
  • T12 (live VPN gate) → deferred-live. Genuinely external infra (VPN to wonder-sql-vd03 + a 0.2.0 gateway with RuntimeDb:Enabled=true and an API key carrying historian: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 into docs/Historian.md + STATUS.md.