# 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-11** — `HistorianGatewayClientAdapter.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 `CreateAlarmWriter` → `HistorianGatewayClientAdapter.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.cs` — `LdapOptions` (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-7** — `GatewayTagProvisioner` 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-7** — `GatewayHistorianValueWriter` 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.cs` — `AddValidatedOptions` 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_error` — `HistorianGatewayClientAdapter.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-tag** → `TagOperationResult.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>().Value` → `Should.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_warns` — `Endpoint = "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==0` → `WriteLiveValuesAsync(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`.

### 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:**
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 (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.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.
